Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/agent/dispatcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,12 @@ impl Agent {
);

let system_prompt = if let Some(ws) = self.workspace() {
match ws
let scoped_workspace = if ws.user_id() == message.user_id {
Arc::clone(ws)
} else {
Arc::new(ws.scoped_to_user(&message.user_id))
};
match scoped_workspace
.system_prompt_for_context_tz(is_group_chat, user_tz)
.await
{
Expand Down
16 changes: 15 additions & 1 deletion src/tools/builtin/routine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -915,7 +915,7 @@ fn parse_routine_create_request(
fn build_routine_trigger(trigger: &NormalizedTriggerRequest) -> Trigger {
match trigger {
NormalizedTriggerRequest::Cron { schedule, timezone } => Trigger::Cron {
schedule: schedule.clone(),
schedule: normalize_cron_expression(schedule),
timezone: timezone.clone(),
},
NormalizedTriggerRequest::Manual => Trigger::Manual,
Expand Down Expand Up @@ -1836,6 +1836,20 @@ mod tests {
assert_eq!(parsed.cooldown_secs, 30);
}

#[test]
fn build_routine_trigger_normalizes_cron_schedule() {
let trigger = build_routine_trigger(&NormalizedTriggerRequest::Cron {
schedule: "0 0 9 * * MON-FRI".to_string(),
timezone: Some("UTC".to_string()),
});

assert!(matches!(
trigger,
Trigger::Cron { schedule, timezone }
if schedule == "0 0 9 * * MON-FRI *" && timezone.as_deref() == Some("UTC")
));
}

#[test]
fn parses_grouped_message_event_with_tools() {
let params = serde_json::json!({
Expand Down
55 changes: 55 additions & 0 deletions src/workspace/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ fn reject_if_injected(path: &str, content: &str) -> Result<(), WorkspaceError> {
///
/// Allows Workspace to work with either a PostgreSQL `Repository` (the original
/// path) or any `Database` trait implementation (e.g. libSQL backend).
#[derive(Clone)]
enum WorkspaceStorage {
/// PostgreSQL-backed repository (uses connection pool directly).
#[cfg(feature = "postgres")]
Expand Down Expand Up @@ -576,6 +577,60 @@ impl Workspace {
self
}

/// Clone the workspace configuration for a different primary user scope.
///
/// This preserves search config, embeddings, shared read scopes, memory
/// layers, and privacy classifier while switching the primary read/write
/// scope to `user_id`.
pub fn scoped_to_user(&self, user_id: impl Into<String>) -> Self {
let user_id = user_id.into();

let mut memory_layers = self.memory_layers.clone();
for layer in &mut memory_layers {
if layer.sensitivity == crate::workspace::layer::LayerSensitivity::Private
&& layer.scope == self.user_id
{
layer.scope = user_id.clone();
}
}

let mut read_user_ids = vec![user_id.clone()];
for scope in &self.read_user_ids {
if scope != &self.user_id && !read_user_ids.contains(scope) {
read_user_ids.push(scope.clone());
}
}
for scope in crate::workspace::layer::MemoryLayer::read_scopes(&memory_layers) {
if !read_user_ids.contains(&scope) {
read_user_ids.push(scope);
}
}

let preserve_flags = user_id == self.user_id;
Self {
user_id,
read_user_ids,
agent_id: self.agent_id,
storage: self.storage.clone(),
embeddings: self.embeddings.clone(),
bootstrap_pending: std::sync::atomic::AtomicBool::new(if preserve_flags {
self.bootstrap_pending
.load(std::sync::atomic::Ordering::Acquire)
} else {
false
}),
bootstrap_completed: std::sync::atomic::AtomicBool::new(if preserve_flags {
self.bootstrap_completed
.load(std::sync::atomic::Ordering::Acquire)
} else {
false
}),
search_defaults: self.search_defaults.clone(),
memory_layers,
privacy_classifier: self.privacy_classifier.clone(),
}
}

/// Get the user ID (primary scope for writes).
pub fn user_id(&self) -> &str {
&self.user_id
Expand Down
1 change: 1 addition & 0 deletions src/workspace/repository.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use crate::workspace::document::{MemoryChunk, MemoryDocument, WorkspaceEntry};
use crate::workspace::search::{RankedResult, SearchConfig, SearchResult, fuse_results};

/// Database repository for workspace operations.
#[derive(Clone)]
pub struct Repository {
pool: Pool,
}
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e_builtin_tool_coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,7 @@ mod tests {

match &routine.trigger {
Trigger::Cron { schedule, timezone } => {
assert_eq!(schedule, "0 0 9 * * MON-FRI");
assert_eq!(schedule, "0 0 9 * * MON-FRI *");
assert_eq!(timezone.as_deref(), Some("UTC"));
}
other => panic!("expected cron trigger, got {other:?}"),
Expand Down
4 changes: 3 additions & 1 deletion tests/e2e_workspace_coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ mod tests {

use crate::support::test_rig::TestRigBuilder;
use crate::support::trace_llm::LlmTrace;
use ironclaw::workspace::Workspace;

// -----------------------------------------------------------------------
// Test 1: write_chunk_search
Expand Down Expand Up @@ -268,6 +269,7 @@ mod tests {

#[tokio::test]
async fn identity_in_system_prompt() {
const TEST_USER_ID: &str = "test-user";
let trace = LlmTrace::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/workspace/identity_prompt.json"
Expand All @@ -280,7 +282,7 @@ mod tests {
.await;

// Seed an IDENTITY.md so the system prompt has real content to inject.
let ws = rig.workspace().expect("workspace must be available");
let ws = Workspace::new_with_db(TEST_USER_ID, rig.database().clone());
ws.write(
"IDENTITY.md",
"I am TestBot, a helpful testing assistant created for E2E verification.",
Expand Down
14 changes: 7 additions & 7 deletions tests/multi_tenant_system_prompt.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
//! Tests proving that multi-tenant system prompts are broken.
//! Regression tests for multi-tenant system prompts.
//!
//! Bug: In multi-tenant mode, the agent loop uses `self.workspace()` which
//! returns a single shared workspace (user_id="default"). Identity files
//! (IDENTITY.md, SOUL.md, USER.md) seeded under per-user IDs ("alice",
//! "bob") are invisible to this workspace, so the system prompt is
//! empty/wrong.
//! The agent must build the conversational system prompt from a workspace
//! scoped to the incoming message's user, not from the shared owner-scope
//! workspace created at startup. Otherwise per-user identity files
//! (IDENTITY.md, SOUL.md, USER.md) become invisible and different users can
//! see the same owner-scoped prompt.
//!
//! These tests:
//! 1. Seed identity files for two users (alice, bob) in the database
Expand All @@ -13,7 +13,7 @@
//! correct user's identity
//! 4. Verify user A's identity doesn't leak into user B's prompt
//!
//! All tests are expected to FAIL until the bug is fixed.
//! These tests ensure each user's identity is isolated correctly.

#[cfg(feature = "libsql")]
mod support;
Expand Down
Loading