Compare commits

...
Author SHA1 Message Date
Henry Park 060ce8de25 fix(agent): surface active extension state to the LLM 2026-03-23 11:47:58 -07:00
485d1568c4 feat(cli): add ironclaw models subcommands (list/status/set/set-provider) (#1043)
* feat(cli): add ironclaw models subcommands (list/status/set/set-provider)
  Implements  model management CLI (part of #83):
  - `models list [provider] [--verbose] [--json]` — list providers; fetches
    live model list from the provider API when a specific provider is given
  - `models status [--json]` — show active provider/model
  - `models set <model>` — set default model with validation
  - `models set-provider <id> [--model <name>]` — set provider with alias
    normalization
  - fix conflicts

* fix(deps): update tar to 0.4.45 (RUSTSEC-2026-0067, RUSTSEC-2026-0068)

---------

Co-authored-by: firat.sertgoz <[email protected]>
2026-03-23 12:36:41 +01:00
acb590214a test: Google OAuth URL broken when initiated from Telegram channel (#1165)
* fix: Google OAuth URL broken when initiated from Telegram channel

* test: validate OAuth URL parameters for bug #992

Add comprehensive OAuth URL parameter validation tests for bug #992 (Google
OAuth URL broken when initiated from Telegram channel). Tests verify:
- Correct parameter names (client_id not clientid)
- All required OAuth parameters present
- Google OAuth spec compliance
- CSRF state uniqueness per request
- Extra parameters from capabilities preserved
- URL parameter escaping

Consolidates tests into tests/e2e/scenarios/ with improved fixture approach
(session-scoped installed_gmail, auth_url, oauth_params fixtures for efficiency).

Co-Authored-By: Claude Haiku 4.5 <[email protected]>

* review fixes

---------

Co-authored-by: Claude Haiku 4.5 <[email protected]>
Co-authored-by: firat.sertgoz <[email protected]>
2026-03-23 10:08:24 +01:00
d9358b0fa9 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]>
2026-03-22 22:56:26 -07:00
Vitali AvagyanandGitHub 8f6999a074 docs: add gitcgr code graph badge (#1563) 2026-03-22 21:03:51 -07:00
Henry ParkandGitHub 4d7501a968 Fix owner-scoped message routing fallbacks (#1574)
* Fix owner-scoped message routing fallbacks

* Address PR feedback on routing regressions

* Address review notes on routing fallbacks
2026-03-22 20:33:52 -07:00
33 changed files with 3644 additions and 182 deletions
+1 -1
View File
@@ -54,7 +54,7 @@ jobs:
- group: features
files: "tests/e2e/scenarios/test_skills.py tests/e2e/scenarios/test_tool_approval.py tests/e2e/scenarios/test_webhook.py"
- group: extensions
files: "tests/e2e/scenarios/test_extensions.py tests/e2e/scenarios/test_extension_oauth.py tests/e2e/scenarios/test_telegram_token_validation.py tests/e2e/scenarios/test_telegram_hot_activation.py tests/e2e/scenarios/test_wasm_lifecycle.py tests/e2e/scenarios/test_tool_execution.py tests/e2e/scenarios/test_pairing.py tests/e2e/scenarios/test_mcp_auth_flow.py tests/e2e/scenarios/test_oauth_credential_fallback.py tests/e2e/scenarios/test_routine_oauth_credential_injection.py"
files: "tests/e2e/scenarios/test_extensions.py tests/e2e/scenarios/test_extension_oauth.py tests/e2e/scenarios/test_oauth_url_parameters.py tests/e2e/scenarios/test_telegram_token_validation.py tests/e2e/scenarios/test_telegram_hot_activation.py tests/e2e/scenarios/test_wasm_lifecycle.py tests/e2e/scenarios/test_tool_execution.py tests/e2e/scenarios/test_pairing.py tests/e2e/scenarios/test_mcp_auth_flow.py tests/e2e/scenarios/test_oauth_credential_fallback.py tests/e2e/scenarios/test_routine_oauth_credential_injection.py"
- group: routines
files: "tests/e2e/scenarios/test_owner_scope.py tests/e2e/scenarios/test_routine_event_batch.py"
steps:
Generated
+12 -12
View File
@@ -157,7 +157,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -168,7 +168,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -2136,7 +2136,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -2323,7 +2323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -4134,7 +4134,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -5472,7 +5472,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -6154,7 +6154,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -6354,9 +6354,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tar"
version = "0.4.44"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
dependencies = [
"filetime",
"libc",
@@ -6379,7 +6379,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -7179,7 +7179,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -8029,7 +8029,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]
+1 -1
View File
@@ -161,7 +161,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| `config` | ✅ | ✅ | - | Read/write config plus validate/path helpers |
| `backup` | ✅ | ❌ | P3 | Create/verify local backup archives |
| `channels` | ✅ | 🚧 | P2 | `list` implemented; `enable`/`disable`/`status` deferred pending config source unification |
| `models` | ✅ | 🚧 | - | Model selector in TUI |
| `models` | ✅ | 🚧 | P1 | `models list [<provider>]` (`--verbose`, `--json`; fetches live model list when provider specified), `models status` (`--json`), `models set <model>`, `models set-provider <provider> [--model model]` (alias normalization, config.toml + .env persistence). Remaining: `set` doesn't validate model against live list. |
| `status` | ✅ | ✅ | - | System status (enriched session details) |
| `agents` | ✅ | ❌ | P3 | Multi-agent management |
| `sessions` | ✅ | ❌ | P3 | Session listing (shows subagent models) |
+3
View File
@@ -12,6 +12,9 @@
<a href="#license"><img src="https://img.shields.io/badge/license-MIT%20OR%20Apache%202.0-blue.svg" alt="License: MIT OR Apache-2.0" /></a>
<a href="https://t.me/ironclawAI"><img src="https://img.shields.io/badge/Telegram-%40ironclawAI-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @ironclawAI" /></a>
<a href="https://www.reddit.com/r/ironclawAI/"><img src="https://img.shields.io/badge/Reddit-r%2FironclawAI-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/ironclawAI" /></a>
<a href="https://gitcgr.com/nearai/ironclaw">
<img src="https://gitcgr.com/badge/nearai/ironclaw.svg" alt="gitcgr" />
</a>
</p>
<p align="center">
+12
View File
@@ -131,6 +131,18 @@ impl Agent {
}
}
if let Some(extension_manager) = self.deps.extension_manager.as_ref() {
match extension_manager.llm_extension_state_summary().await {
Ok(Some(summary)) => {
reasoning = reasoning.with_extension_state_summary(summary);
}
Ok(None) => {}
Err(e) => {
tracing::debug!("Could not load extension state summary: {}", e);
}
}
}
if let Some(prompt) = system_prompt {
reasoning = reasoning.with_system_prompt(prompt);
}
+11
View File
@@ -325,9 +325,20 @@ impl AppBuilder {
};
let mut ws = Workspace::new_with_db(workspace_user_id, db.clone())
.with_search_config(&self.config.search);
if let Some(ref emb) = embeddings {
ws = ws.with_embeddings_cached(emb.clone(), emb_cache_config);
}
// Wire workspace-level settings (read scopes, memory layers)
if !self.config.workspace.read_scopes.is_empty() {
ws = ws.with_additional_read_scopes(self.config.workspace.read_scopes.clone());
tracing::info!(
user_id = workspace_user_id,
read_scopes = ?ws.read_user_ids(),
"Workspace configured with multi-scope reads"
);
}
ws = ws.with_memory_layers(self.config.workspace.memory_layers.clone());
let ws = Arc::new(ws);
tools.register_memory_tools(Arc::clone(&ws));
+7 -1
View File
@@ -1822,7 +1822,13 @@ async fn memory_write_handler(
"Workspace not available".to_string(),
))?;
// Route through layer-aware methods when a layer is specified
// Route through layer-aware methods when a layer is specified.
//
// Note: unlike MemoryWriteTool, this endpoint does NOT block writes to
// identity files (IDENTITY.md, SOUL.md, etc.). The HTTP API is an
// authenticated admin interface; the supervisor uses it to seed identity
// files at startup. Identity-file protection is enforced at the tool
// layer (LLM-facing) where the write originates from an untrusted agent.
if let Some(ref layer_name) = req.layer {
let result = if req.append {
workspace
+10
View File
@@ -25,6 +25,7 @@ pub mod import;
mod logs;
mod mcp;
pub mod memory;
mod models;
pub mod oauth_defaults;
mod pairing;
mod registry;
@@ -45,6 +46,7 @@ pub use logs::{LogsCommand, run_logs_command};
pub use mcp::{McpCommand, run_mcp_command};
pub use memory::MemoryCommand;
pub use memory::run_memory_command_with_db;
pub use models::{ModelsCommand, run_models_command};
pub use pairing::{PairingCommand, run_pairing_command, run_pairing_command_with_store};
pub use registry::{RegistryCommand, run_registry_command};
pub use routines::{RoutinesCommand, run_routines_command};
@@ -217,6 +219,14 @@ pub enum Command {
)]
Hooks(HooksCommand),
/// Manage LLM providers and models
#[command(
subcommand,
about = "Manage LLM providers and models",
long_about = "List providers, view current configuration, and set active provider/model.\nExamples:\n ironclaw models list\n ironclaw models list openai --verbose\n ironclaw models status\n ironclaw models set gpt-4o\n ironclaw models set-provider anthropic --model claude-sonnet-4-6-20250514"
)]
Models(ModelsCommand),
/// Probe external dependencies and validate configuration
#[command(
about = "Run diagnostics",
+864
View File
@@ -0,0 +1,864 @@
//! Models management CLI commands.
//!
//! Provides subcommands for listing providers, viewing current model
//! configuration, and setting the active provider/model. Settings are
//! persisted to both `config.toml` and `~/.ironclaw/.env` so changes
//! take effect immediately (no DB connection required).
use clap::Subcommand;
use std::path::Path;
use crate::llm::registry::ProviderRegistry;
use crate::settings::Settings;
#[derive(Subcommand, Debug, Clone)]
pub enum ModelsCommand {
/// List providers (or available models for a specific provider)
List {
/// Show only a specific provider (by ID or alias)
provider: Option<String>,
/// Show detailed information (env vars, base URL, protocol)
#[arg(short, long)]
verbose: bool,
/// Output as JSON
#[arg(long)]
json: bool,
},
/// Show current model configuration
Status {
/// Output as JSON
#[arg(long)]
json: bool,
},
/// Set the default model
Set {
/// Model name (e.g., "gpt-5-mini", "claude-sonnet-4-6-20250514")
model: String,
},
/// Set the LLM provider
SetProvider {
/// Provider ID or alias (e.g., "openai", "anthropic", "ollama")
provider: String,
/// Also set the model (defaults to provider's default model)
#[arg(long)]
model: Option<String>,
},
}
/// Run the models CLI subcommand.
pub async fn run_models_command(
cmd: ModelsCommand,
config_path: Option<&Path>,
) -> anyhow::Result<()> {
match cmd {
ModelsCommand::List {
provider,
verbose,
json,
} => {
if let Some(ref id) = provider {
cmd_show_provider(id, verbose, json, config_path).await
} else {
cmd_list_providers(verbose, json, config_path).await
}
}
ModelsCommand::Status { json } => cmd_status(json, config_path),
ModelsCommand::Set { model } => cmd_set_model(&model, config_path),
ModelsCommand::SetProvider { provider, model } => {
cmd_set_provider(&provider, model.as_deref(), config_path)
}
}
}
// ─── Shared helpers ───────────────────────────────────────────────
/// Resolve the currently active backend and model from env + settings.
fn resolve_active(config_path: Option<&Path>) -> (String, String) {
let settings = load_settings(config_path);
resolve_active_from_settings(&settings)
}
/// Resolve active backend + model from a pre-loaded Settings.
fn resolve_active_from_settings(settings: &Settings) -> (String, String) {
let backend = std::env::var("LLM_BACKEND")
.ok()
.or_else(|| settings.llm_backend.clone())
.unwrap_or_else(|| "nearai".to_string());
let registry = ProviderRegistry::load();
let canonical_backend = registry
.find(&backend)
.map(|d| d.id.clone())
.unwrap_or_else(|| backend.clone());
let model = if canonical_backend == "nearai" {
std::env::var("NEARAI_MODEL")
.ok()
.or_else(|| settings.selected_model.clone())
.unwrap_or_else(|| "qwen2.5-72b-instruct:free".to_string())
} else if let Some(def) = registry.find(&canonical_backend) {
std::env::var(&def.model_env)
.ok()
.or_else(|| settings.selected_model.clone())
.unwrap_or_else(|| def.default_model.clone())
} else {
settings
.selected_model
.clone()
.unwrap_or_else(|| "unknown".to_string())
};
(canonical_backend, model)
}
fn load_settings(config_path: Option<&Path>) -> Settings {
if let Some(path) = config_path {
Settings::load_toml(path).ok().flatten().unwrap_or_default()
} else {
let toml_path = config_toml_path();
if toml_path.exists() {
Settings::load_toml(&toml_path)
.ok()
.flatten()
.unwrap_or_default()
} else {
Settings::load()
}
}
}
fn save_settings(settings: &Settings, config_path: Option<&Path>) -> anyhow::Result<()> {
let path = config_path
.map(|p| p.to_path_buf())
.unwrap_or_else(config_toml_path);
settings
.save_toml(&path)
.map_err(|e| anyhow::anyhow!("{}", e))?;
Ok(())
}
fn config_toml_path() -> std::path::PathBuf {
crate::bootstrap::ironclaw_base_dir().join("config.toml")
}
/// Try to fetch the live model list from a provider.
///
/// Best-effort: returns `None` if config loading, provider creation, or the
/// `list_models()` call fails (missing API key, network error, etc.).
async fn try_fetch_models(provider_id: &str, config_path: Option<&Path>) -> Option<Vec<String>> {
let config = crate::config::Config::from_env_with_toml(config_path)
.await
.ok()?;
// Override backend to the requested provider so create_llm_provider
// constructs the right one.
let mut llm_config = config.llm.clone();
llm_config.backend = provider_id.to_string();
// For registry providers, resolve the RegistryProviderConfig if not
// already set for this backend.
if provider_id != "nearai" && provider_id != "bedrock" {
let registry = ProviderRegistry::load();
if let Some(def) = registry.find(provider_id)
&& llm_config
.provider
.as_ref()
.is_none_or(|p| p.provider_id != def.id)
{
// Build a minimal RegistryProviderConfig from env + registry
let api_key = def
.api_key_env
.as_ref()
.and_then(|env| std::env::var(env).ok());
if def.api_key_required && api_key.is_none() {
return None;
}
let base_url = def.default_base_url.clone().unwrap_or_default();
llm_config.provider = Some(crate::llm::RegistryProviderConfig {
protocol: def.protocol,
provider_id: def.id.clone(),
model: def.default_model.clone(),
api_key: api_key.map(secrecy::SecretString::from),
base_url,
extra_headers: Vec::new(),
oauth_token: None,
is_codex_chatgpt: false,
refresh_token: None,
auth_path: None,
cache_retention: Default::default(),
unsupported_params: def.unsupported_params.clone(),
});
}
}
let session = crate::llm::create_session_manager(config.llm.session.clone()).await;
let provider = crate::llm::create_llm_provider(&llm_config, session)
.await
.ok()?;
provider.list_models().await.ok().filter(|m| !m.is_empty())
}
/// Print available models section (text output).
fn print_model_list(models: &Option<Vec<String>>, active_model: Option<&String>) {
match models {
Some(models) => {
println!("\n Available models ({}):", models.len());
for m in models {
let marker = active_model
.filter(|a| a.as_str() == m)
.map(|_| " (active)")
.unwrap_or("");
println!(" {}{}", m, marker);
}
}
None => {
println!(
"\n Could not fetch model list (missing credentials or provider unavailable)."
);
}
}
}
/// Also update `~/.ironclaw/.env` so changes take effect immediately.
///
/// Skipped when `config_path` is `Some` (custom `--config`), because the user
/// is explicitly targeting a different config file and we must not pollute the
/// default profile's `.env`.
fn sync_to_dotenv(config_path: Option<&Path>, vars: &[(&str, &str)]) {
if config_path.is_some() {
return;
}
if let Err(e) = crate::bootstrap::upsert_bootstrap_vars(vars) {
eprintln!("Warning: failed to update .env: {}", e);
}
}
// ─── status ───────────────────────────────────────────────────────
fn cmd_status(json: bool, config_path: Option<&Path>) -> anyhow::Result<()> {
let settings = load_settings(config_path);
let (backend, model) = resolve_active_from_settings(&settings);
let registry = ProviderRegistry::load();
let fallback = std::env::var("NEARAI_FALLBACK_MODEL").ok();
let cheap = std::env::var("NEARAI_CHEAP_MODEL").ok();
let description = if backend == "nearai" {
"NEAR AI inference (default)".to_string()
} else {
registry
.find(&backend)
.map(|d| d.description.clone())
.unwrap_or_default()
};
if json {
let v = serde_json::json!({
"provider": backend,
"model": model,
"description": description,
"fallback_model": fallback,
"cheap_model": cheap,
});
println!(
"{}",
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
);
return Ok(());
}
println!("Provider: {} ({})", backend, description);
println!("Model: {}", model);
if let Some(ref fb) = fallback {
println!("Fallback: {}", fb);
}
if let Some(ref ch) = cheap {
println!("Cheap: {}", ch);
}
Ok(())
}
// ─── set ──────────────────────────────────────────────────────────
fn cmd_set_model(model: &str, config_path: Option<&Path>) -> anyhow::Result<()> {
let trimmed = model.trim();
if trimmed.is_empty() {
anyhow::bail!("Model name cannot be empty");
}
let mut settings = load_settings(config_path);
let registry = ProviderRegistry::load();
// Warn if model name doesn't match any known provider's default model
let known_model = registry.all().iter().any(|d| d.default_model == trimmed)
|| trimmed.contains("qwen") // nearai models
|| trimmed.contains("llama")
|| trimmed.contains("gpt")
|| trimmed.contains("claude")
|| trimmed.contains("gemini")
|| trimmed.contains("mistral");
if !known_model {
eprintln!(
"Warning: '{}' is not a recognized model name. Proceeding anyway.",
trimmed
);
}
settings.selected_model = Some(trimmed.to_string());
save_settings(&settings, config_path)?;
let backend = std::env::var("LLM_BACKEND")
.ok()
.or_else(|| settings.llm_backend.clone())
.unwrap_or_else(|| "nearai".to_string());
// Also write to .env so the change takes effect immediately
let model_env = if backend == "nearai" {
"NEARAI_MODEL".to_string()
} else {
registry
.find(&backend)
.map(|d| d.model_env.clone())
.unwrap_or_default()
};
if !model_env.is_empty() {
sync_to_dotenv(config_path, &[(&model_env, trimmed)]);
}
println!("Model set to '{}' (provider: {})", trimmed, backend);
println!(
"Saved to {}",
config_path
.map(|p| p.display().to_string())
.unwrap_or_else(|| config_toml_path().display().to_string())
);
Ok(())
}
// ─── set-provider ─────────────────────────────────────────────────
fn cmd_set_provider(
provider: &str,
model: Option<&str>,
config_path: Option<&Path>,
) -> anyhow::Result<()> {
let registry = ProviderRegistry::load();
// Validate and normalize provider
let canonical_id = if provider == "nearai" || provider == "near_ai" || provider == "near" {
"nearai".to_string()
} else {
let def = registry.find(provider).ok_or_else(|| {
let known: Vec<&str> = std::iter::once("nearai")
.chain(registry.all().iter().map(|d| d.id.as_str()))
.collect();
anyhow::anyhow!(
"Unknown provider '{}'. Known providers: {}",
provider,
known.join(", ")
)
})?;
def.id.clone()
};
// Resolve model: explicit > provider default
let resolved_model = if let Some(m) = model {
m.to_string()
} else if canonical_id == "nearai" {
"qwen2.5-72b-instruct:free".to_string()
} else if let Some(def) = registry.find(&canonical_id) {
def.default_model.clone()
} else {
"default".to_string()
};
let mut settings = load_settings(config_path);
settings.llm_backend = Some(canonical_id.clone());
settings.selected_model = Some(resolved_model.clone());
save_settings(&settings, config_path)?;
// Also write to .env so the change takes effect immediately
let model_env = if canonical_id == "nearai" {
"NEARAI_MODEL".to_string()
} else {
registry
.find(&canonical_id)
.map(|d| d.model_env.clone())
.unwrap_or_default()
};
let mut vars: Vec<(&str, &str)> = vec![("LLM_BACKEND", &canonical_id)];
if !model_env.is_empty() {
vars.push((&model_env, &resolved_model));
}
sync_to_dotenv(config_path, &vars);
println!(
"Provider set to '{}', model set to '{}'",
canonical_id, resolved_model
);
println!(
"Saved to {}",
config_path
.map(|p| p.display().to_string())
.unwrap_or_else(|| config_toml_path().display().to_string())
);
Ok(())
}
// ─── list ─────────────────────────────────────────────────────────
/// List all providers with their default models.
async fn cmd_list_providers(
verbose: bool,
json: bool,
config_path: Option<&Path>,
) -> anyhow::Result<()> {
let registry = ProviderRegistry::load();
let (active_backend, active_model) = resolve_active(config_path);
if json {
let mut entries: Vec<serde_json::Value> = Vec::new();
// NEAR AI (not in registry)
let nearai_active = active_backend == "nearai";
entries.push(serde_json::json!({
"id": "nearai",
"description": "NEAR AI inference (default)",
"default_model": "qwen2.5-72b-instruct:free",
"active": nearai_active,
"active_model": if nearai_active { Some(&active_model) } else { None },
}));
for def in registry.all() {
let is_active = active_backend == def.id;
let mut v = serde_json::json!({
"id": def.id,
"description": def.description,
"default_model": def.default_model,
"protocol": format!("{:?}", def.protocol),
"active": is_active,
});
if is_active {
v["active_model"] = serde_json::json!(active_model);
}
if verbose {
v["aliases"] = serde_json::json!(def.aliases);
v["model_env"] = serde_json::json!(def.model_env);
v["api_key_env"] = serde_json::json!(def.api_key_env);
v["api_key_required"] = serde_json::json!(def.api_key_required);
if let Some(ref url) = def.default_base_url {
v["base_url"] = serde_json::json!(url);
}
if let Some(ref setup) = def.setup {
v["can_list_models"] = serde_json::json!(setup.can_list_models());
}
}
entries.push(v);
}
println!(
"{}",
serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string())
);
return Ok(());
}
let providers = registry.all();
println!("Active: {} (model: {})\n", active_backend, active_model);
println!(
"{} provider(s) available:\n",
providers.len() + 1 // +1 for NEAR AI
);
// NEAR AI (not in registry)
let nearai_marker = if active_backend == "nearai" { " *" } else { "" };
if verbose {
println!(" nearai{}", nearai_marker);
println!(" Description: NEAR AI inference (default)");
println!(" Default model: qwen2.5-72b-instruct:free");
println!(" Model env: NEARAI_MODEL");
if active_backend == "nearai" {
println!(" Active model: {}", active_model);
}
println!();
} else {
println!(
" {:<22} {:<40} NEAR AI inference (default)",
format!("nearai{nearai_marker}"),
"qwen2.5-72b-instruct:free"
);
}
for def in providers {
let is_active = active_backend == def.id;
let marker = if is_active { " *" } else { "" };
if verbose {
println!(" {}{}", def.id, marker);
println!(" Description: {}", def.description);
println!(" Default model: {}", def.default_model);
println!(" Protocol: {:?}", def.protocol);
println!(" Model env: {}", def.model_env);
if let Some(ref env) = def.api_key_env {
println!(
" API key env: {} ({})",
env,
if def.api_key_required {
"required"
} else {
"optional"
}
);
}
if let Some(ref url) = def.default_base_url {
println!(" Base URL: {}", url);
}
if !def.aliases.is_empty() {
println!(" Aliases: {}", def.aliases.join(", "));
}
if is_active {
println!(" Active model: {}", active_model);
}
println!();
} else {
let model_display = if is_active {
active_model.clone()
} else {
def.default_model.clone()
};
println!(
" {:<22} {:<40} {}",
format!("{}{marker}", def.id),
model_display,
def.description,
);
}
}
if !verbose {
println!();
println!("* = active provider. Use --verbose for details.");
}
Ok(())
}
/// Show details for a specific provider.
async fn cmd_show_provider(
id: &str,
verbose: bool,
json: bool,
config_path: Option<&Path>,
) -> anyhow::Result<()> {
let registry = ProviderRegistry::load();
let (active_backend, active_model) = resolve_active(config_path);
// Resolve canonical ID for model fetching
let canonical_id = if id == "nearai" || id == "near_ai" || id == "near" {
"nearai".to_string()
} else {
registry
.find(id)
.map(|d| d.id.clone())
.unwrap_or_else(|| id.to_string())
};
// Try to fetch live model list from the provider
let live_models = try_fetch_models(&canonical_id, config_path).await;
// Check NEAR AI first (not in registry)
if id == "nearai" || id == "near_ai" || id == "near" {
let is_active = active_backend == "nearai";
if json {
let mut v = serde_json::json!({
"id": "nearai",
"description": "NEAR AI inference (default)",
"default_model": "qwen2.5-72b-instruct:free",
"model_env": "NEARAI_MODEL",
"active": is_active,
});
if is_active {
v["active_model"] = serde_json::json!(active_model);
}
if let Some(ref models) = live_models {
v["available_models"] = serde_json::json!(models);
}
println!(
"{}",
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
);
} else {
println!("Provider: nearai");
println!(" Description: NEAR AI inference (default)");
println!(" Default model: qwen2.5-72b-instruct:free");
println!(" Model env: NEARAI_MODEL");
println!(" Active: {}", if is_active { "yes" } else { "no" });
if is_active {
println!(" Active model: {}", active_model);
}
print_model_list(&live_models, is_active.then_some(&active_model));
}
return Ok(());
}
let def = registry.find(id).ok_or_else(|| {
let known: Vec<&str> = std::iter::once("nearai")
.chain(registry.all().iter().map(|d| d.id.as_str()))
.collect();
anyhow::anyhow!(
"Unknown provider '{}'. Known providers: {}",
id,
known.join(", ")
)
})?;
let is_active = active_backend == def.id;
if json {
let mut v = serde_json::json!({
"id": def.id,
"description": def.description,
"protocol": format!("{:?}", def.protocol),
"default_model": def.default_model,
"model_env": def.model_env,
"api_key_env": def.api_key_env,
"api_key_required": def.api_key_required,
"aliases": def.aliases,
"active": is_active,
});
if let Some(ref url) = def.default_base_url {
v["base_url"] = serde_json::json!(url);
}
if let Some(ref setup) = def.setup {
v["can_list_models"] = serde_json::json!(setup.can_list_models());
v["display_name"] = serde_json::json!(setup.display_name());
}
if is_active {
v["active_model"] = serde_json::json!(active_model);
}
if verbose && !def.unsupported_params.is_empty() {
v["unsupported_params"] = serde_json::json!(def.unsupported_params);
}
if let Some(ref models) = live_models {
v["available_models"] = serde_json::json!(models);
}
println!(
"{}",
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
);
return Ok(());
}
println!("Provider: {}", def.id);
println!(" Description: {}", def.description);
println!(" Protocol: {:?}", def.protocol);
println!(" Default model: {}", def.default_model);
println!(" Model env: {}", def.model_env);
if let Some(ref env) = def.api_key_env {
println!(
" API key env: {} ({})",
env,
if def.api_key_required {
"required"
} else {
"optional"
}
);
}
if let Some(ref url) = def.default_base_url {
println!(" Base URL: {}", url);
}
if !def.aliases.is_empty() {
println!(" Aliases: {}", def.aliases.join(", "));
}
if let Some(ref setup) = def.setup {
println!(
" List models: {}",
if setup.can_list_models() {
"supported"
} else {
"not supported"
}
);
println!(" Display name: {}", setup.display_name());
}
if !def.unsupported_params.is_empty() {
println!(" Unsupported: {}", def.unsupported_params.join(", "));
}
println!(" Active: {}", if is_active { "yes" } else { "no" });
if is_active {
println!(" Active model: {}", active_model);
}
print_model_list(&live_models, is_active.then_some(&active_model));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_active_defaults_to_nearai() {
let settings = Settings::default();
assert!(settings.llm_backend.is_none());
assert!(settings.selected_model.is_none());
}
#[test]
fn registry_loads_all_providers() {
let registry = ProviderRegistry::load();
let all = registry.all();
assert!(
all.len() >= 10,
"should have at least 10 built-in providers, got {}",
all.len()
);
}
#[test]
fn registry_find_by_alias() {
let registry = ProviderRegistry::load();
let def = registry
.find("claude")
.expect("claude alias should resolve");
assert_eq!(def.id, "anthropic");
}
#[test]
fn all_providers_have_description() {
let registry = ProviderRegistry::load();
for def in registry.all() {
assert!(
!def.description.is_empty(),
"provider {} should have a description",
def.id
);
}
}
#[test]
fn set_model_persists_to_toml() {
let dir = tempfile::tempdir().expect("create temp dir");
let toml_path = dir.path().join("config.toml");
cmd_set_model("gpt-5-mini", Some(&toml_path)).expect("set model");
let settings = Settings::load_toml(&toml_path)
.expect("read toml")
.expect("should have settings");
assert_eq!(settings.selected_model.as_deref(), Some("gpt-5-mini"));
}
#[test]
fn set_provider_validates_unknown() {
let dir = tempfile::tempdir().expect("create temp dir");
let toml_path = dir.path().join("config.toml");
let result = cmd_set_provider("nonexistent_provider", None, Some(&toml_path));
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("Unknown provider"),
"should mention unknown provider: {}",
err
);
}
#[test]
fn set_provider_persists_to_toml() {
let dir = tempfile::tempdir().expect("create temp dir");
let toml_path = dir.path().join("config.toml");
cmd_set_provider("groq", None, Some(&toml_path)).expect("set provider");
let settings = Settings::load_toml(&toml_path)
.expect("read toml")
.expect("should have settings");
assert_eq!(settings.llm_backend.as_deref(), Some("groq"));
assert_eq!(
settings.selected_model.as_deref(),
Some("llama-3.3-70b-versatile")
);
}
#[test]
fn set_provider_with_custom_model() {
let dir = tempfile::tempdir().expect("create temp dir");
let toml_path = dir.path().join("config.toml");
cmd_set_provider("anthropic", Some("claude-opus-4-6"), Some(&toml_path))
.expect("set provider with model");
let settings = Settings::load_toml(&toml_path)
.expect("read toml")
.expect("should have settings");
assert_eq!(settings.llm_backend.as_deref(), Some("anthropic"));
assert_eq!(settings.selected_model.as_deref(), Some("claude-opus-4-6"));
}
#[test]
fn custom_config_does_not_pollute_default_dotenv() {
let dir = tempfile::tempdir().expect("create temp dir");
let toml_path = dir.path().join("config.toml");
// With a custom config path, sync_to_dotenv should be a no-op
// (it returns early when config_path is Some).
// We verify by checking that cmd_set_provider succeeds without
// trying to write to the default ~/.ironclaw/.env.
cmd_set_provider("groq", None, Some(&toml_path)).expect("set provider with custom config");
let settings = Settings::load_toml(&toml_path)
.expect("read toml")
.expect("should have settings");
assert_eq!(settings.llm_backend.as_deref(), Some("groq"));
// The key assertion is that no error was thrown trying to write
// to the default .env — sync_to_dotenv skipped it.
}
#[test]
fn set_model_rejects_empty_name() {
let dir = tempfile::tempdir().expect("create temp dir");
let toml_path = dir.path().join("config.toml");
let result = cmd_set_model("", Some(&toml_path));
assert!(result.is_err());
assert!(
result.unwrap_err().to_string().contains("cannot be empty"),
"should reject empty model name"
);
let result2 = cmd_set_model(" ", Some(&toml_path));
assert!(result2.is_err());
}
#[test]
fn set_provider_normalizes_alias() {
let dir = tempfile::tempdir().expect("create temp dir");
let toml_path = dir.path().join("config.toml");
cmd_set_provider("claude", None, Some(&toml_path)).expect("set via alias");
let settings = Settings::load_toml(&toml_path)
.expect("read toml")
.expect("should have settings");
assert_eq!(
settings.llm_backend.as_deref(),
Some("anthropic"),
"alias should be normalized to canonical ID"
);
}
}
@@ -20,6 +20,7 @@ Commands:
service Manage OS service
skills Manage skills
hooks Manage lifecycle hooks
models Manage LLM providers and models
doctor Run diagnostics
logs View and manage gateway logs
status Show system status
@@ -20,6 +20,7 @@ Commands:
service Manage OS service
skills Manage skills
hooks Manage lifecycle hooks
models Manage LLM providers and models
doctor Run diagnostics
logs View and manage gateway logs
status Show system status
@@ -23,6 +23,7 @@ Commands:
service Manage OS service
skills Manage skills
hooks Manage lifecycle hooks
models Manage LLM providers and models
doctor Run diagnostics
logs View and manage gateway logs
status Show system status
@@ -23,6 +23,7 @@ Commands:
service Manage OS service
skills Manage skills
hooks Manage lifecycle hooks
models Manage LLM providers and models
doctor Run diagnostics
logs View and manage gateway logs
status Show system status
+8 -7
View File
@@ -24,7 +24,7 @@ mod skills;
mod transcription;
mod tunnel;
mod wasm;
mod workspace;
pub(crate) mod workspace;
use std::collections::HashMap;
use std::sync::{LazyLock, Mutex, Once};
@@ -178,9 +178,7 @@ impl Config {
},
transcription: TranscriptionConfig::default(),
search: WorkspaceSearchConfig::default(),
workspace: WorkspaceConfig {
memory_layers: vec![],
},
workspace: WorkspaceConfig::default(),
observability: crate::observability::ObservabilityConfig::default(),
relay: None,
}
@@ -313,11 +311,14 @@ impl Config {
let tunnel = TunnelConfig::resolve(settings)?;
let channels = ChannelsConfig::resolve(settings, &owner_id)?;
// Resolve workspace config using the gateway user_id for default layers.
let workspace_user_id = channels
.gateway
.as_ref()
.map(|gw| gw.user_id.clone())
.unwrap_or_else(|| "default".to_string());
.map(|gw| gw.user_id.as_str())
.unwrap_or("default");
let workspace = WorkspaceConfig::resolve(workspace_user_id)?;
Ok(Self {
owner_id: owner_id.clone(),
@@ -339,7 +340,7 @@ impl Config {
skills: SkillsConfig::resolve()?,
transcription: TranscriptionConfig::resolve(settings)?,
search: WorkspaceSearchConfig::resolve()?,
workspace: WorkspaceConfig::resolve(&workspace_user_id)?,
workspace,
observability: crate::observability::ObservabilityConfig {
backend: std::env::var("OBSERVABILITY_BACKEND").unwrap_or_else(|_| "none".into()),
},
+68 -7
View File
@@ -2,18 +2,29 @@ use crate::config::helpers::optional_env;
use crate::error::ConfigError;
use crate::workspace::layer::MemoryLayer;
/// Workspace memory configuration.
/// Workspace-level configuration (memory layers, read scopes).
///
/// Controls memory layer definitions for privacy-aware writes.
/// Layers are parsed from the `MEMORY_LAYERS` env var (JSON array)
/// or default to a single private layer scoped to the gateway user.
#[derive(Debug, Clone)]
/// 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 {
pub(crate) fn resolve(user_id: &str) -> Result<Self, ConfigError> {
/// 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 {
@@ -57,6 +68,20 @@ impl WorkspaceConfig {
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
@@ -72,7 +97,43 @@ impl WorkspaceConfig {
}
}
Ok(Self { memory_layers })
// --- 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,
})
}
}
+97
View File
@@ -644,6 +644,103 @@ pub trait WorkspaceStore: Send + Sync {
embedding: Option<&[f32]>,
config: &SearchConfig,
) -> Result<Vec<SearchResult>, WorkspaceError>;
// ==================== Multi-scope read methods ====================
//
// Default implementations loop over user_ids calling single-scope methods,
// then merge results. Backends can override with efficient SQL (e.g.,
// `WHERE user_id = ANY($1::text[])`).
/// Hybrid search across multiple user scopes, merging results by score.
///
/// **Note:** The default implementation calls `hybrid_search` per scope and
/// merges by raw score. Because RRF scores are normalized independently
/// within each scope, scores are not directly comparable across scopes.
/// The Postgres backend overrides this with a single combined query that
/// applies RRF once to the unified result set.
async fn hybrid_search_multi(
&self,
user_ids: &[String],
agent_id: Option<Uuid>,
query: &str,
embedding: Option<&[f32]>,
config: &SearchConfig,
) -> Result<Vec<SearchResult>, WorkspaceError> {
if user_ids.len() > 1 {
tracing::debug!(
scope_count = user_ids.len(),
"hybrid_search_multi: using default per-scope RRF merge; \
cross-scope score comparison may be unreliable"
);
}
let mut all_results = Vec::new();
for uid in user_ids {
let results = self
.hybrid_search(uid, agent_id, query, embedding, config)
.await?;
all_results.extend(results);
}
// Re-sort by score descending and truncate to limit
all_results.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
all_results.truncate(config.limit);
Ok(all_results)
}
/// List all file paths across multiple user scopes.
async fn list_all_paths_multi(
&self,
user_ids: &[String],
agent_id: Option<Uuid>,
) -> Result<Vec<String>, WorkspaceError> {
let mut all_paths = Vec::new();
for uid in user_ids {
let paths = self.list_all_paths(uid, agent_id).await?;
all_paths.extend(paths);
}
all_paths.sort();
all_paths.dedup();
Ok(all_paths)
}
/// Get a document by path, searching across multiple user scopes.
///
/// Returns the first match found (tries each user_id in order).
async fn get_document_by_path_multi(
&self,
user_ids: &[String],
agent_id: Option<Uuid>,
path: &str,
) -> Result<MemoryDocument, WorkspaceError> {
for uid in user_ids {
match self.get_document_by_path(uid, agent_id, path).await {
Ok(doc) => return Ok(doc),
Err(WorkspaceError::DocumentNotFound { .. }) => continue,
Err(e) => return Err(e),
}
}
Err(WorkspaceError::DocumentNotFound {
doc_type: path.to_string(),
user_id: format!("[{}]", user_ids.join(", ")),
})
}
/// List directory contents across multiple user scopes.
async fn list_directory_multi(
&self,
user_ids: &[String],
agent_id: Option<Uuid>,
directory: &str,
) -> Result<Vec<WorkspaceEntry>, WorkspaceError> {
let mut all_entries = Vec::new();
for uid in user_ids {
all_entries.extend(self.list_directory(uid, agent_id, directory).await?);
}
Ok(crate::workspace::merge_workspace_entries(all_entries))
}
}
/// Backend-agnostic database supertrait.
+45
View File
@@ -717,4 +717,49 @@ impl WorkspaceStore for PgBackend {
.hybrid_search(user_id, agent_id, query, embedding, config)
.await
}
// Optimized multi-scope overrides using `ANY($1::text[])` SQL.
async fn hybrid_search_multi(
&self,
user_ids: &[String],
agent_id: Option<Uuid>,
query: &str,
embedding: Option<&[f32]>,
config: &SearchConfig,
) -> Result<Vec<SearchResult>, WorkspaceError> {
self.repo
.hybrid_search_multi(user_ids, agent_id, query, embedding, config)
.await
}
async fn list_all_paths_multi(
&self,
user_ids: &[String],
agent_id: Option<Uuid>,
) -> Result<Vec<String>, WorkspaceError> {
self.repo.list_all_paths_multi(user_ids, agent_id).await
}
async fn get_document_by_path_multi(
&self,
user_ids: &[String],
agent_id: Option<Uuid>,
path: &str,
) -> Result<MemoryDocument, WorkspaceError> {
self.repo
.get_document_by_path_multi(user_ids, agent_id, path)
.await
}
async fn list_directory_multi(
&self,
user_ids: &[String],
agent_id: Option<Uuid>,
directory: &str,
) -> Result<Vec<WorkspaceEntry>, WorkspaceError> {
self.repo
.list_directory_multi(user_ids, agent_id, directory)
.await
}
}
-3
View File
@@ -304,9 +304,6 @@ pub enum WorkspaceError {
#[error("I/O error: {reason}")]
IoError { reason: String },
#[error("Not found: {path}")]
NotFound { path: String },
#[error("Layer not found: {name}")]
LayerNotFound { name: String },
+216
View File
@@ -1498,6 +1498,52 @@ impl ExtensionManager {
Ok(extensions)
}
/// Build a compact, deterministic extension snapshot for LLM prompt context.
pub async fn llm_extension_state_summary(&self) -> Result<Option<String>, ExtensionError> {
let mut extensions = self.list(None, false).await?;
extensions.sort_by(|a, b| {
llm_extension_sort_key(a.kind)
.cmp(&llm_extension_sort_key(b.kind))
.then_with(|| a.name.cmp(&b.name))
});
let mut channels = Vec::new();
let mut tools = Vec::new();
let mut servers = Vec::new();
for extension in extensions {
let owner_bound = matches!(extension.kind, ExtensionKind::WasmChannel)
&& self.has_wasm_channel_owner_binding(&extension.name).await;
if !(extension.active || extension.authenticated || owner_bound) {
continue;
}
let item = llm_extension_summary_item(&extension, owner_bound);
match extension.kind {
ExtensionKind::WasmChannel | ExtensionKind::ChannelRelay => channels.push(item),
ExtensionKind::WasmTool => tools.push(item),
ExtensionKind::McpServer => servers.push(item),
}
}
let mut lines = Vec::new();
if !channels.is_empty() {
lines.push(format!("- Channels: {}", channels.join("; ")));
}
if !tools.is_empty() {
lines.push(format!("- Tools: {}", tools.join("; ")));
}
if !servers.is_empty() {
lines.push(format!("- MCP servers: {}", servers.join("; ")));
}
if lines.is_empty() {
Ok(None)
} else {
Ok(Some(lines.join("\n")))
}
}
/// Remove an installed extension.
pub async fn remove(&self, name: &str) -> Result<String, ExtensionError> {
Self::validate_extension_name(name)?;
@@ -5613,6 +5659,40 @@ fn combine_install_errors(
}
}
fn llm_extension_sort_key(kind: ExtensionKind) -> u8 {
match kind {
ExtensionKind::WasmChannel | ExtensionKind::ChannelRelay => 0,
ExtensionKind::WasmTool => 1,
ExtensionKind::McpServer => 2,
}
}
fn llm_extension_summary_item(extension: &InstalledExtension, owner_bound: bool) -> String {
let mut states = Vec::new();
if extension.authenticated {
states.push("authenticated".to_string());
}
if extension.active {
states.push("active".to_string());
} else if extension.authenticated {
states.push("inactive".to_string());
}
if owner_bound {
states.push("owner-bound".to_string());
}
if !extension.tools.is_empty() {
let mut tool_names = extension.tools.clone();
tool_names.sort();
states.push(format!("tools: {}", tool_names.join(", ")));
}
if states.is_empty() {
extension.name.clone()
} else {
format!("{} ({})", extension.name, states.join(", "))
}
}
#[cfg(test)]
mod tests {
use std::fmt::Debug;
@@ -6571,6 +6651,142 @@ mod tests {
)
}
#[cfg(feature = "libsql")]
#[tokio::test]
async fn test_llm_extension_state_summary_reports_active_owner_bound_telegram()
-> Result<(), String> {
let dir = tempfile::tempdir().map_err(|err| format!("temp dir: {err}"))?;
let channels_dir = dir.path().join("channels");
std::fs::create_dir_all(&channels_dir).map_err(|err| format!("channels dir: {err}"))?;
std::fs::write(channels_dir.join("telegram.wasm"), b"mock")
.map_err(|err| format!("write wasm: {err}"))?;
std::fs::write(
channels_dir.join("telegram.capabilities.json"),
serde_json::to_vec(&serde_json::json!({
"type": "channel",
"name": "telegram",
"setup": {
"required_secrets": [
{
"name": "telegram_bot_token",
"prompt": "Enter your Telegram Bot API token (from @BotFather)",
"optional": false
}
]
},
"capabilities": {
"channel": {
"allowed_paths": ["/webhook/telegram"]
}
},
"config": {
"owner_id": null
}
}))
.map_err(|err| format!("serialize capabilities: {err}"))?,
)
.map_err(|err| format!("write capabilities: {err}"))?;
let (db, _db_tmp) = crate::testing::test_db().await;
let manager = {
use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
use crate::testing::credentials::TEST_CRYPTO_KEY;
use crate::tools::ToolRegistry;
use crate::tools::mcp::process::McpProcessManager;
use crate::tools::mcp::session::McpSessionManager;
let master_key = secrecy::SecretString::from(TEST_CRYPTO_KEY.to_string());
let crypto = Arc::new(
SecretsCrypto::new(master_key)
.map_err(|err| format!("failed to construct test crypto: {err}"))?,
);
ExtensionManager::new(
Arc::new(McpSessionManager::new()),
Arc::new(McpProcessManager::new()),
Arc::new(InMemorySecretsStore::new(crypto)),
Arc::new(ToolRegistry::new()),
None,
None,
dir.path().join("tools"),
channels_dir.clone(),
None,
"test".to_string(),
Some(db),
Vec::new(),
)
};
let channel_manager = Arc::new(ChannelManager::new());
let runtime = Arc::new(
WasmChannelRuntime::new(WasmChannelRuntimeConfig::for_testing())
.map_err(|err| format!("runtime: {err}"))?,
);
let pairing_store = Arc::new(PairingStore::with_base_dir(
dir.path().join("pairing-state"),
));
let router = Arc::new(WasmChannelRouter::new());
manager
.set_channel_runtime(
Arc::clone(&channel_manager),
Arc::clone(&runtime),
Arc::clone(&pairing_store),
Arc::clone(&router),
std::collections::HashMap::new(),
)
.await;
manager
.set_test_wasm_channel_loader(Arc::new({
let runtime = Arc::clone(&runtime);
let pairing_store = Arc::clone(&pairing_store);
move |name| {
Ok(make_test_loaded_channel(
Arc::clone(&runtime),
name,
Arc::clone(&pairing_store),
))
}
}))
.await;
manager
.set_test_telegram_binding_resolver(Arc::new(|_token, existing_owner_id| {
if existing_owner_id.is_some() {
return Err(ExtensionError::Other(
"owner binding should be derived during setup".to_string(),
));
}
Ok(TelegramBindingResult::Bound(TelegramBindingData {
owner_id: 424242,
bot_username: Some("test_hot_bot".to_string()),
binding_state: TelegramOwnerBindingState::VerifiedNow,
}))
}))
.await;
manager
.configure(
"telegram",
&std::collections::HashMap::from([(
"telegram_bot_token".to_string(),
"123456789:ABCdefGhI".to_string(),
)]),
&std::collections::HashMap::new(),
)
.await
.map_err(|err| format!("configure succeeds: {err}"))?;
let summary = manager
.llm_extension_state_summary()
.await
.map_err(|err| format!("summary: {err}"))?
.ok_or_else(|| "expected extension summary".to_string())?;
require(
summary.contains("- Channels: telegram (authenticated, active, owner-bound)"),
format!("unexpected summary: {summary}"),
)
}
#[tokio::test]
async fn test_telegram_hot_activation_returns_verification_challenge_before_binding()
-> Result<(), String> {
+92 -12
View File
@@ -353,6 +353,8 @@ pub struct Reasoning {
workspace_system_prompt: Option<String>,
/// Optional skill context block to inject into system prompt.
skill_context: Option<String>,
/// Optional snapshot of connected/active extensions for the current user.
extension_state_summary: Option<String>,
/// Channel name (e.g. "discord", "telegram") for formatting hints.
channel: Option<String>,
/// Model name for runtime context.
@@ -371,6 +373,7 @@ impl Reasoning {
llm,
workspace_system_prompt: None,
skill_context: None,
extension_state_summary: None,
channel: None,
model_name: None,
is_group_chat: false,
@@ -400,6 +403,14 @@ impl Reasoning {
self
}
/// Set extension runtime context to inject into the system prompt.
pub fn with_extension_state_summary(mut self, summary: String) -> Self {
if !summary.is_empty() {
self.extension_state_summary = Some(summary);
}
self
}
/// Set the channel name for channel-specific formatting hints.
pub fn with_channel(mut self, channel: impl Into<String>) -> Self {
let ch = channel.into();
@@ -932,21 +943,54 @@ Example:
}
fn build_extensions_section_for_tools(&self, tools: &[ToolDefinition]) -> String {
// Only include when the extension management tools are available
let has_ext_tools = tools.iter().any(|t| t.name == "tool_search");
if !has_ext_tools {
let has_search = tools.iter().any(|t| t.name == "tool_search");
let has_list = tools.iter().any(|t| t.name == "tool_list");
let has_info = tools.iter().any(|t| t.name == "extension_info");
if self.extension_state_summary.is_none() && !has_search && !has_list && !has_info {
return String::new();
}
"\n\n## Extensions\n\
You can search, install, and activate extensions to add new capabilities:\n\
- **Channels** (Telegram, Slack, Discord) messaging integrations. \
When users ask about connecting a messaging platform, search for it as a channel.\n\
- **Tools** sandboxed functions that extend your abilities.\n\
- **MCP servers** external API integrations via the Model Context Protocol.\n\n\
Use `tool_search` to find extensions by name. Refer to them by their kind \
(channel, tool, or server) not as \"MCP server\" generically."
.to_string()
let mut blocks = Vec::new();
if let Some(ref summary) = self.extension_state_summary {
blocks.push(format!(
"Current extension state for this user:\n{}",
summary
));
}
if has_search || has_list || has_info {
let mut guidance = String::from(
"You can search, install, and activate extensions to add new capabilities:\n\
- **Channels** (Telegram, Slack, Discord) messaging integrations. \
When users ask about connecting a messaging platform, search for it as a channel.\n\
- **Tools** sandboxed functions that extend your abilities.\n\
- **MCP servers** external API integrations via the Model Context Protocol.",
);
if has_list {
guidance.push_str(
"\n\nBefore telling the user to connect, activate, or re-enable an extension, \
inspect the current state with `tool_list`.",
);
}
if has_info {
guidance.push_str(
"\nUse `extension_info` when you need deeper compatibility or runtime details \
for an installed extension.",
);
}
if has_search {
guidance.push_str(
"\nUse `tool_search` to find extensions by name. Refer to them by their kind \
(channel, tool, or server) not as \"MCP server\" generically.",
);
}
blocks.push(guidance);
}
format!("\n\n## Extensions\n{}", blocks.join("\n\n"))
}
fn build_channel_section(&self) -> String {
@@ -2285,6 +2329,42 @@ That's my plan."#;
);
}
#[test]
fn test_system_prompt_includes_extension_runtime_summary() {
let reasoning = make_test_reasoning().with_extension_state_summary(
"- Channels: telegram (authenticated, active, owner-bound)".to_string(),
);
let prompt = reasoning.build_system_prompt_with_tools(&[]);
assert!(
prompt.contains("## Extensions"),
"Prompt should contain an Extensions section when runtime state is present"
);
assert!(
prompt.contains("telegram (authenticated, active, owner-bound)"),
"Prompt should include the injected extension runtime summary"
);
}
#[test]
fn test_system_prompt_extension_guidance_prefers_inspection_before_reconnect() {
let reasoning = make_test_reasoning();
let prompt = reasoning.build_system_prompt_with_tools(&make_tools(&[
"tool_search",
"tool_list",
"extension_info",
]));
assert!(
prompt.contains("inspect the current state with `tool_list`"),
"Prompt should direct the model to inspect installed/active state first"
);
assert!(
prompt.contains("Use `extension_info` when you need deeper compatibility"),
"Prompt should mention extension_info for deeper extension details"
);
}
// ---- plan/evaluate bypass clean_response (Bug #564-2) ----
#[test]
+5
View File
@@ -142,6 +142,11 @@ async fn async_main() -> anyhow::Result<()> {
init_cli_tracing();
return ironclaw::cli::run_logs_command(logs_cmd.clone(), cli.config.as_deref()).await;
}
Some(Command::Models(models_cmd)) => {
init_cli_tracing();
return ironclaw::cli::run_models_command(models_cmd.clone(), cli.config.as_deref())
.await;
}
Some(Command::Doctor) => {
init_cli_tracing();
return ironclaw::cli::run_doctor_command().await;
+70 -1
View File
@@ -28,7 +28,7 @@ use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use async_trait::async_trait;
use rust_decimal::Decimal;
use tokio::sync::mpsc;
use tokio::sync::{Mutex as AsyncMutex, mpsc};
use crate::agent::AgentDeps;
use crate::channels::{
@@ -361,6 +361,75 @@ impl Channel for StubChannel {
}
}
/// Captured broadcast deliveries keyed by the target user or chat identifier.
pub type BroadcastCapture = Arc<AsyncMutex<Vec<(String, OutgoingResponse)>>>;
/// A lightweight channel double that only records `broadcast()` traffic.
///
/// This is useful for unit tests that need to assert message routing without
/// spinning up a full interactive channel harness.
pub struct RecordingBroadcastChannel {
name: &'static str,
captures: BroadcastCapture,
}
impl RecordingBroadcastChannel {
pub fn new(name: &'static str) -> (Self, BroadcastCapture) {
let captures = Arc::new(AsyncMutex::new(Vec::new()));
(
Self {
name,
captures: Arc::clone(&captures),
},
captures,
)
}
}
#[async_trait]
impl Channel for RecordingBroadcastChannel {
fn name(&self) -> &str {
self.name
}
async fn start(&self) -> Result<MessageStream, ChannelError> {
let (_tx, rx) = mpsc::channel::<IncomingMessage>(1);
Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
}
async fn respond(
&self,
_msg: &IncomingMessage,
_response: OutgoingResponse,
) -> Result<(), ChannelError> {
Ok(())
}
async fn send_status(
&self,
_status: StatusUpdate,
_metadata: &serde_json::Value,
) -> Result<(), ChannelError> {
Ok(())
}
async fn broadcast(
&self,
user_id: &str,
response: OutgoingResponse,
) -> Result<(), ChannelError> {
self.captures
.lock()
.await
.push((user_id.to_string(), response));
Ok(())
}
async fn health_check(&self) -> Result<(), ChannelError> {
Ok(())
}
}
/// Assembled test components.
pub struct TestHarness {
/// The agent dependencies, ready for use.
+3 -4
View File
@@ -271,12 +271,13 @@ impl Tool for MemoryWriteTool {
.and_then(|v| v.as_bool())
.unwrap_or(false);
// Parse timezone once for targets that need it (daily_log).
let tz = crate::timezone::parse_timezone(&ctx.user_timezone).unwrap_or(chrono_tz::Tz::UTC);
// Resolve the target to a workspace path
let resolved_path = match target {
"memory" => paths::MEMORY.to_string(),
"daily_log" => {
let tz = crate::timezone::parse_timezone(&ctx.user_timezone)
.unwrap_or(chrono_tz::Tz::UTC);
let now = chrono::Utc::now().with_timezone(&tz);
format!("daily/{}.md", now.format("%Y-%m-%d"))
}
@@ -318,8 +319,6 @@ impl Tool for MemoryWriteTool {
}
}
"daily_log" => {
let tz = crate::timezone::parse_timezone(&ctx.user_timezone)
.unwrap_or(chrono_tz::Tz::UTC);
self.workspace
.append_daily_log_tz(content, tz)
.await
+76 -91
View File
@@ -80,6 +80,12 @@ fn metadata_notify_user(metadata: &serde_json::Value) -> Option<String> {
metadata_string(metadata, "notify_user").filter(|value| value != "default")
}
// Autonomous runs include `owner_id` when the job is executing on behalf of a
// durable owner scope instead of an interactive channel actor.
fn metadata_owner_id(metadata: &serde_json::Value) -> Option<String> {
metadata_string(metadata, "owner_id")
}
fn channel_matches_source(resolved_channel: Option<&str>, source_channel: Option<&str>) -> bool {
match (resolved_channel, source_channel) {
(None, _) => true,
@@ -91,11 +97,13 @@ fn channel_matches_source(resolved_channel: Option<&str>, source_channel: Option
async fn resolve_channel_fallback_target(
extension_manager: Option<&Arc<ExtensionManager>>,
channel: Option<&str>,
owner_scope_target: Option<&str>,
ctx_user_id: &str,
) -> Option<String> {
let channel_name = channel?;
if let Some(extension_manager) = extension_manager
// Prefer an explicit channel binding when the extension manager knows the
// durable delivery target (for example, a bound Telegram chat ID).
if let Some(channel_name) = channel
&& let Some(extension_manager) = extension_manager
&& let Some(target) = extension_manager
.notification_target_for_channel(channel_name)
.await
@@ -103,13 +111,19 @@ async fn resolve_channel_fallback_target(
return Some(target);
}
Some(ctx_user_id.to_string())
// `owner_id` is only present for autonomous owner-scoped executions.
// Interactive chat turns intentionally fall back to `ctx.user_id`, which is
// already the active conversation target for the current channel.
owner_scope_target
.map(ToOwned::to_owned)
.or_else(|| Some(ctx_user_id.to_string()))
}
struct MessageTargetResolution<'a> {
extension_manager: Option<&'a Arc<ExtensionManager>>,
explicit_target: Option<String>,
metadata_target: Option<String>,
owner_scope_target: Option<String>,
default_target: Option<String>,
channel: Option<&'a str>,
metadata_channel: Option<&'a str>,
@@ -133,6 +147,7 @@ async fn resolve_message_target(inputs: MessageTargetResolution<'_>) -> Option<S
return resolve_channel_fallback_target(
inputs.extension_manager,
inputs.channel,
inputs.owner_scope_target.as_deref(),
inputs.ctx_user_id,
)
.await;
@@ -145,9 +160,12 @@ async fn resolve_message_target(inputs: MessageTargetResolution<'_>) -> Option<S
}
if inputs.channel.is_some() {
// Shared per-turn conversation defaults are already scoped to the
// active interactive target, so owner scope metadata is irrelevant.
return resolve_channel_fallback_target(
inputs.extension_manager,
inputs.channel,
None,
inputs.ctx_user_id,
)
.await;
@@ -224,8 +242,9 @@ impl Tool for MessageTool {
.unwrap_or_else(|e| e.into_inner())
.clone();
let metadata_target = metadata_notify_user(&ctx.metadata);
let owner_scope_target = metadata_owner_id(&ctx.metadata);
let has_execution_routing_metadata =
metadata_channel.is_some() || metadata_target.is_some();
metadata_channel.is_some() || metadata_target.is_some() || owner_scope_target.is_some();
// Job metadata is authoritative for autonomous executions. The shared
// conversation defaults are only a legacy fallback when no execution-local
@@ -250,6 +269,7 @@ impl Tool for MessageTool {
extension_manager: self.extension_manager.as_ref(),
explicit_target,
metadata_target,
owner_scope_target,
default_target,
channel: channel.as_deref(),
metadata_channel: metadata_channel.as_deref(),
@@ -405,83 +425,13 @@ impl Tool for MessageTool {
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use tokio::sync::{Mutex, mpsc};
use crate::channels::{
Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate,
};
use crate::error::ChannelError;
type BroadcastCapture = Arc<Mutex<Vec<(String, OutgoingResponse)>>>;
struct RecordingChannel {
name: &'static str,
captures: BroadcastCapture,
}
impl RecordingChannel {
fn new(name: &'static str) -> (Self, BroadcastCapture) {
let captures = Arc::new(Mutex::new(Vec::new()));
(
Self {
name,
captures: Arc::clone(&captures),
},
captures,
)
}
}
#[async_trait]
impl Channel for RecordingChannel {
fn name(&self) -> &str {
self.name
}
async fn start(&self) -> Result<MessageStream, ChannelError> {
let (_tx, rx) = mpsc::channel::<IncomingMessage>(1);
Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
}
async fn respond(
&self,
_msg: &IncomingMessage,
_response: OutgoingResponse,
) -> Result<(), ChannelError> {
Ok(())
}
async fn send_status(
&self,
_status: StatusUpdate,
_metadata: &serde_json::Value,
) -> Result<(), ChannelError> {
Ok(())
}
async fn broadcast(
&self,
user_id: &str,
response: OutgoingResponse,
) -> Result<(), ChannelError> {
self.captures
.lock()
.await
.push((user_id.to_string(), response));
Ok(())
}
async fn health_check(&self) -> Result<(), ChannelError> {
Ok(())
}
}
use crate::testing::{BroadcastCapture, RecordingBroadcastChannel};
async fn message_tool_with_recording_channels()
-> (MessageTool, BroadcastCapture, BroadcastCapture) {
let channel_manager = ChannelManager::new();
let (gateway, gateway_captures) = RecordingChannel::new("gateway");
let (telegram, telegram_captures) = RecordingChannel::new("telegram");
let (gateway, gateway_captures) = RecordingBroadcastChannel::new("gateway");
let (telegram, telegram_captures) = RecordingBroadcastChannel::new("telegram");
channel_manager.add(Box::new(gateway)).await;
channel_manager.add(Box::new(telegram)).await;
@@ -870,28 +820,63 @@ mod tests {
}
#[tokio::test]
async fn message_tool_falls_back_to_ctx_user_when_channel_known() {
// Regression for owner-scoped notifications: a channel can be known
// even when the concrete delivery target is omitted, so the message
// tool should pass ctx.user_id through to the channel layer.
let tool = MessageTool::new(Arc::new(ChannelManager::new()));
async fn message_tool_falls_back_to_owner_scope_when_channel_known() {
let (tool, gateway_captures, telegram_captures) =
message_tool_with_recording_channels().await;
let mut ctx =
crate::context::JobContext::with_user("owner-scope", "routine-job", "price alert");
crate::context::JobContext::with_user("telegram", "routine-job", "price alert");
ctx.metadata = serde_json::json!({
"notify_channel": "telegram",
"owner_id": "owner-scope",
});
let result = tool
.execute(serde_json::json!({"content": "NEAR price is $5"}), &ctx)
.await
.expect("message tool should use owner scope before ctx.user_id");
assert_eq!(
result.result.as_str(),
Some("Sent message to telegram:owner-scope")
);
assert!(gateway_captures.lock().await.is_empty());
let telegram = telegram_captures.lock().await.clone();
assert_eq!(telegram.len(), 1);
assert_eq!(telegram[0].0, "owner-scope");
assert_eq!(telegram[0].1.content, "NEAR price is $5");
}
#[tokio::test]
async fn message_tool_falls_back_to_ctx_user_when_owner_scope_absent() {
let (tool, gateway_captures, telegram_captures) =
message_tool_with_recording_channels().await;
let mut ctx = crate::context::JobContext::with_user(
"interactive-chat-user",
"routine-job",
"price alert",
);
ctx.metadata = serde_json::json!({
"notify_channel": "telegram",
});
let result = tool
.execute(serde_json::json!({"content": "NEAR price is $5"}), &ctx)
.await;
.await
.expect(
"message tool should fall back to ctx.user_id when owner scope metadata is absent",
);
assert!(result.is_err()); // safety: test-only assertion
let err = result.unwrap_err().to_string();
let mentions_missing_target = err.contains("No target specified");
assert!(!mentions_missing_target); // safety: test-only assertion
let mentions_missing_channel = err.contains("No channel specified");
assert!(!mentions_missing_channel); // safety: test-only assertion
assert_eq!(
result.result.as_str(),
Some("Sent message to telegram:interactive-chat-user")
);
assert!(gateway_captures.lock().await.is_empty());
let telegram = telegram_captures.lock().await.clone();
assert_eq!(telegram.len(), 1);
assert_eq!(telegram[0].0, "interactive-chat-user");
assert_eq!(telegram[0].1.content, "NEAR price is $5");
}
#[tokio::test]
+65
View File
@@ -1438,6 +1438,9 @@ impl From<TaskOutput> for Result<String, Error> {
#[cfg(test)]
mod tests {
use std::sync::Arc;
use crate::channels::ChannelManager;
use crate::llm::ToolSelection;
use super::*;
@@ -1448,6 +1451,8 @@ mod tests {
ToolCompletionResponse,
};
use crate::safety::SafetyLayer;
use crate::testing::{BroadcastCapture, RecordingBroadcastChannel};
use crate::tools::builtin::MessageTool;
use crate::tools::{Tool, ToolError as ToolExecError, ToolOutput};
/// A test tool that sleeps for a configurable duration before returning.
@@ -1539,6 +1544,20 @@ mod tests {
Worker::new(job_id, deps)
}
async fn make_worker_with_message_tool()
-> (Worker, Arc<MessageTool>, BroadcastCapture, BroadcastCapture) {
let channel_manager = ChannelManager::new();
let (gateway, gateway_captures) = RecordingBroadcastChannel::new("gateway");
let (telegram, telegram_captures) = RecordingBroadcastChannel::new("telegram");
channel_manager.add(Box::new(gateway)).await;
channel_manager.add(Box::new(telegram)).await;
let message_tool = Arc::new(MessageTool::new(Arc::new(channel_manager)));
let worker = make_worker(vec![message_tool.clone()]).await;
(worker, message_tool, gateway_captures, telegram_captures)
}
#[test]
fn test_tool_selection_preserves_call_id() {
let selection = ToolSelection {
@@ -2147,4 +2166,50 @@ mod tests {
assert_eq!(ctx.metadata, original); // safety: test
}
#[tokio::test]
async fn autonomous_message_tool_ignores_stale_gateway_context_when_routine_metadata_targets_telegram()
{
let (worker, message_tool, gateway_captures, telegram_captures) =
make_worker_with_message_tool().await;
message_tool
.set_context(
Some("gateway".to_string()),
Some("stale-gateway-target".to_string()),
)
.await;
worker
.context_manager()
.update_context(worker.job_id, |ctx| {
ctx.user_id = "telegram".to_string();
ctx.metadata = serde_json::json!({
"notify_channel": "telegram",
"owner_id": "owner-scope",
});
Ok::<(), String>(())
})
.await
.unwrap() // safety: test
.unwrap(); // safety: test
let result = worker
.execute_tool(
"message",
&serde_json::json!({"content": "hello from routine"}),
)
.await
.unwrap(); // safety: test
assert!(
result.contains("telegram:owner-scope"),
"expected telegram owner-scope routing, got: {result}"
);
assert!(gateway_captures.lock().await.is_empty());
let telegram = telegram_captures.lock().await.clone();
assert_eq!(telegram.len(), 1);
assert_eq!(telegram[0].0, "owner-scope");
assert_eq!(telegram[0].1.content, "hello from routine");
}
}
+21
View File
@@ -91,6 +91,27 @@ Default k=60. Results from both methods are combined, with documents appearing i
- **PostgreSQL:** `ts_rank_cd` for FTS, pgvector cosine distance for vectors, full RRF
- **libSQL:** FTS5 for keyword search + vector search via `libsql_vector_idx` (dimension set dynamically by `ensure_vector_index()` during startup)
## Multi-Scope Reads & Identity Isolation
When a workspace has additional read scopes (via `with_additional_read_scopes`), read operations can span multiple user scopes — a user with scopes `["alice", "shared"]` can read documents from both.
**Identity files are exempt from multi-scope reads.** The system prompt reads identity and configuration files from the **primary scope only** (`read_primary()`), never from secondary scopes:
| File | Read method | Rationale |
|------|------------|-----------|
| AGENTS.md | `read_primary()` | Agent instructions are per-user |
| SOUL.md | `read_primary()` | Core values are per-user |
| USER.md | `read_primary()` | User context is per-user |
| IDENTITY.md | `read_primary()` | Identity is per-user |
| TOOLS.md | `read_primary()` | Tool config is per-user |
| BOOTSTRAP.md | `read_primary()` | Onboarding is per-user |
| MEMORY.md | `read()` | Shared memory is a feature |
| daily/*.md | `read()` | Shared daily logs are a feature |
**Why:** Without this, a user with read access to another scope could silently inherit that scope's identity if their own copy is missing. The agent would present itself as the wrong user — a correctness and security issue.
**Design rule:** If you want shared identity across users, seed the same content into each user's scope at setup time. Don't rely on multi-scope fallback for identity files.
## Heartbeat System
Proactive periodic execution (default: 30 minutes):
+167 -4
View File
@@ -37,6 +37,25 @@ pub mod paths {
pub const ASSISTANT_DIRECTIVES: &str = "context/assistant-directives.md";
}
/// Paths treated as identity documents for multi-scope isolation.
///
/// These files are always read from the primary scope only — never from
/// secondary read scopes. This prevents silent identity inheritance
/// (e.g., user A accidentally presenting as user B).
pub const IDENTITY_PATHS: &[&str] = &[
paths::IDENTITY,
paths::SOUL,
paths::AGENTS,
paths::USER,
paths::TOOLS,
paths::BOOTSTRAP,
];
/// Check if a path is an identity document that must be isolated to primary scope.
pub fn is_identity_path(path: &str) -> bool {
IDENTITY_PATHS.contains(&path)
}
/// A memory document stored in the database.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryDocument {
@@ -101,10 +120,7 @@ impl MemoryDocument {
/// Check if this is a well-known identity document.
pub fn is_identity_document(&self) -> bool {
matches!(
self.path.as_str(),
paths::IDENTITY | paths::SOUL | paths::AGENTS | paths::USER
)
is_identity_path(&self.path)
}
}
@@ -128,6 +144,42 @@ impl WorkspaceEntry {
}
}
/// Merge workspace entries from multiple scopes into a deduplicated, sorted list.
///
/// When the same path appears in multiple scopes:
/// - Keeps the most recent `updated_at`
/// - If any scope marks it as a directory, the merged entry is a directory
pub fn merge_workspace_entries(
entries: impl IntoIterator<Item = WorkspaceEntry>,
) -> Vec<WorkspaceEntry> {
let mut seen = std::collections::HashMap::new();
for entry in entries {
seen.entry(entry.path.clone())
.and_modify(|existing: &mut WorkspaceEntry| {
// Keep the most recent updated_at (and its content_preview)
if let (Some(existing_ts), Some(new_ts)) = (&existing.updated_at, &entry.updated_at)
{
if new_ts > existing_ts {
existing.updated_at = Some(*new_ts);
existing.content_preview = entry.content_preview.clone();
}
} else if existing.updated_at.is_none() {
existing.updated_at = entry.updated_at;
existing.content_preview = entry.content_preview.clone();
}
// If either is a directory, mark as directory
if entry.is_directory {
existing.is_directory = true;
existing.content_preview = None;
}
})
.or_insert(entry);
}
let mut result: Vec<WorkspaceEntry> = seen.into_values().collect();
result.sort_by(|a, b| a.path.cmp(&b.path));
result
}
/// A chunk of a memory document for search indexing.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryChunk {
@@ -226,4 +278,115 @@ mod tests {
};
assert_eq!(entry.name(), "alpha");
}
#[test]
fn test_merge_workspace_entries_empty() {
let result = merge_workspace_entries(vec![]);
assert!(result.is_empty());
}
#[test]
fn test_merge_workspace_entries_keeps_newer_timestamp_and_preview() {
use chrono::TimeZone;
let old_ts = chrono::Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
let new_ts = chrono::Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap();
let entries = vec![
WorkspaceEntry {
path: "notes.md".to_string(),
is_directory: false,
updated_at: Some(old_ts),
content_preview: Some("old".to_string()),
},
WorkspaceEntry {
path: "notes.md".to_string(),
is_directory: false,
updated_at: Some(new_ts),
content_preview: Some("new".to_string()),
},
];
let result = merge_workspace_entries(entries);
assert_eq!(result.len(), 1);
assert_eq!(result[0].updated_at, Some(new_ts));
assert_eq!(result[0].content_preview, Some("new".to_string()));
}
#[test]
fn test_merge_workspace_entries_directory_wins() {
let entries = vec![
WorkspaceEntry {
path: "projects".to_string(),
is_directory: false,
updated_at: None,
content_preview: Some("file content".to_string()),
},
WorkspaceEntry {
path: "projects".to_string(),
is_directory: true,
updated_at: None,
content_preview: None,
},
];
let result = merge_workspace_entries(entries);
assert_eq!(result.len(), 1);
assert!(result[0].is_directory);
assert!(result[0].content_preview.is_none());
}
#[test]
fn test_merge_workspace_entries_fills_missing_timestamp() {
use chrono::TimeZone;
let ts = chrono::Utc.with_ymd_and_hms(2025, 3, 1, 0, 0, 0).unwrap();
let entries = vec![
WorkspaceEntry {
path: "a.md".to_string(),
is_directory: false,
updated_at: None,
content_preview: None,
},
WorkspaceEntry {
path: "a.md".to_string(),
is_directory: false,
updated_at: Some(ts),
content_preview: None,
},
];
let result = merge_workspace_entries(entries);
assert_eq!(result.len(), 1);
assert_eq!(result[0].updated_at, Some(ts));
}
#[test]
fn test_merge_workspace_entries_sorted_by_path() {
let entries = vec![
WorkspaceEntry {
path: "z.md".to_string(),
is_directory: false,
updated_at: None,
content_preview: None,
},
WorkspaceEntry {
path: "a.md".to_string(),
is_directory: false,
updated_at: None,
content_preview: None,
},
WorkspaceEntry {
path: "m.md".to_string(),
is_directory: false,
updated_at: None,
content_preview: None,
},
];
let result = merge_workspace_entries(entries);
assert_eq!(result.len(), 3);
assert_eq!(result[0].path, "a.md");
assert_eq!(result[1].path, "m.md");
assert_eq!(result[2].path, "z.md");
}
}
+362 -38
View File
@@ -52,7 +52,10 @@ mod repository;
mod search;
pub use chunker::{ChunkConfig, chunk_document};
pub use document::{MemoryChunk, MemoryDocument, WorkspaceEntry, paths};
pub use document::{
IDENTITY_PATHS, MemoryChunk, MemoryDocument, WorkspaceEntry, is_identity_path,
merge_workspace_entries, paths,
};
pub use embedding_cache::{CachedEmbeddingProvider, EmbeddingCacheConfig};
pub use embeddings::{
EmbeddingProvider, MockEmbeddings, NearAiEmbeddings, OllamaEmbeddings, OpenAiEmbeddings,
@@ -320,6 +323,48 @@ impl WorkspaceStorage {
}
}
}
// ==================== Multi-scope read methods ====================
async fn hybrid_search_multi(
&self,
user_ids: &[String],
agent_id: Option<Uuid>,
query: &str,
embedding: Option<&[f32]>,
config: &SearchConfig,
) -> Result<Vec<SearchResult>, WorkspaceError> {
match self {
#[cfg(feature = "postgres")]
Self::Repo(repo) => {
repo.hybrid_search_multi(user_ids, agent_id, query, embedding, config)
.await
}
Self::Db(db) => {
db.hybrid_search_multi(user_ids, agent_id, query, embedding, config)
.await
}
}
}
async fn get_document_by_path_multi(
&self,
user_ids: &[String],
agent_id: Option<Uuid>,
path: &str,
) -> Result<MemoryDocument, WorkspaceError> {
match self {
#[cfg(feature = "postgres")]
Self::Repo(repo) => {
repo.get_document_by_path_multi(user_ids, agent_id, path)
.await
}
Self::Db(db) => {
db.get_document_by_path_multi(user_ids, agent_id, path)
.await
}
}
}
}
/// Default template seeded into HEARTBEAT.md on first access.
@@ -340,9 +385,20 @@ const BOOTSTRAP_SEED: &str = include_str!("seeds/BOOTSTRAP.md");
/// Each workspace is scoped to a user (and optionally an agent).
/// Documents are persisted to the database and indexed for search.
/// Supports both PostgreSQL (via Repository) and libSQL (via Database trait).
///
/// ## Multi-scope reads
///
/// By default, a workspace reads from and writes to a single `user_id`.
/// With `with_additional_read_scopes`, read operations (search, read, list)
/// can span multiple user scopes while writes remain isolated to the primary
/// `user_id`. This enables cross-tenant read access (e.g., a user reading
/// from both their own workspace and a "shared" workspace).
pub struct Workspace {
/// User identifier (from channel).
/// User identifier (from channel). All writes go to this scope.
user_id: String,
/// User identifiers for read operations. Includes `user_id` as the first
/// element, plus any additional scopes added via `with_additional_read_scopes`.
read_user_ids: Vec<String>,
/// Optional agent ID for multi-agent isolation.
agent_id: Option<Uuid>,
/// Database storage backend.
@@ -371,6 +427,7 @@ impl Workspace {
let user_id_str = user_id.into();
let memory_layers = crate::workspace::layer::MemoryLayer::default_for_user(&user_id_str);
Self {
read_user_ids: vec![user_id_str.clone()],
user_id: user_id_str,
agent_id: None,
storage: WorkspaceStorage::Repo(Repository::new(pool)),
@@ -390,6 +447,7 @@ impl Workspace {
let user_id_str = user_id.into();
let memory_layers = crate::workspace::layer::MemoryLayer::default_for_user(&user_id_str);
Self {
read_user_ids: vec![user_id_str.clone()],
user_id: user_id_str,
agent_id: None,
storage: WorkspaceStorage::Db(db),
@@ -474,6 +532,12 @@ impl Workspace {
///
/// Also updates read_user_ids to include all layer scopes.
pub fn with_memory_layers(mut self, layers: Vec<crate::workspace::layer::MemoryLayer>) -> Self {
// Add layer scopes to read_user_ids (same dedup logic as with_additional_read_scopes)
for layer in &layers {
if !self.read_user_ids.contains(&layer.scope) {
self.read_user_ids.push(layer.scope.clone());
}
}
self.memory_layers = layers;
self
}
@@ -496,11 +560,37 @@ impl Workspace {
&self.memory_layers
}
/// Get the user ID.
/// Add additional user scopes for read operations.
///
/// The primary `user_id` is always included. Additional scopes allow
/// read operations (search, read, list) to span multiple tenants while
/// writes remain isolated to the primary scope.
///
/// Duplicate scopes are ignored.
pub fn with_additional_read_scopes(mut self, scopes: Vec<String>) -> Self {
for scope in scopes {
if !self.read_user_ids.contains(&scope) {
self.read_user_ids.push(scope);
}
}
self
}
/// Get the user ID (primary scope for writes).
pub fn user_id(&self) -> &str {
&self.user_id
}
/// Get the user IDs used for read operations.
pub fn read_user_ids(&self) -> &[String] {
&self.read_user_ids
}
/// Whether this workspace has multiple read scopes.
fn is_multi_scope(&self) -> bool {
self.read_user_ids.len() > 1
}
/// Get the agent ID.
pub fn agent_id(&self) -> Option<Uuid> {
self.agent_id
@@ -518,6 +608,33 @@ impl Workspace {
/// println!("{}", doc.content);
/// ```
pub async fn read(&self, path: &str) -> Result<MemoryDocument, WorkspaceError> {
let path = normalize_path(path);
if self.is_multi_scope() && is_identity_path(&path) {
// Identity files must only come from the primary scope.
self.storage
.get_document_by_path(&self.user_id, self.agent_id, &path)
.await
} else if self.is_multi_scope() {
self.storage
.get_document_by_path_multi(&self.read_user_ids, self.agent_id, &path)
.await
} else {
self.storage
.get_document_by_path(&self.user_id, self.agent_id, &path)
.await
}
}
/// Read a file from the **primary scope only**, ignoring additional read scopes.
///
/// Use this for identity and configuration files (AGENTS.md, SOUL.md, USER.md,
/// IDENTITY.md, TOOLS.md, BOOTSTRAP.md) where inheriting content from another
/// scope would be a correctness/security issue — the agent must never silently
/// present itself as the wrong user.
///
/// For memory files that should span scopes (MEMORY.md, daily logs), use
/// [`read`] instead.
pub async fn read_primary(&self, path: &str) -> Result<MemoryDocument, WorkspaceError> {
let path = normalize_path(path);
self.storage
.get_document_by_path(&self.user_id, self.agent_id, &path)
@@ -556,6 +673,9 @@ impl Workspace {
/// Uses a single `\n` separator (suitable for log-style entries).
/// For semantic separation (e.g., memory entries), use `append_memory()`
/// which uses `\n\n`.
///
/// Uses a read-modify-write pattern that is not concurrency-safe:
/// concurrent appends to the same path may lose writes.
pub async fn append(&self, path: &str, content: &str) -> Result<(), WorkspaceError> {
let path = normalize_path(path);
// Scan system-prompt-injected files for prompt injection.
@@ -676,6 +796,20 @@ impl Workspace {
}
/// Write to a layer, with append semantics.
///
/// Note: privacy classification only examines the new `content`, not the
/// full document after concatenation. See [`PatternPrivacyClassifier`]
/// limitations for details.
///
/// When a privacy redirect occurs, the append targets a **separate
/// document** in the private scope at the same path — the shared-scope
/// document is left unmodified. Subsequent multi-scope reads will return
/// the private copy (primary scope wins), effectively shadowing the
/// shared document at that path. The `WriteResult::redirected` flag
/// indicates when this has happened.
///
/// Uses a read-modify-write pattern that is not concurrency-safe:
/// concurrent appends to the same path may lose writes.
pub async fn append_to_layer(
&self,
layer_name: &str,
@@ -706,13 +840,25 @@ impl Workspace {
}
/// Check if a file exists.
///
/// When multi-scope reads are configured, checks across all read scopes.
pub async fn exists(&self, path: &str) -> Result<bool, WorkspaceError> {
let path = normalize_path(path);
match self
.storage
.get_document_by_path(&self.user_id, self.agent_id, &path)
.await
{
let result = if self.is_multi_scope() && is_identity_path(&path) {
// Identity files only checked in primary scope.
self.storage
.get_document_by_path(&self.user_id, self.agent_id, &path)
.await
} else if self.is_multi_scope() {
self.storage
.get_document_by_path_multi(&self.read_user_ids, self.agent_id, &path)
.await
} else {
self.storage
.get_document_by_path(&self.user_id, self.agent_id, &path)
.await
};
match result {
Ok(_) => Ok(true),
Err(WorkspaceError::DocumentNotFound { .. }) => Ok(false),
Err(e) => Err(e),
@@ -747,16 +893,55 @@ impl Workspace {
/// ```
pub async fn list(&self, directory: &str) -> Result<Vec<WorkspaceEntry>, WorkspaceError> {
let directory = normalize_directory(directory);
self.storage
.list_directory(&self.user_id, self.agent_id, &directory)
.await
if self.is_multi_scope() {
// Iterate per-scope rather than using list_directory_multi because
// we need to filter identity paths from secondary scopes only — the
// merged _multi result loses scope attribution.
let primary = self
.storage
.list_directory(&self.user_id, self.agent_id, &directory)
.await?;
let mut all_entries = primary;
for scope in &self.read_user_ids[1..] {
let entries = self
.storage
.list_directory(scope, self.agent_id, &directory)
.await?;
all_entries.extend(entries.into_iter().filter(|e| !is_identity_path(&e.path)));
}
Ok(merge_workspace_entries(all_entries))
} else {
self.storage
.list_directory(&self.user_id, self.agent_id, &directory)
.await
}
}
/// List all files recursively (flat list of all paths).
///
/// When multi-scope reads are configured, lists across all read scopes.
pub async fn list_all(&self) -> Result<Vec<String>, WorkspaceError> {
self.storage
.list_all_paths(&self.user_id, self.agent_id)
.await
if self.is_multi_scope() {
// Iterate per-scope rather than using list_all_paths_multi because
// we need to filter identity paths from secondary scopes only.
// Primary scope: all paths. Secondary scopes: filter identity paths.
let mut all_paths = self
.storage
.list_all_paths(&self.user_id, self.agent_id)
.await?;
for scope in &self.read_user_ids[1..] {
let paths = self.storage.list_all_paths(scope, self.agent_id).await?;
all_paths.extend(paths.into_iter().filter(|p| !is_identity_path(p)));
}
// Deduplicate and sort
all_paths.sort();
all_paths.dedup();
Ok(all_paths)
} else {
self.storage
.list_all_paths(&self.user_id, self.agent_id)
.await
}
}
// ==================== Convenience Methods ====================
@@ -791,7 +976,7 @@ impl Workspace {
/// comments, which the heartbeat runner treats as "effectively empty"
/// and skips the LLM call.
pub async fn heartbeat_checklist(&self) -> Result<Option<String>, WorkspaceError> {
match self.read(paths::HEARTBEAT).await {
match self.read_primary(paths::HEARTBEAT).await {
Ok(doc) => Ok(Some(doc.content)),
Err(WorkspaceError::DocumentNotFound { .. }) => Ok(Some(HEARTBEAT_SEED.to_string())),
Err(e) => Err(e),
@@ -799,7 +984,29 @@ impl Workspace {
}
/// Helper to read or create a file.
///
/// When multi-scope reads are configured, checks all read scopes before
/// creating. If the file exists in any scope, returns it. If not found in
/// any scope, creates it in the primary (write) scope.
///
/// **Important:** In multi-scope mode, the returned document may belong to
/// a secondary scope. Callers that intend to **write** to the document
/// (via `update_document(doc.id, ...)`) must NOT use this method — use
/// `storage.get_or_create_document_by_path(&self.user_id, ...)` instead
/// to guarantee writes target the primary scope. See `append_memory` for
/// the correct pattern.
async fn read_or_create(&self, path: &str) -> Result<MemoryDocument, WorkspaceError> {
if self.is_multi_scope() {
match self
.storage
.get_document_by_path_multi(&self.read_user_ids, self.agent_id, path)
.await
{
Ok(doc) => return Ok(doc),
Err(WorkspaceError::DocumentNotFound { .. }) => {}
Err(e) => return Err(e),
}
}
self.storage
.get_or_create_document_by_path(&self.user_id, self.agent_id, path)
.await
@@ -811,9 +1018,18 @@ impl Workspace {
///
/// This is for important facts, decisions, and preferences worth
/// remembering long-term.
///
/// Uses `get_or_create_document_by_path` with the primary `user_id`
/// instead of `self.memory()` to guarantee writes always target the
/// primary (write) scope. `self.memory()` delegates to `read_or_create`,
/// which in multi-scope mode may return a document owned by a secondary
/// scope; writing to that document by UUID would violate write isolation.
pub async fn append_memory(&self, entry: &str) -> Result<(), WorkspaceError> {
// Use double newline for memory entries (semantic separation)
let doc = self.memory().await?;
// Always get/create in the primary scope to preserve write isolation.
let doc = self
.storage
.get_or_create_document_by_path(&self.user_id, self.agent_id, paths::MEMORY)
.await?;
let new_content = if doc.content.is_empty() {
entry.to_string()
} else {
@@ -905,9 +1121,16 @@ impl Workspace {
// Safety net: if `profile_onboarding_completed` was already set (the
// LLM completed onboarding but forgot to delete BOOTSTRAP.md), skip
// injection to avoid repeating the first-run ritual.
//
// Identity and config files use read_primary() to prevent cross-scope
// bleed in multi-scope workspaces. Without this, a user with read access
// to other scopes could silently inherit another user's identity if their
// own copy is missing — the agent would present as the wrong person.
// Memory files (MEMORY.md, daily logs) intentionally use multi-scope
// read() since sharing memory across scopes is a feature.
let bootstrap_injected = if self.is_bootstrap_completed() {
if self
.read(paths::BOOTSTRAP)
.read_primary(paths::BOOTSTRAP)
.await
.is_ok_and(|d| !d.content.is_empty())
{
@@ -917,7 +1140,7 @@ impl Workspace {
);
}
false
} else if let Ok(doc) = self.read(paths::BOOTSTRAP).await
} else if let Ok(doc) = self.read_primary(paths::BOOTSTRAP).await
&& !doc.content.is_empty()
{
parts.push(format!("## First-Run Bootstrap\n\n{}", doc.content));
@@ -926,7 +1149,8 @@ impl Workspace {
false
};
// Load identity files in order of importance
// Load identity files in order of importance.
// These MUST use read_primary() — see comment above.
let identity_files = [
(paths::AGENTS, "## Agent Instructions"),
(paths::SOUL, "## Core Values"),
@@ -935,7 +1159,7 @@ impl Workspace {
];
for (path, header) in identity_files {
if let Ok(doc) = self.read(path).await
if let Ok(doc) = self.read_primary(path).await
&& !doc.content.is_empty()
{
parts.push(format!("{}\n\n{}", header, doc.content));
@@ -944,7 +1168,8 @@ impl Workspace {
// Tool notes: environment-specific guidance the agent or user has written.
// TOOLS.md does not control tool availability; it is guidance only.
if let Ok(doc) = self.read(paths::TOOLS).await
// Uses read_primary() — tool config is per-user, not inherited.
if let Ok(doc) = self.read_primary(paths::TOOLS).await
&& !doc.content.is_empty()
{
parts.push(format!("## Tool Notes\n\n{}", doc.content));
@@ -1235,6 +1460,8 @@ impl Workspace {
}
/// Search with custom configuration.
///
/// When multi-scope reads are configured, searches across all read scopes.
pub async fn search_with_config(
&self,
query: &str,
@@ -1254,15 +1481,46 @@ impl Workspace {
None
};
self.storage
.hybrid_search(
&self.user_id,
self.agent_id,
query,
embedding.as_deref(),
&config,
)
.await
if self.is_multi_scope() {
let results = self
.storage
.hybrid_search_multi(
&self.read_user_ids,
self.agent_id,
query,
embedding.as_deref(),
&config,
)
.await?;
// Post-filter: exclude identity documents from secondary scopes.
// Collect document IDs that are identity paths in secondary scopes.
let mut excluded_doc_ids = std::collections::HashSet::new();
for result in &results {
if is_identity_path(&result.document_path) {
// Check if this document belongs to a secondary scope
match self.storage.get_document_by_id(result.document_id).await {
Ok(doc) if doc.user_id != self.user_id => {
excluded_doc_ids.insert(result.document_id);
}
_ => {}
}
}
}
Ok(results
.into_iter()
.filter(|r| !excluded_doc_ids.contains(&r.document_id))
.collect())
} else {
self.storage
.hybrid_search(
&self.user_id,
self.agent_id,
query,
embedding.as_deref(),
&config,
)
.await
}
}
// ==================== Indexing ====================
@@ -1323,13 +1581,13 @@ impl Workspace {
// Check freshness BEFORE seeding identity files, otherwise the
// seeded files make the workspace look non-fresh and BOOTSTRAP.md
// never gets created.
let is_fresh_workspace = if self.read(paths::BOOTSTRAP).await.is_ok() {
let is_fresh_workspace = if self.read_primary(paths::BOOTSTRAP).await.is_ok() {
false // BOOTSTRAP already exists
} else {
let (agents_res, soul_res, user_res) = tokio::join!(
self.read(paths::AGENTS),
self.read(paths::SOUL),
self.read(paths::USER),
self.read_primary(paths::AGENTS),
self.read_primary(paths::SOUL),
self.read_primary(paths::USER),
);
matches!(agents_res, Err(WorkspaceError::DocumentNotFound { .. }))
&& matches!(soul_res, Err(WorkspaceError::DocumentNotFound { .. }))
@@ -1338,8 +1596,10 @@ impl Workspace {
let mut count = 0;
for (path, content) in seed_files {
// Skip files that already exist (never overwrite user edits)
match self.read(path).await {
// Skip files that already exist in the primary scope (never overwrite user edits).
// Uses read_primary to avoid false positives from secondary scopes —
// a file in another scope should not suppress seeding in this scope.
match self.read_primary(path).await {
Ok(_) => continue,
Err(WorkspaceError::DocumentNotFound { .. }) => {}
Err(e) => {
@@ -1360,7 +1620,8 @@ impl Workspace {
// may already have a profile from a previous install and doesn't need
// onboarding). This prevents existing users from getting a spurious
// first-run ritual after upgrading.
let has_profile = self.read(paths::PROFILE).await.is_ok_and(|d| {
// Uses read_primary() to avoid false positives from secondary scopes.
let has_profile = self.read_primary(paths::PROFILE).await.is_ok_and(|d| {
!d.content.trim().is_empty()
&& serde_json::from_str::<crate::profile::PsychographicProfile>(&d.content).is_ok()
});
@@ -1791,4 +2052,67 @@ mod seed_tests {
"BOOTSTRAP.md should NOT have been seeded with existing profile"
);
}
#[test]
fn test_default_single_scope() {
// Verify backward compatibility: default workspace has single read scope
// matching user_id.
let user_id = "alice";
let read_user_ids = [user_id.to_string()];
assert_eq!(read_user_ids.len(), 1);
assert_eq!(read_user_ids[0], user_id);
}
#[test]
fn test_additional_read_scopes() {
// Verify that additional read scopes are added correctly.
let user_id = "alice".to_string();
let mut read_user_ids = Vec::from([user_id.clone()]);
// Simulate with_additional_read_scopes logic
let scopes = ["shared", "team"];
for scope in scopes {
let s = scope.to_string();
if !read_user_ids.contains(&s) {
read_user_ids.push(s);
}
}
assert_eq!(read_user_ids.len(), 3);
assert_eq!(read_user_ids[0], "alice");
assert_eq!(read_user_ids[1], "shared");
assert_eq!(read_user_ids[2], "team");
}
#[test]
fn test_additional_read_scopes_dedup() {
// Verify that duplicate scopes are ignored.
let user_id = "alice".to_string();
let mut read_user_ids = Vec::from([user_id.clone()]);
let scopes = ["shared", "alice", "shared"];
for scope in scopes {
let s = scope.to_string();
if !read_user_ids.contains(&s) {
read_user_ids.push(s);
}
}
assert_eq!(read_user_ids.len(), 2);
assert_eq!(read_user_ids[0], "alice");
assert_eq!(read_user_ids[1], "shared");
}
#[test]
fn test_is_multi_scope_logic() {
// Test the multi-scope detection logic: > 1 means multi-scope
let single_count = 1_usize;
let multi_count = 2_usize;
// Single scope: not multi
assert!(single_count <= 1);
// Multi scope: is multi
assert!(multi_count > 1);
}
}
+199
View File
@@ -502,4 +502,203 @@ impl Repository {
})
.collect())
}
// ==================== Multi-scope search (optimized SQL) ====================
/// Hybrid search across multiple user scopes with efficient SQL.
///
/// Uses `user_id = ANY($1::text[])` instead of N separate queries.
pub async fn hybrid_search_multi(
&self,
user_ids: &[String],
agent_id: Option<Uuid>,
query: &str,
embedding: Option<&[f32]>,
config: &SearchConfig,
) -> Result<Vec<SearchResult>, WorkspaceError> {
let fts_results = if config.use_fts {
self.fts_search_multi(user_ids, agent_id, query, config.pre_fusion_limit)
.await?
} else {
Vec::new()
};
let vector_results = if config.use_vector {
if let Some(embedding) = embedding {
self.vector_search_multi(user_ids, agent_id, embedding, config.pre_fusion_limit)
.await?
} else {
Vec::new()
}
} else {
Vec::new()
};
Ok(fuse_results(fts_results, vector_results, config))
}
/// FTS search across multiple user scopes.
async fn fts_search_multi(
&self,
user_ids: &[String],
agent_id: Option<Uuid>,
query: &str,
limit: usize,
) -> Result<Vec<RankedResult>, WorkspaceError> {
let conn = self.conn().await?;
let rows = conn
.query(
r#"
SELECT c.id as chunk_id, c.document_id, d.path as document_path,
c.content,
ts_rank_cd(c.content_tsv, plainto_tsquery('english', $3)) as rank
FROM memory_chunks c
JOIN memory_documents d ON d.id = c.document_id
WHERE d.user_id = ANY($1::text[]) AND d.agent_id IS NOT DISTINCT FROM $2
AND c.content_tsv @@ plainto_tsquery('english', $3)
ORDER BY rank DESC
LIMIT $4
"#,
&[&user_ids, &agent_id, &query, &(limit as i64)],
)
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("FTS multi-scope query failed: {}", e),
})?;
Ok(rows
.iter()
.enumerate()
.map(|(i, row)| RankedResult {
chunk_id: row.get("chunk_id"),
document_id: row.get("document_id"),
document_path: row.get("document_path"),
content: row.get("content"),
rank: (i + 1) as u32,
})
.collect())
}
/// Vector search across multiple user scopes.
async fn vector_search_multi(
&self,
user_ids: &[String],
agent_id: Option<Uuid>,
embedding: &[f32],
limit: usize,
) -> Result<Vec<RankedResult>, WorkspaceError> {
let conn = self.conn().await?;
let embedding_vec = Vector::from(embedding.to_vec());
let rows = conn
.query(
r#"
SELECT c.id as chunk_id, c.document_id, d.path as document_path,
c.content, 1 - (c.embedding <=> $3) as similarity
FROM memory_chunks c
JOIN memory_documents d ON d.id = c.document_id
WHERE d.user_id = ANY($1::text[]) AND d.agent_id IS NOT DISTINCT FROM $2
AND c.embedding IS NOT NULL
ORDER BY c.embedding <=> $3
LIMIT $4
"#,
&[&user_ids, &agent_id, &embedding_vec, &(limit as i64)],
)
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("Vector multi-scope query failed: {}", e),
})?;
Ok(rows
.iter()
.enumerate()
.map(|(i, row)| RankedResult {
chunk_id: row.get("chunk_id"),
document_id: row.get("document_id"),
document_path: row.get("document_path"),
content: row.get("content"),
rank: (i + 1) as u32,
})
.collect())
}
/// List all file paths across multiple user scopes with a single query.
pub async fn list_all_paths_multi(
&self,
user_ids: &[String],
agent_id: Option<Uuid>,
) -> Result<Vec<String>, WorkspaceError> {
let conn = self.conn().await?;
let rows = conn
.query(
r#"
SELECT DISTINCT path FROM memory_documents
WHERE user_id = ANY($1::text[]) AND agent_id IS NOT DISTINCT FROM $2
ORDER BY path
"#,
&[&user_ids, &agent_id],
)
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("List paths multi-scope failed: {}", e),
})?;
Ok(rows.iter().map(|row| row.get("path")).collect())
}
/// Get a document by path across multiple user scopes.
///
/// Returns the first match (ordered by the input user_ids priority).
pub async fn get_document_by_path_multi(
&self,
user_ids: &[String],
agent_id: Option<Uuid>,
path: &str,
) -> Result<MemoryDocument, WorkspaceError> {
let conn = self.conn().await?;
let row = conn
.query_opt(
r#"
SELECT id, user_id, agent_id, path, content,
created_at, updated_at, metadata
FROM memory_documents
WHERE user_id = ANY($1::text[]) AND agent_id IS NOT DISTINCT FROM $2 AND path = $3
ORDER BY array_position($1::text[], user_id)
LIMIT 1
"#,
&[&user_ids, &agent_id, &path],
)
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("get_document_by_path_multi failed: {}", e),
})?;
match row {
Some(row) => Ok(self.row_to_document(&row)),
None => Err(WorkspaceError::DocumentNotFound {
doc_type: path.to_string(),
user_id: format!("[{}]", user_ids.join(", ")),
}),
}
}
/// List directory contents across multiple user scopes.
///
/// Iterates per scope and merges results. A future migration could add an
/// optimised SQL function, at which point this method can call it directly.
pub async fn list_directory_multi(
&self,
user_ids: &[String],
agent_id: Option<Uuid>,
directory: &str,
) -> Result<Vec<WorkspaceEntry>, WorkspaceError> {
let mut all_entries = Vec::new();
for uid in user_ids {
all_entries.extend(self.list_directory(uid, agent_id, directory).await?);
}
Ok(crate::workspace::merge_workspace_entries(all_entries))
}
}
@@ -0,0 +1,249 @@
"""OAuth URL parameter validation e2e tests.
Tests for bug #992: Google OAuth URL broken when initiated from Telegram.
Specifically verifies that OAuth query parameters are correctly formatted:
- "client_id" (with underscore) NOT "clientid" (without underscore)
- All standard OAuth parameters are present and correctly encoded
- URLs are consistent across channels (web, Telegram, etc.)
The test verifies:
1. OAuth URL is generated with correct parameters
2. URL works with the OAuth provider (Google)
3. Extra parameters (access_type, prompt) are preserved
"""
from urllib.parse import parse_qs, urlparse
import pytest
from helpers import api_post, api_get
async def _extract_oauth_params(auth_url: str) -> dict:
"""Extract and validate OAuth query parameters from auth_url.
Returns dict with parsed parameters:
{
'client_id': '...',
'redirect_uri': '...',
'response_type': 'code',
'scope': '...',
'state': '...',
'access_type': '...',
'prompt': '...',
...
}
"""
parsed = urlparse(auth_url)
qs = parse_qs(parsed.query)
# Convert lists to single values for easier testing
params = {k: v[0] if len(v) > 0 else v for k, v in qs.items()}
return params
async def _get_extension(ironclaw_server, name):
"""Get a specific extension from the extensions list, or None."""
r = await api_get(ironclaw_server, "/api/extensions")
for ext in r.json().get("extensions", []):
if ext["name"] == name:
return ext
return None
@pytest.fixture
async def installed_gmail(ironclaw_server):
"""Installs the 'gmail' extension before a test and removes it after.
This fixture handles the setup and teardown of the Gmail extension,
ensuring a clean state for each test.
"""
# Ensure Gmail is not installed before test
ext = await _get_extension(ironclaw_server, "gmail")
if ext:
r = await api_post(ironclaw_server, "/api/extensions/gmail/remove", timeout=30)
assert r.status_code == 200
# Install Gmail
r = await api_post(
ironclaw_server,
"/api/extensions/install",
json={"name": "gmail"},
timeout=180,
)
assert r.status_code == 200, f"Gmail install failed: {r.text}"
assert r.json().get("success") is True, f"Install failed: {r.json().get('message', '')}"
yield
# Teardown: remove gmail
r = await api_post(ironclaw_server, "/api/extensions/gmail/remove", timeout=30)
assert r.status_code == 200, f"Gmail removal failed: {r.text}"
@pytest.fixture
async def auth_url(ironclaw_server, installed_gmail):
"""Generate and return an OAuth auth URL.
Requires Gmail to be installed (depends on installed_gmail fixture).
"""
r = await api_post(
ironclaw_server,
"/api/extensions/gmail/setup",
json={"secrets": {}},
timeout=30,
)
assert r.status_code == 200
data = r.json()
assert data.get("success") is True, f"Setup failed: {data.get('message', '')}"
url = data.get("auth_url")
assert url is not None, f"Expected auth_url in response: {data}"
assert "accounts.google.com" in url, f"auth_url should point to Google: {url}"
return url
@pytest.fixture
async def oauth_params(auth_url):
"""Extract and return OAuth parameters from auth_url.
Depends on auth_url fixture.
"""
return await _extract_oauth_params(auth_url)
# ─ OAuth URL parameter validation tests ────────────────────────────────
async def test_oauth_url_has_client_id_not_clientid(oauth_params, auth_url):
"""Verify OAuth URL has 'client_id' (with underscore), NOT 'clientid'.
Bug #992: Ensure the parameter name is correct across all channels.
"""
params = oauth_params
# The bug: "clientid" appears instead of "client_id"
# Verify the CORRECT parameter name exists
assert "client_id" in params, (
f"OAuth URL missing 'client_id' parameter. "
f"URL: {auth_url}\nParams: {params}"
)
assert params["client_id"], "client_id should have a value"
# Verify the INCORRECT parameter name does NOT exist
assert "clientid" not in params, (
f"OAuth URL should NOT have 'clientid' (without underscore). "
f"Bug #992: URL: {auth_url}\nParams: {params}"
)
async def test_oauth_url_has_required_parameters(oauth_params):
"""Verify all required OAuth 2.0 parameters are present."""
params = oauth_params
# Required OAuth 2.0 parameters
required = ["client_id", "response_type", "redirect_uri", "scope", "state"]
for param in required:
assert param in params, (
f"Missing required OAuth parameter: {param}. "
f"Params: {params}"
)
assert params[param], f"Parameter '{param}' should have a non-empty value"
# Validate specific values
assert params["response_type"] == "code", "Should use authorization_code flow"
assert "oauth" in params["redirect_uri"], "Redirect URI should be an OAuth callback"
async def test_oauth_url_has_extra_params(oauth_params):
"""Verify extra_params from capabilities.json are included."""
params = oauth_params
# Google-specific extra_params from gmail-tool.capabilities.json
assert "access_type" in params, (
"Should include 'access_type' from extra_params"
)
assert params["access_type"] == "offline", (
"access_type should be 'offline' for Gmail"
)
assert "prompt" in params, (
"Should include 'prompt' from extra_params"
)
assert params["prompt"] == "consent", (
"prompt should be 'consent' for Gmail"
)
async def test_oauth_url_is_valid_google_oauth(auth_url):
"""Verify the URL is a valid Google OAuth 2.0 authorization URL."""
# Verify scheme and host
parsed = urlparse(auth_url)
assert parsed.scheme == "https", "OAuth URL must use HTTPS"
assert "accounts.google.com" in parsed.netloc, "Must be Google's OAuth endpoint"
assert parsed.path == "/o/oauth2/v2/auth", "Must use Google OAuth 2.0 endpoint"
async def test_oauth_url_state_is_unique(ironclaw_server, installed_gmail, oauth_params, auth_url):
"""Verify CSRF state is present and unique per request."""
# Get a new OAuth URL
r = await api_post(
ironclaw_server,
"/api/extensions/gmail/setup",
json={"secrets": {}},
timeout=30,
)
assert r.status_code == 200
new_auth_url = r.json().get("auth_url")
assert new_auth_url is not None
# Extract state from both URLs
original_params = oauth_params
new_params = await _extract_oauth_params(new_auth_url)
original_state = original_params.get("state")
new_state = new_params.get("state")
assert original_state is not None, "Should have state parameter"
assert new_state is not None, "New request should have state parameter"
assert original_state != new_state, (
"CSRF state should be unique per request (for security)"
)
async def test_oauth_url_escaping(auth_url):
"""Verify URL query parameters are properly escaped."""
# Verify special characters in values are URL-encoded
# For example, scopes contain spaces which should be %20
assert "%20" in auth_url or "+" in auth_url or "%2B" in auth_url or " " not in auth_url, (
"OAuth URL should properly encode special characters in parameters"
)
# ─ Telegram-specific tests (when Telegram channel is available) ──────────
class TestOAuthURLViaTelegram:
"""Test OAuth URL generation specifically via Telegram channel.
These tests would verify that the same OAuth URL works correctly when
transmitted through the Telegram WASM channel (as opposed to web gateway).
Currently marked as xfail pending Telegram channel setup in E2E tests.
"""
@pytest.mark.skip(reason="Telegram channel E2E setup not yet implemented")
async def test_telegram_oauth_url_has_correct_parameters(self):
"""Verify OAuth URL sent via Telegram has correct parameter names."""
# This test would:
# 1. Send a message via Telegram that triggers OAuth
# 2. Capture the status update sent to Telegram
# 3. Extract the auth_url from the message
# 4. Verify it has "client_id" not "clientid"
pass
@pytest.mark.skip(reason="Telegram channel E2E setup not yet implemented")
async def test_telegram_oauth_url_can_be_regenerated(self):
"""Verify OAuth URL can be regenerated when requested via Telegram."""
# This test would verify that the bug #992 symptom
# "URL cannot be regenerated when asked" is fixed.
# If the URL is cached incorrectly, regeneration would fail.
pass
+195
View File
@@ -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}"
);
}
+451
View File
@@ -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");
}
+330
View File
@@ -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;
}