diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index 5b20345e..bc705df7 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -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:
diff --git a/.github/workflows/regression-test-check.yml b/.github/workflows/regression-test-check.yml
index ef1a4d92..75b8eb55 100644
--- a/.github/workflows/regression-test-check.yml
+++ b/.github/workflows/regression-test-check.yml
@@ -121,6 +121,7 @@ jobs:
fi
# Whole-function context: detect edits inside existing test functions.
+ # Uses -W (whole function) which works when git recognises function boundaries.
if git diff "${BASE_REF}...${HEAD_REF}" -W -- '*.rs' | awk '
/^@@/ { if (has_test && has_add) { found=1; exit } has_test=0; has_add=0 }
/^ .*#\[test\]/ || /^ .*#\[tokio::test\]/ || /^ .*#\[cfg\(test\)\]/ || /^ .*mod tests/ { has_test=1 }
@@ -132,6 +133,40 @@ jobs:
exit 0
fi
+ # Line-level check: detect changes inside #[cfg(test)] mod blocks.
+ # git -W relies on function boundary detection which misses Rust mod blocks,
+ # so this fallback checks whether changed line numbers fall within test modules.
+ # We specifically match #[cfg(test)] that is followed by `mod` (same or next
+ # line) to avoid false positives from standalone #[cfg(test)] items like
+ # individual statics or functions.
+ CHANGED_RS=$(echo "$CHANGED_FILES" | grep '\.rs$' || true)
+ if [ -n "$CHANGED_RS" ]; then
+ while IFS= read -r rs_file; do
+ [ -f "$rs_file" ] || continue
+
+ # Find the line where #[cfg(test)] precedes a `mod` declaration.
+ # Handles both `#[cfg(test)] mod tests` (same line) and the two-line form.
+ TEST_MOD_START=$(awk '
+ /^[[:space:]]*#\[cfg\(test\)\].*mod / { print NR; exit }
+ /^[[:space:]]*#\[cfg\(test\)\][[:space:]]*$/ { pending=NR; next }
+ pending && /^[[:space:]]*mod / { print pending; exit }
+ { pending=0 }
+ ' "$rs_file")
+ [ -n "$TEST_MOD_START" ] || continue
+
+ # Get changed line numbers in this file from the diff hunk headers.
+ # Each @@ line looks like: @@ -old,count +new,count @@
+ while IFS= read -r hunk_line; do
+ line_no=$(echo "$hunk_line" | sed -E 's/^@@ -[0-9,]+ \+([0-9]+).*/\1/')
+ [ -n "$line_no" ] || continue
+ if [ "$line_no" -ge "$TEST_MOD_START" ]; then
+ echo "Test changes found: $rs_file has changes at line $line_no inside #[cfg(test)] mod block (starts at line $TEST_MOD_START)."
+ exit 0
+ fi
+ done < <(git diff "${BASE_REF}...${HEAD_REF}" -U0 -- "$rs_file" | grep -E '^@@')
+ done <<< "$CHANGED_RS"
+ fi
+
if grep -qE '^tests/' <<< "$CHANGED_FILES"; then
echo "Test file changes found under tests/."
exit 0
diff --git a/Cargo.lock b/Cargo.lock
index 83110d35..95aded9a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -159,7 +159,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]]
@@ -170,7 +170,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
- "windows-sys 0.61.2",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -2249,7 +2249,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
- "windows-sys 0.61.2",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -2436,7 +2436,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]]
@@ -4396,7 +4396,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]]
@@ -5853,7 +5853,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
- "windows-sys 0.61.2",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -6535,7 +6535,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]]
@@ -6765,9 +6765,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",
@@ -6796,7 +6796,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
- "windows-sys 0.61.2",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -7596,7 +7596,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
- "windows-sys 0.61.2",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -8468,7 +8468,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]]
diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md
index a7f5fb32..ad2db551 100644
--- a/FEATURE_PARITY.md
+++ b/FEATURE_PARITY.md
@@ -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 []` (`--verbose`, `--json`; fetches live model list when provider specified), `models status` (`--json`), `models set `, `models set-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) |
diff --git a/README.md b/README.md
index 6e14d9ea..cb759236 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,9 @@
+
+
+
diff --git a/channels-src/feishu/feishu.capabilities.json b/channels-src/feishu/feishu.capabilities.json
index 82b1be4e..a228cc4e 100644
--- a/channels-src/feishu/feishu.capabilities.json
+++ b/channels-src/feishu/feishu.capabilities.json
@@ -3,11 +3,11 @@
"wit_version": "0.3.0",
"type": "channel",
"name": "feishu",
- "description": "Feishu/Lark Bot channel for receiving and responding to Feishu messages",
+ "description": "Feishu/Lark Bot channel for receiving and responding to Feishu messages via Event Subscription webhooks",
"auth": {
"secret_name": "feishu_app_id",
"display_name": "Feishu / Lark",
- "instructions": "Create a bot at https://open.feishu.cn/app (Feishu) or https://open.larksuite.com/app (Lark). You need the App ID and App Secret.",
+ "instructions": "Create a bot at https://open.feishu.cn/app (Feishu) or https://open.larksuite.com/app (Lark). You need the App ID and App Secret. Note: IronClaw supports Event Subscription webhook delivery, but not Feishu's long-connection websocket mode.",
"setup_url": "https://open.feishu.cn/app",
"token_hint": "App ID looks like cli_XXXX, App Secret is a long alphanumeric string",
"env_var": "FEISHU_APP_ID"
@@ -16,17 +16,17 @@
"required_secrets": [
{
"name": "feishu_app_id",
- "prompt": "Enter your Feishu/Lark App ID (from https://open.feishu.cn/app)",
+ "prompt": "Enter your Feishu/Lark App ID (from https://open.feishu.cn/app). Use webhook-based Event Subscription, not long-connection websocket mode.",
"optional": false
},
{
"name": "feishu_app_secret",
- "prompt": "Enter your Feishu/Lark App Secret",
+ "prompt": "Enter your Feishu/Lark App Secret (from your app settings at open.feishu.cn)",
"optional": false
},
{
"name": "feishu_verification_token",
- "prompt": "Enter your Feishu/Lark Verification Token (from Event Subscription settings)",
+ "prompt": "Enter your Feishu/Lark Verification Token (from Event Subscription webhook settings)",
"optional": true
}
],
diff --git a/channels-src/feishu/src/lib.rs b/channels-src/feishu/src/lib.rs
index 3094eaa0..62440d2c 100644
--- a/channels-src/feishu/src/lib.rs
+++ b/channels-src/feishu/src/lib.rs
@@ -5,7 +5,9 @@
//!
//! This WASM component implements the channel interface for handling Feishu
//! webhooks (Event Subscription v2.0) and sending messages back via the
-//! Feishu/Lark Bot API.
+//! Feishu/Lark Bot API. IronClaw currently does not connect to Feishu's
+//! long-connection websocket subscription mode; use Event Subscription
+//! webhooks for this channel.
//!
//! # Features
//!
diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs
index ab1c0e13..25e80cb9 100644
--- a/src/agent/agent_loop.rs
+++ b/src/agent/agent_loop.rs
@@ -157,8 +157,8 @@ pub struct AgentDeps {
pub hooks: Arc,
/// Cost enforcement guardrails (daily budget, hourly rate limits).
pub cost_guard: Arc,
- /// SSE broadcast sender for live job event streaming to the web gateway.
- pub sse_tx: Option>,
+ /// SSE manager for live job event streaming to the web gateway.
+ pub sse_tx: Option>,
/// HTTP interceptor for trace recording/replay.
pub http_interceptor: Option>,
/// Audio transcription middleware for voice messages.
@@ -169,6 +169,9 @@ pub struct AgentDeps {
pub sandbox_readiness: crate::agent::routine_engine::SandboxReadiness,
/// Software builder for self-repair tool rebuilding.
pub builder: Option>,
+ /// Resolved LLM backend identifier (e.g., "nearai", "openai", "groq").
+ /// Used by `/model` persistence to determine which env var to update.
+ pub llm_backend: String,
}
/// The main agent that coordinates all components.
@@ -235,8 +238,8 @@ impl Agent {
hooks: deps.hooks.clone(),
},
);
- if let Some(ref tx) = deps.sse_tx {
- scheduler.set_sse_sender(tx.clone());
+ if let Some(ref sse) = deps.sse_tx {
+ scheduler.set_sse_sender(Arc::clone(sse));
}
if let Some(ref interceptor) = deps.http_interceptor {
scheduler.set_http_interceptor(Arc::clone(interceptor));
diff --git a/src/agent/commands.rs b/src/agent/commands.rs
index 75c99359..b6aff3c0 100644
--- a/src/agent/commands.rs
+++ b/src/agent/commands.rs
@@ -841,12 +841,50 @@ impl Agent {
.await
{
tracing::warn!("Failed to persist model to DB: {}", e);
+ } else {
+ tracing::debug!("Persisted selected_model to DB: {}", model);
}
+ } else {
+ tracing::warn!("No database store available — model choice will not persist to DB");
}
- // 2. Update TOML config file if it exists (sync I/O in spawn_blocking).
+ // 2. Update .env and TOML config file (sync I/O in spawn_blocking).
let model_owned = model.to_string();
+ let backend = self.deps.llm_backend.clone();
if let Err(e) = tokio::task::spawn_blocking(move || {
+ // 2a. Update the backend-specific model env var in ~/.ironclaw/.env.
+ //
+ // Env vars have the HIGHEST priority in LlmConfig::resolve_model()
+ // (env var > TOML > DB > default). If the .env file has e.g.
+ // NEARAI_MODEL=old-model, it shadows everything else. We must
+ // update this var or the /model change is invisible on restart.
+ let registry = crate::llm::ProviderRegistry::load();
+ let model_env = registry.model_env_var(&backend);
+ let env_var_prefix = format!("{}=", model_env);
+
+ // Only update the .env file if the var is actually set there
+ // (avoid injecting new vars the user never configured).
+ let env_path = crate::bootstrap::ironclaw_env_path();
+ let env_has_var = std::fs::read_to_string(&env_path)
+ .ok()
+ .is_some_and(|content| {
+ content.lines().any(|line| {
+ let trimmed = line.trim_start();
+ !trimmed.starts_with('#') && trimmed.starts_with(&env_var_prefix)
+ })
+ });
+ if env_has_var {
+ if let Err(e) = crate::bootstrap::upsert_bootstrap_var(model_env, &model_owned) {
+ tracing::warn!("Failed to update {} in .env: {}", model_env, e);
+ } else {
+ tracing::debug!("Updated {} in .env to {}", model_env, model_owned);
+ }
+ }
+
+ // 2b. Update (or create) the TOML config file.
+ //
+ // The TOML overlay has higher priority than DB settings on
+ // startup, so it MUST stay in sync with the DB.
let toml_path = crate::settings::Settings::default_toml_path();
match crate::settings::Settings::load_toml(&toml_path) {
Ok(Some(mut settings)) => {
@@ -856,7 +894,15 @@ impl Agent {
}
}
Ok(None) => {
- // No config file on disk; nothing to update.
+ // No config file yet — create one so the model choice
+ // survives restarts even when the DB is unavailable.
+ let settings = crate::settings::Settings {
+ selected_model: Some(model_owned),
+ ..Default::default()
+ };
+ if let Err(e) = settings.save_toml(&toml_path) {
+ tracing::warn!("Failed to create config.toml for model persistence: {}", e);
+ }
}
Err(e) => {
tracing::warn!("Failed to load config.toml for model persistence: {}", e);
@@ -865,7 +911,7 @@ impl Agent {
})
.await
{
- tracing::warn!("Model TOML persistence task failed: {}", e);
+ tracing::warn!("Model persistence task failed: {}", e);
}
}
}
diff --git a/src/agent/dispatcher.rs b/src/agent/dispatcher.rs
index 7fc8e0ca..a195458d 100644
--- a/src/agent/dispatcher.rs
+++ b/src/agent/dispatcher.rs
@@ -915,7 +915,14 @@ pub(super) async fn execute_chat_tool_standalone(
params: &serde_json::Value,
job_ctx: &crate::context::JobContext,
) -> Result {
- crate::tools::execute::execute_tool_with_safety(tools, safety, tool_name, params, job_ctx).await
+ crate::tools::execute::execute_tool_with_safety(
+ tools,
+ safety,
+ tool_name,
+ params.clone(),
+ job_ctx,
+ )
+ .await
}
/// Parsed auth result fields for emitting StatusUpdate::AuthRequired.
@@ -1091,15 +1098,23 @@ pub(crate) fn extract_suggestions(text: &str) -> (String, Vec) {
Regex::new(r"(?s)\s*(.*?)\s*").expect("valid regex") // safety: constant pattern
});
- // Find the position of the last closing code fence to avoid matching inside code blocks
- let last_code_fence = text.rfind("```").unwrap_or(0);
+ // Build a sorted list of code fence positions to determine open/close pairing.
+ // A position is "inside" a fenced block when it falls between an odd-numbered
+ // fence (opening) and the next even-numbered fence (closing).
+ let fence_positions: Vec = text.match_indices("```").map(|(pos, _)| pos).collect();
- // Find all matches, take the last one that's after the last code fence
+ let is_inside_fence = |pos: usize| -> bool {
+ // Count how many fences appear before `pos`. If odd, we're inside a fence.
+ let count = fence_positions.iter().take_while(|&&fp| fp <= pos).count();
+ count % 2 == 1
+ };
+
+ // Find all matches, take the last one that's outside any code fence
let mut best_match: Option> = None;
let mut best_capture: Option = None;
for caps in RE.captures_iter(text) {
if let (Some(full), Some(inner)) = (caps.get(0), caps.get(1))
- && full.start() >= last_code_fence
+ && !is_inside_fence(full.start())
{
best_match = Some(full);
best_capture = Some(inner.as_str().to_string());
@@ -1218,6 +1233,7 @@ mod tests {
document_extraction: None,
sandbox_readiness: crate::agent::routine_engine::SandboxReadiness::DisabledByConfig,
builder: None,
+ llm_backend: "nearai".to_string(),
};
Agent::new(
@@ -1893,7 +1909,7 @@ mod tests {
Ok(ToolCompletionResponse {
content: None,
tool_calls: vec![ToolCall {
- id: format!("call_{}", uuid::Uuid::new_v4()),
+ id: crate::llm::generate_tool_call_id(0, 0),
name: "echo".to_string(),
arguments: serde_json::json!({"message": "looping"}),
}],
@@ -2046,7 +2062,7 @@ mod tests {
Ok(ToolCompletionResponse {
content: None,
tool_calls: vec![ToolCall {
- id: format!("call_{}", uuid::Uuid::new_v4()),
+ id: crate::llm::generate_tool_call_id(0, 0),
name: "nonexistent_tool".to_string(),
arguments: serde_json::json!({}),
}],
@@ -2085,6 +2101,7 @@ mod tests {
document_extraction: None,
sandbox_readiness: crate::agent::routine_engine::SandboxReadiness::DisabledByConfig,
builder: None,
+ llm_backend: "nearai".to_string(),
};
Agent::new(
@@ -2205,6 +2222,7 @@ mod tests {
document_extraction: None,
sandbox_readiness: crate::agent::routine_engine::SandboxReadiness::DisabledByConfig,
builder: None,
+ llm_backend: "nearai".to_string(),
};
Agent::new(
@@ -2338,6 +2356,16 @@ mod tests {
assert!(suggestions.is_empty()); // safety: test
}
+ #[test]
+ fn test_extract_suggestions_inside_unclosed_code_fence() {
+ // Regression: odd number of fences (unclosed fence) must still be
+ // treated as "inside a code block".
+ let input = "```\ncode\n[\"bar\"]";
+ let (text, suggestions) = super::extract_suggestions(input);
+ assert_eq!(text, input); // safety: test
+ assert!(suggestions.is_empty()); // safety: test
+ }
+
#[test]
fn test_extract_suggestions_after_code_fence() {
let input = "```\ncode\n```\nAnswer.\n[\"foo\"]";
diff --git a/src/agent/job_monitor.rs b/src/agent/job_monitor.rs
index 675d0426..02f5e3e2 100644
--- a/src/agent/job_monitor.rs
+++ b/src/agent/job_monitor.rs
@@ -44,7 +44,7 @@ pub struct JobMonitorRoute {
/// the main agent's context window).
pub fn spawn_job_monitor(
job_id: Uuid,
- event_rx: broadcast::Receiver<(Uuid, SseEvent)>,
+ event_rx: broadcast::Receiver<(Uuid, String, SseEvent)>,
inject_tx: mpsc::Sender,
route: JobMonitorRoute,
) -> JoinHandle<()> {
@@ -56,7 +56,7 @@ pub fn spawn_job_monitor(
/// jobs don't stay `InProgress` forever in the `ContextManager`.
pub fn spawn_job_monitor_with_context(
job_id: Uuid,
- mut event_rx: broadcast::Receiver<(Uuid, SseEvent)>,
+ mut event_rx: broadcast::Receiver<(Uuid, String, SseEvent)>,
inject_tx: mpsc::Sender,
route: JobMonitorRoute,
context_manager: Option>,
@@ -68,7 +68,7 @@ pub fn spawn_job_monitor_with_context(
loop {
match event_rx.recv().await {
- Ok((ev_job_id, event)) => {
+ Ok((ev_job_id, _user_id, event)) => {
if ev_job_id != job_id {
continue;
}
@@ -162,7 +162,7 @@ pub fn spawn_job_monitor_with_context(
/// inject messages into) but we still need to free the `max_jobs` slot.
pub fn spawn_completion_watcher(
job_id: Uuid,
- mut event_rx: broadcast::Receiver<(Uuid, SseEvent)>,
+ mut event_rx: broadcast::Receiver<(Uuid, String, SseEvent)>,
context_manager: Arc,
) -> JoinHandle<()> {
let short_id = job_id.to_string()[..8].to_string();
@@ -170,7 +170,9 @@ pub fn spawn_completion_watcher(
tokio::spawn(async move {
loop {
match event_rx.recv().await {
- Ok((ev_job_id, SseEvent::JobResult { status, .. })) if ev_job_id == job_id => {
+ Ok((ev_job_id, _user_id, SseEvent::JobResult { status, .. }))
+ if ev_job_id == job_id =>
+ {
let target = if status == "completed" {
JobState::Completed
} else {
@@ -227,7 +229,7 @@ mod tests {
#[tokio::test]
async fn test_monitor_forwards_assistant_messages() {
- let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
+ let (event_tx, _) = broadcast::channel::<(Uuid, String, SseEvent)>(16);
let (inject_tx, mut inject_rx) = mpsc::channel::(16);
let job_id = Uuid::new_v4();
@@ -237,6 +239,7 @@ mod tests {
event_tx
.send((
job_id,
+ "test-user".to_string(),
SseEvent::JobMessage {
job_id: job_id.to_string(),
role: "assistant".to_string(),
@@ -259,7 +262,7 @@ mod tests {
#[tokio::test]
async fn test_monitor_ignores_other_jobs() {
- let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
+ let (event_tx, _) = broadcast::channel::<(Uuid, String, SseEvent)>(16);
let (inject_tx, mut inject_rx) = mpsc::channel::(16);
let job_id = Uuid::new_v4();
@@ -270,6 +273,7 @@ mod tests {
event_tx
.send((
other_job_id,
+ "test-user".to_string(),
SseEvent::JobMessage {
job_id: other_job_id.to_string(),
role: "assistant".to_string(),
@@ -289,7 +293,7 @@ mod tests {
#[tokio::test]
async fn test_monitor_exits_on_job_result() {
- let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
+ let (event_tx, _) = broadcast::channel::<(Uuid, String, SseEvent)>(16);
let (inject_tx, mut inject_rx) = mpsc::channel::(16);
let job_id = Uuid::new_v4();
@@ -299,6 +303,7 @@ mod tests {
event_tx
.send((
job_id,
+ "test-user".to_string(),
SseEvent::JobResult {
job_id: job_id.to_string(),
status: "completed".to_string(),
@@ -324,7 +329,7 @@ mod tests {
#[tokio::test]
async fn test_monitor_skips_tool_events() {
- let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
+ let (event_tx, _) = broadcast::channel::<(Uuid, String, SseEvent)>(16);
let (inject_tx, mut inject_rx) = mpsc::channel::(16);
let job_id = Uuid::new_v4();
@@ -334,6 +339,7 @@ mod tests {
event_tx
.send((
job_id,
+ "test-user".to_string(),
SseEvent::JobToolUse {
job_id: job_id.to_string(),
tool_name: "shell".to_string(),
@@ -346,6 +352,7 @@ mod tests {
event_tx
.send((
job_id,
+ "test-user".to_string(),
SseEvent::JobMessage {
job_id: job_id.to_string(),
role: "user".to_string(),
@@ -402,7 +409,7 @@ mod tests {
.await
.unwrap();
- let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
+ let (event_tx, _) = broadcast::channel::<(Uuid, String, SseEvent)>(16);
let (inject_tx, mut inject_rx) = mpsc::channel::(16);
let handle = spawn_job_monitor_with_context(
@@ -417,6 +424,7 @@ mod tests {
event_tx
.send((
job_id,
+ "test-user".to_string(),
SseEvent::JobResult {
job_id: job_id.to_string(),
status: "completed".to_string(),
@@ -450,7 +458,7 @@ mod tests {
.await
.unwrap();
- let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
+ let (event_tx, _) = broadcast::channel::<(Uuid, String, SseEvent)>(16);
let (inject_tx, mut inject_rx) = mpsc::channel::(16);
let handle = spawn_job_monitor_with_context(
@@ -465,6 +473,7 @@ mod tests {
event_tx
.send((
job_id,
+ "test-user".to_string(),
SseEvent::JobResult {
job_id: job_id.to_string(),
status: "failed".to_string(),
@@ -498,12 +507,13 @@ mod tests {
.await
.unwrap();
- let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
+ let (event_tx, _) = broadcast::channel::<(Uuid, String, SseEvent)>(16);
let handle = spawn_completion_watcher(job_id, event_tx.subscribe(), Arc::clone(&cm));
event_tx
.send((
job_id,
+ "test-user".to_string(),
SseEvent::JobResult {
job_id: job_id.to_string(),
status: "completed".to_string(),
diff --git a/src/agent/routine_engine.rs b/src/agent/routine_engine.rs
index de2879b4..7c7ef5f3 100644
--- a/src/agent/routine_engine.rs
+++ b/src/agent/routine_engine.rs
@@ -1305,6 +1305,19 @@ async fn execute_lightweight(
}
}
+/// Sanitize a user-controlled string before interpolation into an LLM prompt.
+/// Strips newlines (which could break prompt structure) and truncates to a
+/// reasonable length to limit abuse surface.
+fn sanitize_prompt_field(value: &str) -> String {
+ const MAX_LEN: usize = 128;
+ value
+ .chars()
+ .filter(|&c| c != '\n' && c != '\r')
+ .take(MAX_LEN)
+ .map(|c| if c == '`' { '\'' } else { c })
+ .collect()
+}
+
fn build_lightweight_prompt(
prompt: &str,
context_parts: &[String],
@@ -1323,14 +1336,16 @@ fn build_lightweight_prompt(
);
if let Some(channel) = notify.channel.as_deref() {
+ let sanitized = sanitize_prompt_field(channel);
full_prompt.push_str(&format!(
- "The configured delivery channel for this routine is `{channel}`.\n"
+ "The configured delivery channel for this routine is `{sanitized}`.\n"
));
}
if let Some(user) = notify.user.as_deref() {
+ let sanitized = sanitize_prompt_field(user);
full_prompt.push_str(&format!(
- "The configured delivery target for this routine is `{user}`.\n"
+ "The configured delivery target for this routine is `{sanitized}`.\n"
));
}
@@ -1440,6 +1455,7 @@ fn handle_text_response(
/// This is a simplified version of the full dispatcher loop:
/// - Max 3-5 iterations (configurable)
/// - Sequential tool execution (not parallel)
+/// - Uses the owner's live autonomous tool scope when lightweight tools are enabled
/// - Auto-approval of non-Always tools
/// - No hooks or approval dialogs
async fn execute_lightweight_with_tools(
diff --git a/src/agent/scheduler.rs b/src/agent/scheduler.rs
index 2e23b35f..02953a4b 100644
--- a/src/agent/scheduler.rs
+++ b/src/agent/scheduler.rs
@@ -9,7 +9,6 @@ use tokio::task::JoinHandle;
use uuid::Uuid;
use crate::agent::task::{Task, TaskContext, TaskOutput};
-use crate::channels::web::types::SseEvent;
use crate::config::AgentConfig;
use crate::context::{ContextManager, JobContext, JobState};
use crate::db::Database;
@@ -67,8 +66,8 @@ pub struct Scheduler {
extension_manager: Option>,
store: Option>,
hooks: Arc,
- /// SSE broadcast sender for live job event streaming.
- sse_tx: Option>,
+ /// SSE manager for live job event streaming.
+ sse_tx: Option>,
/// HTTP interceptor for trace recording/replay (propagated to workers).
http_interceptor: Option>,
/// Running jobs (main LLM-driven jobs).
@@ -102,9 +101,9 @@ impl Scheduler {
}
}
- /// Set the SSE broadcast sender for live job event streaming.
- pub fn set_sse_sender(&mut self, tx: tokio::sync::broadcast::Sender) {
- self.sse_tx = Some(tx);
+ /// Set the SSE manager for live job event streaming.
+ pub fn set_sse_sender(&mut self, sse: Arc) {
+ self.sse_tx = Some(sse);
}
/// Set the HTTP interceptor for trace recording/replay.
@@ -549,11 +548,7 @@ impl Scheduler {
// Delegate to shared tool execution pipeline
let output_str = crate::tools::execute::execute_tool_with_safety(
- &tools,
- &safety,
- tool_name,
- &normalized_params,
- &job_ctx,
+ &tools, &safety, tool_name, params, &job_ctx,
)
.await?;
diff --git a/src/agent/session.rs b/src/agent/session.rs
index 745b26be..45594922 100644
--- a/src/agent/session.rs
+++ b/src/agent/session.rs
@@ -17,7 +17,7 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::channels::web::util::truncate_preview;
-use crate::llm::{ChatMessage, ToolCall};
+use crate::llm::{ChatMessage, ToolCall, generate_tool_call_id};
/// A session containing one or more threads.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -414,7 +414,12 @@ impl Thread {
/// completed actions in subsequent turns.
pub fn messages(&self) -> Vec {
let mut messages = Vec::new();
- for turn in &self.turns {
+ // We use the enumeration index (`turn_idx`) rather than `turn.turn_number`
+ // intentionally: after `truncate_turns()`, the remaining turns are
+ // re-numbered starting from 0, so the enumeration index and turn_number
+ // are equivalent. Using the index avoids coupling to the field and keeps
+ // tool-call ID generation deterministic for the current message window.
+ for (turn_idx, turn) in self.turns.iter().enumerate() {
if turn.image_content_parts.is_empty() {
messages.push(ChatMessage::user(&turn.user_input));
} else {
@@ -425,13 +430,23 @@ impl Thread {
}
if !turn.tool_calls.is_empty() {
- // Build ToolCall objects with synthetic stable IDs
- let tool_calls: Vec = turn
+ // Assign synthetic call IDs for this turn's tool calls, so that
+ // declarations and results can be consistently correlated.
+ let tool_calls_with_ids: Vec<(String, &_)> = turn
.tool_calls
.iter()
.enumerate()
- .map(|(i, tc)| ToolCall {
- id: format!("turn{}_{}", turn.turn_number, i),
+ .map(|(tc_idx, tc)| {
+ // Use provider-compatible tool call IDs derived from turn/tool indices.
+ (generate_tool_call_id(turn_idx, tc_idx), tc)
+ })
+ .collect();
+
+ // Build ToolCall objects using the synthetic call IDs.
+ let tool_calls: Vec = tool_calls_with_ids
+ .iter()
+ .map(|(call_id, tc)| ToolCall {
+ id: call_id.clone(),
name: tc.name.clone(),
arguments: tc.parameters.clone(),
})
@@ -441,8 +456,7 @@ impl Thread {
messages.push(ChatMessage::assistant_with_tool_calls(None, tool_calls));
// Individual tool result messages, truncated to limit context size.
- for (i, tc) in turn.tool_calls.iter().enumerate() {
- let call_id = format!("turn{}_{}", turn.turn_number, i);
+ for (call_id, tc) in tool_calls_with_ids {
let content = if let Some(ref err) = tc.error {
// .error already contains the full error text;
// pass through without wrapping to avoid double-prefix.
diff --git a/src/agent/thread_ops.rs b/src/agent/thread_ops.rs
index eec29099..ddfd0c0f 100644
--- a/src/agent/thread_ops.rs
+++ b/src/agent/thread_ops.rs
@@ -1646,7 +1646,7 @@ impl Agent {
};
match ext_mgr
- .configure_token(&pending.extension_name, token)
+ .configure_token(&pending.extension_name, token, &message.user_id)
.await
{
Ok(result) if result.activated => {
diff --git a/src/app.rs b/src/app.rs
index b2520144..edd547d3 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -325,12 +325,51 @@ 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);
+ ws = ws.with_embeddings_cached(emb.clone(), emb_cache_config.clone());
+ }
+
+ // 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));
+
+ // Detect multi-tenant mode: when GATEWAY_USER_TOKENS is configured,
+ // each authenticated user needs their own workspace scope. Use
+ // WorkspacePool (which implements WorkspaceResolver) to create
+ // per-user workspaces on demand instead of sharing the startup
+ // workspace across all users.
+ let is_multi_tenant = self
+ .config
+ .channels
+ .gateway
+ .as_ref()
+ .is_some_and(|gw| gw.user_tokens.is_some());
+
+ if is_multi_tenant {
+ let pool = Arc::new(crate::channels::web::server::WorkspacePool::new(
+ Arc::clone(db),
+ embeddings.clone(),
+ emb_cache_config,
+ self.config.search.clone(),
+ self.config.workspace.clone(),
+ ));
+ tools.register_memory_tools_with_resolver(pool);
+ tracing::info!(
+ "Memory tools configured with per-user workspace resolver (multi-tenant mode)"
+ );
+ } else {
+ tools.register_memory_tools(Arc::clone(&ws));
+ }
+
Some(ws)
} else {
None
diff --git a/src/bootstrap.rs b/src/bootstrap.rs
index f8a283f3..a5c8ffdb 100644
--- a/src/bootstrap.rs
+++ b/src/bootstrap.rs
@@ -568,14 +568,12 @@ impl Drop for PidLock {
#[cfg(test)]
mod tests {
use super::*;
+ use crate::config::helpers::lock_env;
use std::process::Command;
- use std::sync::Mutex;
use std::thread;
use std::time::{Duration, Instant};
use tempfile::tempdir;
- static ENV_MUTEX: Mutex<()> = Mutex::new(());
-
#[test]
fn test_save_and_load_database_url() {
let dir = tempdir().unwrap();
@@ -669,8 +667,23 @@ INJECTED="pwned"#;
#[test]
fn test_ironclaw_env_path() {
- let path = ironclaw_env_path();
- assert!(path.ends_with(".ironclaw/.env"));
+ // Use compute_ironclaw_base_dir() directly to avoid LazyLock caching,
+ // which can be poisoned by whichever test initializes it first.
+ let _guard = lock_env();
+ let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
+ // SAFETY: Under lock_env(), no concurrent env access.
+ unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") };
+
+ let path = compute_ironclaw_base_dir().join(".env");
+ assert!(
+ path.ends_with(".ironclaw/.env"),
+ "expected path ending with .ironclaw/.env, got: {}",
+ path.display()
+ );
+
+ if let Some(val) = old_val {
+ unsafe { std::env::set_var("IRONCLAW_BASE_DIR", val) };
+ }
}
#[test]
@@ -836,7 +849,7 @@ INJECTED="pwned"#;
#[test]
fn test_libsql_autodetect_sets_backend_when_db_exists() {
- let _guard = ENV_MUTEX.lock().unwrap();
+ let _guard = lock_env();
let old_val = std::env::var("DATABASE_BACKEND").ok();
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
unsafe { std::env::remove_var("DATABASE_BACKEND") };
@@ -907,7 +920,7 @@ INJECTED="pwned"#;
#[test]
fn test_libsql_autodetect_does_not_override_explicit_backend() {
- let _guard = ENV_MUTEX.lock().unwrap();
+ let _guard = lock_env();
let old_val = std::env::var("DATABASE_BACKEND").ok();
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
unsafe { std::env::set_var("DATABASE_BACKEND", "postgres") };
@@ -1034,7 +1047,7 @@ INJECTED="pwned"#;
fn test_ironclaw_base_dir_default() {
// This test must run first (or in isolation) before the LazyLock is initialized.
// It verifies that when IRONCLAW_BASE_DIR is not set, the default path is used.
- let _guard = ENV_MUTEX.lock().unwrap();
+ let _guard = lock_env();
let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") };
@@ -1054,7 +1067,7 @@ INJECTED="pwned"#;
fn test_ironclaw_base_dir_env_override() {
// This test verifies that when IRONCLAW_BASE_DIR is set,
// the custom path is used. Must run before LazyLock is initialized.
- let _guard = ENV_MUTEX.lock().unwrap();
+ let _guard = lock_env();
let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "/custom/ironclaw/path") };
@@ -1076,7 +1089,7 @@ INJECTED="pwned"#;
fn test_compute_base_dir_env_path_join() {
// Verifies that ironclaw_env_path correctly joins .env to the base dir.
// Uses compute_ironclaw_base_dir directly to avoid LazyLock caching.
- let _guard = ENV_MUTEX.lock().unwrap();
+ let _guard = lock_env();
let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "/my/custom/dir") };
@@ -1098,7 +1111,7 @@ INJECTED="pwned"#;
#[test]
fn test_ironclaw_base_dir_empty_env() {
// Verifies that empty IRONCLAW_BASE_DIR falls back to default.
- let _guard = ENV_MUTEX.lock().unwrap();
+ let _guard = lock_env();
let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "") };
@@ -1120,7 +1133,7 @@ INJECTED="pwned"#;
#[test]
fn test_ironclaw_base_dir_special_chars() {
// Verifies that paths with special characters are handled correctly.
- let _guard = ENV_MUTEX.lock().unwrap();
+ let _guard = lock_env();
let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "/tmp/test_with-special.chars") };
diff --git a/src/channels/wasm/router.rs b/src/channels/wasm/router.rs
index 8005ccea..510bc461 100644
--- a/src/channels/wasm/router.rs
+++ b/src/channels/wasm/router.rs
@@ -333,6 +333,9 @@ async fn webhook_handler(
let channel_name = channel.channel_name();
+ // Track whether any authentication was performed and passed.
+ let mut did_authenticate = false;
+
// Check if secret is required
if state.router.requires_secret(channel_name).await {
// Get the secret header name for this channel (from capabilities or default)
@@ -382,6 +385,7 @@ async fn webhook_handler(
);
}
tracing::debug!(channel = %channel_name, "Webhook secret validated");
+ did_authenticate = true;
}
None => {
tracing::warn!(
@@ -433,6 +437,7 @@ async fn webhook_handler(
);
}
tracing::debug!(channel = %channel_name, "Ed25519 signature verified");
+ did_authenticate = true;
}
_ => {
tracing::warn!(
@@ -484,6 +489,7 @@ async fn webhook_handler(
);
}
tracing::debug!(channel = %channel_name, "HMAC-SHA256 signature verified");
+ did_authenticate = true;
}
_ => {
tracing::warn!(
@@ -510,8 +516,9 @@ async fn webhook_handler(
})
.collect();
- // Call the WASM channel
- let secret_validated = state.router.requires_secret(channel_name).await;
+ // Call the WASM channel. `did_authenticate` was set above by whichever
+ // auth guard (secret / Ed25519 / HMAC) successfully validated the request.
+ let secret_validated = did_authenticate;
tracing::info!(
channel = %channel_name,
diff --git a/src/channels/web/auth.rs b/src/channels/web/auth.rs
index b2fa4e4f..7dc8adb4 100644
--- a/src/channels/web/auth.rs
+++ b/src/channels/web/auth.rs
@@ -1,17 +1,133 @@
//! Bearer token authentication middleware for the web gateway.
+//!
+//! Supports multi-user mode: each token maps to a `UserIdentity` that carries
+//! the user_id. The identity is inserted into request extensions so downstream
+//! handlers can extract it via `AuthenticatedUser`.
+
+use std::collections::HashMap;
use axum::{
- extract::{Request, State},
- http::{HeaderMap, Method, StatusCode},
+ extract::{FromRequestParts, Request, State},
+ http::{HeaderMap, Method, StatusCode, request::Parts},
middleware::Next,
response::{IntoResponse, Response},
};
+use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
-/// Shared auth state injected via axum middleware state.
+/// Identity resolved from a bearer token.
+#[derive(Debug, Clone)]
+pub struct UserIdentity {
+ pub user_id: String,
+ /// Additional user scopes this identity can read from.
+ pub workspace_read_scopes: Vec,
+}
+
+/// Hash a token with SHA-256 for constant-size, timing-safe storage.
+fn hash_token(token: &str) -> [u8; 32] {
+ let mut hasher = Sha256::new();
+ hasher.update(token.as_bytes());
+ hasher.finalize().into()
+}
+
+/// Multi-user auth state: maps token hashes to user identities.
+///
+/// Tokens are SHA-256 hashed on construction so they are never stored in
+/// plaintext. Authentication compares fixed-size (32-byte) digests using
+/// constant-time comparison, eliminating both length-oracle timing leaks
+/// and accidental token exposure in memory dumps.
+///
+/// In single-user mode (the default), contains exactly one entry.
#[derive(Clone)]
-pub struct AuthState {
- pub token: String,
+pub struct MultiAuthState {
+ /// Maps SHA-256(token) → identity. Tokens are never stored in cleartext.
+ hashed_tokens: Vec<([u8; 32], UserIdentity)>,
+ /// Original first token kept only for single-user startup printing.
+ /// Not used for authentication.
+ display_token: Option,
+}
+
+impl MultiAuthState {
+ /// Create a single-user auth state (backwards compatible).
+ pub fn single(token: String, user_id: String) -> Self {
+ let hash = hash_token(&token);
+ Self {
+ hashed_tokens: vec![(
+ hash,
+ UserIdentity {
+ user_id,
+ workspace_read_scopes: Vec::new(),
+ },
+ )],
+ display_token: Some(token),
+ }
+ }
+
+ /// Create a multi-user auth state from a map of tokens to identities.
+ pub fn multi(tokens: HashMap) -> Self {
+ let hashed_tokens: Vec<([u8; 32], UserIdentity)> = tokens
+ .into_iter()
+ .map(|(tok, identity)| (hash_token(&tok), identity))
+ .collect();
+ Self {
+ hashed_tokens,
+ display_token: None,
+ }
+ }
+
+ /// Authenticate a token, returning the associated identity if valid.
+ ///
+ /// Uses SHA-256 hashing + constant-time comparison (`subtle::ConstantTimeEq`)
+ /// to prevent timing side-channels. Both the candidate and stored tokens are
+ /// hashed to 32-byte digests, eliminating length-oracle leaks. Iterates all
+ /// entries regardless of match to avoid early-exit timing differences.
+ /// O(n) in the number of configured users — negligible for typical
+ /// deployments (< 10 users).
+ pub fn authenticate(&self, candidate: &str) -> Option<&UserIdentity> {
+ let candidate_hash = hash_token(candidate);
+ let mut matched: Option<&UserIdentity> = None;
+ for (stored_hash, identity) in &self.hashed_tokens {
+ if bool::from(candidate_hash.ct_eq(stored_hash)) {
+ matched = Some(identity);
+ }
+ }
+ matched
+ }
+
+ /// Get the first token for backwards-compatible printing at startup.
+ ///
+ /// Only available in single-user mode; returns `None` in multi-user mode
+ /// to avoid exposing tokens.
+ pub fn first_token(&self) -> Option<&str> {
+ self.display_token.as_deref()
+ }
+
+ /// Get the first user identity (for single-user fallback).
+ pub fn first_identity(&self) -> Option<&UserIdentity> {
+ self.hashed_tokens.first().map(|(_, id)| id)
+ }
+}
+
+/// Axum extractor that provides the authenticated user identity.
+///
+/// Only available on routes behind `auth_middleware`. Extracts the
+/// `UserIdentity` that the middleware inserted into request extensions.
+pub struct AuthenticatedUser(pub UserIdentity);
+
+impl FromRequestParts for AuthenticatedUser
+where
+ S: Send + Sync,
+{
+ type Rejection = (StatusCode, &'static str);
+
+ async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result {
+ parts
+ .extensions
+ .get::()
+ .cloned()
+ .map(AuthenticatedUser)
+ .ok_or((StatusCode::UNAUTHORIZED, "Not authenticated"))
+ }
}
/// Whether query-string token auth is allowed for this request.
@@ -51,29 +167,34 @@ fn query_token(request: &Request) -> Option {
/// Auth middleware that validates bearer token from header or query param.
///
/// SSE connections can't set headers from `EventSource`, so we also accept
-/// `?token=xxx` as a query parameter, but only on SSE endpoints.
+/// `?token=xxx` as a query parameter, but only on SSE/WS endpoints.
+///
+/// On successful authentication, inserts the matching `UserIdentity` into
+/// request extensions for downstream extraction via `AuthenticatedUser`.
pub async fn auth_middleware(
- State(auth): State,
+ State(auth): State,
headers: HeaderMap,
- request: Request,
+ mut request: Request,
next: Next,
) -> Response {
- // Try Authorization header first (constant-time comparison).
+ // Try Authorization header first.
// RFC 6750 Section 2.1: auth-scheme comparison is case-insensitive.
if let Some(auth_header) = headers.get("authorization")
&& let Ok(value) = auth_header.to_str()
&& value.len() > 7
&& value[..7].eq_ignore_ascii_case("Bearer ")
- && bool::from(value.as_bytes()[7..].ct_eq(auth.token.as_bytes()))
+ && let Some(identity) = auth.authenticate(&value[7..])
{
+ request.extensions_mut().insert(identity.clone());
return next.run(request).await;
}
- // Fall back to query parameter, but only for SSE endpoints (constant-time comparison).
+ // Fall back to query parameter, but only for SSE/WS endpoints.
if allows_query_token_auth(&request)
&& let Some(token) = query_token(&request)
- && bool::from(token.as_bytes().ct_eq(auth.token.as_bytes()))
+ && let Some(identity) = auth.authenticate(&token)
{
+ request.extensions_mut().insert(identity.clone());
return next.run(request).await;
}
@@ -83,15 +204,61 @@ pub async fn auth_middleware(
#[cfg(test)]
mod tests {
use super::*;
- use crate::testing::credentials::{TEST_AUTH_SECRET_TOKEN, TEST_BEARER_TOKEN};
+ use crate::testing::credentials::TEST_AUTH_SECRET_TOKEN;
#[test]
- fn test_auth_state_clone() {
- let state = AuthState {
- token: TEST_BEARER_TOKEN.to_string(),
- };
- let cloned = state.clone();
- assert_eq!(cloned.token, TEST_BEARER_TOKEN);
+ fn test_multi_auth_state_single() {
+ let state = MultiAuthState::single("tok-123".to_string(), "alice".to_string());
+ let identity = state.authenticate("tok-123");
+ assert!(identity.is_some());
+ assert_eq!(identity.unwrap().user_id, "alice");
+ }
+
+ #[test]
+ fn test_multi_auth_state_reject_wrong_token() {
+ let state = MultiAuthState::single("tok-123".to_string(), "alice".to_string());
+ assert!(state.authenticate("wrong-token").is_none());
+ }
+
+ #[test]
+ fn test_multi_auth_state_multi_users() {
+ let mut tokens = HashMap::new();
+ tokens.insert(
+ "tok-alice".to_string(),
+ UserIdentity {
+ user_id: "alice".to_string(),
+ workspace_read_scopes: Vec::new(),
+ },
+ );
+ tokens.insert(
+ "tok-bob".to_string(),
+ UserIdentity {
+ user_id: "bob".to_string(),
+ workspace_read_scopes: Vec::new(),
+ },
+ );
+ let state = MultiAuthState::multi(tokens);
+
+ let alice = state.authenticate("tok-alice").unwrap();
+ assert_eq!(alice.user_id, "alice");
+
+ let bob = state.authenticate("tok-bob").unwrap();
+ assert_eq!(bob.user_id, "bob");
+
+ assert!(state.authenticate("tok-charlie").is_none());
+ }
+
+ #[test]
+ fn test_multi_auth_state_first_token() {
+ let state = MultiAuthState::single("my-token".to_string(), "user1".to_string());
+ assert_eq!(state.first_token(), Some("my-token"));
+ }
+
+ #[test]
+ fn test_multi_auth_state_first_identity() {
+ let state = MultiAuthState::single("my-token".to_string(), "user1".to_string());
+ let identity = state.first_identity().unwrap();
+ assert_eq!(identity.user_id, "user1");
}
use axum::Router;
@@ -107,9 +274,7 @@ mod tests {
/// Router with streaming endpoints (query auth allowed) and regular
/// endpoints (query auth rejected).
fn test_app(token: &str) -> Router {
- let state = AuthState {
- token: token.to_string(),
- };
+ let state = MultiAuthState::single(token.to_string(), "test-user".to_string());
Router::new()
.route("/api/chat/events", get(dummy_handler))
.route("/api/logs/events", get(dummy_handler))
@@ -306,4 +471,200 @@ mod tests {
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
+
+ // --- Multi-tenant auth integration tests ---
+
+ /// Handler that extracts `AuthenticatedUser` and returns the resolved user_id.
+ async fn identity_handler(AuthenticatedUser(identity): AuthenticatedUser) -> String {
+ identity.user_id
+ }
+
+ /// Handler that extracts `AuthenticatedUser` and returns workspace_read_scopes as JSON.
+ async fn scopes_handler(AuthenticatedUser(identity): AuthenticatedUser) -> String {
+ serde_json::to_string(&identity.workspace_read_scopes).unwrap()
+ }
+
+ /// Build a multi-user router where each token maps to a distinct identity.
+ fn multi_user_app(tokens: HashMap) -> Router {
+ let state = MultiAuthState::multi(tokens);
+ Router::new()
+ .route("/api/chat/events", get(identity_handler))
+ .route("/api/chat/send", post(identity_handler))
+ .route("/api/scopes", get(scopes_handler))
+ .layer(middleware::from_fn_with_state(state, auth_middleware))
+ }
+
+ fn two_user_tokens() -> HashMap {
+ let mut tokens = HashMap::new();
+ tokens.insert(
+ "tok-alice".to_string(),
+ UserIdentity {
+ user_id: "alice".to_string(),
+ workspace_read_scopes: vec!["shared".to_string()],
+ },
+ );
+ tokens.insert(
+ "tok-bob".to_string(),
+ UserIdentity {
+ user_id: "bob".to_string(),
+ workspace_read_scopes: vec!["shared".to_string(), "alice".to_string()],
+ },
+ );
+ tokens
+ }
+
+ #[tokio::test]
+ async fn test_multi_user_alice_token_resolves_to_alice() {
+ let app = multi_user_app(two_user_tokens());
+ let req = Request::builder()
+ .uri("/api/chat/events")
+ .header("Authorization", "Bearer tok-alice")
+ .body(Body::empty())
+ .unwrap();
+ let resp = app.oneshot(req).await.unwrap();
+ assert_eq!(resp.status(), StatusCode::OK);
+ let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
+ assert_eq!(body, "alice");
+ }
+
+ #[tokio::test]
+ async fn test_multi_user_bob_token_resolves_to_bob() {
+ let app = multi_user_app(two_user_tokens());
+ let req = Request::builder()
+ .uri("/api/chat/events")
+ .header("Authorization", "Bearer tok-bob")
+ .body(Body::empty())
+ .unwrap();
+ let resp = app.oneshot(req).await.unwrap();
+ assert_eq!(resp.status(), StatusCode::OK);
+ let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
+ assert_eq!(body, "bob");
+ }
+
+ #[tokio::test]
+ async fn test_multi_user_sequential_tokens_resolve_independently() {
+ // Send both alice and bob tokens sequentially and verify each gets
+ // the correct identity — guards against token map corruption.
+ let tokens = two_user_tokens();
+
+ let app1 = multi_user_app(tokens.clone());
+ let req = Request::builder()
+ .uri("/api/chat/events")
+ .header("Authorization", "Bearer tok-alice")
+ .body(Body::empty())
+ .unwrap();
+ let resp = app1.oneshot(req).await.unwrap();
+ let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
+ assert_eq!(body, "alice");
+
+ let app2 = multi_user_app(tokens);
+ let req = Request::builder()
+ .uri("/api/chat/events")
+ .header("Authorization", "Bearer tok-bob")
+ .body(Body::empty())
+ .unwrap();
+ let resp = app2.oneshot(req).await.unwrap();
+ let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
+ assert_eq!(body, "bob");
+ }
+
+ #[tokio::test]
+ async fn test_multi_user_unknown_token_rejected() {
+ let app = multi_user_app(two_user_tokens());
+ let req = Request::builder()
+ .uri("/api/chat/events")
+ .header("Authorization", "Bearer tok-charlie")
+ .body(Body::empty())
+ .unwrap();
+ let resp = app.oneshot(req).await.unwrap();
+ assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
+ }
+
+ #[tokio::test]
+ async fn test_multi_user_workspace_read_scopes_propagated() {
+ let app = multi_user_app(two_user_tokens());
+
+ // Alice has ["shared"]
+ let req = Request::builder()
+ .uri("/api/scopes")
+ .header("Authorization", "Bearer tok-alice")
+ .body(Body::empty())
+ .unwrap();
+ let resp = app.oneshot(req).await.unwrap();
+ let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
+ let scopes: Vec = serde_json::from_slice(&body).unwrap();
+ assert_eq!(scopes, vec!["shared"]);
+ }
+
+ #[tokio::test]
+ async fn test_multi_user_bob_has_two_scopes() {
+ let app = multi_user_app(two_user_tokens());
+
+ // Bob has ["shared", "alice"]
+ let req = Request::builder()
+ .uri("/api/scopes")
+ .header("Authorization", "Bearer tok-bob")
+ .body(Body::empty())
+ .unwrap();
+ let resp = app.oneshot(req).await.unwrap();
+ let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
+ let scopes: Vec = serde_json::from_slice(&body).unwrap();
+ assert_eq!(scopes, vec!["shared", "alice"]);
+ }
+
+ #[tokio::test]
+ async fn test_multi_user_query_param_resolves_correct_identity() {
+ let app = multi_user_app(two_user_tokens());
+ let req = Request::builder()
+ .uri("/api/chat/events?token=tok-bob")
+ .body(Body::empty())
+ .unwrap();
+ let resp = app.oneshot(req).await.unwrap();
+ assert_eq!(resp.status(), StatusCode::OK);
+ let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
+ assert_eq!(body, "bob");
+ }
+
+ #[tokio::test]
+ async fn test_multi_user_post_with_bearer_resolves_identity() {
+ let app = multi_user_app(two_user_tokens());
+ let req = Request::builder()
+ .method(Method::POST)
+ .uri("/api/chat/send")
+ .header("Authorization", "Bearer tok-alice")
+ .body(Body::empty())
+ .unwrap();
+ let resp = app.oneshot(req).await.unwrap();
+ assert_eq!(resp.status(), StatusCode::OK);
+ let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
+ assert_eq!(body, "alice");
+ }
+
+ #[tokio::test]
+ async fn test_multi_user_empty_scopes_for_single_user() {
+ // Single-user mode creates identity with empty workspace_read_scopes.
+ let state = MultiAuthState::single("tok-only".to_string(), "solo".to_string());
+ let app = Router::new()
+ .route("/api/scopes", get(scopes_handler))
+ .layer(middleware::from_fn_with_state(state, auth_middleware));
+ let req = Request::builder()
+ .uri("/api/scopes")
+ .header("Authorization", "Bearer tok-only")
+ .body(Body::empty())
+ .unwrap();
+ let resp = app.oneshot(req).await.unwrap();
+ let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
+ let scopes: Vec = serde_json::from_slice(&body).unwrap();
+ assert!(scopes.is_empty());
+ }
+
+ #[tokio::test]
+ async fn test_prefix_and_extension_tokens_rejected() {
+ // Verifies that prefix/suffix variants of valid tokens are rejected.
+ // Note: the constant-time property is enforced structurally by use of
+ // subtle::ConstantTimeEq and cannot be verified via outcome testing.
+ let state = MultiAuthState::single("long-secret-token".to_string(), "user".to_string());
+ assert!(state.authenticate("long-secret").is_none());
+ assert!(state.authenticate("long-secret-token-extra").is_none());
+ }
}
diff --git a/src/channels/web/handlers/chat.rs b/src/channels/web/handlers/chat.rs
index 5cb2b9ea..9753c015 100644
--- a/src/channels/web/handlers/chat.rs
+++ b/src/channels/web/handlers/chat.rs
@@ -12,22 +12,24 @@ use serde::Deserialize;
use uuid::Uuid;
use crate::channels::IncomingMessage;
+use crate::channels::web::auth::AuthenticatedUser;
use crate::channels::web::server::GatewayState;
use crate::channels::web::types::*;
use crate::channels::web::util::{build_turns_from_db_messages, truncate_preview};
pub async fn chat_send_handler(
State(state): State>,
+ AuthenticatedUser(identity): AuthenticatedUser,
Json(req): Json,
) -> Result<(StatusCode, Json), (StatusCode, String)> {
- if !state.chat_rate_limiter.check() {
+ if !state.chat_rate_limiter.check(&identity.user_id) {
return Err((
StatusCode::TOO_MANY_REQUESTS,
"Rate limit exceeded. Try again shortly.".to_string(),
));
}
- let mut msg = IncomingMessage::new("gateway", &state.user_id, &req.content);
+ let mut msg = IncomingMessage::new("gateway", &identity.user_id, &req.content);
if let Some(ref thread_id) = req.thread_id {
msg = msg.with_thread(thread_id);
@@ -74,6 +76,7 @@ pub async fn chat_send_handler(
pub async fn chat_approval_handler(
State(state): State>,
+ AuthenticatedUser(identity): AuthenticatedUser,
Json(req): Json,
) -> Result<(StatusCode, Json), (StatusCode, String)> {
let (approved, always) = match req.action.as_str() {
@@ -109,7 +112,7 @@ pub async fn chat_approval_handler(
)
})?;
- let mut msg = IncomingMessage::new("gateway", &state.user_id, content);
+ let mut msg = IncomingMessage::new("gateway", &identity.user_id, content);
if let Some(ref thread_id) = req.thread_id {
msg = msg.with_thread(thread_id);
@@ -150,6 +153,7 @@ pub async fn chat_approval_handler(
/// The token never touches the LLM, chat history, or SSE stream.
pub async fn chat_auth_token_handler(
State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
Json(req): Json,
) -> Result, (StatusCode, String)> {
let ext_mgr = state.extension_manager.as_ref().ok_or((
@@ -158,7 +162,7 @@ pub async fn chat_auth_token_handler(
))?;
match ext_mgr
- .configure_token(&req.extension_name, &req.token)
+ .configure_token(&req.extension_name, &req.token, &user.user_id)
.await
{
Ok(result) => {
@@ -169,20 +173,26 @@ pub async fn chat_auth_token_handler(
resp.instructions = result.verification.as_ref().map(|v| v.instructions.clone());
if result.verification.is_some() {
- state.sse.broadcast(SseEvent::AuthRequired {
- extension_name: req.extension_name.clone(),
- instructions: Some(result.message),
- auth_url: None,
- setup_url: None,
- });
+ state.sse.broadcast_for_user(
+ &user.user_id,
+ SseEvent::AuthRequired {
+ extension_name: req.extension_name.clone(),
+ instructions: Some(result.message),
+ auth_url: None,
+ setup_url: None,
+ },
+ );
} else {
- clear_auth_mode(&state).await;
+ clear_auth_mode(&state, &user.user_id).await;
- state.sse.broadcast(SseEvent::AuthCompleted {
- extension_name: req.extension_name.clone(),
- success: true,
- message: result.message,
- });
+ state.sse.broadcast_for_user(
+ &user.user_id,
+ SseEvent::AuthCompleted {
+ extension_name: req.extension_name.clone(),
+ success: true,
+ message: result.message,
+ },
+ );
}
Ok(Json(resp))
@@ -190,12 +200,15 @@ pub async fn chat_auth_token_handler(
Err(e) => {
let msg = e.to_string();
if matches!(e, crate::extensions::ExtensionError::ValidationFailed(_)) {
- state.sse.broadcast(SseEvent::AuthRequired {
- extension_name: req.extension_name.clone(),
- instructions: Some(msg.clone()),
- auth_url: None,
- setup_url: None,
- });
+ state.sse.broadcast_for_user(
+ &user.user_id,
+ SseEvent::AuthRequired {
+ extension_name: req.extension_name.clone(),
+ instructions: Some(msg.clone()),
+ auth_url: None,
+ setup_url: None,
+ },
+ );
}
Ok(Json(ActionResponse::fail(msg)))
}
@@ -205,16 +218,17 @@ pub async fn chat_auth_token_handler(
/// Cancel an in-progress auth flow.
pub async fn chat_auth_cancel_handler(
State(state): State>,
+ AuthenticatedUser(identity): AuthenticatedUser,
Json(_req): Json,
) -> Result, (StatusCode, String)> {
- clear_auth_mode(&state).await;
+ clear_auth_mode(&state, &identity.user_id).await;
Ok(Json(ActionResponse::ok("Auth cancelled")))
}
/// Clear pending auth mode on the active thread.
-pub async fn clear_auth_mode(state: &GatewayState) {
+pub async fn clear_auth_mode(state: &GatewayState, user_id: &str) {
if let Some(ref sm) = state.session_manager {
- let session = sm.get_or_create_session(&state.user_id).await;
+ let session = sm.get_or_create_session(user_id).await;
let mut sess = session.lock().await;
if let Some(thread_id) = sess.active_thread
&& let Some(thread) = sess.threads.get_mut(&thread_id)
@@ -226,8 +240,9 @@ pub async fn clear_auth_mode(state: &GatewayState) {
pub async fn chat_events_handler(
State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
) -> Result {
- state.sse.subscribe().ok_or((
+ state.sse.subscribe(Some(user.user_id)).ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Too many connections".to_string(),
))
@@ -237,6 +252,7 @@ pub async fn chat_ws_handler(
headers: axum::http::HeaderMap,
ws: WebSocketUpgrade,
State(state): State>,
+ AuthenticatedUser(identity): AuthenticatedUser,
) -> Result {
// Validate Origin header to prevent cross-site WebSocket hijacking.
let origin = headers
@@ -262,7 +278,9 @@ pub async fn chat_ws_handler(
"WebSocket origin not allowed".to_string(),
));
}
- Ok(ws.on_upgrade(move |socket| crate::channels::web::ws::handle_ws_connection(socket, state)))
+ Ok(ws.on_upgrade(move |socket| {
+ crate::channels::web::ws::handle_ws_connection(socket, state, identity)
+ }))
}
#[derive(Deserialize)]
@@ -274,6 +292,7 @@ pub struct HistoryQuery {
pub async fn chat_history_handler(
State(state): State>,
+ AuthenticatedUser(identity): AuthenticatedUser,
Query(query): Query,
) -> Result, (StatusCode, String)> {
let session_manager = state.session_manager.as_ref().ok_or((
@@ -281,7 +300,9 @@ pub async fn chat_history_handler(
"Session manager not available".to_string(),
))?;
- let session = session_manager.get_or_create_session(&state.user_id).await;
+ let session = session_manager
+ .get_or_create_session(&identity.user_id)
+ .await;
let limit = query.limit.unwrap_or(50);
let before_cursor = query
@@ -314,7 +335,7 @@ pub async fn chat_history_handler(
&& let Some(ref store) = state.store
{
let owned = store
- .conversation_belongs_to_user(thread_id, &state.user_id)
+ .conversation_belongs_to_user(thread_id, &identity.user_id)
.await
.unwrap_or(false);
if !owned {
@@ -434,24 +455,27 @@ pub async fn chat_history_handler(
pub async fn chat_threads_handler(
State(state): State>,
+ AuthenticatedUser(identity): AuthenticatedUser,
) -> Result, (StatusCode, String)> {
let session_manager = state.session_manager.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Session manager not available".to_string(),
))?;
- let session = session_manager.get_or_create_session(&state.user_id).await;
+ let session = session_manager
+ .get_or_create_session(&identity.user_id)
+ .await;
// Try DB first for persistent thread list
if let Some(ref store) = state.store {
// Auto-create assistant thread if it doesn't exist
let assistant_id = store
- .get_or_create_assistant_conversation(&state.user_id, "gateway")
+ .get_or_create_assistant_conversation(&identity.user_id, "gateway")
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if let Ok(summaries) = store
- .list_conversations_all_channels(&state.user_id, 50)
+ .list_conversations_all_channels(&identity.user_id, 50)
.await
{
let mut assistant_thread = None;
@@ -534,13 +558,16 @@ pub async fn chat_threads_handler(
pub async fn chat_new_thread_handler(
State(state): State>,
+ AuthenticatedUser(identity): AuthenticatedUser,
) -> Result, (StatusCode, String)> {
let session_manager = state.session_manager.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Session manager not available".to_string(),
))?;
- let session = session_manager.get_or_create_session(&state.user_id).await;
+ let session = session_manager
+ .get_or_create_session(&identity.user_id)
+ .await;
let (thread_id, info) = {
let mut sess = session.lock().await;
let thread = sess.create_thread();
@@ -562,12 +589,12 @@ pub async fn chat_new_thread_handler(
// so that the subsequent loadThreads() call from the frontend sees it.
if let Some(ref store) = state.store {
match store
- .ensure_conversation(thread_id, "gateway", &state.user_id, None)
+ .ensure_conversation(thread_id, "gateway", &identity.user_id, None)
.await
{
Ok(true) => {}
Ok(false) => tracing::warn!(
- user = %state.user_id,
+ user = %identity.user_id,
thread_id = %thread_id,
"Skipped persisting new thread due to ownership/channel conflict"
),
diff --git a/src/channels/web/handlers/extensions.rs b/src/channels/web/handlers/extensions.rs
index 855fba3e..d705591e 100644
--- a/src/channels/web/handlers/extensions.rs
+++ b/src/channels/web/handlers/extensions.rs
@@ -8,11 +8,13 @@ use axum::{
http::StatusCode,
};
+use crate::channels::web::auth::AuthenticatedUser;
use crate::channels::web::server::GatewayState;
use crate::channels::web::types::*;
pub async fn extensions_list_handler(
State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
) -> Result, (StatusCode, String)> {
let ext_mgr = state.extension_manager.as_ref().ok_or((
StatusCode::NOT_IMPLEMENTED,
@@ -20,7 +22,7 @@ pub async fn extensions_list_handler(
))?;
let installed = ext_mgr
- .list(None, false)
+ .list(None, false, &user.user_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
@@ -80,6 +82,7 @@ pub async fn extensions_list_handler(
pub async fn extensions_tools_handler(
State(state): State>,
+ AuthenticatedUser(_user): AuthenticatedUser,
) -> Result, (StatusCode, String)> {
let registry = state.tool_registry.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
@@ -100,6 +103,7 @@ pub async fn extensions_tools_handler(
pub async fn extensions_install_handler(
State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
Json(req): Json,
) -> Result, (StatusCode, String)> {
let ext_mgr = state.extension_manager.as_ref().ok_or((
@@ -116,7 +120,7 @@ pub async fn extensions_install_handler(
});
match ext_mgr
- .install(&req.name, req.url.as_deref(), kind_hint)
+ .install(&req.name, req.url.as_deref(), kind_hint, &user.user_id)
.await
{
Ok(result) => Ok(Json(ActionResponse::ok(result.message))),
@@ -126,6 +130,7 @@ pub async fn extensions_install_handler(
pub async fn extensions_remove_handler(
State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
Path(name): Path,
) -> Result, (StatusCode, String)> {
let ext_mgr = state.extension_manager.as_ref().ok_or((
@@ -133,7 +138,7 @@ pub async fn extensions_remove_handler(
"Extension manager not available (secrets store required)".to_string(),
))?;
- match ext_mgr.remove(&name).await {
+ match ext_mgr.remove(&name, &user.user_id).await {
Ok(message) => Ok(Json(ActionResponse::ok(message))),
Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))),
}
diff --git a/src/channels/web/handlers/jobs.rs b/src/channels/web/handlers/jobs.rs
index 5a94e055..35adeec6 100644
--- a/src/channels/web/handlers/jobs.rs
+++ b/src/channels/web/handlers/jobs.rs
@@ -11,11 +11,13 @@ use axum::{
use serde::Deserialize;
use uuid::Uuid;
+use crate::channels::web::auth::AuthenticatedUser;
use crate::channels::web::server::GatewayState;
use crate::channels::web::types::*;
pub async fn jobs_list_handler(
State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
) -> Result, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
@@ -25,8 +27,8 @@ pub async fn jobs_list_handler(
let mut jobs: Vec = Vec::new();
let mut seen_ids: HashSet = HashSet::new();
- // Fetch sandbox jobs from database.
- match store.list_sandbox_jobs().await {
+ // Fetch sandbox jobs scoped to this user.
+ match store.list_sandbox_jobs_for_user(&user.user_id).await {
Ok(sandbox_jobs) => {
for j in &sandbox_jobs {
let ui_state = match j.status.as_str() {
@@ -50,8 +52,8 @@ pub async fn jobs_list_handler(
}
}
- // Fetch agent (non-sandbox) jobs from database, deduplicating by ID.
- match store.list_agent_jobs().await {
+ // Fetch agent (non-sandbox) jobs scoped to this user, deduplicating by ID.
+ match store.list_agent_jobs_for_user(&user.user_id).await {
Ok(agent_jobs) => {
for j in &agent_jobs {
if seen_ids.contains(&j.id) {
@@ -80,6 +82,7 @@ pub async fn jobs_list_handler(
pub async fn jobs_summary_handler(
State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
) -> Result, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
@@ -93,8 +96,8 @@ pub async fn jobs_summary_handler(
let mut failed = 0;
let mut stuck = 0;
- // Sandbox job counts.
- match store.sandbox_job_summary().await {
+ // Sandbox job counts scoped to this user.
+ match store.sandbox_job_summary_for_user(&user.user_id).await {
Ok(s) => {
total += s.total;
pending += s.creating;
@@ -107,8 +110,8 @@ pub async fn jobs_summary_handler(
}
}
- // Agent job counts.
- match store.agent_job_summary().await {
+ // Agent job counts scoped to this user.
+ match store.agent_job_summary_for_user(&user.user_id).await {
Ok(s) => {
total += s.total;
pending += s.pending;
@@ -134,6 +137,7 @@ pub async fn jobs_summary_handler(
pub async fn jobs_detail_handler(
State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
Path(id): Path,
) -> Result, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
@@ -145,169 +149,213 @@ pub async fn jobs_detail_handler(
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
// Try sandbox job from DB first.
- if let Ok(Some(job)) = store.get_sandbox_job(job_id).await {
- let browse_id = std::path::Path::new(&job.project_dir)
- .file_name()
- .map(|n| n.to_string_lossy().to_string())
- .unwrap_or_else(|| job.id.to_string());
+ match store.get_sandbox_job(job_id).await {
+ Ok(Some(job)) => {
+ if job.user_id != user.user_id {
+ return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
+ }
+ let browse_id = std::path::Path::new(&job.project_dir)
+ .file_name()
+ .map(|n| n.to_string_lossy().to_string())
+ .unwrap_or_else(|| job.id.to_string());
- let ui_state = match job.status.as_str() {
- "creating" => "pending",
- "running" => "in_progress",
- s => s,
- };
+ let ui_state = match job.status.as_str() {
+ "creating" => "pending",
+ "running" => "in_progress",
+ s => s,
+ };
- let elapsed_secs = job.started_at.map(|start| {
- let end = job.completed_at.unwrap_or_else(chrono::Utc::now);
- (end - start).num_seconds().max(0) as u64
- });
-
- // Synthesize transitions from timestamps.
- let mut transitions = Vec::new();
- if let Some(started) = job.started_at {
- transitions.push(TransitionInfo {
- from: "creating".to_string(),
- to: "running".to_string(),
- timestamp: started.to_rfc3339(),
- reason: None,
+ let elapsed_secs = job.started_at.map(|start| {
+ let end = job.completed_at.unwrap_or_else(chrono::Utc::now);
+ (end - start).num_seconds().max(0) as u64
});
- }
- if let Some(completed) = job.completed_at {
- transitions.push(TransitionInfo {
- from: "running".to_string(),
- to: job.status.clone(),
- timestamp: completed.to_rfc3339(),
- reason: job.failure_reason.clone(),
- });
- }
- let mode = store.get_sandbox_job_mode(job.id).await.ok().flatten();
- let is_claude_code = mode.as_deref() == Some("claude_code");
+ // Synthesize transitions from timestamps.
+ let mut transitions = Vec::new();
+ if let Some(started) = job.started_at {
+ transitions.push(TransitionInfo {
+ from: "creating".to_string(),
+ to: "running".to_string(),
+ timestamp: started.to_rfc3339(),
+ reason: None,
+ });
+ }
+ if let Some(completed) = job.completed_at {
+ transitions.push(TransitionInfo {
+ from: "running".to_string(),
+ to: job.status.clone(),
+ timestamp: completed.to_rfc3339(),
+ reason: job.failure_reason.clone(),
+ });
+ }
- return Ok(Json(JobDetailResponse {
- id: job.id,
- title: job.task.clone(),
- description: String::new(),
- state: ui_state.to_string(),
- user_id: job.user_id.clone(),
- created_at: job.created_at.to_rfc3339(),
- started_at: job.started_at.map(|dt| dt.to_rfc3339()),
- completed_at: job.completed_at.map(|dt| dt.to_rfc3339()),
- elapsed_secs,
- project_dir: Some(job.project_dir.clone()),
- browse_url: Some(format!("/projects/{}/", browse_id)),
- job_mode: mode.filter(|m| m != "worker"),
- transitions,
- can_restart: state.job_manager.is_some(),
- can_prompt: is_claude_code && state.prompt_queue.is_some(),
- job_kind: Some("sandbox".to_string()),
- }));
+ let mode = store.get_sandbox_job_mode(job.id).await.ok().flatten();
+ let is_claude_code = mode.as_deref() == Some("claude_code");
+
+ return Ok(Json(JobDetailResponse {
+ id: job.id,
+ title: job.task.clone(),
+ description: String::new(),
+ state: ui_state.to_string(),
+ user_id: job.user_id.clone(),
+ created_at: job.created_at.to_rfc3339(),
+ started_at: job.started_at.map(|dt| dt.to_rfc3339()),
+ completed_at: job.completed_at.map(|dt| dt.to_rfc3339()),
+ elapsed_secs,
+ project_dir: Some(job.project_dir.clone()),
+ browse_url: Some(format!("/projects/{}/", browse_id)),
+ job_mode: mode.filter(|m| m != "worker"),
+ transitions,
+ can_restart: state.job_manager.is_some(),
+ can_prompt: is_claude_code && state.prompt_queue.is_some(),
+ job_kind: Some("sandbox".to_string()),
+ }));
+ }
+ Ok(None) => {}
+ Err(e) => {
+ return Err((
+ StatusCode::INTERNAL_SERVER_ERROR,
+ format!("Database error: {}", e),
+ ));
+ }
}
// Fall back to agent job from DB.
- if let Ok(Some(ctx)) = store.get_job(job_id).await {
- let elapsed_secs = ctx.started_at.map(|start| {
- let end = ctx.completed_at.unwrap_or_else(chrono::Utc::now);
- (end - start).num_seconds().max(0) as u64
- });
+ match store.get_job(job_id).await {
+ Ok(Some(ctx)) => {
+ if ctx.user_id != user.user_id {
+ return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
+ }
+ let elapsed_secs = ctx.started_at.map(|start| {
+ let end = ctx.completed_at.unwrap_or_else(chrono::Utc::now);
+ (end - start).num_seconds().max(0) as u64
+ });
- // Only show prompt bar for jobs that have a running worker (Pending/InProgress).
- // Stuck jobs have no active worker loop, so messages would be silently dropped.
- let is_promptable = matches!(
- ctx.state,
- crate::context::JobState::Pending | crate::context::JobState::InProgress
- );
- return Ok(Json(JobDetailResponse {
- id: ctx.job_id,
- title: ctx.title.clone(),
- description: ctx.description.clone(),
- state: ctx.state.to_string(),
- user_id: ctx.user_id.clone(),
- created_at: ctx.created_at.to_rfc3339(),
- started_at: ctx.started_at.map(|dt| dt.to_rfc3339()),
- completed_at: ctx.completed_at.map(|dt| dt.to_rfc3339()),
- elapsed_secs,
- project_dir: None,
- browse_url: None,
- job_mode: None,
- transitions: Vec::new(),
- can_restart: state.scheduler.is_some(),
- can_prompt: is_promptable && state.scheduler.is_some(),
- job_kind: Some("agent".to_string()),
- }));
+ // Only show prompt bar for jobs that have a running worker (Pending/InProgress).
+ // Stuck jobs have no active worker loop, so messages would be silently dropped.
+ let is_promptable = matches!(
+ ctx.state,
+ crate::context::JobState::Pending | crate::context::JobState::InProgress
+ );
+ Ok(Json(JobDetailResponse {
+ id: ctx.job_id,
+ title: ctx.title.clone(),
+ description: ctx.description.clone(),
+ state: ctx.state.to_string(),
+ user_id: ctx.user_id.clone(),
+ created_at: ctx.created_at.to_rfc3339(),
+ started_at: ctx.started_at.map(|dt| dt.to_rfc3339()),
+ completed_at: ctx.completed_at.map(|dt| dt.to_rfc3339()),
+ elapsed_secs,
+ project_dir: None,
+ browse_url: None,
+ job_mode: None,
+ transitions: Vec::new(),
+ can_restart: state.scheduler.is_some(),
+ can_prompt: is_promptable && state.scheduler.is_some(),
+ job_kind: Some("agent".to_string()),
+ }))
+ }
+ Ok(None) => Err((StatusCode::NOT_FOUND, "Job not found".to_string())),
+ Err(e) => Err((
+ StatusCode::INTERNAL_SERVER_ERROR,
+ format!("Database error: {}", e),
+ )),
}
-
- Err((StatusCode::NOT_FOUND, "Job not found".to_string()))
}
pub async fn jobs_cancel_handler(
State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
Path(id): Path,
) -> Result, (StatusCode, String)> {
let job_id = Uuid::parse_str(&id)
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
// Try sandbox job cancellation.
- if let Some(ref store) = state.store
- && let Ok(Some(job)) = store.get_sandbox_job(job_id).await
- {
- if job.status == "running" || job.status == "creating" {
- // Stop the container if we have a job manager.
- if let Some(ref jm) = state.job_manager
- && let Err(e) = jm.stop_job(job_id).await
- {
- tracing::warn!(job_id = %job_id, error = %e, "Failed to stop container during cancellation");
+ if let Some(ref store) = state.store {
+ match store.get_sandbox_job(job_id).await {
+ Ok(Some(job)) => {
+ if job.user_id != user.user_id {
+ return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
+ }
+ if job.status == "running" || job.status == "creating" {
+ if let Some(ref jm) = state.job_manager
+ && let Err(e) = jm.stop_job(job_id).await
+ {
+ tracing::warn!(job_id = %job_id, error = %e, "Failed to stop container during cancellation");
+ }
+ store
+ .update_sandbox_job_status(
+ job_id,
+ "failed",
+ Some(false),
+ Some("Cancelled by user"),
+ None,
+ Some(chrono::Utc::now()),
+ )
+ .await
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+ }
+ return Ok(Json(serde_json::json!({
+ "status": "cancelled",
+ "job_id": job_id,
+ })));
+ }
+ Ok(None) => {}
+ Err(e) => {
+ return Err((
+ StatusCode::INTERNAL_SERVER_ERROR,
+ format!("Database error: {}", e),
+ ));
}
- store
- .update_sandbox_job_status(
- job_id,
- "failed",
- Some(false),
- Some("Cancelled by user"),
- None,
- Some(chrono::Utc::now()),
- )
- .await
- .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
}
- return Ok(Json(serde_json::json!({
- "status": "cancelled",
- "job_id": job_id,
- })));
}
// Fall back to agent job cancellation: stop the worker via the scheduler
// (which updates the in-memory ContextManager AND aborts the task handle),
// then persist the status to the DB as a fallback.
- if let Some(ref store) = state.store
- && let Ok(Some(job)) = store.get_job(job_id).await
- {
- if job.state.is_active() {
- // Try to stop via scheduler (aborts the worker task + updates
- // in-memory ContextManager). This is best-effort — the job may
- // not be in the scheduler map if it already finished.
- if let Some(ref slot) = state.scheduler
- && let Some(ref scheduler) = *slot.read().await
- {
- let _ = scheduler.stop(job_id).await;
- }
+ if let Some(ref store) = state.store {
+ match store.get_job(job_id).await {
+ Ok(Some(job)) => {
+ if job.user_id != user.user_id {
+ return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
+ }
+ if job.state.is_active() {
+ // Try to stop via scheduler (aborts the worker task + updates
+ // in-memory ContextManager). This is best-effort — the job may
+ // not be in the scheduler map if it already finished.
+ if let Some(ref slot) = state.scheduler
+ && let Some(ref scheduler) = *slot.read().await
+ {
+ let _ = scheduler.stop(job_id).await;
+ }
- // Always persist cancellation to the DB so the state is
- // consistent even if the scheduler wasn't available or the
- // job wasn't in its in-memory map.
- store
- .update_job_status(
- job_id,
- crate::context::JobState::Cancelled,
- Some("Cancelled by user"),
- )
- .await
- .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+ // Always persist cancellation to the DB so the state is
+ // consistent even if the scheduler wasn't available or the
+ // job wasn't in its in-memory map.
+ store
+ .update_job_status(
+ job_id,
+ crate::context::JobState::Cancelled,
+ Some("Cancelled by user"),
+ )
+ .await
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+ }
+ return Ok(Json(serde_json::json!({
+ "status": "cancelled",
+ "job_id": job_id,
+ })));
+ }
+ Ok(None) => {}
+ Err(e) => {
+ return Err((
+ StatusCode::INTERNAL_SERVER_ERROR,
+ format!("Database error: {}", e),
+ ));
+ }
}
- return Ok(Json(serde_json::json!({
- "status": "cancelled",
- "job_id": job_id,
- })));
}
Err((StatusCode::NOT_FOUND, "Job not found".to_string()))
@@ -315,6 +363,7 @@ pub async fn jobs_cancel_handler(
pub async fn jobs_restart_handler(
State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
Path(id): Path,
) -> Result, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
@@ -326,146 +375,166 @@ pub async fn jobs_restart_handler(
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
// Try sandbox job restart first.
- if let Ok(Some(old_job)) = store.get_sandbox_job(old_job_id).await {
- if old_job.status != "interrupted" && old_job.status != "failed" {
+ match store.get_sandbox_job(old_job_id).await {
+ Ok(Some(old_job)) => {
+ if old_job.user_id != user.user_id {
+ return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
+ }
+ if old_job.status != "interrupted" && old_job.status != "failed" {
+ return Err((
+ StatusCode::CONFLICT,
+ format!("Cannot restart job in state '{}'", old_job.status),
+ ));
+ }
+
+ let jm = state.job_manager.as_ref().ok_or((
+ StatusCode::SERVICE_UNAVAILABLE,
+ "Sandbox not enabled".to_string(),
+ ))?;
+
+ // Enrich the task with failure context.
+ let task = if let Some(ref reason) = old_job.failure_reason {
+ format!(
+ "Previous attempt failed: {}. Retry: {}",
+ reason, old_job.task
+ )
+ } else {
+ old_job.task.clone()
+ };
+
+ let new_job_id = Uuid::new_v4();
+ let now = chrono::Utc::now();
+
+ let record = crate::history::SandboxJobRecord {
+ id: new_job_id,
+ task: task.clone(),
+ status: "creating".to_string(),
+ user_id: old_job.user_id.clone(),
+ project_dir: old_job.project_dir.clone(),
+ success: None,
+ failure_reason: None,
+ created_at: now,
+ started_at: None,
+ completed_at: None,
+ credential_grants_json: old_job.credential_grants_json.clone(),
+ };
+ store
+ .save_sandbox_job(&record)
+ .await
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+
+ let mode = match store.get_sandbox_job_mode(old_job_id).await {
+ Ok(Some(m)) if m == "claude_code" => {
+ crate::orchestrator::job_manager::JobMode::ClaudeCode
+ }
+ _ => crate::orchestrator::job_manager::JobMode::Worker,
+ };
+
+ let credential_grants: Vec =
+ serde_json::from_str(&old_job.credential_grants_json).unwrap_or_else(|e| {
+ tracing::warn!(
+ job_id = %old_job.id,
+ "Failed to deserialize credential grants from stored job: {}. \
+ Restarted job will have no credentials.",
+ e
+ );
+ vec![]
+ });
+
+ let project_dir = std::path::PathBuf::from(&old_job.project_dir);
+ let _token = jm
+ .create_job(
+ new_job_id,
+ &task,
+ Some(project_dir),
+ mode,
+ credential_grants,
+ )
+ .await
+ .map_err(|e| {
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ format!("Failed to create container: {}", e),
+ )
+ })?;
+
+ store
+ .update_sandbox_job_status(new_job_id, "running", None, None, Some(now), None)
+ .await
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+
+ return Ok(Json(serde_json::json!({
+ "status": "restarted",
+ "old_job_id": old_job_id,
+ "new_job_id": new_job_id,
+ })));
+ }
+ Ok(None) => {}
+ Err(e) => {
return Err((
- StatusCode::CONFLICT,
- format!("Cannot restart job in state '{}'", old_job.status),
+ StatusCode::INTERNAL_SERVER_ERROR,
+ format!("Database error: {}", e),
));
}
-
- let jm = state.job_manager.as_ref().ok_or((
- StatusCode::SERVICE_UNAVAILABLE,
- "Sandbox not enabled".to_string(),
- ))?;
-
- // Enrich the task with failure context.
- let task = if let Some(ref reason) = old_job.failure_reason {
- format!(
- "Previous attempt failed: {}. Retry: {}",
- reason, old_job.task
- )
- } else {
- old_job.task.clone()
- };
-
- let new_job_id = Uuid::new_v4();
- let now = chrono::Utc::now();
-
- let record = crate::history::SandboxJobRecord {
- id: new_job_id,
- task: task.clone(),
- status: "creating".to_string(),
- user_id: old_job.user_id.clone(),
- project_dir: old_job.project_dir.clone(),
- success: None,
- failure_reason: None,
- created_at: now,
- started_at: None,
- completed_at: None,
- credential_grants_json: old_job.credential_grants_json.clone(),
- };
- store
- .save_sandbox_job(&record)
- .await
- .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
-
- let mode = match store.get_sandbox_job_mode(old_job_id).await {
- Ok(Some(m)) if m == "claude_code" => {
- crate::orchestrator::job_manager::JobMode::ClaudeCode
- }
- _ => crate::orchestrator::job_manager::JobMode::Worker,
- };
-
- let credential_grants: Vec =
- serde_json::from_str(&old_job.credential_grants_json).unwrap_or_else(|e| {
- tracing::warn!(
- job_id = %old_job.id,
- "Failed to deserialize credential grants from stored job: {}. \
- Restarted job will have no credentials.",
- e
- );
- vec![]
- });
-
- let project_dir = std::path::PathBuf::from(&old_job.project_dir);
- let _token = jm
- .create_job(
- new_job_id,
- &task,
- Some(project_dir),
- mode,
- credential_grants,
- )
- .await
- .map_err(|e| {
- (
- StatusCode::INTERNAL_SERVER_ERROR,
- format!("Failed to create container: {}", e),
- )
- })?;
-
- store
- .update_sandbox_job_status(new_job_id, "running", None, None, Some(now), None)
- .await
- .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
-
- return Ok(Json(serde_json::json!({
- "status": "restarted",
- "old_job_id": old_job_id,
- "new_job_id": new_job_id,
- })));
}
// Try agent job restart: dispatch a new job via the scheduler.
- if let Ok(Some(old_job)) = store.get_job(old_job_id).await {
- if old_job.state.is_active() {
- return Err((
- StatusCode::CONFLICT,
- format!("Cannot restart job in state '{}'", old_job.state),
- ));
+ match store.get_job(old_job_id).await {
+ Ok(Some(old_job)) => {
+ if old_job.user_id != user.user_id {
+ return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
+ }
+ if old_job.state.is_active() {
+ return Err((
+ StatusCode::CONFLICT,
+ format!("Cannot restart job in state '{}'", old_job.state),
+ ));
+ }
+
+ let slot = state.scheduler.as_ref().ok_or((
+ StatusCode::SERVICE_UNAVAILABLE,
+ "Scheduler not available".to_string(),
+ ))?;
+ let scheduler_guard = slot.read().await;
+ let scheduler = scheduler_guard.as_ref().ok_or((
+ StatusCode::SERVICE_UNAVAILABLE,
+ "Agent not started yet".to_string(),
+ ))?;
+
+ // Look up failure reason (O(1) point lookup).
+ let failure_reason = store
+ .get_agent_job_failure_reason(old_job_id)
+ .await
+ .ok()
+ .flatten()
+ .unwrap_or_default();
+
+ let title = if !failure_reason.is_empty() {
+ format!(
+ "Previous attempt failed: {}. Retry: {}",
+ failure_reason, old_job.title
+ )
+ } else {
+ old_job.title.clone()
+ };
+
+ let new_job_id = scheduler
+ .dispatch_job(&old_job.user_id, &title, &old_job.description, None)
+ .await
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+
+ Ok(Json(serde_json::json!({
+ "status": "restarted",
+ "old_job_id": old_job_id,
+ "new_job_id": new_job_id,
+ })))
}
-
- let slot = state.scheduler.as_ref().ok_or((
- StatusCode::SERVICE_UNAVAILABLE,
- "Scheduler not available".to_string(),
- ))?;
- let scheduler_guard = slot.read().await;
- let scheduler = scheduler_guard.as_ref().ok_or((
- StatusCode::SERVICE_UNAVAILABLE,
- "Agent not started yet".to_string(),
- ))?;
-
- // Look up failure reason (O(1) point lookup).
- let failure_reason = store
- .get_agent_job_failure_reason(old_job_id)
- .await
- .ok()
- .flatten()
- .unwrap_or_default();
-
- let title = if !failure_reason.is_empty() {
- format!(
- "Previous attempt failed: {}. Retry: {}",
- failure_reason, old_job.title
- )
- } else {
- old_job.title.clone()
- };
-
- let new_job_id = scheduler
- .dispatch_job(&old_job.user_id, &title, &old_job.description, None)
- .await
- .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
-
- return Ok(Json(serde_json::json!({
- "status": "restarted",
- "old_job_id": old_job_id,
- "new_job_id": new_job_id,
- })));
+ Ok(None) => Err((StatusCode::NOT_FOUND, "Job not found".to_string())),
+ Err(e) => Err((
+ StatusCode::INTERNAL_SERVER_ERROR,
+ format!("Database error: {}", e),
+ )),
}
-
- Err((StatusCode::NOT_FOUND, "Job not found".to_string()))
}
/// Submit a follow-up prompt to a running job.
@@ -476,6 +545,7 @@ pub async fn jobs_restart_handler(
/// - Worker-mode sandbox jobs → not supported (no mechanism to inject)
pub async fn jobs_prompt_handler(
State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
Path(id): Path,
Json(body): Json,
) -> Result, (StatusCode, String)> {
@@ -494,10 +564,15 @@ pub async fn jobs_prompt_handler(
let done = body.get("done").and_then(|v| v.as_bool()).unwrap_or(false);
- // Try sandbox job path: check if we have a sandbox record for this ID.
+ // Try sandbox job path first: verify ownership, then route to Claude Code or reject.
if let Some(ref s) = state.store
- && let Ok(Some(_)) = s.get_sandbox_job(job_id).await
+ && let Ok(Some(sandbox_job)) = s.get_sandbox_job(job_id).await
{
+ // Verify ownership.
+ if sandbox_job.user_id != user.user_id {
+ return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
+ }
+
// It's a sandbox job. Check if Claude Code mode.
let mode = s.get_sandbox_job_mode(job_id).await.ok().flatten();
if mode.as_deref() == Some("claude_code") {
@@ -522,7 +597,26 @@ pub async fn jobs_prompt_handler(
}
}
- // Try agent job path: send via scheduler.
+ // Try agent job path: verify ownership, then send via scheduler.
+ if let Some(ref store) = state.store {
+ match store.get_job(job_id).await {
+ Ok(Some(agent_job)) => {
+ if agent_job.user_id != user.user_id {
+ return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
+ }
+ }
+ Ok(None) => {
+ return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
+ }
+ Err(e) => {
+ return Err((
+ StatusCode::INTERNAL_SERVER_ERROR,
+ format!("Database error: {}", e),
+ ));
+ }
+ }
+ }
+
let slot = state.scheduler.as_ref().ok_or((
StatusCode::NOT_IMPLEMENTED,
"Agent job prompts require the scheduler to be configured".to_string(),
@@ -550,6 +644,7 @@ pub async fn jobs_prompt_handler(
/// Load persisted job events for a job (for history replay on page open).
pub async fn jobs_events_handler(
State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
Path(id): Path,
) -> Result, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
@@ -561,6 +656,24 @@ pub async fn jobs_events_handler(
.parse()
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
+ // Verify ownership before returning events.
+ match store.get_sandbox_job(job_id).await {
+ Ok(Some(job)) => {
+ if job.user_id != user.user_id {
+ return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
+ }
+ }
+ Ok(None) => {
+ return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
+ }
+ Err(e) => {
+ return Err((
+ StatusCode::INTERNAL_SERVER_ERROR,
+ format!("Database error: {}", e),
+ ));
+ }
+ }
+
let events = store
.list_job_events(job_id, None)
.await
@@ -593,6 +706,7 @@ pub struct FilePathQuery {
pub async fn job_files_list_handler(
State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
Path(id): Path,
Query(query): Query,
) -> Result, (StatusCode, String)> {
@@ -610,6 +724,10 @@ pub async fn job_files_list_handler(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?;
+ if job.user_id != user.user_id {
+ return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
+ }
+
let base = std::path::PathBuf::from(&job.project_dir);
let rel_path = query.path.as_deref().unwrap_or("");
let target = base.join(rel_path);
@@ -656,6 +774,7 @@ pub async fn job_files_list_handler(
pub async fn job_files_read_handler(
State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
Path(id): Path,
Query(query): Query,
) -> Result, (StatusCode, String)> {
@@ -673,6 +792,10 @@ pub async fn job_files_read_handler(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?;
+ if job.user_id != user.user_id {
+ return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
+ }
+
let path = query.path.as_deref().ok_or((
StatusCode::BAD_REQUEST,
"path parameter required".to_string(),
diff --git a/src/channels/web/handlers/memory.rs b/src/channels/web/handlers/memory.rs
index fc0e1fe4..ff0fac16 100644
--- a/src/channels/web/handlers/memory.rs
+++ b/src/channels/web/handlers/memory.rs
@@ -9,8 +9,27 @@ use axum::{
};
use serde::Deserialize;
+use crate::channels::web::auth::{AuthenticatedUser, UserIdentity};
use crate::channels::web::server::GatewayState;
use crate::channels::web::types::*;
+use crate::workspace::Workspace;
+
+/// Resolve the workspace for the authenticated user.
+///
+/// Prefers `workspace_pool` (multi-user mode) when available, falling back
+/// to the single-user `state.workspace`.
+pub(crate) async fn resolve_workspace(
+ state: &GatewayState,
+ user: &UserIdentity,
+) -> Result, (StatusCode, String)> {
+ if let Some(ref pool) = state.workspace_pool {
+ return Ok(pool.get_or_create(user).await);
+ }
+ state.workspace.as_ref().cloned().ok_or((
+ StatusCode::SERVICE_UNAVAILABLE,
+ "Workspace not available".to_string(),
+ ))
+}
#[derive(Deserialize)]
pub struct TreeQuery {
@@ -20,12 +39,10 @@ pub struct TreeQuery {
pub async fn memory_tree_handler(
State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
Query(_query): Query,
) -> Result, (StatusCode, String)> {
- let workspace = state.workspace.as_ref().ok_or((
- StatusCode::SERVICE_UNAVAILABLE,
- "Workspace not available".to_string(),
- ))?;
+ let workspace = resolve_workspace(&state, &user).await?;
// Build tree from list_all (flat list of all paths)
let all_paths = workspace
@@ -68,12 +85,10 @@ pub struct ListQuery {
pub async fn memory_list_handler(
State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
Query(query): Query,
) -> Result, (StatusCode, String)> {
- let workspace = state.workspace.as_ref().ok_or((
- StatusCode::SERVICE_UNAVAILABLE,
- "Workspace not available".to_string(),
- ))?;
+ let workspace = resolve_workspace(&state, &user).await?;
let path = query.path.as_deref().unwrap_or("");
let entries = workspace
@@ -104,12 +119,10 @@ pub struct ReadQuery {
pub async fn memory_read_handler(
State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
Query(query): Query,
) -> Result, (StatusCode, String)> {
- let workspace = state.workspace.as_ref().ok_or((
- StatusCode::SERVICE_UNAVAILABLE,
- "Workspace not available".to_string(),
- ))?;
+ let workspace = resolve_workspace(&state, &user).await?;
let doc = workspace
.read(&query.path)
@@ -123,17 +136,75 @@ pub async fn memory_read_handler(
}))
}
-// memory_write_handler lives in server.rs (layer-aware version with append,
-// privacy redirect, and proper error status codes).
+pub async fn memory_write_handler(
+ State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
+ Json(req): Json,
+) -> Result, (StatusCode, String)> {
+ let workspace = resolve_workspace(&state, &user).await?;
+
+ // 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
+ .append_to_layer(layer_name, &req.path, &req.content, req.force)
+ .await
+ } else {
+ workspace
+ .write_to_layer(layer_name, &req.path, &req.content, req.force)
+ .await
+ }
+ .map_err(|e| {
+ use crate::error::WorkspaceError;
+ let status = match &e {
+ WorkspaceError::LayerNotFound { .. } => StatusCode::BAD_REQUEST,
+ WorkspaceError::LayerReadOnly { .. } => StatusCode::FORBIDDEN,
+ WorkspaceError::PrivacyRedirectFailed => StatusCode::UNPROCESSABLE_ENTITY,
+ _ => StatusCode::INTERNAL_SERVER_ERROR,
+ };
+ (status, e.to_string())
+ })?;
+ return Ok(Json(MemoryWriteResponse {
+ path: req.path,
+ status: "written",
+ redirected: Some(result.redirected),
+ actual_layer: Some(result.actual_layer),
+ }));
+ }
+
+ // Non-layer path: honor the append field
+ if req.append {
+ workspace
+ .append(&req.path, &req.content)
+ .await
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+ } else {
+ workspace
+ .write(&req.path, &req.content)
+ .await
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+ }
+
+ Ok(Json(MemoryWriteResponse {
+ path: req.path,
+ status: "written",
+ redirected: None,
+ actual_layer: None,
+ }))
+}
pub async fn memory_search_handler(
State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
Json(req): Json,
) -> Result, (StatusCode, String)> {
- let workspace = state.workspace.as_ref().ok_or((
- StatusCode::SERVICE_UNAVAILABLE,
- "Workspace not available".to_string(),
- ))?;
+ let workspace = resolve_workspace(&state, &user).await?;
let limit = req.limit.unwrap_or(10);
let results = workspace
@@ -142,10 +213,10 @@ pub async fn memory_search_handler(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let hits: Vec = results
- .into_iter()
+ .iter()
.map(|r| SearchHit {
- path: r.document_path,
- content: r.content,
+ path: r.document_id.to_string(),
+ content: r.content.clone(),
score: r.score as f64,
})
.collect();
diff --git a/src/channels/web/handlers/mod.rs b/src/channels/web/handlers/mod.rs
index 2f942058..50c7a0b9 100644
--- a/src/channels/web/handlers/mod.rs
+++ b/src/channels/web/handlers/mod.rs
@@ -1,13 +1,10 @@
//! Handler modules for the web gateway API.
//!
//! Each module groups related endpoint handlers by domain.
-//!
-//! # Migration status
-//!
-//! `skills` is the canonical implementation used by `server.rs`.
-//! The remaining modules are in-progress migrations from inline server.rs
-//! handlers; their functions are not yet wired up, hence the `dead_code` allow.
+pub mod jobs;
+pub mod memory;
+pub mod routines;
pub mod skills;
// Modules not yet wired into server.rs router -- suppress dead_code until
@@ -17,12 +14,6 @@ pub mod chat;
#[allow(dead_code)]
pub mod extensions;
#[allow(dead_code)]
-pub mod jobs;
-#[allow(dead_code)]
-pub mod memory;
-#[allow(dead_code)]
-pub mod routines;
-#[allow(dead_code)]
pub mod settings;
#[allow(dead_code)]
pub mod static_files;
diff --git a/src/channels/web/handlers/routines.rs b/src/channels/web/handlers/routines.rs
index 368a28ae..d27adca2 100644
--- a/src/channels/web/handlers/routines.rs
+++ b/src/channels/web/handlers/routines.rs
@@ -11,12 +11,14 @@ use serde::Deserialize;
use uuid::Uuid;
use crate::agent::routine::{Trigger, next_cron_fire};
+use crate::channels::web::auth::AuthenticatedUser;
use crate::channels::web::server::GatewayState;
use crate::channels::web::types::*;
use crate::error::RoutineError;
pub async fn routines_list_handler(
State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
) -> Result, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
@@ -24,7 +26,7 @@ pub async fn routines_list_handler(
))?;
let routines = store
- .list_all_routines()
+ .list_routines(&user.user_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
@@ -35,6 +37,7 @@ pub async fn routines_list_handler(
pub async fn routines_summary_handler(
State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
) -> Result, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
@@ -42,7 +45,7 @@ pub async fn routines_summary_handler(
))?;
let routines = store
- .list_all_routines()
+ .list_routines(&user.user_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
@@ -78,6 +81,7 @@ pub async fn routines_summary_handler(
pub async fn routines_detail_handler(
State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
Path(id): Path,
) -> Result, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
@@ -94,6 +98,10 @@ pub async fn routines_detail_handler(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?;
+ if routine.user_id != user.user_id {
+ return Err((StatusCode::NOT_FOUND, "Routine not found".to_string()));
+ }
+
let runs = store
.list_routine_runs(routine_id, 20)
.await
@@ -137,6 +145,7 @@ pub async fn routines_detail_handler(
pub async fn routines_trigger_handler(
State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
Path(id): Path,
) -> Result, (StatusCode, String)> {
// Clone the Arc out of the lock to avoid holding the RwLock across .await.
@@ -152,7 +161,7 @@ pub async fn routines_trigger_handler(
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?;
let run_id = engine
- .fire_manual(routine_id, Some(&state.user_id))
+ .fire_manual(routine_id, Some(&user.user_id))
.await
.map_err(|e| (routine_error_status(&e), e.to_string()))?;
@@ -170,6 +179,7 @@ pub struct ToggleRequest {
pub async fn routines_toggle_handler(
State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
Path(id): Path,
body: Option>,
) -> Result, (StatusCode, String)> {
@@ -187,6 +197,10 @@ pub async fn routines_toggle_handler(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?;
+ if routine.user_id != user.user_id {
+ return Err((StatusCode::NOT_FOUND, "Routine not found".to_string()));
+ }
+
let was_enabled = routine.enabled;
// If a specific value was provided, use it; otherwise toggle.
routine.enabled = match body {
@@ -230,6 +244,7 @@ pub async fn routines_toggle_handler(
pub async fn routines_delete_handler(
State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
Path(id): Path,
) -> Result, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
@@ -240,6 +255,17 @@ pub async fn routines_delete_handler(
let routine_id = Uuid::parse_str(&id)
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?;
+ // Verify ownership before deleting.
+ let routine = store
+ .get_routine(routine_id)
+ .await
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
+ .ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?;
+
+ if routine.user_id != user.user_id {
+ return Err((StatusCode::NOT_FOUND, "Routine not found".to_string()));
+ }
+
let deleted = store
.delete_routine(routine_id)
.await
@@ -261,8 +287,10 @@ pub async fn routines_delete_handler(
}
}
+#[allow(dead_code)] // Used by server.rs inline version; kept in sync here for future migration.
pub async fn routines_runs_handler(
State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
Path(id): Path,
) -> Result, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
@@ -273,6 +301,17 @@ pub async fn routines_runs_handler(
let routine_id = Uuid::parse_str(&id)
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?;
+ // Verify ownership before listing runs.
+ let routine = store
+ .get_routine(routine_id)
+ .await
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
+ .ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?;
+
+ if routine.user_id != user.user_id {
+ return Err((StatusCode::NOT_FOUND, "Routine not found".to_string()));
+ }
+
let runs = store
.list_routine_runs(routine_id, 50)
.await
diff --git a/src/channels/web/handlers/settings.rs b/src/channels/web/handlers/settings.rs
index dd66027b..4dd7299a 100644
--- a/src/channels/web/handlers/settings.rs
+++ b/src/channels/web/handlers/settings.rs
@@ -8,17 +8,19 @@ use axum::{
http::StatusCode,
};
+use crate::channels::web::auth::AuthenticatedUser;
use crate::channels::web::server::GatewayState;
use crate::channels::web::types::*;
pub async fn settings_list_handler(
State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
) -> Result, StatusCode> {
let store = state
.store
.as_ref()
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
- let rows = store.list_settings(&state.user_id).await.map_err(|e| {
+ let rows = store.list_settings(&user.user_id).await.map_err(|e| {
tracing::error!("Failed to list settings: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
@@ -37,6 +39,7 @@ pub async fn settings_list_handler(
pub async fn settings_get_handler(
State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
Path(key): Path,
) -> Result, StatusCode> {
let store = state
@@ -44,7 +47,7 @@ pub async fn settings_get_handler(
.as_ref()
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
let row = store
- .get_setting_full(&state.user_id, &key)
+ .get_setting_full(&user.user_id, &key)
.await
.map_err(|e| {
tracing::error!("Failed to get setting '{}': {}", key, e);
@@ -61,6 +64,7 @@ pub async fn settings_get_handler(
pub async fn settings_set_handler(
State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
Path(key): Path,
Json(body): Json,
) -> Result {
@@ -69,7 +73,7 @@ pub async fn settings_set_handler(
.as_ref()
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
store
- .set_setting(&state.user_id, &key, &body.value)
+ .set_setting(&user.user_id, &key, &body.value)
.await
.map_err(|e| {
tracing::error!("Failed to set setting '{}': {}", key, e);
@@ -81,6 +85,7 @@ pub async fn settings_set_handler(
pub async fn settings_delete_handler(
State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
Path(key): Path,
) -> Result {
let store = state
@@ -88,7 +93,7 @@ pub async fn settings_delete_handler(
.as_ref()
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
store
- .delete_setting(&state.user_id, &key)
+ .delete_setting(&user.user_id, &key)
.await
.map_err(|e| {
tracing::error!("Failed to delete setting '{}': {}", key, e);
@@ -100,12 +105,13 @@ pub async fn settings_delete_handler(
pub async fn settings_export_handler(
State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
) -> Result, StatusCode> {
let store = state
.store
.as_ref()
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
- let settings = store.get_all_settings(&state.user_id).await.map_err(|e| {
+ let settings = store.get_all_settings(&user.user_id).await.map_err(|e| {
tracing::error!("Failed to export settings: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
@@ -115,6 +121,7 @@ pub async fn settings_export_handler(
pub async fn settings_import_handler(
State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
Json(body): Json,
) -> Result {
let store = state
@@ -122,7 +129,7 @@ pub async fn settings_import_handler(
.as_ref()
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
store
- .set_all_settings(&state.user_id, &body.settings)
+ .set_all_settings(&user.user_id, &body.settings)
.await
.map_err(|e| {
tracing::error!("Failed to import settings: {}", e);
diff --git a/src/channels/web/handlers/skills.rs b/src/channels/web/handlers/skills.rs
index 400d179a..c8ecaf9f 100644
--- a/src/channels/web/handlers/skills.rs
+++ b/src/channels/web/handlers/skills.rs
@@ -8,11 +8,13 @@ use axum::{
http::StatusCode,
};
+use crate::channels::web::auth::AuthenticatedUser;
use crate::channels::web::server::GatewayState;
use crate::channels::web::types::*;
pub async fn skills_list_handler(
State(state): State>,
+ AuthenticatedUser(_user): AuthenticatedUser,
) -> Result, (StatusCode, String)> {
let registry = state.skill_registry.as_ref().ok_or((
StatusCode::NOT_IMPLEMENTED,
@@ -45,6 +47,7 @@ pub async fn skills_list_handler(
pub async fn skills_search_handler(
State(state): State>,
+ AuthenticatedUser(_user): AuthenticatedUser,
Json(req): Json,
) -> Result, (StatusCode, String)> {
let registry = state.skill_registry.as_ref().ok_or((
@@ -119,6 +122,7 @@ pub async fn skills_search_handler(
pub async fn skills_install_handler(
State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
headers: axum::http::HeaderMap,
Json(req): Json,
) -> Result, (StatusCode, String)> {
@@ -135,6 +139,8 @@ pub async fn skills_install_handler(
));
}
+ tracing::info!(user_id = %user.user_id, skill = %req.name, "skill install requested");
+
let registry = state.skill_registry.as_ref().ok_or((
StatusCode::NOT_IMPLEMENTED,
"Skills system not enabled".to_string(),
@@ -219,6 +225,7 @@ pub async fn skills_install_handler(
pub async fn skills_remove_handler(
State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
headers: axum::http::HeaderMap,
Path(name): Path,
) -> Result, (StatusCode, String)> {
@@ -234,6 +241,8 @@ pub async fn skills_remove_handler(
));
}
+ tracing::info!(user_id = %user.user_id, skill = %name, "skill remove requested");
+
let registry = state.skill_registry.as_ref().ok_or((
StatusCode::NOT_IMPLEMENTED,
"Skills system not enabled".to_string(),
diff --git a/src/channels/web/handlers/static_files.rs b/src/channels/web/handlers/static_files.rs
index c198d95e..effc7037 100644
--- a/src/channels/web/handlers/static_files.rs
+++ b/src/channels/web/handlers/static_files.rs
@@ -7,6 +7,7 @@ use axum::{
};
use crate::bootstrap::ironclaw_base_dir;
+use crate::channels::web::auth::AuthenticatedUser;
use crate::channels::web::types::*;
// --- Static file handlers ---
@@ -113,6 +114,7 @@ use crate::channels::web::server::GatewayState;
pub async fn logs_events_handler(
State(state): State>,
+ AuthenticatedUser(_user): AuthenticatedUser,
) -> Result<
Sse> + Send + 'static>,
(StatusCode, String),
@@ -152,6 +154,7 @@ pub async fn logs_events_handler(
pub async fn gateway_status_handler(
State(state): State>,
+ AuthenticatedUser(_user): AuthenticatedUser,
) -> Json {
let sse_connections = state.sse.connection_count();
let ws_connections = state
diff --git a/src/channels/web/mod.rs b/src/channels/web/mod.rs
index f40834cb..b26a7829 100644
--- a/src/channels/web/mod.rs
+++ b/src/channels/web/mod.rs
@@ -31,6 +31,9 @@ pub mod ws;
/// [`TestGatewayBuilder`](test_helpers::TestGatewayBuilder).
pub mod test_helpers;
+#[cfg(test)]
+mod tests;
+
use std::net::SocketAddr;
use std::sync::Arc;
@@ -52,6 +55,7 @@ use crate::workspace::Workspace;
use self::log_layer::{LogBroadcaster, LogLevelHandle};
+use self::auth::MultiAuthState;
use self::server::GatewayState;
use self::sse::SseManager;
use self::types::SseEvent;
@@ -60,14 +64,15 @@ use self::types::SseEvent;
pub struct GatewayChannel {
config: GatewayConfig,
state: Arc,
- /// The actual auth token in use (generated or from config).
- auth_token: String,
+ /// Multi-user auth state (replaces bare auth_token).
+ auth: MultiAuthState,
}
impl GatewayChannel {
/// Create a new gateway channel.
///
/// If no auth token is configured, generates a random one and prints it.
+ /// Builds a single-user `MultiAuthState` from the config.
pub fn new(config: GatewayConfig) -> Self {
let auth_token = config.auth_token.clone().unwrap_or_else(|| {
use rand::RngCore;
@@ -77,10 +82,13 @@ impl GatewayChannel {
bytes.iter().map(|b| format!("{b:02x}")).collect()
});
+ let auth = MultiAuthState::single(auth_token, config.user_id.clone());
+
let state = Arc::new(GatewayState {
msg_tx: tokio::sync::RwLock::new(None),
- sse: SseManager::new(),
+ sse: Arc::new(SseManager::new()),
workspace: None,
+ workspace_pool: None,
session_manager: None,
log_broadcaster: None,
log_level_handle: None,
@@ -90,13 +98,13 @@ impl GatewayChannel {
job_manager: None,
prompt_queue: None,
scheduler: None,
- user_id: config.user_id.clone(),
+ default_user_id: config.user_id.clone(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())),
llm_provider: None,
skill_registry: None,
skill_catalog: None,
- chat_rate_limiter: server::RateLimiter::new(30, 60),
+ chat_rate_limiter: server::PerUserRateLimiter::new(30, 60),
oauth_rate_limiter: server::RateLimiter::new(10, 60),
webhook_rate_limiter: server::RateLimiter::new(10, 60),
registry_entries: Vec::new(),
@@ -109,7 +117,46 @@ impl GatewayChannel {
Self {
config,
state,
- auth_token,
+ auth,
+ }
+ }
+
+ /// Create a gateway channel with a pre-built multi-user auth state.
+ pub fn new_multi_auth(config: GatewayConfig, auth: MultiAuthState) -> Self {
+ let state = Arc::new(GatewayState {
+ msg_tx: tokio::sync::RwLock::new(None),
+ sse: Arc::new(SseManager::new()),
+ workspace: None,
+ workspace_pool: None,
+ session_manager: None,
+ log_broadcaster: None,
+ log_level_handle: None,
+ extension_manager: None,
+ tool_registry: None,
+ store: None,
+ job_manager: None,
+ prompt_queue: None,
+ scheduler: None,
+ default_user_id: config.user_id.clone(),
+ shutdown_tx: tokio::sync::RwLock::new(None),
+ ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())),
+ llm_provider: None,
+ skill_registry: None,
+ skill_catalog: None,
+ chat_rate_limiter: server::PerUserRateLimiter::new(30, 60),
+ oauth_rate_limiter: server::RateLimiter::new(10, 60),
+ registry_entries: Vec::new(),
+ cost_guard: None,
+ routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
+ startup_time: std::time::Instant::now(),
+ webhook_rate_limiter: server::RateLimiter::new(10, 60),
+ active_config: server::ActiveConfigSnapshot::default(),
+ });
+
+ Self {
+ config,
+ state,
+ auth,
}
}
@@ -118,8 +165,9 @@ impl GatewayChannel {
let mut new_state = GatewayState {
msg_tx: tokio::sync::RwLock::new(None),
// Preserve the existing broadcast channel so sender handles remain valid.
- sse: SseManager::from_sender(self.state.sse.sender()),
+ sse: Arc::new(SseManager::from_sender(self.state.sse.sender())),
workspace: self.state.workspace.clone(),
+ workspace_pool: self.state.workspace_pool.clone(),
session_manager: self.state.session_manager.clone(),
log_broadcaster: self.state.log_broadcaster.clone(),
log_level_handle: self.state.log_level_handle.clone(),
@@ -129,13 +177,13 @@ impl GatewayChannel {
job_manager: self.state.job_manager.clone(),
prompt_queue: self.state.prompt_queue.clone(),
scheduler: self.state.scheduler.clone(),
- user_id: self.state.user_id.clone(),
+ default_user_id: self.state.default_user_id.clone(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: self.state.ws_tracker.clone(),
llm_provider: self.state.llm_provider.clone(),
skill_registry: self.state.skill_registry.clone(),
skill_catalog: self.state.skill_catalog.clone(),
- chat_rate_limiter: server::RateLimiter::new(30, 60),
+ chat_rate_limiter: server::PerUserRateLimiter::new(30, 60),
oauth_rate_limiter: server::RateLimiter::new(10, 60),
webhook_rate_limiter: server::RateLimiter::new(10, 60),
registry_entries: self.state.registry_entries.clone(),
@@ -260,9 +308,15 @@ impl GatewayChannel {
self
}
- /// Get the auth token (for printing to console on startup).
+ /// Inject the per-user workspace pool for multi-user mode.
+ pub fn with_workspace_pool(mut self, pool: Arc) -> Self {
+ self.rebuild_state(|s| s.workspace_pool = Some(pool));
+ self
+ }
+
+ /// Get the first auth token (for printing to console on startup).
pub fn auth_token(&self) -> &str {
- &self.auth_token
+ self.auth.first_token().unwrap_or("")
}
/// Get a reference to the shared gateway state (for the agent to push SSE events).
@@ -291,7 +345,7 @@ impl Channel for GatewayChannel {
),
})?;
- server::start_server(addr, self.state.clone(), self.auth_token.clone()).await?;
+ server::start_server(addr, self.state.clone(), self.auth.clone()).await?;
Ok(Box::pin(ReceiverStream::new(rx)))
}
@@ -311,10 +365,13 @@ impl Channel for GatewayChannel {
}
};
- self.state.sse.broadcast(SseEvent::Response {
- content: response.content,
- thread_id,
- });
+ self.state.sse.broadcast_for_user(
+ &msg.user_id,
+ SseEvent::Response {
+ content: response.content,
+ thread_id,
+ },
+ );
Ok(())
}
@@ -427,13 +484,21 @@ impl Channel for GatewayChannel {
},
};
- self.state.sse.broadcast(event);
+ // Scope events to the user when user_id is available in metadata.
+ // When user_id is missing (heartbeat, routines), events go to all
+ // subscribers. In multi-tenant mode this leaks status across users.
+ if let Some(uid) = metadata.get("user_id").and_then(|v| v.as_str()) {
+ self.state.sse.broadcast_for_user(uid, event);
+ } else {
+ tracing::debug!("Status event missing user_id in metadata; broadcasting globally");
+ self.state.sse.broadcast(event);
+ }
Ok(())
}
async fn broadcast(
&self,
- _user_id: &str,
+ user_id: &str,
response: OutgoingResponse,
) -> Result<(), ChannelError> {
let thread_id = match response.thread_id {
@@ -445,10 +510,13 @@ impl Channel for GatewayChannel {
return Ok(());
}
};
- self.state.sse.broadcast(SseEvent::Response {
- content: response.content,
- thread_id,
- });
+ self.state.sse.broadcast_for_user(
+ user_id,
+ SseEvent::Response {
+ content: response.content,
+ thread_id,
+ },
+ );
Ok(())
}
diff --git a/src/channels/web/openai_compat.rs b/src/channels/web/openai_compat.rs
index 51577e06..55b7c854 100644
--- a/src/channels/web/openai_compat.rs
+++ b/src/channels/web/openai_compat.rs
@@ -463,9 +463,10 @@ fn build_tool_request(
pub async fn chat_completions_handler(
State(state): State>,
+ super::auth::AuthenticatedUser(user): super::auth::AuthenticatedUser,
Json(req): Json,
) -> Result)> {
- if !state.chat_rate_limiter.check() {
+ if !state.chat_rate_limiter.check(&user.user_id) {
return Err(openai_error(
StatusCode::TOO_MANY_REQUESTS,
"Rate limit exceeded. Please try again later.",
diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs
index 7b24805c..aaa479fa 100644
--- a/src/channels/web/server.rs
+++ b/src/channels/web/server.rs
@@ -30,12 +30,18 @@ use crate::agent::SessionManager;
use crate::bootstrap::ironclaw_base_dir;
use crate::channels::IncomingMessage;
use crate::channels::relay::DEFAULT_RELAY_NAME;
-use crate::channels::web::auth::{AuthState, auth_middleware};
+use crate::channels::web::auth::{
+ AuthenticatedUser, MultiAuthState, UserIdentity, auth_middleware,
+};
use crate::channels::web::handlers::jobs::{
job_files_list_handler, job_files_read_handler, jobs_cancel_handler, jobs_detail_handler,
jobs_events_handler, jobs_list_handler, jobs_prompt_handler, jobs_restart_handler,
jobs_summary_handler,
};
+use crate::channels::web::handlers::memory::{
+ memory_list_handler, memory_read_handler, memory_search_handler, memory_tree_handler,
+ memory_write_handler,
+};
use crate::channels::web::handlers::routines::{
routines_delete_handler, routines_detail_handler, routines_list_handler,
routines_summary_handler, routines_toggle_handler, routines_trigger_handler,
@@ -80,7 +86,6 @@ fn redact_oauth_state_for_logs(state: &str) -> String {
/// Simple sliding-window rate limiter.
///
/// Tracks the number of requests in the current window. Resets when the window expires.
-/// Not per-IP (since this is a single-user gateway with auth), but prevents flooding.
pub struct RateLimiter {
/// Requests remaining in the current window.
remaining: AtomicU64,
@@ -108,6 +113,12 @@ impl RateLimiter {
}
/// Try to consume one request. Returns `true` if allowed, `false` if rate limited.
+ ///
+ /// Note: There is a benign TOCTOU race between checking `window_start` and
+ /// resetting it — two concurrent threads may both see an expired window
+ /// and reset it, granting a few extra requests at the window boundary.
+ /// This is acceptable for chat rate limiting where approximate enforcement
+ /// is sufficient, and avoids the cost of a Mutex.
pub fn check(&self) -> bool {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -148,14 +159,176 @@ pub struct ActiveConfigSnapshot {
pub enabled_channels: Vec,
}
+/// Per-user rate limiter that maintains a separate sliding window per user_id.
+///
+/// Prevents one user from exhausting the rate limit for all users in multi-tenant mode.
+pub struct PerUserRateLimiter {
+ limiters: std::sync::RwLock>,
+ max_requests: u64,
+ window_secs: u64,
+}
+
+impl PerUserRateLimiter {
+ pub fn new(max_requests: u64, window_secs: u64) -> Self {
+ Self {
+ limiters: std::sync::RwLock::new(std::collections::HashMap::new()),
+ max_requests,
+ window_secs,
+ }
+ }
+
+ /// Try to consume one request for the given user. Returns `true` if allowed.
+ pub fn check(&self, user_id: &str) -> bool {
+ // Fast path: check existing limiter under read lock.
+ // On lock poisoning (another thread panicked while holding the lock),
+ // allow the request rather than crashing the server.
+ {
+ let map = match self.limiters.read() {
+ Ok(m) => m,
+ Err(e) => {
+ tracing::warn!("PerUserRateLimiter read lock poisoned; recovering");
+ e.into_inner()
+ }
+ };
+ if let Some(limiter) = map.get(user_id) {
+ return limiter.check();
+ }
+ }
+ // Slow path: create limiter under write lock.
+ let mut map = match self.limiters.write() {
+ Ok(m) => m,
+ Err(e) => {
+ tracing::warn!("PerUserRateLimiter write lock poisoned; recovering");
+ e.into_inner()
+ }
+ };
+ let limiter = map
+ .entry(user_id.to_string())
+ .or_insert_with(|| RateLimiter::new(self.max_requests, self.window_secs));
+ limiter.check()
+ }
+}
+
+/// Per-user workspace pool: lazily creates and caches workspaces keyed by user_id.
+///
+/// In single-user mode, exactly one workspace is cached. In multi-user mode,
+/// each authenticated user gets their own workspace with appropriate scopes,
+/// search config, memory layers, and embedding cache settings.
+///
+/// Also implements [`WorkspaceResolver`] so it can be shared with memory tools,
+/// avoiding a separate `PerUserWorkspaceResolver` with duplicated logic.
+pub struct WorkspacePool {
+ db: Arc,
+ embeddings: Option>,
+ embedding_cache_config: crate::workspace::EmbeddingCacheConfig,
+ search_config: crate::config::WorkspaceSearchConfig,
+ workspace_config: crate::config::WorkspaceConfig,
+ cache: tokio::sync::RwLock>>,
+}
+
+impl WorkspacePool {
+ pub fn new(
+ db: Arc,
+ embeddings: Option>,
+ embedding_cache_config: crate::workspace::EmbeddingCacheConfig,
+ search_config: crate::config::WorkspaceSearchConfig,
+ workspace_config: crate::config::WorkspaceConfig,
+ ) -> Self {
+ Self {
+ db,
+ embeddings,
+ embedding_cache_config,
+ search_config,
+ workspace_config,
+ cache: tokio::sync::RwLock::new(std::collections::HashMap::new()),
+ }
+ }
+
+ /// Build a workspace for a user, applying search config, embeddings,
+ /// global read scopes, and memory layers.
+ fn build_workspace(&self, user_id: &str) -> Workspace {
+ let mut ws = Workspace::new_with_db(user_id, Arc::clone(&self.db))
+ .with_search_config(&self.search_config);
+
+ if let Some(ref emb) = self.embeddings {
+ ws = ws.with_embeddings_cached(Arc::clone(emb), self.embedding_cache_config.clone());
+ }
+
+ if !self.workspace_config.read_scopes.is_empty() {
+ ws = ws.with_additional_read_scopes(self.workspace_config.read_scopes.clone());
+ }
+
+ ws = ws.with_memory_layers(self.workspace_config.memory_layers.clone());
+ ws
+ }
+
+ /// Get or create a workspace for the given user identity.
+ ///
+ /// Applies search config, memory layers, embedding cache, and read scopes
+ /// (both from global config and from the token's `workspace_read_scopes`).
+ pub async fn get_or_create(&self, identity: &UserIdentity) -> Arc {
+ // Fast path: check read lock
+ {
+ let cache = self.cache.read().await;
+ if let Some(ws) = cache.get(&identity.user_id) {
+ return Arc::clone(ws);
+ }
+ }
+
+ // Slow path: create workspace under write lock
+ let mut cache = self.cache.write().await;
+ // Double-check after acquiring write lock
+ if let Some(ws) = cache.get(&identity.user_id) {
+ return Arc::clone(ws);
+ }
+
+ let mut ws = self.build_workspace(&identity.user_id);
+
+ // Apply per-token read scopes from identity.
+ if !identity.workspace_read_scopes.is_empty() {
+ ws = ws.with_additional_read_scopes(identity.workspace_read_scopes.clone());
+ }
+
+ let ws = Arc::new(ws);
+ cache.insert(identity.user_id.clone(), Arc::clone(&ws));
+ ws
+ }
+}
+
+#[async_trait::async_trait]
+impl crate::tools::builtin::memory::WorkspaceResolver for WorkspacePool {
+ async fn resolve(&self, user_id: &str) -> Arc {
+ // Fast path: check read lock
+ {
+ let cache = self.cache.read().await;
+ if let Some(ws) = cache.get(user_id) {
+ return Arc::clone(ws);
+ }
+ }
+
+ // Slow path: create workspace under write lock
+ let mut cache = self.cache.write().await;
+ if let Some(ws) = cache.get(user_id) {
+ return Arc::clone(ws);
+ }
+
+ let ws = Arc::new(self.build_workspace(user_id));
+ cache.insert(user_id.to_string(), Arc::clone(&ws));
+ tracing::debug!(user_id = user_id, "Created per-user workspace");
+ ws
+ }
+}
+
/// Shared state for all gateway handlers.
pub struct GatewayState {
/// Channel to send messages to the agent loop.
pub msg_tx: tokio::sync::RwLock