Compare commits

..
Author SHA1 Message Date
Claude b4fb487472 style: apply cargo fmt formatting
https://claude.ai/code/session_017MJoXHYqvfdyWoDuSRPHim
2026-03-27 15:05:14 +00:00
ZakiandClaude Opus 4.6 9fb704a213 fix(security): unified sensitive path protection across shell and file tools
Add a shared SENSITIVE_PATH_PATTERNS list in path_utils.rs that protects
credentials, secrets, and private keys consistently across all tool types:

- Shell tool: command_references_sensitive_path() scans commands for
  references to sensitive files (cat ~/.ssh/id_rsa, etc.)
- File tools: is_sensitive_path() blocks ReadFileTool, WriteFileTool,
  ListDirTool, and ApplyPatchTool from accessing sensitive paths
- ListDirTool: skips sensitive subdirectories during recursive traversal

Previously, the shell tool had a small hardcoded list (5 patterns) in
DANGEROUS_PATTERNS while file tools had no sensitive path protection at
all. This created an asymmetric security model where file tools were
more permissive than the shell tool.

The shared list covers: SSH keys, GPG, AWS/Azure/GCP credentials,
Kubernetes config, GitHub CLI tokens, Terraform credentials, Docker
config, Vault tokens, shell history, .env files, git credentials,
system shadow files, and sensitive key file extensions (.pem, .key,
.p12, .pfx, .jks, .keystore). Safe suffixes (.example, .sample,
.template) are excluded.

21 tests covering path detection, command scanning, safe suffixes,
and normal file allowlisting.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-27 07:48:32 -07:00
90 changed files with 1009 additions and 9384 deletions
+3 -2
View File
@@ -191,9 +191,10 @@ HEARTBEAT_NOTIFY_CHANNEL=cli
HEARTBEAT_NOTIFY_USER=default
# Memory hygiene settings (automatic cleanup of stale workspace documents)
# Runs on each heartbeat tick; discovers cleanup targets from .config metadata
# Runs on each heartbeat tick; identity files (IDENTITY.md, SOUL.md) are never deleted
# MEMORY_HYGIENE_ENABLED=true
# MEMORY_HYGIENE_VERSION_KEEP_COUNT=50 # max versions to keep per document
# MEMORY_HYGIENE_DAILY_RETENTION_DAYS=30 # delete daily/ docs older than this many days
# MEMORY_HYGIENE_CONVERSATION_RETENTION_DAYS=7 # delete conversations/ docs older than this many days
# MEMORY_HYGIENE_CADENCE_HOURS=12 # minimum hours between cleanup passes
# Docker Sandbox
Generated
+5 -28
View File
@@ -3150,7 +3150,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.3",
"socket2 0.5.10",
"system-configuration",
"tokio",
"tower-service",
@@ -3429,7 +3429,6 @@ dependencies = [
"iana-time-zone",
"insta",
"ironclaw_common",
"ironclaw_frontend",
"ironclaw_safety",
"json5",
"libsql",
@@ -3440,7 +3439,6 @@ dependencies = [
"pgvector",
"postgres-types",
"pretty_assertions",
"pty-process",
"rand 0.8.5",
"readabilityrs",
"refinery",
@@ -3484,7 +3482,6 @@ dependencies = [
"wasmparser 0.220.1",
"wasmtime",
"wasmtime-wasi",
"webpki-roots 0.26.11",
"zbus",
"zip",
]
@@ -3497,15 +3494,6 @@ dependencies = [
"serde_json",
]
[[package]]
name = "ironclaw_frontend"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "ironclaw_safety"
version = "0.2.0"
@@ -3535,7 +3523,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -4917,16 +4905,6 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "pty-process"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71cec9e2670207c5ebb9e477763c74436af3b9091dd550b9fb3c1bec7f3ea266"
dependencies = [
"rustix 1.1.4",
"tokio",
]
[[package]]
name = "pulley-interpreter"
version = "28.0.1"
@@ -4951,7 +4929,7 @@ dependencies = [
"quinn-udp",
"rustc-hash 2.1.1",
"rustls 0.23.37",
"socket2 0.6.3",
"socket2 0.5.10",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -4988,9 +4966,9 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.3",
"socket2 0.5.10",
"tracing",
"windows-sys 0.60.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -7013,7 +6991,6 @@ dependencies = [
"futures-util",
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
"iri-string",
"pin-project-lite",
"tower 0.5.3",
+2 -9
View File
@@ -1,5 +1,5 @@
[workspace]
members = [".", "crates/ironclaw_common", "crates/ironclaw_safety", "crates/ironclaw_frontend"]
members = [".", "crates/ironclaw_common", "crates/ironclaw_safety"]
exclude = [
"channels-src/discord",
"channels-src/telegram",
@@ -57,7 +57,6 @@ refinery = { version = "0.8", features = ["tokio-postgres"], optional = true }
tokio-postgres-rustls = { version = "0.13", optional = true }
rustls = { version = "0.23", optional = true, default-features = false }
rustls-native-certs = { version = "0.8", optional = true }
webpki-roots = { version = "0.26", optional = true }
# Database - libSQL/Turso (optional embedded database)
libsql = { version = "0.6", optional = true, default-features = false, features = ["core", "replication", "remote", "tls"] }
@@ -96,7 +95,7 @@ termimad = "0.34"
# Channel integrations
axum = { version = "0.8", features = ["ws"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["trace", "cors", "set-header", "catch-panic"] }
tower-http = { version = "0.6", features = ["trace", "cors", "set-header"] }
# Cron scheduling for routines
cron = "0.13"
@@ -105,7 +104,6 @@ cron = "0.13"
ironclaw_common = { path = "crates/ironclaw_common", version = "0.1.0" }
# Safety/sanitization
ironclaw_frontend = { path = "crates/ironclaw_frontend", version = "0.1.0" }
ironclaw_safety = { path = "crates/ironclaw_safety", version = "0.2.0" }
regex = "1"
aho-corasick = "1"
@@ -190,10 +188,6 @@ json5 = { version = "0.4", optional = true }
[target.'cfg(target_os = "macos")'.dependencies]
security-framework = "3"
# PTY allocation for Claude CLI stdout buffering fix (Unix only)
[target.'cfg(unix)'.dependencies]
pty-process = { version = "0.5", features = ["async"] }
# Linux secret-service (GNOME Keyring, KWallet)
[target.'cfg(target_os = "linux")'.dependencies]
secret-service = { version = "4", features = ["rt-tokio-crypto-rust"] }
@@ -225,7 +219,6 @@ postgres = [
"dep:tokio-postgres-rustls",
"dep:rustls",
"dep:rustls-native-certs",
"dep:webpki-roots",
"dep:postgres-types",
"dep:refinery",
"dep:pgvector",
+8 -34
View File
@@ -1,71 +1,45 @@
# Multi-stage Dockerfile for the IronClaw agent (cloud deployment).
#
# Uses cargo-chef for dependency caching — only rebuilds deps when
# Cargo.toml/Cargo.lock change, not on every source edit.
#
# Build:
# docker build --platform linux/amd64 -t ironclaw:latest .
#
# Run:
# docker run --env-file .env -p 3000:3000 ironclaw:latest
# Stage 1: Install cargo-chef
FROM rust:1.92-slim-bookworm AS chef
# Stage 1: Build
FROM rust:1.92-slim-bookworm AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config libssl-dev cmake gcc g++ \
&& rm -rf /var/lib/apt/lists/* \
&& rustup target add wasm32-wasip2 \
&& cargo install cargo-chef wasm-tools
&& cargo install wasm-tools
WORKDIR /app
# Stage 2: Generate the dependency recipe (changes only when Cargo.toml/lock change)
FROM chef AS planner
# Copy manifests first for layer caching
COPY Cargo.toml Cargo.lock ./
COPY crates/ crates/
# Copy source, build script, tests, and supporting directories
COPY build.rs build.rs
COPY src/ src/
COPY tests/ tests/
COPY benches/ benches/
COPY migrations/ migrations/
COPY registry/ registry/
COPY channels-src/ channels-src/
COPY wit/ wit/
COPY providers.json providers.json
RUN cargo chef prepare --recipe-path recipe.json
# Stage 3: Build dependencies (cached unless Cargo.toml/lock change)
FROM chef AS deps
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
# Stage 4: Build the actual binary (only recompiles ironclaw source)
FROM deps AS builder
COPY Cargo.toml Cargo.lock ./
COPY crates/ crates/
COPY build.rs build.rs
COPY src/ src/
COPY tests/ tests/
# [[bench]] entries in Cargo.toml require bench sources to exist for cargo to parse the manifest
COPY benches/ benches/
COPY migrations/ migrations/
COPY registry/ registry/
COPY channels-src/ channels-src/
COPY wit/ wit/
COPY providers.json providers.json
RUN cargo build --release --bin ironclaw
# Stage 5: Runtime
# Stage 2: Runtime
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libssl3 \
&& update-ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/ironclaw /usr/local/bin/ironclaw
+23 -118
View File
@@ -28,9 +28,6 @@ use std::{cmp::Ordering, collections::HashMap};
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
/// Discord REST API v10 base URL.
const DISCORD_API_BASE: &str = "https://discord.com/api/v10";
use exports::near::agent::channel::{
AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
OutgoingHttpResponse, PollConfig, StatusUpdate,
@@ -430,7 +427,7 @@ impl Guest for DiscordChannel {
(
"PATCH",
format!(
"{DISCORD_API_BASE}/webhooks/{}/{}/messages/@original",
"https://discord.com/api/v10/webhooks/{}/{}/messages/@original",
application_id, token
),
)
@@ -441,7 +438,20 @@ impl Guest for DiscordChannel {
payload["allowed_mentions"] = serde_json::json!({
"replied_user": true
});
return send_channel_message(&metadata.channel_id, payload);
let mention_payload = serde_json::to_vec(&payload)
.map_err(|e| format!("Failed to serialize mention payload: {}", e))?;
let mention_url = format!(
"https://discord.com/api/v10/channels/{}/messages",
metadata.channel_id
);
let result = channel_host::http_request(
"POST",
&mention_url,
&discord_auth_headers_json(true),
Some(&mention_payload),
None,
);
return map_discord_response(result);
} else {
return Err("Unsupported Discord response metadata".to_string());
};
@@ -459,8 +469,8 @@ impl Guest for DiscordChannel {
fn on_status(_update: StatusUpdate) {}
fn on_broadcast(user_id: String, response: AgentResponse) -> Result<(), String> {
broadcast_dm(&user_id, &response.content)
fn on_broadcast(_user_id: String, _response: AgentResponse) -> Result<(), String> {
Err("broadcast not yet implemented for Discord channel".to_string())
}
fn on_shutdown() {
@@ -491,21 +501,6 @@ fn map_discord_response(
}
}
/// Post a JSON payload to a Discord channel as a new message.
fn send_channel_message(channel_id: &str, payload: serde_json::Value) -> Result<(), String> {
let payload_bytes = serde_json::to_vec(&payload)
.map_err(|e| format!("Failed to serialize message: {}", e))?;
let url = format!("{DISCORD_API_BASE}/channels/{}/messages", channel_id);
let result = channel_host::http_request(
"POST",
&url,
&discord_auth_headers_json(true),
Some(&payload_bytes),
None,
);
map_discord_response(result)
}
fn load_runtime_config() -> DiscordRuntimeConfig {
channel_host::workspace_read("config.json")
.and_then(|raw| serde_json::from_str::<DiscordRuntimeConfig>(&raw).ok())
@@ -544,7 +539,7 @@ fn get_or_fetch_bot_id() -> Option<String> {
let response = channel_host::http_request(
"GET",
&format!("{DISCORD_API_BASE}/users/@me"),
"https://discord.com/api/v10/users/@me",
&discord_auth_headers_json(false),
None,
Some(10_000),
@@ -664,7 +659,7 @@ fn poll_channel_mentions(channel_id: &str, bot_id: &str) {
fn fetch_latest_message_id(channel_id: &str) -> Option<String> {
let url = format!(
"{DISCORD_API_BASE}/channels/{}/messages?limit=1",
"https://discord.com/api/v10/channels/{}/messages?limit=1",
channel_id
);
let response = channel_host::http_request(
@@ -702,7 +697,7 @@ fn fetch_messages_after_cursor(
for page in 0..MAX_PAGES {
let url = format!(
"{DISCORD_API_BASE}/channels/{}/messages?limit={}&after={}",
"https://discord.com/api/v10/channels/{}/messages?limit={}&after={}",
channel_id, PAGE_LIMIT, after
);
let response = match channel_host::http_request(
@@ -991,7 +986,7 @@ fn handle_slash_command(interaction: &DiscordInteraction) -> bool {
);
// Attempt to notify user of internal error
let url = format!(
"{DISCORD_API_BASE}/webhooks/{}/{}",
"https://discord.com/api/v10/webhooks/{}/{}",
interaction.application_id, interaction.token
);
let payload = serde_json::json!({
@@ -1111,7 +1106,7 @@ fn check_sender_permission(
}
let dm_policy =
channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(default_dm_policy);
channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| default_dm_policy());
if dm_policy == "open" {
return true;
}
@@ -1166,7 +1161,7 @@ fn check_sender_permission(
/// Send a pairing code as an ephemeral Discord followup message.
fn send_pairing_reply(ctx: &PairingReplyCtx, code: &str) -> Result<(), String> {
let url = format!(
"{DISCORD_API_BASE}/webhooks/{}/{}",
"https://discord.com/api/v10/webhooks/{}/{}",
ctx.application_id, ctx.token
);
let payload = serde_json::json!({
@@ -1199,57 +1194,6 @@ fn send_pairing_reply(ctx: &PairingReplyCtx, code: &str) -> Result<(), String> {
}
}
/// Send a broadcast message to a Discord user via DM.
///
/// Creates a DM channel with the user (Discord caches this, so repeated calls
/// for the same user reuse the existing channel) and then posts the message.
fn broadcast_dm(user_id: &str, content: &str) -> Result<(), String> {
// Validate user_id is a plausible Discord snowflake (numeric, 17-20 digits)
// to avoid injecting arbitrary strings into API URLs.
if user_id.is_empty()
|| !user_id.chars().all(|c| c.is_ascii_digit())
|| user_id.len() < 17
|| user_id.len() > 20
{
return Err(format!("Invalid Discord user ID: '{}'", user_id));
}
// Step 1: Open (or reuse) a DM channel with the target user.
let create_dm_payload = serde_json::json!({ "recipient_id": user_id });
let create_dm_bytes = serde_json::to_vec(&create_dm_payload)
.map_err(|e| format!("Failed to serialize DM channel request: {}", e))?;
let dm_response = channel_host::http_request(
"POST",
&format!("{DISCORD_API_BASE}/users/@me/channels"),
&discord_auth_headers_json(true),
Some(&create_dm_bytes),
Some(10_000),
)
.map_err(|e| format!("Failed to create DM channel: {}", e))?;
if !(200..300).contains(&dm_response.status) {
let body = String::from_utf8_lossy(&dm_response.body);
return Err(format!(
"Discord create-DM failed: {} - {}",
dm_response.status, body
));
}
#[derive(Deserialize)]
struct DmChannelResponse {
id: String,
}
let dm_channel: DmChannelResponse = serde_json::from_slice(&dm_response.body)
.map_err(|e| format!("Failed to parse DM channel response: {}", e))?;
let channel_id = &dm_channel.id;
// Step 2: Send the message to the DM channel.
let truncated = truncate_message(content);
let payload = serde_json::json!({ "content": truncated });
send_channel_message(channel_id, payload)
}
fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse {
let body = serde_json::to_vec(&value).unwrap_or_default();
let headers = serde_json::json!({"Content-Type": "application/json"});
@@ -1649,43 +1593,4 @@ mod tests {
assert_eq!(interaction.interaction_type, 2);
assert!(interaction.data.is_some());
}
#[test]
fn test_broadcast_dm_payload_format() {
// Verify the DM channel creation payload is well-formed JSON that
// Discord's API expects.
let user_id = "123456789012345678";
let payload = serde_json::json!({ "recipient_id": user_id });
let serialized = serde_json::to_vec(&payload).unwrap();
let parsed: serde_json::Value = serde_json::from_slice(&serialized).unwrap();
assert_eq!(
parsed.get("recipient_id").and_then(|v| v.as_str()),
Some(user_id)
);
}
#[test]
fn test_broadcast_message_truncation() {
// Broadcast uses truncate_message, verify it handles content within
// Discord's 2000-char limit for DMs.
let short = "Hello from broadcast";
assert_eq!(truncate_message(short), short);
let long = "x".repeat(2500);
let result = truncate_message(&long);
assert!(result.len() <= 2006); // 1990 content + 16 suffix
assert!(result.ends_with("\n... (truncated)"));
}
#[test]
fn test_broadcast_dm_validates_snowflake() {
// broadcast_dm rejects invalid Discord snowflake IDs before making
// any API calls. We can call it directly since invalid IDs are
// rejected before any host function is invoked.
assert!(broadcast_dm("", "hi").is_err());
assert!(broadcast_dm("abc", "hi").is_err());
assert!(broadcast_dm("12345", "hi").is_err()); // too short
assert!(broadcast_dm("123456789012345678901", "hi").is_err()); // too long
assert!(broadcast_dm("12345678901234567x", "hi").is_err()); // non-digit
}
}
-15
View File
@@ -1,15 +0,0 @@
[package]
name = "ironclaw_frontend"
version = "0.1.0"
edition = "2024"
rust-version = "1.92"
description = "Frontend assets, layout configuration, and widget extension system for IronClaw"
license = "MIT OR Apache-2.0"
[package.metadata.dist]
dist = false
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
-37
View File
@@ -1,37 +0,0 @@
//! Embedded static assets for the IronClaw web gateway.
//!
//! All frontend files are compiled into the binary via `include_str!()` /
//! `include_bytes!()`. The web gateway serves these as the default baseline;
//! workspace-stored customizations (layout config, widgets, CSS overrides)
//! are layered on top at runtime.
// ==================== Core Files ====================
/// Main HTML page (SPA shell).
pub const INDEX_HTML: &str = include_str!("../static/index.html");
/// Main application JavaScript.
pub const APP_JS: &str = include_str!("../static/app.js");
/// Base stylesheet.
pub const STYLE_CSS: &str = include_str!("../static/style.css");
/// Theme initialization script (runs synchronously in `<head>` to prevent FOUC).
pub const THEME_INIT_JS: &str = include_str!("../static/theme-init.js");
/// Favicon.
pub const FAVICON_ICO: &[u8] = include_bytes!("../static/favicon.ico");
// ==================== Internationalization ====================
/// i18n core library.
pub const I18N_INDEX_JS: &str = include_str!("../static/i18n/index.js");
/// English translations.
pub const I18N_EN_JS: &str = include_str!("../static/i18n/en.js");
/// Chinese (Simplified) translations.
pub const I18N_ZH_CN_JS: &str = include_str!("../static/i18n/zh-CN.js");
/// i18n integration with the app.
pub const I18N_APP_JS: &str = include_str!("../static/i18n-app.js");
-237
View File
@@ -1,237 +0,0 @@
//! Frontend bundle assembly.
//!
//! Combines the embedded base HTML with workspace customizations (layout
//! config, widgets, CSS overrides) into the final served page.
use crate::layout::LayoutConfig;
use crate::widget::{WidgetManifest, scope_css};
/// A resolved frontend bundle ready for serving.
///
/// Contains the layout configuration, resolved widgets (with their JS/CSS
/// content loaded), and any custom CSS overrides.
#[derive(Debug, Clone, Default)]
pub struct FrontendBundle {
/// Layout configuration (branding, tabs, chat settings).
pub layout: LayoutConfig,
/// Resolved widgets with their source code loaded.
pub widgets: Vec<ResolvedWidget>,
/// Custom CSS to append after the base stylesheet.
pub custom_css: Option<String>,
}
/// A widget with its manifest and source files loaded.
#[derive(Debug, Clone)]
pub struct ResolvedWidget {
/// Widget metadata.
pub manifest: WidgetManifest,
/// JavaScript source code (`index.js`).
pub js: String,
/// Optional CSS source code (`style.css`), auto-scoped.
pub css: Option<String>,
}
/// Inject frontend customizations into the base HTML template.
///
/// Modifications:
///
/// **Before `</head>`:**
/// - Branding CSS custom property overrides
/// - Title override (replaces `<title>` content)
///
/// **Before `</body>`:**
/// - Layout config as `window.__IRONCLAW_LAYOUT__`
/// - Scoped widget `<style>` blocks
/// - Widget `<script type="module">` tags
/// - Custom CSS `<style>` block
pub fn assemble_index(base_html: &str, bundle: &FrontendBundle) -> String {
let mut head_injections = Vec::new();
let mut body_injections = Vec::new();
// --- Head injections ---
// Branding CSS variables
let css_vars = bundle.layout.branding.to_css_vars();
if !css_vars.is_empty() {
head_injections.push(format!("<style>{}</style>", css_vars));
}
// --- Body injections ---
// Layout config as global variable
if let Ok(layout_json) = serde_json::to_string(&bundle.layout) {
body_injections.push(format!(
"<script>window.__IRONCLAW_LAYOUT__ = {};</script>",
layout_json
));
}
// Widget CSS (scoped) and JS
for widget in &bundle.widgets {
if let Some(ref css) = widget.css {
let scoped = scope_css(css, &widget.manifest.id);
if !scoped.trim().is_empty() {
body_injections.push(format!(
"<style data-widget=\"{}\">{}</style>",
widget.manifest.id, scoped
));
}
}
// Widget JS as module script served from API
body_injections.push(format!(
"<script type=\"module\" src=\"/api/frontend/widget/{}/index.js\"></script>",
widget.manifest.id
));
}
// Custom CSS
if let Some(ref custom_css) = bundle.custom_css {
if !custom_css.trim().is_empty() {
body_injections.push(format!("<style data-custom-css>{}</style>", custom_css));
}
}
// --- Assemble ---
let mut result = base_html.to_string();
// Inject before </head>
if !head_injections.is_empty() {
let head_block = head_injections.join("\n");
if let Some(pos) = result.rfind("</head>") {
result.insert_str(pos, &format!("\n{}\n", head_block));
}
}
// Override <title> if branding title is set
if let Some(ref title) = bundle.layout.branding.title {
if let Some(start) = result.find("<title>") {
if let Some(end) = result[start..].find("</title>") {
let end = start + end + "</title>".len();
result.replace_range(start..end, &format!("<title>{}</title>", title));
}
}
}
// Inject before </body>
if !body_injections.is_empty() {
let body_block = body_injections.join("\n");
if let Some(pos) = result.rfind("</body>") {
result.insert_str(pos, &format!("\n{}\n", body_block));
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layout::*;
use crate::widget::*;
const MINIMAL_HTML: &str =
"<!DOCTYPE html><html><head><title>IronClaw</title></head><body></body></html>";
#[test]
fn test_assemble_index_no_customizations() {
let bundle = FrontendBundle::default();
let result = assemble_index(MINIMAL_HTML, &bundle);
// Layout config is always injected (even when default/empty)
assert!(result.contains("window.__IRONCLAW_LAYOUT__"));
// No branding overrides or custom CSS
assert!(!result.contains("--color-primary"));
assert!(!result.contains("data-custom-css"));
}
#[test]
fn test_assemble_index_branding_title() {
let bundle = FrontendBundle {
layout: LayoutConfig {
branding: BrandingConfig {
title: Some("Acme AI".to_string()),
..Default::default()
},
..Default::default()
},
..Default::default()
};
let result = assemble_index(MINIMAL_HTML, &bundle);
assert!(result.contains("<title>Acme AI</title>"));
assert!(!result.contains("<title>IronClaw</title>"));
}
#[test]
fn test_assemble_index_branding_colors() {
let bundle = FrontendBundle {
layout: LayoutConfig {
branding: BrandingConfig {
colors: Some(BrandingColors {
primary: Some("#0066cc".to_string()),
accent: None,
}),
..Default::default()
},
..Default::default()
},
..Default::default()
};
let result = assemble_index(MINIMAL_HTML, &bundle);
assert!(result.contains("--color-primary: #0066cc;"));
}
#[test]
fn test_assemble_index_layout_config_injected() {
let bundle = FrontendBundle {
layout: LayoutConfig {
tabs: TabConfig {
hidden: Some(vec!["routines".to_string()]),
..Default::default()
},
..Default::default()
},
..Default::default()
};
let result = assemble_index(MINIMAL_HTML, &bundle);
assert!(result.contains("window.__IRONCLAW_LAYOUT__"));
assert!(result.contains("routines"));
}
#[test]
fn test_assemble_index_widget_script() {
let bundle = FrontendBundle {
widgets: vec![ResolvedWidget {
manifest: WidgetManifest {
id: "dashboard".to_string(),
name: "Dashboard".to_string(),
slot: WidgetSlot::Tab,
icon: None,
position: None,
},
js: "console.log('hello');".to_string(),
css: Some(".panel { color: red; }".to_string()),
}],
..Default::default()
};
let result = assemble_index(MINIMAL_HTML, &bundle);
assert!(result.contains("src=\"/api/frontend/widget/dashboard/index.js\""));
assert!(result.contains("data-widget=\"dashboard\""));
assert!(result.contains("[data-widget=\"dashboard\"] .panel"));
}
#[test]
fn test_assemble_index_custom_css() {
let bundle = FrontendBundle {
custom_css: Some("body { background: #111; }".to_string()),
..Default::default()
};
let result = assemble_index(MINIMAL_HTML, &bundle);
assert!(result.contains("data-custom-css"));
assert!(result.contains("background: #111;"));
}
}
-179
View File
@@ -1,179 +0,0 @@
//! Layout configuration types for frontend customization.
//!
//! A [`LayoutConfig`] is stored as `frontend/layout.json` in the workspace.
//! It controls branding, tab visibility/order, chat features, and per-widget
//! configuration. All fields are optional with sensible defaults.
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
/// Top-level layout configuration.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LayoutConfig {
/// Branding overrides (title, logo, colors).
#[serde(default)]
pub branding: BrandingConfig,
/// Tab bar configuration.
#[serde(default)]
pub tabs: TabConfig,
/// Chat panel configuration.
#[serde(default)]
pub chat: ChatConfig,
/// Per-widget instance configuration (keyed by widget ID).
#[serde(default)]
pub widgets: HashMap<String, WidgetInstanceConfig>,
}
/// Branding overrides for the gateway UI.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BrandingConfig {
/// Page title (replaces default "IronClaw").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
/// Subtitle shown below the title.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subtitle: Option<String>,
/// URL to a logo image.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logo_url: Option<String>,
/// URL to a custom favicon.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub favicon_url: Option<String>,
/// Color overrides (injected as CSS custom properties on `:root`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub colors: Option<BrandingColors>,
}
/// Color overrides for the UI theme.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BrandingColors {
/// Primary brand color (e.g., `"#0066cc"`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub primary: Option<String>,
/// Accent color.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub accent: Option<String>,
}
/// Tab bar layout configuration.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TabConfig {
/// Ordered list of tab IDs to display (built-in + widget tabs).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub order: Option<Vec<String>>,
/// Tab IDs to hide from the tab bar.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hidden: Option<Vec<String>>,
/// Default tab to show on load.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_tab: Option<String>,
}
/// Chat panel feature flags.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ChatConfig {
/// Show suggestion chips below the input.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub suggestions: Option<bool>,
/// Enable image upload in the chat input.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_upload: Option<bool>,
}
/// Per-widget instance configuration.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WidgetInstanceConfig {
/// Whether this widget is enabled.
#[serde(default)]
pub enabled: bool,
/// Arbitrary widget-specific configuration passed to `widget.init()`.
#[serde(default)]
pub config: serde_json::Value,
}
impl BrandingConfig {
/// Generate CSS custom property overrides for injection into `:root`.
pub fn to_css_vars(&self) -> String {
let mut vars = Vec::new();
if let Some(ref colors) = self.colors {
if let Some(ref primary) = colors.primary {
vars.push(format!("--color-primary: {};", primary));
}
if let Some(ref accent) = colors.accent {
vars.push(format!("--color-accent: {};", accent));
}
}
if vars.is_empty() {
String::new()
} else {
format!(":root {{ {} }}", vars.join(" "))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_layout_config_default_is_empty() {
let config = LayoutConfig::default();
assert!(config.branding.title.is_none());
assert!(config.tabs.order.is_none());
assert!(config.widgets.is_empty());
}
#[test]
fn test_layout_config_roundtrip() {
let json = serde_json::json!({
"branding": { "title": "Acme AI", "colors": { "primary": "#0066cc" } },
"tabs": { "order": ["chat", "memory"], "hidden": ["routines"] },
"widgets": { "dashboard": { "enabled": true, "config": { "refresh": 30 } } }
});
let config: LayoutConfig = serde_json::from_value(json).unwrap();
assert_eq!(config.branding.title.as_deref(), Some("Acme AI"));
assert_eq!(config.tabs.hidden.as_ref().map(|h| h.len()), Some(1));
assert!(config.widgets.get("dashboard").is_some_and(|w| w.enabled));
}
#[test]
fn test_branding_css_vars_empty() {
let branding = BrandingConfig::default();
assert!(branding.to_css_vars().is_empty());
}
#[test]
fn test_branding_css_vars_with_colors() {
let branding = BrandingConfig {
colors: Some(BrandingColors {
primary: Some("#0066cc".to_string()),
accent: Some("#ff6b00".to_string()),
}),
..Default::default()
};
let css = branding.to_css_vars();
assert!(css.contains("--color-primary: #0066cc;"));
assert!(css.contains("--color-accent: #ff6b00;"));
}
#[test]
fn test_partial_deserialization() {
let json = serde_json::json!({"branding": {"title": "Test"}});
let config: LayoutConfig = serde_json::from_value(json).unwrap();
assert_eq!(config.branding.title.as_deref(), Some("Test"));
assert!(config.chat.suggestions.is_none());
}
}
-36
View File
@@ -1,36 +0,0 @@
//! IronClaw Frontend — assets, layout configuration, and widget extension system.
//!
//! This crate owns the complete frontend for the IronClaw web gateway:
//!
//! - **Embedded assets** (`assets` module): HTML, JS, CSS, i18n files compiled
//! into the binary for zero-dependency serving.
//! - **Layout configuration** (`layout` module): Branding, tab order, feature
//! flags — customizable per-tenant via workspace.
//! - **Widget system** (`widget` module): Self-contained frontend components
//! that plug into named slots in the UI.
//! - **Bundle assembly** (`bundle` module): Combines base assets with workspace
//! customizations into the final served HTML.
pub mod assets;
mod bundle;
mod layout;
mod widget;
pub use bundle::{FrontendBundle, ResolvedWidget, assemble_index};
pub use layout::{
BrandingColors, BrandingConfig, ChatConfig, LayoutConfig, TabConfig, WidgetInstanceConfig,
};
pub use widget::{WidgetManifest, WidgetSlot, scope_css};
/// Errors from frontend operations.
#[derive(Debug, thiserror::Error)]
pub enum FrontendError {
#[error("Layout configuration is invalid: {reason}")]
InvalidLayout { reason: String },
#[error("Widget '{id}' not found")]
WidgetNotFound { id: String },
#[error("Widget manifest is invalid: {reason}")]
InvalidManifest { reason: String },
}
-178
View File
@@ -1,178 +0,0 @@
//! Widget system types and utilities.
//!
//! Widgets are self-contained frontend components that plug into named
//! [`WidgetSlot`]s in the UI. Each widget has a manifest (`widget.json`)
//! and implementation files (`index.js`, optional `style.css`).
use serde::{Deserialize, Serialize};
/// Widget manifest — metadata about a widget component.
///
/// Stored as `frontend/widgets/{id}/manifest.json` in the workspace.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WidgetManifest {
/// Unique widget identifier (must be a valid HTML attribute value).
pub id: String,
/// Human-readable widget name.
pub name: String,
/// Where this widget is rendered in the UI.
pub slot: WidgetSlot,
/// Optional icon identifier (CSS class or emoji).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
/// Positioning hint (e.g., `"after:memory"`, `"before:jobs"`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub position: Option<String>,
}
/// Named insertion points in the UI where widgets can be rendered.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum WidgetSlot {
/// Full tab panel (adds a new tab to the tab bar).
Tab,
/// Banner area above the chat message list.
ChatHeader,
/// Area below the chat input.
ChatFooter,
/// Extra action buttons next to the send button.
ChatActions,
/// Right sidebar panel.
Sidebar,
/// Left side of the status bar.
StatusLeft,
/// Right side of the status bar.
StatusRight,
/// Additional section in the Settings tab.
SettingsSection,
/// Custom inline renderer for structured data in chat messages.
/// Registered via `IronClaw.registerChatRenderer()` on the browser side.
ChatRenderer,
}
/// Prefix every CSS selector with `[data-widget="{widget_id}"]` for style isolation.
///
/// This prevents widget styles from bleeding into the main app or other widgets.
/// The widget container element gets `data-widget="{id}"` set by the runtime.
///
/// # Example
///
/// ```
/// use ironclaw_frontend::scope_css;
///
/// let scoped = scope_css(".title { color: red; }", "my-widget");
/// assert!(scoped.contains("[data-widget=\"my-widget\"] .title"));
/// ```
pub fn scope_css(css: &str, widget_id: &str) -> String {
let prefix = format!("[data-widget=\"{}\"]", widget_id);
let mut result = String::with_capacity(css.len() + css.len() / 4);
let mut chars = css.chars().peekable();
let mut in_block = false;
let mut current_selector = String::new();
while let Some(ch) = chars.next() {
match ch {
'{' if !in_block => {
// Scope each comma-separated selector
let selectors: Vec<&str> = current_selector.split(',').collect();
let scoped: Vec<String> = selectors
.iter()
.map(|s| {
let s = s.trim();
if s.is_empty() || s.starts_with('@') {
s.to_string()
} else {
format!("{} {}", prefix, s)
}
})
.collect();
result.push_str(&scoped.join(", "));
result.push_str(" {");
current_selector.clear();
in_block = true;
}
'}' if in_block => {
result.push('}');
in_block = false;
}
_ if in_block => {
result.push(ch);
}
_ => {
current_selector.push(ch);
}
}
}
// Append any trailing content
if !current_selector.is_empty() {
result.push_str(&current_selector);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_widget_manifest_roundtrip() {
let json = serde_json::json!({
"id": "dashboard",
"name": "Analytics Dashboard",
"slot": "tab",
"icon": "chart-bar",
"position": "after:memory"
});
let manifest: WidgetManifest = serde_json::from_value(json).unwrap();
assert_eq!(manifest.id, "dashboard");
assert_eq!(manifest.slot, WidgetSlot::Tab);
assert_eq!(manifest.icon.as_deref(), Some("chart-bar"));
}
#[test]
fn test_widget_slot_serialization() {
assert_eq!(
serde_json::to_string(&WidgetSlot::ChatHeader).unwrap(),
"\"chat_header\""
);
assert_eq!(
serde_json::to_string(&WidgetSlot::SettingsSection).unwrap(),
"\"settings_section\""
);
}
#[test]
fn test_scope_css_basic() {
let input = ".title { color: red; }";
let result = scope_css(input, "my-widget");
assert!(result.contains("[data-widget=\"my-widget\"] .title"));
assert!(result.contains("color: red;"));
}
#[test]
fn test_scope_css_multiple_selectors() {
let input = ".a, .b { margin: 0; }";
let result = scope_css(input, "w");
assert!(result.contains("[data-widget=\"w\"] .a"));
assert!(result.contains("[data-widget=\"w\"] .b"));
}
#[test]
fn test_scope_css_multiple_rules() {
let input = ".a { color: red; } .b { color: blue; }";
let result = scope_css(input, "w");
assert!(result.contains("[data-widget=\"w\"] .a"));
assert!(result.contains("[data-widget=\"w\"] .b"));
}
#[test]
fn test_scope_css_empty() {
assert_eq!(scope_css("", "w"), "");
}
}
-572
View File
@@ -1,572 +0,0 @@
# User Management API
DB-backed user management for multi-tenant IronClaw deployments. Covers admin user CRUD, per-user secrets provisioning, self-service profile, API token management, and usage reporting.
## Authentication
All endpoints require `Authorization: Bearer <token>`. Tokens are either:
- **Env-var tokens** — configured via `GATEWAY_AUTH_TOKEN` (single-user) at startup
- **DB-backed tokens** — created via `POST /api/tokens` or `POST /api/admin/users`
DB tokens are SHA-256 hashed at rest; plaintext is returned exactly once at creation time.
Auth is cached in a bounded LRU (1024 entries, 60s TTL). Suspending a user or revoking a token may take up to 60s to take effect.
## Roles
| Role | Scope |
|------|-------|
| `admin` | Full access to all endpoints |
| `member` | Self-service profile + own token management only |
Endpoints marked **Admin** return `403 Forbidden` for `member` role.
---
## Admin: Users
### POST /api/admin/users
Create a new user. Returns the user record and a one-time plaintext API token.
**Auth:** Admin
**Request body:**
```json
{
"display_name": "Alice Smith",
"email": "[email protected]",
"role": "member"
}
```
| Field | Type | Required | Default | Notes |
|-------|------|----------|---------|-------|
| `display_name` | string | yes | | |
| `email` | string | no | `null` | Must be unique if provided |
| `role` | string | no | `"member"` | `"admin"` or `"member"` |
**Response:** `200 OK`
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "[email protected]",
"display_name": "Alice Smith",
"status": "active",
"role": "member",
"token": "a1b2c3d4e5f6...64-char hex...",
"created_at": "2026-03-25T12:00:00+00:00",
"created_by": "admin-user-id"
}
```
The `token` field is the plaintext API token. It is shown **only once** — store it securely.
**Errors:** `400` (missing display_name, invalid role), `403` (not admin), `503` (no database)
---
### GET /api/admin/users
List all users.
**Auth:** Admin
**Response:** `200 OK`
```json
{
"users": [
{
"id": "550e8400-...",
"email": "[email protected]",
"display_name": "Alice Smith",
"status": "active",
"role": "member",
"created_at": "2026-03-25T12:00:00+00:00",
"updated_at": "2026-03-25T12:00:00+00:00",
"last_login_at": "2026-03-25T14:30:00+00:00",
"created_by": "admin-user-id"
}
]
}
```
---
### GET /api/admin/users/{id}
Get a single user by ID.
**Auth:** Admin
**Response:** `200 OK`
```json
{
"id": "550e8400-...",
"email": "[email protected]",
"display_name": "Alice Smith",
"status": "active",
"role": "member",
"created_at": "2026-03-25T12:00:00+00:00",
"updated_at": "2026-03-25T12:00:00+00:00",
"last_login_at": "2026-03-25T14:30:00+00:00",
"created_by": "admin-user-id",
"metadata": {}
}
```
**Errors:** `404` (user not found), `403` (not admin)
---
### PATCH /api/admin/users/{id}
Update a user's display name and/or metadata. Omitted fields are left unchanged.
**Auth:** Admin
**Request body:**
```json
{
"display_name": "Alice Johnson",
"metadata": {"department": "engineering"}
}
```
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `display_name` | string | no | |
| `role` | string | no | `"admin"` or `"member"` |
| `metadata` | object | no | Replaces entire metadata object (full replacement; keys not included are removed) |
**Response:** `200 OK` — returns the full updated user record (same shape as GET detail, without `last_login_at`/`created_by`).
**Errors:** `404` (user not found), `403` (not admin)
---
### POST /api/admin/users/{id}/suspend
Suspend a user. Suspended users cannot authenticate (DB auth checks user status).
**Auth:** Admin
**Response:** `200 OK`
```json
{
"id": "550e8400-...",
"status": "suspended"
}
```
**Errors:** `404` (user not found), `403` (not admin)
---
### POST /api/admin/users/{id}/activate
Re-activate a suspended user.
**Auth:** Admin
**Response:** `200 OK`
```json
{
"id": "550e8400-...",
"status": "active"
}
```
**Errors:** `404` (user not found), `403` (not admin)
---
### DELETE /api/admin/users/{id}
Permanently delete a user and all associated data (tokens, jobs, conversations, memory, routines, settings, secrets).
**Auth:** Admin
**Response:** `200 OK`
```json
{
"id": "550e8400-...",
"deleted": true
}
```
**Errors:** `404` (user not found), `403` (not admin)
**Cascade:** Deletes from `api_tokens`, `agent_jobs`, `conversations`, `memory_documents`, `routines`, `secrets`, `settings`, `wasm_tools`, and related tables. On PostgreSQL this uses FK cascades; on libSQL it uses explicit deletes.
---
## Admin: Per-User Secrets
Provision secrets on behalf of individual users. The primary use case is an application backend (acting as admin) that configures per-user credentials so each user's IronClaw agent can call back to external services.
Secrets are encrypted at rest with AES-256-GCM using a per-secret HKDF-derived key. Plaintext values are **never returned** by any endpoint — they can only be used by the agent's tool system at runtime.
### PUT /api/admin/users/{user_id}/secrets/{name}
Create or update a secret for the specified user. If a secret with the same name already exists, it is overwritten.
**Auth:** Admin
**Path parameters:**
| Param | Type | Notes |
|-------|------|-------|
| `user_id` | string | The user's ID |
| `name` | string | Secret name (normalized to lowercase) |
**Request body:**
```json
{
"value": "sk-live-abc123...",
"provider": "my-app-backend",
"expires_in_days": 90
}
```
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `value` | string | yes | The secret value (encrypted at rest, never returned) |
| `provider` | string | no | Tag for grouping (e.g. `"stripe"`, `"my-app"`) |
| `expires_in_days` | integer | no | Auto-expire after N days; `null` = never |
**Response:** `200 OK`
```json
{
"user_id": "550e8400-...",
"name": "my_app_callback_token",
"status": "created"
}
```
**Errors:** `400` (missing value), `403` (not admin), `503` (secrets store not available)
**Example — application backend provisioning a callback token:**
```bash
# Admin creates a user
curl -X POST https://ironclaw.example.com/api/admin/users \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-d '{"display_name": "Alice", "role": "member"}'
# Response includes: {"id": "alice-uuid", "token": "alice-bearer-token", ...}
# Admin provisions a per-user callback secret
curl -X PUT https://ironclaw.example.com/api/admin/users/alice-uuid/secrets/app_callback_token \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-d '{"value": "per-user-jwt-for-alice", "provider": "my-app"}'
# Now Alice's IronClaw agent can use the "app_callback_token" secret
# when calling tools that need to authenticate back to the app backend.
```
---
### GET /api/admin/users/{user_id}/secrets
List a user's secrets. Returns names and providers only — **never values or hashes**.
**Auth:** Admin
**Response:** `200 OK`
```json
{
"user_id": "550e8400-...",
"secrets": [
{"name": "app_callback_token", "provider": "my-app"},
{"name": "openai_api_key", "provider": "openai"}
]
}
```
---
### DELETE /api/admin/users/{user_id}/secrets/{name}
Delete a specific secret for a user.
**Auth:** Admin
**Response:** `200 OK`
```json
{
"user_id": "550e8400-...",
"name": "app_callback_token",
"deleted": true
}
```
**Errors:** `404` (secret not found), `403` (not admin), `503` (secrets store not available)
---
## Admin: Usage
### GET /api/admin/usage
Per-user LLM usage statistics aggregated from `llm_calls` via `agent_jobs.user_id`.
**Auth:** Admin
**Query parameters:**
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| `user_id` | string | all users | Filter to a single user |
| `period` | string | `"day"` | `"day"` (24h), `"week"` (7d), or `"month"` (30d) |
**Response:** `200 OK`
```json
{
"period": "week",
"since": "2026-03-18T12:00:00+00:00",
"usage": [
{
"user_id": "alice-id",
"model": "claude-sonnet-4-5-20250514",
"call_count": 42,
"input_tokens": 150000,
"output_tokens": 30000,
"total_cost": "1.23"
}
]
}
```
---
## Self-Service: Profile
### GET /api/profile
Get the authenticated user's own profile.
**Auth:** Any authenticated user
**Response:** `200 OK`
```json
{
"id": "550e8400-...",
"email": "[email protected]",
"display_name": "Alice Smith",
"status": "active",
"role": "member",
"created_at": "2026-03-25T12:00:00+00:00",
"last_login_at": "2026-03-25T14:30:00+00:00"
}
```
---
### PATCH /api/profile
Update the authenticated user's own display name and/or metadata.
**Auth:** Any authenticated user
**Request body:**
```json
{
"display_name": "Alice Johnson",
"metadata": {"theme": "dark"}
}
```
**Response:** `200 OK`
```json
{
"id": "550e8400-...",
"display_name": "Alice Johnson",
"updated": true
}
```
---
## Self-Service: Tokens
### POST /api/tokens
Create a new API token for the authenticated user. Admins can optionally create tokens for other users by including `user_id`.
**Auth:** Any authenticated user
**Request body:**
```json
{
"name": "CI pipeline",
"expires_in_days": 90,
"user_id": "other-user-id"
}
```
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `name` | string | yes | Human-readable label |
| `expires_in_days` | integer | no | `null` = never expires |
| `user_id` | string | no | Admin-only; create token for another user |
**Response:** `200 OK`
```json
{
"token": "a1b2c3d4...64-char hex...",
"id": "token-uuid",
"name": "CI pipeline",
"token_prefix": "a1b2c3d4",
"expires_at": "2026-06-23T12:00:00+00:00",
"created_at": "2026-03-25T12:00:00+00:00"
}
```
The `token` field is shown **only once**.
---
### GET /api/tokens
List the authenticated user's tokens. Token hashes are never returned.
**Auth:** Any authenticated user
**Response:** `200 OK`
```json
{
"tokens": [
{
"id": "token-uuid",
"name": "CI pipeline",
"token_prefix": "a1b2c3d4",
"expires_at": "2026-06-23T12:00:00+00:00",
"last_used_at": "2026-03-25T14:00:00+00:00",
"created_at": "2026-03-25T12:00:00+00:00",
"revoked_at": null
}
]
}
```
---
### DELETE /api/tokens/{id}
Revoke one of the authenticated user's tokens. Users can only revoke their own tokens.
**Auth:** Any authenticated user
**Path:** `id` — UUID of the token to revoke
**Response:** `200 OK`
```json
{
"status": "revoked",
"id": "token-uuid"
}
```
**Errors:** `400` (invalid UUID), `404` (token not found or belongs to another user)
---
## Error Format
All error responses return a plain text body with the error message and the corresponding HTTP status code:
| Code | Meaning |
|------|---------|
| `400` | Bad request (missing fields, invalid input) |
| `401` | Missing or invalid bearer token |
| `403` | Authenticated but insufficient role (member accessing admin endpoint) |
| `404` | Resource not found |
| `503` | Database or secrets store not available |
| `500` | Internal server error |
---
## Security Model
### Secrets Encryption
- **Algorithm:** AES-256-GCM with per-secret HKDF-SHA256 derived keys
- **Master key:** 32+ bytes, resolved from `SECRETS_MASTER_KEY` env var or OS keychain
- **Storage format:** `nonce (12B) || ciphertext || tag (16B)` in `encrypted_value` column
- **Per-secret salt:** 32 random bytes stored alongside the ciphertext
- **Zero-exposure:** Plaintext never appears in logs, debug output, API responses, or LLM conversations
### Auth Cache
- Bounded LRU cache (1024 entries max)
- 60-second TTL per entry
- Suspending a user or revoking a token takes up to 60s to propagate
---
## Database Schema
### users
| Column | Type (PG / libSQL) | Notes |
|--------|--------------------|-------|
| `id` | `TEXT` / `TEXT` | Primary key; typically UUID v4 strings (bootstrap admin may use a custom ID) |
| `email` | `TEXT UNIQUE` | Nullable |
| `display_name` | `TEXT NOT NULL` | |
| `status` | `TEXT NOT NULL` | `"active"` or `"suspended"` |
| `role` | `TEXT NOT NULL` | `"admin"` or `"member"` |
| `created_at` | `TIMESTAMPTZ` / `TEXT` | |
| `updated_at` | `TIMESTAMPTZ` / `TEXT` | |
| `last_login_at` | `TIMESTAMPTZ` / `TEXT` | Nullable |
| `created_by` | `TEXT` | Nullable, references `users.id` |
| `metadata` | `JSONB` / `TEXT` | Default `{}` |
### api_tokens
| Column | Type (PG / libSQL) | Notes |
|--------|--------------------|-------|
| `id` | `UUID` / `TEXT` | Primary key |
| `user_id` | `TEXT NOT NULL` | FK to `users.id` (PG cascades; libSQL explicit cleanup) |
| `token_hash` | `BYTEA` / `BLOB` | SHA-256 of hex-encoded plaintext |
| `token_prefix` | `TEXT NOT NULL` | First 8 chars for identification |
| `name` | `TEXT NOT NULL` | Human-readable label |
| `expires_at` | `TIMESTAMPTZ` / `TEXT` | Nullable |
| `last_used_at` | `TIMESTAMPTZ` / `TEXT` | Nullable |
| `created_at` | `TIMESTAMPTZ` / `TEXT` | |
| `revoked_at` | `TIMESTAMPTZ` / `TEXT` | Nullable; set on revocation |
### secrets
| Column | Type (PG / libSQL) | Notes |
|--------|--------------------|-------|
| `id` | `UUID` / `TEXT` | Primary key |
| `user_id` | `TEXT NOT NULL` | Scoped to user |
| `name` | `TEXT NOT NULL` | Unique per user (lowercase normalized) |
| `encrypted_value` | `BYTEA` / `BLOB` | AES-256-GCM (nonce + ciphertext + tag) |
| `key_salt` | `BYTEA` / `BLOB` | Per-secret HKDF salt |
| `provider` | `TEXT` | Optional grouping tag |
| `expires_at` | `TIMESTAMPTZ` / `TEXT` | Nullable |
| `last_used_at` | `TIMESTAMPTZ` / `TEXT` | Audit: last injection time |
| `usage_count` | `BIGINT` / `INTEGER` | Audit: total injections |
| `created_at` | `TIMESTAMPTZ` / `TEXT` | |
| `updated_at` | `TIMESTAMPTZ` / `TEXT` | |
-31
View File
@@ -1,31 +0,0 @@
-- User management tables for multi-tenant deployments.
--
-- Replaces the static GATEWAY_USER_TOKENS env var with DB-backed
-- user registration, API token management, and invitation flow.
CREATE TABLE users (
id TEXT PRIMARY KEY, -- matches existing user_id pattern (string, not UUID)
email TEXT UNIQUE, -- nullable for token-only users
display_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', -- active | suspended | deactivated
role TEXT NOT NULL DEFAULT 'member', -- admin | member
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_login_at TIMESTAMPTZ,
created_by TEXT REFERENCES users(id), -- who invited this user (nullable for bootstrap)
metadata JSONB NOT NULL DEFAULT '{}' -- extensible profile data
);
CREATE TABLE api_tokens (
id UUID PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash BYTEA NOT NULL, -- SHA-256 hash (never store plaintext)
token_prefix TEXT NOT NULL, -- first 8 hex chars for display
name TEXT NOT NULL, -- human label ("my-laptop", "ci-bot")
expires_at TIMESTAMPTZ, -- nullable = never expires
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
revoked_at TIMESTAMPTZ -- soft-revoke: set this instead of deleting
);
CREATE INDEX idx_api_tokens_user ON api_tokens(user_id);
CREATE INDEX idx_api_tokens_hash ON api_tokens(token_hash);
-23
View File
@@ -1,23 +0,0 @@
-- Document version history for workspace files.
-- Every content update saves the previous content as a version,
-- enabling rollback and audit trails.
CREATE TABLE memory_document_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID NOT NULL REFERENCES memory_documents(id) ON DELETE CASCADE,
version INTEGER NOT NULL,
content TEXT NOT NULL,
content_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
changed_by TEXT,
UNIQUE(document_id, version)
);
CREATE INDEX idx_doc_versions_lookup
ON memory_document_versions(document_id, version DESC);
-- GIN index on metadata for JSON path queries (used by hygiene to find
-- .config documents with hygiene.enabled). The metadata column already
-- exists (V1) but was never indexed.
CREATE INDEX idx_memory_documents_metadata
ON memory_documents USING GIN (metadata jsonb_path_ops);
+5 -45
View File
@@ -345,25 +345,13 @@ impl Agent {
.map(|db| crate::tenant::TenantScope::new(user_id, Arc::clone(db)));
// Reuse the owner workspace if user matches, otherwise create per-user.
// Per-user workspaces are seeded on first creation so they get identity
// files and BOOTSTRAP.md (which triggers the onboarding greeting).
let workspace = match &self.deps.workspace {
Some(ws) if ws.user_id() == user_id => Some(Arc::clone(ws)),
_ => {
if let Some(db) = self.deps.store.as_ref() {
let ws = Arc::new(Workspace::new_with_db(user_id, Arc::clone(db)));
if let Err(e) = ws.seed_if_empty().await {
tracing::warn!(
user_id = user_id,
"Failed to seed per-user workspace: {}",
e
);
}
Some(ws)
} else {
None
}
}
_ => self
.deps
.store
.as_ref()
.map(|db| Arc::new(Workspace::new_with_db(user_id, Arc::clone(db)))),
};
crate::tenant::TenantCtx::new(
@@ -1275,34 +1263,6 @@ impl Agent {
// Build per-tenant execution context once; threaded through all handlers.
let tenant = self.tenant_ctx(&message.user_id).await;
// Per-user bootstrap: if this user's workspace was just seeded (fresh),
// persist the static greeting to their assistant conversation and
// broadcast it so the web client shows it immediately.
if tenant
.workspace()
.is_some_and(|ws| ws.take_bootstrap_pending())
{
tracing::info!(
user_id = message.user_id,
"Fresh user workspace — persisting bootstrap greeting"
);
if let Some(store) = tenant.store()
&& let Ok(conv_id) = store
.get_or_create_assistant_conversation(&message.channel)
.await
{
let _ = store
.add_conversation_message(conv_id, "assistant", BOOTSTRAP_GREETING)
.await;
let mut out = OutgoingResponse::text(BOOTSTRAP_GREETING.to_string());
out.thread_id = Some(conv_id.to_string());
let _ = self
.channels
.broadcast(&message.channel, &message.user_id, out)
.await;
}
}
let session_for_empty_exit = Arc::clone(&session);
// Process based on submission type
+9 -35
View File
@@ -400,21 +400,16 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
};
// Record cost and track token usage (global + per-user).
// Use the provider's effective_model_name so cost attribution matches
// the model that actually served the request. When the override is
// honoured (e.g. NearAI), this returns the override name; when the
// provider ignores overrides (e.g. Rig-based), it returns the active
// model, keeping attribution accurate in both cases.
let model_name = self
.agent
.llm()
.effective_model_name(reason_ctx.model_override.as_deref());
let cost_per_token = if reason_ctx.model_override.is_some() {
// Override may use different pricing; let CostGuard fall back to
// costs::model_cost() for the effective model.
None
// When a model override is active, use the override name for attribution
// and let CostGuard look up pricing via costs::model_cost() instead of
// using the default provider's cost_per_token (which reflects the wrong model).
let (model_name, cost_per_token) = if let Some(ref ovr) = reason_ctx.model_override {
(ovr.clone(), None)
} else {
Some(self.agent.llm().cost_per_token())
(
self.agent.llm().active_model_name(),
Some(self.agent.llm().cost_per_token()),
)
};
let read_discount = self.agent.llm().cache_read_discount();
let write_multiplier = self.agent.llm().cache_write_multiplier();
@@ -438,24 +433,6 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
call_cost,
);
// Persist LLM call to DB so usage stats survive restarts.
// Chat turns don't create agent_jobs, so job_id is None.
if let Some(store) = self.tenant.store() {
let record = crate::history::LlmCallRecord {
job_id: None,
conversation_id: Some(self.thread_id),
provider: &self.agent.deps.llm_backend,
model: &model_name,
input_tokens: output.usage.input_tokens,
output_tokens: output.usage.output_tokens,
cost: call_cost,
purpose: Some("chat"),
};
if let Err(e) = store.record_llm_call(&record).await {
tracing::warn!("Failed to persist LLM call to DB: {}", e);
}
}
Ok(output)
}
@@ -1383,7 +1360,6 @@ mod tests {
max_tool_iterations: 50,
auto_approve_tools: false,
default_timezone: "UTC".to_string(),
max_jobs_per_user: None,
max_tokens_per_job: 0,
multi_tenant: false,
max_llm_concurrent_per_user: None,
@@ -2265,7 +2241,6 @@ mod tests {
max_tool_iterations,
auto_approve_tools: true,
default_timezone: "UTC".to_string(),
max_jobs_per_user: None,
max_tokens_per_job: 0,
multi_tenant: false,
max_llm_concurrent_per_user: None,
@@ -2393,7 +2368,6 @@ mod tests {
max_tool_iterations: max_iter,
auto_approve_tools: true,
default_timezone: "UTC".to_string(),
max_jobs_per_user: None,
max_tokens_per_job: 0,
multi_tenant: false,
max_llm_concurrent_per_user: None,
+10 -29
View File
@@ -276,8 +276,8 @@ impl HeartbeatRunner {
.await;
if report.had_work() {
tracing::info!(
directories_cleaned = ?report.directories_cleaned,
versions_pruned = report.versions_pruned,
daily_logs_deleted = report.daily_logs_deleted,
conversation_docs_deleted = report.conversation_docs_deleted,
"heartbeat: memory hygiene deleted stale documents"
);
}
@@ -400,7 +400,7 @@ impl HeartbeatRunner {
}
/// Send a notification about heartbeat findings.
async fn send_notification(&self, message: &str) {
pub(crate) async fn send_notification(&self, message: &str) {
let Some(ref tx) = self.response_tx else {
tracing::debug!("No response channel configured for heartbeat notifications");
return;
@@ -512,8 +512,8 @@ pub fn spawn_heartbeat(
})
}
/// Spawn a multi-user heartbeat runner that cycles through all users who
/// have routines (enabled or not). Each tick, it queries the DB for distinct
/// Spawn a multi-user heartbeat runner that cycles through all users that
/// own routines (enabled or not). Each tick, it queries the DB for distinct
/// user_ids, creates a per-user workspace, and runs a heartbeat check for
/// each user concurrently. Per-user failure counts are tracked independently.
pub fn spawn_multi_user_heartbeat(
@@ -574,10 +574,8 @@ pub fn spawn_multi_user_heartbeat(
}
};
// Run user heartbeats (and hygiene) concurrently so one slow LLM
// call doesn't block others. Cap concurrency to avoid flooding the
// LLM provider. Hygiene runs inside the same JoinSet so it is
// tracked and bounded by the same concurrency cap.
// Run user heartbeats concurrently so one slow LLM call doesn't
// block others. Cap concurrency to avoid flooding the LLM provider.
const MAX_CONCURRENT_HEARTBEATS: usize = 8;
let mut join_set = tokio::task::JoinSet::new();
@@ -600,8 +598,8 @@ pub fn spawn_multi_user_heartbeat(
if report.had_work() {
tracing::info!(
user_id = hygiene_user,
directories_cleaned = ?report.directories_cleaned,
versions_pruned = report.versions_pruned,
daily_logs_deleted = report.daily_logs_deleted,
conversation_docs_deleted = report.conversation_docs_deleted,
"multi-user heartbeat: memory hygiene deleted stale documents"
);
}
@@ -615,30 +613,13 @@ pub fn spawn_multi_user_heartbeat(
}
let uid = user_id.clone();
// In multi-tenant mode, clear notify_user_id so that
// HeartbeatRunner::send_notification falls back to
// workspace.user_id() — each user's heartbeat should persist
// and notify that user, not the shared config target.
let mut cfg = config.clone();
cfg.notify_user_id = None;
let cfg = config.clone();
let hyg = hygiene_config.clone();
let llm_clone = llm.clone();
let tx = response_tx.clone();
let admin = store.clone();
join_set.spawn(async move {
// Run memory hygiene per user (same as single-user heartbeat)
// inside the tracked task so concurrency is bounded.
let report = crate::workspace::hygiene::run_if_due(&workspace, &hyg).await;
if report.had_work() {
tracing::info!(
user_id = uid,
directories_cleaned = ?report.directories_cleaned,
versions_pruned = report.versions_pruned,
"multi-user heartbeat: memory hygiene deleted stale documents"
);
}
let mut runner = HeartbeatRunner::new(cfg, hyg, workspace, llm_clone);
if let Some(tx) = tx {
runner = runner.with_response_channel(tx);
-15
View File
@@ -267,20 +267,6 @@ impl Scheduler {
});
}
// Per-user concurrency check — only count jobs consuming a parallel
// execution slot (Pending/InProgress/Stuck), not Completed/Submitted.
if let Some(max_per_user) = self.config.max_jobs_per_user
&& let Ok(ctx) = self.context_manager.get_context(job_id).await
{
let user_blocking = self
.context_manager
.parallel_blocking_count_for(&ctx.user_id)
.await;
if user_blocking >= max_per_user {
return Err(JobError::MaxJobsExceeded { max: max_per_user });
}
}
// Transition job to in_progress
self.context_manager
.update_context(job_id, |ctx| {
@@ -798,7 +784,6 @@ mod tests {
max_tool_iterations: 10,
auto_approve_tools: true,
default_timezone: "UTC".to_string(),
max_jobs_per_user: None,
max_tokens_per_job,
multi_tenant: false,
max_llm_concurrent_per_user: None,
+7 -2
View File
@@ -336,12 +336,17 @@ impl AppBuilder {
ws = ws.with_memory_layers(self.config.workspace.memory_layers.clone());
let ws = Arc::new(ws);
// Detect multi-tenant mode: when the database has registered users,
// 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 = db.has_any_users().await.unwrap_or(false);
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(
-28
View File
@@ -91,34 +91,6 @@ Browser-facing HTTP API and SSE/WebSocket real-time streaming. Axum-based, singl
| DELETE | `/api/routines/{id}` | Delete a routine |
| GET | `/api/routines/{id}/runs` | List runs for a specific routine |
### User Management (admin — requires `admin` role, see `docs/USER_MANAGEMENT_API.md`)
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/admin/users` | Create a new user (returns one-time token) |
| GET | `/api/admin/users` | List all users |
| GET | `/api/admin/users/{id}` | Get a single user |
| PATCH | `/api/admin/users/{id}` | Update user profile/metadata |
| DELETE | `/api/admin/users/{id}` | Delete user and all data |
| POST | `/api/admin/users/{id}/suspend` | Suspend a user |
| POST | `/api/admin/users/{id}/activate` | Re-activate a user |
| GET | `/api/admin/usage` | Per-user LLM usage stats |
| GET | `/api/admin/users/{user_id}/secrets` | List a user's secrets (names only) |
| PUT | `/api/admin/users/{user_id}/secrets/{name}` | Create or update a user's secret |
| DELETE | `/api/admin/users/{user_id}/secrets/{name}` | Delete a user's secret |
### Profile (self-service)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/profile` | Get own profile |
| PATCH | `/api/profile` | Update own display name/metadata |
### Tokens (self-service)
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/tokens` | Create API token (returns plaintext once) |
| GET | `/api/tokens` | List own tokens |
| DELETE | `/api/tokens/{id}` | Revoke a token |
### Settings
| Method | Path | Description |
|--------|------|-------------|
+20 -220
View File
@@ -5,7 +5,6 @@
//! handlers can extract it via `AuthenticatedUser`.
use std::collections::HashMap;
use std::num::NonZeroUsize;
use axum::{
extract::{FromRequestParts, Request, State},
@@ -14,25 +13,18 @@ use axum::{
response::{IntoResponse, Response},
};
use sha2::{Digest, Sha256};
use std::sync::Arc;
use std::time::Instant;
use subtle::ConstantTimeEq;
use tokio::sync::RwLock;
use crate::db::Database;
/// Identity resolved from a bearer token.
#[derive(Debug, Clone)]
pub struct UserIdentity {
pub user_id: String,
/// `admin` or `member`.
pub role: String,
/// Additional user scopes this identity can read from.
pub workspace_read_scopes: Vec<String>,
}
/// Hash a token with SHA-256 for constant-size, timing-safe storage.
pub fn hash_token(token: &str) -> [u8; 32] {
fn hash_token(token: &str) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
hasher.finalize().into()
@@ -64,7 +56,6 @@ impl MultiAuthState {
hash,
UserIdentity {
user_id,
role: "admin".to_string(),
workspace_read_scopes: Vec::new(),
},
)],
@@ -73,11 +64,6 @@ impl MultiAuthState {
}
/// Create a multi-user auth state from a map of tokens to identities.
///
/// **Test-only** — production multi-user auth is DB-backed via
/// `DbAuthenticator`. This constructor is kept public (not `#[cfg(test)]`)
/// because integration tests in `tests/` compile the crate as a library
/// where `cfg(test)` is not set.
pub fn multi(tokens: HashMap<String, UserIdentity>) -> Self {
let hashed_tokens: Vec<([u8; 32], UserIdentity)> = tokens
.into_iter()
@@ -122,131 +108,6 @@ impl MultiAuthState {
}
}
/// DB-backed token authenticator with a bounded LRU cache.
///
/// Checks an LRU cache first (TTL 60s), then falls back to a DB query.
/// The cache is bounded to `MAX_CACHE_ENTRIES` — when full, the least
/// recently used entry is evicted regardless of TTL.
///
/// Revoking a token or suspending a user has at most 60s of stale
/// authentication before the cache entry expires.
#[derive(Clone)]
#[allow(clippy::type_complexity)]
pub struct DbAuthenticator {
store: Arc<dyn Database>,
/// Bounded LRU cache: token_hash → (identity, inserted_at).
cache: Arc<RwLock<lru::LruCache<[u8; 32], (UserIdentity, Instant)>>>,
}
impl DbAuthenticator {
/// Cache TTL — how long a successful auth is cached before re-querying the DB.
const CACHE_TTL_SECS: u64 = 60;
/// Maximum cache entries to prevent unbounded growth.
// SAFETY: 1024 is non-zero, so the unwrap in `new()` is infallible.
const MAX_CACHE_ENTRIES: NonZeroUsize = match NonZeroUsize::new(1024) {
Some(v) => v,
None => unreachable!(),
};
pub fn new(store: Arc<dyn Database>) -> Self {
Self {
store,
cache: Arc::new(RwLock::new(lru::LruCache::new(Self::MAX_CACHE_ENTRIES))),
}
}
/// Evict all cached entries for a specific user.
///
/// Call this after security-critical actions (suspend, activate, role
/// change, token revocation) so the change takes effect immediately
/// instead of waiting for the 60-second TTL to expire.
pub async fn invalidate_user(&self, user_id: &str) {
let mut cache = self.cache.write().await;
// LruCache doesn't support predicate-based removal, so collect keys
// first then remove. The cache is bounded (1024) so this is cheap.
let keys_to_remove: Vec<[u8; 32]> = cache
.iter()
.filter(|(_, (identity, _))| identity.user_id == user_id)
.map(|(k, _)| *k)
.collect();
for key in keys_to_remove {
cache.pop(&key);
}
}
/// Authenticate a token against the database, using cache when possible.
///
/// Returns `Ok(Some(identity))` on success, `Ok(None)` if the token is
/// not found, or `Err(())` if the database is unreachable (so the caller
/// can return 503 instead of 401).
pub async fn authenticate(&self, candidate: &str) -> Result<Option<UserIdentity>, ()> {
let hash = hash_token(candidate);
// Check cache first (promotes to most-recent on hit)
{
let mut cache = self.cache.write().await;
if let Some((identity, inserted_at)) = cache.get(&hash) {
if inserted_at.elapsed().as_secs() < Self::CACHE_TTL_SECS {
return Ok(Some(identity.clone()));
}
// Expired — remove stale entry
cache.pop(&hash);
}
}
// Cache miss or expired — query DB
let (token_record, user_record) = match self.store.authenticate_token(&hash).await {
Ok(Some(pair)) => pair,
Ok(None) => return Ok(None),
Err(e) => {
tracing::warn!("DB auth lookup failed: {e}");
return Err(());
}
};
let identity = UserIdentity {
user_id: user_record.id.clone(),
role: user_record.role.clone(),
workspace_read_scopes: Vec::new(),
};
// Record token usage (best-effort, don't block auth)
let store = self.store.clone();
let token_id = token_record.id;
let user_id = user_record.id;
tokio::spawn(async move {
let _ = store.record_token_usage(token_id).await;
let _ = store.record_login(&user_id).await;
});
// Insert into bounded LRU — if full, least-recently-used entry is evicted
{
let mut cache = self.cache.write().await;
cache.put(hash, (identity.clone(), Instant::now()));
}
Ok(Some(identity))
}
}
/// Combined auth state: tries env-var tokens first, then DB-backed tokens.
#[derive(Clone)]
pub struct CombinedAuthState {
/// In-memory tokens from GATEWAY_AUTH_TOKEN.
pub env_auth: MultiAuthState,
/// DB-backed token authenticator (optional — only when a database is available).
pub db_auth: Option<DbAuthenticator>,
}
impl From<MultiAuthState> for CombinedAuthState {
fn from(env_auth: MultiAuthState) -> Self {
Self {
env_auth,
db_auth: None,
}
}
}
/// Axum extractor that provides the authenticated user identity.
///
/// Only available on routes behind `auth_middleware`. Extracts the
@@ -269,31 +130,6 @@ where
}
}
/// Axum extractor that requires the authenticated user to have the `admin` role.
///
/// Use instead of `AuthenticatedUser` on endpoints that modify system-wide
/// state (user management, model selection, extension/skill installation).
pub struct AdminUser(pub UserIdentity);
impl<S> FromRequestParts<S> for AdminUser
where
S: Send + Sync,
{
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let identity = parts
.extensions
.get::<UserIdentity>()
.cloned()
.ok_or((StatusCode::UNAUTHORIZED, "Not authenticated"))?;
if identity.role != "admin" {
return Err((StatusCode::FORBIDDEN, "Admin role required"));
}
Ok(AdminUser(identity))
}
}
/// Whether query-string token auth is allowed for this request.
///
/// Only GET requests to streaming endpoints may use `?token=xxx`. This
@@ -330,65 +166,39 @@ fn query_token(request: &Request) -> Option<String> {
/// Auth middleware that validates bearer token from header or query param.
///
/// Tries env-var tokens first (constant-time, in-memory), then falls back
/// to DB-backed token lookup if configured. SSE connections can't set
/// headers from `EventSource`, so we also accept `?token=xxx` as a query
/// parameter, but only on SSE/WS endpoints.
/// SSE connections can't set headers from `EventSource`, so we also accept
/// `?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<CombinedAuthState>,
State(auth): State<MultiAuthState>,
headers: HeaderMap,
mut request: Request,
next: Next,
) -> Response {
// Extract the candidate token from header or query param.
let token = extract_token(&headers, &request);
if let Some(ref tok) = token {
// 1. Try env-var tokens first (fast, constant-time, in-memory).
if let Some(identity) = auth.env_auth.authenticate(tok) {
request.extensions_mut().insert(identity.clone());
return next.run(request).await;
}
// 2. Fall back to DB-backed token lookup.
if let Some(ref db_auth) = auth.db_auth {
match db_auth.authenticate(tok).await {
Ok(Some(identity)) => {
request.extensions_mut().insert(identity);
return next.run(request).await;
}
Err(()) => {
return (StatusCode::SERVICE_UNAVAILABLE, "Database unavailable")
.into_response();
}
Ok(None) => {}
}
}
}
(StatusCode::UNAUTHORIZED, "Invalid or missing auth token").into_response()
}
/// Extract a bearer token from the Authorization header or query parameter.
fn extract_token(headers: &HeaderMap, request: &Request) -> Option<String> {
// Try Authorization header first (RFC 6750).
// 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 ")
&& let Some(identity) = auth.authenticate(&value[7..])
{
return Some(value[7..].to_string());
request.extensions_mut().insert(identity.clone());
return next.run(request).await;
}
// Fall back to query parameter for SSE/WS endpoints.
if allows_query_token_auth(request) {
return query_token(request);
// Fall back to query parameter, but only for SSE/WS endpoints.
if allows_query_token_auth(&request)
&& let Some(token) = query_token(&request)
&& let Some(identity) = auth.authenticate(&token)
{
request.extensions_mut().insert(identity.clone());
return next.run(request).await;
}
None
(StatusCode::UNAUTHORIZED, "Invalid or missing auth token").into_response()
}
#[cfg(test)]
@@ -417,7 +227,6 @@ mod tests {
"tok-alice".to_string(),
UserIdentity {
user_id: "alice".to_string(),
role: "admin".to_string(),
workspace_read_scopes: Vec::new(),
},
);
@@ -425,7 +234,6 @@ mod tests {
"tok-bob".to_string(),
UserIdentity {
user_id: "bob".to_string(),
role: "admin".to_string(),
workspace_read_scopes: Vec::new(),
},
);
@@ -466,10 +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 = CombinedAuthState::from(MultiAuthState::single(
token.to_string(),
"test-user".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))
@@ -681,7 +486,7 @@ mod tests {
/// Build a multi-user router where each token maps to a distinct identity.
fn multi_user_app(tokens: HashMap<String, UserIdentity>) -> Router {
let state = CombinedAuthState::from(MultiAuthState::multi(tokens));
let state = MultiAuthState::multi(tokens);
Router::new()
.route("/api/chat/events", get(identity_handler))
.route("/api/chat/send", post(identity_handler))
@@ -695,7 +500,6 @@ mod tests {
"tok-alice".to_string(),
UserIdentity {
user_id: "alice".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec!["shared".to_string()],
},
);
@@ -703,7 +507,6 @@ mod tests {
"tok-bob".to_string(),
UserIdentity {
user_id: "bob".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec!["shared".to_string(), "alice".to_string()],
},
);
@@ -840,10 +643,7 @@ mod tests {
#[tokio::test]
async fn test_multi_user_empty_scopes_for_single_user() {
// Single-user mode creates identity with empty workspace_read_scopes.
let state = CombinedAuthState::from(MultiAuthState::single(
"tok-only".to_string(),
"solo".to_string(),
));
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));
-149
View File
@@ -1,149 +0,0 @@
//! Frontend extension API handlers.
//!
//! Provides endpoints for reading/writing layout configuration and
//! discovering/serving widget files from the workspace.
use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
http::{StatusCode, header},
response::IntoResponse,
};
use ironclaw_frontend::{LayoutConfig, WidgetManifest};
use crate::channels::web::auth::AuthenticatedUser;
use crate::channels::web::handlers::memory::resolve_workspace;
use crate::channels::web::server::GatewayState;
/// `GET /api/frontend/layout` — return the current layout configuration.
///
/// Reads `frontend/layout.json` from the workspace. Returns an empty
/// default config if the file doesn't exist.
pub async fn frontend_layout_handler(
State(state): State<Arc<GatewayState>>,
AuthenticatedUser(user): AuthenticatedUser,
) -> Result<Json<LayoutConfig>, (StatusCode, String)> {
let workspace = resolve_workspace(&state, &user).await?;
let layout = match workspace.read("frontend/layout.json").await {
Ok(doc) => serde_json::from_str(&doc.content).unwrap_or_default(),
Err(_) => LayoutConfig::default(),
};
Ok(Json(layout))
}
/// `PUT /api/frontend/layout` — update the layout configuration.
///
/// Writes the provided layout config to `frontend/layout.json` in workspace.
pub async fn frontend_layout_update_handler(
State(state): State<Arc<GatewayState>>,
AuthenticatedUser(user): AuthenticatedUser,
Json(layout): Json<LayoutConfig>,
) -> Result<StatusCode, (StatusCode, String)> {
let workspace = resolve_workspace(&state, &user).await?;
let content = serde_json::to_string_pretty(&layout).map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("Invalid layout config: {e}"),
)
})?;
workspace
.write("frontend/layout.json", &content)
.await
.map_err(|e| {
tracing::error!("Failed to write layout config: {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to write layout config".to_string(),
)
})?;
Ok(StatusCode::OK)
}
/// `GET /api/frontend/widgets` — list all widget manifests.
///
/// Scans `frontend/widgets/` in workspace for directories containing
/// `manifest.json` and returns their parsed manifests.
pub async fn frontend_widgets_handler(
State(state): State<Arc<GatewayState>>,
AuthenticatedUser(user): AuthenticatedUser,
) -> Result<Json<Vec<WidgetManifest>>, (StatusCode, String)> {
let workspace = resolve_workspace(&state, &user).await?;
let entries = workspace
.list("frontend/widgets/")
.await
.unwrap_or_default();
let mut manifests = Vec::new();
for entry in entries {
if !entry.is_directory {
continue;
}
let manifest_path = format!("frontend/widgets/{}/manifest.json", entry.name());
if let Ok(doc) = workspace.read(&manifest_path).await {
match serde_json::from_str::<WidgetManifest>(&doc.content) {
Ok(manifest) => manifests.push(manifest),
Err(e) => {
tracing::warn!(
path = %manifest_path,
"skipping widget with invalid manifest: {e}"
);
}
}
}
}
Ok(Json(manifests))
}
/// `GET /api/frontend/widget/{id}/{*file}` — serve a widget file.
///
/// Serves JS/CSS files from `frontend/widgets/{id}/{file}` in workspace
/// with appropriate MIME types.
pub async fn frontend_widget_file_handler(
State(state): State<Arc<GatewayState>>,
AuthenticatedUser(user): AuthenticatedUser,
Path((id, file)): Path<(String, String)>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
// Reject path traversal
if id.contains("..") || file.contains("..") {
return Err((StatusCode::BAD_REQUEST, "Invalid path".to_string()));
}
let workspace = resolve_workspace(&state, &user).await?;
let path = format!("frontend/widgets/{}/{}", id, file);
let doc = workspace.read(&path).await.map_err(|_| {
(
StatusCode::NOT_FOUND,
format!("Widget file not found: {path}"),
)
})?;
// Determine MIME type from extension
let content_type = if file.ends_with(".js") {
"application/javascript"
} else if file.ends_with(".css") {
"text/css"
} else if file.ends_with(".json") {
"application/json"
} else {
"text/plain"
};
Ok((
[
(header::CONTENT_TYPE, content_type),
(header::CACHE_CONTROL, "no-cache"),
],
doc.content,
))
}
+32 -30
View File
@@ -15,14 +15,6 @@ use crate::channels::web::auth::AuthenticatedUser;
use crate::channels::web::server::GatewayState;
use crate::channels::web::types::*;
fn db_error(context: &str, e: impl std::fmt::Display) -> (StatusCode, String) {
tracing::error!(%e, context, "Database error in jobs handler");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Internal database error".to_string(),
)
}
pub async fn jobs_list_handler(
State(state): State<Arc<GatewayState>>,
AuthenticatedUser(user): AuthenticatedUser,
@@ -221,7 +213,10 @@ pub async fn jobs_detail_handler(
}
Ok(None) => {}
Err(e) => {
return Err(db_error("jobs_handler", e));
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
));
}
}
@@ -262,7 +257,10 @@ pub async fn jobs_detail_handler(
}))
}
Ok(None) => Err((StatusCode::NOT_FOUND, "Job not found".to_string())),
Err(e) => Err(db_error("jobs_handler", e)),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)),
}
}
@@ -306,7 +304,10 @@ pub async fn jobs_cancel_handler(
}
Ok(None) => {}
Err(e) => {
return Err(db_error("jobs_handler", e));
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
));
}
}
}
@@ -349,7 +350,10 @@ pub async fn jobs_cancel_handler(
}
Ok(None) => {}
Err(e) => {
return Err(db_error("jobs_handler", e));
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
));
}
}
}
@@ -467,7 +471,10 @@ pub async fn jobs_restart_handler(
}
Ok(None) => {}
Err(e) => {
return Err(db_error("jobs_handler", e));
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
));
}
}
@@ -523,7 +530,10 @@ pub async fn jobs_restart_handler(
})))
}
Ok(None) => Err((StatusCode::NOT_FOUND, "Job not found".to_string())),
Err(e) => Err(db_error("jobs_handler", e)),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
)),
}
}
@@ -599,7 +609,10 @@ pub async fn jobs_prompt_handler(
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
}
Err(e) => {
return Err(db_error("jobs_handler", e));
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
));
}
}
}
@@ -654,7 +667,10 @@ pub async fn jobs_events_handler(
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
}
Err(e) => {
return Err(db_error("jobs_handler", e));
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {}", e),
));
}
}
@@ -807,17 +823,3 @@ pub async fn job_files_read_handler(
content,
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_db_error_does_not_leak_details() {
let (status, body) = db_error("test_context", "relation \"jobs\" does not exist");
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(body, "Internal database error");
assert!(!body.contains("relation"));
assert!(!body.contains("does not exist"));
}
}
-4
View File
@@ -5,10 +5,7 @@
pub mod jobs;
pub mod memory;
pub mod routines;
pub mod secrets;
pub mod skills;
pub mod tokens;
pub mod users;
// Modules not yet wired into server.rs router -- suppress dead_code until
// they replace their inline counterparts.
@@ -16,7 +13,6 @@ pub mod users;
pub mod chat;
#[allow(dead_code)]
pub mod extensions;
pub mod frontend;
#[allow(dead_code)]
pub mod settings;
#[allow(dead_code)]
-183
View File
@@ -1,183 +0,0 @@
//! Admin secrets provisioning handlers.
//!
//! Allows an admin (typically an application backend) to create, list, and
//! delete secrets on behalf of individual users so their IronClaw agent can
//! call back to external services with per-user credentials.
use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use crate::channels::web::auth::AdminUser;
use crate::channels::web::server::GatewayState;
use crate::secrets::CreateSecretParams;
/// PUT /api/admin/users/{user_id}/secrets/{name} — create or update a secret.
///
/// Upserts: if a secret with the same (user_id, name) already exists it is
/// overwritten. The plaintext value is encrypted at rest (AES-256-GCM) and
/// never returned by any endpoint.
pub async fn secrets_put_handler(
State(state): State<Arc<GatewayState>>,
AdminUser(_admin): AdminUser,
Path((user_id, name)): Path<(String, String)>,
Json(body): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let name = name.to_lowercase();
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
store
.get_user(&user_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
let secrets = state.secrets_store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Secrets store not available".to_string(),
))?;
let value = body
.get("value")
.and_then(|v| v.as_str())
.ok_or((
StatusCode::BAD_REQUEST,
"Missing required field 'value'".to_string(),
))?
.to_string();
let provider = body
.get("provider")
.and_then(|v| v.as_str())
.map(String::from);
let expires_in_days = body.get("expires_in_days").and_then(|v| v.as_u64());
if let Some(days) = expires_in_days
&& days > 36500
{
return Err((
StatusCode::BAD_REQUEST,
"expires_in_days must be at most 36500".to_string(),
));
}
let expires_at =
expires_in_days.map(|days| chrono::Utc::now() + chrono::Duration::days(days as i64));
let mut params = CreateSecretParams::new(name.clone(), value);
if let Some(p) = provider {
params = params.with_provider(p);
}
if let Some(exp) = expires_at {
params = params.with_expiry(exp);
}
let already_exists = secrets
.exists(&user_id, &name)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
secrets
.create(&user_id, params)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(serde_json::json!({
"user_id": user_id,
"name": name,
"status": if already_exists { "updated" } else { "created" },
})))
}
/// GET /api/admin/users/{user_id}/secrets — list a user's secrets (names only).
///
/// Never returns secret values or hashes.
pub async fn secrets_list_handler(
State(state): State<Arc<GatewayState>>,
AdminUser(_admin): AdminUser,
Path(user_id): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
// Verify the target user exists (consistent with PUT/DELETE).
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
if store
.get_user(&user_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.is_none()
{
return Err((StatusCode::NOT_FOUND, "User not found".to_string()));
}
let secrets = state.secrets_store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Secrets store not available".to_string(),
))?;
let refs = secrets
.list(&user_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let secrets_json: Vec<serde_json::Value> = refs
.into_iter()
.map(|r| {
serde_json::json!({
"name": r.name,
"provider": r.provider,
})
})
.collect();
Ok(Json(serde_json::json!({
"user_id": user_id,
"secrets": secrets_json,
})))
}
/// DELETE /api/admin/users/{user_id}/secrets/{name} — delete a user's secret.
pub async fn secrets_delete_handler(
State(state): State<Arc<GatewayState>>,
AdminUser(_admin): AdminUser,
Path((user_id, name)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let name = name.to_lowercase();
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
store
.get_user(&user_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
let secrets = state.secrets_store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Secrets store not available".to_string(),
))?;
let deleted = secrets
.delete(&user_id, &name)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if !deleted {
return Err((StatusCode::NOT_FOUND, "Secret not found".to_string()));
}
Ok(Json(serde_json::json!({
"user_id": user_id,
"name": name,
"deleted": true,
})))
}
+3 -3
View File
@@ -13,20 +13,20 @@ use crate::channels::web::types::*;
// --- Static file handlers ---
pub async fn index_handler() -> Html<&'static str> {
Html(ironclaw_frontend::assets::INDEX_HTML)
Html(include_str!("../static/index.html"))
}
pub async fn css_handler() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "text/css")],
ironclaw_frontend::assets::STYLE_CSS,
include_str!("../static/style.css"),
)
}
pub async fn js_handler() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "application/javascript")],
ironclaw_frontend::assets::APP_JS,
include_str!("../static/app.js"),
)
}
-163
View File
@@ -1,163 +0,0 @@
//! API token management handlers.
use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use rand::RngCore;
use rand::rngs::OsRng;
use uuid::Uuid;
use crate::channels::web::auth::AuthenticatedUser;
use crate::channels::web::server::GatewayState;
/// POST /api/tokens — create a new API token (returns plaintext ONCE).
pub async fn tokens_create_handler(
State(state): State<Arc<GatewayState>>,
AuthenticatedUser(user): AuthenticatedUser,
Json(body): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
let name = body
.get("name")
.and_then(|v| v.as_str())
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.ok_or((
StatusCode::BAD_REQUEST,
"Missing or empty 'name'".to_string(),
))?
.to_string();
let expires_in_days: Option<i64> = match body.get("expires_in_days").and_then(|v| v.as_u64()) {
Some(d) if d > 36500 => {
return Err((
StatusCode::BAD_REQUEST,
"expires_in_days must not exceed 36500 (100 years)".to_string(),
));
}
Some(d) => Some(d as i64),
None => None,
};
let expires_at = expires_in_days.map(|days| chrono::Utc::now() + chrono::Duration::days(days));
// Generate 32 random bytes for the token.
// Hash the hex-encoded plaintext (what the user sends as Bearer token),
// NOT the raw bytes — must match hash_token() in auth.rs.
let mut token_bytes = [0u8; 32];
OsRng.fill_bytes(&mut token_bytes);
let plaintext_token = hex::encode(token_bytes);
let hash = crate::channels::web::auth::hash_token(&plaintext_token);
// First 8 chars of the hex token as a prefix for identification.
let token_prefix = &plaintext_token[..8];
// Admin users can create tokens for other users via optional "user_id" field.
let target_user = body
.get("user_id")
.and_then(|v| v.as_str())
.filter(|_| user.role == "admin")
.unwrap_or(&user.user_id);
// Verify the target user exists to prevent orphan tokens.
if target_user != user.user_id {
store
.get_user(target_user)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((
StatusCode::NOT_FOUND,
format!("Target user '{target_user}' not found"),
))?;
}
let record = store
.create_api_token(target_user, &name, &hash, token_prefix, expires_at)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Return the plaintext token — this is the ONLY time it is shown.
Ok(Json(serde_json::json!({
"token": plaintext_token,
"id": record.id.to_string(),
"name": record.name,
"token_prefix": record.token_prefix,
"expires_at": record.expires_at.map(|dt| dt.to_rfc3339()),
"created_at": record.created_at.to_rfc3339(),
})))
}
/// GET /api/tokens — list the current user's tokens (no hashes).
pub async fn tokens_list_handler(
State(state): State<Arc<GatewayState>>,
AuthenticatedUser(user): AuthenticatedUser,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
let tokens = store
.list_api_tokens(&user.user_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let tokens_json: Vec<serde_json::Value> = tokens
.into_iter()
.map(|t| {
serde_json::json!({
"id": t.id.to_string(),
"name": t.name,
"token_prefix": t.token_prefix,
"expires_at": t.expires_at.map(|dt| dt.to_rfc3339()),
"last_used_at": t.last_used_at.map(|dt| dt.to_rfc3339()),
"created_at": t.created_at.to_rfc3339(),
"revoked_at": t.revoked_at.map(|dt| dt.to_rfc3339()),
})
})
.collect();
Ok(Json(serde_json::json!({ "tokens": tokens_json })))
}
/// DELETE /api/tokens/{id} — revoke a token.
pub async fn tokens_revoke_handler(
State(state): State<Arc<GatewayState>>,
AuthenticatedUser(user): AuthenticatedUser,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
let token_id = Uuid::parse_str(&id)
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid token ID".to_string()))?;
let revoked = store
.revoke_api_token(token_id, &user.user_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if !revoked {
return Err((StatusCode::NOT_FOUND, "Token not found".to_string()));
}
// Evict cached auth so revocation takes effect immediately.
if let Some(ref db_auth) = state.db_auth {
db_auth.invalidate_user(&user.user_id).await;
}
Ok(Json(serde_json::json!({
"status": "revoked",
"id": token_id.to_string(),
})))
}
-534
View File
@@ -1,534 +0,0 @@
//! User management API handlers (admin).
use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use rand::RngCore;
use rand::rngs::OsRng;
use uuid::Uuid;
use crate::channels::web::auth::{AdminUser, AuthenticatedUser};
use crate::channels::web::server::GatewayState;
use crate::db::{Database, UserRecord};
/// Check whether `user_id` is the sole active admin. Returns true if demoting,
/// suspending, or deleting this user would leave zero admins.
async fn is_last_admin(store: &dyn Database, user_id: &str) -> Result<bool, String> {
let users = store
.list_users(Some("active"))
.await
.map_err(|e| e.to_string())?;
let active_admins: Vec<_> = users.iter().filter(|u| u.role == "admin").collect();
Ok(active_admins.len() == 1 && active_admins[0].id == user_id)
}
/// POST /api/admin/users — create a new user.
pub async fn users_create_handler(
State(state): State<Arc<GatewayState>>,
AdminUser(user): AdminUser,
Json(body): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
let display_name = body
.get("display_name")
.and_then(|v| v.as_str())
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.ok_or((
StatusCode::BAD_REQUEST,
"Missing or empty 'display_name'".to_string(),
))?
.to_string();
let email = body
.get("email")
.and_then(|v| v.as_str())
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(String::from);
let role = body
.get("role")
.and_then(|v| v.as_str())
.unwrap_or("member")
.to_string();
if role != "admin" && role != "member" {
return Err((
StatusCode::BAD_REQUEST,
"role must be 'admin' or 'member'".to_string(),
));
}
let user_id = Uuid::new_v4().to_string();
let now = chrono::Utc::now();
let user_record = UserRecord {
id: user_id.clone(),
email,
display_name: display_name.clone(),
status: "active".to_string(),
role,
created_at: now,
updated_at: now,
last_login_at: None,
created_by: Some(user.user_id.clone()),
metadata: serde_json::json!({}),
};
// Generate a first API token so the new user can authenticate immediately.
// Hash the hex-encoded plaintext (what the user sends as Bearer token),
// NOT the raw bytes — must match hash_token() in auth.rs.
let mut token_bytes = [0u8; 32];
OsRng.fill_bytes(&mut token_bytes);
let plaintext_token = hex::encode(token_bytes);
let token_hash = crate::channels::web::auth::hash_token(&plaintext_token);
let token_prefix = &plaintext_token[..8];
// Create user and initial token atomically — if either fails, both roll back.
let _token_record = store
.create_user_with_token(&user_record, "initial", &token_hash, token_prefix, None)
.await
.map_err(|e| {
let msg = e.to_string();
let lower = msg.to_ascii_lowercase();
if lower.contains("unique")
|| lower.contains("duplicate")
|| lower.contains("already exists")
{
(StatusCode::CONFLICT, msg)
} else {
(StatusCode::INTERNAL_SERVER_ERROR, msg)
}
})?;
Ok(Json(serde_json::json!({
"id": user_record.id,
"email": user_record.email,
"display_name": user_record.display_name,
"status": user_record.status,
"role": user_record.role,
"token": plaintext_token,
"created_at": user_record.created_at.to_rfc3339(),
"created_by": user_record.created_by,
})))
}
/// GET /api/admin/users — list all users with inline usage stats.
pub async fn users_list_handler(
State(state): State<Arc<GatewayState>>,
AdminUser(_user): AdminUser,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
let users = store
.list_users(None)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Fetch per-user summary stats from DB (agent_jobs + llm_calls).
let summary_stats = store
.user_summary_stats(None)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let stats_map: std::collections::HashMap<String, _> = summary_stats
.into_iter()
.map(|s| (s.user_id.clone(), s))
.collect();
let mut users_json: Vec<serde_json::Value> = Vec::with_capacity(users.len());
for u in users {
let db_stats = stats_map.get(&u.id);
let total_cost = db_stats.map_or(rust_decimal::Decimal::ZERO, |s| s.total_cost);
// Last active: prefer DB timestamp, fall back to last_login_at.
let last_active = db_stats.and_then(|s| s.last_active_at).or(u.last_login_at);
users_json.push(serde_json::json!({
"id": u.id,
"email": u.email,
"display_name": u.display_name,
"status": u.status,
"role": u.role,
"created_at": u.created_at.to_rfc3339(),
"updated_at": u.updated_at.to_rfc3339(),
"last_login_at": u.last_login_at.map(|dt| dt.to_rfc3339()),
"created_by": u.created_by,
"job_count": db_stats.map_or(0, |s| s.job_count),
"total_cost": total_cost.to_string(),
"last_active_at": last_active.map(|dt| dt.to_rfc3339()),
}));
}
Ok(Json(serde_json::json!({ "users": users_json })))
}
/// GET /api/admin/users/{id} — get a single user.
pub async fn users_detail_handler(
State(state): State<Arc<GatewayState>>,
AdminUser(_user): AdminUser,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
let user_record = store
.get_user(&id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
Ok(Json(serde_json::json!({
"id": user_record.id,
"email": user_record.email,
"display_name": user_record.display_name,
"status": user_record.status,
"role": user_record.role,
"created_at": user_record.created_at.to_rfc3339(),
"updated_at": user_record.updated_at.to_rfc3339(),
"last_login_at": user_record.last_login_at.map(|dt| dt.to_rfc3339()),
"created_by": user_record.created_by,
"metadata": user_record.metadata,
})))
}
/// PATCH /api/admin/users/{id} — update a user's profile.
pub async fn users_update_handler(
State(state): State<Arc<GatewayState>>,
AdminUser(_user): AdminUser,
Path(id): Path<String>,
Json(body): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
// Verify the user exists.
let existing = store
.get_user(&id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
let display_name = body
.get("display_name")
.and_then(|v| v.as_str())
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.unwrap_or(&existing.display_name);
let metadata = if let Some(m) = body.get("metadata") {
if !m.is_object() {
return Err((
StatusCode::BAD_REQUEST,
"metadata must be a JSON object".to_string(),
));
}
m
} else {
&existing.metadata
};
// Update role if provided and valid.
if let Some(role) = body.get("role").and_then(|v| v.as_str()) {
if role != "admin" && role != "member" {
return Err((
StatusCode::BAD_REQUEST,
"role must be 'admin' or 'member'".to_string(),
));
}
if role != existing.role {
// Prevent demoting the last admin.
if existing.role == "admin"
&& role == "member"
&& is_last_admin(store.as_ref(), &id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?
{
return Err((
StatusCode::CONFLICT,
"Cannot demote the last admin".to_string(),
));
}
store
.update_user_role(&id, role)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Evict cached auth so role change takes effect immediately.
if let Some(ref db_auth) = state.db_auth {
db_auth.invalidate_user(&id).await;
}
}
}
store
.update_user_profile(&id, display_name, metadata)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Re-fetch the updated record to return consistent data.
let updated = store
.get_user(&id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
Ok(Json(serde_json::json!({
"id": updated.id,
"email": updated.email,
"display_name": updated.display_name,
"status": updated.status,
"role": updated.role,
"created_at": updated.created_at.to_rfc3339(),
"updated_at": updated.updated_at.to_rfc3339(),
"metadata": updated.metadata,
})))
}
/// POST /api/admin/users/{id}/suspend — suspend a user.
pub async fn users_suspend_handler(
State(state): State<Arc<GatewayState>>,
AdminUser(_user): AdminUser,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
// Verify the user exists.
store
.get_user(&id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
// Prevent suspending the last admin.
if is_last_admin(store.as_ref(), &id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?
{
return Err((
StatusCode::CONFLICT,
"Cannot suspend the last admin".to_string(),
));
}
store
.update_user_status(&id, "suspended")
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Evict cached auth so suspension takes effect immediately.
if let Some(ref db_auth) = state.db_auth {
db_auth.invalidate_user(&id).await;
}
Ok(Json(serde_json::json!({
"id": id,
"status": "suspended",
})))
}
/// POST /api/admin/users/{id}/activate — activate a user.
pub async fn users_activate_handler(
State(state): State<Arc<GatewayState>>,
AdminUser(_user): AdminUser,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
// Verify the user exists.
store
.get_user(&id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
store
.update_user_status(&id, "active")
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Evict cached auth so reactivation takes effect immediately.
if let Some(ref db_auth) = state.db_auth {
db_auth.invalidate_user(&id).await;
}
Ok(Json(serde_json::json!({
"id": id,
"status": "active",
})))
}
/// DELETE /api/admin/users/{id} — delete a user and all their data.
pub async fn users_delete_handler(
State(state): State<Arc<GatewayState>>,
AdminUser(_user): AdminUser,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
// Prevent deleting the last admin.
if is_last_admin(store.as_ref(), &id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?
{
return Err((
StatusCode::CONFLICT,
"Cannot delete the last admin".to_string(),
));
}
let deleted = store
.delete_user(&id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if !deleted {
return Err((StatusCode::NOT_FOUND, "User not found".to_string()));
}
Ok(Json(serde_json::json!({
"id": id,
"deleted": true,
})))
}
/// GET /api/profile — get the authenticated user's own profile.
pub async fn profile_get_handler(
State(state): State<Arc<GatewayState>>,
AuthenticatedUser(user): AuthenticatedUser,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
let record = store
.get_user(&user.user_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
Ok(Json(serde_json::json!({
"id": record.id,
"email": record.email,
"display_name": record.display_name,
"status": record.status,
"role": record.role,
"created_at": record.created_at.to_rfc3339(),
"last_login_at": record.last_login_at.map(|dt| dt.to_rfc3339()),
})))
}
/// PATCH /api/profile — update the authenticated user's own profile.
pub async fn profile_update_handler(
State(state): State<Arc<GatewayState>>,
AuthenticatedUser(user): AuthenticatedUser,
Json(body): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
let current = store
.get_user(&user.user_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
let display_name = body
.get("display_name")
.and_then(|v| v.as_str())
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.unwrap_or(&current.display_name);
let metadata = if let Some(m) = body.get("metadata") {
if !m.is_object() {
return Err((
StatusCode::BAD_REQUEST,
"metadata must be a JSON object".to_string(),
));
}
m
} else {
&current.metadata
};
store
.update_user_profile(&user.user_id, display_name, metadata)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(serde_json::json!({
"id": user.user_id,
"display_name": display_name,
"updated": true,
})))
}
/// GET /api/admin/usage — per-user LLM usage stats.
pub async fn usage_stats_handler(
State(state): State<Arc<GatewayState>>,
AdminUser(_user): AdminUser,
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
let user_id = params.get("user_id").map(|s| s.as_str());
let period = params.get("period").map(|s| s.as_str()).unwrap_or("day");
let since = match period {
"week" => chrono::Utc::now() - chrono::Duration::days(7),
"month" => chrono::Utc::now() - chrono::Duration::days(30),
_ => chrono::Utc::now() - chrono::Duration::days(1),
};
let stats = store
.user_usage_stats(user_id, since)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let entries: Vec<serde_json::Value> = stats
.iter()
.map(|s| {
serde_json::json!({
"user_id": s.user_id,
"model": s.model,
"call_count": s.call_count,
"input_tokens": s.input_tokens,
"output_tokens": s.output_tokens,
"total_cost": s.total_cost.to_string(),
})
})
.collect();
Ok(Json(serde_json::json!({
"period": period,
"since": since.to_rfc3339(),
"usage": entries,
})))
}
+1 -12
View File
@@ -56,24 +56,13 @@ fn validate_webhook_secret(
/// by the per-routine webhook secret sent via the `X-Webhook-Secret` header.
///
/// **Single-user/backward-compatible**: looks up routines by path across all
/// users. Disabled in multi-tenant mode — use the user-scoped endpoint at
/// users. For multi-tenant isolation, use the user-scoped endpoint at
/// `/api/webhooks/u/{user_id}/{path}` instead.
pub async fn webhook_trigger_handler(
State(state): State<Arc<GatewayState>>,
Path(path): Path<String>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
// In multi-tenant mode, reject unscoped webhooks to prevent cross-user
// routine triggering. The per-routine secret provides some protection,
// but tenant isolation requires scoping by user_id.
// Use workspace_pool as the multi-tenant indicator — it's only set when
// has_any_users() was true at startup (not just when a DB exists).
if state.workspace_pool.is_some() {
return Err((
StatusCode::GONE,
"Unscoped webhooks disabled in multi-tenant mode. Use /api/webhooks/u/{user_id}/{path} instead.".to_string(),
));
}
fire_webhook_inner(state, &path, None, &headers).await
}
+65 -34
View File
@@ -56,7 +56,7 @@ use crate::workspace::Workspace;
use self::log_layer::{LogBroadcaster, LogLevelHandle};
use self::auth::{CombinedAuthState, DbAuthenticator, MultiAuthState};
use self::auth::MultiAuthState;
use self::server::GatewayState;
use self::sse::SseManager;
use self::types::AppEvent;
@@ -65,8 +65,8 @@ use self::types::AppEvent;
pub struct GatewayChannel {
config: GatewayConfig,
state: Arc<GatewayState>,
/// Combined auth state: env-var tokens + optional DB-backed tokens.
auth: CombinedAuthState,
/// Multi-user auth state (replaces bare auth_token).
auth: MultiAuthState,
}
impl GatewayChannel {
@@ -74,7 +74,7 @@ impl GatewayChannel {
///
/// 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, owner_id: String) -> Self {
pub fn new(config: GatewayConfig) -> Self {
let auth_token = config.auth_token.clone().unwrap_or_else(|| {
use rand::RngCore;
use rand::rngs::OsRng;
@@ -83,10 +83,7 @@ impl GatewayChannel {
bytes.iter().map(|b| format!("{b:02x}")).collect()
});
let auth = CombinedAuthState {
env_auth: MultiAuthState::single(auth_token, owner_id.clone()),
db_auth: None,
};
let auth = MultiAuthState::single(auth_token, config.user_id.clone());
let state = Arc::new(GatewayState {
msg_tx: tokio::sync::RwLock::new(None),
@@ -102,7 +99,8 @@ impl GatewayChannel {
job_manager: None,
prompt_queue: None,
scheduler: None,
owner_id,
owner_id: config.user_id.clone(),
default_sender_id: config.user_id.clone(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())),
llm_provider: None,
@@ -116,8 +114,62 @@ impl GatewayChannel {
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: server::ActiveConfigSnapshot::default(),
secrets_store: None,
db_auth: None,
});
Self {
config,
state,
auth,
}
}
/// Rebind the single-user auth identity to the durable owner scope while
/// preserving the configured gateway sender/routing identity.
pub fn with_owner_scope(mut self, owner_id: impl Into<String>) -> Self {
let owner_id = owner_id.into();
let single_user_token = if self.config.user_tokens.is_none() {
self.auth.first_token().map(ToOwned::to_owned)
} else {
None
};
if let Some(token) = single_user_token {
self.auth = MultiAuthState::single(token, owner_id.clone());
}
self.rebuild_state(|s| s.owner_id = owner_id);
self
}
/// 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,
owner_id: config.user_id.clone(),
default_sender_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 {
@@ -145,6 +197,7 @@ impl GatewayChannel {
prompt_queue: self.state.prompt_queue.clone(),
scheduler: self.state.scheduler.clone(),
owner_id: self.state.owner_id.clone(),
default_sender_id: self.state.default_sender_id.clone(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: self.state.ws_tracker.clone(),
llm_provider: self.state.llm_provider.clone(),
@@ -158,8 +211,6 @@ impl GatewayChannel {
routine_engine: Arc::clone(&self.state.routine_engine),
startup_time: self.state.startup_time,
active_config: self.state.active_config.clone(),
secrets_store: self.state.secrets_store.clone(),
db_auth: self.state.db_auth.clone(),
};
mutate(&mut new_state);
self.state = Arc::new(new_state);
@@ -207,17 +258,6 @@ impl GatewayChannel {
self
}
/// Enable DB-backed token authentication alongside env-var tokens.
pub fn with_db_auth(mut self, store: Arc<dyn Database>) -> Self {
let authenticator = DbAuthenticator::new(store);
// Share the same DbAuthenticator (and its cache) between the auth
// middleware and GatewayState so handlers can invalidate the cache
// on security-critical actions (suspend, role change, token revoke).
self.rebuild_state(|s| s.db_auth = Some(Arc::new(authenticator.clone())));
self.auth.db_auth = Some(authenticator);
self
}
/// Inject the container job manager for sandbox operations.
pub fn with_job_manager(mut self, jm: Arc<ContainerJobManager>) -> Self {
self.rebuild_state(|s| s.job_manager = Some(jm));
@@ -288,15 +328,6 @@ impl GatewayChannel {
self
}
/// Inject the secrets store for admin secret provisioning.
pub fn with_secrets_store(
mut self,
store: Arc<dyn crate::secrets::SecretsStore + Send + Sync>,
) -> Self {
self.rebuild_state(|s| s.secrets_store = Some(store));
self
}
/// Inject the per-user workspace pool for multi-user mode.
pub fn with_workspace_pool(mut self, pool: Arc<server::WorkspacePool>) -> Self {
self.rebuild_state(|s| s.workspace_pool = Some(pool));
@@ -305,7 +336,7 @@ impl GatewayChannel {
/// Get the first auth token (for printing to console on startup).
pub fn auth_token(&self) -> &str {
self.auth.env_auth.first_token().unwrap_or("")
self.auth.first_token().unwrap_or("")
}
/// Get a reference to the shared gateway state (for the agent to push SSE events).
+22 -137
View File
@@ -16,7 +16,7 @@ use axum::{
IntoResponse,
sse::{Event, KeepAlive, Sse},
},
routing::{get, post, put},
routing::{get, post},
};
use serde::Deserialize;
use sha2::{Digest, Sha256};
@@ -31,11 +31,7 @@ use crate::bootstrap::ironclaw_base_dir;
use crate::channels::IncomingMessage;
use crate::channels::relay::DEFAULT_RELAY_NAME;
use crate::channels::web::auth::{
AuthenticatedUser, CombinedAuthState, UserIdentity, auth_middleware,
};
use crate::channels::web::handlers::frontend::{
frontend_layout_handler, frontend_layout_update_handler, frontend_widget_file_handler,
frontend_widgets_handler,
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,
@@ -294,22 +290,7 @@ impl WorkspacePool {
}
let ws = Arc::new(ws);
cache.insert(identity.user_id.clone(), Arc::clone(&ws));
// Seed identity files after inserting into cache (so the lock can be
// dropped) but before returning, so callers see a seeded workspace.
// Drop the write lock explicitly before the async seed to avoid
// blocking other workspace lookups.
drop(cache);
if let Err(e) = ws.seed_if_empty().await {
tracing::warn!(
user_id = identity.user_id,
"Failed to seed workspace: {}",
e
);
}
ws
}
}
@@ -366,6 +347,8 @@ pub struct GatewayState {
pub prompt_queue: Option<PromptQueue>,
/// Durable owner scope for persistence and unauthenticated callback flows.
pub owner_id: String,
/// Default sender/routing identity for gateway-originated messages.
pub default_sender_id: String,
/// Shutdown signal sender.
pub shutdown_tx: tokio::sync::RwLock<Option<oneshot::Sender<()>>>,
/// WebSocket connection tracker.
@@ -395,10 +378,6 @@ pub struct GatewayState {
pub startup_time: std::time::Instant,
/// Snapshot of active (resolved) configuration for the frontend.
pub active_config: ActiveConfigSnapshot,
/// Secrets store for admin secret provisioning.
pub secrets_store: Option<Arc<dyn crate::secrets::SecretsStore + Send + Sync>>,
/// DB auth cache for invalidation on security-critical actions.
pub db_auth: Option<Arc<crate::channels::web::auth::DbAuthenticator>>,
}
/// Start the gateway HTTP server.
@@ -407,7 +386,7 @@ pub struct GatewayState {
pub async fn start_server(
addr: SocketAddr,
state: Arc<GatewayState>,
auth: CombinedAuthState,
auth: MultiAuthState,
) -> Result<SocketAddr, crate::error::ChannelError> {
let listener = tokio::net::TcpListener::bind(addr).await.map_err(|e| {
crate::error::ChannelError::StartupFailed {
@@ -533,67 +512,6 @@ pub async fn start_server(
"/api/settings/{key}",
axum::routing::delete(settings_delete_handler),
)
// User management (admin)
.route(
"/api/admin/users",
get(super::handlers::users::users_list_handler)
.post(super::handlers::users::users_create_handler),
)
.route(
"/api/admin/users/{id}",
get(super::handlers::users::users_detail_handler)
.patch(super::handlers::users::users_update_handler)
.delete(super::handlers::users::users_delete_handler),
)
.route(
"/api/admin/users/{id}/suspend",
post(super::handlers::users::users_suspend_handler),
)
.route(
"/api/admin/users/{id}/activate",
post(super::handlers::users::users_activate_handler),
)
// Admin secrets provisioning (per-user)
.route(
"/api/admin/users/{user_id}/secrets",
get(super::handlers::secrets::secrets_list_handler),
)
.route(
"/api/admin/users/{user_id}/secrets/{name}",
put(super::handlers::secrets::secrets_put_handler)
.delete(super::handlers::secrets::secrets_delete_handler),
)
// Usage reporting (admin)
.route(
"/api/admin/usage",
get(super::handlers::users::usage_stats_handler),
)
// User self-service profile
.route(
"/api/profile",
get(super::handlers::users::profile_get_handler)
.patch(super::handlers::users::profile_update_handler),
)
// Token management
.route(
"/api/tokens",
get(super::handlers::tokens::tokens_list_handler)
.post(super::handlers::tokens::tokens_create_handler),
)
.route(
"/api/tokens/{id}",
axum::routing::delete(super::handlers::tokens::tokens_revoke_handler),
)
// Frontend extension API
.route(
"/api/frontend/layout",
get(frontend_layout_handler).put(frontend_layout_update_handler),
)
.route("/api/frontend/widgets", get(frontend_widgets_handler))
.route(
"/api/frontend/widget/{id}/{*file}",
get(frontend_widget_file_handler),
)
// Gateway control plane
.route("/api/gateway/status", get(gateway_status_handler))
// OpenAI-compatible API
@@ -653,7 +571,6 @@ pub async fn start_server(
axum::http::Method::GET,
axum::http::Method::POST,
axum::http::Method::PUT,
axum::http::Method::PATCH,
axum::http::Method::DELETE,
])
.allow_headers(AllowHeaders::list([
@@ -668,33 +585,6 @@ pub async fn start_server(
.merge(projects)
.merge(protected)
.layer(DefaultBodyLimit::max(10 * 1024 * 1024)) // 10 MB max request body (image uploads)
.layer(tower_http::catch_panic::CatchPanicLayer::custom(
|panic_info: Box<dyn std::any::Any + Send + 'static>| {
let detail = if let Some(s) = panic_info.downcast_ref::<String>() {
s.clone()
} else if let Some(s) = panic_info.downcast_ref::<&str>() {
(*s).to_string()
} else {
"unknown panic".to_string()
};
// Truncate panic payload to avoid leaking sensitive data into logs.
// Use floor_char_boundary to avoid panicking on multi-byte UTF-8.
let safe_detail = if detail.len() > 200 {
let end = detail.floor_char_boundary(200);
format!("{}", &detail[..end])
} else {
detail
};
tracing::error!("Handler panicked: {}", safe_detail);
axum::http::Response::builder()
.status(axum::http::StatusCode::INTERNAL_SERVER_ERROR)
.header("content-type", "text/plain")
.body(axum::body::Body::from("Internal Server Error"))
.unwrap_or_else(|_| {
axum::http::Response::new(axum::body::Body::from("Internal Server Error"))
})
},
))
.layer(cors)
.layer(SetResponseHeaderLayer::if_not_present(
header::X_CONTENT_TYPE_OPTIONS,
@@ -740,11 +630,6 @@ pub async fn start_server(
}
// --- Static file handlers ---
//
// All frontend assets are embedded in the `ironclaw_frontend` crate.
// These handlers serve them with appropriate MIME types and cache headers.
use ironclaw_frontend::assets;
async fn index_handler() -> impl IntoResponse {
(
@@ -752,7 +637,7 @@ async fn index_handler() -> impl IntoResponse {
(header::CONTENT_TYPE, "text/html; charset=utf-8"),
(header::CACHE_CONTROL, "no-cache"),
],
assets::INDEX_HTML,
include_str!("static/index.html"),
)
}
@@ -762,7 +647,7 @@ async fn css_handler() -> impl IntoResponse {
(header::CONTENT_TYPE, "text/css"),
(header::CACHE_CONTROL, "no-cache"),
],
assets::STYLE_CSS,
include_str!("static/style.css"),
)
}
@@ -772,7 +657,7 @@ async fn js_handler() -> impl IntoResponse {
(header::CONTENT_TYPE, "application/javascript"),
(header::CACHE_CONTROL, "no-cache"),
],
assets::APP_JS,
include_str!("static/app.js"),
)
}
@@ -782,7 +667,7 @@ async fn theme_init_handler() -> impl IntoResponse {
(header::CONTENT_TYPE, "application/javascript"),
(header::CACHE_CONTROL, "no-cache"),
],
assets::THEME_INIT_JS,
include_str!("static/theme-init.js"),
)
}
@@ -792,7 +677,7 @@ async fn favicon_handler() -> impl IntoResponse {
(header::CONTENT_TYPE, "image/x-icon"),
(header::CACHE_CONTROL, "public, max-age=86400"),
],
assets::FAVICON_ICO,
include_bytes!("static/favicon.ico").as_slice(),
)
}
@@ -802,7 +687,7 @@ async fn i18n_index_handler() -> impl IntoResponse {
(header::CONTENT_TYPE, "application/javascript"),
(header::CACHE_CONTROL, "no-cache"),
],
assets::I18N_INDEX_JS,
include_str!("static/i18n/index.js"),
)
}
@@ -812,7 +697,7 @@ async fn i18n_en_handler() -> impl IntoResponse {
(header::CONTENT_TYPE, "application/javascript"),
(header::CACHE_CONTROL, "no-cache"),
],
assets::I18N_EN_JS,
include_str!("static/i18n/en.js"),
)
}
@@ -822,7 +707,7 @@ async fn i18n_zh_handler() -> impl IntoResponse {
(header::CONTENT_TYPE, "application/javascript"),
(header::CACHE_CONTROL, "no-cache"),
],
assets::I18N_ZH_CN_JS,
include_str!("static/i18n/zh-CN.js"),
)
}
@@ -832,7 +717,7 @@ async fn i18n_app_handler() -> impl IntoResponse {
(header::CONTENT_TYPE, "application/javascript"),
(header::CACHE_CONTROL, "no-cache"),
],
assets::I18N_APP_JS,
include_str!("static/i18n-app.js"),
)
}
@@ -1447,6 +1332,9 @@ async fn chat_send_handler(
}
let mut msg = IncomingMessage::new("gateway", &user.user_id, &req.content);
if state.owner_id != state.default_sender_id && user.user_id == state.owner_id {
msg = msg.with_sender_id(&state.default_sender_id);
}
// Prefer timezone from JSON body, fall back to X-Timezone header
let tz = req
.timezone
@@ -1548,6 +1436,9 @@ async fn chat_approval_handler(
})?;
let mut msg = IncomingMessage::new("gateway", &user.user_id, content);
if state.owner_id != state.default_sender_id && user.user_id == state.owner_id {
msg = msg.with_sender_id(&state.default_sender_id);
}
if let Some(ref thread_id) = req.thread_id {
msg = msg.with_thread(thread_id);
@@ -3150,6 +3041,7 @@ mod tests {
job_manager: None,
prompt_queue: None,
owner_id: "test".to_string(),
default_sender_id: "test".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: None,
llm_provider: None,
@@ -3164,8 +3056,6 @@ mod tests {
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: ActiveConfigSnapshot::default(),
secrets_store: None,
db_auth: None,
})
}
@@ -3386,7 +3276,6 @@ mod tests {
// without needing the full auth middleware layer.
req.extensions_mut().insert(UserIdentity {
user_id: "test".to_string(),
role: "admin".to_string(),
workspace_read_scopes: Vec::new(),
});
@@ -3471,7 +3360,6 @@ mod tests {
// without needing the full auth middleware layer.
req.extensions_mut().insert(UserIdentity {
user_id: "test".to_string(),
role: "admin".to_string(),
workspace_read_scopes: Vec::new(),
});
@@ -3521,10 +3409,7 @@ mod tests {
let state = test_gateway_state(None);
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let auth = CombinedAuthState::from(crate::channels::web::auth::MultiAuthState::single(
"test-token".to_string(),
"test".to_string(),
));
let auth = MultiAuthState::single("test-token".to_string(), "test".to_string());
let bound = start_server(addr, state.clone(), auth)
.await
.expect("server should start");
@@ -95,118 +95,6 @@ let authFlowPending = false;
let _ghostSuggestion = '';
let currentSettingsSubtab = 'inference';
// --- Hash-based URL Navigation ---
//
// Encodes navigation state in window.location.hash so refreshing
// the page restores the current tab, thread, memory file, job detail, etc.
//
// Hash format: #/{tab}[/{detail}[/{subtab}]]
// #/chat → chat tab, assistant thread
// #/chat/{threadId} → chat tab, specific thread
// #/memory → memory tab, tree root
// #/memory/{path/to/file} → memory tab, specific file
// #/jobs → jobs list
// #/jobs/{jobId} → job detail
// #/routines → routines list
// #/routines/{id} → routine detail
// #/settings/{subtab} → settings tab with specific sub-tab
// #/logs → logs tab
/** Suppress hash-change handling while we're programmatically updating. */
let _suppressHashChange = false;
/** Update the URL hash to reflect current navigation state. */
function updateHash() {
var parts = [currentTab];
switch (currentTab) {
case 'chat':
if (currentThreadId && currentThreadId !== assistantThreadId) {
parts.push(currentThreadId);
}
break;
case 'memory':
if (typeof currentMemoryPath === 'string' && currentMemoryPath) {
parts.push(currentMemoryPath);
}
break;
case 'jobs':
if (typeof currentJobId !== 'undefined' && currentJobId) {
parts.push(currentJobId);
}
break;
case 'routines':
if (typeof currentRoutineId !== 'undefined' && currentRoutineId) {
parts.push(currentRoutineId);
}
break;
case 'settings':
if (currentSettingsSubtab && currentSettingsSubtab !== 'inference') {
parts.push(currentSettingsSubtab);
}
break;
}
var hash = '#/' + parts.join('/');
_suppressHashChange = true;
if (window.location.hash !== hash) {
window.history.replaceState(null, '', hash);
}
_suppressHashChange = false;
}
/** Parse the current URL hash into navigation state. */
function parseHash() {
var hash = window.location.hash || '';
if (!hash.startsWith('#/')) return null;
var parts = hash.substring(2).split('/');
return {
tab: parts[0] || 'chat',
detail: parts.slice(1).join('/') || null,
};
}
/**
* Restore navigation state from the URL hash.
* Called once after authentication and on hashchange events.
*/
function restoreFromHash() {
var state = parseHash();
if (!state) return;
// Switch tab (without recursively updating hash)
if (state.tab && state.tab !== currentTab) {
switchTab(state.tab);
}
// Restore detail state within the tab
if (state.detail) {
switch (state.tab) {
case 'chat':
// Defer thread switch until threads are loaded
window._pendingThreadRestore = state.detail;
break;
case 'memory':
readMemoryFile(state.detail);
break;
case 'jobs':
openJobDetail(state.detail);
break;
case 'routines':
openRoutineDetail(state.detail);
break;
case 'settings':
switchSettingsSubtab(state.detail);
break;
}
}
}
window.addEventListener('hashchange', function() {
if (_suppressHashChange) return;
restoreFromHash();
});
// --- Streaming Debounce State ---
let _streamBuffer = '';
let _streamDebounceTimer = null;
@@ -298,19 +186,10 @@ function authenticate() {
connectSSE();
connectLogSSE();
startGatewayStatusPolling();
// Hide the Users settings tab for non-admin users.
apiFetch('/api/profile').then(function(profile) {
if (profile && profile.role !== 'admin') {
var usersTab = document.querySelector('[data-settings-subtab="users"]');
if (usersTab) usersTab.style.display = 'none';
}
}).catch(function() {});
checkTeeStatus();
loadThreads();
loadMemoryTree();
loadJobs();
// Restore navigation state from URL hash (tab, thread, memory file, etc.)
restoreFromHash();
// Apply URL log_level param if present, otherwise just sync the dropdown
if (urlLogLevel) {
setServerLogLevel(urlLogLevel);
@@ -1134,128 +1013,6 @@ function sanitizeRenderedHtml(html) {
return '';
}
// ==================== Structured Data Rendering ====================
//
// Detects JSON objects and key-value data in assistant messages and
// renders them as styled cards instead of raw text. Also supports
// extensible chat renderers via IronClaw.registerChatRenderer().
/**
* Post-process a .message-content element to upgrade structured data into cards.
* Runs registered chat renderers first, then falls back to built-in JSON detection.
*/
function upgradeStructuredData(contentEl) {
// 1. Run registered chat renderers
var renderers = (window.IronClaw && IronClaw._chatRenderers) || [];
for (var i = 0; i < renderers.length; i++) {
try {
if (renderers[i].match(contentEl.textContent, contentEl)) {
renderers[i].render(contentEl, contentEl.textContent);
return; // First matching renderer wins
}
} catch (e) {
console.error('[IronClaw] Chat renderer "' + renderers[i].id + '" failed:', e);
}
}
// 2. Built-in: detect and upgrade inline JSON objects
upgradeInlineJson(contentEl);
}
/**
* Find JSON-like objects in text nodes and replace them with styled cards.
*/
function upgradeInlineJson(contentEl) {
// Walk text content looking for JSON objects: {...} patterns
// Only process <p> and top-level text, not code blocks
var paragraphs = contentEl.querySelectorAll('p');
if (paragraphs.length === 0) {
// No <p> tags — markdown might have produced bare text
paragraphs = [contentEl];
}
paragraphs.forEach(function(p) {
// Skip code blocks
if (p.closest('pre') || p.closest('code')) return;
var html = p.innerHTML;
// Match JSON-like objects: {...} (including Python-style single quotes)
var jsonRegex = /(\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\})/g;
var match;
var replaced = false;
while ((match = jsonRegex.exec(html)) !== null) {
var raw = match[1];
// Normalize Python-style single quotes to double quotes for parsing
var normalized = raw.replace(/'/g, '"');
try {
var obj = JSON.parse(normalized);
if (typeof obj === 'object' && obj !== null && !Array.isArray(obj)) {
var card = buildDataCard(obj);
html = html.substring(0, match.index) + card + html.substring(match.index + match[0].length);
replaced = true;
// Reset regex since we modified the string
jsonRegex.lastIndex = match.index + card.length;
}
} catch (e) {
// Not valid JSON — leave as text
}
}
if (replaced) {
p.innerHTML = html;
}
});
}
/**
* Build an HTML data card from a plain object.
*/
function buildDataCard(obj) {
var keys = Object.keys(obj);
if (keys.length === 0) return '';
var rows = '';
for (var i = 0; i < keys.length; i++) {
var key = keys[i];
var value = obj[key];
var displayKey = key.replace(/_/g, ' ');
var valueClass = 'data-card-value';
var valueHtml;
// Special rendering for known value types
if (key === 'status' || key === 'state') {
var badgeClass = 'status-badge';
var sv = String(value).toLowerCase();
if (sv === 'created' || sv === 'active' || sv === 'success' || sv === 'completed' || sv === 'ok' || sv === 'running') {
badgeClass += ' status-success';
} else if (sv === 'failed' || sv === 'error' || sv === 'cancelled' || sv === 'rejected') {
badgeClass += ' status-error';
} else if (sv === 'pending' || sv === 'waiting' || sv === 'queued') {
badgeClass += ' status-pending';
}
valueHtml = '<span class="' + badgeClass + '">' + escapeHtml(String(value)) + '</span>';
} else if (typeof value === 'object' && value !== null) {
valueHtml = '<code>' + escapeHtml(JSON.stringify(value)) + '</code>';
} else {
// Check if value looks like a UUID or ID
var strVal = String(value);
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(strVal)) {
valueHtml = '<code class="data-card-id">' + escapeHtml(strVal) + '</code>';
} else {
valueHtml = '<span>' + escapeHtml(strVal) + '</span>';
}
}
rows += '<div class="data-card-row">' +
'<span class="data-card-label">' + escapeHtml(displayKey) + '</span>' +
'<span class="' + valueClass + '">' + valueHtml + '</span>' +
'</div>';
}
return '<div class="data-card">' + rows + '</div>';
}
function copyCodeBlock(btn) {
const pre = btn.parentElement;
const code = pre.querySelector('code');
@@ -2060,8 +1817,6 @@ function createMessageElement(role, content) {
} else {
div.setAttribute('data-raw', content);
contentEl.innerHTML = renderMarkdown(content);
// Upgrade structured data (JSON objects, etc.) into styled cards
upgradeStructuredData(contentEl);
// Syntax highlighting for code blocks
if (typeof hljs !== 'undefined') {
requestAnimationFrame(() => {
@@ -2250,19 +2005,6 @@ function loadThreads() {
list.appendChild(item);
}
// Restore thread from URL hash if pending (deferred from restoreFromHash)
if (window._pendingThreadRestore) {
var pendingId = window._pendingThreadRestore;
window._pendingThreadRestore = null;
// Verify the thread exists in the loaded list
var found = (pendingId === assistantThreadId) ||
threads.some(function(t) { return t.id === pendingId; });
if (found) {
switchThread(pendingId);
return;
}
}
// Default to assistant thread on first load if no thread selected
if (!currentThreadId && assistantThreadId) {
switchToAssistant();
@@ -2302,7 +2044,6 @@ function switchToAssistant() {
oldestTimestamp = null;
loadHistory();
loadThreads();
updateHash();
if (window.innerWidth <= 768) {
const sidebar = document.getElementById('thread-sidebar');
sidebar.classList.remove('expanded-mobile');
@@ -2319,7 +2060,6 @@ function switchThread(threadId) {
oldestTimestamp = null;
loadHistory();
loadThreads();
updateHash();
if (window.innerWidth <= 768) {
const sidebar = document.getElementById('thread-sidebar');
sidebar.classList.remove('expanded-mobile');
@@ -2333,7 +2073,6 @@ function createNewThread() {
document.getElementById('chat-messages').innerHTML = '';
showWelcomeCard();
loadThreads();
updateHash();
}).catch((err) => {
showToast('Failed to create thread: ' + err.message, 'error');
});
@@ -2481,7 +2220,6 @@ function switchTab(tab) {
stopPairingPoll();
}
updateTabIndicator();
updateHash();
}
function updateTabIndicator() {
@@ -2627,7 +2365,6 @@ function toggleExpand(node) {
function readMemoryFile(path) {
currentMemoryPath = path;
updateHash();
// Update breadcrumb
document.getElementById('memory-breadcrumb-path').innerHTML = buildBreadcrumb(path);
document.getElementById('memory-edit-btn').style.display = 'inline-block';
@@ -3941,7 +3678,6 @@ function restartJob(jobId) {
function openJobDetail(jobId) {
currentJobId = jobId;
currentJobSubTab = 'activity';
updateHash();
apiFetch('/api/jobs/' + jobId).then((job) => {
renderJobDetail(job);
}).catch((err) => {
@@ -3954,7 +3690,6 @@ function closeJobDetail() {
currentJobId = null;
jobFilesTreeState = null;
loadJobs();
updateHash();
}
function renderJobDetail(job) {
@@ -4451,7 +4186,6 @@ function renderRoutinesList(routines) {
function openRoutineDetail(id) {
currentRoutineId = id;
updateHash();
apiFetch('/api/routines/' + id).then((routine) => {
renderRoutineDetail(routine);
}).catch((err) => {
@@ -4462,7 +4196,6 @@ function openRoutineDetail(id) {
function closeRoutineDetail() {
currentRoutineId = null;
loadRoutines();
updateHash();
}
function renderRoutineDetail(routine) {
@@ -4606,165 +4339,6 @@ function formatRelativeTime(isoString) {
return future ? I18n.t('time.daysFromNow', { n: days }) : I18n.t('time.daysAgo', { n: days });
}
// --- Users (admin) ---
function loadUsers() {
apiFetch('/api/admin/users').then(function(data) {
renderUsersList(data.users || []);
}).catch(function(err) {
var tbody = document.getElementById('users-tbody');
var empty = document.getElementById('users-empty');
if (tbody) tbody.innerHTML = '';
if (empty) {
empty.style.display = 'block';
if (err.status === 403 || err.status === 401) {
empty.textContent = I18n.t('users.adminRequired');
} else {
empty.textContent = I18n.t('users.failedToLoad') + ': ' + err.message;
}
}
});
}
function renderUsersList(users) {
var tbody = document.getElementById('users-tbody');
var empty = document.getElementById('users-empty');
if (!users || users.length === 0) {
tbody.innerHTML = '';
empty.style.display = 'block';
empty.textContent = I18n.t('users.emptyState');
return;
}
empty.style.display = 'none';
tbody.innerHTML = users.map(function(u) {
var statusClass = u.status === 'active' ? 'active' : 'failed';
var roleLabel = u.role === 'admin' ? '<span class="badge badge-admin">' + I18n.t('users.roleAdmin') + '</span>' : '<span class="badge">' + I18n.t('users.roleMember') + '</span>';
var actions = '';
if (u.status === 'active') {
actions += '<button class="btn-small btn-danger" data-action="suspend-user" data-user-id="' + escapeHtml(u.id) + '">' + I18n.t('users.suspend') + '</button> ';
} else {
actions += '<button class="btn-small btn-primary" data-action="activate-user" data-user-id="' + escapeHtml(u.id) + '">' + I18n.t('users.activate') + '</button> ';
}
if (u.role === 'member') {
actions += '<button class="btn-small" data-action="change-role" data-user-id="' + escapeHtml(u.id) + '" data-role="admin">' + I18n.t('users.makeAdmin') + '</button> ';
} else {
actions += '<button class="btn-small" data-action="change-role" data-user-id="' + escapeHtml(u.id) + '" data-role="member">' + I18n.t('users.makeMember') + '</button> ';
}
actions += '<button class="btn-small" data-action="create-token" data-user-id="' + escapeHtml(u.id) + '" data-user-name="' + escapeHtml(u.display_name) + '">' + I18n.t('users.addToken') + '</button>';
return '<tr>'
+ '<td class="user-id" title="' + escapeHtml(u.id) + '">' + escapeHtml(u.id.substring(0, 8)) + '…</td>'
+ '<td>' + escapeHtml(u.display_name) + '</td>'
+ '<td>' + escapeHtml(u.email || '—') + '</td>'
+ '<td>' + roleLabel + '</td>'
+ '<td><span class="status-badge ' + statusClass + '">' + escapeHtml(u.status) + '</span></td>'
+ '<td>' + (u.job_count || 0) + '</td>'
+ '<td>' + formatCost(u.total_cost) + '</td>'
+ '<td>' + (u.last_active_at ? formatRelativeTime(u.last_active_at) : '—') + '</td>'
+ '<td>' + formatRelativeTime(u.created_at) + '</td>'
+ '<td>' + actions + '</td>'
+ '</tr>';
}).join('');
}
function suspendUser(userId) {
apiFetch('/api/admin/users/' + userId + '/suspend', { method: 'POST' })
.then(function() { loadUsers(); })
.catch(function(e) { alert(I18n.t('users.failedSuspend') + ': ' + e.message); });
}
function activateUser(userId) {
apiFetch('/api/admin/users/' + userId + '/activate', { method: 'POST' })
.then(function() { loadUsers(); })
.catch(function(e) { alert(I18n.t('users.failedActivate') + ': ' + e.message); });
}
function changeUserRole(userId, newRole) {
apiFetch('/api/admin/users/' + userId, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: newRole })
})
.then(function() { loadUsers(); })
.catch(function(e) { alert(I18n.t('users.failedRoleChange') + ': ' + e.message); });
}
function createTokenForUser(userId, displayName) {
var tokenName = prompt('Token name for ' + displayName + ':', 'api-token');
if (!tokenName) return;
apiFetch('/api/tokens', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: tokenName, user_id: userId }),
}).then(function(data) {
showTokenBanner(data.token, I18n.t('users.tokenCreated'));
}).catch(function(e) { alert(I18n.t('users.failedCreate') + ': ' + e.message); });
}
function showTokenBanner(tokenValue, title) {
var banner = document.getElementById('users-token-result');
if (!banner) return;
var heading = title || I18n.t('users.tokenCreated');
var loginUrl = window.location.origin + '/?token=' + encodeURIComponent(tokenValue);
banner.style.display = 'block';
banner.innerHTML = '<strong>' + escapeHtml(heading) + '</strong> ' + I18n.t('users.tokenShareMessage') + '<br>'
+ '<code class="token-display" id="token-copy-value">' + escapeHtml(loginUrl) + '</code>'
+ '<button class="btn-small" id="token-copy-link">Copy Link</button>'
+ '<br><span style="font-size:0.8em;color:var(--text-muted)">' + I18n.t('users.rawToken') + ' ' + escapeHtml(tokenValue) + '</span>';
document.getElementById('token-copy-link').addEventListener('click', function() {
navigator.clipboard.writeText(loginUrl);
this.textContent = I18n.t('users.copied');
});
}
// Delegated click handler for user action buttons (CSP-safe, no inline onclick)
document.getElementById('users-table')?.addEventListener('click', function(e) {
var btn = e.target.closest('[data-action]');
if (!btn) return;
var action = btn.getAttribute('data-action');
var userId = btn.getAttribute('data-user-id');
var userName = btn.getAttribute('data-user-name');
if (action === 'suspend-user') suspendUser(userId);
else if (action === 'activate-user') activateUser(userId);
else if (action === 'change-role') changeUserRole(userId, btn.getAttribute('data-role'));
else if (action === 'create-token') createTokenForUser(userId, userName || '');
});
// Wire up Users tab create form
document.getElementById('users-create-btn')?.addEventListener('click', function() {
document.getElementById('users-create-form').style.display = 'flex';
document.getElementById('users-token-result').style.display = 'none';
document.getElementById('user-display-name').focus();
});
document.getElementById('users-create-cancel')?.addEventListener('click', function() {
document.getElementById('users-create-form').style.display = 'none';
});
document.getElementById('users-create-submit')?.addEventListener('click', function() {
var displayName = document.getElementById('user-display-name').value.trim();
var email = document.getElementById('user-email').value.trim();
var role = document.getElementById('user-role').value;
if (!displayName) { alert(I18n.t('users.displayNameRequired')); return; }
apiFetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
display_name: displayName,
email: email || undefined,
role: role,
}),
}).then(function(data) {
document.getElementById('users-create-form').style.display = 'none';
document.getElementById('user-display-name').value = '';
document.getElementById('user-email').value = '';
if (data.token) {
showTokenBanner(data.token, I18n.t('users.userCreated'));
}
loadUsers();
}).catch(function(e) { alert(I18n.t('users.failedCreate') + ': ' + e.message); });
});
// --- Gateway status widget ---
let gatewayStatusInterval = null;
@@ -5440,7 +5014,6 @@ function switchSettingsSubtab(subtab) {
document.querySelector('.settings-layout').classList.add('settings-detail-active');
}
loadSettingsSubtab(subtab);
updateHash();
}
function settingsBack() {
@@ -5455,7 +5028,6 @@ function loadSettingsSubtab(subtab) {
else if (subtab === 'extensions') { loadExtensions(); startPairingPoll(); }
else if (subtab === 'mcp') loadMcpServers();
else if (subtab === 'skills') loadSkills();
else if (subtab === 'users') loadUsers();
if (subtab !== 'extensions' && subtab !== 'channels') stopPairingPoll();
}
@@ -6585,177 +6157,3 @@ document.getElementById('settings-search-input').addEventListener('input', funct
activePanel.appendChild(empty);
}
});
// ==================== Widget Extension System ====================
//
// Provides a registration API for frontend widgets. Widgets are self-contained
// components that plug into named slots in the UI (tabs, sidebar, status bar, etc.).
//
// Widget authors call IronClaw.registerWidget({ id, name, slot, init, ... })
// from their module script. The init() function receives a container DOM element
// and the IronClaw.api object for authenticated fetch, event subscription, etc.
window.IronClaw = window.IronClaw || {};
IronClaw.widgets = new Map();
IronClaw._widgetInitQueue = [];
IronClaw._chatRenderers = [];
/**
* Register a widget component.
* @param {Object} def - Widget definition
* @param {string} def.id - Unique widget identifier
* @param {string} def.name - Display name
* @param {string} def.slot - Target slot ('tab', 'chat_header', etc.)
* @param {string} [def.icon] - Icon identifier
* @param {Function} def.init - Called with (container, api) when widget activates
* @param {Function} [def.activate] - Called when widget becomes visible
* @param {Function} [def.deactivate] - Called when widget is hidden
* @param {Function} [def.destroy] - Called when widget is removed
*/
IronClaw.registerWidget = function(def) {
if (!def.id || !def.init) {
console.error('[IronClaw] Widget registration requires id and init:', def);
return;
}
IronClaw.widgets.set(def.id, def);
if (def.slot === 'tab') {
_addWidgetTab(def);
}
};
/**
* Register a chat renderer for custom inline rendering of structured data.
*
* Chat renderers run against each assistant message. The first renderer
* whose `match()` returns true gets to transform the content.
*
* @param {Object} def - Renderer definition
* @param {string} def.id - Unique identifier
* @param {Function} def.match - (textContent, element) => boolean
* @param {Function} def.render - (element, textContent) => void (mutate element in place)
* @param {number} [def.priority=0] - Higher priority runs first
*/
IronClaw.registerChatRenderer = function(def) {
if (!def.id || !def.match || !def.render) {
console.error('[IronClaw] Chat renderer requires id, match, and render:', def);
return;
}
IronClaw._chatRenderers.push(def);
// Sort by priority (higher first)
IronClaw._chatRenderers.sort(function(a, b) {
return (b.priority || 0) - (a.priority || 0);
});
};
/**
* API object exposed to widgets for safe interaction with the app.
*/
IronClaw.api = {
/** Authenticated fetch wrapper — injects the session token. */
fetch: function(path, opts) {
opts = opts || {};
opts.headers = Object.assign({}, opts.headers || {}, {
'Authorization': 'Bearer ' + token
});
return fetch(path, opts);
},
/** Subscribe to an SSE/WebSocket event type. Returns an unsubscribe function. */
subscribe: function(eventType, handler) {
if (!window._widgetEventHandlers) window._widgetEventHandlers = {};
if (!window._widgetEventHandlers[eventType]) window._widgetEventHandlers[eventType] = [];
window._widgetEventHandlers[eventType].push(handler);
return function() {
var handlers = window._widgetEventHandlers[eventType];
if (handlers) {
var idx = handlers.indexOf(handler);
if (idx !== -1) handlers.splice(idx, 1);
}
};
},
/** Current theme information. */
theme: {
get current() { return document.documentElement.dataset.theme || 'dark'; }
},
/** Internationalization helper. */
i18n: {
t: function(key) { return (window.I18n && window.I18n.t) ? window.I18n.t(key) : key; }
},
/** Navigate to a tab by ID. */
navigate: function(tabId) {
if (typeof switchTab === 'function') switchTab(tabId);
}
};
/**
* Add a widget as a new tab in the tab bar.
* @private
*/
function _addWidgetTab(def) {
var tabBar = document.querySelector('.tab-bar');
var tabContent = document.querySelector('.tab-content') || document.getElementById('tab-content');
if (!tabBar || !tabContent) {
// DOM not ready yet — queue for later
IronClaw._widgetInitQueue.push(def);
return;
}
// Create tab button
var btn = document.createElement('button');
btn.className = 'tab-btn';
btn.dataset.tab = def.id;
btn.textContent = def.name;
if (def.icon) {
btn.dataset.icon = def.icon;
}
btn.addEventListener('click', function() {
if (typeof switchTab === 'function') switchTab(def.id);
});
// Insert before the settings tab (last built-in tab) or at the end
var settingsBtn = tabBar.querySelector('[data-tab="settings"]');
if (settingsBtn) {
tabBar.insertBefore(btn, settingsBtn);
} else {
tabBar.appendChild(btn);
}
// Create container panel
var panel = document.createElement('div');
panel.className = 'tab-panel';
panel.dataset.tab = def.id;
panel.dataset.widget = def.id;
panel.style.display = 'none';
tabContent.appendChild(panel);
// Initialize the widget
try {
def.init(panel, IronClaw.api);
} catch (e) {
console.error('[IronClaw] Widget "' + def.id + '" init failed:', e);
panel.innerHTML = '<div style="padding:2rem;color:var(--color-error,red);">Widget "' +
def.id + '" failed to load: ' + (e.message || e) + '</div>';
}
}
// Apply layout config if injected by the server
if (window.__IRONCLAW_LAYOUT__) {
(function() {
var layout = window.__IRONCLAW_LAYOUT__;
// Apply branding title
if (layout.branding && layout.branding.title) {
var titleEl = document.querySelector('.app-title');
if (titleEl) titleEl.textContent = layout.branding.title;
}
// Apply tab visibility
if (layout.tabs && layout.tabs.hidden) {
layout.tabs.hidden.forEach(function(tabId) {
var btn = document.querySelector('.tab-btn[data-tab="' + tabId + '"]');
if (btn) btn.style.display = 'none';
});
}
})();
}

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -9,6 +9,8 @@ I18n.register('en', {
'auth.connect': 'Connect',
'auth.errorRequired': 'Token required',
'auth.errorInvalid': 'Invalid token',
'auth.hint': 'Enter the GATEWAY_AUTH_TOKEN from your .env file',
// Chat
'chat.inputPlaceholder': 'Message or / for commands...',
@@ -42,46 +44,7 @@ I18n.register('en', {
'settings.channels': 'Channels',
'settings.networking': 'Networking',
'settings.mcp': 'MCP',
'settings.users': 'Users',
// Users Tab
'users.heading': 'User Management',
'users.newUser': '+ New User',
'users.displayNamePlaceholder': 'Display name',
'users.emailPlaceholder': 'Email (optional)',
'users.roleMember': 'Member',
'users.roleAdmin': 'Admin',
'users.create': 'Create',
'users.cancel': 'Cancel',
'users.emptyState': 'No users found. Create the first user to get started.',
'users.adminRequired': 'Admin access required to manage users.',
'users.failedToLoad': 'Failed to load users',
'users.suspend': 'Suspend',
'users.activate': 'Activate',
'users.addToken': '+ Token',
'users.failedSuspend': 'Failed to suspend user',
'users.failedActivate': 'Failed to activate user',
'users.makeAdmin': 'Make Admin',
'users.makeMember': 'Make Member',
'users.failedRoleChange': 'Failed to change role',
'users.userCreated': 'User created!',
'users.tokenCreated': 'Token created!',
'users.tokenShareMessage': "Share this login link — it won't be shown again:",
'users.rawToken': 'Raw token:',
'users.copied': 'Copied!',
'users.displayNameRequired': 'Display name is required',
'users.failedCreate': 'Failed to create user',
'users.columns.id': 'ID',
'users.columns.displayName': 'Display Name',
'users.columns.email': 'Email',
'users.columns.role': 'Role',
'users.columns.status': 'Status',
'users.columns.jobs': 'Jobs',
'users.columns.cost': 'Cost',
'users.columns.lastActive': 'Last Active',
'users.columns.created': 'Created',
'users.columns.actions': 'Actions',
// Status
'status.connected': 'Connected',
'status.disconnected': 'Disconnected',
@@ -9,6 +9,8 @@ I18n.register('zh-CN', {
'auth.connect': '连接',
'auth.errorRequired': '请输入令牌',
'auth.errorInvalid': '令牌无效',
'auth.hint': '输入 .env 配置文件中的 GATEWAY_AUTH_TOKEN',
// 聊天
'chat.inputPlaceholder': '输入消息或 / 以使用命令...',
@@ -42,46 +44,7 @@ I18n.register('zh-CN', {
'settings.channels': '频道',
'settings.networking': '网络',
'settings.mcp': 'MCP',
'settings.users': '用户管理',
// 用户管理标签页
'users.heading': '用户管理',
'users.newUser': '+ 新用户',
'users.displayNamePlaceholder': '显示名称',
'users.emailPlaceholder': '邮箱(可选)',
'users.roleMember': '成员',
'users.roleAdmin': '管理员',
'users.create': '创建',
'users.cancel': '取消',
'users.emptyState': '暂无用户。创建第一个用户以开始使用。',
'users.adminRequired': '需要管理员权限来管理用户。',
'users.failedToLoad': '加载用户列表失败',
'users.suspend': '停用',
'users.activate': '启用',
'users.addToken': '+ 令牌',
'users.failedSuspend': '停用用户失败',
'users.failedActivate': '启用用户失败',
'users.makeAdmin': '设为管理员',
'users.makeMember': '设为成员',
'users.failedRoleChange': '更改角色失败',
'users.userCreated': '用户已创建!',
'users.tokenCreated': '令牌已创建!',
'users.tokenShareMessage': '分享此登录链接——此链接只会显示一次:',
'users.rawToken': '原始令牌:',
'users.copied': '已复制!',
'users.displayNameRequired': '显示名称为必填项',
'users.failedCreate': '创建用户失败',
'users.columns.id': 'ID',
'users.columns.displayName': '显示名称',
'users.columns.email': '邮箱',
'users.columns.role': '角色',
'users.columns.status': '状态',
'users.columns.jobs': '任务',
'users.columns.cost': '费用',
'users.columns.lastActive': '最近活跃',
'users.columns.created': '创建时间',
'users.columns.actions': '操作',
// 状态
'status.connected': '已连接',
'status.disconnected': '已断开',
@@ -41,6 +41,7 @@
<button id="auth-connect-btn" data-i18n="auth.connect">Connect</button>
</div>
<div id="auth-error"></div>
<p class="auth-hint" data-i18n="auth.hint">Enter the GATEWAY_AUTH_TOKEN from your .env configuration.</p>
</div>
</div>
@@ -292,7 +293,6 @@
<button class="settings-subtab" data-settings-subtab="extensions" data-i18n="tab.extensions">Extensions</button>
<button class="settings-subtab" data-settings-subtab="mcp" data-i18n="settings.mcp">MCP</button>
<button class="settings-subtab" data-settings-subtab="skills" data-i18n="tab.skills">Skills</button>
<button class="settings-subtab" data-settings-subtab="users" data-i18n="settings.users">Users</button>
<button class="settings-theme-toggle" id="settings-theme-toggle" data-i18n="theme.tooltipSystem" title="Toggle theme">Theme</button>
</div>
<div class="settings-content">
@@ -390,29 +390,6 @@
</div>
</div>
</div>
<div class="settings-subpanel" id="settings-users">
<div class="users-container">
<div class="users-header">
<h3 data-i18n="users.heading">User Management</h3>
<button id="users-create-btn" class="btn-primary" data-i18n="users.newUser">+ New User</button>
</div>
<div id="users-create-form" style="display:none" class="users-form" autocomplete="off">
<input type="text" id="user-display-name" data-i18n-placeholder="users.displayNamePlaceholder" placeholder="Display name" autocomplete="off" />
<input type="text" id="user-email" data-i18n-placeholder="users.emailPlaceholder" placeholder="Email (optional)" autocomplete="off" />
<select id="user-role"><option value="member" data-i18n="users.roleMember">Member</option><option value="admin" data-i18n="users.roleAdmin">Admin</option></select>
<button id="users-create-submit" class="btn-primary" data-i18n="users.create">Create</button>
<button id="users-create-cancel" class="btn-secondary" data-i18n="users.cancel">Cancel</button>
</div>
<div id="users-token-result" style="display:none" class="users-token-banner"></div>
<table class="routines-table" id="users-table">
<thead><tr>
<th data-i18n="users.columns.id">ID</th><th data-i18n="users.columns.displayName">Display Name</th><th data-i18n="users.columns.email">Email</th><th data-i18n="users.columns.role">Role</th><th data-i18n="users.columns.status">Status</th><th data-i18n="users.columns.jobs">Jobs</th><th data-i18n="users.columns.cost">Cost</th><th data-i18n="users.columns.lastActive">Last Active</th><th data-i18n="users.columns.created">Created</th><th data-i18n="users.columns.actions">Actions</th>
</tr></thead>
<tbody id="users-tbody"></tbody>
</table>
<div id="users-empty" class="empty-state" style="display:none" data-i18n="users.emptyState">No users found. Create the first user to get started.</div>
</div>
</div>
</div>
</div>
</div>
@@ -2106,90 +2106,6 @@ body {
background: var(--accent-subtle);
}
/* --- Data Cards (inline structured data) --- */
.data-card {
display: flex;
flex-direction: column;
gap: 0;
margin: 8px 0;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
border-left: 3px solid var(--accent);
overflow: hidden;
}
.data-card-row {
display: flex;
align-items: baseline;
gap: var(--space-3);
padding: 6px 14px;
border-bottom: 1px solid var(--border);
}
.data-card-row:last-child {
border-bottom: none;
}
.data-card-label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
text-transform: capitalize;
min-width: 80px;
flex-shrink: 0;
}
.data-card-value {
font-size: var(--text-sm);
color: var(--text-primary);
word-break: break-word;
}
.data-card-value code {
font-family: var(--font-mono);
font-size: 12px;
padding: 1px 5px;
background: var(--bg-secondary);
border-radius: var(--radius-sm);
}
.data-card-id {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
padding: 1px 5px;
background: var(--bg-secondary);
border-radius: var(--radius-sm);
}
/* Status badges */
.status-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
text-transform: capitalize;
}
.status-success {
background: rgba(52, 211, 153, 0.15);
color: var(--success, #34d399);
}
.status-error {
background: rgba(248, 113, 113, 0.15);
color: var(--error, #f87171);
}
.status-pending {
background: rgba(251, 191, 36, 0.15);
color: var(--warning, #fbbf24);
}
/* Clickable job rows */
.job-row {
cursor: pointer;
@@ -5513,22 +5429,3 @@ body.theme-transition *:not(svg):not(path):not(line):not(circle):not(rect) {
--text-muted: #a1a1aa;
}
}
/* --- Users Tab --- */
.users-container { padding: 1rem; }
.users-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
.users-header h3 { margin: 0; font-size: 1.1rem; }
.users-form { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; }
.users-form input, .users-form select { padding: 0.4rem 0.6rem; border-radius: 6px; border: 1px solid var(--border); background: var(--bg-secondary); color: var(--text-primary); font-size: 0.85rem; }
.users-token-banner { background: var(--bg-tertiary); border: 1px solid var(--accent); border-radius: 8px; padding: 0.75rem 1rem; margin-bottom: 1rem; font-size: 0.85rem; }
.token-display { display: inline-block; padding: 0.3rem 0.6rem; background: var(--bg-primary); border-radius: 4px; font-family: var(--font-mono); word-break: break-all; margin: 0.4rem 0; user-select: all; }
.user-id { font-family: var(--font-mono); font-size: 0.8rem; color: var(--text-muted); }
.badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 10px; font-size: 0.75rem; background: var(--bg-tertiary); color: var(--text-secondary); }
.badge-admin { background: var(--accent); color: #fff; }
.btn-small { padding: 0.25rem 0.5rem; font-size: 0.75rem; border-radius: 4px; border: 1px solid var(--border); background: var(--bg-secondary); color: var(--text-primary); cursor: pointer; }
.btn-small:hover { background: var(--bg-tertiary); }
.btn-danger { border-color: #ef4444; color: #ef4444; }
.btn-danger:hover { background: #ef4444; color: #fff; }
.btn-primary { background: var(--accent); color: #fff; border: none; padding: 0.4rem 0.8rem; border-radius: 6px; cursor: pointer; font-size: 0.85rem; }
.btn-primary:hover { opacity: 0.9; }
.btn-secondary { background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border); padding: 0.4rem 0.8rem; border-radius: 6px; cursor: pointer; font-size: 0.85rem; }
+3 -4
View File
@@ -77,6 +77,7 @@ impl TestGatewayBuilder {
job_manager: None,
prompt_queue: None,
owner_id: self.user_id.clone(),
default_sender_id: self.user_id,
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: self.llm_provider,
@@ -91,8 +92,6 @@ impl TestGatewayBuilder {
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: crate::channels::web::server::ActiveConfigSnapshot::default(),
secrets_store: None,
db_auth: None,
})
}
@@ -107,7 +106,7 @@ impl TestGatewayBuilder {
let addr: SocketAddr = "127.0.0.1:0"
.parse()
.expect("hard-coded address must parse"); // safety: constant literal
let bound = start_server(addr, state.clone(), auth.into()).await?;
let bound = start_server(addr, state.clone(), auth).await?;
Ok((bound, state))
}
@@ -121,7 +120,7 @@ impl TestGatewayBuilder {
let addr: SocketAddr = "127.0.0.1:0"
.parse()
.expect("hard-coded address must parse"); // safety: constant literal
let bound = start_server(addr, state.clone(), auth.into()).await?;
let bound = start_server(addr, state.clone(), auth).await?;
Ok((bound, state))
}
}
+40 -159
View File
@@ -16,6 +16,7 @@ use axum::routing::{delete, get, post};
use tower::ServiceExt;
use uuid::Uuid;
use crate::channels::web::GatewayChannel;
use crate::channels::web::auth::{
AuthenticatedUser, MultiAuthState, UserIdentity, auth_middleware,
};
@@ -23,6 +24,7 @@ use crate::channels::web::server::{
ActiveConfigSnapshot, GatewayState, PerUserRateLimiter, PromptQueue, RateLimiter, WorkspacePool,
};
use crate::channels::web::sse::SseManager;
use crate::config::GatewayConfig;
// ── Helpers ────────────────────────────────────────────────────────────
@@ -33,7 +35,6 @@ fn two_user_auth() -> MultiAuthState {
"tok-alice".to_string(),
UserIdentity {
user_id: "alice".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec!["shared".to_string()],
},
);
@@ -41,7 +42,6 @@ fn two_user_auth() -> MultiAuthState {
"tok-bob".to_string(),
UserIdentity {
user_id: "bob".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec!["shared".to_string(), "alice".to_string()],
},
);
@@ -67,6 +67,7 @@ fn build_state(
job_manager: None,
prompt_queue,
owner_id: "test".to_string(),
default_sender_id: "test".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: None,
llm_provider: None,
@@ -81,11 +82,43 @@ fn build_state(
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: ActiveConfigSnapshot::default(),
secrets_store: None,
db_auth: None,
})
}
fn gateway_config() -> GatewayConfig {
GatewayConfig {
host: "127.0.0.1".to_string(),
port: 3000,
auth_token: Some("gateway-auth".to_string()),
user_id: "gateway-sender".to_string(),
workspace_read_scopes: Vec::new(),
memory_layers: Vec::new(),
user_tokens: None,
}
}
#[test]
fn with_owner_scope_updates_gateway_owner_scope_in_multi_user_mode() {
let mut gateway = GatewayChannel::new(gateway_config());
gateway.auth = two_user_auth();
gateway.config.user_tokens = Some(HashMap::new());
let gateway = gateway.with_owner_scope("owner-scope");
assert_eq!(gateway.state.owner_id, "owner-scope");
assert_eq!(gateway.state.default_sender_id, "gateway-sender");
let alice = gateway
.auth
.authenticate("tok-alice")
.expect("alice token should remain valid");
let bob = gateway
.auth
.authenticate("tok-bob")
.expect("bob token should remain valid");
assert_eq!(alice.user_id, "alice");
assert_eq!(bob.user_id, "bob");
}
/// Create a libSQL-backed test database in a temporary directory.
///
/// Returns the database and a `TempDir` guard — the database file is
@@ -192,7 +225,6 @@ mod workspace_pool {
);
let identity = UserIdentity {
user_id: "alice".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec![],
};
let ws = pool.get_or_create(&identity).await;
@@ -221,7 +253,6 @@ mod workspace_pool {
);
let identity = UserIdentity {
user_id: "alice".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec![],
};
let ws = pool.get_or_create(&identity).await;
@@ -245,7 +276,6 @@ mod workspace_pool {
);
let identity = UserIdentity {
user_id: "bob".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec!["alice".to_string(), "shared".to_string()],
};
let ws = pool.get_or_create(&identity).await;
@@ -272,12 +302,10 @@ mod workspace_pool {
);
let alice_id = UserIdentity {
user_id: "alice".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec![],
};
let bob_id = UserIdentity {
user_id: "bob".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec![],
};
@@ -309,7 +337,6 @@ mod workspace_pool {
);
let identity = UserIdentity {
user_id: "alice".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec!["token-scope".to_string()],
};
let ws = pool.get_or_create(&identity).await;
@@ -350,10 +377,7 @@ mod jobs_isolation {
.route("/api/jobs/{id}/cancel", post(jobs_cancel_handler))
.route("/api/jobs/{id}/restart", post(jobs_restart_handler))
.route("/api/jobs/{id}/prompt", post(jobs_prompt_handler))
.layer(middleware::from_fn_with_state(
crate::channels::web::auth::CombinedAuthState::from(auth),
auth_middleware,
))
.layer(middleware::from_fn_with_state(auth, auth_middleware))
.with_state(state)
}
@@ -559,10 +583,7 @@ mod routines_isolation {
.route("/api/routines/{id}", get(routines_detail_handler))
.route("/api/routines/{id}/toggle", post(routines_toggle_handler))
.route("/api/routines/{id}", delete(routines_delete_handler))
.layer(middleware::from_fn_with_state(
crate::channels::web::auth::CombinedAuthState::from(auth),
auth_middleware,
))
.layer(middleware::from_fn_with_state(auth, auth_middleware))
.with_state(state)
}
@@ -687,10 +708,7 @@ mod auth_enforcement {
.route("/api/logs/level", get(authed_handler).put(authed_handler))
// Gateway status
.route("/api/gateway/status", get(authed_handler))
.layer(middleware::from_fn_with_state(
crate::channels::web::auth::CombinedAuthState::from(auth),
auth_middleware,
))
.layer(middleware::from_fn_with_state(auth, auth_middleware))
.with_state(state)
}
@@ -813,140 +831,3 @@ mod auth_enforcement {
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
}
// ═══════════════════════════════════════════════════════════════════════
// Admin Endpoint Role Enforcement Tests
// ═══════════════════════════════════════════════════════════════════════
mod admin_role_enforcement {
use super::*;
use crate::channels::web::handlers::users::{
users_activate_handler, users_detail_handler, users_list_handler, users_suspend_handler,
users_update_handler,
};
use axum::routing::patch;
/// Build a router with admin user endpoints behind multi-user auth.
/// Uses a member-role token and an admin-role token.
fn admin_router() -> Router {
let mut tokens = HashMap::new();
tokens.insert(
"tok-admin".to_string(),
UserIdentity {
user_id: "admin-user".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec![],
},
);
tokens.insert(
"tok-member".to_string(),
UserIdentity {
user_id: "member-user".to_string(),
role: "member".to_string(),
workspace_read_scopes: vec![],
},
);
let auth = MultiAuthState::multi(tokens);
let state = build_state(None, None);
Router::new()
.route("/api/admin/users", get(users_list_handler))
.route("/api/admin/users/{id}", get(users_detail_handler))
.route("/api/admin/users/{id}", patch(users_update_handler))
.route("/api/admin/users/{id}/suspend", post(users_suspend_handler))
.route(
"/api/admin/users/{id}/activate",
post(users_activate_handler),
)
.layer(middleware::from_fn_with_state(
crate::channels::web::auth::CombinedAuthState::from(auth),
auth_middleware,
))
.with_state(state)
}
/// Assert a request returns FORBIDDEN for a member token.
async fn assert_forbidden_for_member(app: &Router, method: Method, uri: &str) {
let req = Request::builder()
.method(method)
.uri(uri)
.header("Authorization", "Bearer tok-member")
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"expected 403 for member on {}",
uri
);
}
#[tokio::test]
async fn test_admin_user_endpoints_reject_member_role() {
let app = admin_router();
assert_forbidden_for_member(&app, Method::GET, "/api/admin/users").await;
assert_forbidden_for_member(&app, Method::GET, "/api/admin/users/some-id").await;
assert_forbidden_for_member(&app, Method::POST, "/api/admin/users/some-id/suspend").await;
assert_forbidden_for_member(&app, Method::POST, "/api/admin/users/some-id/activate").await;
}
#[tokio::test]
async fn test_admin_user_endpoints_accept_admin_role() {
let app = admin_router();
// Admin token should pass auth (will get 503 since no DB, but not 403).
let req = Request::builder()
.uri("/api/admin/users")
.header("Authorization", "Bearer tok-admin")
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_ne!(
resp.status(),
StatusCode::FORBIDDEN,
"admin should not get 403"
);
}
}
// ═══════════════════════════════════════════════════════════════════════
// DbAuthenticator Cache Bounded Tests
// ═══════════════════════════════════════════════════════════════════════
mod db_auth_cache {
use super::*;
use std::time::Instant;
#[tokio::test]
async fn test_cache_bounded_by_max_entries() {
// Access the internal cache and verify LRU eviction.
// We can't easily test through `authenticate()` since it hits the DB,
// so we test the LRU cache directly.
let cap = std::num::NonZeroUsize::new(4).unwrap(); // safety: test-only, 4 is non-zero
let cache: lru::LruCache<[u8; 32], (UserIdentity, Instant)> = lru::LruCache::new(cap);
let cache = Arc::new(tokio::sync::RwLock::new(cache));
{
let mut c = cache.write().await;
for i in 0..10u8 {
let mut hash = [0u8; 32];
hash[0] = i;
c.put(
hash,
(
UserIdentity {
user_id: format!("user-{i}"),
role: "member".to_string(),
workspace_read_scopes: vec![],
},
Instant::now(),
),
);
}
// Cache must be bounded at capacity, not grown to 10.
assert_eq!(c.len(), 4, "cache should be bounded to capacity"); // safety: test assertion
}
}
}
+1 -2
View File
@@ -521,6 +521,7 @@ mod tests {
prompt_queue: None,
scheduler: None,
owner_id: "test".to_string(),
default_sender_id: "test".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: None,
@@ -534,8 +535,6 @@ mod tests {
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: crate::channels::web::server::ActiveConfigSnapshot::default(),
secrets_store: None,
db_auth: None,
}
}
}
+1 -1
View File
@@ -352,7 +352,7 @@ pub async fn run_routines_cli(
.await
.map_err(|e| anyhow::anyhow!("{e:#}"))?;
let user_id = std::env::var("IRONCLAW_OWNER_ID").unwrap_or_else(|_| "default".to_string());
let user_id = std::env::var("GATEWAY_USER_ID").unwrap_or_else(|_| "default".to_string());
run_routines_command(routines_cmd.clone(), db, &user_id).await
}
+4 -122
View File
@@ -569,42 +569,6 @@ pub async fn sweep_expired_flows(registry: &PendingOAuthRegistry) {
const HOSTED_STATE_PREFIX: &str = "ic2";
const HOSTED_STATE_CHECKSUM_BYTES: usize = 12;
/// Maximum length for a legacy flow ID or instance name.
const LEGACY_STATE_MAX_LEN: usize = 128;
/// Minimum length for a legacy flow ID.
const LEGACY_STATE_MIN_LEN: usize = 8;
/// Validate that a legacy state component (flow_id or instance_name) contains
/// only safe characters: alphanumeric, dash, underscore.
fn is_valid_legacy_state_component(s: &str) -> bool {
!s.is_empty()
&& s.len() <= LEGACY_STATE_MAX_LEN
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
fn validate_legacy_flow_id(flow_id: &str) -> Result<(), String> {
if flow_id.len() < LEGACY_STATE_MIN_LEN {
return Err(format!(
"Legacy OAuth flow_id too short ({} chars, minimum {LEGACY_STATE_MIN_LEN})",
flow_id.len()
));
}
if flow_id.len() > LEGACY_STATE_MAX_LEN {
return Err(format!(
"Legacy OAuth flow_id too long ({} chars, maximum {LEGACY_STATE_MAX_LEN})",
flow_id.len()
));
}
if !flow_id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
{
return Err("Legacy OAuth flow_id contains invalid characters".to_string());
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DecodedHostedOAuthState {
pub flow_id: String,
@@ -689,17 +653,6 @@ pub fn decode_hosted_oauth_state(state: &str) -> Result<DecodedHostedOAuthState,
if flow_id.is_empty() {
return Err("Hosted OAuth legacy state is missing flow_id".to_string());
}
validate_legacy_flow_id(flow_id)?;
if !instance_name.is_empty() && !is_valid_legacy_state_component(instance_name) {
return Err(format!(
"Legacy OAuth instance name contains invalid characters or exceeds max length ({LEGACY_STATE_MAX_LEN})"
));
}
tracing::debug!(
flow_id,
instance_name,
"Decoded legacy prefixed OAuth state"
);
return Ok(DecodedHostedOAuthState {
flow_id: flow_id.to_string(),
instance_name: if instance_name.is_empty() {
@@ -715,9 +668,6 @@ pub fn decode_hosted_oauth_state(state: &str) -> Result<DecodedHostedOAuthState,
return Err("Hosted OAuth state is empty".to_string());
}
validate_legacy_flow_id(state)?;
tracing::debug!(flow_id = state, "Decoded legacy raw OAuth state");
Ok(DecodedHostedOAuthState {
flow_id: state.to_string(),
instance_name: None,
@@ -1784,13 +1734,13 @@ mod tests {
fn test_decode_hosted_oauth_state_accepts_legacy_formats() {
use crate::cli::oauth_defaults::decode_hosted_oauth_state;
let decoded = decode_hosted_oauth_state("kind-deer:abc12345").expect("legacy prefixed");
assert_eq!(decoded.flow_id, "abc12345");
let decoded = decode_hosted_oauth_state("kind-deer:abc123").expect("legacy prefixed");
assert_eq!(decoded.flow_id, "abc123");
assert_eq!(decoded.instance_name.as_deref(), Some("kind-deer"));
assert!(decoded.is_legacy);
let decoded = decode_hosted_oauth_state("abc12345").expect("legacy raw");
assert_eq!(decoded.flow_id, "abc12345");
let decoded = decode_hosted_oauth_state("abc123").expect("legacy raw");
assert_eq!(decoded.flow_id, "abc123");
assert_eq!(decoded.instance_name, None);
assert!(decoded.is_legacy);
}
@@ -1914,72 +1864,4 @@ mod tests {
assert_eq!(decoded_no_instance.instance_name, None);
assert!(!decoded_no_instance.is_legacy);
}
/// Legacy flow IDs that are too short must be rejected (#1443).
#[test]
fn test_legacy_state_rejects_short_flow_id() {
use crate::cli::oauth_defaults::decode_hosted_oauth_state;
let err = decode_hosted_oauth_state("abc").expect_err("short raw flow_id");
assert!(err.contains("too short"), "unexpected error: {err}");
let err = decode_hosted_oauth_state("inst:abc").expect_err("short prefixed flow_id");
assert!(err.contains("too short"), "unexpected error: {err}");
}
/// Legacy flow IDs with invalid characters must be rejected (#1443).
#[test]
fn test_legacy_state_rejects_invalid_characters() {
use crate::cli::oauth_defaults::decode_hosted_oauth_state;
let err = decode_hosted_oauth_state("flow id with spaces!").expect_err("spaces in flow_id");
assert!(
err.contains("invalid characters"),
"unexpected error: {err}"
);
let err = decode_hosted_oauth_state("inst:flow/id?bad=yes")
.expect_err("special chars in prefixed flow_id");
assert!(
err.contains("invalid characters"),
"unexpected error: {err}"
);
}
/// Legacy instance names with invalid characters must be rejected (#1444).
#[test]
fn test_legacy_state_rejects_invalid_instance_name() {
use crate::cli::oauth_defaults::decode_hosted_oauth_state;
let err = decode_hosted_oauth_state("bad instance!:valid-flow-id-12345")
.expect_err("invalid instance name");
assert!(err.contains("instance name"), "unexpected error: {err}");
}
/// Excessively long legacy flow IDs must be rejected (#1443).
#[test]
fn test_legacy_state_rejects_oversized_flow_id() {
use crate::cli::oauth_defaults::decode_hosted_oauth_state;
let long_id = "a".repeat(200);
let err = decode_hosted_oauth_state(&long_id).expect_err("oversized flow_id");
assert!(err.contains("too long"), "unexpected error: {err}");
}
/// Valid legacy flow IDs at boundary lengths are accepted.
#[test]
fn test_legacy_state_accepts_boundary_lengths() {
use crate::cli::oauth_defaults::decode_hosted_oauth_state;
// Exactly 8 chars (minimum)
let decoded = decode_hosted_oauth_state("abcd1234").expect("8-char flow_id");
assert_eq!(decoded.flow_id, "abcd1234");
assert!(decoded.is_legacy);
// Exactly 128 chars (maximum)
let max_id = "a".repeat(128);
let decoded = decode_hosted_oauth_state(&max_id).expect("128-char flow_id");
assert_eq!(decoded.flow_id, max_id);
assert!(decoded.is_legacy);
}
}
+5 -7
View File
@@ -1,6 +1,6 @@
use std::time::Duration;
use crate::config::helpers::{parse_bool_env, parse_option_env, parse_optional_env};
use crate::config::helpers::{optional_env, parse_bool_env, parse_option_env, parse_optional_env};
use crate::error::ConfigError;
use crate::settings::Settings;
@@ -31,12 +31,10 @@ pub struct AgentConfig {
pub auto_approve_tools: bool,
/// Default timezone for new sessions (IANA name, e.g. "America/New_York").
pub default_timezone: String,
/// Maximum concurrent jobs per user. None = use global max_parallel_jobs.
pub max_jobs_per_user: Option<usize>,
/// Maximum tokens per job (0 = unlimited).
pub max_tokens_per_job: u64,
/// Whether the deployment is multi-tenant (multiple users sharing one
/// instance). Defaults to false; can be set via AGENT_MULTI_TENANT env var.
/// instance). Auto-detected from GATEWAY_USER_TOKENS presence.
pub multi_tenant: bool,
/// Maximum concurrent LLM calls per user. None = use default (4).
pub max_llm_concurrent_per_user: Option<usize>,
@@ -64,7 +62,6 @@ impl AgentConfig {
max_tool_iterations: 10,
auto_approve_tools: true,
default_timezone: "UTC".to_string(),
max_jobs_per_user: None,
max_tokens_per_job: 0,
multi_tenant: false,
max_llm_concurrent_per_user: None,
@@ -125,12 +122,13 @@ impl AgentConfig {
}
tz
},
max_jobs_per_user: parse_option_env("MAX_JOBS_PER_USER")?,
max_tokens_per_job: parse_optional_env(
"AGENT_MAX_TOKENS_PER_JOB",
settings.agent.max_tokens_per_job,
)?,
multi_tenant: parse_bool_env("AGENT_MULTI_TENANT", false)?,
// Auto-detected from GATEWAY_USER_TOKENS presence. Not a separate
// knob — multi-tenant mode is always implied by configuring user tokens.
multi_tenant: optional_env("GATEWAY_USER_TOKENS")?.is_some(),
max_llm_concurrent_per_user: parse_option_env("TENANT_MAX_LLM_CONCURRENT")?,
max_jobs_concurrent_per_user: parse_option_env("TENANT_MAX_JOBS_CONCURRENT")?,
})
+65 -4
View File
@@ -1,11 +1,13 @@
use std::collections::HashMap;
use std::path::PathBuf;
use secrecy::SecretString;
use serde::Deserialize;
use crate::bootstrap::ironclaw_base_dir;
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
use crate::error::ConfigError;
use crate::settings::Settings;
use secrecy::SecretString;
/// Channel configurations.
#[derive(Debug, Clone)]
@@ -43,15 +45,27 @@ pub struct GatewayConfig {
pub port: u16,
/// Bearer token for authentication. Random hex generated at startup if unset.
pub auth_token: Option<String>,
pub user_id: String,
/// Additional user scopes for workspace reads.
///
/// When set, the workspace will be able to read (search, read, list) from
/// these additional user scopes while writes remain isolated to the
/// authenticated user's own scope.
/// these additional user scopes while writes remain isolated to `user_id`.
/// Parsed from `WORKSPACE_READ_SCOPES` (comma-separated).
pub workspace_read_scopes: Vec<String>,
/// Memory layer definitions (JSON in env var, or from external config).
pub memory_layers: Vec<crate::workspace::layer::MemoryLayer>,
/// Multi-user token map. When set, each token maps to a user identity.
/// Parsed from `GATEWAY_USER_TOKENS` (JSON string). When absent, falls back
/// to single-user mode via `auth_token` + `user_id`.
pub user_tokens: Option<HashMap<String, UserTokenConfig>>,
}
/// Per-user token configuration for multi-user mode.
#[derive(Debug, Clone, Deserialize)]
pub struct UserTokenConfig {
pub user_id: String,
#[serde(default)]
pub workspace_read_scopes: Vec<String>,
}
/// Signal channel configuration (signal-cli daemon HTTP/JSON-RPC).
@@ -118,6 +132,10 @@ impl ChannelsConfig {
let gateway_enabled = parse_bool_env("GATEWAY_ENABLED", cs.gateway_enabled)?;
let gateway = if gateway_enabled {
let user_id = optional_env("GATEWAY_USER_ID")?
.or_else(|| cs.gateway_user_id.clone())
.unwrap_or_else(|| owner_id.to_string());
let memory_layers: Vec<crate::workspace::layer::MemoryLayer> =
match optional_env("MEMORY_LAYERS")? {
Some(json_str) => {
@@ -126,7 +144,7 @@ impl ChannelsConfig {
message: format!("must be valid JSON array of layer objects: {e}"),
})?
}
None => crate::workspace::layer::MemoryLayer::default_for_user(owner_id),
None => crate::workspace::layer::MemoryLayer::default_for_user(&user_id),
};
// Validate layer names and scopes
@@ -178,6 +196,41 @@ impl ChannelsConfig {
}
}
let user_tokens: Option<HashMap<String, UserTokenConfig>> =
match optional_env("GATEWAY_USER_TOKENS")? {
Some(json_str) => {
let tokens: HashMap<String, UserTokenConfig> = serde_json::from_str(
&json_str,
)
.map_err(|e| ConfigError::InvalidValue {
key: "GATEWAY_USER_TOKENS".to_string(),
message: format!(
"must be valid JSON object mapping tokens to user configs: {e}"
),
})?;
if tokens.is_empty() {
return Err(ConfigError::InvalidValue {
key: "GATEWAY_USER_TOKENS".to_string(),
message:
"token map is empty — remove the variable to use single-user mode"
.to_string(),
});
}
for (tok, cfg) in &tokens {
if cfg.user_id.trim().is_empty() {
return Err(ConfigError::InvalidValue {
key: "GATEWAY_USER_TOKENS".to_string(),
message: format!(
"token '{}...' has an empty user_id",
&tok[..tok.len().min(8)]
),
});
}
}
Some(tokens)
}
None => None,
};
let workspace_read_scopes: Vec<String> = optional_env("WORKSPACE_READ_SCOPES")?
.map(|s| {
s.split(',')
@@ -205,8 +258,10 @@ impl ChannelsConfig {
)?,
auth_token: optional_env("GATEWAY_AUTH_TOKEN")?
.or_else(|| cs.gateway_auth_token.clone()),
user_id,
workspace_read_scopes,
memory_layers,
user_tokens,
})
} else {
None
@@ -361,12 +416,15 @@ mod tests {
host: "127.0.0.1".to_string(),
port: 3000,
auth_token: Some("tok-abc".to_string()),
user_id: "default".to_string(),
workspace_read_scopes: vec![],
memory_layers: vec![],
user_tokens: None,
};
assert_eq!(cfg.host, "127.0.0.1");
assert_eq!(cfg.port, 3000);
assert_eq!(cfg.auth_token.as_deref(), Some("tok-abc"));
assert_eq!(cfg.user_id, "default");
}
#[test]
@@ -375,8 +433,10 @@ mod tests {
host: "0.0.0.0".to_string(),
port: 3001,
auth_token: None,
user_id: "anon".to_string(),
workspace_read_scopes: vec![],
memory_layers: vec![],
user_tokens: None,
};
assert!(cfg.auth_token.is_none());
}
@@ -503,6 +563,7 @@ mod tests {
assert_eq!(gateway.host, "127.0.0.3");
assert_eq!(gateway.port, 9191);
assert_eq!(gateway.auth_token.as_deref(), Some("tok"));
assert_eq!(gateway.user_id, "owner-scope");
let signal = cfg.signal.expect("signal config");
assert_eq!(signal.account, "+15551234567");
+8 -3
View File
@@ -21,8 +21,8 @@ pub struct HeartbeatConfig {
pub quiet_hours_end: Option<u32>,
/// Timezone for fire_at and quiet hours evaluation (IANA name).
pub timezone: Option<String>,
/// When true, cycle through all users with routines. Controlled via
/// HEARTBEAT_MULTI_TENANT env var; defaults to false.
/// When true, cycle through all users with routines. Auto-detected from
/// GATEWAY_USER_TOKENS or set explicitly via HEARTBEAT_MULTI_TENANT.
pub multi_tenant: bool,
}
@@ -105,7 +105,12 @@ impl HeartbeatConfig {
}
tz
},
multi_tenant: parse_bool_env("HEARTBEAT_MULTI_TENANT", false)?,
// Auto-detect multi-tenant mode from GATEWAY_USER_TOKENS presence,
// or allow explicit override via HEARTBEAT_MULTI_TENANT.
multi_tenant: parse_bool_env(
"HEARTBEAT_MULTI_TENANT",
optional_env("GATEWAY_USER_TOKENS")?.is_some(),
)?,
})
}
}
+13 -5
View File
@@ -10,8 +10,10 @@ use crate::error::ConfigError;
pub struct HygieneConfig {
/// Whether hygiene is enabled. Env: `MEMORY_HYGIENE_ENABLED` (default: true).
pub enabled: bool,
/// Maximum versions to keep per document. Env: `MEMORY_HYGIENE_VERSION_KEEP_COUNT` (default: 50).
pub version_keep_count: u32,
/// Days before `daily/` documents are deleted. Env: `MEMORY_HYGIENE_DAILY_RETENTION_DAYS` (default: 30).
pub daily_retention_days: u32,
/// Days before `conversations/` documents are deleted. Env: `MEMORY_HYGIENE_CONVERSATION_RETENTION_DAYS` (default: 7).
pub conversation_retention_days: u32,
/// Minimum hours between hygiene passes. Env: `MEMORY_HYGIENE_CADENCE_HOURS` (default: 12).
pub cadence_hours: u32,
}
@@ -20,7 +22,8 @@ impl Default for HygieneConfig {
fn default() -> Self {
Self {
enabled: true,
version_keep_count: 50,
daily_retention_days: 30,
conversation_retention_days: 7,
cadence_hours: 12,
}
}
@@ -30,7 +33,11 @@ impl HygieneConfig {
pub(crate) fn resolve() -> Result<Self, ConfigError> {
Ok(Self {
enabled: parse_bool_env("MEMORY_HYGIENE_ENABLED", true)?,
version_keep_count: parse_optional_env("MEMORY_HYGIENE_VERSION_KEEP_COUNT", 50)?,
daily_retention_days: parse_optional_env("MEMORY_HYGIENE_DAILY_RETENTION_DAYS", 30)?,
conversation_retention_days: parse_optional_env(
"MEMORY_HYGIENE_CONVERSATION_RETENTION_DAYS",
7,
)?,
cadence_hours: parse_optional_env("MEMORY_HYGIENE_CADENCE_HOURS", 12)?,
})
}
@@ -40,7 +47,8 @@ impl HygieneConfig {
pub fn to_workspace_config(&self) -> crate::workspace::hygiene::HygieneConfig {
crate::workspace::hygiene::HygieneConfig {
enabled: self.enabled,
version_keep_count: self.version_keep_count,
daily_retention_days: self.daily_retention_days,
conversation_retention_days: self.conversation_retention_days,
cadence_hours: self.cadence_hours,
state_dir: ironclaw_base_dir(),
}
-14
View File
@@ -195,20 +195,6 @@ impl ContextManager {
.collect()
}
/// Count jobs consuming a parallel execution slot for a specific user.
///
/// Uses `is_parallel_blocking()` (Pending/InProgress/Stuck) rather than
/// `is_active()`, so Completed/Submitted jobs don't count against the
/// per-user concurrency limit.
pub async fn parallel_blocking_count_for(&self, user_id: &str) -> usize {
self.contexts
.read()
.await
.iter()
.filter(|(_, c)| c.user_id == user_id && c.state.is_parallel_blocking())
.count()
}
/// List all job IDs for a specific user.
pub async fn all_jobs_for(&self, user_id: &str) -> Vec<Uuid> {
self.contexts
+13 -18
View File
@@ -12,11 +12,11 @@ mod routines;
mod sandbox;
mod settings;
mod tool_failures;
mod users;
mod workspace;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use async_trait::async_trait;
use chrono::{DateTime, NaiveDateTime, Utc};
@@ -33,6 +33,8 @@ use crate::workspace::MemoryDocument;
use crate::db::libsql_migrations;
static NAIVE_TIMESTAMP_LOGGED: AtomicBool = AtomicBool::new(false);
/// Explicit column list for routines table (matches positional access in `row_to_routine_libsql`).
pub(crate) const ROUTINE_COLUMNS: &str = "\
id, name, description, user_id, enabled, \
@@ -164,11 +166,13 @@ impl LibSqlBackend {
///
/// Returns an error if none of the formats match.
pub(crate) fn parse_timestamp(s: &str) -> Result<DateTime<Utc>, String> {
let log_naive_timestamp = || {
tracing::warn!(
timestamp = %s,
"parsed naive timestamp, assuming UTC — consider migrating to RFC 3339"
);
let log_naive_timestamp_once = || {
if !NAIVE_TIMESTAMP_LOGGED.swap(true, Ordering::Relaxed) {
tracing::debug!(
timestamp = %s,
"parsed naive timestamp without timezone; assuming UTC for backward compatibility"
);
}
};
// RFC 3339 (our canonical write format)
@@ -177,12 +181,12 @@ pub(crate) fn parse_timestamp(s: &str) -> Result<DateTime<Utc>, String> {
}
// Naive with fractional seconds (legacy or SQLite datetime() output)
if let Ok(ndt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f") {
log_naive_timestamp();
log_naive_timestamp_once();
return Ok(ndt.and_utc());
}
// Naive without fractional seconds (legacy format)
if let Ok(ndt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
log_naive_timestamp();
log_naive_timestamp_once();
return Ok(ndt.and_utc());
}
Err(format!("unparseable timestamp: {:?}", s))
@@ -434,7 +438,7 @@ mod tests {
use chrono::{TimeZone, Utc};
use crate::db::Database;
use crate::db::libsql::{LibSqlBackend, fmt_ts, normalize_notify_user, parse_timestamp};
use crate::db::libsql::{LibSqlBackend, normalize_notify_user, parse_timestamp};
#[test]
fn test_normalize_notify_user_treats_legacy_default_as_missing() {
@@ -463,15 +467,6 @@ mod tests {
assert_eq!(naive_without_millis, expected);
}
#[test]
fn test_fmt_ts_roundtrips_through_parse_timestamp() {
let original = Utc.with_ymd_and_hms(2026, 6, 15, 8, 30, 45).unwrap()
+ chrono::Duration::milliseconds(123);
let formatted = fmt_ts(&original);
let parsed = parse_timestamp(&formatted).unwrap();
assert_eq!(parsed, original);
}
#[tokio::test]
async fn test_libsql_now_format_is_rfc3339_and_parseable() {
let backend = LibSqlBackend::new_memory().await.unwrap();
File diff suppressed because it is too large Load Diff
+2 -326
View File
@@ -13,8 +13,8 @@ use super::{
use crate::db::WorkspaceStore;
use crate::error::{DatabaseError, WorkspaceError};
use crate::workspace::{
DocumentVersion, MemoryChunk, MemoryDocument, RankedResult, SearchConfig, SearchResult,
VersionSummary, WorkspaceEntry, fuse_results,
MemoryChunk, MemoryDocument, RankedResult, SearchConfig, SearchResult, WorkspaceEntry,
fuse_results,
};
use chrono::Utc;
@@ -840,330 +840,6 @@ impl WorkspaceStore for LibSqlBackend {
Ok(fuse_results(fts_results, vector_results, config))
}
// ==================== Metadata ====================
async fn update_document_metadata(
&self,
id: Uuid,
metadata: &serde_json::Value,
) -> Result<(), WorkspaceError> {
let conn = self
.connect()
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: e.to_string(),
})?;
let now = fmt_ts(&Utc::now());
let meta_str =
serde_json::to_string(metadata).map_err(|e| WorkspaceError::SearchFailed {
reason: format!("Failed to serialize metadata: {e}"),
})?;
conn.execute(
"UPDATE memory_documents SET metadata = ?2, updated_at = ?3 WHERE id = ?1",
params![id.to_string(), meta_str, now],
)
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("Failed to update metadata: {e}"),
})?;
Ok(())
}
async fn find_config_documents(
&self,
user_id: &str,
agent_id: Option<Uuid>,
) -> Result<Vec<MemoryDocument>, WorkspaceError> {
let conn = self
.connect()
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: e.to_string(),
})?;
let agent_str = agent_id.map(|a| a.to_string());
let mut rows = conn
.query(
r#"
SELECT id, user_id, agent_id, path, content,
created_at, updated_at, metadata
FROM memory_documents
WHERE user_id = ?1 AND agent_id IS ?2
AND (path LIKE '%/.config' OR path = '.config')
ORDER BY path
"#,
params![user_id, agent_str],
)
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("Failed to find config documents: {e}"),
})?;
let mut docs = Vec::new();
while let Some(row) = rows
.next()
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("Failed to read config document row: {e}"),
})?
{
docs.push(row_to_memory_document(&row));
}
Ok(docs)
}
// ==================== Versioning ====================
async fn save_version(
&self,
document_id: Uuid,
content: &str,
content_hash: &str,
changed_by: Option<&str>,
) -> Result<i32, WorkspaceError> {
let conn = self
.connect()
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: e.to_string(),
})?;
let id = Uuid::new_v4().to_string();
let doc_id = document_id.to_string();
let now = fmt_ts(&Utc::now());
// Use a transaction to prevent race conditions: the SELECT and INSERT
// must be atomic so concurrent writers don't allocate the same version.
let tx = conn
.transaction()
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("Failed to start transaction: {e}"),
})?;
// Get next version number (inside transaction — serializes writers)
let mut rows = tx
.query(
"SELECT COALESCE(MAX(version), 0) + 1 FROM memory_document_versions WHERE document_id = ?1",
params![doc_id.clone()],
)
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("Failed to get next version number: {e}"),
})?;
let next_version = if let Some(row) =
rows.next()
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("Failed to read version number: {e}"),
})? {
get_i64(&row, 0) as i32
} else {
1
};
drop(rows);
tx.execute(
r#"
INSERT INTO memory_document_versions
(id, document_id, version, content, content_hash, created_at, changed_by)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
"#,
params![
id,
doc_id,
next_version as i64,
content,
content_hash,
now,
changed_by
],
)
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("Failed to save version: {e}"),
})?;
tx.commit()
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("Failed to commit version: {e}"),
})?;
Ok(next_version)
}
async fn get_version(
&self,
document_id: Uuid,
version: i32,
) -> Result<DocumentVersion, WorkspaceError> {
let conn = self
.connect()
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: e.to_string(),
})?;
let mut rows = conn
.query(
r#"
SELECT id, document_id, version, content, content_hash,
created_at, changed_by
FROM memory_document_versions
WHERE document_id = ?1 AND version = ?2
"#,
params![document_id.to_string(), version as i64],
)
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("Failed to get version: {e}"),
})?;
let row = rows
.next()
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("Failed to read version row: {e}"),
})?
.ok_or(WorkspaceError::VersionNotFound {
document_id,
version,
})?;
Ok(DocumentVersion {
id: get_text(&row, 0)
.parse()
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("Invalid version UUID: {e}"),
})?,
document_id: get_text(&row, 1)
.parse()
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("Invalid document UUID: {e}"),
})?,
version: get_i64(&row, 2) as i32,
content: get_text(&row, 3),
content_hash: get_text(&row, 4),
created_at: get_ts(&row, 5),
changed_by: get_opt_text(&row, 6),
})
}
async fn list_versions(
&self,
document_id: Uuid,
limit: i64,
) -> Result<Vec<VersionSummary>, WorkspaceError> {
let conn = self
.connect()
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: e.to_string(),
})?;
let mut rows = conn
.query(
r#"
SELECT version, content_hash, created_at, changed_by
FROM memory_document_versions
WHERE document_id = ?1
ORDER BY version DESC
LIMIT ?2
"#,
params![document_id.to_string(), limit],
)
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("Failed to list versions: {e}"),
})?;
let mut versions = Vec::new();
while let Some(row) = rows
.next()
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("Failed to read version row: {e}"),
})?
{
versions.push(VersionSummary {
version: get_i64(&row, 0) as i32,
content_hash: get_text(&row, 1),
created_at: get_ts(&row, 2),
changed_by: get_opt_text(&row, 3),
});
}
Ok(versions)
}
async fn get_latest_version_number(
&self,
document_id: Uuid,
) -> Result<Option<i32>, WorkspaceError> {
let conn = self
.connect()
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: e.to_string(),
})?;
let mut rows = conn
.query(
"SELECT MAX(version) FROM memory_document_versions WHERE document_id = ?1",
params![document_id.to_string()],
)
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("Failed to get latest version number: {e}"),
})?;
if let Some(row) = rows
.next()
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("Failed to read version number: {e}"),
})?
{
// MAX returns NULL if no rows — libsql returns Null for the value
let val = row.get::<libsql::Value>(0).ok();
match val {
Some(libsql::Value::Integer(v)) => Ok(Some(v as i32)),
_ => Ok(None),
}
} else {
Ok(None)
}
}
async fn prune_versions(
&self,
document_id: Uuid,
keep_count: i32,
) -> Result<u64, WorkspaceError> {
let conn = self
.connect()
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: e.to_string(),
})?;
let doc_id = document_id.to_string();
let result = conn
.execute(
r#"
DELETE FROM memory_document_versions
WHERE document_id = ?1
AND version NOT IN (
SELECT version FROM memory_document_versions
WHERE document_id = ?1
ORDER BY version DESC
LIMIT ?2
)
"#,
params![doc_id, keep_count as i64],
)
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("Failed to prune versions: {e}"),
})?;
Ok(result)
}
}
#[cfg(test)]
-81
View File
@@ -579,36 +579,6 @@ INSERT OR IGNORE INTO leak_detection_patterns (id, name, pattern, severity, acti
('550e8400-e29b-41d4-a716-446655440011', 'mailchimp_api_key', '[a-f0-9]{32}-us[0-9]{1,2}', 'medium', 'block', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
('550e8400-e29b-41d4-a716-446655440012', 'high_entropy_hex', '(?<![a-fA-F0-9])[a-fA-F0-9]{64}(?![a-fA-F0-9])', 'medium', 'warn', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
-- ==================== User management (V14) ====================
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE,
display_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
role TEXT NOT NULL DEFAULT 'member',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
last_login_at TEXT,
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
metadata TEXT NOT NULL DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS api_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash BLOB NOT NULL,
token_prefix TEXT NOT NULL,
name TEXT NOT NULL,
expires_at TEXT,
last_used_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
revoked_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
"#;
/// Incremental migrations applied after the base schema.
@@ -753,57 +723,6 @@ CREATE INDEX IF NOT EXISTS idx_routines_event_triggers
WHERE enabled = 1 AND trigger_type IN ('event', 'system_event');
PRAGMA foreign_keys=ON;
"#,
),
(
14,
"users",
r#"
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE,
display_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
role TEXT NOT NULL DEFAULT 'member',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
last_login_at TEXT,
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
metadata TEXT NOT NULL DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS api_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash BLOB NOT NULL,
token_prefix TEXT NOT NULL,
name TEXT NOT NULL,
expires_at TEXT,
last_used_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
revoked_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
"#,
),
(
15,
"document_versions",
r#"
CREATE TABLE IF NOT EXISTS memory_document_versions (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL REFERENCES memory_documents(id) ON DELETE CASCADE,
version INTEGER NOT NULL,
content TEXT NOT NULL,
content_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
changed_by TEXT,
UNIQUE(document_id, version)
);
CREATE INDEX IF NOT EXISTS idx_doc_versions_lookup
ON memory_document_versions(document_id, version DESC);
"#,
),
];
-206
View File
@@ -309,43 +309,6 @@ async fn validate_postgres(pool: &deadpool_postgres::Pool) -> Result<(), Databas
Ok(())
}
// ==================== User management record types ====================
/// A registered user.
#[derive(Debug, Clone)]
pub struct UserRecord {
/// User identifier (string, matches existing `user_id` throughout the codebase).
pub id: String,
pub email: Option<String>,
pub display_name: String,
/// `active`, `suspended`, or `deactivated`.
pub status: String,
/// `admin` or `member`.
pub role: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub last_login_at: Option<DateTime<Utc>>,
/// Who created/invited this user (nullable for bootstrap users).
pub created_by: Option<String>,
pub metadata: serde_json::Value,
}
/// An API token for authenticating requests (hash stored, never plaintext).
#[derive(Debug, Clone)]
pub struct ApiTokenRecord {
pub id: Uuid,
pub user_id: String,
/// Human label (e.g. "my-laptop", "ci-bot").
pub name: String,
/// First 8 hex chars of the plaintext token for display/identification.
pub token_prefix: String,
pub expires_at: Option<DateTime<Utc>>,
pub last_used_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
/// Soft-revoke timestamp. Non-null means revoked.
pub revoked_at: Option<DateTime<Utc>>,
}
// ==================== Sub-traits ====================
//
// Each sub-trait groups related persistence methods. The `Database` supertrait
@@ -700,67 +663,6 @@ pub trait WorkspaceStore: Send + Sync {
config: &SearchConfig,
) -> Result<Vec<SearchResult>, WorkspaceError>;
// ==================== Metadata ====================
/// Update the metadata JSON field on a document (full replacement).
async fn update_document_metadata(
&self,
id: Uuid,
metadata: &serde_json::Value,
) -> Result<(), WorkspaceError>;
/// Find all `.config` documents in the workspace.
///
/// Returns documents whose path ends with `/.config` or equals `.config`.
/// Used by the hygiene system to discover metadata-driven cleanup targets.
async fn find_config_documents(
&self,
user_id: &str,
agent_id: Option<Uuid>,
) -> Result<Vec<MemoryDocument>, WorkspaceError>;
// ==================== Versioning ====================
/// Save the current content of a document as a new version.
///
/// Returns the new version number (1-based, monotonically increasing).
async fn save_version(
&self,
document_id: Uuid,
content: &str,
content_hash: &str,
changed_by: Option<&str>,
) -> Result<i32, WorkspaceError>;
/// Get a specific version of a document.
async fn get_version(
&self,
document_id: Uuid,
version: i32,
) -> Result<crate::workspace::DocumentVersion, WorkspaceError>;
/// List versions of a document (newest first).
async fn list_versions(
&self,
document_id: Uuid,
limit: i64,
) -> Result<Vec<crate::workspace::VersionSummary>, WorkspaceError>;
/// Get the latest version number for a document, or `None` if no versions exist.
async fn get_latest_version_number(
&self,
document_id: Uuid,
) -> Result<Option<i32>, WorkspaceError>;
/// Delete old versions, keeping only the most recent `keep_count`.
///
/// Returns the number of versions deleted.
async fn prune_versions(
&self,
document_id: Uuid,
keep_count: i32,
) -> Result<u64, WorkspaceError>;
// ==================== Multi-scope read methods ====================
//
// Default implementations loop over user_ids calling single-scope methods,
@@ -859,113 +761,6 @@ pub trait WorkspaceStore: Send + Sync {
}
}
#[async_trait]
pub trait UserStore: Send + Sync {
// ---- Users ----
/// Create a new user record.
async fn create_user(&self, user: &UserRecord) -> Result<(), DatabaseError>;
/// Get a user by their string id.
async fn get_user(&self, id: &str) -> Result<Option<UserRecord>, DatabaseError>;
/// Get a user by email address.
async fn get_user_by_email(&self, email: &str) -> Result<Option<UserRecord>, DatabaseError>;
/// List users, optionally filtered by status.
async fn list_users(&self, status: Option<&str>) -> Result<Vec<UserRecord>, DatabaseError>;
/// Update a user's status (active/suspended/deactivated).
async fn update_user_status(&self, id: &str, status: &str) -> Result<(), DatabaseError>;
/// Update a user's role (admin/member).
async fn update_user_role(&self, id: &str, role: &str) -> Result<(), DatabaseError>;
/// Update a user's display name and metadata.
async fn update_user_profile(
&self,
id: &str,
display_name: &str,
metadata: &serde_json::Value,
) -> Result<(), DatabaseError>;
/// Record a login timestamp.
async fn record_login(&self, id: &str) -> Result<(), DatabaseError>;
// ---- API Tokens ----
/// Create a new API token. The `token_hash` is SHA-256 of the plaintext.
async fn create_api_token(
&self,
user_id: &str,
name: &str,
token_hash: &[u8; 32],
token_prefix: &str,
expires_at: Option<DateTime<Utc>>,
) -> Result<ApiTokenRecord, DatabaseError>;
/// List tokens for a user (never includes the hash).
async fn list_api_tokens(&self, user_id: &str) -> Result<Vec<ApiTokenRecord>, DatabaseError>;
/// Soft-revoke a token. Returns false if the token doesn't exist or doesn't belong to the user.
async fn revoke_api_token(&self, token_id: Uuid, user_id: &str) -> Result<bool, DatabaseError>;
/// Look up a token by hash, returning the token record and its owning user.
/// Only returns active (non-revoked, non-expired) tokens for active users.
async fn authenticate_token(
&self,
token_hash: &[u8; 32],
) -> Result<Option<(ApiTokenRecord, UserRecord)>, DatabaseError>;
/// Update `last_used_at` for a token.
async fn record_token_usage(&self, token_id: Uuid) -> Result<(), DatabaseError>;
/// Check whether any user records exist (for first-run bootstrap detection).
async fn has_any_users(&self) -> Result<bool, DatabaseError>;
/// Delete a user and all their data across all user-scoped tables.
/// Returns false if the user doesn't exist.
async fn delete_user(&self, id: &str) -> Result<bool, DatabaseError>;
/// Get per-user LLM usage stats for a time period.
/// Aggregates from llm_calls via agent_jobs.user_id.
async fn user_usage_stats(
&self,
user_id: Option<&str>,
since: DateTime<Utc>,
) -> Result<Vec<UserUsageStats>, DatabaseError>;
/// Lightweight per-user summary stats (job count, total cost, last active).
/// Used by the admin users list to show inline stats.
async fn user_summary_stats(
&self,
user_id: Option<&str>,
) -> Result<Vec<UserSummaryStats>, DatabaseError>;
/// Create a user and their initial API token atomically.
/// If either operation fails, both are rolled back.
async fn create_user_with_token(
&self,
user: &UserRecord,
token_name: &str,
token_hash: &[u8; 32],
token_prefix: &str,
expires_at: Option<DateTime<Utc>>,
) -> Result<ApiTokenRecord, DatabaseError>;
}
/// Per-user LLM usage statistics.
#[derive(Debug, Clone)]
pub struct UserUsageStats {
pub user_id: String,
pub model: String,
pub call_count: i64,
pub input_tokens: i64,
pub output_tokens: i64,
pub total_cost: Decimal,
}
/// Lightweight per-user summary for the admin users list.
#[derive(Debug, Clone)]
pub struct UserSummaryStats {
pub user_id: String,
/// Total agent jobs created by this user.
pub job_count: i64,
/// Total LLM spend across all jobs (all-time).
pub total_cost: Decimal,
/// Most recent activity (latest job or LLM call timestamp).
pub last_active_at: Option<DateTime<Utc>>,
}
/// Backend-agnostic database supertrait.
///
/// Combines all sub-traits into one. Existing `Arc<dyn Database>` consumers
@@ -979,7 +774,6 @@ pub trait Database:
+ ToolFailureStore
+ SettingsStore
+ WorkspaceStore
+ UserStore
+ Send
+ Sync
{
+3 -179
View File
@@ -16,8 +16,8 @@ use crate::agent::routine::{Routine, RoutineRun, RunStatus};
use crate::config::DatabaseConfig;
use crate::context::{ActionRecord, JobContext, JobState};
use crate::db::{
ApiTokenRecord, ConversationStore, Database, JobStore, RoutineStore, SandboxStore,
SettingsStore, ToolFailureStore, UserRecord, UserStore, WorkspaceStore,
ConversationStore, Database, JobStore, RoutineStore, SandboxStore, SettingsStore,
ToolFailureStore, WorkspaceStore,
};
use crate::error::{DatabaseError, WorkspaceError};
use crate::history::{
@@ -25,8 +25,7 @@ use crate::history::{
LlmCallRecord, SandboxJobRecord, SandboxJobSummary, SettingRow, Store,
};
use crate::workspace::{
DocumentVersion, MemoryChunk, MemoryDocument, Repository, SearchConfig, SearchResult,
VersionSummary, WorkspaceEntry,
MemoryChunk, MemoryDocument, Repository, SearchConfig, SearchResult, WorkspaceEntry,
};
/// PostgreSQL database backend.
@@ -786,179 +785,4 @@ impl WorkspaceStore for PgBackend {
.list_directory_multi(user_ids, agent_id, directory)
.await
}
// ==================== Metadata ====================
async fn update_document_metadata(
&self,
id: Uuid,
metadata: &serde_json::Value,
) -> Result<(), WorkspaceError> {
self.repo.update_document_metadata(id, metadata).await
}
async fn find_config_documents(
&self,
user_id: &str,
agent_id: Option<Uuid>,
) -> Result<Vec<MemoryDocument>, WorkspaceError> {
self.repo.find_config_documents(user_id, agent_id).await
}
// ==================== Versioning ====================
async fn save_version(
&self,
document_id: Uuid,
content: &str,
content_hash: &str,
changed_by: Option<&str>,
) -> Result<i32, WorkspaceError> {
self.repo
.save_version(document_id, content, content_hash, changed_by)
.await
}
async fn get_version(
&self,
document_id: Uuid,
version: i32,
) -> Result<DocumentVersion, WorkspaceError> {
self.repo.get_version(document_id, version).await
}
async fn list_versions(
&self,
document_id: Uuid,
limit: i64,
) -> Result<Vec<VersionSummary>, WorkspaceError> {
self.repo.list_versions(document_id, limit).await
}
async fn get_latest_version_number(
&self,
document_id: Uuid,
) -> Result<Option<i32>, WorkspaceError> {
self.repo.get_latest_version_number(document_id).await
}
async fn prune_versions(
&self,
document_id: Uuid,
keep_count: i32,
) -> Result<u64, WorkspaceError> {
self.repo.prune_versions(document_id, keep_count).await
}
}
// ==================== UserStore ====================
#[async_trait]
impl UserStore for PgBackend {
async fn create_user(&self, user: &UserRecord) -> Result<(), DatabaseError> {
self.store.create_user(user).await
}
async fn get_user(&self, id: &str) -> Result<Option<UserRecord>, DatabaseError> {
self.store.get_user(id).await
}
async fn get_user_by_email(&self, email: &str) -> Result<Option<UserRecord>, DatabaseError> {
self.store.get_user_by_email(email).await
}
async fn list_users(&self, status: Option<&str>) -> Result<Vec<UserRecord>, DatabaseError> {
self.store.list_users(status).await
}
async fn update_user_status(&self, id: &str, status: &str) -> Result<(), DatabaseError> {
self.store.update_user_status(id, status).await
}
async fn update_user_role(&self, id: &str, role: &str) -> Result<(), DatabaseError> {
self.store.update_user_role(id, role).await
}
async fn update_user_profile(
&self,
id: &str,
display_name: &str,
metadata: &serde_json::Value,
) -> Result<(), DatabaseError> {
self.store
.update_user_profile(id, display_name, metadata)
.await
}
async fn record_login(&self, id: &str) -> Result<(), DatabaseError> {
self.store.record_login(id).await
}
async fn create_api_token(
&self,
user_id: &str,
name: &str,
token_hash: &[u8; 32],
token_prefix: &str,
expires_at: Option<DateTime<Utc>>,
) -> Result<ApiTokenRecord, DatabaseError> {
self.store
.create_api_token(user_id, name, token_hash, token_prefix, expires_at)
.await
}
async fn list_api_tokens(&self, user_id: &str) -> Result<Vec<ApiTokenRecord>, DatabaseError> {
self.store.list_api_tokens(user_id).await
}
async fn revoke_api_token(&self, token_id: Uuid, user_id: &str) -> Result<bool, DatabaseError> {
self.store.revoke_api_token(token_id, user_id).await
}
async fn authenticate_token(
&self,
token_hash: &[u8; 32],
) -> Result<Option<(ApiTokenRecord, UserRecord)>, DatabaseError> {
self.store.authenticate_token(token_hash).await
}
async fn record_token_usage(&self, token_id: Uuid) -> Result<(), DatabaseError> {
self.store.record_token_usage(token_id).await
}
async fn has_any_users(&self) -> Result<bool, DatabaseError> {
self.store.has_any_users().await
}
async fn delete_user(&self, id: &str) -> Result<bool, DatabaseError> {
self.store.delete_user(id).await
}
async fn user_usage_stats(
&self,
user_id: Option<&str>,
since: DateTime<Utc>,
) -> Result<Vec<crate::db::UserUsageStats>, DatabaseError> {
self.store.user_usage_stats(user_id, since).await
}
async fn user_summary_stats(
&self,
user_id: Option<&str>,
) -> Result<Vec<crate::db::UserSummaryStats>, DatabaseError> {
self.store.user_summary_stats(user_id).await
}
async fn create_user_with_token(
&self,
user: &UserRecord,
token_name: &str,
token_hash: &[u8; 32],
token_prefix: &str,
expires_at: Option<DateTime<Utc>>,
) -> Result<ApiTokenRecord, DatabaseError> {
self.store
.create_user_with_token(user, token_name, token_hash, token_prefix, expires_at)
.await
}
}
+7 -16
View File
@@ -2,8 +2,7 @@
//!
//! Builds a [`deadpool_postgres::Pool`] with the appropriate TLS connector
//! based on the configured [`SslMode`]. Uses `rustls` with system root
//! certificates, falling back to Mozilla's bundled roots via `webpki-roots`
//! when the system store is empty (common in minimal container images).
//! certificates — the same TLS stack that `reqwest` already uses for HTTP.
use deadpool_postgres::{Pool, Runtime};
use thiserror::Error;
@@ -20,15 +19,9 @@ pub enum CreatePoolError {
TlsConfig(#[from] rustls::Error),
}
/// Build a rustls-based TLS connector.
///
/// Tries the platform's native certificate store first. If that yields zero
/// certificates (slim container images, missing ca-certificates package),
/// falls back to Mozilla's root certificates bundled via `webpki-roots`.
/// Build a rustls-based TLS connector using the platform's root certificate store.
fn make_rustls_connector() -> Result<MakeRustlsConnect, rustls::Error> {
let mut root_store = rustls::RootCertStore::empty();
// Try native certs first.
let native = rustls_native_certs::load_native_certs();
for e in &native.errors {
tracing::warn!("error loading system root certs: {e}");
@@ -38,14 +31,11 @@ fn make_rustls_connector() -> Result<MakeRustlsConnect, rustls::Error> {
tracing::warn!("skipping invalid system root cert: {e}");
}
}
// Fall back to bundled Mozilla roots when the system store is empty.
if root_store.is_empty() {
tracing::info!("no system root certificates found, using bundled Mozilla roots");
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
tracing::error!("no system root certificates found -- TLS connections will fail");
}
// Pick the ring crypto provider (same one reqwest uses).
// `--all-features` brings in both aws-lc-rs and ring-backed rustls providers.
// Pick the same ring provider reqwest already uses so postgres TLS setup stays deterministic.
let config = rustls::ClientConfig::builder_with_provider(
rustls::crypto::ring::default_provider().into(),
)
@@ -58,7 +48,7 @@ fn make_rustls_connector() -> Result<MakeRustlsConnect, rustls::Error> {
/// Create a [`deadpool_postgres::Pool`] with the appropriate TLS connector.
///
/// - `Disable` → plain TCP (no TLS)
/// - `Prefer` / `Require` → rustls with system or bundled root certificates
/// - `Prefer` / `Require` → rustls with system root certificates
///
/// **Note:** `Prefer` and `Require` currently behave identically — both
/// provide a TLS connector and will fail if the server rejects the TLS
@@ -91,6 +81,7 @@ mod tests {
fn create_pool_disable_mode() {
let mut config = deadpool_postgres::Config::new();
config.url = Some("postgres://localhost/test".to_string());
// Should succeed — pool is created lazily, no actual connection needed.
let pool = create_pool(&config, SslMode::Disable);
assert!(pool.is_ok());
}
-6
View File
@@ -315,12 +315,6 @@ pub enum WorkspaceError {
#[error("Write rejected for '{path}': prompt injection detected ({reason})")]
InjectionRejected { path: String, reason: String },
#[error("Version not found: document {document_id} version {version}")]
VersionNotFound { document_id: Uuid, version: i32 },
#[error("Patch failed for '{path}': {reason}")]
PatchFailed { path: String, reason: String },
}
/// Orchestrator errors (internal API, container management).
+1 -649
View File
@@ -53,38 +53,6 @@ struct HostedOAuthFlowStart {
flow: crate::cli::oauth_defaults::PendingOAuthFlow,
}
#[derive(Debug, Default)]
struct SecretCleanupPlan {
base_secrets: HashSet<String>,
companion_secrets: HashMap<String, HashSet<String>>,
}
impl SecretCleanupPlan {
fn add_base_secret(&mut self, secret_name: impl AsRef<str>) {
self.base_secrets
.insert(secret_name.as_ref().to_lowercase());
}
fn add_companion_secret(
&mut self,
base_secret_name: impl AsRef<str>,
companion_secret_name: impl AsRef<str>,
) {
self.companion_secrets
.entry(base_secret_name.as_ref().to_lowercase())
.or_default()
.insert(companion_secret_name.as_ref().to_lowercase());
}
}
fn oauth_refresh_secret_name(secret_name: &str) -> String {
format!("{}_refresh_token", secret_name.to_lowercase())
}
fn oauth_scopes_secret_name(secret_name: &str) -> String {
format!("{}_scopes", secret_name.to_lowercase())
}
fn normalize_oauth_callback_path(path: &str) -> String {
let trimmed_path = path.trim_end_matches('/');
if trimmed_path.is_empty() {
@@ -1634,10 +1602,6 @@ impl ExtensionManager {
match kind {
ExtensionKind::McpServer => {
let cleanup_plan = self
.collect_secret_cleanup_plan(name, kind, user_id)
.await?;
// Unregister tools with this server's prefix
let tool_names: Vec<String> = self
.tool_registry
@@ -1659,9 +1623,6 @@ impl ExtensionManager {
.await
.map_err(|e| ExtensionError::Config(e.to_string()))?;
self.cleanup_uninstalled_extension_secrets(cleanup_plan, user_id)
.await;
Ok(format!(
"Removed MCP server '{}' and {} tool(s)",
name,
@@ -1669,10 +1630,6 @@ impl ExtensionManager {
))
}
ExtensionKind::WasmTool => {
let cleanup_plan = self
.collect_secret_cleanup_plan(name, kind, user_id)
.await?;
// Unregister from tool registry
self.tool_registry.unregister(name).await;
@@ -1717,16 +1674,9 @@ impl ExtensionManager {
let _ = tokio::fs::remove_file(&cap_path).await;
}
self.cleanup_uninstalled_extension_secrets(cleanup_plan, user_id)
.await;
Ok(format!("Removed WASM tool '{}'", name))
}
ExtensionKind::WasmChannel => {
let cleanup_plan = self
.collect_secret_cleanup_plan(name, kind, user_id)
.await?;
// Remove from active set and persist
self.active_channel_names.write().await.remove(name);
self.persist_active_channels(user_id).await;
@@ -1752,9 +1702,6 @@ impl ExtensionManager {
let _ = tokio::fs::remove_file(&cap_path).await;
}
self.cleanup_uninstalled_extension_secrets(cleanup_plan, user_id)
.await;
Ok(format!(
"Removed channel '{}'. Restart IronClaw for the change to take effect.",
name
@@ -3052,258 +2999,6 @@ impl ExtensionManager {
crate::tools::wasm::CapabilitiesFile::from_bytes(&cap_bytes).ok()
}
async fn load_channel_capabilities(
&self,
name: &str,
) -> Option<crate::channels::wasm::ChannelCapabilitiesFile> {
let cap_path = self
.wasm_channels_dir
.join(format!("{}.capabilities.json", name));
let cap_bytes = tokio::fs::read(&cap_path).await.ok()?;
crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes).ok()
}
async fn collect_secret_cleanup_plan(
&self,
name: &str,
kind: ExtensionKind,
user_id: &str,
) -> Result<SecretCleanupPlan, ExtensionError> {
let mut plan = SecretCleanupPlan::default();
match kind {
ExtensionKind::WasmTool => {
if let Some(cap) = self.load_tool_capabilities(name).await {
for secret_name in Self::tool_secret_names(&cap) {
plan.add_base_secret(secret_name);
}
if let Some(auth) = cap.auth {
plan.add_base_secret(&auth.secret_name);
plan.add_companion_secret(
&auth.secret_name,
oauth_refresh_secret_name(&auth.secret_name),
);
plan.add_companion_secret(
&auth.secret_name,
oauth_scopes_secret_name(&auth.secret_name),
);
}
}
}
ExtensionKind::WasmChannel => {
if let Some(cap) = self.load_channel_capabilities(name).await {
for secret_name in Self::channel_secret_names(&cap) {
plan.add_base_secret(secret_name);
}
}
}
ExtensionKind::McpServer => {
let server = self
.get_mcp_server(name, user_id)
.await
.map_err(|e| ExtensionError::Config(e.to_string()))?;
let token_secret_name = server.token_secret_name();
plan.add_base_secret(&token_secret_name);
plan.add_base_secret(server.client_id_secret_name());
// MCP OAuth can persist companion secrets through two paths:
// the MCP auth helper uses `mcp_<name>_refresh_token`, while the
// hosted gateway callback stores companions alongside the access
// token secret (`<token_secret>_refresh_token` / `_scopes`).
plan.add_companion_secret(&token_secret_name, server.refresh_token_secret_name());
plan.add_companion_secret(
&token_secret_name,
oauth_refresh_secret_name(&token_secret_name),
);
plan.add_companion_secret(
&token_secret_name,
oauth_scopes_secret_name(&token_secret_name),
);
}
ExtensionKind::ChannelRelay => {}
}
Ok(plan)
}
async fn cleanup_uninstalled_extension_secrets(&self, plan: SecretCleanupPlan, user_id: &str) {
let referenced_secrets = match self.collect_referenced_secret_names(user_id).await {
Ok(secret_names) => secret_names,
Err(error) => {
tracing::warn!(
user_id,
error,
"Failed to determine which secrets are still referenced; keeping secrets"
);
return;
}
};
for base_secret in &plan.base_secrets {
if referenced_secrets.contains(base_secret) {
continue;
}
self.delete_secret_best_effort(user_id, base_secret).await;
if let Some(companion_secrets) = plan.companion_secrets.get(base_secret) {
for companion_secret in companion_secrets {
if !referenced_secrets.contains(companion_secret) {
self.delete_secret_best_effort(user_id, companion_secret)
.await;
}
}
}
}
}
async fn delete_secret_best_effort(&self, user_id: &str, secret_name: &str) {
if let Err(error) = self.secrets.delete(user_id, secret_name).await {
tracing::warn!(
user_id,
secret_name,
error = %error,
"Failed to delete secret while uninstalling extension"
);
}
}
async fn collect_referenced_secret_names(
&self,
user_id: &str,
) -> Result<HashSet<String>, String> {
let mut referenced_secret_names = HashSet::new();
let tools = discover_tools(&self.wasm_tools_dir)
.await
.map_err(|e| format!("discover tools: {e}"))?;
for (tool_name, discovered_tool) in &tools {
let cap = self
.load_tool_capabilities(tool_name)
.await
.ok_or_else(|| {
let path = discovered_tool
.capabilities_path
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| format!("{} (missing)", tool_name));
format!("load tool capabilities for {tool_name}: {path}")
})?;
referenced_secret_names.extend(Self::tool_secret_names(&cap));
}
let channels = crate::channels::wasm::discover_channels(&self.wasm_channels_dir)
.await
.map_err(|e| format!("discover channels: {e}"))?;
for (channel_name, discovered_channel) in &channels {
let cap = self
.load_channel_capabilities(channel_name)
.await
.ok_or_else(|| {
let path = discovered_channel
.capabilities_path
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| format!("{} (missing)", channel_name));
format!("load channel capabilities for {channel_name}: {path}")
})?;
referenced_secret_names.extend(Self::channel_secret_names(&cap));
}
let mcp_servers = self
.load_mcp_servers(user_id)
.await
.map_err(|e| format!("load MCP servers: {e}"))?;
for server in &mcp_servers.servers {
referenced_secret_names.extend(Self::mcp_server_secret_names(server));
}
Ok(referenced_secret_names)
}
fn tool_secret_names(cap: &crate::tools::wasm::CapabilitiesFile) -> HashSet<String> {
let mut names = HashSet::new();
if let Some(auth) = &cap.auth {
names.insert(auth.secret_name.to_lowercase());
}
if let Some(setup) = &cap.setup {
names.extend(
setup
.required_secrets
.iter()
.map(|secret| secret.name.to_lowercase()),
);
}
if let Some(http) = &cap.http {
names.extend(
http.credentials
.values()
.map(|credential| credential.secret_name.to_lowercase()),
);
}
if let Some(webhook) = &cap.webhook {
if let Some(secret_name) = &webhook.secret_name {
names.insert(secret_name.to_lowercase());
}
if let Some(secret_name) = &webhook.signature_key_secret_name {
names.insert(secret_name.to_lowercase());
}
if let Some(secret_name) = &webhook.hmac_secret_name {
names.insert(secret_name.to_lowercase());
}
}
names
}
fn channel_secret_names(
cap: &crate::channels::wasm::ChannelCapabilitiesFile,
) -> HashSet<String> {
let mut names: HashSet<String> = cap
.setup
.required_secrets
.iter()
.map(|secret| secret.name.to_lowercase())
.collect();
if let Some(http) = cap.capabilities.tool.http.as_ref() {
names.extend(
http.credentials
.values()
.map(|credential| credential.secret_name.to_lowercase()),
);
}
if let Some(webhook) = cap
.capabilities
.channel
.as_ref()
.and_then(|channel| channel.webhook.as_ref())
{
if webhook.secret_header.is_some() || webhook.secret_name.is_some() {
names.insert(cap.webhook_secret_name().to_lowercase());
}
if let Some(secret_name) = cap.signature_key_secret_name() {
names.insert(secret_name.to_lowercase());
}
if let Some(secret_name) = cap.hmac_secret_name() {
names.insert(secret_name.to_lowercase());
}
}
names
}
fn mcp_server_secret_names(server: &McpServerConfig) -> HashSet<String> {
[
server.token_secret_name().to_lowercase(),
server.client_id_secret_name().to_lowercase(),
]
.into_iter()
.collect()
}
/// Collect merged OAuth scopes from all installed tools sharing the same secret_name.
///
/// When multiple tools share an OAuth provider (e.g., google-calendar and google-drive
@@ -6338,8 +6033,6 @@ mod tests {
ExtensionError, ExtensionKind, ExtensionSource, InstallResult, VerificationChallenge,
};
use crate::pairing::PairingStore;
use crate::secrets::CreateSecretParams;
use crate::tools::mcp::McpServerConfig;
fn require(condition: bool, message: impl Into<String>) -> Result<(), String> {
if condition {
@@ -6660,38 +6353,6 @@ mod tests {
tools_dir
}
fn write_test_channel(
dir: &std::path::Path,
name: &str,
capabilities_json: &str,
) -> std::path::PathBuf {
let channels_dir = dir.join("channels");
std::fs::create_dir_all(&channels_dir).expect("channels dir");
std::fs::write(
channels_dir.join(format!("{name}.wasm")),
b"not-a-real-wasm",
)
.expect("wasm");
std::fs::write(
channels_dir.join(format!("{name}.capabilities.json")),
capabilities_json,
)
.expect("capabilities");
channels_dir
}
async fn store_test_secret(
manager: &crate::extensions::manager::ExtensionManager,
name: &str,
value: &str,
) {
manager
.secrets
.create("test", CreateSecretParams::new(name, value))
.await
.expect("store secret");
}
#[test]
fn test_setting_value_is_present() {
assert!(
@@ -7762,13 +7423,7 @@ mod tests {
// Regression: remove() only checked channel_runtime for shutdown, missing
// relay-only mode where only relay_channel_manager is set.
let dir = tempfile::tempdir().expect("temp dir");
let (store, _db_dir) = make_test_store().await;
let mgr = make_test_manager_with_dirs(
None,
dir.path().join("tools"),
dir.path().join("channels"),
Some(store),
);
let mgr = make_test_manager(None, dir.path().to_path_buf());
// Set up relay channel manager with a stub channel
let cm = Arc::new(crate::channels::ChannelManager::new());
@@ -7795,8 +7450,6 @@ mod tests {
.await
.expect("store team_id");
}
store_test_secret(&mgr, "relay:slack-relay:oauth_state", "nonce").await;
store_test_secret(&mgr, "relay:slack-relay:stream_token", "legacy-token").await;
// Verify channel exists before removal
assert!(cm.get_channel("slack-relay").await.is_some());
@@ -7825,30 +7478,6 @@ mod tests {
cm.get_channel("slack-relay").await.is_none(),
"relay channel should be removed from the channel manager"
);
assert!(
!mgr.secrets
.exists("test", "relay:slack-relay:oauth_state")
.await
.expect("oauth state exists query"),
"relay oauth_state secret should be removed"
);
assert!(
!mgr.secrets
.exists("test", "relay:slack-relay:stream_token")
.await
.expect("stream token exists query"),
"relay legacy stream token should be removed"
);
assert_eq!(
mgr.store
.as_ref()
.expect("store")
.get_setting("test", "relay:slack-relay:team_id")
.await
.expect("team_id query"),
None,
"relay team_id setting should be removed"
);
}
#[tokio::test]
@@ -7956,185 +7585,6 @@ mod tests {
);
}
#[tokio::test]
async fn test_remove_wasm_tool_deletes_unique_secrets() {
let dir = tempfile::tempdir().expect("temp dir");
let tools_dir = write_test_tool(
dir.path(),
"github",
r#"{
"name": "github",
"auth": { "secret_name": "github_token" },
"setup": {
"required_secrets": [
{ "name": "github_client_secret", "prompt": "GitHub client secret for testing cleanup behavior." }
]
},
"http": {
"credentials": {
"service_token": {
"secret_name": "github_service_token",
"location": { "type": "bearer" }
}
}
},
"webhook": {
"hmac_secret_name": "github_webhook_secret"
}
}"#,
);
let mgr = make_test_manager_with_dirs(None, tools_dir, dir.path().join("channels"), None);
store_test_secret(&mgr, "github_token", "access-token").await;
store_test_secret(&mgr, "github_token_refresh_token", "refresh-token").await;
store_test_secret(&mgr, "github_token_scopes", "repo workflow").await;
store_test_secret(&mgr, "github_client_secret", "client-secret").await;
store_test_secret(&mgr, "github_service_token", "service-token").await;
store_test_secret(&mgr, "github_webhook_secret", "webhook-secret").await;
mgr.remove("github", "test")
.await
.expect("remove should succeed");
for secret_name in [
"github_token",
"github_token_refresh_token",
"github_token_scopes",
"github_client_secret",
"github_service_token",
"github_webhook_secret",
] {
assert!(
!mgr.secrets
.exists("test", secret_name)
.await
.expect("exists query"),
"secret {secret_name} should be deleted"
);
}
}
#[tokio::test]
async fn test_remove_wasm_tool_keeps_secrets_when_other_tool_capabilities_missing() {
let dir = tempfile::tempdir().expect("temp dir");
let tools_dir = write_test_tool(
dir.path(),
"github",
r#"{
"name": "github",
"auth": { "secret_name": "shared_token" }
}"#,
);
std::fs::write(tools_dir.join("broken.wasm"), b"fake-tool").expect("write tool");
let mgr = make_test_manager_with_dirs(None, tools_dir, dir.path().join("channels"), None);
store_test_secret(&mgr, "shared_token", "access-token").await;
store_test_secret(&mgr, "shared_token_refresh_token", "refresh-token").await;
store_test_secret(&mgr, "shared_token_scopes", "repo").await;
mgr.remove("github", "test")
.await
.expect("remove should succeed");
for secret_name in [
"shared_token",
"shared_token_refresh_token",
"shared_token_scopes",
] {
assert!(
mgr.secrets
.exists("test", secret_name)
.await
.expect("exists query"),
"secret {secret_name} should be retained when reference detection is uncertain"
);
}
}
#[tokio::test]
async fn test_remove_wasm_tool_keeps_shared_secrets_until_last_extension() {
let dir = tempfile::tempdir().expect("temp dir");
write_test_tool(
dir.path(),
"google-calendar",
r#"{
"name": "google-calendar",
"auth": { "secret_name": "google_oauth_token" },
"setup": {
"required_secrets": [
{ "name": "google_oauth_client_id", "prompt": "Google OAuth client id for cleanup testing." },
{ "name": "google_oauth_client_secret", "prompt": "Google OAuth client secret for cleanup testing." }
]
}
}"#,
);
let tools_dir = write_test_tool(
dir.path(),
"google-drive",
r#"{
"name": "google-drive",
"auth": { "secret_name": "google_oauth_token" },
"setup": {
"required_secrets": [
{ "name": "google_oauth_client_id", "prompt": "Google OAuth client id for cleanup testing." },
{ "name": "google_oauth_client_secret", "prompt": "Google OAuth client secret for cleanup testing." }
]
}
}"#,
);
let mgr = make_test_manager_with_dirs(None, tools_dir, dir.path().join("channels"), None);
for (secret_name, value) in [
("google_oauth_token", "access-token"),
("google_oauth_token_refresh_token", "refresh-token"),
("google_oauth_token_scopes", "calendar drive"),
("google_oauth_client_id", "client-id"),
("google_oauth_client_secret", "client-secret"),
] {
store_test_secret(&mgr, secret_name, value).await;
}
mgr.remove("google-calendar", "test")
.await
.expect("first remove should succeed");
for secret_name in [
"google_oauth_token",
"google_oauth_token_refresh_token",
"google_oauth_token_scopes",
"google_oauth_client_id",
"google_oauth_client_secret",
] {
assert!(
mgr.secrets
.exists("test", secret_name)
.await
.expect("exists query"),
"shared secret {secret_name} should remain while google-drive is still installed"
);
}
mgr.remove("google-drive", "test")
.await
.expect("second remove should succeed");
for secret_name in [
"google_oauth_token",
"google_oauth_token_refresh_token",
"google_oauth_token_scopes",
"google_oauth_client_id",
"google_oauth_client_secret",
] {
assert!(
!mgr.secrets
.exists("test", secret_name)
.await
.expect("exists query"),
"shared secret {secret_name} should be deleted after the last tool is removed"
);
}
}
#[tokio::test]
async fn test_remove_wasm_channel_clears_activation_error_and_deletes_files() {
let dir = tempfile::tempdir().expect("temp dir");
@@ -8169,104 +7619,6 @@ mod tests {
);
}
#[tokio::test]
async fn test_remove_wasm_channel_deletes_setup_secrets() {
let dir = tempfile::tempdir().expect("temp dir");
let channels_dir = write_test_channel(
dir.path(),
"telegram",
r#"{
"type": "channel",
"name": "telegram",
"setup": {
"required_secrets": [
{
"name": "telegram_bot_token",
"prompt": "Telegram bot token used to verify uninstall cleanup behavior."
}
]
},
"capabilities": {
"http": {
"credentials": {
"tenant_token": {
"secret_name": "telegram_service_token",
"location": { "type": "bearer" }
}
}
},
"channel": {
"webhook": {
"secret_header": "X-Telegram-Bot-Api-Secret-Token",
"secret_name": "telegram_webhook_secret"
}
}
}
}"#,
);
let mgr = make_test_manager_with_dirs(None, dir.path().join("tools"), channels_dir, None);
store_test_secret(&mgr, "telegram_bot_token", "123:telegram-token").await;
store_test_secret(&mgr, "telegram_service_token", "tenant-service-token").await;
store_test_secret(&mgr, "telegram_webhook_secret", "webhook-secret").await;
mgr.remove("telegram", "test")
.await
.expect("remove should succeed");
for secret_name in [
"telegram_bot_token",
"telegram_service_token",
"telegram_webhook_secret",
] {
assert!(
!mgr.secrets
.exists("test", secret_name)
.await
.expect("exists query"),
"channel secret {secret_name} should be deleted"
);
}
}
#[tokio::test]
async fn test_remove_mcp_server_deletes_stored_secrets() {
let dir = tempfile::tempdir().expect("temp dir");
let (store, _db_dir) = make_test_store().await;
let mgr = make_test_manager_with_dirs(
None,
dir.path().join("tools"),
dir.path().join("channels"),
Some(Arc::clone(&store)),
);
let server = McpServerConfig::new("notion", "https://example.com/mcp");
mgr.add_mcp_server(server.clone(), "test")
.await
.expect("add mcp server");
store_test_secret(&mgr, &server.token_secret_name(), "access-token").await;
store_test_secret(&mgr, &server.refresh_token_secret_name(), "refresh-token").await;
store_test_secret(&mgr, &server.client_id_secret_name(), "client-id").await;
mgr.remove("notion", "test")
.await
.expect("remove should succeed");
for secret_name in [
server.token_secret_name(),
server.refresh_token_secret_name(),
server.client_id_secret_name(),
] {
assert!(
!mgr.secrets
.exists("test", &secret_name)
.await
.expect("exists query"),
"MCP secret {secret_name} should be deleted"
);
}
}
#[test]
fn test_sanitize_url_with_query_params() {
let url = "https://api.example.com/path?api_key=secret123&token=abc";
-540
View File
@@ -2279,546 +2279,6 @@ impl Store {
}
}
// ==================== Users / API Tokens / Invitations ====================
#[cfg(feature = "postgres")]
use crate::db::{ApiTokenRecord, UserRecord};
#[cfg(feature = "postgres")]
impl Store {
/// Create a new user record.
pub async fn create_user(&self, user: &UserRecord) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
r#"
INSERT INTO users (id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
"#,
&[
&user.id,
&user.email,
&user.display_name,
&user.status,
&user.role,
&user.created_at,
&user.updated_at,
&user.last_login_at,
&user.created_by,
&user.metadata,
],
)
.await?;
Ok(())
}
/// Get a user by their string id.
pub async fn get_user(&self, id: &str) -> Result<Option<UserRecord>, DatabaseError> {
let conn = self.conn().await?;
let row = conn
.query_opt("SELECT id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata FROM users WHERE id = $1", &[&id])
.await?;
Ok(row.map(|r| row_to_user(&r)))
}
/// Get a user by email address.
pub async fn get_user_by_email(
&self,
email: &str,
) -> Result<Option<UserRecord>, DatabaseError> {
let conn = self.conn().await?;
let row = conn
.query_opt("SELECT id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata FROM users WHERE email = $1", &[&email])
.await?;
Ok(row.map(|r| row_to_user(&r)))
}
/// List users, optionally filtered by status.
pub async fn list_users(&self, status: Option<&str>) -> Result<Vec<UserRecord>, DatabaseError> {
let conn = self.conn().await?;
let rows = match status {
Some(s) => {
conn.query(
"SELECT id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata FROM users WHERE status = $1 ORDER BY created_at DESC",
&[&s],
)
.await?
}
None => {
conn.query("SELECT id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata FROM users ORDER BY created_at DESC", &[])
.await?
}
};
Ok(rows.iter().map(row_to_user).collect())
}
/// Update a user's status.
pub async fn update_user_status(&self, id: &str, status: &str) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
"UPDATE users SET status = $1, updated_at = NOW() WHERE id = $2",
&[&status, &id],
)
.await?;
Ok(())
}
/// Update a user's role (admin/member).
pub async fn update_user_role(&self, id: &str, role: &str) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
"UPDATE users SET role = $1, updated_at = NOW() WHERE id = $2",
&[&role, &id],
)
.await?;
Ok(())
}
/// Update a user's display name and metadata.
pub async fn update_user_profile(
&self,
id: &str,
display_name: &str,
metadata: &serde_json::Value,
) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
"UPDATE users SET display_name = $1, metadata = $2, updated_at = NOW() WHERE id = $3",
&[&display_name, metadata, &id],
)
.await?;
Ok(())
}
/// Record a login timestamp for a user.
pub async fn record_login(&self, id: &str) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
"UPDATE users SET last_login_at = NOW(), updated_at = NOW() WHERE id = $1",
&[&id],
)
.await?;
Ok(())
}
/// Create a new API token.
pub async fn create_api_token(
&self,
user_id: &str,
name: &str,
token_hash: &[u8; 32],
token_prefix: &str,
expires_at: Option<DateTime<Utc>>,
) -> Result<ApiTokenRecord, DatabaseError> {
let conn = self.conn().await?;
let id = Uuid::new_v4();
let now = Utc::now();
conn.execute(
r#"
INSERT INTO api_tokens (id, user_id, token_hash, token_prefix, name, expires_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
"#,
&[
&id,
&user_id,
&token_hash.as_slice(),
&token_prefix,
&name,
&expires_at,
&now,
],
)
.await?;
Ok(ApiTokenRecord {
id,
user_id: user_id.to_string(),
name: name.to_string(),
token_prefix: token_prefix.to_string(),
expires_at,
last_used_at: None,
created_at: now,
revoked_at: None,
})
}
/// Create a user and their initial API token atomically in a single transaction.
pub async fn create_user_with_token(
&self,
user: &UserRecord,
token_name: &str,
token_hash: &[u8; 32],
token_prefix: &str,
expires_at: Option<DateTime<Utc>>,
) -> Result<ApiTokenRecord, DatabaseError> {
let mut conn = self.conn().await?;
let tx = conn.transaction().await?;
tx.execute(
r#"
INSERT INTO users (id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
"#,
&[
&user.id,
&user.email,
&user.display_name,
&user.status,
&user.role,
&user.created_at,
&user.updated_at,
&user.last_login_at,
&user.created_by,
&user.metadata,
],
)
.await?;
let id = Uuid::new_v4();
let now = Utc::now();
tx.execute(
r#"
INSERT INTO api_tokens (id, user_id, token_hash, token_prefix, name, expires_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
"#,
&[
&id,
&user.id,
&token_hash.as_slice(),
&token_prefix,
&token_name,
&expires_at,
&now,
],
)
.await?;
tx.commit().await?;
Ok(ApiTokenRecord {
id,
user_id: user.id.clone(),
name: token_name.to_string(),
token_prefix: token_prefix.to_string(),
expires_at,
last_used_at: None,
created_at: now,
revoked_at: None,
})
}
/// List tokens for a user.
pub async fn list_api_tokens(
&self,
user_id: &str,
) -> Result<Vec<ApiTokenRecord>, DatabaseError> {
let conn = self.conn().await?;
let rows = conn
.query(
r#"
SELECT id, user_id, name, token_prefix, expires_at, last_used_at, created_at, revoked_at
FROM api_tokens
WHERE user_id = $1
ORDER BY created_at DESC
"#,
&[&user_id],
)
.await?;
Ok(rows.iter().map(row_to_api_token).collect())
}
/// Soft-revoke a token. Returns false if the token doesn't exist or doesn't belong to the user.
pub async fn revoke_api_token(
&self,
token_id: Uuid,
user_id: &str,
) -> Result<bool, DatabaseError> {
let conn = self.conn().await?;
let count = conn
.execute(
"UPDATE api_tokens SET revoked_at = NOW() WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL",
&[&token_id, &user_id],
)
.await?;
Ok(count > 0)
}
/// Authenticate a token by hash. Returns the token record and its owning user
/// if the token is active (non-revoked, non-expired) and the user is active.
pub async fn authenticate_token(
&self,
token_hash: &[u8; 32],
) -> Result<Option<(ApiTokenRecord, UserRecord)>, DatabaseError> {
let conn = self.conn().await?;
let row = conn
.query_opt(
r#"
SELECT t.id, t.user_id, t.name, t.token_prefix, t.expires_at, t.last_used_at, t.created_at, t.revoked_at,
u.id as u_id, u.email, u.display_name, u.status, u.role, u.created_at as u_created_at, u.updated_at, u.last_login_at, u.created_by, u.metadata
FROM api_tokens t
JOIN users u ON t.user_id = u.id
WHERE t.token_hash = $1
AND t.revoked_at IS NULL
AND (t.expires_at IS NULL OR t.expires_at > NOW())
AND u.status = 'active'
"#,
&[&token_hash.as_slice()],
)
.await?;
Ok(row.map(|r| {
let token = ApiTokenRecord {
id: r.get("id"),
user_id: r.get("user_id"),
name: r.get("name"),
token_prefix: r.get("token_prefix"),
expires_at: r.get("expires_at"),
last_used_at: r.get("last_used_at"),
created_at: r.get("created_at"),
revoked_at: r.get("revoked_at"),
};
let user = UserRecord {
id: r.get("u_id"),
email: r.get("email"),
display_name: r.get("display_name"),
status: r.get("status"),
role: r.get("role"),
created_at: r.get("u_created_at"),
updated_at: r.get("updated_at"),
last_login_at: r.get("last_login_at"),
created_by: r.get("created_by"),
metadata: r.get("metadata"),
};
(token, user)
}))
}
/// Update `last_used_at` for a token.
pub async fn record_token_usage(&self, token_id: Uuid) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
"UPDATE api_tokens SET last_used_at = NOW() WHERE id = $1",
&[&token_id],
)
.await?;
Ok(())
}
/// Check whether any user records exist.
pub async fn has_any_users(&self) -> Result<bool, DatabaseError> {
let conn = self.conn().await?;
let row = conn
.query_one(
"SELECT EXISTS(SELECT 1 FROM users LIMIT 1) as has_users",
&[],
)
.await?;
Ok(row.get("has_users"))
}
/// Delete a user and all their data across all user-scoped tables.
/// Returns false if the user doesn't exist.
pub async fn delete_user(&self, id: &str) -> Result<bool, DatabaseError> {
let mut conn = self.conn().await?;
let tx = conn
.transaction()
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
// Delete from child tables first to avoid FK violations.
// job_events must come before agent_jobs (FK without CASCADE).
// agent_jobs cascades to job_actions, llm_calls, estimation_snapshots.
// conversations cascades to conversation_messages.
// memory_documents cascades to memory_chunks.
// routines cascades to routine_runs.
// api_tokens cascade automatically via FK on users.
for table in &[
"settings",
"heartbeat_state",
"tool_rate_limit_state",
"secret_usage_log",
"leak_detection_events",
"secrets",
"wasm_tools",
"routines",
"memory_documents",
"conversations",
] {
tx.execute(&format!("DELETE FROM {table} WHERE user_id = $1"), &[&id])
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
}
// job_events references agent_jobs(id) without CASCADE — delete via subquery.
tx.execute(
"DELETE FROM job_events WHERE job_id IN (SELECT id FROM agent_jobs WHERE user_id = $1)",
&[&id],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
tx.execute("DELETE FROM agent_jobs WHERE user_id = $1", &[&id])
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
// Nullify self-referencing created_by before deleting the user
tx.execute(
"UPDATE users SET created_by = NULL WHERE created_by = $1",
&[&id],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
// api_tokens cascade automatically via FK
let result = tx
.execute("DELETE FROM users WHERE id = $1", &[&id])
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
tx.commit()
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
Ok(result > 0)
}
/// Get per-user LLM usage stats for a time period.
/// Aggregates from llm_calls via agent_jobs.user_id.
pub async fn user_usage_stats(
&self,
user_id: Option<&str>,
since: DateTime<Utc>,
) -> Result<Vec<crate::db::UserUsageStats>, DatabaseError> {
let conn = self.conn().await?;
let rows = if let Some(uid) = user_id {
conn.query(
r#"
SELECT COALESCE(j.user_id, c.user_id) as user_id,
l.model, COUNT(*) as call_count,
COALESCE(SUM(l.input_tokens), 0) as input_tokens,
COALESCE(SUM(l.output_tokens), 0) as output_tokens,
COALESCE(SUM(l.cost), 0) as total_cost
FROM llm_calls l
LEFT JOIN agent_jobs j ON l.job_id = j.id
LEFT JOIN conversations c ON l.conversation_id = c.id
WHERE l.created_at >= $1
AND COALESCE(j.user_id, c.user_id) = $2
GROUP BY COALESCE(j.user_id, c.user_id), l.model
ORDER BY total_cost DESC
"#,
&[&since, &uid],
)
.await?
} else {
conn.query(
r#"
SELECT COALESCE(j.user_id, c.user_id) as user_id,
l.model, COUNT(*) as call_count,
COALESCE(SUM(l.input_tokens), 0) as input_tokens,
COALESCE(SUM(l.output_tokens), 0) as output_tokens,
COALESCE(SUM(l.cost), 0) as total_cost
FROM llm_calls l
LEFT JOIN agent_jobs j ON l.job_id = j.id
LEFT JOIN conversations c ON l.conversation_id = c.id
WHERE l.created_at >= $1
GROUP BY COALESCE(j.user_id, c.user_id), l.model
ORDER BY total_cost DESC
"#,
&[&since],
)
.await?
};
let mut stats = Vec::with_capacity(rows.len());
for row in &rows {
stats.push(crate::db::UserUsageStats {
user_id: row.get("user_id"),
model: row.get("model"),
call_count: row.get("call_count"),
input_tokens: row.get("input_tokens"),
output_tokens: row.get("output_tokens"),
total_cost: row.get("total_cost"),
});
}
Ok(stats)
}
/// Lightweight per-user summary stats (job count, total cost, last active).
///
/// Aggregates from `llm_calls`, resolving user_id via either `agent_jobs`
/// (for background job calls) or `conversations` (for chat calls where
/// `job_id` is NULL).
pub async fn user_summary_stats(
&self,
user_id: Option<&str>,
) -> Result<Vec<crate::db::UserSummaryStats>, DatabaseError> {
let conn = self.conn().await?;
let rows = if let Some(uid) = user_id {
conn.query(
r#"
SELECT
COALESCE(j.user_id, c.user_id) AS user_id,
COUNT(DISTINCT j.id) AS job_count,
COALESCE(SUM(l.cost), 0) AS total_cost,
MAX(l.created_at) AS last_active_at
FROM llm_calls l
LEFT JOIN agent_jobs j ON l.job_id = j.id
LEFT JOIN conversations c ON l.conversation_id = c.id
WHERE COALESCE(j.user_id, c.user_id) = $1
GROUP BY COALESCE(j.user_id, c.user_id)
"#,
&[&uid],
)
.await?
} else {
conn.query(
r#"
SELECT
COALESCE(j.user_id, c.user_id) AS user_id,
COUNT(DISTINCT j.id) AS job_count,
COALESCE(SUM(l.cost), 0) AS total_cost,
MAX(l.created_at) AS last_active_at
FROM llm_calls l
LEFT JOIN agent_jobs j ON l.job_id = j.id
LEFT JOIN conversations c ON l.conversation_id = c.id
GROUP BY COALESCE(j.user_id, c.user_id)
"#,
&[],
)
.await?
};
let mut stats = Vec::with_capacity(rows.len());
for row in &rows {
stats.push(crate::db::UserSummaryStats {
user_id: row.get("user_id"),
job_count: row.get("job_count"),
total_cost: row.get("total_cost"),
last_active_at: row.get("last_active_at"),
});
}
Ok(stats)
}
}
#[cfg(feature = "postgres")]
fn row_to_user(row: &tokio_postgres::Row) -> UserRecord {
UserRecord {
id: row.get("id"),
email: row.get("email"),
display_name: row.get("display_name"),
status: row.get("status"),
role: row.get("role"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
last_login_at: row.get("last_login_at"),
created_by: row.get("created_by"),
metadata: row.get("metadata"),
}
}
#[cfg(feature = "postgres")]
fn row_to_api_token(row: &tokio_postgres::Row) -> ApiTokenRecord {
ApiTokenRecord {
id: row.get("id"),
user_id: row.get("user_id"),
name: row.get("name"),
token_prefix: row.get("token_prefix"),
expires_at: row.get("expires_at"),
last_used_at: row.get("last_used_at"),
created_at: row.get("created_at"),
revoked_at: row.get("revoked_at"),
}
}
#[cfg(test)]
mod tests {
use super::*;
-1
View File
@@ -234,7 +234,6 @@ fn is_transient(err: &LlmError) -> bool {
LlmError::RequestFailed { .. }
| LlmError::RateLimited { .. }
| LlmError::InvalidResponse { .. }
| LlmError::EmptyResponse { .. }
| LlmError::SessionExpired { .. }
| LlmError::SessionRenewalFailed { .. }
| LlmError::Http(_)
-3
View File
@@ -17,9 +17,6 @@ pub enum LlmError {
#[error("Invalid response from {provider}: {reason}")]
InvalidResponse { provider: String, reason: String },
#[error("Empty response from {provider}: no content returned")]
EmptyResponse { provider: String },
#[error("Context length exceeded: {used} tokens used, {limit} allowed")]
ContextLengthExceeded { used: usize, limit: usize },
+4 -2
View File
@@ -231,8 +231,9 @@ impl LlmProvider for GithubCopilotProvider {
.choices
.into_iter()
.next()
.ok_or_else(|| LlmError::EmptyResponse {
.ok_or_else(|| LlmError::InvalidResponse {
provider: "github_copilot".to_string(),
reason: "No choices in response".to_string(),
})?;
let (content, _tool_calls) = extract_choice_content(&choice);
@@ -308,8 +309,9 @@ impl LlmProvider for GithubCopilotProvider {
.choices
.into_iter()
.next()
.ok_or_else(|| LlmError::EmptyResponse {
.ok_or_else(|| LlmError::InvalidResponse {
provider: "github_copilot".to_string(),
reason: "No choices in response".to_string(),
})?;
let (content, tool_calls) = extract_choice_content(&choice);
+4 -2
View File
@@ -490,8 +490,9 @@ impl LlmProvider for NearAiChatProvider {
.choices
.into_iter()
.next()
.ok_or_else(|| LlmError::EmptyResponse {
.ok_or_else(|| LlmError::InvalidResponse {
provider: "nearai_chat".to_string(),
reason: "No choices in response".to_string(),
})?;
// Fall back to reasoning_content when content is null (same as
@@ -569,8 +570,9 @@ impl LlmProvider for NearAiChatProvider {
.choices
.into_iter()
.next()
.ok_or_else(|| LlmError::EmptyResponse {
.ok_or_else(|| LlmError::InvalidResponse {
provider: "nearai_chat".to_string(),
reason: "No choices in response".to_string(),
})?;
let tool_calls: Vec<ToolCall> = choice
-1
View File
@@ -48,7 +48,6 @@ pub(crate) fn is_retryable(err: &LlmError) -> bool {
LlmError::RequestFailed { .. }
| LlmError::RateLimited { .. }
| LlmError::InvalidResponse { .. }
| LlmError::EmptyResponse { .. }
| LlmError::SessionRenewalFailed { .. }
| LlmError::Http(_)
| LlmError::Io(_)
+5 -55
View File
@@ -601,14 +601,11 @@ fn build_rig_request(
/// Inject a per-request model override into the rig request's `additional_params`.
///
/// Rig-core bakes the model name at construction time inside each provider's
/// `CompletionModel` implementation. This helper inserts a top-level `"model"`
/// key into `additional_params`, which rig-core flattens into the provider's
/// request payload via `#[serde(flatten)]`.
///
/// Whether the override takes effect depends on the downstream API server's
/// handling of duplicate JSON keys (most Python/Go servers use last-key-wins,
/// but this is not guaranteed by the JSON spec). The `effective_model_name()`
/// trait method should be consulted to determine the model actually used.
/// `CompletionModel` implementation. The actual HTTP request body includes a
/// `model` field set by the provider. Rig-core's `#[serde(flatten)]` on
/// `additional_params` emits these fields AFTER the provider's own fields.
/// Most API servers (Python, Go) use last-key-wins when deserializing
/// duplicate JSON keys, so the injected `model` value takes effect.
fn inject_model_override(rig_req: &mut RigRequest, model_override: Option<&str>) {
let Some(model) = model_override else {
return;
@@ -1518,51 +1515,4 @@ mod tests {
"different raw IDs should produce different hashed IDs"
);
}
fn make_rig_request(additional_params: Option<serde_json::Value>) -> RigRequest {
RigRequest {
preamble: None,
chat_history: OneOrMany::one(RigMessage::user("test")),
documents: Vec::new(),
tools: Vec::new(),
temperature: None,
max_tokens: None,
tool_choice: None,
additional_params,
}
}
#[test]
fn test_inject_model_override_creates_params_when_none() {
let mut req = make_rig_request(None);
inject_model_override(&mut req, Some("test-model"));
let params = req
.additional_params
.expect("additional_params should be Some");
assert_eq!(params, serde_json::json!({ "model": "test-model" }));
}
#[test]
fn test_inject_model_override_preserves_existing_params() {
let mut req = make_rig_request(Some(serde_json::json!({
"cache_control": { "type": "ephemeral" },
})));
inject_model_override(&mut req, Some("override-model"));
let params = req.additional_params.expect("should remain Some");
let obj = params.as_object().expect("should be object");
assert_eq!(
obj.get("cache_control"),
Some(&serde_json::json!({ "type": "ephemeral" }))
);
assert_eq!(obj.get("model"), Some(&serde_json::json!("override-model")));
}
#[test]
fn test_inject_model_override_noop_when_none() {
let mut req = make_rig_request(None);
inject_model_override(&mut req, None);
assert!(req.additional_params.is_none());
}
}
+27 -50
View File
@@ -591,7 +591,27 @@ async fn async_main() -> anyhow::Result<()> {
let mut gateway_url: Option<String> = None;
let mut sse_manager: Option<std::sync::Arc<ironclaw::channels::web::sse::SseManager>> = None;
if let Some(ref gw_config) = config.channels.gateway {
let mut gw = GatewayChannel::new(gw_config.clone(), config.owner_id.clone());
// Build multi-user auth state if user_tokens is configured, else single-user.
let mut gw = if let Some(ref user_tokens) = gw_config.user_tokens {
use ironclaw::channels::web::auth::{MultiAuthState, UserIdentity};
let tokens = user_tokens
.iter()
.map(|(token, cfg)| {
(
token.clone(),
UserIdentity {
user_id: cfg.user_id.clone(),
workspace_read_scopes: cfg.workspace_read_scopes.clone(),
},
)
})
.collect();
let auth = MultiAuthState::multi(tokens);
GatewayChannel::new_multi_auth(gw_config.clone(), auth)
} else {
GatewayChannel::new(gw_config.clone())
};
gw = gw.with_owner_scope(config.owner_id.clone());
gw = gw.with_llm_provider(Arc::clone(&components.llm));
if let Some(ref ws) = components.workspace {
gw = gw.with_workspace(Arc::clone(ws));
@@ -630,54 +650,6 @@ async fn async_main() -> anyhow::Result<()> {
}
if let Some(ref d) = components.db {
gw = gw.with_store(Arc::clone(d));
gw = gw.with_db_auth(Arc::clone(d));
if let Some(ref ss) = components.secrets_store {
gw = gw.with_secrets_store(Arc::clone(ss));
}
// Bootstrap: create the first admin user from single-user config
// so the owner appears in the Users admin panel immediately.
if let Ok(false) = d.has_any_users().await {
let now = chrono::Utc::now();
let user = ironclaw::db::UserRecord {
id: config.owner_id.clone(),
email: None,
display_name: config.owner_id.clone(),
status: "active".to_string(),
role: "admin".to_string(),
created_at: now,
updated_at: now,
last_login_at: None,
created_by: None,
metadata: serde_json::json!({"source": "bootstrap"}),
};
// Create admin user + bootstrap token atomically.
let auth_token = gw.auth_token();
if auth_token.is_empty() {
if let Err(e) = d.create_user(&user).await {
tracing::warn!("Failed to bootstrap admin user: {}", e);
}
} else {
use ironclaw::channels::web::auth::hash_token;
let hash = hash_token(auth_token);
let prefix = if auth_token.len() >= 8 {
&auth_token[..8]
} else {
auth_token
};
if let Err(e) = d
.create_user_with_token(&user, "bootstrap", &hash, prefix, None)
.await
{
tracing::warn!("Failed to bootstrap admin user: {}", e);
} else {
tracing::info!(
user_id = config.owner_id,
"Bootstrapped admin user from gateway config"
);
}
}
}
}
if let Some(ref jm) = container_job_manager {
gw = gw.with_job_manager(Arc::clone(jm));
@@ -819,7 +791,12 @@ async fn async_main() -> anyhow::Result<()> {
.await;
// Default user ID for extension operations (single-user mode).
let ext_user_id = config.owner_id.clone();
let ext_user_id = config
.channels
.gateway
.as_ref()
.map(|g| g.user_id.clone())
.unwrap_or_else(|| "default".to_string());
// Wire up channel runtime for hot-activation of WASM channels.
if let Some(ref ext_mgr) = components.extension_manager
+5
View File
@@ -269,6 +269,10 @@ pub struct ChannelSettings {
#[serde(default)]
pub gateway_auth_token: Option<String>,
/// Web gateway user ID.
#[serde(default)]
pub gateway_user_id: Option<String>,
/// Whether the CLI channel is enabled.
#[serde(default = "default_true")]
pub cli_enabled: bool,
@@ -338,6 +342,7 @@ impl Default for ChannelSettings {
gateway_host: None,
gateway_port: None,
gateway_auth_token: None,
gateway_user_id: None,
cli_enabled: true,
signal_enabled: false,
signal_http_url: None,
+1 -49
View File
@@ -209,13 +209,6 @@ impl TenantScope {
.await
}
// === LLM call recording ===
/// Record an LLM call to the database for persistent usage tracking.
pub async fn record_llm_call(&self, record: &LlmCallRecord<'_>) -> Result<Uuid, DatabaseError> {
self.inner.record_llm_call(record).await
}
// === Settings ===
pub async fn get_setting(&self, key: &str) -> Result<Option<serde_json::Value>, DatabaseError> {
@@ -337,62 +330,35 @@ impl TenantScope {
/// Add a message to a conversation owned by this tenant.
///
/// Returns `NotFound` if the conversation does not belong to this user.
/// Verifies the conversation belongs to this user before adding.
pub async fn add_conversation_message(
&self,
conversation_id: Uuid,
role: &str,
content: &str,
) -> Result<Uuid, DatabaseError> {
if !self.conversation_belongs_to_user(conversation_id).await? {
return Err(DatabaseError::NotFound {
entity: "conversation".to_string(),
id: conversation_id.to_string(),
});
}
self.inner
.add_conversation_message(conversation_id, role, content)
.await
}
/// Touch a conversation timestamp. Returns `NotFound` if not owned by this user.
pub async fn touch_conversation(&self, id: Uuid) -> Result<(), DatabaseError> {
if !self.conversation_belongs_to_user(id).await? {
return Err(DatabaseError::NotFound {
entity: "conversation".to_string(),
id: id.to_string(),
});
}
self.inner.touch_conversation(id).await
}
/// List messages in a conversation. Returns `NotFound` if not owned by this user.
pub async fn list_conversation_messages(
&self,
conversation_id: Uuid,
) -> Result<Vec<ConversationMessage>, DatabaseError> {
if !self.conversation_belongs_to_user(conversation_id).await? {
return Err(DatabaseError::NotFound {
entity: "conversation".to_string(),
id: conversation_id.to_string(),
});
}
self.inner.list_conversation_messages(conversation_id).await
}
/// Paginated message listing. Returns `NotFound` if not owned by this user.
pub async fn list_conversation_messages_paginated(
&self,
conversation_id: Uuid,
before: Option<DateTime<Utc>>,
limit: i64,
) -> Result<(Vec<ConversationMessage>, bool), DatabaseError> {
if !self.conversation_belongs_to_user(conversation_id).await? {
return Err(DatabaseError::NotFound {
entity: "conversation".to_string(),
id: conversation_id.to_string(),
});
}
self.inner
.list_conversation_messages_paginated(conversation_id, before, limit)
.await
@@ -408,35 +374,21 @@ impl TenantScope {
.await
}
/// Update metadata on a conversation. Returns `NotFound` if not owned by this user.
pub async fn update_conversation_metadata_field(
&self,
id: Uuid,
key: &str,
value: &serde_json::Value,
) -> Result<(), DatabaseError> {
if !self.conversation_belongs_to_user(id).await? {
return Err(DatabaseError::NotFound {
entity: "conversation".to_string(),
id: id.to_string(),
});
}
self.inner
.update_conversation_metadata_field(id, key, value)
.await
}
/// Get conversation metadata. Returns `NotFound` if not owned by this user.
pub async fn get_conversation_metadata(
&self,
id: Uuid,
) -> Result<Option<serde_json::Value>, DatabaseError> {
if !self.conversation_belongs_to_user(id).await? {
return Err(DatabaseError::NotFound {
entity: "conversation".to_string(),
id: id.to_string(),
});
}
self.inner.get_conversation_metadata(id).await
}
}
+32
View File
@@ -117,6 +117,13 @@ impl Tool for ReadFileTool {
let path = validate_path(path_str, self.base_dir.as_deref())?;
if super::path_utils::is_sensitive_path(&path) {
return Err(ToolError::NotAuthorized(format!(
"Access denied: '{}' is a sensitive path. Use the appropriate secrets management tool instead.",
path_str
)));
}
// Check file size
let metadata = fs::metadata(&path)
.await
@@ -256,6 +263,13 @@ impl Tool for WriteFileTool {
let path = validate_path(path_str, self.base_dir.as_deref())?;
if super::path_utils::is_sensitive_path(&path) {
return Err(ToolError::NotAuthorized(format!(
"Access denied: '{}' is a sensitive path",
path_str
)));
}
// Create parent directories
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await.map_err(|e| {
@@ -364,6 +378,13 @@ impl Tool for ListDirTool {
let path = validate_path(path_str, self.base_dir.as_deref())?;
if super::path_utils::is_sensitive_path(&path) {
return Err(ToolError::NotAuthorized(format!(
"Access denied: '{}' is a sensitive directory",
path_str
)));
}
let mut entries = Vec::new();
list_dir_inner(&path, &path, recursive, max_depth, 0, &mut entries).await?;
@@ -447,6 +468,10 @@ async fn list_dir_inner(
entries.push(display);
if recursive && is_dir && current_depth < max_depth {
// Skip sensitive directories during recursive traversal
if super::path_utils::is_sensitive_path(&entry_path) {
continue;
}
// Skip common non-essential directories
let name = entry.file_name();
let name_str = name.to_string_lossy();
@@ -561,6 +586,13 @@ impl Tool for ApplyPatchTool {
let path = validate_path(path_str, self.base_dir.as_deref())?;
if super::path_utils::is_sensitive_path(&path) {
return Err(ToolError::NotAuthorized(format!(
"Access denied: '{}' is a sensitive path",
path_str
)));
}
// Read current content
let content = fs::read_to_string(&path)
.await
+4 -140
View File
@@ -246,26 +246,9 @@ impl Tool for MemoryWriteTool {
"type": "boolean",
"description": "Skip privacy classification and write directly to the specified layer without redirect. Use when you're certain the content belongs in the target layer.",
"default": false
},
"metadata": {
"type": "object",
"description": "Optional metadata to set on the document (e.g., {\"skip_indexing\": true, \"hygiene\": {\"enabled\": true, \"retention_days\": 7}})"
},
"old_string": {
"type": "string",
"description": "When present, switches to patch mode: finds and replaces this exact string in the document. Requires target to be a path (not 'memory' or 'daily_log')."
},
"new_string": {
"type": "string",
"description": "Replacement string (required when old_string is present)."
},
"replace_all": {
"type": "boolean",
"description": "If true, replace all occurrences of old_string. Default: false.",
"default": false
}
},
"required": []
"required": ["content"]
})
}
@@ -276,9 +259,7 @@ impl Tool for MemoryWriteTool {
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
// In patch mode (old_string present), content is not required.
let is_patch_mode = params.get("old_string").and_then(|v| v.as_str()).is_some();
let content = params.get("content").and_then(|v| v.as_str()).unwrap_or("");
let content = require_str(&params, "content")?;
let target = params
.get("target")
@@ -318,9 +299,9 @@ impl Tool for MemoryWriteTool {
return Ok(ToolOutput::success(output, start.elapsed()));
}
if !is_patch_mode && content.trim().is_empty() {
if content.trim().is_empty() {
return Err(ToolError::InvalidParameters(
"content cannot be empty (use old_string/new_string for patch mode)".to_string(),
"content cannot be empty".to_string(),
));
}
@@ -349,46 +330,6 @@ impl Tool for MemoryWriteTool {
path => path.to_string(),
};
// Patch mode: if old_string is provided, do search-and-replace instead of write/append.
let old_string = params.get("old_string").and_then(|v| v.as_str());
if let Some(old_str) = old_string {
let new_str = params
.get("new_string")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters(
"new_string is required when old_string is provided".to_string(),
)
})?;
let replace_all = params
.get("replace_all")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let result = workspace
.patch(&resolved_path, old_str, new_str, replace_all)
.await
.map_err(map_write_err)?;
// Apply metadata if provided
if let Some(meta) = params.get("metadata")
&& meta.is_object()
{
workspace
.update_metadata(result.document.id, meta)
.await
.map_err(map_write_err)?;
}
let output = serde_json::json!({
"status": "patched",
"path": resolved_path,
"replacements": result.replacements,
"content_length": result.document.content.len(),
});
return Ok(ToolOutput::success(output, start.elapsed()));
}
// When a layer is specified, route through layer-aware methods for ALL targets.
// Otherwise, use default workspace methods (which include injection scanning).
let layer_result = if let Some(layer_name) = layer {
@@ -492,24 +433,6 @@ impl Tool for MemoryWriteTool {
}
}
// Apply metadata if provided (after write/append, works for all targets).
// We read the document once to get its ID — this is a hot read right
// after the write, so it's effectively free (same DB connection/cache).
if let Some(meta) = params.get("metadata")
&& meta.is_object()
{
match workspace.read(&resolved_path).await {
Ok(doc) => {
if let Err(e) = workspace.update_metadata(doc.id, meta).await {
tracing::warn!(path = %resolved_path, "failed to update metadata: {e}");
}
}
Err(e) => {
tracing::warn!(path = %resolved_path, "failed to read doc for metadata update: {e}");
}
}
}
let mut output = serde_json::json!({
"status": "written",
"path": resolved_path,
@@ -578,15 +501,6 @@ impl Tool for MemoryReadTool {
"path": {
"type": "string",
"description": "Path to the file (e.g., 'MEMORY.md', 'daily/2024-01-15.md', 'projects/alpha/notes.md')"
},
"version": {
"type": "integer",
"description": "Read a specific historical version of the document (omit for current content)"
},
"list_versions": {
"type": "boolean",
"description": "If true, return version history instead of file content",
"default": false
}
},
"required": ["path"]
@@ -611,61 +525,11 @@ impl Tool for MemoryReadTool {
}
let workspace = self.resolver.resolve(&ctx.user_id).await;
let list_versions = params
.get("list_versions")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let version = params
.get("version")
.and_then(|v| v.as_i64())
.map(|v| v as i32);
// Read the document first (needed for document_id in all version operations)
let doc = workspace
.read(path)
.await
.map_err(|e| ToolError::ExecutionFailed(format!("Read failed: {}", e)))?;
// List versions mode
if list_versions {
let versions = workspace
.list_versions(doc.id, 50)
.await
.map_err(|e| ToolError::ExecutionFailed(format!("List versions failed: {}", e)))?;
let output = serde_json::json!({
"path": doc.path,
"versions": versions.iter().map(|v| serde_json::json!({
"version": v.version,
"content_hash": v.content_hash,
"created_at": v.created_at.to_rfc3339(),
"changed_by": v.changed_by,
})).collect::<Vec<_>>(),
"version_count": versions.len(),
});
return Ok(ToolOutput::success(output, start.elapsed()));
}
// Specific version mode
if let Some(ver) = version {
let version_doc = workspace
.get_version(doc.id, ver)
.await
.map_err(|e| ToolError::ExecutionFailed(format!("Get version failed: {}", e)))?;
let output = serde_json::json!({
"path": doc.path,
"version": version_doc.version,
"content": version_doc.content,
"content_hash": version_doc.content_hash,
"created_at": version_doc.created_at.to_rfc3339(),
"changed_by": version_doc.changed_by,
});
return Ok(ToolOutput::success(output, start.elapsed()));
}
// Normal read
let output = serde_json::json!({
"path": doc.path,
"content": doc.content,
+201
View File
@@ -4,9 +4,119 @@
//! attacks and ensure paths stay within allowed sandboxes.
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use crate::tools::tool::ToolError;
/// Paths that contain credentials, secrets, or private keys.
/// Used by both file tools (exact path check) and shell tool (substring scan).
/// Keep sorted by category for readability.
static SENSITIVE_PATH_PATTERNS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
vec![
// SSH
"/.ssh/",
"/id_rsa",
"/id_ed25519",
"/id_ecdsa",
"/id_dsa",
"/authorized_keys",
"/known_hosts",
// GPG
"/.gnupg/",
// AWS
"/.aws/credentials",
"/.aws/config",
// Kubernetes
"/.kube/config",
// Cloud providers
"/.azure/",
"/.gcloud/",
"/.config/gcloud/",
// Terraform
"/.terraform.d/credentials.tfrc.json",
// GitHub CLI
"/.config/gh/hosts.yml",
// Docker
"/.docker/config.json",
// Vault
"/.vault-token",
// Shell history
"/.bash_history",
"/.zsh_history",
"/.histfile",
// Env files (may contain secrets)
"/.env",
// Git credentials
"/.git-credentials",
"/.netrc",
"/.pgpass",
// IronClaw's own secrets
"/.ironclaw/secrets/",
// System
"/etc/shadow",
"/etc/gshadow",
]
});
/// File extensions that are always sensitive regardless of location.
static SENSITIVE_EXTENSIONS: LazyLock<Vec<&'static str>> =
LazyLock::new(|| vec![".pem", ".key", ".p12", ".pfx", ".jks", ".keystore"]);
/// Suffixes that indicate a file is safe despite matching a sensitive pattern
/// (e.g., `.env.example`, `.env.sample`).
static SAFE_SUFFIXES: LazyLock<Vec<&'static str>> =
LazyLock::new(|| vec![".example", ".sample", ".template", ".dist", ".bak.example"]);
/// Check if a resolved file path points to a sensitive location.
/// Used by file tools (read, write, list_dir, apply_patch).
pub fn is_sensitive_path(path: &Path) -> bool {
let path_str = match path.canonicalize() {
Ok(p) => p.to_string_lossy().to_string(),
Err(_) => path.to_string_lossy().to_string(),
};
// Safe suffixes override sensitive patterns
let lower = path_str.to_lowercase();
if SAFE_SUFFIXES.iter().any(|s| lower.ends_with(s)) {
return false;
}
// Check sensitive path patterns
if SENSITIVE_PATH_PATTERNS.iter().any(|p| path_str.contains(p)) {
return true;
}
// Check sensitive file extensions
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
let dot_ext = format!(".{}", ext.to_lowercase());
if SENSITIVE_EXTENSIONS.iter().any(|e| *e == dot_ext) {
return true;
}
}
false
}
/// Scan a shell command string for references to sensitive paths.
/// Returns the first matched pattern, or None if the command is clean.
/// Used by the shell tool to block `cat ~/.ssh/id_rsa` etc.
pub fn command_references_sensitive_path(command: &str) -> Option<&'static str> {
let normalized = command.to_lowercase();
for pattern in SENSITIVE_PATH_PATTERNS.iter() {
// For path patterns, check case-insensitively
if normalized.contains(&pattern.to_lowercase()) {
return Some(pattern);
}
}
// Check for sensitive extensions in file arguments
SENSITIVE_EXTENSIONS
.iter()
.find(|ext| normalized.contains(*ext))
.copied()
}
/// Normalize a path by resolving `.` and `..` components lexically (no filesystem access).
///
/// This is critical for security: `std::fs::canonicalize` only works on paths that exist,
@@ -236,4 +346,95 @@ mod tests {
let result = validate_path("a/b/../c.txt", Some(dir.path()));
assert!(result.is_ok());
}
// ── sensitive path tests ──
#[test]
fn test_is_sensitive_path_blocks_ssh() {
assert!(is_sensitive_path(Path::new("/home/user/.ssh/id_rsa")));
assert!(is_sensitive_path(Path::new(
"/home/user/.ssh/authorized_keys"
)));
assert!(is_sensitive_path(Path::new("/root/.ssh/config")));
}
#[test]
fn test_is_sensitive_path_blocks_cloud_credentials() {
assert!(is_sensitive_path(Path::new("/home/user/.aws/credentials")));
assert!(is_sensitive_path(Path::new("/home/user/.kube/config")));
assert!(is_sensitive_path(Path::new("/home/user/.azure/some_token")));
assert!(is_sensitive_path(Path::new(
"/home/user/.config/gh/hosts.yml"
)));
}
#[test]
fn test_is_sensitive_path_blocks_system_secrets() {
assert!(is_sensitive_path(Path::new("/etc/shadow")));
assert!(is_sensitive_path(Path::new("/etc/gshadow")));
}
#[test]
fn test_is_sensitive_path_blocks_key_files_by_extension() {
assert!(is_sensitive_path(Path::new("/tmp/server.pem")));
assert!(is_sensitive_path(Path::new("/app/certs/private.key")));
assert!(is_sensitive_path(Path::new("/home/user/keystore.p12")));
}
#[test]
fn test_is_sensitive_path_allows_safe_suffixes() {
assert!(!is_sensitive_path(Path::new("/app/.env.example")));
assert!(!is_sensitive_path(Path::new("/app/.env.sample")));
assert!(!is_sensitive_path(Path::new("/app/.env.template")));
}
#[test]
fn test_is_sensitive_path_allows_normal_files() {
assert!(!is_sensitive_path(Path::new("/app/src/main.rs")));
assert!(!is_sensitive_path(Path::new("/home/user/README.md")));
assert!(!is_sensitive_path(Path::new("/tmp/output.json")));
}
#[test]
fn test_is_sensitive_path_blocks_env_files() {
assert!(is_sensitive_path(Path::new("/app/.env")));
assert!(is_sensitive_path(Path::new("/app/.env.local")));
assert!(is_sensitive_path(Path::new("/app/.env.production")));
}
// ── command scanning tests ──
#[test]
fn test_command_references_sensitive_path_catches_cat_ssh() {
assert!(command_references_sensitive_path("cat ~/.ssh/id_rsa").is_some());
assert!(
command_references_sensitive_path("head -n 5 /home/user/.ssh/authorized_keys")
.is_some()
);
}
#[test]
fn test_command_references_sensitive_path_catches_aws() {
assert!(command_references_sensitive_path("cat ~/.aws/credentials").is_some());
assert!(command_references_sensitive_path("grep key ~/.aws/config").is_some());
}
#[test]
fn test_command_references_sensitive_path_catches_etc_shadow() {
assert!(command_references_sensitive_path("cat /etc/shadow").is_some());
}
#[test]
fn test_command_references_sensitive_path_catches_key_extensions() {
assert!(command_references_sensitive_path("cp server.pem /tmp/").is_some());
assert!(command_references_sensitive_path("cat private.key").is_some());
}
#[test]
fn test_command_references_sensitive_path_allows_safe_commands() {
assert!(command_references_sensitive_path("ls -la").is_none());
assert!(command_references_sensitive_path("cargo build").is_none());
assert!(command_references_sensitive_path("git status").is_none());
assert!(command_references_sensitive_path("cat README.md").is_none());
}
}
+9 -13
View File
@@ -83,21 +83,12 @@ static BLOCKED_COMMANDS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
});
/// Patterns that indicate potentially dangerous commands.
/// Note: sensitive file paths (/.ssh/, /etc/shadow, etc.) are now handled by
/// `command_references_sensitive_path` in path_utils.rs for consistency with
/// file tool protections. This list covers command-level dangers only.
static DANGEROUS_PATTERNS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
vec![
"sudo ",
"doas ",
" | sh",
" | bash",
" | zsh",
"eval ",
"$(curl",
"$(wget",
"/etc/passwd",
"/etc/shadow",
"~/.ssh",
".bash_history",
"id_rsa",
"sudo ", "doas ", " | sh", " | bash", " | zsh", "eval ", "$(curl", "$(wget",
]
});
@@ -622,6 +613,11 @@ impl ShellTool {
}
}
// Block commands that reference sensitive file paths (shared with file tools)
if super::path_utils::command_references_sensitive_path(cmd).is_some() {
return Some("Command references sensitive file path");
}
None
}
+2 -4
View File
@@ -124,10 +124,8 @@ impl WasmToolLoader {
let wasm_bytes = fs::read(wasm_path).await?;
// Read capabilities (optional) and extract OAuth refresh config
// and tool description. Parameter schema is NOT read from the
// capabilities file — it is auto-derived from the WASM module's
// schema() export at prepare time (see WasmToolSchemas::compact_schema),
// so no schema override is needed here.
// and tool description. Parameter schema is auto-derived from the
// WASM module's schema() export (see WasmToolSchemas::compact_schema).
let (capabilities, oauth_refresh, description) = if let Some(cap_path) = capabilities_path {
if cap_path.exists() {
let cap_bytes = fs::read(cap_path).await?;
+5 -92
View File
@@ -759,31 +759,14 @@ impl WasmToolSchemas {
}
let kept: serde_json::Map<String, serde_json::Value> = all_properties
.iter()
.into_iter()
.filter(|(name, prop)| {
required.contains(name.as_str())
|| prop.get("enum").is_some()
|| prop.get("const").is_some()
required.contains(name) || prop.get("enum").is_some() || prop.get("const").is_some()
})
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
if kept.is_empty() {
// When the schema has typed properties but none survived the
// required/enum filter, include all typed properties so the LLM
// sees meaningful parameter hints instead of permissive `{}`.
let typed: serde_json::Map<String, serde_json::Value> = all_properties
.into_iter()
.filter(|(_, prop)| schema_is_typed_property(prop))
.collect();
if typed.is_empty() {
return Self::permissive_schema();
}
return serde_json::json!({
"type": "object",
"properties": typed,
"additionalProperties": true,
});
return Self::permissive_schema();
}
let kept_required: Vec<serde_json::Value> = required
@@ -2008,58 +1991,6 @@ mod tests {
);
}
#[tokio::test]
async fn test_typed_schema_without_required_is_advertised() {
// Regression test for #1303: when a WASM tool exports a typed schema
// with no required/enum fields, the advertised schema should still
// contain the typed properties instead of falling back to permissive {}.
let discovery_schema = serde_json::json!({
"type": "object",
"properties": {
"query": { "type": "string" },
"limit": { "type": "integer" }
}
});
let runtime = Arc::new(WasmToolRuntime::new(WasmRuntimeConfig::for_testing()).unwrap());
let prepared = runtime
.prepare("typed_search", b"\0asm\x0d\0\x01\0", None)
.await
.unwrap();
let mut wrapper =
super::WasmToolWrapper::new(Arc::clone(&runtime), prepared, Capabilities::default());
wrapper.schemas = super::WasmToolSchemas::new(discovery_schema.clone());
wrapper.description = "Typed search tool".to_string();
let advertised = wrapper.parameters_schema();
let props = advertised["properties"].as_object().unwrap();
// Both typed properties should be preserved in the advertised schema
assert!(
props.contains_key("query"),
"advertised schema should contain 'query' property"
);
assert!(
props.contains_key("limit"),
"advertised schema should contain 'limit' property"
);
assert_eq!(props.len(), 2);
// The schema should NOT be permissive
assert!(
!super::WasmToolSchemas::is_permissive_schema(&advertised),
"advertised schema should not be permissive when typed properties exist"
);
// No tool_info hint needed since typed properties are visible
let schema = wrapper.schema();
assert!(
!schema.description.contains("tool_info"),
"description should not contain tool_info hint: {}",
schema.description
);
}
#[test]
fn test_compact_schema_keeps_required_and_enum_properties() {
let schema = serde_json::json!({
@@ -2097,8 +2028,8 @@ mod tests {
}
#[test]
fn test_compact_schema_preserves_typed_properties_when_no_required() {
// No required, no enum, but typed properties → keep all typed props
fn test_compact_schema_falls_back_to_permissive_when_empty() {
// No required, no enum → permissive fallback
let schema = serde_json::json!({
"type": "object",
"properties": {
@@ -2107,24 +2038,6 @@ mod tests {
}
});
let compacted = super::WasmToolSchemas::compact_schema(&schema);
let props = compacted["properties"].as_object().unwrap();
assert_eq!(props.len(), 2);
assert!(props.contains_key("query"));
assert!(props.contains_key("limit"));
assert_eq!(compacted["additionalProperties"], true);
}
#[test]
fn test_compact_schema_falls_back_to_permissive_when_no_typed_properties() {
// Properties with no type info → permissive fallback
let schema = serde_json::json!({
"type": "object",
"properties": {
"data": {}
}
});
let compacted = super::WasmToolSchemas::compact_schema(&schema);
assert!(compacted["properties"].as_object().unwrap().is_empty());
}
+4
View File
@@ -428,8 +428,10 @@ mod tests {
host: "127.0.0.1".to_string(),
port: 3000,
auth_token: None,
user_id: "test".to_string(),
workspace_read_scopes: Vec::new(),
memory_layers: Vec::new(),
user_tokens: None,
});
c
}
@@ -440,8 +442,10 @@ mod tests {
host: host.to_string(),
port,
auth_token: None,
user_id: "test".to_string(),
workspace_read_scopes: Vec::new(),
memory_layers: Vec::new(),
user_tokens: None,
});
c
}
+36 -144
View File
@@ -31,7 +31,6 @@ use std::time::Duration;
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncBufReadExt, BufReader};
#[cfg(not(unix))]
use tokio::process::Command;
use uuid::Uuid;
@@ -341,11 +340,6 @@ impl ClaudeBridgeRuntime {
/// Spawn a `claude` CLI process and stream its output.
///
/// Uses a PTY on Unix so Node.js line-buffers stdout instead of
/// full-buffering (which causes the bridge to hang on non-TTY pipes).
/// Arguments are passed via `execve` (no shell) — injection-safe by
/// construction.
///
/// Returns the session_id if captured from the `system` init message.
async fn run_claude_session(
&self,
@@ -353,102 +347,47 @@ impl ClaudeBridgeRuntime {
resume_session_id: Option<&str>,
extra_env: &std::collections::HashMap<String, String>,
) -> Result<Option<String>, WorkerError> {
let max_turns_str = self.config.max_turns.to_string();
let mut cmd = Command::new("claude");
cmd.arg("-p")
.arg(prompt)
.arg("--output-format")
.arg("stream-json")
.arg("--verbose")
.arg("--max-turns")
.arg(self.config.max_turns.to_string())
.arg("--model")
.arg(&self.config.model);
// Spawn with PTY on Unix to fix Node.js stdout buffering.
// All arguments are passed individually via execve — never through
// a shell interpreter. This eliminates shell injection by construction.
#[cfg(unix)]
let (mut child, stdout, stderr) = {
let (pty, pts) = pty_process::open().map_err(|e| WorkerError::ExecutionFailed {
reason: format!("failed to allocate PTY: {}", e),
if let Some(sid) = resume_session_id {
cmd.arg("--resume").arg(sid);
}
// Inject credentials into the child process environment without
// mutating the global process env (which is unsafe in multi-threaded programs).
cmd.envs(extra_env);
cmd.current_dir("/workspace")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().map_err(|e| WorkerError::ExecutionFailed {
reason: format!("failed to spawn claude: {}", e),
})?;
let stdout = child
.stdout
.take()
.ok_or_else(|| WorkerError::ExecutionFailed {
reason: "failed to capture claude stdout".to_string(),
})?;
let mut cmd = pty_process::Command::new("claude");
cmd = cmd
.arg("-p")
.arg(prompt)
.arg("--output-format")
.arg("stream-json")
.arg("--verbose")
.arg("--max-turns")
.arg(&max_turns_str)
.arg("--model")
.arg(&self.config.model);
if let Some(sid) = resume_session_id {
cmd = cmd.arg("--resume").arg(sid);
}
cmd = cmd.envs(extra_env.iter());
cmd = cmd.current_dir("/workspace");
// Keep stderr on a separate pipe — pty-process attaches the PTY
// to all fds by default, which would merge stderr into the PTY
// stream and break NDJSON parsing.
cmd = cmd.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn(pts).map_err(|e| WorkerError::ExecutionFailed {
reason: format!("failed to spawn claude with PTY: {}", e),
let stderr = child
.stderr
.take()
.ok_or_else(|| WorkerError::ExecutionFailed {
reason: "failed to capture claude stderr".to_string(),
})?;
let stderr = child
.stderr
.take()
.ok_or_else(|| WorkerError::ExecutionFailed {
reason: "failed to capture claude stderr".to_string(),
})?;
// stdout comes from the PTY master, which implements AsyncRead
let stdout: Box<dyn tokio::io::AsyncRead + Unpin + Send> = Box::new(pty);
(child, stdout, stderr)
};
// Non-Unix fallback (Windows CI) — no PTY, direct spawn.
// Claude bridge only runs in Linux Docker containers, so this path
// exists solely for compilation on Windows targets.
#[cfg(not(unix))]
let (mut child, stdout, stderr) = {
let mut cmd = Command::new("claude");
cmd.arg("-p")
.arg(prompt)
.arg("--output-format")
.arg("stream-json")
.arg("--verbose")
.arg("--max-turns")
.arg(&max_turns_str)
.arg("--model")
.arg(&self.config.model);
if let Some(sid) = resume_session_id {
cmd.arg("--resume").arg(sid);
}
cmd.envs(extra_env);
cmd.current_dir("/workspace")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().map_err(|e| WorkerError::ExecutionFailed {
reason: format!("failed to spawn claude: {}", e),
})?;
let stdout_pipe = child
.stdout
.take()
.ok_or_else(|| WorkerError::ExecutionFailed {
reason: "failed to capture claude stdout".to_string(),
})?;
let stderr = child
.stderr
.take()
.ok_or_else(|| WorkerError::ExecutionFailed {
reason: "failed to capture claude stderr".to_string(),
})?;
let stdout: Box<dyn tokio::io::AsyncRead + Unpin + Send> = Box::new(stdout_pipe);
(child, stdout, stderr)
};
// Spawn stderr reader that forwards lines as log events
let client_for_stderr = Arc::clone(&self.client);
let job_id = self.config.job_id;
@@ -1088,51 +1027,4 @@ mod tests {
let copied = copy_dir_recursive(nonexistent, dst.path()).unwrap();
assert_eq!(copied, 0);
}
/// Regression test: arguments are passed individually (not via shell string),
/// so shell metacharacters in prompt/model/session_id are harmless.
#[test]
fn command_args_no_shell_interpretation() {
// Prompt, model, and session_id may contain shell metacharacters from
// user-supplied task descriptions or LLM output. Since we use
// Command::arg() (execve), these are passed as literal strings.
let prompt = "Fix the user's bug; echo $HOME && rm -rf /";
let model = "claude-3-opus-20240229";
let session_id = "'; DROP TABLE jobs; --";
let max_turns = 10u32;
let max_turns_str = max_turns.to_string();
let args: Vec<&str> = vec![
"-p",
prompt,
"--output-format",
"stream-json",
"--verbose",
"--max-turns",
&max_turns_str,
"--model",
model,
"--resume",
session_id,
];
// All values present as literal strings — no shell interpretation
// ["-p", prompt, "--output-format", "stream-json", "--verbose",
// "--max-turns", "10", "--model", model, "--resume", session_id]
assert_eq!(args[1], prompt);
assert_eq!(args[8], model);
assert_eq!(args[10], session_id);
// Shell metacharacters preserved, not expanded
assert!(args[1].contains("$HOME"));
assert!(args[1].contains("&&"));
assert!(args[10].contains("'; DROP TABLE"));
}
/// Verify PTY is available on Unix platforms.
#[cfg(unix)]
#[tokio::test]
async fn pty_opens_successfully() {
let result = pty_process::open();
assert!(result.is_ok(), "PTY allocation should succeed on Unix");
}
}
+4 -148
View File
@@ -391,7 +391,6 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
worker: self,
rx: tokio::sync::Mutex::new(rx),
consecutive_rate_limits: std::sync::atomic::AtomicUsize::new(0),
has_text_response: std::sync::atomic::AtomicBool::new(false),
};
let config = AgenticLoopConfig {
@@ -1102,15 +1101,6 @@ fn store_fallback_in_metadata(
}
/// Job delegate: implements `LoopDelegate` for the background job context.
/// Whether an LLM error represents a completion-eligible empty response.
///
/// Only `EmptyResponse` (provider returned no choices/content) qualifies.
/// Infrastructure errors (`AuthFailed`, `Http`, `Io`, etc.) never qualify —
/// they must propagate even if prior text output was produced.
fn is_completion_eligible_error(error: &crate::error::LlmError) -> bool {
matches!(error, crate::error::LlmError::EmptyResponse { .. })
}
///
/// Handles: signal channel (stop/ping/user messages), cancellation checks,
/// rate-limit retry, parallel tool execution, DB persistence, SSE broadcasting.
@@ -1119,10 +1109,6 @@ struct JobDelegate<'a> {
rx: tokio::sync::Mutex<&'a mut mpsc::Receiver<WorkerMessage>>,
/// Tracks consecutive rate-limit errors to fail fast instead of burning iterations.
consecutive_rate_limits: std::sync::atomic::AtomicUsize,
/// Whether a substantive (non-empty) text response has been produced.
/// When true, an empty follow-up response is treated as job completion
/// rather than a retry signal (prevents spurious failures in routines).
has_text_response: std::sync::atomic::AtomicBool,
}
impl<'a> JobDelegate<'a> {
@@ -1175,53 +1161,6 @@ impl<'a> JobDelegate<'a> {
finish_reason: crate::llm::FinishReason::Stop,
})
}
/// Mark the job as completed, logging a warning on failure.
async fn mark_completed_or_warn(&self, context: &str) {
if let Err(e) = self.worker.mark_completed().await {
tracing::warn!(
job_id = %self.worker.job_id,
error = %e,
"Failed to mark job completed ({context})"
);
}
}
/// If a substantive text response was already produced and the error
/// indicates the LLM simply returned nothing, treat it as successful
/// completion rather than a fatal failure.
///
/// Only swallows `EmptyResponse` — infrastructure errors (`AuthFailed`,
/// `ContextLengthExceeded`, `Http`, `Io`, etc.) always propagate.
///
/// Returns `Some(empty RespondOutput)` when the error should be swallowed,
/// `None` when it should propagate normally.
async fn try_complete_on_error(
&self,
context: &str,
error: &crate::error::LlmError,
) -> Option<crate::llm::RespondOutput> {
if !is_completion_eligible_error(error) {
return None;
}
if !self
.has_text_response
.load(std::sync::atomic::Ordering::Relaxed)
{
return None;
}
tracing::info!(
job_id = %self.worker.job_id,
error = %error,
"{context} empty response after text output — treating as completion"
);
self.mark_completed_or_warn(context).await;
Some(crate::llm::RespondOutput {
result: RespondResult::Text(String::new()),
usage: crate::llm::TokenUsage::default(),
finish_reason: crate::llm::FinishReason::Stop,
})
}
}
#[async_trait]
@@ -1352,12 +1291,7 @@ impl<'a> LoopDelegate for JobDelegate<'a> {
Err(crate::error::LlmError::RateLimited { retry_after, .. }) => {
return self.handle_rate_limit(retry_after, "tool selection").await;
}
Err(e) => {
if let Some(output) = self.try_complete_on_error("select_tools", &e).await {
return Ok(output);
}
return Err(e.into());
}
Err(e) => return Err(e.into()),
};
// Fall back to respond_with_tools
@@ -1387,12 +1321,7 @@ impl<'a> LoopDelegate for JobDelegate<'a> {
self.handle_rate_limit(retry_after, "respond_with_tools")
.await
}
Err(e) => {
if let Some(output) = self.try_complete_on_error("respond_with_tools", &e).await {
return Ok(output);
}
Err(e.into())
}
Err(e) => Err(e.into()),
}
}
@@ -1401,22 +1330,9 @@ impl<'a> LoopDelegate for JobDelegate<'a> {
text: &str,
reason_ctx: &mut ReasoningContext,
) -> TextAction {
// Empty text after a substantive response means the LLM has finished.
// Treat as successful completion rather than continuing the loop (which
// would produce "Response contained no message or tool call (empty)").
// Empty text from rate-limit backoff retry — skip processing and let the
// loop proceed to the next iteration which will re-call the LLM.
if text.is_empty() {
if self
.has_text_response
.load(std::sync::atomic::Ordering::Relaxed)
{
tracing::debug!(
job_id = %self.worker.job_id,
"Empty response after text output — treating as completion"
);
self.mark_completed_or_warn("empty text response").await;
return TextAction::Return(LoopOutcome::Response(String::new()));
}
// No prior text response — this is likely a rate-limit backoff retry.
return TextAction::Continue;
}
@@ -1432,10 +1348,6 @@ impl<'a> LoopDelegate for JobDelegate<'a> {
return TextAction::Return(LoopOutcome::Response(text.to_string()));
}
// Track that a substantive response has been produced.
self.has_text_response
.store(true, std::sync::atomic::Ordering::Relaxed);
// Add assistant response to context
reason_ctx.messages.push(ChatMessage::assistant(text));
@@ -2373,60 +2285,4 @@ mod tests {
assert_eq!(telegram[0].0, "owner-scope");
assert_eq!(telegram[0].1.content, "hello from routine");
}
/// Regression test: only `EmptyResponse` errors are eligible for
/// completion-swallowing. Infrastructure errors must always propagate.
#[test]
fn is_completion_eligible_only_matches_empty_response() {
use crate::error::LlmError;
// EmptyResponse is eligible
assert!(super::is_completion_eligible_error(
&LlmError::EmptyResponse {
provider: "test".to_string(),
}
));
// All other variants are NOT eligible
assert!(!super::is_completion_eligible_error(
&LlmError::InvalidResponse {
provider: "test".to_string(),
reason: "parse error".to_string(),
}
));
assert!(!super::is_completion_eligible_error(
&LlmError::AuthFailed {
provider: "test".to_string(),
}
));
assert!(!super::is_completion_eligible_error(
&LlmError::ContextLengthExceeded {
used: 100_000,
limit: 50_000,
}
));
assert!(!super::is_completion_eligible_error(
&LlmError::ModelNotAvailable {
provider: "test".to_string(),
model: "gpt-4".to_string(),
}
));
assert!(!super::is_completion_eligible_error(
&LlmError::RequestFailed {
provider: "test".to_string(),
reason: "timeout".to_string(),
}
));
assert!(!super::is_completion_eligible_error(
&LlmError::SessionExpired {
provider: "test".to_string(),
}
));
assert!(!super::is_completion_eligible_error(
&LlmError::SessionRenewalFailed {
provider: "test".to_string(),
reason: "timeout".to_string(),
}
));
}
}
-248
View File
@@ -2,7 +2,6 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;
/// Well-known document paths.
@@ -38,139 +37,6 @@ pub mod paths {
pub const ASSISTANT_DIRECTIVES: &str = "context/assistant-directives.md";
}
/// Name of the folder-level configuration document.
///
/// A document at `{directory}/.config` carries metadata flags that apply
/// as defaults to all documents in that directory (e.g., `skip_indexing`,
/// `hygiene` settings). Individual document metadata overrides folder defaults.
pub const CONFIG_FILE_NAME: &str = ".config";
/// Typed overlay for the `metadata` JSON field on [`MemoryDocument`].
///
/// Fields use `Option` so that only explicitly set flags participate in
/// the merge chain (document metadata → folder `.config` → system defaults).
/// Unknown fields are preserved via `serde(flatten)`.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct DocumentMetadata {
/// When `true`, skip chunking and embedding for this document/folder.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skip_indexing: Option<bool>,
/// When `true`, skip automatic versioning for this document/folder.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skip_versioning: Option<bool>,
/// Hygiene (auto-cleanup) configuration for this folder.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hygiene: Option<HygieneMetadata>,
/// Preserve unknown fields for forward compatibility.
#[serde(flatten)]
pub extra: serde_json::Map<String, serde_json::Value>,
}
impl DocumentMetadata {
/// Parse from a raw JSON [`serde_json::Value`].
///
/// Returns [`Default`] if the value is not an object or cannot be parsed.
pub fn from_value(value: &serde_json::Value) -> Self {
serde_json::from_value(value.clone()).unwrap_or_default()
}
/// Convert to a JSON [`serde_json::Value`].
pub fn to_value(&self) -> serde_json::Value {
serde_json::to_value(self).unwrap_or(serde_json::json!({}))
}
/// Merge two metadata values: `overlay` keys win over `base` keys.
///
/// This is a shallow merge at the top-level keys — nested objects are
/// replaced wholesale, not recursively merged. This keeps the semantics
/// simple and predictable across both PostgreSQL and libSQL.
pub fn merge(base: &serde_json::Value, overlay: &serde_json::Value) -> serde_json::Value {
let mut merged = match base {
serde_json::Value::Object(map) => map.clone(),
_ => serde_json::Map::new(),
};
if let serde_json::Value::Object(over) = overlay {
for (k, v) in over {
merged.insert(k.clone(), v.clone());
}
}
serde_json::Value::Object(merged)
}
}
/// Hygiene (auto-cleanup) settings for a folder.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct HygieneMetadata {
/// Whether this folder is a hygiene target.
pub enabled: bool,
/// Delete documents older than this many days.
#[serde(default = "default_retention_days")]
pub retention_days: u32,
}
fn default_retention_days() -> u32 {
30
}
/// A historical version of a workspace document.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DocumentVersion {
/// Version record ID.
pub id: Uuid,
/// Parent document ID.
pub document_id: Uuid,
/// Version number (1-based, monotonically increasing per document).
pub version: i32,
/// Full document content at this version.
pub content: String,
/// SHA-256 hash of `content` (hex-encoded, prefixed with `sha256:`).
pub content_hash: String,
/// When this version was created.
pub created_at: DateTime<Utc>,
/// Who/what created this version (e.g. `"agent"`, `"user:alice"`).
pub changed_by: Option<String>,
}
/// Summary of a document version (without full content).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionSummary {
/// Version number.
pub version: i32,
/// SHA-256 hash of the version's content.
pub content_hash: String,
/// When this version was created.
pub created_at: DateTime<Utc>,
/// Who/what created this version.
pub changed_by: Option<String>,
}
/// Result of a workspace patch operation.
#[derive(Debug, Clone)]
pub struct PatchResult {
/// The updated document.
pub document: MemoryDocument,
/// Number of replacements made.
pub replacements: usize,
}
/// Compute a SHA-256 hash of content, returned as `"sha256:{hex}"`.
pub fn content_sha256(content: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
let result = hasher.finalize();
format!("sha256:{:x}", result)
}
/// Check if a path refers to a `.config` document.
pub fn is_config_path(path: &str) -> bool {
let file_name = path.rsplit('/').next().unwrap_or(path);
file_name == CONFIG_FILE_NAME
}
/// Paths treated as identity documents for multi-scope isolation.
///
/// These files are always read from the primary scope only — never from
@@ -494,120 +360,6 @@ mod tests {
assert_eq!(result[0].updated_at, Some(ts));
}
#[test]
fn test_document_metadata_default_is_empty() {
let meta = DocumentMetadata::default();
assert_eq!(meta.skip_indexing, None);
assert_eq!(meta.skip_versioning, None);
assert_eq!(meta.hygiene, None);
assert!(meta.extra.is_empty());
}
#[test]
fn test_document_metadata_from_value_full() {
let value = serde_json::json!({
"skip_indexing": true,
"skip_versioning": false,
"hygiene": { "enabled": true, "retention_days": 7 }
});
let meta = DocumentMetadata::from_value(&value);
assert_eq!(meta.skip_indexing, Some(true));
assert_eq!(meta.skip_versioning, Some(false));
let hygiene = meta.hygiene.unwrap();
assert!(hygiene.enabled);
assert_eq!(hygiene.retention_days, 7);
}
#[test]
fn test_document_metadata_from_value_partial() {
let value = serde_json::json!({"skip_indexing": true});
let meta = DocumentMetadata::from_value(&value);
assert_eq!(meta.skip_indexing, Some(true));
assert_eq!(meta.hygiene, None);
}
#[test]
fn test_document_metadata_from_value_invalid() {
let meta = DocumentMetadata::from_value(&serde_json::json!("not an object"));
assert_eq!(meta, DocumentMetadata::default());
}
#[test]
fn test_document_metadata_preserves_unknown_fields() {
let value = serde_json::json!({
"skip_indexing": true,
"custom_field": "hello"
});
let meta = DocumentMetadata::from_value(&value);
assert_eq!(meta.skip_indexing, Some(true));
assert_eq!(
meta.extra.get("custom_field").and_then(|v| v.as_str()),
Some("hello")
);
// Round-trip preserves the field
let back = meta.to_value();
assert_eq!(
back.get("custom_field").and_then(|v| v.as_str()),
Some("hello")
);
}
#[test]
fn test_document_metadata_merge() {
let base = serde_json::json!({"skip_indexing": false, "hygiene": {"enabled": true, "retention_days": 30}});
let overlay = serde_json::json!({"skip_indexing": true, "skip_versioning": true});
let merged = DocumentMetadata::merge(&base, &overlay);
let meta = DocumentMetadata::from_value(&merged);
// Overlay wins
assert_eq!(meta.skip_indexing, Some(true));
assert_eq!(meta.skip_versioning, Some(true));
// Base preserved when not overridden
assert!(meta.hygiene.is_some());
}
#[test]
fn test_document_metadata_merge_empty_base() {
let base = serde_json::json!({});
let overlay = serde_json::json!({"skip_indexing": true});
let merged = DocumentMetadata::merge(&base, &overlay);
let meta = DocumentMetadata::from_value(&merged);
assert_eq!(meta.skip_indexing, Some(true));
}
#[test]
fn test_hygiene_metadata_default_retention() {
let value = serde_json::json!({"enabled": true});
let hygiene: HygieneMetadata = serde_json::from_value(value).unwrap();
assert!(hygiene.enabled);
assert_eq!(hygiene.retention_days, 30);
}
#[test]
fn test_content_sha256_deterministic() {
let hash1 = content_sha256("hello world");
let hash2 = content_sha256("hello world");
assert_eq!(hash1, hash2);
assert!(hash1.starts_with("sha256:"));
}
#[test]
fn test_content_sha256_different_content() {
let hash1 = content_sha256("hello");
let hash2 = content_sha256("world");
assert_ne!(hash1, hash2);
}
#[test]
fn test_is_config_path() {
assert!(is_config_path(".config"));
assert!(is_config_path("daily/.config"));
assert!(is_config_path("frontend/widgets/.config"));
assert!(!is_config_path("daily/2024-01-15.md"));
assert!(!is_config_path("MEMORY.md"));
assert!(!is_config_path(".config.bak"));
}
#[test]
fn test_merge_workspace_entries_sorted_by_path() {
let entries = vec![
+248 -184
View File
@@ -1,10 +1,8 @@
//! Memory hygiene: automatic cleanup of stale workspace documents.
//!
//! Runs on a configurable cadence and discovers which directories have hygiene
//! enabled by reading `.config` metadata documents. This is a **metadata-driven**
//! approach: instead of hardcoding `daily/` and `conversations/`, the system
//! respects `hygiene.enabled` and `hygiene.retention_days` set on each folder's
//! `.config` document.
//! Runs on a configurable cadence and deletes daily log entries and conversation
//! documents older than their respective retention periods. Identity files
//! (`IDENTITY.md`, `SOUL.md`, etc.) are never touched.
//!
//! A global [`AtomicBool`] guard prevents concurrent hygiene passes, which
//! avoids TOCTOU races on the state file and Windows file-locking errors
@@ -12,16 +10,18 @@
//! pass completes.
//!
//! ```text
//! ┌──────────────────────────────────────────────────
//! │ Hygiene Pass
//! │
//! │ 0. Acquire RUNNING guard (skip if held)
//! │ 1. Check cadence (skip if ran recently)
//! │ 2. Save state (claim the cadence window)
//! │ 3. Discover .config docs with hygiene.enabled
//! │ 4. For each: cleanup_directory(parent, retention)
//! │ 5. Log summary
//! └──────────────────────────────────────────────────┘
//! ┌─────────────────────────────────────────────┐
//! │ Hygiene Pass │
//! │ │
//! │ 0. Acquire RUNNING guard (skip if held) │
//! │ 1. Check cadence (skip if ran recently) │
//! │ 2. Save state (claim the cadence window) │
//! │ 3. List daily/ documents
//! │ 4. Delete those older than daily_retention
//! │ 5. List conversations/ documents
//! │ 6. Delete those older than conversation_ret │
//! │ 7. Log summary │
//! └─────────────────────────────────────────────┘
//! ```
use std::path::PathBuf;
@@ -31,22 +31,46 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::bootstrap::ironclaw_base_dir;
use crate::workspace::{DocumentMetadata, Workspace, is_config_path};
use crate::workspace::Workspace;
/// Global guard preventing concurrent hygiene passes.
static RUNNING: AtomicBool = AtomicBool::new(false);
/// Paths that must never be deleted by hygiene, regardless of age.
const IDENTITY_PATHS: &[&str] = &[
crate::workspace::document::paths::MEMORY,
crate::workspace::document::paths::IDENTITY,
crate::workspace::document::paths::SOUL,
crate::workspace::document::paths::AGENTS,
crate::workspace::document::paths::USER,
crate::workspace::document::paths::HEARTBEAT,
crate::workspace::document::paths::README,
crate::workspace::document::paths::TOOLS,
crate::workspace::document::paths::BOOTSTRAP,
];
/// Check if a document path is an identity document that must never be deleted.
///
/// Performs case-insensitive comparison to handle case-insensitive filesystems
/// (Windows, macOS) and prevent accidental deletion of identity docs with
/// different casing (e.g., memory.md, MEMORY.MD, Memory.md).
fn is_identity_path(path: &str) -> bool {
let file_name = path.rsplit('/').next().unwrap_or(path);
let file_name_lower = file_name.to_lowercase();
IDENTITY_PATHS
.iter()
.any(|&p| p.to_lowercase() == file_name_lower)
}
/// Configuration for workspace hygiene.
#[derive(Debug, Clone)]
pub struct HygieneConfig {
/// Whether hygiene is enabled at all.
pub enabled: bool,
/// Maximum number of versions to keep per document.
///
/// TODO: Wire up global version pruning once per-document iteration
/// is efficient (e.g., via a dedicated DB query). For now this field
/// is stored in config but not actively enforced during hygiene passes.
pub version_keep_count: u32,
/// Documents in `daily/` older than this many days are deleted.
pub daily_retention_days: u32,
/// Documents in `conversations/` older than this many days are deleted.
pub conversation_retention_days: u32,
/// Minimum hours between hygiene passes.
pub cadence_hours: u32,
/// Directory to store state file (default: `~/.ironclaw`).
@@ -57,7 +81,8 @@ impl Default for HygieneConfig {
fn default() -> Self {
Self {
enabled: true,
version_keep_count: 50,
daily_retention_days: 30,
conversation_retention_days: 7,
cadence_hours: 12,
state_dir: ironclaw_base_dir(),
}
@@ -73,10 +98,10 @@ struct HygieneState {
/// Summary of what a hygiene pass cleaned up.
#[derive(Debug, Default)]
pub struct HygieneReport {
/// Per-directory cleanup results: `(directory_path, deleted_count)`.
pub directories_cleaned: Vec<(String, u32)>,
/// Number of document versions pruned across all documents.
pub versions_pruned: u64,
/// Number of daily log documents deleted.
pub daily_logs_deleted: u32,
/// Number of conversation documents deleted.
pub conversation_docs_deleted: u32,
/// Whether the run was skipped (cadence not yet elapsed).
pub skipped: bool,
}
@@ -84,7 +109,7 @@ pub struct HygieneReport {
impl HygieneReport {
/// True if any cleanup work was done.
pub fn had_work(&self) -> bool {
self.directories_cleaned.iter().any(|(_, n)| *n > 0) || self.versions_pruned > 0
self.daily_logs_deleted > 0 || self.conversation_docs_deleted > 0
}
}
@@ -143,51 +168,30 @@ pub async fn run_if_due(workspace: &Workspace, config: &HygieneConfig) -> Hygien
// TOCTOU races where another task reads stale state.
save_state(&state_file);
tracing::info!("memory hygiene: starting cleanup pass");
tracing::info!(
daily_retention_days = config.daily_retention_days,
conversation_retention_days = config.conversation_retention_days,
"memory hygiene: starting cleanup pass"
);
let mut report = HygieneReport::default();
// Discover directories that have hygiene enabled via .config metadata.
let config_docs = match workspace.find_config_documents().await {
Ok(docs) => docs,
Err(e) => {
tracing::warn!("memory hygiene: failed to discover .config documents: {e}");
return report;
}
};
// Delete old daily logs
match cleanup_daily_logs(workspace, config.daily_retention_days).await {
Ok(count) => report.daily_logs_deleted = count,
Err(e) => tracing::warn!("memory hygiene: failed to clean daily logs: {e}"),
}
for doc in &config_docs {
let meta = DocumentMetadata::from_value(&doc.metadata);
let Some(hygiene) = meta.hygiene else {
continue;
};
if !hygiene.enabled {
continue;
}
// Derive the parent directory from the .config path.
let directory = match doc.path.rsplit_once('/') {
Some((dir, _)) => format!("{dir}/"),
None => continue, // root-level .config — skip
};
match cleanup_directory(workspace, &directory, hygiene.retention_days).await {
Ok(deleted) => {
if deleted > 0 {
tracing::info!(directory, deleted, "memory hygiene: cleaned directory");
}
report.directories_cleaned.push((directory, deleted));
}
Err(e) => {
tracing::warn!(directory, "memory hygiene: failed to clean directory: {e}");
}
}
// Delete old conversation documents
match cleanup_conversation_docs(workspace, config.conversation_retention_days).await {
Ok(count) => report.conversation_docs_deleted = count,
Err(e) => tracing::warn!("memory hygiene: failed to clean conversation docs: {e}"),
}
if report.had_work() {
tracing::info!(
directories_cleaned = ?report.directories_cleaned,
versions_pruned = report.versions_pruned,
daily_logs_deleted = report.daily_logs_deleted,
conversation_docs_deleted = report.conversation_docs_deleted,
"memory hygiene: cleanup complete"
);
} else {
@@ -206,41 +210,88 @@ impl Drop for RunningGuard {
}
}
/// Delete documents in `directory` that are older than `retention_days`.
///
/// Skips directories and `.config` files (which must never be deleted by
/// hygiene). Returns the number of documents deleted.
async fn cleanup_directory(
/// Delete daily log documents older than `retention_days`.
async fn cleanup_daily_logs(
workspace: &Workspace,
directory: &str,
retention_days: u32,
) -> Result<u32, anyhow::Error> {
let cutoff = Utc::now() - chrono::Duration::days(i64::from(retention_days));
let entries = workspace.list(directory).await?;
let entries = workspace.list("daily/").await?;
let mut deleted = 0u32;
for entry in entries {
if entry.is_directory {
continue;
}
if is_config_path(&entry.path) {
// Never delete identity documents
if is_identity_path(&entry.path) {
continue;
}
// Check if the document is old enough to delete
if let Some(updated_at) = entry.updated_at
&& updated_at < cutoff
{
let path = if entry.path.starts_with(directory) {
let path = if entry.path.starts_with("daily/") {
entry.path.clone()
} else {
format!("{}{}", directory, entry.path)
format!("daily/{}", entry.path)
};
if let Err(e) = workspace.delete(&path).await {
tracing::warn!(path, "memory hygiene: failed to delete: {e}");
} else {
tracing::debug!(path, "memory hygiene: deleted stale document");
tracing::debug!(path, "memory hygiene: deleted old daily log");
deleted += 1;
}
}
}
Ok(deleted)
}
/// Delete conversation documents older than `retention_days`.
async fn cleanup_conversation_docs(
workspace: &Workspace,
retention_days: u32,
) -> Result<u32, anyhow::Error> {
let cutoff = Utc::now() - chrono::Duration::days(i64::from(retention_days));
let entries = workspace.list("conversations/").await?;
let mut deleted = 0u32;
for entry in entries {
if entry.is_directory {
continue;
}
// Never delete identity documents
if is_identity_path(&entry.path) {
continue;
}
// Check if the document is old enough to delete
if let Some(updated_at) = entry.updated_at
&& updated_at < cutoff
{
let path = if entry.path.starts_with("conversations/") {
entry.path.clone()
} else {
format!("conversations/{}", entry.path)
};
if let Err(e) = workspace.delete(&path).await {
tracing::warn!(
path,
"memory hygiene: failed to delete conversation doc: {e}"
);
} else {
tracing::debug!(path, "memory hygiene: deleted old conversation doc");
deleted += 1;
}
}
}
Ok(deleted)
}
@@ -298,7 +349,8 @@ mod tests {
fn default_config_is_reasonable() {
let cfg = HygieneConfig::default();
assert!(cfg.enabled);
assert_eq!(cfg.version_keep_count, 50);
assert_eq!(cfg.daily_retention_days, 30);
assert_eq!(cfg.conversation_retention_days, 7);
assert_eq!(cfg.cadence_hours, 12);
}
@@ -310,33 +362,84 @@ mod tests {
}
#[test]
fn report_had_work_when_directories_cleaned() {
fn report_had_work_when_deleted() {
let report = HygieneReport {
directories_cleaned: vec![("daily/".to_string(), 3)],
versions_pruned: 0,
daily_logs_deleted: 3,
conversation_docs_deleted: 0,
skipped: false,
};
assert!(report.had_work());
}
#[test]
fn report_had_work_when_versions_pruned() {
fn report_had_work_when_conversation_deleted() {
let report = HygieneReport {
directories_cleaned: vec![],
versions_pruned: 5,
daily_logs_deleted: 0,
conversation_docs_deleted: 2,
skipped: false,
};
assert!(report.had_work());
}
#[test]
fn report_no_work_when_zero_deletions() {
let report = HygieneReport {
directories_cleaned: vec![("daily/".to_string(), 0)],
versions_pruned: 0,
skipped: false,
};
assert!(!report.had_work());
fn is_identity_path_excludes_sacred_docs() {
for name in [
"MEMORY.md",
"IDENTITY.md",
"SOUL.md",
"AGENTS.md",
"USER.md",
"HEARTBEAT.md",
"README.md",
"TOOLS.md",
"BOOTSTRAP.md",
] {
assert!(is_identity_path(name), "{name} should be excluded");
assert!(
is_identity_path(&format!("conversations/{name}")),
"conversations/{name} should be excluded via path"
);
}
}
#[test]
fn is_identity_path_case_insensitive() {
// Verify case-insensitive matching for case-insensitive filesystems
assert!(
is_identity_path("memory.md"),
"lowercase memory.md should be excluded"
);
assert!(
is_identity_path("Memory.md"),
"mixed case Memory.md should be excluded"
);
assert!(
is_identity_path("MEMORY.MD"),
"uppercase MEMORY.MD should be excluded"
);
assert!(
is_identity_path("identity.md"),
"lowercase identity.md should be excluded"
);
assert!(
is_identity_path("conversations/soul.md"),
"conversations/soul.md should be excluded"
);
assert!(
is_identity_path("conversations/SOUL.MD"),
"conversations/SOUL.MD should be excluded"
);
}
#[test]
fn is_identity_path_allows_normal_docs() {
for path in [
"daily/2024-01-01.md",
"conversations/chat-abc.md",
"notes.md",
] {
assert!(!is_identity_path(path), "{path} should not be excluded");
}
}
#[test]
@@ -449,105 +552,61 @@ mod tests {
Arc::new(Workspace::new_with_db("default", db.clone()))
}
/// Helper to seed a .config document with hygiene metadata on a directory.
async fn seed_hygiene_config(workspace: &Workspace, directory: &str, retention_days: u32) {
let config_path = format!("{}.config", directory);
// Create the .config document with empty content
workspace
.write(&config_path, "")
.await
.expect("write .config");
// Read back to get the document ID
let doc = workspace
.read(&config_path)
.await
.expect("read .config doc");
// Set hygiene metadata
workspace
.update_metadata(
doc.id,
&serde_json::json!({
"hygiene": {"enabled": true, "retention_days": retention_days},
"skip_versioning": true
}),
)
.await
.expect("set metadata");
}
#[tokio::test]
async fn cleanup_directory_skips_config_files() {
async fn cleanup_daily_logs_preserves_identity_documents() {
let (db, _tmp) = create_test_db().await;
let ws = create_workspace(&db);
// Write documents including a .config
// Write several regular documents (non-identity)
ws.write("daily/2024-01-15.md", "Old log")
.await
.expect("write log");
ws.write("daily/.config", "").await.expect("write config");
.expect("write log 1");
ws.write("daily/2024-01-20.md", "Another log")
.await
.expect("write log 2");
// Write an identity document
ws.write("MEMORY.md", "Long-term curated memory")
.await
.expect("write identity");
// List before cleanup
let before = ws.list("daily/").await.expect("list before");
let daily_count_before = before.iter().filter(|e| !e.is_directory).count();
assert!(daily_count_before >= 2, "should have at least 2 daily logs");
// Run cleanup with 0-day retention (deletes everything old)
let deleted = cleanup_directory(&ws, "daily/", 0)
// This tests that even with aggressive cleanup, identity docs survive
let deleted = cleanup_daily_logs(&ws, 0)
.await
.expect("cleanup_directory");
.expect("cleanup_daily_logs");
// Should have deleted the log but not the .config
// Should have deleted some documents (the daily logs)
assert!(deleted > 0, "should have deleted old daily documents");
// Verify .config still exists
let config_doc = db
.get_document_by_path("default", None, "daily/.config")
// Verify identity doc still exists
let identity = db
.get_document_by_path("default", None, "MEMORY.md")
.await
.expect("get .config doc");
assert_eq!(config_doc.path, "daily/.config");
.expect("get identity doc");
assert_eq!(identity.path, "MEMORY.md");
assert_eq!(identity.content, "Long-term curated memory");
}
#[tokio::test]
async fn cleanup_directory_handles_empty_directory() {
async fn cleanup_conversation_docs_handles_empty_directory() {
let (db, _tmp) = create_test_db().await;
let ws = create_workspace(&db);
// Run cleanup on an empty directory
let deleted = cleanup_directory(&ws, "conversations/", 7)
// Run cleanup on an empty directory (conversations/ doesn't exist)
let deleted = cleanup_conversation_docs(&ws, 7)
.await
.expect("cleanup_directory");
.expect("cleanup_conversation_docs");
// Should delete 0 (nothing to delete)
assert_eq!(deleted, 0, "should delete 0 from empty directory");
}
#[tokio::test]
async fn metadata_driven_cleanup_discovers_directories() {
let (db, _tmp) = create_test_db().await;
let ws = create_workspace(&db);
// Seed .config with hygiene enabled on daily/
seed_hygiene_config(&ws, "daily/", 0).await;
// Write some documents
ws.write("daily/log1.md", "content 1")
.await
.expect("write doc 1");
ws.write("daily/log2.md", "content 2")
.await
.expect("write doc 2");
let config = HygieneConfig {
enabled: true,
version_keep_count: 50,
cadence_hours: 12,
state_dir: _tmp.path().to_path_buf(),
};
// First run should discover daily/ and clean it
let report = run_if_due(&ws, &config).await;
assert!(!report.skipped, "first run should not be skipped");
assert!(report.had_work(), "should have cleaned documents");
assert!(
!report.directories_cleaned.is_empty(),
"should have at least one directory cleaned"
);
}
#[tokio::test]
async fn cleanup_respects_cadence_prevents_concurrent_runs() {
let (db, _tmp) = create_test_db().await;
@@ -555,7 +614,8 @@ mod tests {
let config = HygieneConfig {
enabled: true,
version_keep_count: 50,
daily_retention_days: 30,
conversation_retention_days: 7,
cadence_hours: 12,
state_dir: _tmp.path().to_path_buf(),
};
@@ -567,6 +627,13 @@ mod tests {
// Second run immediately should be skipped (cadence not elapsed)
let report2 = run_if_due(&ws, &config).await;
assert!(report2.skipped, "second run should be skipped by cadence");
// Report structure should be correct
assert_eq!(
report1.daily_logs_deleted + report1.conversation_docs_deleted,
0,
"first run should have clean counts"
);
}
#[tokio::test]
@@ -574,10 +641,6 @@ mod tests {
let (db, _tmp) = create_test_db().await;
let ws = create_workspace(&db);
// Seed hygiene on both directories
seed_hygiene_config(&ws, "daily/", 0).await;
seed_hygiene_config(&ws, "conversations/", 0).await;
// Write some documents
ws.write("daily/log1.md", "content 1")
.await
@@ -589,34 +652,35 @@ mod tests {
.await
.expect("write doc 3");
// Run with 0-day retention via direct cleanup_directory calls
let deleted_daily = cleanup_directory(&ws, "daily/", 0)
.await
.expect("cleanup daily");
let deleted_conv = cleanup_directory(&ws, "conversations/", 0)
// Run with 0-day retention to delete everything non-identity
let deleted_daily = cleanup_daily_logs(&ws, 0).await.expect("cleanup daily");
let deleted_conv = cleanup_conversation_docs(&ws, 0)
.await
.expect("cleanup conversations");
// Both should report deletions
assert!(deleted_daily > 0, "should report deleted daily logs");
assert_eq!(deleted_conv, 1, "should report 1 deleted conversation doc");
// Verify HygieneReport aggregation
// Create a HygieneReport and verify aggregation works
let report = HygieneReport {
directories_cleaned: vec![
("daily/".to_string(), deleted_daily),
("conversations/".to_string(), deleted_conv),
],
versions_pruned: 0,
daily_logs_deleted: deleted_daily,
conversation_docs_deleted: deleted_conv,
skipped: false,
};
// Verify HygieneReport structure
assert!(!report.skipped, "should not be skipped");
assert!(report.had_work(), "report should indicate work was done");
assert!(
report.daily_logs_deleted > 0 || report.conversation_docs_deleted > 0,
"report should have at least one deletion count > 0"
);
// Verify had_work() correctly checks directory counts
// Verify had_work() correctly combines both counts
let no_work = HygieneReport {
directories_cleaned: vec![],
versions_pruned: 0,
daily_logs_deleted: 0,
conversation_docs_deleted: 0,
skipped: false,
};
assert!(!no_work.had_work(), "empty report should indicate no work");
+2 -351
View File
@@ -53,9 +53,8 @@ mod search;
pub use chunker::{ChunkConfig, chunk_document};
pub use document::{
CONFIG_FILE_NAME, DocumentMetadata, DocumentVersion, HygieneMetadata, IDENTITY_PATHS,
MemoryChunk, MemoryDocument, PatchResult, VersionSummary, WorkspaceEntry, content_sha256,
is_config_path, is_identity_path, merge_workspace_entries, paths,
IDENTITY_PATHS, MemoryChunk, MemoryDocument, WorkspaceEntry, is_identity_path,
merge_workspace_entries, paths,
};
pub use embedding_cache::{CachedEmbeddingProvider, EmbeddingCacheConfig};
pub use embeddings::{
@@ -367,101 +366,6 @@ impl WorkspaceStorage {
}
}
}
// ==================== Metadata ====================
async fn update_document_metadata(
&self,
id: Uuid,
metadata: &serde_json::Value,
) -> Result<(), WorkspaceError> {
match self {
#[cfg(feature = "postgres")]
Self::Repo(repo) => repo.update_document_metadata(id, metadata).await,
Self::Db(db) => db.update_document_metadata(id, metadata).await,
}
}
async fn find_config_documents(
&self,
user_id: &str,
agent_id: Option<Uuid>,
) -> Result<Vec<MemoryDocument>, WorkspaceError> {
match self {
#[cfg(feature = "postgres")]
Self::Repo(repo) => repo.find_config_documents(user_id, agent_id).await,
Self::Db(db) => db.find_config_documents(user_id, agent_id).await,
}
}
// ==================== Versioning ====================
async fn save_version(
&self,
document_id: Uuid,
content: &str,
content_hash: &str,
changed_by: Option<&str>,
) -> Result<i32, WorkspaceError> {
match self {
#[cfg(feature = "postgres")]
Self::Repo(repo) => {
repo.save_version(document_id, content, content_hash, changed_by)
.await
}
Self::Db(db) => {
db.save_version(document_id, content, content_hash, changed_by)
.await
}
}
}
async fn get_version(
&self,
document_id: Uuid,
version: i32,
) -> Result<DocumentVersion, WorkspaceError> {
match self {
#[cfg(feature = "postgres")]
Self::Repo(repo) => repo.get_version(document_id, version).await,
Self::Db(db) => db.get_version(document_id, version).await,
}
}
async fn list_versions(
&self,
document_id: Uuid,
limit: i64,
) -> Result<Vec<VersionSummary>, WorkspaceError> {
match self {
#[cfg(feature = "postgres")]
Self::Repo(repo) => repo.list_versions(document_id, limit).await,
Self::Db(db) => db.list_versions(document_id, limit).await,
}
}
async fn get_latest_version_number(
&self,
document_id: Uuid,
) -> Result<Option<i32>, WorkspaceError> {
match self {
#[cfg(feature = "postgres")]
Self::Repo(repo) => repo.get_latest_version_number(document_id).await,
Self::Db(db) => db.get_latest_version_number(document_id).await,
}
}
async fn prune_versions(
&self,
document_id: Uuid,
keep_count: i32,
) -> Result<u64, WorkspaceError> {
match self {
#[cfg(feature = "postgres")]
Self::Repo(repo) => repo.prune_versions(document_id, keep_count).await,
Self::Db(db) => db.prune_versions(document_id, keep_count).await,
}
}
}
/// Default template seeded into HEARTBEAT.md on first access.
@@ -792,199 +696,10 @@ impl Workspace {
.await
}
// ==================== Metadata ====================
/// Update the metadata JSON on a document by ID (full replacement).
pub async fn update_metadata(
&self,
id: Uuid,
metadata: &serde_json::Value,
) -> Result<(), WorkspaceError> {
self.storage.update_document_metadata(id, metadata).await
}
/// Prune old versions for a document, keeping only the most recent `keep_count`.
///
/// Returns the number of versions deleted.
pub async fn prune_versions(
&self,
document_id: Uuid,
keep_count: i32,
) -> Result<u64, WorkspaceError> {
self.storage.prune_versions(document_id, keep_count).await
}
/// Find all `.config` documents in this workspace scope.
pub async fn find_config_documents(&self) -> Result<Vec<MemoryDocument>, WorkspaceError> {
self.storage
.find_config_documents(&self.user_id, self.agent_id)
.await
}
/// Resolve effective metadata for a document path.
///
/// Resolution chain: document's own metadata → nearest ancestor `.config` → defaults.
pub async fn resolve_metadata(&self, path: &str) -> DocumentMetadata {
// 1. Document's own metadata
let doc_meta = self
.storage
.get_document_by_path(&self.user_id, self.agent_id, path)
.await
.ok()
.map(|d| d.metadata);
// 2. Walk up parent directories looking for .config
let mut config_meta = None;
let normalized = normalize_path(path);
let mut current = normalized.as_str();
while let Some(slash_pos) = current.rfind('/') {
let parent = &current[..slash_pos];
let config_path = format!("{}/{CONFIG_FILE_NAME}", parent);
if let Ok(doc) = self
.storage
.get_document_by_path(&self.user_id, self.agent_id, &config_path)
.await
{
config_meta = Some(doc.metadata);
break;
}
current = parent;
}
// Also check root-level .config
if config_meta.is_none()
&& let Ok(doc) = self
.storage
.get_document_by_path(&self.user_id, self.agent_id, CONFIG_FILE_NAME)
.await
{
config_meta = Some(doc.metadata);
}
// 3. Merge: config as base, document metadata as overlay
let base = config_meta.unwrap_or(serde_json::json!({}));
let overlay = doc_meta.unwrap_or(serde_json::json!({}));
let merged = DocumentMetadata::merge(&base, &overlay);
DocumentMetadata::from_value(&merged)
}
// ==================== Versioning ====================
/// List versions of a document (newest first).
pub async fn list_versions(
&self,
document_id: Uuid,
limit: i64,
) -> Result<Vec<VersionSummary>, WorkspaceError> {
self.storage.list_versions(document_id, limit).await
}
/// Get a specific version of a document.
pub async fn get_version(
&self,
document_id: Uuid,
version: i32,
) -> Result<DocumentVersion, WorkspaceError> {
self.storage.get_version(document_id, version).await
}
/// Save the current content as a version if it differs from the latest.
///
/// Returns the new version number, or `None` if skipped (empty content,
/// identical hash, or versioning disabled via metadata).
async fn maybe_save_version(
&self,
document_id: Uuid,
current_content: &str,
path: &str,
changed_by: Option<&str>,
) -> Result<Option<i32>, WorkspaceError> {
// Don't version empty documents
if current_content.is_empty() {
return Ok(None);
}
// Check metadata for skip_versioning flag
let metadata = self.resolve_metadata(path).await;
if metadata.skip_versioning == Some(true) {
return Ok(None);
}
let hash = content_sha256(current_content);
// Check if latest version already has this hash (skip duplicate saves)
if let Ok(Some(latest)) = self.storage.get_latest_version_number(document_id).await
&& let Ok(ver) = self.storage.get_version(document_id, latest).await
&& ver.content_hash == hash
{
return Ok(None);
}
let version = self
.storage
.save_version(document_id, current_content, &hash, changed_by)
.await?;
Ok(Some(version))
}
// ==================== Patch ====================
/// Apply a search-and-replace patch to a workspace document.
///
/// Finds `old_string` in the document and replaces it with `new_string`.
/// If `replace_all` is true, replaces all occurrences; otherwise only the first.
/// Auto-versions before applying the patch.
pub async fn patch(
&self,
path: &str,
old_string: &str,
new_string: &str,
replace_all: bool,
) -> Result<PatchResult, WorkspaceError> {
let path = normalize_path(path);
let doc = self
.storage
.get_document_by_path(&self.user_id, self.agent_id, &path)
.await?;
if !doc.content.contains(old_string) {
return Err(WorkspaceError::PatchFailed {
path,
reason: "old_string not found in document".to_string(),
});
}
let (new_content, count) = if replace_all {
let count = doc.content.matches(old_string).count();
(doc.content.replace(old_string, new_string), count)
} else {
(doc.content.replacen(old_string, new_string, 1), 1)
};
// Injection scan for system prompt files
if is_system_prompt_file(&path) && !new_content.is_empty() {
reject_if_injected(&path, &new_content)?;
}
// Auto-version before updating
let _ = self
.maybe_save_version(doc.id, &doc.content, &path, None)
.await;
self.storage.update_document(doc.id, &new_content).await?;
self.reindex_document(doc.id).await?;
let updated = self.storage.get_document_by_id(doc.id).await?;
Ok(PatchResult {
document: updated,
replacements: count,
})
}
/// Write (create or update) a file.
///
/// Creates parent directories implicitly (they're virtual in the DB).
/// Re-indexes the document for search after writing.
/// Auto-versions the previous content before overwriting.
///
/// # Example
/// ```ignore
@@ -1000,12 +715,6 @@ impl Workspace {
.storage
.get_or_create_document_by_path(&self.user_id, self.agent_id, &path)
.await?;
// Auto-version previous content before overwriting
let _ = self
.maybe_save_version(doc.id, &doc.content, &path, None)
.await;
self.storage.update_document(doc.id, content).await?;
self.reindex_document(doc.id).await?;
@@ -1045,11 +754,6 @@ impl Workspace {
reject_if_injected(&path, &new_content)?;
}
// Auto-version previous content before appending
let _ = self
.maybe_save_version(doc.id, &doc.content, &path, None)
.await;
self.storage.update_document(doc.id, &new_content).await?;
self.reindex_document(doc.id).await?;
Ok(())
@@ -1881,14 +1585,6 @@ impl Workspace {
// Get the document
let doc = self.storage.get_document_by_id(document_id).await?;
// Check metadata for skip_indexing flag
let metadata = self.resolve_metadata(&doc.path).await;
if metadata.skip_indexing == Some(true) {
// Delete any existing chunks and skip indexing
self.storage.delete_chunks(document_id).await?;
return Ok(());
}
// Chunk the content
let chunks = chunk_document(&doc.content, ChunkConfig::default());
@@ -1974,51 +1670,6 @@ impl Workspace {
}
}
// Seed folder-level .config documents for hygiene defaults.
let config_seeds: &[(&str, serde_json::Value)] = &[
(
"daily/.config",
serde_json::json!({
"hygiene": {"enabled": true, "retention_days": 30},
"skip_versioning": true
}),
),
(
"conversations/.config",
serde_json::json!({
"hygiene": {"enabled": true, "retention_days": 7},
"skip_versioning": true
}),
),
];
for (config_path, metadata_value) in config_seeds {
match self.read_primary(config_path).await {
Ok(_) => continue, // Already exists, don't overwrite
Err(WorkspaceError::DocumentNotFound { .. }) => {}
Err(e) => {
tracing::debug!("Failed to check {}: {}", config_path, e);
continue;
}
}
// Create empty document with metadata
if let Ok(doc) = self
.storage
.get_or_create_document_by_path(&self.user_id, self.agent_id, config_path)
.await
{
if let Err(e) = self
.storage
.update_document_metadata(doc.id, metadata_value)
.await
{
tracing::debug!("Failed to set metadata on {}: {}", config_path, e);
} else {
count += 1;
}
}
}
// BOOTSTRAP.md is only seeded on truly fresh workspaces (no identity
// files existed before seeding) AND when no profile exists yet (the user
// may already have a profile from a previous install and doesn't need
+1 -191
View File
@@ -11,9 +11,7 @@ use uuid::Uuid;
use crate::error::WorkspaceError;
use crate::workspace::document::{
DocumentVersion, MemoryChunk, MemoryDocument, VersionSummary, WorkspaceEntry,
};
use crate::workspace::document::{MemoryChunk, MemoryDocument, WorkspaceEntry};
use crate::workspace::search::{RankedResult, SearchConfig, SearchResult, fuse_results};
/// Database repository for workspace operations.
@@ -704,192 +702,4 @@ impl Repository {
}
Ok(crate::workspace::merge_workspace_entries(all_entries))
}
// ==================== Metadata ====================
pub async fn update_document_metadata(
&self,
id: Uuid,
metadata: &serde_json::Value,
) -> Result<(), WorkspaceError> {
let conn = self.conn().await?;
conn.execute(
"UPDATE memory_documents SET metadata = $2, updated_at = NOW() WHERE id = $1",
&[&id, &metadata],
)
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("Failed to update metadata: {e}"),
})?;
Ok(())
}
pub async fn find_config_documents(
&self,
user_id: &str,
agent_id: Option<Uuid>,
) -> Result<Vec<MemoryDocument>, WorkspaceError> {
let conn = self.conn().await?;
let rows = conn
.query(
r#"
SELECT id, user_id, agent_id, path, content,
created_at, updated_at, metadata
FROM memory_documents
WHERE user_id = $1 AND agent_id IS NOT DISTINCT FROM $2
AND (path LIKE '%/.config' OR path = '.config')
ORDER BY path
"#,
&[&user_id, &agent_id],
)
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("Failed to find config documents: {e}"),
})?;
Ok(rows.iter().map(|r| self.row_to_document(r)).collect())
}
// ==================== Versioning ====================
pub async fn save_version(
&self,
document_id: Uuid,
content: &str,
content_hash: &str,
changed_by: Option<&str>,
) -> Result<i32, WorkspaceError> {
let conn = self.conn().await?;
let row = conn
.query_one(
r#"
INSERT INTO memory_document_versions
(id, document_id, version, content, content_hash, changed_by)
VALUES (
gen_random_uuid(),
$1,
(SELECT COALESCE(MAX(version), 0) + 1
FROM memory_document_versions WHERE document_id = $1),
$2, $3, $4
)
RETURNING version
"#,
&[&document_id, &content, &content_hash, &changed_by],
)
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("Failed to save version: {e}"),
})?;
Ok(row.get(0))
}
pub async fn get_version(
&self,
document_id: Uuid,
version: i32,
) -> Result<DocumentVersion, WorkspaceError> {
let conn = self.conn().await?;
let row = conn
.query_opt(
r#"
SELECT id, document_id, version, content, content_hash,
created_at, changed_by
FROM memory_document_versions
WHERE document_id = $1 AND version = $2
"#,
&[&document_id, &version],
)
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("Failed to get version: {e}"),
})?
.ok_or(WorkspaceError::VersionNotFound {
document_id,
version,
})?;
Ok(DocumentVersion {
id: row.get(0),
document_id: row.get(1),
version: row.get(2),
content: row.get(3),
content_hash: row.get(4),
created_at: row.get(5),
changed_by: row.get(6),
})
}
pub async fn list_versions(
&self,
document_id: Uuid,
limit: i64,
) -> Result<Vec<VersionSummary>, WorkspaceError> {
let conn = self.conn().await?;
let rows = conn
.query(
r#"
SELECT version, content_hash, created_at, changed_by
FROM memory_document_versions
WHERE document_id = $1
ORDER BY version DESC
LIMIT $2
"#,
&[&document_id, &limit],
)
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("Failed to list versions: {e}"),
})?;
Ok(rows
.iter()
.map(|row| VersionSummary {
version: row.get(0),
content_hash: row.get(1),
created_at: row.get(2),
changed_by: row.get(3),
})
.collect())
}
pub async fn get_latest_version_number(
&self,
document_id: Uuid,
) -> Result<Option<i32>, WorkspaceError> {
let conn = self.conn().await?;
let row = conn
.query_one(
"SELECT MAX(version) FROM memory_document_versions WHERE document_id = $1",
&[&document_id],
)
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("Failed to get latest version number: {e}"),
})?;
Ok(row.get(0))
}
pub async fn prune_versions(
&self,
document_id: Uuid,
keep_count: i32,
) -> Result<u64, WorkspaceError> {
let conn = self.conn().await?;
let result = conn
.execute(
r#"
DELETE FROM memory_document_versions
WHERE document_id = $1
AND version NOT IN (
SELECT version FROM memory_document_versions
WHERE document_id = $1
ORDER BY version DESC
LIMIT $2
)
"#,
&[&document_id, &(keep_count as i64)],
)
.await
.map_err(|e| WorkspaceError::SearchFailed {
reason: format!("Failed to prune versions: {e}"),
})?;
Ok(result)
}
}
-2
View File
@@ -53,7 +53,6 @@ HEADED=1 pytest scenarios/
| `test_skills.py` | Skills tab UI visibility, ClawHub search (skipped if registry unreachable), install + remove lifecycle |
| `test_sse_reconnect.py` | SSE reconnects after programmatic `eventSource.close()` + `connectSSE()`; history is reloaded after reconnect |
| `test_tool_approval.py` | Approval card appears, buttons disable on approve/deny, parameters toggle via `page.evaluate("showApproval(...)")`; the waiting-approval regression uses a real HTTP tool call |
| `test_extension_uninstall_cleanup.py` | Real install/setup/remove coverage for WASM tools, WASM channels, OAuth-backed shared Google tools, and MCP servers; verifies uninstall deletes stored secrets from the libSQL `secrets` table while preserving shared credentials until the last referencing extension is removed |
| `test_oauth_refresh.py` | Hosted Gmail OAuth regression: complete setup via `/oauth/callback`, expire the stored access token in libSQL, trigger a real `gmail` tool call through `/api/chat/send`, and verify refresh goes through the mock `/oauth/refresh` proxy without forwarding `client_secret` |
## `helpers.py`
@@ -78,7 +77,6 @@ All fixtures are defined in `tests/e2e/conftest.py`. Running `pytest scenarios/`
| `mock_llm_server` | Starts `mock_llm.py --port 0`, reads the assigned port from stdout, waits for `/v1/models` to return 200. Yields the base URL. |
| `ironclaw_server` | Starts the ironclaw binary with a minimal env (see below), waits for `/api/health` (timeout 60s). Yields the base URL. On teardown sends **SIGINT** (not SIGTERM) so the tokio ctrl_c handler triggers a graceful shutdown and LLVM coverage data is flushed. |
| `hosted_oauth_refresh_server` | Starts a second ironclaw instance with a dedicated libSQL DB and `GOOGLE_OAUTH_CLIENT_ID=hosted-google-client-id`, while still pointing `IRONCLAW_OAUTH_EXCHANGE_URL` at `mock_llm.py`. Yields a dict with `base_url`, `db_path`, `gateway_user_id`, and `mock_llm_url` for the hosted refresh regression scenario. |
| `extension_cleanup_server` | Starts an isolated ironclaw instance with its own temp DB/home/WASM dirs, `SECRETS_MASTER_KEY`, and hosted-style OAuth env so uninstall-cleanup scenarios can inspect the `secrets` table without interfering with the shared E2E server state. |
| `browser` | Launches a single Chromium instance (headless by default; set `HEADED=1` for headed). Shared across all tests. |
### Function-scoped fixtures
+4 -109
View File
@@ -271,6 +271,7 @@ async def ironclaw_server(
"GATEWAY_HOST": "127.0.0.1",
"GATEWAY_PORT": str(gateway_port),
"GATEWAY_AUTH_TOKEN": AUTH_TOKEN,
"GATEWAY_USER_ID": "e2e-web-sender",
"HTTP_HOST": "127.0.0.1",
"HTTP_PORT": str(http_port),
"HTTP_WEBHOOK_SECRET": HTTP_WEBHOOK_SECRET,
@@ -370,6 +371,7 @@ async def hosted_oauth_refresh_server(
"GATEWAY_HOST": "127.0.0.1",
"GATEWAY_PORT": str(gateway_port),
"GATEWAY_AUTH_TOKEN": AUTH_TOKEN,
"GATEWAY_USER_ID": OWNER_SCOPE_ID,
"HTTP_HOST": "127.0.0.1",
"HTTP_PORT": str(http_port),
"HTTP_WEBHOOK_SECRET": HTTP_WEBHOOK_SECRET,
@@ -409,6 +411,7 @@ async def hosted_oauth_refresh_server(
yield {
"base_url": base_url,
"db_path": db_path,
"gateway_user_id": OWNER_SCOPE_ID,
"mock_llm_url": mock_llm_server,
}
except TimeoutError:
@@ -443,115 +446,6 @@ async def hosted_oauth_refresh_server(
home_tmpdir.cleanup()
@pytest.fixture(scope="session")
async def extension_cleanup_server(
ironclaw_binary,
mock_llm_server,
):
"""Start an isolated ironclaw instance for uninstall secret cleanup E2E tests."""
reserved = _reserve_loopback_sockets(2)
db_tmpdir = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-cleanup-db-")
home_tmpdir = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-cleanup-home-")
tools_tmpdir = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-cleanup-tools-")
channels_tmpdir = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-cleanup-channels-")
try:
gateway_port = reserved[0].getsockname()[1]
http_port = reserved[1].getsockname()[1]
for sock in reserved:
if sock.fileno() != -1:
sock.close()
db_path = os.path.join(db_tmpdir.name, "extension-cleanup.db")
home_dir = home_tmpdir.name
env = {
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
"HOME": home_dir,
"IRONCLAW_BASE_DIR": os.path.join(home_dir, ".ironclaw"),
"RUST_LOG": "ironclaw=info",
"RUST_BACKTRACE": "1",
"IRONCLAW_OWNER_ID": OWNER_SCOPE_ID,
"GATEWAY_ENABLED": "true",
"GATEWAY_HOST": "127.0.0.1",
"GATEWAY_PORT": str(gateway_port),
"GATEWAY_AUTH_TOKEN": AUTH_TOKEN,
"GATEWAY_USER_ID": OWNER_SCOPE_ID,
"HTTP_HOST": "127.0.0.1",
"HTTP_PORT": str(http_port),
"HTTP_WEBHOOK_SECRET": HTTP_WEBHOOK_SECRET,
"CLI_ENABLED": "false",
"LLM_BACKEND": "openai_compatible",
"LLM_BASE_URL": mock_llm_server,
"LLM_MODEL": "mock-model",
"DATABASE_BACKEND": "libsql",
"LIBSQL_PATH": db_path,
"SECRETS_MASTER_KEY": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"SANDBOX_ENABLED": "false",
"SKILLS_ENABLED": "true",
"ROUTINES_ENABLED": "true",
"HEARTBEAT_ENABLED": "false",
"EMBEDDING_ENABLED": "false",
"WASM_ENABLED": "true",
"WASM_TOOLS_DIR": tools_tmpdir.name,
"WASM_CHANNELS_DIR": channels_tmpdir.name,
"ONBOARD_COMPLETED": "true",
"IRONCLAW_OAUTH_CALLBACK_URL": "https://oauth.test.example/oauth/callback",
"IRONCLAW_OAUTH_EXCHANGE_URL": mock_llm_server,
"GOOGLE_OAUTH_CLIENT_ID": "hosted-google-client-id",
}
_forward_coverage_env(env)
proc = await asyncio.create_subprocess_exec(
ironclaw_binary, "--no-onboard",
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
startup_kill_attempted = False
base_url = f"http://127.0.0.1:{gateway_port}"
try:
await wait_for_ready(f"{base_url}/api/health", timeout=60)
yield {
"base_url": base_url,
"db_path": db_path,
"gateway_user_id": OWNER_SCOPE_ID,
"mock_llm_url": mock_llm_server,
}
except TimeoutError:
if proc.returncode is None:
startup_kill_attempted = True
await _stop_process(proc, timeout=2)
returncode = proc.returncode
stderr_bytes = b""
if proc.stderr:
try:
stderr_bytes = await asyncio.wait_for(proc.stderr.read(8192), timeout=2)
except asyncio.TimeoutError:
pass
stderr_text = stderr_bytes.decode("utf-8", errors="replace")
pytest.fail(
f"extension cleanup server failed to start on port {gateway_port} "
f"(returncode={returncode}).\nstderr:\n{stderr_text}"
)
finally:
if proc.returncode is None:
if startup_kill_attempted:
await _stop_process(proc, timeout=2)
else:
await _stop_process(proc, sig=signal.SIGINT, timeout=10)
if proc.returncode is None:
await _stop_process(proc, timeout=2)
finally:
for sock in reserved:
if sock.fileno() != -1:
sock.close()
db_tmpdir.cleanup()
home_tmpdir.cleanup()
tools_tmpdir.cleanup()
channels_tmpdir.cleanup()
@pytest.fixture(scope="session")
async def http_channel_server(ironclaw_server, server_ports):
"""HTTP webhook channel base URL."""
@@ -579,6 +473,7 @@ async def http_channel_server_without_secret(
"GATEWAY_HOST": "127.0.0.1",
"GATEWAY_PORT": str(gateway_port),
"GATEWAY_AUTH_TOKEN": AUTH_TOKEN,
"GATEWAY_USER_ID": "e2e-tester",
"HTTP_HOST": "127.0.0.1",
"HTTP_PORT": str(http_port),
"CLI_ENABLED": "false",
@@ -1,266 +0,0 @@
"""Extension uninstall secret cleanup E2E tests.
Exercises real install/setup/auth/remove flows and verifies the backing
secrets table is cleaned up when extensions are uninstalled.
"""
import sqlite3
from urllib.parse import parse_qs, urlparse
import httpx
from helpers import api_get, api_post
def _extract_state(auth_url: str) -> str:
parsed = urlparse(auth_url)
state = parse_qs(parsed.query).get("state", [None])[0]
assert state, f"auth_url should include state: {auth_url}"
return state
def _secret_exists(db_path: str, user_id: str, name: str) -> bool:
with sqlite3.connect(db_path) as conn:
row = conn.execute(
"SELECT 1 FROM secrets WHERE user_id = ?1 AND name = ?2 LIMIT 1",
(user_id, name),
).fetchone()
return row is not None
def _secret_names(db_path: str, user_id: str) -> set[str]:
with sqlite3.connect(db_path) as conn:
rows = conn.execute(
"SELECT name FROM secrets WHERE user_id = ?1",
(user_id,),
).fetchall()
return {row[0] for row in rows}
async def _get_extension(base_url: str, name: str) -> dict | None:
response = await api_get(base_url, "/api/extensions", timeout=15)
response.raise_for_status()
for extension in response.json().get("extensions", []):
if extension["name"] == name:
return extension
return None
async def _ensure_removed(base_url: str, name: str) -> None:
extension = await _get_extension(base_url, name)
if extension is not None:
response = await api_post(base_url, f"/api/extensions/{name}/remove", timeout=30)
assert response.status_code == 200, response.text
assert response.json().get("success") is True, response.text
async def _install_extension(
base_url: str,
name: str,
*,
kind: str | None = None,
url: str | None = None,
) -> None:
payload = {"name": name}
if kind is not None:
payload["kind"] = kind
if url is not None:
payload["url"] = url
response = await api_post(
base_url,
"/api/extensions/install",
json=payload,
timeout=180,
)
assert response.status_code == 200, response.text
assert response.json().get("success") is True, response.text
async def test_remove_wasm_tool_deletes_unique_secret(extension_cleanup_server):
server = extension_cleanup_server["base_url"]
db_path = extension_cleanup_server["db_path"]
user_id = extension_cleanup_server["gateway_user_id"]
await _ensure_removed(server, "web-search")
await _install_extension(server, "web-search")
setup_response = await api_post(
server,
"/api/extensions/web-search/setup",
json={"secrets": {"brave_api_key": "cleanup-test-key"}},
timeout=30,
)
assert setup_response.status_code == 200, setup_response.text
assert setup_response.json().get("success") is True, setup_response.text
assert _secret_exists(db_path, user_id, "brave_api_key")
remove_response = await api_post(
server,
"/api/extensions/web-search/remove",
timeout=30,
)
assert remove_response.status_code == 200, remove_response.text
assert remove_response.json().get("success") is True, remove_response.text
assert not _secret_exists(db_path, user_id, "brave_api_key")
async def test_remove_wasm_channel_deletes_setup_secrets(extension_cleanup_server):
server = extension_cleanup_server["base_url"]
db_path = extension_cleanup_server["db_path"]
user_id = extension_cleanup_server["gateway_user_id"]
await _ensure_removed(server, "discord")
await _install_extension(server, "discord", kind="wasm_channel")
setup_response = await api_post(
server,
"/api/extensions/discord/setup",
json={
"secrets": {
"discord_bot_token": "cleanup-discord-bot-token",
"discord_public_key": "cleanup-discord-public-key",
}
},
timeout=30,
)
assert setup_response.status_code == 200, setup_response.text
assert setup_response.json().get("success") is True, setup_response.text
assert _secret_exists(db_path, user_id, "discord_bot_token")
assert _secret_exists(db_path, user_id, "discord_public_key")
remove_response = await api_post(
server,
"/api/extensions/discord/remove",
timeout=30,
)
assert remove_response.status_code == 200, remove_response.text
assert remove_response.json().get("success") is True, remove_response.text
assert not _secret_exists(db_path, user_id, "discord_bot_token")
assert not _secret_exists(db_path, user_id, "discord_public_key")
async def test_remove_shared_google_oauth_secrets_after_last_tool(extension_cleanup_server):
server = extension_cleanup_server["base_url"]
db_path = extension_cleanup_server["db_path"]
user_id = extension_cleanup_server["gateway_user_id"]
await _ensure_removed(server, "gmail")
await _ensure_removed(server, "google-drive")
await _install_extension(server, "gmail")
await _install_extension(server, "google-drive")
setup_response = await api_post(
server,
"/api/extensions/gmail/setup",
json={"secrets": {}},
timeout=30,
)
assert setup_response.status_code == 200, setup_response.text
auth_url = setup_response.json().get("auth_url")
assert auth_url, setup_response.text
async with httpx.AsyncClient() as client:
callback_response = await client.get(
f"{server}/oauth/callback",
params={"code": "mock_auth_code", "state": _extract_state(auth_url)},
timeout=30,
follow_redirects=True,
)
assert callback_response.status_code == 200, callback_response.text[:400]
shared_secrets = [
"google_oauth_token",
"google_oauth_token_refresh_token",
"google_oauth_token_scopes",
]
for secret_name in shared_secrets:
assert _secret_exists(db_path, user_id, secret_name), f"expected {secret_name} to exist"
gmail_remove_response = await api_post(
server,
"/api/extensions/gmail/remove",
timeout=30,
)
assert gmail_remove_response.status_code == 200, gmail_remove_response.text
assert gmail_remove_response.json().get("success") is True, gmail_remove_response.text
for secret_name in shared_secrets:
assert _secret_exists(db_path, user_id, secret_name), (
f"{secret_name} should remain while google-drive is still installed"
)
drive_remove_response = await api_post(
server,
"/api/extensions/google-drive/remove",
timeout=30,
)
assert drive_remove_response.status_code == 200, drive_remove_response.text
assert drive_remove_response.json().get("success") is True, drive_remove_response.text
for secret_name in shared_secrets:
assert not _secret_exists(db_path, user_id, secret_name), (
f"{secret_name} should be deleted after the last Google tool is removed"
)
async def test_remove_mcp_server_deletes_stored_secrets(extension_cleanup_server):
server = extension_cleanup_server["base_url"]
db_path = extension_cleanup_server["db_path"]
user_id = extension_cleanup_server["gateway_user_id"]
mcp_url = f"{extension_cleanup_server['mock_llm_url']}/mcp"
await _ensure_removed(server, "mock-mcp")
await _install_extension(server, "mock-mcp", kind="mcp_server", url=mcp_url)
setup_response = await api_post(
server,
"/api/extensions/mock-mcp/setup",
json={"secrets": {}},
timeout=30,
)
assert setup_response.status_code == 200, setup_response.text
auth_url = setup_response.json().get("auth_url")
if auth_url is None:
activate_response = await api_post(
server,
"/api/extensions/mock-mcp/activate",
timeout=30,
)
assert activate_response.status_code == 200, activate_response.text
auth_url = activate_response.json().get("auth_url")
assert auth_url, "mock-mcp should require OAuth in E2E"
async with httpx.AsyncClient() as client:
callback_response = await client.get(
f"{server}/oauth/callback",
params={"code": "mock_mcp_code", "state": _extract_state(auth_url)},
timeout=30,
follow_redirects=True,
)
assert callback_response.status_code == 200, callback_response.text[:400]
expected_mcp_secrets = [
"mcp_mock-mcp_access_token",
"mcp_mock-mcp_client_id",
]
stored_secret_names = _secret_names(db_path, user_id)
for secret_name in expected_mcp_secrets:
assert secret_name in stored_secret_names, (
f"expected {secret_name} to exist; stored secrets were {sorted(stored_secret_names)}"
)
remove_response = await api_post(
server,
"/api/extensions/mock-mcp/remove",
timeout=30,
)
assert remove_response.status_code == 200, remove_response.text
assert remove_response.json().get("success") is True, remove_response.text
remaining_secret_names = _secret_names(db_path, user_id)
assert not any(name.startswith("mcp_mock-mcp_") for name in remaining_secret_names), (
f"mock-mcp secrets should be deleted on remove; remaining secrets were "
f"{sorted(remaining_secret_names)}"
)
+4 -2
View File
@@ -955,7 +955,8 @@ mod tests {
let hygiene_config = HygieneConfig {
enabled: false,
version_keep_count: 50,
daily_retention_days: 30,
conversation_retention_days: 7,
cadence_hours: 24,
state_dir: _tmp.path().to_path_buf(),
};
@@ -1001,7 +1002,8 @@ mod tests {
let hygiene_config = HygieneConfig {
enabled: false,
version_keep_count: 50,
daily_retention_days: 30,
conversation_retention_days: 7,
cadence_hours: 24,
state_dir: _tmp.path().to_path_buf(),
};
+10 -17
View File
@@ -42,6 +42,8 @@ const ALICE_USER_ID: &str = "alice";
const BOB_USER_ID: &str = "bob";
const OWNER_TOKEN: &str = "tok-owner-secret";
const OWNER_SCOPE_ID: &str = "owner-scope";
const GATEWAY_SENDER_ID: &str = "gateway-sender";
/// Build a MultiAuthState with two users.
fn two_user_auth() -> MultiAuthState {
let mut tokens = HashMap::new();
@@ -49,7 +51,6 @@ fn two_user_auth() -> MultiAuthState {
ALICE_TOKEN.to_string(),
UserIdentity {
user_id: ALICE_USER_ID.to_string(),
role: "admin".to_string(),
workspace_read_scopes: Vec::new(),
},
);
@@ -57,7 +58,6 @@ fn two_user_auth() -> MultiAuthState {
BOB_TOKEN.to_string(),
UserIdentity {
user_id: BOB_USER_ID.to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec!["shared".to_string()],
},
);
@@ -79,10 +79,7 @@ fn user_echo_app(auth: MultiAuthState) -> Router {
.route("/api/whoami/scopes", get(echo_user_with_scopes))
.route("/api/action", post(echo_user))
.route("/api/chat/events", get(echo_user)) // SSE endpoint (allows query token)
.layer(middleware::from_fn_with_state(
ironclaw::channels::web::auth::CombinedAuthState::from(auth),
auth_middleware,
))
.layer(middleware::from_fn_with_state(auth, auth_middleware))
}
// ===========================================================================
@@ -547,6 +544,7 @@ fn gateway_state_has_multi_tenant_fields() {
prompt_queue: None,
scheduler: None,
owner_id: "fallback".to_string(),
default_sender_id: "fallback".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: None,
@@ -560,11 +558,10 @@ fn gateway_state_has_multi_tenant_fields() {
startup_time: std::time::Instant::now(),
webhook_rate_limiter: RateLimiter::new(10, 60),
active_config: Default::default(),
secrets_store: None,
db_auth: None,
};
assert_eq!(state.owner_id, "fallback");
assert_eq!(state.default_sender_id, "fallback");
assert!(state.workspace_pool.is_none());
}
@@ -595,7 +592,6 @@ async fn start_owner_scoped_sender_server() -> (
OWNER_TOKEN.to_string(),
UserIdentity {
user_id: OWNER_SCOPE_ID.to_string(),
role: "admin".to_string(),
workspace_read_scopes: Vec::new(),
},
);
@@ -603,7 +599,6 @@ async fn start_owner_scoped_sender_server() -> (
BOB_TOKEN.to_string(),
UserIdentity {
user_id: BOB_USER_ID.to_string(),
role: "member".to_string(),
workspace_read_scopes: Vec::new(),
},
);
@@ -623,6 +618,7 @@ async fn start_owner_scoped_sender_server() -> (
prompt_queue: None,
scheduler: None,
owner_id: OWNER_SCOPE_ID.to_string(),
default_sender_id: GATEWAY_SENDER_ID.to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: None,
@@ -636,11 +632,9 @@ async fn start_owner_scoped_sender_server() -> (
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: Default::default(),
secrets_store: None,
db_auth: None,
});
let auth = MultiAuthState::multi(tokens).into();
let auth = MultiAuthState::multi(tokens);
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let bound = start_server(addr, state.clone(), auth)
.await
@@ -775,7 +769,7 @@ async fn full_server_chat_send_rewrites_sender_only_for_owner_scope_rebind() {
.expect("Timed out waiting for owner message")
.expect("Agent channel closed");
assert_eq!(owner_msg.user_id, OWNER_SCOPE_ID);
assert_eq!(owner_msg.sender_id, OWNER_SCOPE_ID);
assert_eq!(owner_msg.sender_id, GATEWAY_SENDER_ID);
assert_eq!(owner_msg.content, "hello from owner");
let other_resp = client
@@ -1009,6 +1003,7 @@ async fn start_multi_user_server_with_db() -> (
prompt_queue: None,
scheduler: None,
owner_id: ALICE_USER_ID.to_string(),
default_sender_id: ALICE_USER_ID.to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: None,
@@ -1022,12 +1017,10 @@ async fn start_multi_user_server_with_db() -> (
startup_time: std::time::Instant::now(),
webhook_rate_limiter: RateLimiter::new(10, 60),
active_config: Default::default(),
secrets_store: None,
db_auth: None,
});
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let bound = ironclaw::channels::web::server::start_server(addr, state.clone(), auth.into())
let bound = ironclaw::channels::web::server::start_server(addr, state.clone(), auth)
.await
.expect("Failed to start server with DB");
+5 -7
View File
@@ -205,6 +205,7 @@ async fn start_test_server_with_provider(
prompt_queue: None,
scheduler: None,
owner_id: "test-user".to_string(),
default_sender_id: "test-user".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: Some(llm_provider),
@@ -218,8 +219,6 @@ async fn start_test_server_with_provider(
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
secrets_store: None,
db_auth: None,
});
let auth = ironclaw::channels::web::auth::MultiAuthState::single(
@@ -227,7 +226,7 @@ async fn start_test_server_with_provider(
"test-user".to_string(),
);
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let bound_addr = start_server(addr, state.clone(), auth.into())
let bound_addr = start_server(addr, state.clone(), auth)
.await
.expect("Failed to start test server");
@@ -705,6 +704,7 @@ async fn test_no_llm_provider_returns_503() {
prompt_queue: None,
scheduler: None,
owner_id: "test-user".to_string(),
default_sender_id: "test-user".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: None, // No LLM!
@@ -718,8 +718,6 @@ async fn test_no_llm_provider_returns_503() {
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
secrets_store: None,
db_auth: None,
});
let auth = ironclaw::channels::web::auth::MultiAuthState::single(
@@ -727,7 +725,7 @@ async fn test_no_llm_provider_returns_503() {
"test-user".to_string(),
);
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let bound_addr = start_server(addr, state, auth.into()).await.unwrap();
let bound_addr = start_server(addr, state, auth).await.unwrap();
let url = format!("http://{}/v1/chat/completions", bound_addr);
let resp = client()
@@ -765,7 +763,7 @@ async fn test_chat_completions_body_too_large() {
post(ironclaw::channels::web::openai_compat::chat_completions_handler),
)
.route_layer(middleware::from_fn_with_state(
ironclaw::channels::web::auth::CombinedAuthState::from(auth_state),
auth_state,
ironclaw::channels::web::auth::auth_middleware,
))
.layer(DefaultBodyLimit::max(10 * 1024 * 1024))
+2 -3
View File
@@ -227,6 +227,7 @@ impl GatewayWorkflowHarness {
prompt_queue: None,
scheduler: Some(scheduler_slot.clone()),
owner_id: user_id.clone(),
default_sender_id: user_id.clone(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: Some(Arc::clone(&components.llm)),
@@ -240,8 +241,6 @@ impl GatewayWorkflowHarness {
routine_engine: Arc::clone(&routine_slot),
startup_time: Instant::now(),
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
secrets_store: None,
db_auth: None,
});
let mut agent = Agent::new(
@@ -300,7 +299,7 @@ impl GatewayWorkflowHarness {
let addr = start_server(
"127.0.0.1:0".parse().expect("valid localhost addr"),
Arc::clone(&gateway_state),
auth.into(),
auth,
)
.await
.expect("failed to start gateway server");
+2 -3
View File
@@ -52,6 +52,7 @@ async fn start_test_server() -> (
prompt_queue: None,
scheduler: None,
owner_id: "test-user".to_string(),
default_sender_id: "test-user".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: None,
@@ -65,8 +66,6 @@ async fn start_test_server() -> (
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
secrets_store: None,
db_auth: None,
});
let auth = ironclaw::channels::web::auth::MultiAuthState::single(
@@ -74,7 +73,7 @@ async fn start_test_server() -> (
"test-user".to_string(),
);
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let bound_addr = start_server(addr, state.clone(), auth.into())
let bound_addr = start_server(addr, state.clone(), auth)
.await
.expect("Failed to start test server");