diff --git a/src/config/mod.rs b/src/config/mod.rs index b733ad042..3ebacbb18 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -20,7 +20,7 @@ pub use schema::{ SlackConfig, StorageConfig, StorageProviderConfig, StorageProviderSection, StreamMode, SyscallAnomalyConfig, TelegramConfig, TranscriptionConfig, TunnelConfig, WasmCapabilityEscalationMode, WasmModuleHashPolicy, WasmRuntimeConfig, WasmSecurityConfig, - WebFetchConfig, WebSearchConfig, WebhookConfig, + WebFetchConfig, WebSearchConfig, WebhookConfig, WorkspacesConfig, }; pub fn name_and_presence(channel: Option<&T>) -> (&'static str, bool) { diff --git a/src/config/schema.rs b/src/config/schema.rs index 512f68136..4e1b73e98 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -148,6 +148,10 @@ pub struct Config { #[serde(default)] pub agent: AgentConfig, + /// Multi-workspace routing and registry settings (`[workspaces]`). + #[serde(default)] + pub workspaces: WorkspacesConfig, + /// Skills loading and community repository behavior (`[skills]`). #[serde(default)] pub skills: SkillsConfig, @@ -298,6 +302,50 @@ pub struct ProviderConfig { pub reasoning_level: Option, } +/// Multi-workspace registry configuration (`[workspaces]`). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct WorkspacesConfig { + /// Enables in-process workspace registry behavior. + #[serde(default)] + pub enabled: bool, + /// Optional workspace registry root override. + /// If omitted, defaults to `/workspaces`. + #[serde(default)] + pub root: Option, +} + +impl WorkspacesConfig { + /// Resolve the workspace registry root from config and runtime context. + pub fn resolve_root(&self, config_dir: &Path) -> PathBuf { + match self + .root + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + Some(value) => { + let expanded = shellexpand::tilde(value).into_owned(); + let path = PathBuf::from(expanded); + if path.is_absolute() { + path + } else { + config_dir.join(path) + } + } + None => config_dir.join("workspaces"), + } + } +} + +impl Default for WorkspacesConfig { + fn default() -> Self { + Self { + enabled: false, + root: None, + } + } +} + // ── Delegate Agents ────────────────────────────────────────────── /// Configuration for a delegate sub-agent used by the `delegate` tool. @@ -4677,6 +4725,7 @@ impl Default for Config { reliability: ReliabilityConfig::default(), scheduler: SchedulerConfig::default(), agent: AgentConfig::default(), + workspaces: WorkspacesConfig::default(), skills: SkillsConfig::default(), model_routes: Vec::new(), embedding_routes: Vec::new(), @@ -6994,6 +7043,7 @@ default_temperature = 0.7 scheduler: SchedulerConfig::default(), coordination: CoordinationConfig::default(), skills: SkillsConfig::default(), + workspaces: WorkspacesConfig::default(), model_routes: Vec::new(), embedding_routes: Vec::new(), query_classification: QueryClassificationConfig::default(), @@ -7400,6 +7450,7 @@ tool_dispatcher = "xml" scheduler: SchedulerConfig::default(), coordination: CoordinationConfig::default(), skills: SkillsConfig::default(), + workspaces: WorkspacesConfig::default(), model_routes: Vec::new(), embedding_routes: Vec::new(), query_classification: QueryClassificationConfig::default(), @@ -10444,4 +10495,30 @@ baseline_syscalls = ["read", "write", "openat", "close"] .validate() .expect("disabled coordination should allow empty lead agent"); } + + #[test] + async fn workspaces_config_defaults_disabled() { + let config = Config::default(); + assert!(!config.workspaces.enabled); + assert!(config.workspaces.root.is_none()); + } + + #[test] + async fn workspaces_config_resolve_root_default_and_relative_override() { + let config_dir = std::path::PathBuf::from("/tmp/zeroclaw-config-root"); + let default_cfg = WorkspacesConfig::default(); + assert_eq!( + default_cfg.resolve_root(&config_dir), + config_dir.join("workspaces") + ); + + let relative_cfg = WorkspacesConfig { + enabled: true, + root: Some("profiles".into()), + }; + assert_eq!( + relative_cfg.resolve_root(&config_dir), + config_dir.join("profiles") + ); + } } diff --git a/src/lib.rs b/src/lib.rs index 42992a1ac..1ae8f64f4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -74,6 +74,7 @@ pub mod tools; pub(crate) mod tunnel; pub mod update; pub(crate) mod util; +pub mod workspace; pub use config::Config; diff --git a/src/main.rs b/src/main.rs index d1eda4087..9fb5a3f51 100644 --- a/src/main.rs +++ b/src/main.rs @@ -86,6 +86,7 @@ mod tools; mod tunnel; mod update; mod util; +mod workspace; use config::Config; @@ -513,6 +514,24 @@ Examples: config_command: ConfigCommands, }, + /// Manage in-process workspace registry entries + #[command(long_about = "\ +Manage workspace registry entries for multi-workspace deployments. + +The workspace registry is opt-in and controlled by `[workspaces].enabled = true` +in your config.toml. Commands operate on the local filesystem registry root. + +Examples: + zeroclaw workspace create --name team-a + zeroclaw workspace list + zeroclaw workspace disable + zeroclaw workspace token rotate + zeroclaw workspace delete --confirm")] + Workspace { + #[command(subcommand)] + workspace_command: WorkspaceCommands, + }, + /// Generate shell completion script to stdout #[command(long_about = "\ Generate shell completion scripts for `zeroclaw`. @@ -536,6 +555,45 @@ enum ConfigCommands { Schema, } +#[derive(Subcommand, Debug)] +enum WorkspaceCommands { + /// Create a workspace and print its bearer token once + Create { + /// Optional display name shown in `workspace list` + #[arg(long)] + name: Option, + }, + /// List registered workspaces + List, + /// Disable an existing workspace (data preserved) + Disable { + /// Workspace UUID + workspace_id: String, + }, + /// Delete workspace data recursively (requires --confirm) + Delete { + /// Workspace UUID + workspace_id: String, + /// Acknowledge destructive deletion + #[arg(long)] + confirm: bool, + }, + /// Manage workspace bearer tokens + Token { + #[command(subcommand)] + token_command: WorkspaceTokenCommands, + }, +} + +#[derive(Subcommand, Debug)] +enum WorkspaceTokenCommands { + /// Rotate bearer token for a workspace and print new token once + Rotate { + /// Workspace UUID + workspace_id: String, + }, +} + #[derive(Subcommand, Debug)] enum EstopSubcommands { /// Print current estop status. @@ -1168,6 +1226,90 @@ async fn main() -> Result<()> { Ok(()) } }, + + Commands::Workspace { workspace_command } => { + handle_workspace_command(workspace_command, &config) + } + } +} + +fn handle_workspace_command(workspace_command: WorkspaceCommands, config: &Config) -> Result<()> { + if !config.workspaces.enabled { + bail!( + "workspace registry is disabled. Enable [workspaces].enabled = true in {}", + config.config_path.display() + ); + } + + let root = workspace::registry_root_from_config(config)?; + let mut registry = workspace::WorkspaceRegistry::load(&root)?; + + match workspace_command { + WorkspaceCommands::Create { name } => { + let display_name = name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| format!("workspace-{}", uuid::Uuid::new_v4())); + let (workspace_id, token) = registry.create(&display_name)?; + println!("Workspace created."); + println!(" id: {workspace_id}"); + println!(" display_name: {display_name}"); + println!(" root: {}", registry.root().display()); + println!(" token: {token}"); + println!("Store this token securely; it is shown only once."); + Ok(()) + } + WorkspaceCommands::List => { + let workspaces = registry.list(); + if workspaces.is_empty() { + println!("No workspaces found under {}", registry.root().display()); + return Ok(()); + } + + println!("Workspace root: {}", registry.root().display()); + println!( + "{:<36} {:<7} {:<20} {}", + "ID", "ENABLED", "CREATED_AT", "DISPLAY_NAME" + ); + for workspace in workspaces { + println!( + "{:<36} {:<7} {:<20} {}", + workspace.id, workspace.enabled, workspace.created_at, workspace.display_name + ); + } + Ok(()) + } + WorkspaceCommands::Disable { workspace_id } => { + registry.disable(&workspace_id)?; + println!("Workspace disabled: {workspace_id}"); + Ok(()) + } + WorkspaceCommands::Delete { + workspace_id, + confirm, + } => { + if !confirm { + bail!( + "workspace delete is destructive. Re-run with --confirm to delete {}", + workspace_id + ); + } + registry.delete(&workspace_id)?; + println!("Workspace deleted: {workspace_id}"); + Ok(()) + } + WorkspaceCommands::Token { token_command } => match token_command { + WorkspaceTokenCommands::Rotate { workspace_id } => { + let token = registry.rotate_token(&workspace_id)?; + println!("Workspace token rotated."); + println!(" id: {workspace_id}"); + println!(" token: {token}"); + println!("Store this token securely; it is shown only once."); + Ok(()) + } + }, } } @@ -2170,4 +2312,65 @@ mod tests { other => panic!("expected estop resume command, got {other:?}"), } } + + #[test] + fn workspace_cli_parses_create_with_name() { + let cli = Cli::try_parse_from(["zeroclaw", "workspace", "create", "--name", "team-a"]) + .expect("workspace create should parse"); + + match cli.command { + Commands::Workspace { + workspace_command: WorkspaceCommands::Create { name }, + } => assert_eq!(name.as_deref(), Some("team-a")), + other => panic!("expected workspace create command, got {other:?}"), + } + } + + #[test] + fn workspace_cli_parses_token_rotate() { + let cli = Cli::try_parse_from([ + "zeroclaw", + "workspace", + "token", + "rotate", + "550e8400-e29b-41d4-a716-446655440000", + ]) + .expect("workspace token rotate should parse"); + + match cli.command { + Commands::Workspace { + workspace_command: + WorkspaceCommands::Token { + token_command: WorkspaceTokenCommands::Rotate { workspace_id }, + }, + } => assert_eq!(workspace_id, "550e8400-e29b-41d4-a716-446655440000"), + other => panic!("expected workspace token rotate command, got {other:?}"), + } + } + + #[test] + fn workspace_cli_delete_requires_explicit_confirm_flag_value() { + let cli = Cli::try_parse_from([ + "zeroclaw", + "workspace", + "delete", + "550e8400-e29b-41d4-a716-446655440000", + "--confirm", + ]) + .expect("workspace delete should parse"); + + match cli.command { + Commands::Workspace { + workspace_command: + WorkspaceCommands::Delete { + workspace_id, + confirm, + }, + } => { + assert_eq!(workspace_id, "550e8400-e29b-41d4-a716-446655440000"); + assert!(confirm); + } + other => panic!("expected workspace delete command, got {other:?}"), + } + } } diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index 7e883fc86..b4e00b0ab 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -152,6 +152,7 @@ pub async fn run_wizard(force: bool) -> Result { scheduler: crate::config::schema::SchedulerConfig::default(), coordination: crate::config::CoordinationConfig::default(), agent: crate::config::schema::AgentConfig::default(), + workspaces: crate::config::WorkspacesConfig::default(), skills: crate::config::SkillsConfig::default(), model_routes: Vec::new(), embedding_routes: Vec::new(), @@ -510,6 +511,7 @@ async fn run_quick_setup_with_home( scheduler: crate::config::schema::SchedulerConfig::default(), coordination: crate::config::CoordinationConfig::default(), agent: crate::config::schema::AgentConfig::default(), + workspaces: crate::config::WorkspacesConfig::default(), skills: crate::config::SkillsConfig::default(), model_routes: Vec::new(), embedding_routes: Vec::new(), diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs new file mode 100644 index 000000000..f0ba9734d --- /dev/null +++ b/src/workspace/mod.rs @@ -0,0 +1,390 @@ +//! Workspace registry and lifecycle management. +//! +//! PR1 scope: local filesystem registry + CLI lifecycle commands. +//! Gateway/agent routing integration is intentionally out of scope here. + +use anyhow::{bail, Context, Result}; +use chrono::{SecondsFormat, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use uuid::Uuid; + +const WORKSPACE_METADATA_FILE: &str = "workspace.toml"; +const WORKSPACE_CONFIG_FILE: &str = "config.toml"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct WorkspaceMetadata { + id: String, + display_name: String, + enabled: bool, + created_at: String, + token_hash: String, +} + +#[derive(Debug, Clone)] +struct WorkspaceRecord { + metadata: WorkspaceMetadata, + path: PathBuf, +} + +/// Read-only summary for CLI table output. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceSummary { + pub id: String, + pub display_name: String, + pub enabled: bool, + pub created_at: String, +} + +/// Resolved workspace identity for token lookup. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceHandle { + pub id: String, + pub display_name: String, + pub path: PathBuf, +} + +/// In-process workspace registry loaded from `/*/workspace.toml`. +#[derive(Debug, Clone)] +pub struct WorkspaceRegistry { + root: PathBuf, + workspaces: HashMap, + token_index: HashMap, +} + +impl WorkspaceRegistry { + /// Load registry state from workspace metadata files under `root`. + pub fn load(root: &Path) -> Result { + fs::create_dir_all(root) + .with_context(|| format!("failed to create workspace root {}", root.display()))?; + + let mut registry = Self { + root: root.to_path_buf(), + workspaces: HashMap::new(), + token_index: HashMap::new(), + }; + + let mut workspace_dirs = Vec::new(); + for entry in fs::read_dir(root) + .with_context(|| format!("failed to read workspace root {}", root.display()))? + { + let entry = entry?; + if entry.file_type()?.is_dir() { + workspace_dirs.push(entry.path()); + } + } + workspace_dirs.sort(); + + for workspace_dir in workspace_dirs { + let metadata_path = workspace_dir.join(WORKSPACE_METADATA_FILE); + if !metadata_path.exists() { + continue; + } + + let raw = fs::read_to_string(&metadata_path) + .with_context(|| format!("failed to read {}", metadata_path.display()))?; + let mut metadata: WorkspaceMetadata = toml::from_str(&raw) + .with_context(|| format!("failed to parse {}", metadata_path.display()))?; + + let normalized_id = normalize_workspace_id(&metadata.id)?; + metadata.id = normalized_id.clone(); + + if metadata.token_hash.trim().is_empty() { + bail!( + "workspace {} has empty token_hash in {}", + metadata.id, + metadata_path.display() + ); + } + + if registry.workspaces.contains_key(&normalized_id) { + bail!( + "duplicate workspace id {normalized_id} in {}", + root.display() + ); + } + + if metadata.enabled { + if registry + .token_index + .insert(metadata.token_hash.clone(), normalized_id.clone()) + .is_some() + { + bail!( + "duplicate enabled token hash detected while loading {}", + metadata_path.display() + ); + } + } + + registry.workspaces.insert( + normalized_id, + WorkspaceRecord { + metadata, + path: workspace_dir, + }, + ); + } + + Ok(registry) + } + + pub fn root(&self) -> &Path { + &self.root + } + + pub fn list(&self) -> Vec { + let mut summaries: Vec<_> = self + .workspaces + .values() + .map(|record| WorkspaceSummary { + id: record.metadata.id.clone(), + display_name: record.metadata.display_name.clone(), + enabled: record.metadata.enabled, + created_at: record.metadata.created_at.clone(), + }) + .collect(); + summaries.sort_by(|a, b| { + a.created_at + .cmp(&b.created_at) + .then_with(|| a.id.cmp(&b.id)) + }); + summaries + } + + /// Resolve a bearer token to a workspace identity. + pub fn resolve(&self, raw_token: &str) -> Option { + let token = raw_token.trim(); + if token.is_empty() { + return None; + } + let token_hash = hash_token(token); + let workspace_id = self.token_index.get(&token_hash)?; + let record = self.workspaces.get(workspace_id)?; + if !record.metadata.enabled { + return None; + } + Some(WorkspaceHandle { + id: record.metadata.id.clone(), + display_name: record.metadata.display_name.clone(), + path: record.path.clone(), + }) + } + + /// Create a new workspace and return `(workspace_id, plaintext_token)`. + pub fn create(&mut self, display_name: &str) -> Result<(String, String)> { + let display_name = display_name.trim(); + if display_name.is_empty() { + bail!("workspace display name cannot be empty"); + } + + let workspace_id = Uuid::new_v4().to_string(); + let token = generate_token(); + let token_hash = hash_token(&token); + let created_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); + let workspace_dir = self.root.join(&workspace_id); + + fs::create_dir_all(workspace_dir.join("memory")) + .with_context(|| format!("failed to create {}", workspace_dir.display()))?; + fs::create_dir_all(workspace_dir.join("identity")) + .with_context(|| format!("failed to create {}", workspace_dir.display()))?; + fs::create_dir_all(workspace_dir.join("channels")) + .with_context(|| format!("failed to create {}", workspace_dir.display()))?; + fs::write( + workspace_dir.join(WORKSPACE_CONFIG_FILE), + default_workspace_config(), + ) + .with_context(|| format!("failed to write {}", workspace_dir.display()))?; + + let metadata = WorkspaceMetadata { + id: workspace_id.clone(), + display_name: display_name.to_string(), + enabled: true, + created_at, + token_hash: token_hash.clone(), + }; + write_workspace_metadata(&workspace_dir, &metadata)?; + + self.token_index.insert(token_hash, workspace_id.clone()); + self.workspaces.insert( + workspace_id.clone(), + WorkspaceRecord { + metadata, + path: workspace_dir, + }, + ); + + Ok((workspace_id, token)) + } + + /// Disable a workspace while preserving its on-disk data. + pub fn disable(&mut self, workspace_id: &str) -> Result<()> { + let workspace_id = normalize_workspace_id(workspace_id)?; + let record = self + .workspaces + .get_mut(&workspace_id) + .with_context(|| format!("workspace {} not found", workspace_id))?; + + if !record.metadata.enabled { + return Ok(()); + } + + record.metadata.enabled = false; + write_workspace_metadata(&record.path, &record.metadata)?; + self.token_index.retain(|_, id| id != &workspace_id); + Ok(()) + } + + /// Rotate workspace token and return the new plaintext token once. + pub fn rotate_token(&mut self, workspace_id: &str) -> Result { + let workspace_id = normalize_workspace_id(workspace_id)?; + let record = self + .workspaces + .get_mut(&workspace_id) + .with_context(|| format!("workspace {} not found", workspace_id))?; + + let token = generate_token(); + let token_hash = hash_token(&token); + record.metadata.token_hash = token_hash.clone(); + write_workspace_metadata(&record.path, &record.metadata)?; + + self.token_index.retain(|_, id| id != &workspace_id); + if record.metadata.enabled { + self.token_index.insert(token_hash, workspace_id); + } + + Ok(token) + } + + /// Delete workspace data recursively. + pub fn delete(&mut self, workspace_id: &str) -> Result<()> { + let workspace_id = normalize_workspace_id(workspace_id)?; + let record = self + .workspaces + .remove(&workspace_id) + .with_context(|| format!("workspace {} not found", workspace_id))?; + + self.token_index.retain(|_, id| id != &workspace_id); + + let root_canon = self + .root + .canonicalize() + .unwrap_or_else(|_| self.root.clone()); + let workspace_canon = record + .path + .canonicalize() + .unwrap_or_else(|_| record.path.clone()); + if !workspace_canon.starts_with(&root_canon) { + bail!( + "refusing to delete workspace outside root: {}", + record.path.display() + ); + } + + fs::remove_dir_all(&record.path) + .with_context(|| format!("failed to delete workspace {}", record.path.display()))?; + Ok(()) + } +} + +/// Resolve workspace registry root from runtime config. +pub fn registry_root_from_config(config: &crate::config::Config) -> Result { + let config_dir = config + .config_path + .parent() + .context("config path must have a parent directory")?; + Ok(config.workspaces.resolve_root(config_dir)) +} + +fn default_workspace_config() -> &'static str { + "# Workspace-scoped config overrides.\n" +} + +fn write_workspace_metadata(workspace_dir: &Path, metadata: &WorkspaceMetadata) -> Result<()> { + let metadata_path = workspace_dir.join(WORKSPACE_METADATA_FILE); + let serialized = + toml::to_string(metadata).context("failed to serialize workspace metadata to TOML")?; + fs::write(&metadata_path, serialized) + .with_context(|| format!("failed to write {}", metadata_path.display()))?; + Ok(()) +} + +fn normalize_workspace_id(workspace_id: &str) -> Result { + let parsed = Uuid::parse_str(workspace_id.trim()).context( + "workspace_id must be a valid UUID (for example 550e8400-e29b-41d4-a716-446655440000)", + )?; + Ok(parsed.to_string()) +} + +fn generate_token() -> String { + let bytes: [u8; 32] = rand::random(); + format!("zc_ws_{}", hex::encode(bytes)) +} + +fn hash_token(token: &str) -> String { + format!("sha256:{:x}", Sha256::digest(token.as_bytes())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn workspace_registry_load_empty_dir() { + let tmp = tempfile::TempDir::new().expect("tempdir should be created"); + let registry = WorkspaceRegistry::load(tmp.path()).expect("registry should load"); + assert!(registry.list().is_empty()); + } + + #[test] + fn workspace_registry_resolve_valid_token() { + let tmp = tempfile::TempDir::new().expect("tempdir should be created"); + let mut registry = WorkspaceRegistry::load(tmp.path()).expect("registry should load"); + + let (id, token) = registry + .create("workspace-a") + .expect("workspace should be created"); + let resolved = registry.resolve(&token).expect("token should resolve"); + assert_eq!(resolved.id, id); + } + + #[test] + fn workspace_registry_resolve_unknown_token() { + let tmp = tempfile::TempDir::new().expect("tempdir should be created"); + let mut registry = WorkspaceRegistry::load(tmp.path()).expect("registry should load"); + registry + .create("workspace-a") + .expect("workspace should be created"); + assert!(registry.resolve("zc_ws_unknown").is_none()); + } + + #[test] + fn workspace_registry_token_hash_not_plain() { + let tmp = tempfile::TempDir::new().expect("tempdir should be created"); + let mut registry = WorkspaceRegistry::load(tmp.path()).expect("registry should load"); + let (workspace_id, token) = registry + .create("workspace-a") + .expect("workspace should be created"); + + let metadata_path = tmp.path().join(workspace_id).join(WORKSPACE_METADATA_FILE); + let metadata_raw = fs::read_to_string(&metadata_path).expect("metadata file should exist"); + let metadata: WorkspaceMetadata = + toml::from_str(&metadata_raw).expect("metadata should parse"); + + assert_ne!(metadata.token_hash, token); + assert!(metadata.token_hash.starts_with("sha256:")); + } + + #[test] + fn workspace_id_path_traversal_rejected() { + let tmp = tempfile::TempDir::new().expect("tempdir should be created"); + let mut registry = WorkspaceRegistry::load(tmp.path()).expect("registry should load"); + + assert!(registry.disable("../evil").is_err()); + assert!(registry.rotate_token("../evil").is_err()); + assert!(registry.delete("../evil").is_err()); + } +}