feat(workspace): add registry storage and lifecycle CLI

This commit is contained in:
argenis de la rosa 2026-03-05 02:50:21 -05:00 committed by Argenis
parent 1caf1a07c7
commit 69232d0eaa
6 changed files with 674 additions and 1 deletions

View File

@ -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<T: traits::ChannelConfig>(channel: Option<&T>) -> (&'static str, bool) {

View File

@ -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<String>,
}
/// 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 `<config_dir>/workspaces`.
#[serde(default)]
pub root: Option<String>,
}
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")
);
}
}

View File

@ -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;

View File

@ -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 <workspace-id>
zeroclaw workspace token rotate <workspace-id>
zeroclaw workspace delete <workspace-id> --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<String>,
},
/// 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:?}"),
}
}
}

View File

@ -152,6 +152,7 @@ pub async fn run_wizard(force: bool) -> Result<Config> {
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(),

390
src/workspace/mod.rs Normal file
View File

@ -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 `<root>/*/workspace.toml`.
#[derive(Debug, Clone)]
pub struct WorkspaceRegistry {
root: PathBuf,
workspaces: HashMap<String, WorkspaceRecord>,
token_index: HashMap<String, String>,
}
impl WorkspaceRegistry {
/// Load registry state from workspace metadata files under `root`.
pub fn load(root: &Path) -> Result<Self> {
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<WorkspaceSummary> {
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<WorkspaceHandle> {
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<String> {
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<PathBuf> {
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<String> {
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());
}
}