mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 23:10:11 +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]>
452 lines
14 KiB
Rust
452 lines
14 KiB
Rust
#![cfg(feature = "libsql")]
|
|
//! Integration tests for multi-scope workspace reads using file-backed libSQL.
|
|
//!
|
|
//! Guards the PR2 contract: workspaces can read from multiple user scopes
|
|
//! while writes remain isolated to the primary scope.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use ironclaw::db::Database;
|
|
use ironclaw::db::libsql::LibSqlBackend;
|
|
use ironclaw::workspace::Workspace;
|
|
|
|
async fn setup() -> (Arc<dyn Database>, tempfile::TempDir) {
|
|
let dir = tempfile::tempdir().expect("create temp dir");
|
|
let db_path = dir.path().join("test.db");
|
|
let backend = LibSqlBackend::new_local(&db_path).await.expect("create db");
|
|
backend.run_migrations().await.expect("run migrations");
|
|
let db: Arc<dyn Database> = Arc::new(backend);
|
|
(db, dir)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn read_across_scopes() {
|
|
let (db, _dir) = setup().await;
|
|
|
|
// Write docs as the "shared" user
|
|
let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
|
|
ws_shared
|
|
.write("docs/team-standup.md", "Team standup notes from Monday")
|
|
.await
|
|
.expect("shared write failed");
|
|
|
|
// Alice's workspace with "shared" as an additional read scope
|
|
let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
|
|
.with_additional_read_scopes(vec!["shared".to_string()]);
|
|
|
|
// Alice can read shared docs
|
|
let doc = ws_alice
|
|
.read("docs/team-standup.md")
|
|
.await
|
|
.expect("cross-scope read failed");
|
|
assert_eq!(doc.content, "Team standup notes from Monday");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn write_stays_in_primary_scope() {
|
|
let (db, _dir) = setup().await;
|
|
|
|
// Alice has "shared" as a read scope
|
|
let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
|
|
.with_additional_read_scopes(vec!["shared".to_string()]);
|
|
|
|
// Alice writes a personal note
|
|
ws_alice
|
|
.write("notes/personal.md", "Alice's private note")
|
|
.await
|
|
.expect("alice write failed");
|
|
|
|
// The "shared" workspace should NOT see Alice's note
|
|
let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
|
|
let result = ws_shared.read("notes/personal.md").await;
|
|
assert!(result.is_err(), "Shared scope should not see Alice's note");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_paths_merges_across_scopes() {
|
|
let (db, _dir) = setup().await;
|
|
|
|
// Write as alice
|
|
let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db));
|
|
ws_alice_plain
|
|
.write("notes/personal.md", "My notes")
|
|
.await
|
|
.expect("alice write failed");
|
|
|
|
// Write as shared
|
|
let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
|
|
ws_shared
|
|
.write("docs/shared-doc.md", "Shared document")
|
|
.await
|
|
.expect("shared write failed");
|
|
|
|
// Alice with multi-scope should see both
|
|
let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
|
|
.with_additional_read_scopes(vec!["shared".to_string()]);
|
|
|
|
let all_paths = ws_alice.list_all().await.expect("list_all failed");
|
|
assert!(
|
|
all_paths.contains(&"notes/personal.md".to_string()),
|
|
"Should contain alice's note: {:?}",
|
|
all_paths
|
|
);
|
|
assert!(
|
|
all_paths.contains(&"docs/shared-doc.md".to_string()),
|
|
"Should contain shared doc: {:?}",
|
|
all_paths
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_directory_merges_across_scopes() {
|
|
let (db, _dir) = setup().await;
|
|
|
|
// Alice writes to docs/
|
|
let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db));
|
|
ws_alice_plain
|
|
.write("docs/alice-doc.md", "Alice's doc")
|
|
.await
|
|
.expect("alice write failed");
|
|
|
|
// Shared writes to docs/
|
|
let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
|
|
ws_shared
|
|
.write("docs/shared-doc.md", "Shared doc")
|
|
.await
|
|
.expect("shared write failed");
|
|
|
|
// Alice with multi-scope lists docs/
|
|
let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
|
|
.with_additional_read_scopes(vec!["shared".to_string()]);
|
|
|
|
let entries = ws_alice.list("docs").await.expect("list failed");
|
|
let paths: Vec<&str> = entries.iter().map(|e| e.path.as_str()).collect();
|
|
assert!(
|
|
paths.contains(&"docs/alice-doc.md"),
|
|
"Should contain alice's doc: {:?}",
|
|
paths
|
|
);
|
|
assert!(
|
|
paths.contains(&"docs/shared-doc.md"),
|
|
"Should contain shared doc: {:?}",
|
|
paths
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn search_spans_scopes() {
|
|
let (db, _dir) = setup().await;
|
|
|
|
// Write searchable content in shared scope
|
|
let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
|
|
ws_shared
|
|
.write(
|
|
"docs/architecture.md",
|
|
"The microservice architecture uses gRPC for inter-service communication",
|
|
)
|
|
.await
|
|
.expect("shared write failed");
|
|
|
|
// Write searchable content in alice scope
|
|
let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db));
|
|
ws_alice_plain
|
|
.write("notes/ideas.md", "Consider switching to GraphQL federation")
|
|
.await
|
|
.expect("alice write failed");
|
|
|
|
// Alice with multi-scope searches
|
|
let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
|
|
.with_additional_read_scopes(vec!["shared".to_string()]);
|
|
|
|
// Search for content in the shared scope
|
|
let results = ws_alice
|
|
.search("microservice architecture gRPC", 10)
|
|
.await
|
|
.expect("search failed");
|
|
assert!(!results.is_empty(), "Should find results from shared scope");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn read_priority_primary_first() {
|
|
let (db, _dir) = setup().await;
|
|
|
|
// Write same path in both scopes
|
|
let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
|
|
ws_shared
|
|
.write("config/settings.md", "Shared settings v1")
|
|
.await
|
|
.expect("shared write failed");
|
|
|
|
let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db));
|
|
ws_alice_plain
|
|
.write("config/settings.md", "Alice's settings override")
|
|
.await
|
|
.expect("alice write failed");
|
|
|
|
// Alice with multi-scope should get her own version (primary scope wins)
|
|
let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
|
|
.with_additional_read_scopes(vec!["shared".to_string()]);
|
|
|
|
let doc = ws_alice
|
|
.read("config/settings.md")
|
|
.await
|
|
.expect("read failed");
|
|
assert_eq!(
|
|
doc.content, "Alice's settings override",
|
|
"Primary scope should take priority"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn exists_spans_scopes() {
|
|
let (db, _dir) = setup().await;
|
|
|
|
// Write a doc as "shared"
|
|
let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
|
|
ws_shared
|
|
.write("docs/shared-only.md", "Shared content")
|
|
.await
|
|
.expect("shared write failed");
|
|
|
|
// Alice without multi-scope should NOT see it
|
|
let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db));
|
|
assert!(
|
|
!ws_alice_plain
|
|
.exists("docs/shared-only.md")
|
|
.await
|
|
.expect("exists failed"),
|
|
"Alice without multi-scope should not see shared doc"
|
|
);
|
|
|
|
// Alice with multi-scope should see it
|
|
let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
|
|
.with_additional_read_scopes(vec!["shared".to_string()]);
|
|
assert!(
|
|
ws_alice
|
|
.exists("docs/shared-only.md")
|
|
.await
|
|
.expect("exists failed"),
|
|
"Alice with multi-scope should see shared doc"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn append_stays_in_primary_scope() {
|
|
let (db, _dir) = setup().await;
|
|
|
|
// Write a document as "shared"
|
|
let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
|
|
ws_shared
|
|
.write("notes/log.md", "shared original content")
|
|
.await
|
|
.expect("shared write failed");
|
|
|
|
// Alice has "shared" as a read scope and appends to the same path
|
|
let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
|
|
.with_additional_read_scopes(vec!["shared".to_string()]);
|
|
ws_alice
|
|
.append("notes/log.md", "alice appended line")
|
|
.await
|
|
.expect("alice append failed");
|
|
|
|
// Shared document must be unchanged (write isolation)
|
|
let shared_doc = ws_shared
|
|
.read("notes/log.md")
|
|
.await
|
|
.expect("shared read failed");
|
|
assert_eq!(
|
|
shared_doc.content, "shared original content",
|
|
"Append must not modify the secondary scope's document"
|
|
);
|
|
|
|
// Alice should have her own copy with the appended content
|
|
let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db));
|
|
let alice_doc = ws_alice_plain
|
|
.read("notes/log.md")
|
|
.await
|
|
.expect("alice read failed");
|
|
assert_eq!(
|
|
alice_doc.content, "alice appended line",
|
|
"Append should create a new document in alice's scope"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn append_memory_stays_in_primary_scope() {
|
|
let (db, _dir) = setup().await;
|
|
|
|
// Write MEMORY.md as "shared"
|
|
let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
|
|
ws_shared
|
|
.write("MEMORY.md", "shared memory baseline")
|
|
.await
|
|
.expect("shared write failed");
|
|
|
|
// Alice has "shared" as a read scope and appends a memory entry
|
|
let ws_alice = Workspace::new_with_db("alice", Arc::clone(&db))
|
|
.with_additional_read_scopes(vec!["shared".to_string()]);
|
|
ws_alice
|
|
.append_memory("alice remembers this")
|
|
.await
|
|
.expect("alice append_memory failed");
|
|
|
|
// Shared MEMORY.md must be unchanged
|
|
let shared_doc = ws_shared
|
|
.read("MEMORY.md")
|
|
.await
|
|
.expect("shared read failed");
|
|
assert_eq!(
|
|
shared_doc.content, "shared memory baseline",
|
|
"append_memory must not modify the secondary scope's document"
|
|
);
|
|
|
|
// Alice should have her own MEMORY.md
|
|
let ws_alice_plain = Workspace::new_with_db("alice", Arc::clone(&db));
|
|
let alice_doc = ws_alice_plain
|
|
.read("MEMORY.md")
|
|
.await
|
|
.expect("alice read failed");
|
|
assert_eq!(
|
|
alice_doc.content, "alice remembers this",
|
|
"append_memory should create in alice's scope"
|
|
);
|
|
}
|
|
|
|
// ==================== Identity isolation tests ====================
|
|
|
|
#[tokio::test]
|
|
async fn identity_files_not_readable_from_secondary_scope() {
|
|
let (db, _dir) = setup().await;
|
|
|
|
let ws_other = Workspace::new_with_db("other-user", Arc::clone(&db));
|
|
ws_other
|
|
.write("IDENTITY.md", "I am the other user")
|
|
.await
|
|
.expect("write failed");
|
|
ws_other
|
|
.write("SOUL.md", "Other user soul overlay")
|
|
.await
|
|
.expect("write failed");
|
|
ws_other
|
|
.write("USER.md", "Other user profile")
|
|
.await
|
|
.expect("write failed");
|
|
ws_other
|
|
.write("AGENTS.md", "Other user agent config")
|
|
.await
|
|
.expect("write failed");
|
|
|
|
let ws_primary = Workspace::new_with_db("primary", Arc::clone(&db))
|
|
.with_additional_read_scopes(vec!["other-user".to_string()]);
|
|
|
|
for path in &["IDENTITY.md", "SOUL.md", "USER.md", "AGENTS.md"] {
|
|
let result = ws_primary.read(path).await;
|
|
assert!(
|
|
result.is_err(),
|
|
"Primary should NOT read other user's {} via secondary scope",
|
|
path
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn identity_files_not_in_search_from_secondary_scope() {
|
|
let (db, _dir) = setup().await;
|
|
|
|
let ws_other = Workspace::new_with_db("other-user", Arc::clone(&db));
|
|
ws_other
|
|
.write("SOUL.md", "Other user loves xylophone music passionately")
|
|
.await
|
|
.expect("write failed");
|
|
ws_other
|
|
.write(
|
|
"notes/music.md",
|
|
"Other user played xylophone at the concert",
|
|
)
|
|
.await
|
|
.expect("write failed");
|
|
|
|
let ws_primary = Workspace::new_with_db("primary", Arc::clone(&db))
|
|
.with_additional_read_scopes(vec!["other-user".to_string()]);
|
|
|
|
let results = ws_primary
|
|
.search("xylophone", 10)
|
|
.await
|
|
.expect("search failed");
|
|
let has_concert = results.iter().any(|r| r.content.contains("concert"));
|
|
assert!(
|
|
has_concert,
|
|
"Should find non-identity content from secondary scope"
|
|
);
|
|
let has_soul = results.iter().any(|r| r.content.contains("passionately"));
|
|
assert!(
|
|
!has_soul,
|
|
"SOUL.md content from secondary scope should not appear in search results"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn identity_files_not_in_list_from_secondary_scope() {
|
|
let (db, _dir) = setup().await;
|
|
|
|
let ws_other = Workspace::new_with_db("other-user", Arc::clone(&db));
|
|
ws_other
|
|
.write("IDENTITY.md", "I am the other user")
|
|
.await
|
|
.expect("write failed");
|
|
ws_other
|
|
.write("notes/shared-note.md", "A shared note")
|
|
.await
|
|
.expect("write failed");
|
|
|
|
let ws_primary = Workspace::new_with_db("primary", Arc::clone(&db))
|
|
.with_additional_read_scopes(vec!["other-user".to_string()]);
|
|
|
|
let paths = ws_primary.list_all().await.expect("list failed");
|
|
assert!(
|
|
!paths.contains(&"IDENTITY.md".to_string()),
|
|
"IDENTITY.md from secondary scope should not appear"
|
|
);
|
|
assert!(
|
|
paths.contains(&"notes/shared-note.md".to_string()),
|
|
"Non-identity files should be listed"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn empty_read_scopes_reads_primary_only() {
|
|
let (db, _dir) = setup().await;
|
|
|
|
let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
|
|
ws_shared
|
|
.write("docs/note.md", "Shared note")
|
|
.await
|
|
.expect("write failed");
|
|
|
|
let ws_primary =
|
|
Workspace::new_with_db("primary", Arc::clone(&db)).with_additional_read_scopes(vec![]);
|
|
|
|
let result = ws_primary.read("docs/note.md").await;
|
|
assert!(
|
|
result.is_err(),
|
|
"Empty read scopes should not grant cross-scope access"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn duplicate_read_scopes_handled() {
|
|
let (db, _dir) = setup().await;
|
|
|
|
let ws_shared = Workspace::new_with_db("shared", Arc::clone(&db));
|
|
ws_shared
|
|
.write("docs/note.md", "One note")
|
|
.await
|
|
.expect("write failed");
|
|
|
|
let ws_primary = Workspace::new_with_db("primary", Arc::clone(&db))
|
|
.with_additional_read_scopes(vec!["shared".to_string(), "shared".to_string()]);
|
|
|
|
let doc = ws_primary.read("docs/note.md").await.expect("read failed");
|
|
assert_eq!(doc.content, "One note");
|
|
}
|