mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 00:59:33 +00:00
feat(workspace): multi-scope workspace reads (#1117)
* 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]>
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
//! Tests for identity file scope isolation in multi-scope workspaces.
|
||||
//!
|
||||
//! When a workspace has multiple read scopes (e.g., Andrew can read from
|
||||
//! "andrew", "grace", "household"), identity files (SOUL.md, USER.md,
|
||||
//! IDENTITY.md, AGENTS.md) must ONLY come from the primary scope.
|
||||
//!
|
||||
//! Multi-scope reads are designed for memory sharing (MEMORY.md, daily logs),
|
||||
//! not identity inheritance. Silently inheriting identity from another scope
|
||||
//! is a correctness and security issue — the agent would present itself as
|
||||
//! the wrong user.
|
||||
//!
|
||||
//! These tests verify that:
|
||||
//! 1. Identity files are read from primary scope only
|
||||
//! 2. If the primary scope's identity file is missing, it's absent from the
|
||||
//! system prompt — never falls back to another scope
|
||||
//! 3. Memory files (MEMORY.md) still benefit from multi-scope reads
|
||||
#![cfg(feature = "libsql")]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use ironclaw::db::Database;
|
||||
use ironclaw::db::libsql::LibSqlBackend;
|
||||
use ironclaw::workspace::{Workspace, paths};
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/// Seed a document into a specific user's workspace scope.
|
||||
async fn seed(db: &Arc<dyn Database>, user_id: &str, path: &str, content: &str) {
|
||||
let ws = Workspace::new_with_db(user_id, db.clone());
|
||||
ws.write(path, content)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("Failed to seed {path} for {user_id}: {e}"));
|
||||
}
|
||||
|
||||
// ─── Test 1: Primary scope identity appears in system prompt ───────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn system_prompt_uses_primary_scope_identity() {
|
||||
let (db, _dir) = setup().await;
|
||||
|
||||
// Seed Alice's identity files in her own scope
|
||||
seed(&db, "alice", paths::SOUL, "Alice is kind and curious.").await;
|
||||
seed(
|
||||
&db,
|
||||
"alice",
|
||||
paths::USER,
|
||||
"You are talking to Alice, a software engineer.",
|
||||
)
|
||||
.await;
|
||||
|
||||
// Seed Bob's identity files in his scope
|
||||
seed(&db, "bob", paths::SOUL, "Bob is analytical and precise.").await;
|
||||
seed(
|
||||
&db,
|
||||
"bob",
|
||||
paths::USER,
|
||||
"You are talking to Bob, a marine biologist.",
|
||||
)
|
||||
.await;
|
||||
|
||||
// Create Alice's workspace WITH multi-scope reads including Bob
|
||||
let ws = Workspace::new_with_db("alice", db.clone())
|
||||
.with_additional_read_scopes(vec!["bob".to_string()]);
|
||||
|
||||
let prompt = ws
|
||||
.system_prompt_for_context(false)
|
||||
.await
|
||||
.expect("system_prompt_for_context failed");
|
||||
|
||||
// Alice's identity must appear
|
||||
assert!(
|
||||
prompt.contains("Alice is kind and curious"),
|
||||
"Primary scope SOUL.md should appear in system prompt.\nPrompt:\n{prompt}"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("Alice, a software engineer"),
|
||||
"Primary scope USER.md should appear in system prompt.\nPrompt:\n{prompt}"
|
||||
);
|
||||
|
||||
// Bob's identity must NOT appear
|
||||
assert!(
|
||||
!prompt.contains("Bob is analytical"),
|
||||
"Secondary scope SOUL.md must NOT appear in system prompt.\nPrompt:\n{prompt}"
|
||||
);
|
||||
assert!(
|
||||
!prompt.contains("Bob, a marine biologist"),
|
||||
"Secondary scope USER.md must NOT appear in system prompt.\nPrompt:\n{prompt}"
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Test 2: Missing primary identity does NOT fall back to other scope ─
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_primary_identity_does_not_fallback_to_other_scope() {
|
||||
let (db, _dir) = setup().await;
|
||||
|
||||
// Only seed Bob's identity — Alice has no identity files
|
||||
seed(&db, "bob", paths::SOUL, "Bob is analytical and precise.").await;
|
||||
seed(
|
||||
&db,
|
||||
"bob",
|
||||
paths::USER,
|
||||
"You are talking to Bob, a marine biologist.",
|
||||
)
|
||||
.await;
|
||||
|
||||
// Create Alice's workspace with multi-scope reads including Bob
|
||||
let ws = Workspace::new_with_db("alice", db.clone())
|
||||
.with_additional_read_scopes(vec!["bob".to_string()]);
|
||||
|
||||
let prompt = ws
|
||||
.system_prompt_for_context(false)
|
||||
.await
|
||||
.expect("system_prompt_for_context failed");
|
||||
|
||||
// Bob's identity must NOT appear — Alice's missing identity should stay missing,
|
||||
// not silently inherit from Bob's scope
|
||||
assert!(
|
||||
!prompt.contains("Bob"),
|
||||
"When primary scope identity is missing, must NOT fall back to secondary scope.\n\
|
||||
This would cause the agent to present itself as the wrong user.\nPrompt:\n{prompt}"
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Test 3: MEMORY.md still benefits from multi-scope reads ────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn memory_files_still_use_multi_scope_reads() {
|
||||
let (db, _dir) = setup().await;
|
||||
|
||||
// Seed shared memory in the "shared" scope (not Alice's primary)
|
||||
seed(
|
||||
&db,
|
||||
"shared",
|
||||
paths::MEMORY,
|
||||
"Shared grocery list: milk, eggs, bread.",
|
||||
)
|
||||
.await;
|
||||
|
||||
// Create Alice's workspace with read access to shared scope
|
||||
let ws = Workspace::new_with_db("alice", db.clone())
|
||||
.with_additional_read_scopes(vec!["shared".to_string()]);
|
||||
|
||||
let prompt = ws
|
||||
.system_prompt_for_context(false)
|
||||
.await
|
||||
.expect("system_prompt_for_context failed");
|
||||
|
||||
// Shared memory SHOULD appear — multi-scope reads are correct for memory
|
||||
assert!(
|
||||
prompt.contains("grocery list"),
|
||||
"MEMORY.md should still use multi-scope reads.\nPrompt:\n{prompt}"
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Test 4: All identity files are scope-isolated ──────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn all_identity_files_are_scope_isolated() {
|
||||
let (db, _dir) = setup().await;
|
||||
|
||||
// Seed identity files ONLY in the "other" scope, not in Alice's
|
||||
seed(&db, "other", paths::AGENTS, "You are Other's agent.").await;
|
||||
seed(&db, "other", paths::SOUL, "Other's soul values.").await;
|
||||
seed(&db, "other", paths::USER, "You are talking to Other.").await;
|
||||
seed(&db, "other", paths::IDENTITY, "Other's identity.").await;
|
||||
|
||||
// Also seed BOOTSTRAP.md and TOOLS.md in other scope
|
||||
seed(&db, "other", "BOOTSTRAP.md", "Other's bootstrap.").await;
|
||||
seed(&db, "other", "TOOLS.md", "Other's tool notes.").await;
|
||||
|
||||
// Create Alice's workspace with read access to "other"
|
||||
let ws = Workspace::new_with_db("alice", db.clone())
|
||||
.with_additional_read_scopes(vec!["other".to_string()]);
|
||||
|
||||
let prompt = ws
|
||||
.system_prompt_for_context(false)
|
||||
.await
|
||||
.expect("system_prompt_for_context failed");
|
||||
|
||||
// None of Other's identity/config files should appear
|
||||
assert!(
|
||||
!prompt.contains("Other"),
|
||||
"No identity or config files from secondary scope should appear.\n\
|
||||
Every identity file (AGENTS.md, SOUL.md, USER.md, IDENTITY.md, \
|
||||
BOOTSTRAP.md, TOOLS.md) must read from primary scope only.\nPrompt:\n{prompt}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
#![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");
|
||||
}
|
||||
@@ -407,3 +407,333 @@ async fn test_workspace_system_prompt() {
|
||||
|
||||
cleanup_user(&pool, user_id).await;
|
||||
}
|
||||
|
||||
// ── Multi-scope workspace read tests ──────────────────────────────────
|
||||
//
|
||||
// These exercise the PostgreSQL-optimized `_multi` query paths
|
||||
// (repository.rs) that the libSQL backend covers via default trait impls.
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_multi_scope_read_across_scopes() {
|
||||
let pool = get_pool();
|
||||
if try_connect(&pool).await.is_none() {
|
||||
return;
|
||||
}
|
||||
let shared_id = "ms_shared_read";
|
||||
let alice_id = "ms_alice_read";
|
||||
cleanup_user(&pool, shared_id).await;
|
||||
cleanup_user(&pool, alice_id).await;
|
||||
|
||||
// Write a doc as "shared"
|
||||
let ws_shared = Workspace::new(shared_id, pool.clone());
|
||||
ws_shared
|
||||
.write("docs/team-standup.md", "Team standup notes from Monday")
|
||||
.await
|
||||
.expect("shared write failed");
|
||||
|
||||
// Alice with "shared" as an additional read scope
|
||||
let ws_alice = Workspace::new(alice_id, pool.clone())
|
||||
.with_additional_read_scopes(vec![shared_id.to_string()]);
|
||||
|
||||
let doc = ws_alice
|
||||
.read("docs/team-standup.md")
|
||||
.await
|
||||
.expect("cross-scope read failed");
|
||||
assert_eq!(doc.content, "Team standup notes from Monday");
|
||||
|
||||
cleanup_user(&pool, shared_id).await;
|
||||
cleanup_user(&pool, alice_id).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_multi_scope_write_stays_in_primary() {
|
||||
let pool = get_pool();
|
||||
if try_connect(&pool).await.is_none() {
|
||||
return;
|
||||
}
|
||||
let shared_id = "ms_shared_write";
|
||||
let alice_id = "ms_alice_write";
|
||||
cleanup_user(&pool, shared_id).await;
|
||||
cleanup_user(&pool, alice_id).await;
|
||||
|
||||
let ws_alice = Workspace::new(alice_id, pool.clone())
|
||||
.with_additional_read_scopes(vec![shared_id.to_string()]);
|
||||
|
||||
ws_alice
|
||||
.write("notes/personal.md", "Alice's private note")
|
||||
.await
|
||||
.expect("alice write failed");
|
||||
|
||||
// Shared workspace should NOT see Alice's note
|
||||
let ws_shared = Workspace::new(shared_id, pool.clone());
|
||||
let result = ws_shared.read("notes/personal.md").await;
|
||||
assert!(result.is_err(), "Shared scope should not see Alice's note");
|
||||
|
||||
cleanup_user(&pool, shared_id).await;
|
||||
cleanup_user(&pool, alice_id).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_multi_scope_list_all_merges() {
|
||||
let pool = get_pool();
|
||||
if try_connect(&pool).await.is_none() {
|
||||
return;
|
||||
}
|
||||
let shared_id = "ms_shared_list";
|
||||
let alice_id = "ms_alice_list";
|
||||
cleanup_user(&pool, shared_id).await;
|
||||
cleanup_user(&pool, alice_id).await;
|
||||
|
||||
// Write as alice (plain, no multi-scope)
|
||||
let ws_alice_plain = Workspace::new(alice_id, pool.clone());
|
||||
ws_alice_plain
|
||||
.write("notes/personal.md", "My notes")
|
||||
.await
|
||||
.expect("alice write failed");
|
||||
|
||||
// Write as shared
|
||||
let ws_shared = Workspace::new(shared_id, pool.clone());
|
||||
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(alice_id, pool.clone())
|
||||
.with_additional_read_scopes(vec![shared_id.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
|
||||
);
|
||||
|
||||
cleanup_user(&pool, shared_id).await;
|
||||
cleanup_user(&pool, alice_id).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_multi_scope_list_directory_merges() {
|
||||
let pool = get_pool();
|
||||
if try_connect(&pool).await.is_none() {
|
||||
return;
|
||||
}
|
||||
let shared_id = "ms_shared_dir";
|
||||
let alice_id = "ms_alice_dir";
|
||||
cleanup_user(&pool, shared_id).await;
|
||||
cleanup_user(&pool, alice_id).await;
|
||||
|
||||
let ws_alice_plain = Workspace::new(alice_id, pool.clone());
|
||||
ws_alice_plain
|
||||
.write("docs/alice-doc.md", "Alice's doc")
|
||||
.await
|
||||
.expect("alice write failed");
|
||||
|
||||
let ws_shared = Workspace::new(shared_id, pool.clone());
|
||||
ws_shared
|
||||
.write("docs/shared-doc.md", "Shared doc")
|
||||
.await
|
||||
.expect("shared write failed");
|
||||
|
||||
let ws_alice = Workspace::new(alice_id, pool.clone())
|
||||
.with_additional_read_scopes(vec![shared_id.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
|
||||
);
|
||||
|
||||
cleanup_user(&pool, shared_id).await;
|
||||
cleanup_user(&pool, alice_id).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_multi_scope_read_priority_primary_first() {
|
||||
let pool = get_pool();
|
||||
if try_connect(&pool).await.is_none() {
|
||||
return;
|
||||
}
|
||||
let shared_id = "ms_shared_prio";
|
||||
let alice_id = "ms_alice_prio";
|
||||
cleanup_user(&pool, shared_id).await;
|
||||
cleanup_user(&pool, alice_id).await;
|
||||
|
||||
// Write same path in both scopes
|
||||
let ws_shared = Workspace::new(shared_id, pool.clone());
|
||||
ws_shared
|
||||
.write("config/settings.md", "Shared settings v1")
|
||||
.await
|
||||
.expect("shared write failed");
|
||||
|
||||
let ws_alice_plain = Workspace::new(alice_id, pool.clone());
|
||||
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(alice_id, pool.clone())
|
||||
.with_additional_read_scopes(vec![shared_id.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"
|
||||
);
|
||||
|
||||
cleanup_user(&pool, shared_id).await;
|
||||
cleanup_user(&pool, alice_id).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_multi_scope_exists_spans_scopes() {
|
||||
let pool = get_pool();
|
||||
if try_connect(&pool).await.is_none() {
|
||||
return;
|
||||
}
|
||||
let shared_id = "ms_shared_exists";
|
||||
let alice_id = "ms_alice_exists";
|
||||
cleanup_user(&pool, shared_id).await;
|
||||
cleanup_user(&pool, alice_id).await;
|
||||
|
||||
let ws_shared = Workspace::new(shared_id, pool.clone());
|
||||
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(alice_id, pool.clone());
|
||||
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(alice_id, pool.clone())
|
||||
.with_additional_read_scopes(vec![shared_id.to_string()]);
|
||||
assert!(
|
||||
ws_alice
|
||||
.exists("docs/shared-only.md")
|
||||
.await
|
||||
.expect("exists failed"),
|
||||
"Alice with multi-scope should see shared doc"
|
||||
);
|
||||
|
||||
cleanup_user(&pool, shared_id).await;
|
||||
cleanup_user(&pool, alice_id).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_multi_scope_search_spans_scopes() {
|
||||
let pool = get_pool();
|
||||
if try_connect(&pool).await.is_none() {
|
||||
return;
|
||||
}
|
||||
let shared_id = "ms_shared_search";
|
||||
let alice_id = "ms_alice_search";
|
||||
cleanup_user(&pool, shared_id).await;
|
||||
cleanup_user(&pool, alice_id).await;
|
||||
|
||||
let ws_shared = Workspace::new(shared_id, pool.clone());
|
||||
ws_shared
|
||||
.write(
|
||||
"docs/architecture.md",
|
||||
"The microservice architecture uses gRPC for inter-service communication",
|
||||
)
|
||||
.await
|
||||
.expect("shared write failed");
|
||||
|
||||
let ws_alice_plain = Workspace::new(alice_id, pool.clone());
|
||||
ws_alice_plain
|
||||
.write("notes/ideas.md", "Consider switching to GraphQL federation")
|
||||
.await
|
||||
.expect("alice write failed");
|
||||
|
||||
let ws_alice = Workspace::new(alice_id, pool.clone())
|
||||
.with_additional_read_scopes(vec![shared_id.to_string()]);
|
||||
|
||||
// Search for content in the shared scope
|
||||
let results = ws_alice
|
||||
.search_with_config(
|
||||
"microservice gRPC architecture",
|
||||
SearchConfig::default().fts_only(),
|
||||
)
|
||||
.await
|
||||
.expect("search failed");
|
||||
assert!(!results.is_empty(), "Should find results from shared scope");
|
||||
|
||||
cleanup_user(&pool, shared_id).await;
|
||||
cleanup_user(&pool, alice_id).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_multi_scope_append_stays_in_primary() {
|
||||
let pool = get_pool();
|
||||
if try_connect(&pool).await.is_none() {
|
||||
return;
|
||||
}
|
||||
let shared_id = "ms_shared_append";
|
||||
let alice_id = "ms_alice_append";
|
||||
cleanup_user(&pool, shared_id).await;
|
||||
cleanup_user(&pool, alice_id).await;
|
||||
|
||||
// Write a document as "shared"
|
||||
let ws_shared = Workspace::new(shared_id, pool.clone());
|
||||
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(alice_id, pool.clone())
|
||||
.with_additional_read_scopes(vec![shared_id.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(alice_id, pool.clone());
|
||||
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"
|
||||
);
|
||||
|
||||
cleanup_user(&pool, shared_id).await;
|
||||
cleanup_user(&pool, alice_id).await;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user