fix: remove repository-local server configuration paths

This commit is contained in:
2026-08-26 03:56:35 +09:00
parent 33db2ea7f4
commit 864367f4f5
11 changed files with 340 additions and 1174 deletions
+1 -3
View File
@@ -19,11 +19,9 @@ services:
- runtime
expose:
- "8787"
environment:
YOI_BROWSER_PUBLIC_URL: "${YOI_BROWSER_PUBLIC_URL:-http://localhost:8080}"
volumes:
- server-data:/server-data
- ./docker/workspace:/workspace:ro
- /etc/yoi/server.toml:/server-config/server.toml:ro
webui:
image: yoi-webui:latest
+150 -643
View File
@@ -7,42 +7,50 @@ use url::Url;
use crate::hosts::RemoteRuntimeConfig;
use crate::identity::WorkspaceIdentity;
use crate::repositories::ConfiguredRepository;
use crate::server::{AuthConfig, ServerConfig};
use crate::{Error, Result};
pub const WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH: &str = ".yoi/workspace-backend.local.toml";
pub const BACKEND_RUNTIMES_CONFIG_FILE_NAME: &str = "runtimes.toml";
pub const WORKSPACE_BACKEND_CONFIG_TEMPLATE: &str =
include_str!("../../../resources/workspace-backend.default.toml");
pub const SERVER_HOST_CONFIG_FILE_NAME: &str = "server.toml";
const DEFAULT_LISTEN: &str = "127.0.0.1:8787";
const DEFAULT_BROWSER_PUBLIC_URL: &str = "http://localhost:5173";
const DEFAULT_AUTH_COOKIE_NAME: &str = "yoi_workspace_session";
const DEFAULT_MAX_RECORDS: usize = 200;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ServerHostConfigFile {
#[serde(default)]
pub browser: ServerBrowserConfig,
}
impl Default for ServerHostConfigFile {
fn default() -> Self {
Self {
browser: ServerBrowserConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ServerBrowserConfig {
#[serde(default = "default_browser_public_url")]
pub public_url: String,
}
impl Default for ServerBrowserConfig {
fn default() -> Self {
Self {
public_url: default_browser_public_url(),
}
}
}
fn default_browser_public_url() -> String {
DEFAULT_BROWSER_PUBLIC_URL.to_string()
}
fn default_auth_cookie_name() -> String {
DEFAULT_AUTH_COOKIE_NAME.to_string()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendConfigFile {
#[serde(default)]
pub server: WorkspaceBackendServerConfig,
#[serde(default)]
pub data: WorkspaceBackendDataConfig,
#[serde(default)]
pub limits: WorkspaceBackendLimitsConfig,
#[serde(default)]
pub auth: WorkspaceBackendAuthConfig,
#[serde(default)]
pub repositories: Vec<WorkspaceRepositoryConfigFile>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct BackendRuntimesConfigFile {
@@ -50,63 +58,6 @@ pub struct BackendRuntimesConfigFile {
pub runtimes: WorkspaceBackendRuntimesConfig,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendServerConfig {
#[serde(default)]
pub listen: Option<String>,
#[serde(default)]
pub static_assets_dir: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendDataConfig {
#[serde(default)]
pub root: Option<PathBuf>,
#[serde(default)]
pub workspace_database_path: Option<PathBuf>,
#[serde(default)]
pub embedded_runtime_store_root: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendLimitsConfig {
#[serde(default)]
pub max_records: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendAuthConfig {
#[serde(default = "default_browser_public_url")]
pub browser_public_url: String,
#[serde(default = "default_auth_cookie_name")]
pub cookie_name: String,
}
impl Default for WorkspaceBackendAuthConfig {
fn default() -> Self {
Self {
browser_public_url: default_browser_public_url(),
cookie_name: default_auth_cookie_name(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceRepositoryConfigFile {
pub id: String,
pub provider: String,
pub uri: String,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub default_selector: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendRuntimesConfig {
@@ -125,61 +76,6 @@ pub struct RemoteRuntimeConfigFile {
pub token_ref: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigDiff {
pub differs: bool,
pub text: String,
}
impl ConfigDiff {
fn new(default: &str, local: &str) -> Self {
if default == local {
return Self {
differs: false,
text: "workspace backend local config matches the packaged default\n".to_string(),
};
}
let mut text = String::from("--- packaged default\n+++ workspace local\n");
let default_lines = default.lines().collect::<Vec<_>>();
let local_lines = local.lines().collect::<Vec<_>>();
let max = default_lines.len().max(local_lines.len());
for index in 0..max {
match (default_lines.get(index), local_lines.get(index)) {
(Some(left), Some(right)) if left == right => {
text.push(' ');
text.push_str(left);
text.push('\n');
}
(Some(left), Some(right)) => {
text.push('-');
text.push_str(left);
text.push('\n');
text.push('+');
text.push_str(right);
text.push('\n');
}
(Some(left), None) => {
text.push('-');
text.push_str(left);
text.push('\n');
}
(None, Some(right)) => {
text.push('+');
text.push_str(right);
text.push('\n');
}
(None, None) => {}
}
}
Self {
differs: true,
text,
}
}
}
#[derive(Clone)]
pub struct ResolvedWorkspaceBackendConfig {
pub server: ServerConfig,
@@ -187,6 +83,47 @@ pub struct ResolvedWorkspaceBackendConfig {
pub database_path: PathBuf,
}
impl ServerHostConfigFile {
pub fn path_for_config_dir(config_dir: impl AsRef<Path>) -> PathBuf {
config_dir.as_ref().join(SERVER_HOST_CONFIG_FILE_NAME)
}
pub fn default_path() -> Option<PathBuf> {
manifest::paths::config_dir().map(Self::path_for_config_dir)
}
pub fn load_default() -> Result<Self> {
let Some(path) = Self::default_path() else {
return Ok(Self::default());
};
match fs::read_to_string(&path) {
Ok(raw) => Self::parse_str(&raw, &path),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
Err(error) => Err(Error::Io(error)),
}
}
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
let raw = fs::read_to_string(path).map_err(|error| {
Error::Config(format!(
"failed to read Server host config `{}`: {error}",
path.display()
))
})?;
Self::parse_str(&raw, path)
}
pub fn parse_str(raw: &str, path: impl AsRef<Path>) -> Result<Self> {
toml::from_str(raw).map_err(|error| {
Error::Config(format!(
"failed to parse Server host config `{}`: {error}",
path.as_ref().display()
))
})
}
}
impl BackendRuntimesConfigFile {
pub fn path_for_config_dir(config_dir: impl AsRef<Path>) -> PathBuf {
config_dir.as_ref().join(BACKEND_RUNTIMES_CONFIG_FILE_NAME)
@@ -255,148 +192,22 @@ impl BackendRuntimesConfigFile {
}
}
impl WorkspaceBackendConfigFile {
pub fn path_for_workspace(workspace_root: impl AsRef<Path>) -> PathBuf {
workspace_root
.as_ref()
.join(WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH)
}
pub fn ensure_local_config_for_workspace(workspace_root: impl AsRef<Path>) -> Result<()> {
let path = Self::path_for_workspace(workspace_root);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut file) => {
use std::io::Write;
file.write_all(WORKSPACE_BACKEND_CONFIG_TEMPLATE.as_bytes())?;
file.sync_all()?;
Ok(())
}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => Ok(()),
Err(error) => Err(Error::Io(error)),
}
}
pub fn local_config_diff_for_workspace(workspace_root: impl AsRef<Path>) -> Result<ConfigDiff> {
let workspace_root = workspace_root.as_ref();
let path = Self::path_for_workspace(workspace_root);
match fs::read_to_string(&path) {
Ok(local) => Ok(ConfigDiff::new(WORKSPACE_BACKEND_CONFIG_TEMPLATE, &local)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Err(Error::Config(format!(
"workspace backend local config `{}` does not exist; run `yoi-server init --workspace {}` first",
path.display(),
workspace_root.display()
))),
Err(error) => Err(Error::Io(error)),
}
}
pub fn load_for_workspace(workspace_root: impl AsRef<Path>) -> Result<Self> {
let path = Self::path_for_workspace(workspace_root);
match fs::read_to_string(&path) {
Ok(raw) => Self::parse_str(&raw, &path),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
Err(error) => Err(Error::Io(error)),
}
}
pub fn write_for_workspace(&self, workspace_root: impl AsRef<Path>) -> Result<()> {
let path = Self::path_for_workspace(workspace_root);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let raw = toml::to_string_pretty(self).map_err(|error| {
Error::Config(format!(
"failed to serialize workspace backend config: {error}"
))
})?;
fs::write(path, raw)?;
Ok(())
}
pub fn parse_str(raw: &str, path: impl AsRef<Path>) -> Result<Self> {
toml::from_str(raw).map_err(|error| {
Error::Config(format!(
"failed to parse workspace backend config `{}`: {error}",
path.as_ref().display()
))
})
}
pub fn resolve(
&self,
workspace_root: impl AsRef<Path>,
identity: WorkspaceIdentity,
) -> Result<ResolvedWorkspaceBackendConfig> {
self.resolve_with_runtime_config(
workspace_root,
identity,
&BackendRuntimesConfigFile::default(),
)
}
pub fn resolve_with_runtime_config(
&self,
impl ResolvedWorkspaceBackendConfig {
pub fn local_dev(
workspace_root: impl AsRef<Path>,
identity: WorkspaceIdentity,
host_config: &ServerHostConfigFile,
runtime_config: &BackendRuntimesConfigFile,
) -> Result<ResolvedWorkspaceBackendConfig> {
) -> Result<Self> {
let workspace_root = workspace_root.as_ref();
let data_root = self
.data
.root
.as_ref()
.map(|path| resolve_workspace_path(workspace_root, path))
.unwrap_or_else(|| {
ServerConfig::default_workspace_backend_data_root(&identity.workspace_id)
});
let database_path = self
.data
.workspace_database_path
.as_ref()
.map(|path| resolve_workspace_path(workspace_root, path))
.unwrap_or_else(ServerConfig::default_server_database_path);
let embedded_runtime_store_root = self
.data
.embedded_runtime_store_root
.as_ref()
.map(|path| resolve_workspace_path(workspace_root, path))
.unwrap_or_else(|| data_root.join("embedded-runtime"));
let listen = self
.server
.listen
.as_deref()
.unwrap_or(DEFAULT_LISTEN)
.parse::<SocketAddr>()
.map_err(|_| {
Error::Config(format!(
"invalid workspace backend server.listen `{}`",
self.server.listen.as_deref().unwrap_or(DEFAULT_LISTEN)
))
})?;
let data_root = ServerConfig::default_workspace_backend_data_root(&identity.workspace_id);
let database_path = ServerConfig::default_server_database_path();
let (browser_public_url, browser_rp_id) =
resolve_browser_public_url(&self.auth.browser_public_url)?;
resolve_browser_public_url(&host_config.browser.public_url)?;
let mut server = ServerConfig::local_dev(workspace_root.to_path_buf(), identity);
server.database_path = database_path.clone();
server.static_assets_dir = self
.server
.static_assets_dir
.as_ref()
.map(|path| resolve_workspace_path(workspace_root, path));
server.embedded_runtime_store_root = embedded_runtime_store_root;
server.max_records = self.limits.max_records.unwrap_or(DEFAULT_MAX_RECORDS);
server.repositories = self
.repositories
.iter()
.map(|repository| resolve_repository(workspace_root, repository))
.collect::<Result<Vec<_>>>()?;
server.embedded_runtime_store_root = data_root.join("embedded-runtime");
server.max_records = DEFAULT_MAX_RECORDS;
server.remote_runtime_sources = runtime_config
.runtimes
.remote
@@ -407,10 +218,13 @@ impl WorkspaceBackendConfigFile {
rp_id: browser_rp_id,
origin: browser_public_url.clone(),
public_base_url: browser_public_url,
cookie_name: normalize_required_string("auth.cookie_name", &self.auth.cookie_name)?,
cookie_name: DEFAULT_AUTH_COOKIE_NAME.to_string(),
};
let listen = DEFAULT_LISTEN.parse::<SocketAddr>().map_err(|error| {
Error::Config(format!("invalid built-in Server listen address: {error}"))
})?;
Ok(ResolvedWorkspaceBackendConfig {
Ok(Self {
server,
listen,
database_path,
@@ -419,32 +233,6 @@ impl WorkspaceBackendConfigFile {
}
impl ResolvedWorkspaceBackendConfig {
pub fn with_database_path(mut self, path: impl Into<PathBuf>) -> Self {
let path = path.into();
self.database_path = path.clone();
self.server.database_path = path;
self
}
pub fn with_static_assets_dir(mut self, path: Option<PathBuf>) -> Self {
self.server.static_assets_dir = path;
self
}
pub fn with_browser_public_url(mut self, public_url: &str) -> Result<Self> {
let (origin, rp_id) = resolve_browser_public_url(public_url)?;
let AuthConfig::Passkey {
rp_id: configured_rp_id,
origin: configured_origin,
public_base_url,
..
} = &mut self.server.auth;
*configured_rp_id = rp_id;
*configured_origin = origin.clone();
*public_base_url = origin;
Ok(self)
}
pub fn with_backend_base_url(mut self, base_url: impl Into<String>) -> Self {
self.server.backend_base_url = Some(base_url.into().trim_end_matches('/').to_string());
self
@@ -456,33 +244,6 @@ impl ResolvedWorkspaceBackendConfig {
}
}
fn resolve_repository(
workspace_root: &Path,
config: &WorkspaceRepositoryConfigFile,
) -> Result<ConfiguredRepository> {
let id = normalize_required_string("repository id", &config.id)?;
validate_repository_id(&id)?;
let provider =
normalize_required_string("repository provider", &config.provider)?.to_ascii_lowercase();
let uri = normalize_required_string("repository uri", &config.uri)?;
let (source, path) = resolve_repository_source(workspace_root, &id, &uri)?;
let display_name = normalize_optional_string(config.display_name.as_deref());
let default_selector = normalize_optional_string(config.default_selector.as_deref());
Ok(ConfiguredRepository {
id,
provider,
source_fingerprint: crate::repository_source::repository_source_fingerprint(&source),
source,
source_revision: 1,
observed_status: workspace_api::RepositoryObservedStatus::Unverified,
observed_at: None,
path,
display_name,
default_selector,
})
}
fn normalize_required_string(field: &str, value: &str) -> Result<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
@@ -491,73 +252,12 @@ fn normalize_required_string(field: &str, value: &str) -> Result<String> {
Ok(trimmed.to_string())
}
fn normalize_optional_string(value: Option<&str>) -> Option<String> {
value.and_then(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
fn validate_repository_id(id: &str) -> Result<()> {
if id
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
{
Ok(())
} else {
Err(Error::Config(format!(
"repository id `{id}` must contain only ASCII letters, digits, `_`, `-`, or `.`"
)))
}
}
fn resolve_repository_source(
workspace_root: &Path,
id: &str,
uri: &str,
) -> Result<(workspace_api::RepositorySource, Option<PathBuf>)> {
match crate::repository_source::parse_repository_source(uri) {
Ok(source) => {
let path = match source.kind {
workspace_api::RepositorySourceKind::LocalPath => Some(PathBuf::from(&source.uri)),
workspace_api::RepositorySourceKind::File => url::Url::parse(&source.uri)
.ok()
.and_then(|uri| uri.to_file_path().ok()),
workspace_api::RepositorySourceKind::Ssh
| workspace_api::RepositorySourceKind::Http
| workspace_api::RepositorySourceKind::Https => None,
workspace_api::RepositorySourceKind::Invalid => {
return Err(Error::Config(format!(
"repository `{id}` has an invalid source"
)));
}
};
Ok((source, path))
}
Err(_) if !Path::new(uri).is_absolute() && !uri.contains("://") => {
let path = resolve_workspace_path(workspace_root, Path::new(uri));
let source = workspace_api::RepositorySource {
kind: workspace_api::RepositorySourceKind::LocalPath,
uri: path.to_string_lossy().into_owned(),
};
Ok((source, Some(path)))
}
Err(error) => Err(Error::Config(format!(
"repository `{id}` has an invalid source: {error}"
))),
}
}
pub(crate) fn resolve_remote_runtime(
config: &RemoteRuntimeConfigFile,
) -> Result<RemoteRuntimeConfig> {
if let Some(token_ref) = config.token_ref.as_deref() {
return Err(Error::Config(format!(
"remote runtime `{}` uses token_ref `{token_ref}`, but secret ref resolution is not implemented for workspace backend config yet",
"remote runtime `{}` uses token_ref `{token_ref}`, but secret ref resolution is not implemented for Backend runtime settings yet",
config.id
)));
}
@@ -573,43 +273,35 @@ pub(crate) fn resolve_remote_runtime(
}
fn resolve_browser_public_url(value: &str) -> Result<(String, String)> {
let value = normalize_required_string("auth.browser_public_url", value)?;
let value = normalize_required_string("browser.public_url", value)?;
let url = Url::parse(&value).map_err(|error| {
Error::Config(format!(
"auth.browser_public_url must be an absolute http(s) URL: {error}"
"browser.public_url must be an absolute http(s) URL: {error}"
))
})?;
if !matches!(url.scheme(), "http" | "https") {
return Err(Error::Config(
"auth.browser_public_url must use the http or https scheme".to_string(),
"browser.public_url must use the http or https scheme".to_string(),
));
}
if !url.username().is_empty() || url.password().is_some() {
return Err(Error::Config(
"auth.browser_public_url must not contain user information".to_string(),
"browser.public_url must not contain user information".to_string(),
));
}
if url.path() != "/" || url.query().is_some() || url.fragment().is_some() {
return Err(Error::Config(
"auth.browser_public_url must contain only an origin without a path, query, or fragment"
"browser.public_url must contain only an origin without a path, query, or fragment"
.to_string(),
));
}
let rp_id = url
.host_str()
.ok_or_else(|| Error::Config("auth.browser_public_url must contain a host".to_string()))?
.ok_or_else(|| Error::Config("browser.public_url must contain a host".to_string()))?
.to_string();
Ok((url.origin().ascii_serialization(), rp_id))
}
fn resolve_workspace_path(workspace_root: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
workspace_root.join(path)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -622,11 +314,22 @@ mod tests {
}
}
#[test]
fn missing_config_path_uses_defaults() {
fn resolved_with_runtimes(
runtimes: &BackendRuntimesConfigFile,
) -> ResolvedWorkspaceBackendConfig {
let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::load_for_workspace(dir.path()).unwrap();
let resolved = config.resolve(dir.path(), identity()).unwrap();
ResolvedWorkspaceBackendConfig::local_dev(
dir.path(),
identity(),
&ServerHostConfigFile::default(),
runtimes,
)
.unwrap()
}
#[test]
fn default_settings_resolve_without_a_repository_file() {
let resolved = resolved_with_runtimes(&BackendRuntimesConfigFile::default());
assert_eq!(resolved.listen, "127.0.0.1:8787".parse().unwrap());
let AuthConfig::Passkey {
@@ -650,12 +353,8 @@ mod tests {
#[test]
fn backend_base_url_is_explicit_and_normalized() {
let dir = tempfile::tempdir().unwrap();
let listen = "127.0.0.1:48787".parse().unwrap();
let resolved = WorkspaceBackendConfigFile::load_for_workspace(dir.path())
.unwrap()
.resolve(dir.path(), identity())
.unwrap()
let resolved = resolved_with_runtimes(&BackendRuntimesConfigFile::default())
.with_listen(listen)
.with_backend_base_url("http://127.0.0.1:48787/");
@@ -667,14 +366,18 @@ mod tests {
}
#[test]
fn browser_public_url_drives_all_browser_auth_settings() {
let dir = tempfile::tempdir().unwrap();
let resolved = WorkspaceBackendConfigFile::parse_str(
"[auth]\nbrowser_public_url = \"https://Yoi.Example:443/\"\n",
"test",
fn browser_public_url_from_host_config_drives_all_browser_auth_settings() {
let host_config = ServerHostConfigFile::parse_str(
"[browser]\npublic_url = \"https://Yoi.Example:443/\"\n",
"server.toml",
)
.unwrap();
let resolved = ResolvedWorkspaceBackendConfig::local_dev(
tempfile::tempdir().unwrap().path(),
identity(),
&host_config,
&BackendRuntimesConfigFile::default(),
)
.unwrap()
.resolve(dir.path(), identity())
.unwrap();
let AuthConfig::Passkey {
@@ -688,26 +391,6 @@ mod tests {
assert_eq!(public_base_url, "https://yoi.example");
}
#[test]
fn browser_public_url_override_replaces_the_derived_settings() {
let dir = tempfile::tempdir().unwrap();
let resolved = WorkspaceBackendConfigFile::default()
.resolve(dir.path(), identity())
.unwrap()
.with_browser_public_url("https://deploy.example.test:8443")
.unwrap();
let AuthConfig::Passkey {
rp_id,
origin,
public_base_url,
..
} = &resolved.server.auth;
assert_eq!(rp_id, "deploy.example.test");
assert_eq!(origin, "https://deploy.example.test:8443");
assert_eq!(public_base_url, "https://deploy.example.test:8443");
}
#[test]
fn browser_public_url_rejects_non_origin_urls() {
for value in [
@@ -715,204 +398,51 @@ mod tests {
"https://example.test?query=true",
"file:///tmp/web",
] {
let result = WorkspaceBackendConfigFile::parse_str(
&format!("[auth]\nbrowser_public_url = {value:?}\n"),
"test",
)
.unwrap()
.resolve(tempfile::tempdir().unwrap().path(), identity());
let host_config = ServerHostConfigFile {
browser: ServerBrowserConfig {
public_url: value.to_string(),
},
};
let result = ResolvedWorkspaceBackendConfig::local_dev(
tempfile::tempdir().unwrap().path(),
identity(),
&host_config,
&BackendRuntimesConfigFile::default(),
);
let error = match result {
Ok(_) => panic!("expected {value} to be rejected"),
Err(error) => error,
};
assert!(
error.to_string().contains("auth.browser_public_url"),
error.to_string().contains("browser.public_url"),
"unexpected error for {value}: {error}"
);
}
}
#[test]
fn rejects_legacy_independent_browser_auth_settings() {
for key in ["rp_id", "origin", "public_base_url"] {
let error = WorkspaceBackendConfigFile::parse_str(
&format!("[auth]\n{key} = \"legacy.example\"\n"),
"test",
)
.unwrap_err();
assert!(
error.to_string().contains("unknown field"),
"unexpected error for {key}: {error}"
);
}
}
#[test]
fn rejects_unknown_fields() {
let error = WorkspaceBackendConfigFile::parse_str("[server]\nunknown = true\n", "test")
.unwrap_err();
assert!(
error.to_string().contains("unknown field"),
"unexpected error: {error}"
);
}
#[test]
fn resolves_relative_paths_against_workspace_root() {
fn server_host_config_loads_only_from_the_explicit_host_path() {
let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::parse_str(
r#"
[server]
static_assets_dir = "web/build"
[data]
root = ".yoi/backend-data"
workspace_database_path = ".yoi/custom.db"
embedded_runtime_store_root = ".yoi/runtime-store"
"#,
"test",
)
.unwrap();
let resolved = config.resolve(dir.path(), identity()).unwrap();
assert_eq!(
resolved.server.static_assets_dir,
Some(dir.path().join("web/build"))
);
assert_eq!(resolved.database_path, dir.path().join(".yoi/custom.db"));
assert_eq!(
resolved.server.embedded_runtime_store_root,
dir.path().join(".yoi/runtime-store")
);
}
#[test]
fn absolute_paths_are_preserved() {
let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::parse_str(
r#"
[data]
workspace_database_path = "/tmp/yoi-workspace.db"
embedded_runtime_store_root = "/tmp/yoi-runtime"
"#,
"test",
)
.unwrap();
let resolved = config.resolve(dir.path(), identity()).unwrap();
assert_eq!(
resolved.database_path,
PathBuf::from("/tmp/yoi-workspace.db")
);
assert_eq!(
resolved.server.embedded_runtime_store_root,
PathBuf::from("/tmp/yoi-runtime")
);
}
#[test]
fn data_root_derives_runtime_store_path_only() {
let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::parse_str(
r#"
[data]
root = ".local-data"
"#,
"test",
)
.unwrap();
let resolved = config.resolve(dir.path(), identity()).unwrap();
assert!(resolved.database_path.ends_with("server.db"));
assert_eq!(
resolved.server.embedded_runtime_store_root,
dir.path().join(".local-data/embedded-runtime")
);
}
#[test]
fn copies_local_config_without_overwriting() {
let dir = tempfile::tempdir().unwrap();
WorkspaceBackendConfigFile::ensure_local_config_for_workspace(dir.path()).unwrap();
let path = WorkspaceBackendConfigFile::path_for_workspace(dir.path());
let raw = fs::read_to_string(&path).unwrap();
assert_eq!(raw, WORKSPACE_BACKEND_CONFIG_TEMPLATE);
WorkspaceBackendConfigFile::parse_str(&raw, &path).unwrap();
fs::write(&path, "# custom local config\n").unwrap();
WorkspaceBackendConfigFile::ensure_local_config_for_workspace(dir.path()).unwrap();
assert_eq!(
fs::read_to_string(&path).unwrap(),
"# custom local config\n"
);
}
#[test]
fn local_config_diff_reports_match_and_difference() {
let dir = tempfile::tempdir().unwrap();
WorkspaceBackendConfigFile::ensure_local_config_for_workspace(dir.path()).unwrap();
let matched =
WorkspaceBackendConfigFile::local_config_diff_for_workspace(dir.path()).unwrap();
assert!(!matched.differs);
let path = ServerHostConfigFile::path_for_config_dir(dir.path());
fs::write(
WorkspaceBackendConfigFile::path_for_workspace(dir.path()),
"[server]\nlisten = \"127.0.0.1:9999\"\n",
&path,
"[browser]\npublic_url = \"https://deploy.example.test\"\n",
)
.unwrap();
let diff = WorkspaceBackendConfigFile::local_config_diff_for_workspace(dir.path()).unwrap();
assert!(diff.differs);
assert!(diff.text.contains("+++ workspace local"));
assert!(diff.text.contains("127.0.0.1:9999"));
let loaded = ServerHostConfigFile::load_from_path(&path).unwrap();
assert_eq!(loaded.browser.public_url, "https://deploy.example.test");
assert_eq!(path, dir.path().join("server.toml"));
}
#[test]
fn resolves_repository_uri_relative_to_workspace_root() {
let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::parse_str(
r#"
[[repositories]]
id = "main"
provider = "git"
uri = "."
display_name = "Main"
default_selector = "HEAD"
"#,
"test",
)
.unwrap();
let resolved = config.resolve(dir.path(), identity()).unwrap();
let repository = resolved.server.repositories.first().unwrap();
assert_eq!(repository.id, "main");
assert_eq!(repository.provider, "git");
assert_eq!(repository.path.as_deref(), Some(dir.path()));
assert_eq!(repository.display_name.as_deref(), Some("Main"));
assert_eq!(repository.default_selector.as_deref(), Some("HEAD"));
}
#[test]
fn remote_repository_source_is_preserved_without_a_local_path() {
let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::parse_str(
r#"
[[repositories]]
id = "main"
provider = "git"
uri = "https://example.com/org/repo.git"
"#,
"test",
)
.unwrap();
let resolved = config.resolve(dir.path(), identity()).unwrap();
let repository = &resolved.server.repositories[0];
assert_eq!(
repository.source.kind,
workspace_api::RepositorySourceKind::Https
fn explicit_missing_server_host_config_fails_closed() {
let error = ServerHostConfigFile::load_from_path("/missing/yoi/server.toml").unwrap_err();
assert!(
error
.to_string()
.contains("failed to read Server host config")
);
assert_eq!(repository.source.uri, "https://example.com/org/repo.git");
assert!(repository.path.is_none());
}
#[test]
@@ -937,28 +467,8 @@ uri = "https://example.com/org/repo.git"
);
}
#[test]
fn workspace_backend_config_rejects_runtime_entries() {
let error = WorkspaceBackendConfigFile::parse_str(
r#"
[[runtimes.remote]]
id = "arc"
endpoint = "http://legacy.example.test"
display_name = "legacy arc"
"#,
"test",
)
.unwrap_err();
assert!(
error.to_string().contains("unknown field `runtimes`"),
"unexpected error: {error}"
);
}
#[test]
fn backend_runtimes_config_is_the_only_runtime_source() {
let dir = tempfile::tempdir().unwrap();
let workspace_config = WorkspaceBackendConfigFile::parse_str("", "test").unwrap();
let runtime_config = BackendRuntimesConfigFile::parse_str(
r#"
[[runtimes.remote]]
@@ -969,9 +479,7 @@ display_name = "xdg arc"
"runtimes.toml",
)
.unwrap();
let resolved = workspace_config
.resolve_with_runtime_config(dir.path(), identity(), &runtime_config)
.unwrap();
let resolved = resolved_with_runtimes(&runtime_config);
assert_eq!(resolved.server.remote_runtime_sources.len(), 1);
assert_eq!(resolved.server.remote_runtime_sources[0].runtime_id, "arc");
assert_eq!(
@@ -1000,8 +508,6 @@ token = "secret"
#[test]
fn token_ref_fails_closed_until_secret_resolution_exists() {
let dir = tempfile::tempdir().unwrap();
let workspace_config = WorkspaceBackendConfigFile::parse_str("", "test").unwrap();
let runtime_config = BackendRuntimesConfigFile::parse_str(
r#"
[[runtimes.remote]]
@@ -1012,9 +518,10 @@ token_ref = "local:remote-token"
"runtimes.toml",
)
.unwrap();
let error = match workspace_config.resolve_with_runtime_config(
dir.path(),
let error = match ResolvedWorkspaceBackendConfig::local_dev(
tempfile::tempdir().unwrap().path(),
identity(),
&ServerHostConfigFile::default(),
&runtime_config,
) {
Ok(_) => panic!("token_ref should fail closed until secret resolution exists"),
+2 -3
View File
@@ -46,8 +46,7 @@ impl WorkspaceIdentity {
Ok(raw) => Self::parse_str(&raw, &path),
Err(error) if error.kind() == ErrorKind::NotFound => {
Err(Error::WorkspaceIdentity(format!(
"workspace is not initialized at {}; run `yoi-server init --workspace {}` first",
workspace_root.as_ref().display(),
"workspace identity is missing at {}; register the Workspace through the Server before using repository-local client routing",
workspace_root.as_ref().display()
)))
}
@@ -219,7 +218,7 @@ mod tests {
let error = WorkspaceIdentity::load_required(&workspace_root).unwrap_err();
assert!(
error.to_string().contains("workspace is not initialized"),
error.to_string().contains("workspace identity is missing"),
"unexpected error: {error}"
);
assert!(!WorkspaceIdentity::path(&workspace_root).exists());
+1 -5
View File
@@ -38,11 +38,7 @@ pub use authority::{
ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource,
WorkspaceAuthority,
};
pub use config::{
BackendRuntimesConfigFile, ConfigDiff, ResolvedWorkspaceBackendConfig,
WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH, WORKSPACE_BACKEND_CONFIG_TEMPLATE,
WorkspaceBackendConfigFile,
};
pub use config::{BackendRuntimesConfigFile, ResolvedWorkspaceBackendConfig, ServerHostConfigFile};
pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity};
pub use records::{ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary};
pub use repositories::{
+42 -250
View File
@@ -11,19 +11,13 @@ use worker_runtime::auth::{RuntimeIdentityMaterial, decode_public_key};
use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig};
use yoi_workspace_server::store::{SqliteWorkspaceStore, TrustedRuntimeRecord};
use yoi_workspace_server::{
BackendRuntimesConfigFile, ControlPlaneStore, InitialRepositoryIntent, ServerConfig,
WORKSPACE_BACKEND_CONFIG_TEMPLATE, WorkspaceBackendConfigFile, WorkspaceCatalogService,
WorkspaceCreateRequest, WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog,
BackendRuntimesConfigFile, ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig,
ServerHostConfigFile, WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog,
};
const BROWSER_PUBLIC_URL_ENV: &str = "YOI_BROWSER_PUBLIC_URL";
#[derive(Debug)]
enum Command {
Serve(ServeOptions),
Init(InitOptions),
ConfigDefault,
ConfigDiff(WorkspacePathOptions),
Identity(Vec<String>),
TrustRuntime(Vec<String>),
MigrateDryRun { database: Option<PathBuf> },
@@ -34,16 +28,7 @@ enum Command {
#[derive(Debug)]
struct ServeOptions {
listen: Option<SocketAddr>,
}
#[derive(Debug)]
struct InitOptions {
workspace: PathBuf,
}
#[derive(Debug)]
struct WorkspacePathOptions {
workspace: PathBuf,
config: Option<PathBuf>,
}
#[derive(Debug)]
@@ -84,9 +69,6 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
let args = std::env::args().skip(1).collect::<Vec<_>>();
match parse_command(&args)? {
Command::Serve(options) => run_serve(options).await,
Command::Init(options) => run_init(options).await,
Command::ConfigDefault => run_config_default(),
Command::ConfigDiff(options) => run_config_diff(options),
Command::Identity(args) => run_identity_command(args),
Command::TrustRuntime(args) => run_trust_runtime_command(args),
Command::MigrateDryRun { database } => {
@@ -112,14 +94,6 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
};
match command.as_str() {
"init" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
print_init_help();
return Ok(Command::Help);
}
Ok(Command::Init(parse_init_options(rest)?))
}
"config" => parse_config_command(rest),
"identity" => Ok(Command::Identity(rest.to_vec())),
"trust-runtime" => Ok(Command::TrustRuntime(rest.to_vec())),
"migrate" => parse_migrate_command(rest),
@@ -136,61 +110,11 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
Ok(Command::Help)
}
other => Err(CliError(format!(
"unknown command `{other}`; expected `init`, `config`, `identity`, `trust-runtime`, `migrate`, `skills`, or `serve`"
"unknown command `{other}`; expected `identity`, `trust-runtime`, `migrate`, `skills`, or `serve`"
))),
}
}
async fn run_init(options: InitOptions) -> Result<(), Box<dyn std::error::Error>> {
run_init_with_database_path(options, ServerConfig::default_server_database_path()).await
}
async fn run_init_with_database_path(
options: InitOptions,
database_path: PathBuf,
) -> Result<(), Box<dyn std::error::Error>> {
let identity = WorkspaceIdentity::load_or_init(&options.workspace)?;
WorkspaceBackendConfigFile::ensure_local_config_for_workspace(&options.workspace)?;
if let Some(parent) = database_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let store = Arc::new(SqliteWorkspaceStore::open(&database_path)?);
let service = WorkspaceCatalogService::new(store);
service.create_with_workspace_id(
WorkspaceCreateRequest {
operation_key: format!("cli-init:{}", identity.workspace_id),
display_name: identity.display_name.clone(),
repository: InitialRepositoryIntent {
uri: options.workspace.display().to_string(),
display_name: Some("Main repository".to_string()),
default_ref: Some("HEAD".to_string()),
},
},
None,
Some(identity.workspace_id.clone()),
)?;
eprintln!(
"yoi-server: initialized workspace `{}` ({}) in server DB `{}`",
options.workspace.display(),
identity.workspace_id,
database_path.display()
);
Ok(())
}
fn run_config_default() -> Result<(), Box<dyn std::error::Error>> {
print!("{WORKSPACE_BACKEND_CONFIG_TEMPLATE}");
Ok(())
}
fn run_config_diff(options: WorkspacePathOptions) -> Result<(), Box<dyn std::error::Error>> {
let diff = WorkspaceBackendConfigFile::local_config_diff_for_workspace(&options.workspace)?;
print!("{}", diff.text);
Ok(())
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct ServerIdentityFile {
identity: RuntimeIdentityMaterial,
@@ -628,22 +552,23 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
.to_path_buf(),
)
};
let host_config = match options.config.as_ref() {
Some(path) => ServerHostConfigFile::load_from_path(path)?,
None => ServerHostConfigFile::load_default()?,
};
let runtime_config = BackendRuntimesConfigFile::load_default()?;
let mut resolved = WorkspaceBackendConfigFile::default().resolve_with_runtime_config(
let mut resolved = ResolvedWorkspaceBackendConfig::local_dev(
&workspace_root,
identity,
&host_config,
&runtime_config,
)?;
resolved.database_path = database_path.clone();
resolved.server.database_path = database_path.clone();
if let Some(browser_public_url) = browser_public_url_from_environment()? {
resolved = resolved.with_browser_public_url(&browser_public_url)?;
}
append_trusted_runtime_sources(store.as_ref(), &mut resolved.server.remote_runtime_sources)?;
if let Some(listen) = options.listen {
resolved = resolved.with_listen(listen);
}
resolved.server.allow_local_workspace_bootstrap = resolved.listen.ip().is_loopback();
let listener = TcpListener::bind(resolved.listen).await?;
let local_addr = listener.local_addr()?;
@@ -660,16 +585,6 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
Ok(())
}
fn browser_public_url_from_environment() -> Result<Option<String>, CliError> {
match std::env::var(BROWSER_PUBLIC_URL_ENV) {
Ok(value) => Ok(Some(value)),
Err(std::env::VarError::NotPresent) => Ok(None),
Err(std::env::VarError::NotUnicode(_)) => Err(CliError(format!(
"{BROWSER_PUBLIC_URL_ENV} must contain valid UTF-8"
))),
}
}
fn append_trusted_runtime_sources(
store: &SqliteWorkspaceStore,
remote_runtime_sources: &mut Vec<RemoteRuntimeConfig>,
@@ -737,41 +652,6 @@ fn infer_workspace_root_from_repositories(
))
}
fn parse_config_command(args: &[String]) -> Result<Command, CliError> {
let Some((subcommand, rest)) = args.split_first() else {
print_config_help();
return Ok(Command::Help);
};
match subcommand.as_str() {
"default" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
print_config_help();
return Ok(Command::Help);
}
if !rest.is_empty() {
return Err(CliError(
"config default does not accept options".to_string(),
));
}
Ok(Command::ConfigDefault)
}
"diff" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
print_config_help();
return Ok(Command::Help);
}
Ok(Command::ConfigDiff(parse_workspace_path_options(rest)?))
}
"--help" | "-h" => {
print_config_help();
Ok(Command::Help)
}
other => Err(CliError(format!(
"unknown config subcommand `{other}`; expected `default` or `diff`"
))),
}
}
fn parse_migrate_command(args: &[String]) -> Result<Command, CliError> {
let mut dry_run = false;
let mut database = None;
@@ -855,57 +735,9 @@ fn parse_skill_workspace_options(args: &[String]) -> Result<SkillWorkspaceOption
Ok(SkillWorkspaceOptions { workspace_id })
}
fn parse_workspace_path_options(args: &[String]) -> Result<WorkspacePathOptions, CliError> {
let mut workspace = std::env::current_dir()
.map_err(|error| CliError(format!("failed to read current dir: {error}")))?;
let mut iter = args.iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
"--workspace" => {
let value = iter
.next()
.ok_or_else(|| CliError("--workspace requires a path".to_string()))?;
workspace = PathBuf::from(value);
}
value if value.starts_with("--workspace=") => {
workspace = PathBuf::from(value_after_equals(arg, "--workspace")?);
}
other => return Err(CliError(format!("unknown workspace option `{other}`"))),
}
}
let workspace = workspace
.canonicalize()
.map_err(|error| CliError(format!("failed to canonicalize workspace: {error}")))?;
Ok(WorkspacePathOptions { workspace })
}
fn parse_init_options(args: &[String]) -> Result<InitOptions, CliError> {
let mut workspace = std::env::current_dir()
.map_err(|error| CliError(format!("failed to read current dir: {error}")))?;
let mut iter = args.iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
"--workspace" => {
let value = iter
.next()
.ok_or_else(|| CliError("--workspace requires a path".to_string()))?;
workspace = PathBuf::from(value);
}
value if value.starts_with("--workspace=") => {
workspace = PathBuf::from(value_after_equals(arg, "--workspace")?);
}
other => return Err(CliError(format!("unknown init option `{other}`"))),
}
}
let workspace = workspace
.canonicalize()
.map_err(|error| CliError(format!("failed to canonicalize workspace: {error}")))?;
Ok(InitOptions { workspace })
}
fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
let mut listen = None;
let mut config = None;
let mut index = 0;
while index < args.len() {
@@ -921,6 +753,16 @@ fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
_ if arg.starts_with("--listen=") => {
listen = Some(parse_listen(value_after_equals(arg, "--listen")?)?);
}
"--config" => {
index += 1;
let value = args
.get(index)
.ok_or_else(|| CliError("--config requires a path".to_string()))?;
config = Some(PathBuf::from(value));
}
_ if arg.starts_with("--config=") => {
config = Some(PathBuf::from(value_after_equals(arg, "--config")?));
}
_ if arg.starts_with('-') => {
return Err(CliError(format!("unknown serve option `{arg}`")));
}
@@ -933,7 +775,7 @@ fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
index += 1;
}
Ok(ServeOptions { listen })
Ok(ServeOptions { listen, config })
}
fn value_after_equals<'a>(arg: &'a str, flag: &str) -> Result<&'a str, CliError> {
@@ -955,23 +797,11 @@ fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
fn print_help() {
println!(
"yoi-server\n\nUsage:\n yoi-server init [OPTIONS]\n yoi-server config <COMMAND> [OPTIONS]\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --workspace-id <WORKSPACE_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id <RUNTIME_ID>\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server migrate --dry-run [--database <PATH>]
"yoi-server\n\nUsage:\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --workspace-id <WORKSPACE_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id <RUNTIME_ID>\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server migrate --dry-run [--database <PATH>]
yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
);
}
fn print_init_help() {
println!(
"yoi-server init\n\nUsage:\n yoi-server init [OPTIONS]\n\nDescription:\n Initializes a Workspace identity, copies the packaged Backend config template to .yoi/workspace-backend.local.toml, and registers the Workspace in the Yoi server DB.\n\nOptions:\n --workspace <PATH> Workspace root to initialize (defaults to cwd)\n -h, --help Print help"
);
}
fn print_config_help() {
println!(
"yoi-server config\n\nUsage:\n yoi-server config default\n yoi-server config diff [OPTIONS]\n\nDescription:\n Prints the packaged Workspace Backend config template or compares it with the workspace-local config.\n\nOptions for diff:\n --workspace <PATH> Workspace root (defaults to cwd)\n -h, --help Print help"
);
}
fn print_skills_help() {
println!(
"yoi-server skills\n\nUsage:\n yoi-server skills list --workspace <WORKSPACE_ID>\n yoi-server skills lint --workspace <WORKSPACE_ID>\n yoi-server skills show <NAME> --workspace <WORKSPACE_ID>\n\nDescription:\n Reads the active Server DB virtual-config revision. Catalog output is lightweight and omits imported Markdown content; detail output includes that content. allowed-tools and scripts are diagnostics only.\n\nOptions:\n --workspace <WORKSPACE_ID> Workspace id in the Server DB (required)\n -h, --help Print help"
@@ -981,24 +811,23 @@ fn print_skills_help() {
fn print_serve_help() {
println!(
"yoi-server serve\n\nUsage:\n yoi-server migrate --dry-run [--database <PATH>]
yoi-server serve [OPTIONS]\n\nDescription:\n Serves the Workspace recorded in the Yoi server DB. Workspace records are stored in the XDG/Yoi data directory, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen <ADDR> Listen address (default 127.0.0.1:8787)\n -h, --help Print help\n\nEnvironment:\n YOI_BROWSER_PUBLIC_URL Browser-facing Vite/Nginx origin used by WebAuthn, device login, cookies, and CSRF checks"
yoi-server serve [OPTIONS]\n\nDescription:\n Serves Workspaces recorded in the Yoi server DB. Host-level deployment settings are loaded from the explicit --config path or the canonical XDG yoi/server.toml path, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen <ADDR> Listen address (default 127.0.0.1:8787)\n --config <PATH> Host-level Server config path\n -h, --help Print help"
);
}
#[cfg(test)]
mod tests {
use super::*;
use yoi_workspace_server::{
WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH, WORKSPACE_BACKEND_CONFIG_TEMPLATE,
WORKSPACE_IDENTITY_RELATIVE_PATH,
};
#[test]
fn parse_init_defaults_workspace_to_cwd_or_flag() {
let temp = tempfile::tempdir().unwrap();
let args = vec!["--workspace".to_string(), temp.path().display().to_string()];
let options = parse_init_options(&args).unwrap();
assert_eq!(options.workspace, temp.path().canonicalize().unwrap());
fn removed_repository_local_commands_are_rejected() {
for command in ["init", "config"] {
let error = parse_command(&[command.to_string()]).unwrap_err();
assert!(
error.to_string().contains("unknown command"),
"unexpected error for {command}: {error}"
);
}
}
#[test]
@@ -1038,10 +867,18 @@ mod tests {
}
#[test]
fn parse_serve_accepts_listen_only() {
let args = vec!["--listen".to_string(), "127.0.0.1:0".to_string()];
fn parse_serve_accepts_listen_and_host_config() {
let args = vec![
"--listen".to_string(),
"127.0.0.1:0".to_string(),
"--config=/etc/yoi/server.toml".to_string(),
];
let options = parse_serve_options(&args).unwrap();
assert_eq!(options.listen.unwrap(), "127.0.0.1:0".parse().unwrap());
assert_eq!(
options.config.unwrap(),
PathBuf::from("/etc/yoi/server.toml")
);
}
#[test]
@@ -1096,49 +933,4 @@ mod tests {
);
ensure_trusted_runtime_replace_allowed(&store, "runtime-a", true).unwrap();
}
#[tokio::test]
async fn init_creates_identity_local_config_and_server_records() {
let temp = tempfile::tempdir().unwrap();
let database_path = temp.path().join("data").join("server").join("server.db");
std::fs::create_dir(temp.path().join(".git")).unwrap();
run_init_with_database_path(
InitOptions {
workspace: temp.path().canonicalize().unwrap(),
},
database_path.clone(),
)
.await
.unwrap();
assert!(temp.path().join(WORKSPACE_IDENTITY_RELATIVE_PATH).exists());
let local_config_path = temp.path().join(WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH);
assert!(local_config_path.exists());
assert_eq!(
std::fs::read_to_string(local_config_path).unwrap(),
WORKSPACE_BACKEND_CONFIG_TEMPLATE
);
assert!(
!temp
.path()
.join(".yoi/workspace-backend.default.toml")
.exists()
);
assert!(!temp.path().join(".yoi/workspace.db").exists());
assert!(!temp.path().join(".yoi/embedded-runtime").exists());
assert!(database_path.exists());
let store = SqliteWorkspaceStore::open(&database_path).unwrap();
let workspaces = store.list_workspaces().unwrap();
assert_eq!(workspaces.len(), 1);
let repositories = store
.list_repositories(&workspaces[0].workspace_id)
.unwrap();
assert_eq!(repositories.len(), 1);
assert_eq!(repositories[0].repository_id, "main");
assert_eq!(
repositories[0].uri,
temp.path().canonicalize().unwrap().display().to_string()
);
}
}
+83 -106
View File
@@ -164,9 +164,6 @@ pub struct ServerConfig {
pub remote_runtime_sources: Vec<RemoteRuntimeConfig>,
pub runtime_config_path: Option<PathBuf>,
pub backend_base_url: Option<String>,
/// Allows the first ownerless Workspace to be created without a session.
/// This must only be enabled for a loopback-bound local Server.
pub allow_local_workspace_bootstrap: bool,
}
impl ServerConfig {
@@ -195,7 +192,6 @@ impl ServerConfig {
remote_runtime_sources: Vec::new(),
runtime_config_path: BackendRuntimesConfigFile::default_path(),
backend_base_url: None,
allow_local_workspace_bootstrap: false,
}
}
@@ -252,11 +248,6 @@ impl ServerConfig {
Self::default_workspace_backend_data_root(workspace_id).join("embedded-runtime")
}
pub fn with_local_workspace_bootstrap(mut self, enabled: bool) -> Self {
self.allow_local_workspace_bootstrap = enabled;
self
}
pub fn with_embedded_runtime_store_root(mut self, root: impl Into<PathBuf>) -> Self {
self.embedded_runtime_store_root = root.into();
self
@@ -842,9 +833,9 @@ async fn list_server_workspaces(
) -> Response {
let owner = match resolve_server_actor(&api, &headers).await {
Ok(Some(actor)) => Some(actor.account_id),
Ok(None) => match api.catalog.list(None, 1) {
Ok(workspaces) if workspaces.is_empty() => return Json(workspaces).into_response(),
Ok(_) => return StatusCode::UNAUTHORIZED.into_response(),
Ok(None) => match api.catalog.is_empty() {
Ok(true) => return Json(Vec::<WorkspaceRecord>::new()).into_response(),
Ok(false) => return StatusCode::UNAUTHORIZED.into_response(),
Err(error) => return server_error_response(error),
},
Err(error) => return server_error_response(error),
@@ -863,19 +854,14 @@ async fn create_server_workspace(
headers: HeaderMap,
Json(request): Json<WorkspaceCreateRequest>,
) -> Response {
let (owner_account_id, local_bootstrap) = match resolve_server_actor(&api, &headers).await {
Ok(Some(actor)) => (Some(actor.account_id), false),
Ok(None) if api.template.allow_local_workspace_bootstrap => (None, true),
let owner_account_id = match resolve_server_actor(&api, &headers).await {
Ok(Some(actor)) => actor.account_id,
Ok(None) => {
return forbidden_server_response("Workspace creation requires an authenticated owner");
}
Err(error) => return server_error_response(error),
};
let created = match if local_bootstrap {
api.catalog.create_first_ownerless(request)
} else {
api.catalog.create(request, owner_account_id)
} {
let created = match api.catalog.create(request, owner_account_id) {
Ok(created) => created,
Err(error) => return server_error_response(error),
};
@@ -1213,6 +1199,33 @@ pub async fn build_workspace_server_router(
)))
}
#[cfg(test)]
async fn seed_test_registered_workspace(
store: &dyn ControlPlaneStore,
config: &ServerConfig,
) -> Result<()> {
let account_id = format!("account-{}", config.workspace_id);
store.upsert_account(&AccountRecord {
account_id: account_id.clone(),
kind: "user".to_owned(),
handle: format!("owner-{}", config.workspace_id),
display_name: "Workspace Owner".to_owned(),
created_at: config.workspace_created_at.clone(),
updated_at: config.workspace_created_at.clone(),
})?;
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: config.workspace_id.clone(),
owner_account_id: Some(account_id),
display_name: config.workspace_display_name.clone(),
state: "active".to_owned(),
created_at: config.workspace_created_at.clone(),
updated_at: config.workspace_created_at.clone(),
})
.await?;
Ok(())
}
impl WorkspaceApi {
pub fn with_config_schema_provider(
mut self,
@@ -1276,6 +1289,7 @@ impl WorkspaceApi {
store: Arc<dyn ControlPlaneStore>,
execution_backend: Arc<dyn worker_runtime::execution::WorkerExecutionBackend>,
) -> Result<Self> {
seed_test_registered_workspace(store.as_ref(), &config).await?;
Self::new_with_execution_backend_and_broker(
config,
store,
@@ -1295,16 +1309,12 @@ impl WorkspaceApi {
Arc<crate::worker_source::EmbeddedServerWorkerMutationDispatcher>,
>,
) -> Result<Self> {
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: config.workspace_id.clone(),
owner_account_id: None,
display_name: config.workspace_display_name.clone(),
state: "active".to_string(),
created_at: config.workspace_created_at.clone(),
updated_at: config.workspace_created_at.clone(),
})
.await?;
if store.get_workspace(&config.workspace_id).await?.is_none() {
return Err(crate::Error::Config(format!(
"Workspace {} is not registered in the Server DB",
config.workspace_id
)));
}
import_configured_repositories(store.as_ref(), &config)?;
config.repositories = load_configured_repositories_from_store(store.as_ref(), &config)?;
let embedded_runtime = EmbeddedWorkerRuntime::new_fs_store_with_execution_backend(
@@ -15167,13 +15177,34 @@ mod tests {
));
}
#[tokio::test]
async fn workspace_api_does_not_register_a_missing_workspace() {
let dir = tempfile::tempdir().unwrap();
let config = test_server_config(dir.path());
let store = Arc::new(SqliteWorkspaceStore::open(&config.database_path).unwrap());
let error = match WorkspaceApi::new(config, store.clone()).await {
Ok(_) => panic!("missing Workspace registration must fail closed"),
Err(error) => error,
};
assert!(
error
.to_string()
.contains("is not registered in the Server DB")
);
assert!(store.list_workspaces().unwrap().is_empty());
}
#[tokio::test]
async fn production_profile_backend_rejects_unrecoverable_pending_orchestrator_restore() {
let workspace = tempfile::tempdir().unwrap();
init_clean_git_workspace(workspace.path());
let config = test_server_config(workspace.path());
let store = SqliteWorkspaceStore::open(config.database_path.clone()).unwrap();
let api = WorkspaceApi::new(config, Arc::new(store)).await.unwrap();
let store = Arc::new(SqliteWorkspaceStore::open(config.database_path.clone()).unwrap());
seed_test_registered_workspace(store.as_ref(), &config)
.await
.unwrap();
let api = WorkspaceApi::new(config, store).await.unwrap();
let workspace_id = api.config.workspace_id.clone();
let result = scoped_start_workspace_orchestrator(
@@ -15725,13 +15756,8 @@ mod tests {
#[test]
fn backend_errors_preserve_operation_details() {
let sanitized = sanitize_backend_error(
"failed to open /home/example/.yoi/workspace-backend.local.toml",
);
assert_eq!(
sanitized,
"failed to open /home/example/.yoi/workspace-backend.local.toml"
);
let sanitized = sanitize_backend_error("failed to open server database");
assert_eq!(sanitized, "failed to open server database");
}
#[test]
@@ -16170,6 +16196,16 @@ mod tests {
} = &config.auth;
let expected_origin = expected_origin.clone();
let store = Arc::new(SqliteWorkspaceStore::open(&config.database_path).unwrap());
store
.upsert_account(&AccountRecord {
account_id: "account-auth".to_owned(),
kind: "user".to_owned(),
handle: "auth-user".to_owned(),
display_name: "Auth User".to_owned(),
created_at: "2026-01-01T00:00:00Z".to_owned(),
updated_at: "2026-01-01T00:00:00Z".to_owned(),
})
.unwrap();
let catalog = WorkspaceCatalogService::new(store.clone());
let repository = temp.path().join("repository");
std::fs::create_dir_all(&repository).unwrap();
@@ -16192,19 +16228,9 @@ mod tests {
default_ref: None,
},
},
None,
"account-auth".to_owned(),
)
.unwrap();
store
.upsert_account(&AccountRecord {
account_id: "account-auth".to_owned(),
kind: "user".to_owned(),
handle: "auth-user".to_owned(),
display_name: "Auth User".to_owned(),
created_at: "2026-01-01T00:00:00Z".to_owned(),
updated_at: "2026-01-01T00:00:00Z".to_owned(),
})
.unwrap();
store
.upsert_user(&UserRecord {
user_id: "user-auth".to_owned(),
@@ -16556,6 +16582,7 @@ mod tests {
let mut template = test_server_config(dir.path());
template.static_assets_dir = Some(static_dir);
let store = Arc::new(SqliteWorkspaceStore::open(&template.database_path).unwrap());
let token = seed_test_api_token(store.as_ref(), "two-workspaces");
let catalog = WorkspaceCatalogService::new(store.clone());
let workspace_a = catalog
.create(
@@ -16568,7 +16595,7 @@ mod tests {
default_ref: None,
},
},
None,
"account-two-workspaces".to_owned(),
)
.unwrap();
let workspace_b = catalog
@@ -16582,10 +16609,9 @@ mod tests {
default_ref: None,
},
},
None,
"account-two-workspaces".to_owned(),
)
.unwrap();
let token = seed_test_api_token(store.as_ref(), "two-workspaces");
let app = build_workspace_server_router(template, store)
.await
.unwrap();
@@ -16686,13 +16712,12 @@ mod tests {
}
#[tokio::test]
async fn local_bootstrap_create_activates_workspace_without_server_restart() {
async fn local_workspace_creation_requires_an_authenticated_owner() {
let dir = tempfile::tempdir().unwrap();
let repository = dir.path().join("repository");
std::fs::create_dir_all(repository.join(".git")).unwrap();
let template = test_server_config(dir.path()).with_local_workspace_bootstrap(true);
let template = test_server_config(dir.path());
let store = Arc::new(SqliteWorkspaceStore::open(&template.database_path).unwrap());
let token = seed_test_api_token(store.as_ref(), "bootstrap");
let app = build_workspace_server_router(template, store)
.await
.unwrap();
@@ -16706,8 +16731,7 @@ mod tests {
}
});
let created = app
.clone()
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
@@ -16718,54 +16742,7 @@ mod tests {
)
.await
.unwrap();
assert_eq!(created.status(), StatusCode::CREATED);
let body = to_bytes(created.into_body(), usize::MAX).await.unwrap();
let body: Value = serde_json::from_slice(&body).unwrap();
let workspace_id = body["workspace"]["workspace_id"].as_str().unwrap();
let workspace = get_json_authenticated(
app.clone(),
&format!("/api/w/{workspace_id}/workspace"),
&token,
)
.await;
assert_eq!(workspace["display_name"], "Created Workspace");
let replayed = app
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/workspaces")
.header(axum::http::header::CONTENT_TYPE, "application/json")
.body(Body::from(payload.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(replayed.status(), StatusCode::OK);
let second_payload = json!({
"operation_key": "bootstrap-2",
"display_name": "Second Ownerless Workspace",
"repository": {
"uri": repository,
"display_name": "Repository",
"default_ref": "HEAD"
}
});
let second = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/workspaces")
.header(axum::http::header::CONTENT_TYPE, "application/json")
.body(Body::from(second_payload.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(second.status(), StatusCode::CONFLICT);
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[test]
@@ -54,6 +54,10 @@ impl WorkspaceCatalogService {
Self { store }
}
pub fn is_empty(&self) -> Result<bool> {
Ok(self.store.list_workspaces()?.is_empty())
}
pub fn list(
&self,
owner_account_id: Option<&str>,
@@ -76,33 +80,16 @@ impl WorkspaceCatalogService {
pub fn create(
&self,
request: WorkspaceCreateRequest,
owner_account_id: Option<String>,
owner_account_id: String,
) -> Result<WorkspaceCreateResponse> {
self.create_internal(request, owner_account_id, None, false)
}
pub fn create_first_ownerless(
&self,
request: WorkspaceCreateRequest,
) -> Result<WorkspaceCreateResponse> {
self.create_internal(request, None, None, true)
}
pub fn create_with_workspace_id(
&self,
request: WorkspaceCreateRequest,
owner_account_id: Option<String>,
requested_workspace_id: Option<String>,
) -> Result<WorkspaceCreateResponse> {
self.create_internal(request, owner_account_id, requested_workspace_id, false)
self.create_internal(request, owner_account_id, None)
}
fn create_internal(
&self,
request: WorkspaceCreateRequest,
owner_account_id: Option<String>,
owner_account_id: String,
requested_workspace_id: Option<String>,
require_empty_catalog: bool,
) -> Result<WorkspaceCreateResponse> {
let operation_key = normalize_required(
"operation_key",
@@ -142,7 +129,7 @@ impl WorkspaceCatalogService {
let fingerprint = workspace_create_fingerprint(
requested_workspace_id.as_deref(),
&display_name,
owner_account_id.as_deref(),
Some(&owner_account_id),
&repository_uri,
&repository_name,
&default_ref,
@@ -153,10 +140,10 @@ impl WorkspaceCatalogService {
.create_workspace_bootstrap(&WorkspaceBootstrapRecord {
operation_key,
request_fingerprint: fingerprint.clone(),
require_empty_catalog,
require_empty_catalog: false,
workspace: WorkspaceRecord {
workspace_id: workspace_id.clone(),
owner_account_id,
owner_account_id: Some(owner_account_id),
display_name,
state: "active".to_string(),
created_at: now.clone(),
@@ -235,7 +222,7 @@ fn workspace_create_fingerprint(
#[cfg(test)]
mod tests {
use super::*;
use crate::store::SqliteWorkspaceStore;
use crate::store::{AccountRecord, SqliteWorkspaceStore};
use workspace_api::RepositorySourceKind;
fn git_repository() -> tempfile::TempDir {
@@ -244,6 +231,22 @@ mod tests {
dir
}
fn owner_account(store: &SqliteWorkspaceStore) -> String {
let account_id = Uuid::now_v7().to_string();
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
store
.upsert_account(&AccountRecord {
account_id: account_id.clone(),
kind: "user".to_string(),
handle: format!("owner-{}", &account_id[..8]),
display_name: "Workspace Owner".to_string(),
created_at: now.clone(),
updated_at: now,
})
.unwrap();
account_id
}
#[tokio::test]
async fn create_is_atomic_and_exact_retries_converge() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
@@ -259,8 +262,11 @@ mod tests {
},
};
let created = service.create(request.clone(), None).unwrap();
let replayed = service.create(request, None).unwrap();
let owner_account_id = owner_account(store.as_ref());
let created = service
.create(request.clone(), owner_account_id.clone())
.unwrap();
let replayed = service.create(request, owner_account_id).unwrap();
assert!(!created.replayed);
assert!(replayed.replayed);
@@ -284,64 +290,10 @@ mod tests {
);
}
#[test]
fn concurrent_ownerless_bootstrap_commits_exactly_one_workspace() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
let service = WorkspaceCatalogService::new(store.clone());
let repository_a = git_repository();
let repository_b = git_repository();
let requests = [
WorkspaceCreateRequest {
operation_key: "bootstrap-a".to_string(),
display_name: "Workspace A".to_string(),
repository: InitialRepositoryIntent {
uri: repository_a.path().display().to_string(),
display_name: None,
default_ref: None,
},
},
WorkspaceCreateRequest {
operation_key: "bootstrap-b".to_string(),
display_name: "Workspace B".to_string(),
repository: InitialRepositoryIntent {
uri: repository_b.path().display().to_string(),
display_name: None,
default_ref: None,
},
},
];
let barrier = Arc::new(std::sync::Barrier::new(2));
let results = std::thread::scope(|scope| {
requests
.into_iter()
.map(|request| {
let service = service.clone();
let barrier = barrier.clone();
scope.spawn(move || {
barrier.wait();
service.create_first_ownerless(request)
})
})
.collect::<Vec<_>>()
.into_iter()
.map(|handle| handle.join().unwrap())
.collect::<Vec<_>>()
});
assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
assert_eq!(store.list_workspaces().unwrap().len(), 1);
let error = results
.into_iter()
.find_map(Result::err)
.unwrap()
.to_string();
assert!(error.contains("catalog is empty"), "{error}");
}
#[tokio::test]
async fn idempotency_key_reuse_with_different_payload_is_rejected() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
let owner_account_id = owner_account(store.as_ref());
let service = WorkspaceCatalogService::new(store);
let repository = git_repository();
let mut request = WorkspaceCreateRequest {
@@ -353,10 +305,15 @@ mod tests {
default_ref: None,
},
};
service.create(request.clone(), None).unwrap();
service
.create(request.clone(), owner_account_id.clone())
.unwrap();
request.display_name = "Workspace B".to_string();
let error = service.create(request, None).unwrap_err().to_string();
let error = service
.create(request, owner_account_id)
.unwrap_err()
.to_string();
assert!(error.contains("different input"), "{error}");
}
@@ -378,17 +335,21 @@ mod tests {
#[test]
fn remote_repository_creation_persists_typed_source_without_auth_metadata() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
let owner_account_id = owner_account(store.as_ref());
let service = WorkspaceCatalogService::new(store.clone());
let result = service
.create_first_ownerless(WorkspaceCreateRequest {
operation_key: "remote-create".to_string(),
display_name: "Remote Workspace".to_string(),
repository: InitialRepositoryIntent {
uri: "ssh://git@example.test/org/repository.git".to_string(),
display_name: Some("Remote Repository".to_string()),
default_ref: Some("main".to_string()),
.create(
WorkspaceCreateRequest {
operation_key: "remote-create".to_string(),
display_name: "Remote Workspace".to_string(),
repository: InitialRepositoryIntent {
uri: "ssh://git@example.test/org/repository.git".to_string(),
display_name: Some("Remote Repository".to_string()),
default_ref: Some("main".to_string()),
},
},
})
owner_account_id,
)
.unwrap();
let persisted = store
+3 -1
View File
@@ -264,6 +264,8 @@ in
"serve"
"--listen"
"0.0.0.0:8787"
"--config"
"/server-config/server.toml"
];
Env = [
"PATH=/bin"
@@ -274,7 +276,7 @@ in
};
Volumes = {
"/server-data" = { };
"/workspace" = { };
"/server-config" = { };
};
WorkingDir = "/server-data";
};
+5 -6
View File
@@ -58,19 +58,18 @@ The Compose files live at:
```text
compose.yaml
docker/workspace/.yoi/workspace.toml
docker/workspace/.yoi/workspace-backend.local.toml
```
The WebUI container serves static assets and proxies `/api` to the Backend Server. The Backend Server registers the Runtime container as a remote Runtime such as `docker-runtime`. The Runtime container runs `yoi-runtime` and owns Worker spawning/materialization for that runtime.
`YOI_BROWSER_PUBLIC_URL` is the single browser-facing deployment setting used by the Server for WebAuthn, device-login URLs, cookie policy, and cookie-authenticated mutation origin checks. Compose defaults it to `http://localhost:8080`; deployments exposed through another host, port, or HTTPS endpoint must set the exact Nginx-facing origin, for example:
The operator-owned `/etc/yoi/server.toml` is mounted read-only at `/server-config/server.toml`. Its `browser.public_url` is the single browser-facing setting used for WebAuthn, device-login URLs, cookie policy, and cookie-authenticated mutation origin checks:
```text
YOI_BROWSER_PUBLIC_URL=https://yoi.example.com docker compose up
```toml
[browser]
public_url = "https://yoi.example.com"
```
The value is an origin, not an API/backend URL, and must not include a path, query, or fragment.
Create this host file before starting Compose and set it to the exact Nginx-facing origin. It is not an API/backend URL and must not include a path, query, or fragment. It is deployment topology outside the source and Workspace repositories, not Workspace DB state or repository-local configuration.
Container user and writable data directories matter: runtime/server images must be able to write their configured data directories and named volumes. The current local-image Compose setup avoids an image-level `User` override and sets data-directory permissions accordingly.
+1 -5
View File
@@ -153,11 +153,7 @@ For repository builds:
cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787
```
If the Server DB has no workspace record yet, initialize it first:
```bash
yoi-server init --workspace <WORKSPACE_ROOT>
```
An empty Server DB is valid. Open the Web UI, create or authenticate the Account, and register the first Workspace through the normal Workspace creation flow. Server startup does not create a Workspace from its current working directory or repository-local configuration.
## Smoke checks
-61
View File
@@ -1,61 +0,0 @@
# Workspace Backend local config template.
#
# `yoi-server init` copies this packaged template to
# `.yoi/workspace-backend.local.toml` without overwriting an existing file.
# The `.local` file is intentionally git-ignored.
#
# Print the latest packaged template with:
# yoi-server config default
#
# Compare the local config with the latest packaged template with:
# yoi-server config diff
#
# Omit a key to use the built-in fallback. TOML has no `null`, so optional
# settings are represented by leaving the key commented out.
[server]
# Backend HTTP/WebSocket listen address.
listen = "127.0.0.1:8787"
# Static SPA build directory override. Leave commented for dev/API-only mode.
# Relative paths are resolved from the workspace root.
# static_assets_dir = "web/workspace/dist"
[data]
# Workspace-scoped runtime/data root override. Leave commented to use the user-data fallback:
# <data_dir>/server/workspaces/<workspace_id>/
# Relative paths are resolved from the workspace root.
# root = ".yoi/workspace-backend.data"
# Explicit control-plane SQLite DB path override. Normal `serve` uses the Yoi
# server DB at `<data_dir>/server/server.db`; keep this commented unless a
# local test needs a custom DB path.
# workspace_database_path = ".yoi/workspace-backend.data/server.db"
# Explicit embedded Runtime fs-store root override.
# If omitted, this falls back to `<data.root>/embedded-runtime`.
# embedded_runtime_store_root = ".yoi/workspace-backend.data/embedded-runtime"
[limits]
max_records = 200
[auth]
# Vite owns this origin in local development. Set YOI_BROWSER_PUBLIC_URL to the
# browser-facing Nginx origin in deployments. The Server derives the WebAuthn
# origin, RP ID, device-login URL, cookie policy, and CSRF check from this URL.
browser_public_url = "http://localhost:5173"
cookie_name = "yoi_workspace_session"
# Repository registry. Browser/API repository projection reads only configured
# entries and never falls back to the backend process cwd. Relative URI values
# are resolved from this workspace config root. Git is the v0 supported provider.
#
# [[repositories]]
# id = "main"
# provider = "git"
# uri = "."
# display_name = "Main repository"
# default_selector = "HEAD"
# Runtime registrations live in `$XDG_CONFIG_HOME/yoi/runtimes.toml`
# or `YOI_CONFIG_DIR/runtimes.toml`, not in workspace backend config.