mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* feat(workspace): multi-scope workspace reads Adds the ability for a workspace to read from multiple user scopes while keeping writes isolated to the primary scope. Configuration via WORKSPACE_READ_SCOPES env var (comma-separated user IDs). Includes identity file isolation (read_primary), multi-scope search, list, and read operations, WorkspaceConfig refactor, and comprehensive integration tests. * fix: address review feedback for multi-scope workspace reads - fix(memory): deduplicate timezone parsing for daily_log target parse_timezone was called twice when target was "daily_log" without a layer — once in path resolution, again in the fallback. Now computed once and reused. - fix(config): add character validation for WORKSPACE_READ_SCOPES and layer scopes — both enforce [a-zA-Z0-9_-] to prevent path traversal or injection via scope strings used as user_id in SQL queries. - fix(config): use chars().take(32) instead of byte-index slicing for scope length error messages (UTF-8 safety). - fix(error): remove unused WorkspaceError::NotFound variant Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * style: downgrade search log to debug, add comments on list iteration - Downgrade hybrid_search_multi tracing::info! to debug! — fires on every multi-scope search with the default backend, too noisy for info - Add comments explaining why list/list_all iterate per-scope instead of using _multi trait methods (identity path filtering needs scope attribution that merged results lose) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: [email protected] <[email protected]> Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
267 lines
9.9 KiB
Rust
267 lines
9.9 KiB
Rust
use crate::config::helpers::optional_env;
|
|
use crate::error::ConfigError;
|
|
use crate::workspace::layer::MemoryLayer;
|
|
|
|
/// Workspace-level configuration (memory layers, read scopes).
|
|
///
|
|
/// Parsed from environment variables. Lives outside of `GatewayConfig`
|
|
/// so that non-gateway channels can eventually use the same settings.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct WorkspaceConfig {
|
|
/// Memory layer definitions (JSON in `MEMORY_LAYERS` env var, or defaults).
|
|
pub memory_layers: Vec<MemoryLayer>,
|
|
/// Additional user scopes for workspace reads.
|
|
///
|
|
/// When set, the workspace can read (search, read, list) from these
|
|
/// additional user scopes while writes remain isolated to the primary
|
|
/// `user_id`. Parsed from `WORKSPACE_READ_SCOPES` (comma-separated).
|
|
pub read_scopes: Vec<String>,
|
|
}
|
|
|
|
impl WorkspaceConfig {
|
|
/// Resolve workspace config from environment variables.
|
|
///
|
|
/// `user_id` is used to derive default memory layers when `MEMORY_LAYERS`
|
|
/// is not set.
|
|
pub fn resolve(user_id: &str) -> Result<Self, ConfigError> {
|
|
// --- Memory layers ---
|
|
let memory_layers: Vec<MemoryLayer> = match optional_env("MEMORY_LAYERS")? {
|
|
Some(json_str) => {
|
|
serde_json::from_str(&json_str).map_err(|e| ConfigError::InvalidValue {
|
|
key: "MEMORY_LAYERS".to_string(),
|
|
message: format!("must be valid JSON array of layer objects: {e}"),
|
|
})?
|
|
}
|
|
None => MemoryLayer::default_for_user(user_id),
|
|
};
|
|
|
|
// Validate layer names and scopes
|
|
for layer in &memory_layers {
|
|
if layer.name.trim().is_empty() {
|
|
return Err(ConfigError::InvalidValue {
|
|
key: "MEMORY_LAYERS".to_string(),
|
|
message: "layer name must not be empty".to_string(),
|
|
});
|
|
}
|
|
if layer.name.len() > 64 {
|
|
return Err(ConfigError::InvalidValue {
|
|
key: "MEMORY_LAYERS".to_string(),
|
|
message: format!("layer name '{}' exceeds 64 characters", layer.name),
|
|
});
|
|
}
|
|
if !layer
|
|
.name
|
|
.chars()
|
|
.all(|c| c.is_alphanumeric() || c == '_' || c == '-')
|
|
{
|
|
return Err(ConfigError::InvalidValue {
|
|
key: "MEMORY_LAYERS".to_string(),
|
|
message: format!(
|
|
"layer name '{}' contains invalid characters (only alphanumeric, _, - allowed)",
|
|
layer.name
|
|
),
|
|
});
|
|
}
|
|
if layer.scope.trim().is_empty() {
|
|
return Err(ConfigError::InvalidValue {
|
|
key: "MEMORY_LAYERS".to_string(),
|
|
message: format!("layer '{}' has an empty scope", layer.name),
|
|
});
|
|
}
|
|
if !layer
|
|
.scope
|
|
.chars()
|
|
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
|
{
|
|
return Err(ConfigError::InvalidValue {
|
|
key: "MEMORY_LAYERS".to_string(),
|
|
message: format!(
|
|
"layer '{}' scope '{}' contains invalid characters \
|
|
(allowed: a-z, A-Z, 0-9, _, -)",
|
|
layer.name, layer.scope
|
|
),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Check for duplicate layer names
|
|
{
|
|
let mut seen = std::collections::HashSet::new();
|
|
for layer in &memory_layers {
|
|
if !seen.insert(&layer.name) {
|
|
return Err(ConfigError::InvalidValue {
|
|
key: "MEMORY_LAYERS".to_string(),
|
|
message: format!("duplicate layer name '{}'", layer.name),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Read scopes ---
|
|
let read_scopes: Vec<String> = optional_env("WORKSPACE_READ_SCOPES")?
|
|
.map(|s| {
|
|
s.split(',')
|
|
.map(|s| s.trim().to_string())
|
|
.filter(|s| !s.is_empty())
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
for scope in &read_scopes {
|
|
if scope.len() > 128 {
|
|
let prefix: String = scope.chars().take(32).collect();
|
|
return Err(ConfigError::InvalidValue {
|
|
key: "WORKSPACE_READ_SCOPES".to_string(),
|
|
message: format!("scope '{prefix}...' exceeds 128 characters"),
|
|
});
|
|
}
|
|
if !scope
|
|
.chars()
|
|
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
|
{
|
|
return Err(ConfigError::InvalidValue {
|
|
key: "WORKSPACE_READ_SCOPES".to_string(),
|
|
message: format!(
|
|
"scope '{}' contains invalid characters \
|
|
(allowed: a-z, A-Z, 0-9, _, -)",
|
|
scope
|
|
),
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(Self {
|
|
memory_layers,
|
|
read_scopes,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::config::helpers::lock_env;
|
|
|
|
fn with_env(key: &str, val: Option<&str>, f: impl FnOnce()) {
|
|
let _guard = lock_env();
|
|
let prev = std::env::var(key).ok();
|
|
match val {
|
|
Some(v) => unsafe { std::env::set_var(key, v) },
|
|
None => unsafe { std::env::remove_var(key) },
|
|
}
|
|
f();
|
|
match prev {
|
|
Some(v) => unsafe { std::env::set_var(key, v) },
|
|
None => unsafe { std::env::remove_var(key) },
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn valid_json_parses_correctly() {
|
|
let json = r#"[{"name":"private","scope":"alice","writable":true,"sensitivity":"private"},{"name":"shared","scope":"shared","writable":true,"sensitivity":"shared"}]"#;
|
|
with_env("MEMORY_LAYERS", Some(json), || {
|
|
let config = WorkspaceConfig::resolve("alice").expect("should parse");
|
|
assert_eq!(config.memory_layers.len(), 2);
|
|
assert_eq!(config.memory_layers[0].name, "private");
|
|
assert_eq!(config.memory_layers[1].name, "shared");
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_json_returns_error() {
|
|
with_env("MEMORY_LAYERS", Some("not json"), || {
|
|
let result = WorkspaceConfig::resolve("alice");
|
|
assert!(result.is_err(), "invalid JSON should fail");
|
|
let err = result.unwrap_err().to_string();
|
|
assert!(
|
|
err.contains("valid JSON"),
|
|
"error should mention JSON: {err}"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn empty_layer_name_returns_error() {
|
|
let json = r#"[{"name":"","scope":"alice"}]"#;
|
|
with_env("MEMORY_LAYERS", Some(json), || {
|
|
let result = WorkspaceConfig::resolve("alice");
|
|
assert!(result.is_err(), "empty layer name should fail");
|
|
let err = result.unwrap_err().to_string();
|
|
assert!(err.contains("empty"), "error should mention empty: {err}");
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn layer_name_exceeding_64_chars_returns_error() {
|
|
let long_name = "a".repeat(65);
|
|
let json = format!(r#"[{{"name":"{long_name}","scope":"alice"}}]"#);
|
|
with_env("MEMORY_LAYERS", Some(&json), || {
|
|
let result = WorkspaceConfig::resolve("alice");
|
|
assert!(result.is_err(), "long layer name should fail");
|
|
let err = result.unwrap_err().to_string();
|
|
assert!(
|
|
err.contains("exceeds 64"),
|
|
"error should mention 64 chars: {err}"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn layer_name_with_invalid_chars_returns_error() {
|
|
for bad_name in ["has space", "has@at", "has.dot", "has/slash"] {
|
|
let json = format!(r#"[{{"name":"{bad_name}","scope":"alice"}}]"#);
|
|
with_env("MEMORY_LAYERS", Some(&json), || {
|
|
let result = WorkspaceConfig::resolve("alice");
|
|
assert!(
|
|
result.is_err(),
|
|
"layer name '{bad_name}' should fail validation"
|
|
);
|
|
let err = result.unwrap_err().to_string();
|
|
assert!(
|
|
err.contains("invalid characters"),
|
|
"error for '{bad_name}' should mention invalid characters: {err}"
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn empty_scope_returns_error() {
|
|
let json = r#"[{"name":"private","scope":""}]"#;
|
|
with_env("MEMORY_LAYERS", Some(json), || {
|
|
let result = WorkspaceConfig::resolve("alice");
|
|
assert!(result.is_err(), "empty scope should fail");
|
|
let err = result.unwrap_err().to_string();
|
|
assert!(
|
|
err.contains("empty scope"),
|
|
"error should mention empty scope: {err}"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_layer_names_returns_error() {
|
|
let json = r#"[{"name":"private","scope":"alice"},{"name":"private","scope":"bob"}]"#;
|
|
with_env("MEMORY_LAYERS", Some(json), || {
|
|
let result = WorkspaceConfig::resolve("alice");
|
|
assert!(result.is_err(), "duplicate names should fail");
|
|
let err = result.unwrap_err().to_string();
|
|
assert!(
|
|
err.contains("duplicate"),
|
|
"error should mention duplicate: {err}"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn missing_env_defaults_to_single_private_layer() {
|
|
with_env("MEMORY_LAYERS", None, || {
|
|
let config = WorkspaceConfig::resolve("alice").expect("should default");
|
|
assert_eq!(config.memory_layers.len(), 1);
|
|
assert_eq!(config.memory_layers[0].name, "private");
|
|
assert_eq!(config.memory_layers[0].scope, "alice");
|
|
assert!(config.memory_layers[0].writable);
|
|
});
|
|
}
|
|
}
|