mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db50fb17c8 | ||
|
|
40a87d0e91 | ||
|
|
ca80be6d97 | ||
|
|
42f6dbdfe9 | ||
|
|
9e5d07c222 | ||
|
|
b8c3facfcd | ||
|
|
b9fa735158 | ||
|
|
cdfcf7174c | ||
|
|
e7786fa3aa | ||
|
|
e8e0379e14 | ||
|
|
01d60fa281 | ||
|
|
7584843a92 | ||
|
|
08ad984701 | ||
|
|
f13226d011 | ||
|
|
f246cf6bf9 | ||
|
|
fbfe8cb70e | ||
|
|
fc9590dfd6 | ||
|
|
f1ebc06147 | ||
|
|
bc6494527b | ||
|
|
06ea64eee9 | ||
|
|
e17edd1831 | ||
|
|
f9d7cc7c98 | ||
|
|
ee8de6d1a9 | ||
|
|
32efb46f4f | ||
|
|
afecf626b4 | ||
|
|
f795324c80 | ||
|
|
61a5b5553f | ||
|
|
14349267f4 | ||
|
|
e125024667 | ||
|
|
275a6b1761 | ||
|
|
2339616637 | ||
|
|
3800a09613 |
+3
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -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");
|
||||
@@ -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;"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
@@ -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(¤t_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"), "");
|
||||
}
|
||||
}
|
||||
@@ -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` | |
|
||||
@@ -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);
|
||||
@@ -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
@@ -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
|
||||
|
||||
+14
-22
@@ -947,6 +947,12 @@ impl Agent {
|
||||
/// Best-effort: logs warnings on failure but does not propagate errors,
|
||||
/// since the in-memory model switch already succeeded.
|
||||
///
|
||||
/// The DB setting is the primary persistence layer. For LLM settings the
|
||||
/// resolution priority is `DB > env > TOML > default`, so writing to DB
|
||||
/// is sufficient for the change to survive restarts. The `.env` and TOML
|
||||
/// files are only updated as a courtesy when they already contain a model
|
||||
/// var, to avoid user confusion.
|
||||
///
|
||||
/// In multi-tenant mode, only the per-user DB setting is written — global
|
||||
/// .env and TOML files are shared across users and must not be mutated.
|
||||
async fn persist_selected_model(&self, tenant: &crate::tenant::TenantCtx, model: &str) {
|
||||
@@ -972,22 +978,18 @@ impl Agent {
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Update .env and TOML config file (sync I/O in spawn_blocking).
|
||||
// 3. Best-effort update of .env and TOML if they already contain a
|
||||
// model var. DB is authoritative (DB > env > TOML), but keeping
|
||||
// these in sync avoids confusion when users inspect the files.
|
||||
let model_owned = model.to_string();
|
||||
let backend = self.deps.llm_backend.clone();
|
||||
if let Err(e) = tokio::task::spawn_blocking(move || {
|
||||
// 2a. Update the backend-specific model env var in ~/.ironclaw/.env.
|
||||
//
|
||||
// Env vars have the HIGHEST priority in LlmConfig::resolve_model()
|
||||
// (env var > TOML > DB > default). If the .env file has e.g.
|
||||
// NEARAI_MODEL=old-model, it shadows everything else. We must
|
||||
// update this var or the /model change is invisible on restart.
|
||||
// 3a. Update the backend-specific model env var in ~/.ironclaw/.env
|
||||
// only if the var already exists (don't inject new vars).
|
||||
let registry = crate::llm::ProviderRegistry::load();
|
||||
let model_env = registry.model_env_var(&backend);
|
||||
let env_var_prefix = format!("{}=", model_env);
|
||||
|
||||
// Only update the .env file if the var is actually set there
|
||||
// (avoid injecting new vars the user never configured).
|
||||
let env_path = crate::bootstrap::ironclaw_env_path();
|
||||
let env_has_var = std::fs::read_to_string(&env_path)
|
||||
.ok()
|
||||
@@ -1005,10 +1007,8 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
// 2b. Update (or create) the TOML config file.
|
||||
//
|
||||
// The TOML overlay has higher priority than DB settings on
|
||||
// startup, so it MUST stay in sync with the DB.
|
||||
// 3b. Update TOML config file if it already exists.
|
||||
// Don't create a new one — DB persistence is sufficient.
|
||||
let toml_path = crate::settings::Settings::default_toml_path();
|
||||
match crate::settings::Settings::load_toml(&toml_path) {
|
||||
Ok(Some(mut settings)) => {
|
||||
@@ -1018,15 +1018,7 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
// No config file yet — create one so the model choice
|
||||
// survives restarts even when the DB is unavailable.
|
||||
let settings = crate::settings::Settings {
|
||||
selected_model: Some(model_owned),
|
||||
..Default::default()
|
||||
};
|
||||
if let Err(e) = settings.save_toml(&toml_path) {
|
||||
tracing::warn!("Failed to create config.toml for model persistence: {}", e);
|
||||
}
|
||||
// No config file on disk; DB persistence is sufficient.
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to load config.toml for model persistence: {}", e);
|
||||
|
||||
+9
-35
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
+27
-5
@@ -229,18 +229,35 @@ impl AppBuilder {
|
||||
let store = crate::secrets::create_secrets_store(crypto, handles);
|
||||
|
||||
if let Some(ref secrets) = store {
|
||||
// Migrate any plaintext API keys from the settings table to the
|
||||
// encrypted secrets store. Idempotent — safe to run on every startup.
|
||||
if let Some(ref db) = self.db {
|
||||
crate::config::migrate_plaintext_llm_keys(
|
||||
db.as_ref(),
|
||||
secrets.as_ref(),
|
||||
&self.config.owner_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Inject LLM API keys from encrypted storage
|
||||
crate::config::inject_llm_keys_from_secrets(secrets.as_ref(), &self.config.owner_id)
|
||||
.await;
|
||||
|
||||
// Re-resolve only the LLM config with newly available keys.
|
||||
let store: Option<&(dyn crate::db::SettingsStore + Sync)> =
|
||||
// Re-resolve only the LLM config with newly available keys,
|
||||
// including keys hydrated from the secrets store.
|
||||
let settings_store: Option<&(dyn crate::db::SettingsStore + Sync)> =
|
||||
self.db.as_ref().map(|db| db.as_ref() as _);
|
||||
let toml_path = self.toml_path.as_deref();
|
||||
let owner_id = self.config.owner_id.clone();
|
||||
if let Err(e) = self
|
||||
.config
|
||||
.re_resolve_llm(store, &owner_id, toml_path)
|
||||
.re_resolve_llm_with_secrets(
|
||||
settings_store,
|
||||
&owner_id,
|
||||
toml_path,
|
||||
Some(secrets.as_ref()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to re-resolve LLM config after secret injection: {e}");
|
||||
@@ -336,12 +353,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(
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
})))
|
||||
}
|
||||
@@ -7,10 +7,15 @@ use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use secrecy::SecretString;
|
||||
|
||||
use crate::channels::web::auth::AuthenticatedUser;
|
||||
use crate::channels::web::server::GatewayState;
|
||||
use crate::channels::web::types::*;
|
||||
use crate::secrets::{CreateSecretParams, SecretsStore};
|
||||
|
||||
/// Sentinel value the frontend sends to mean "key is unchanged, don't touch it".
|
||||
const API_KEY_UNCHANGED: &str = "••••••••";
|
||||
|
||||
pub async fn settings_list_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
@@ -25,12 +30,34 @@ pub async fn settings_list_handler(
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
// Build a map of sensitive keys so we can annotate and mask them.
|
||||
let sensitive_keys = ["llm_builtin_overrides", "llm_custom_providers"];
|
||||
let mut sensitive_map: std::collections::HashMap<String, serde_json::Value> = rows
|
||||
.iter()
|
||||
.filter(|r| sensitive_keys.contains(&r.key.as_str()))
|
||||
.map(|r| (r.key.clone(), r.value.clone()))
|
||||
.collect();
|
||||
if !sensitive_map.is_empty() {
|
||||
annotate_secret_key_presence(&state, &user.user_id, &mut sensitive_map).await;
|
||||
mask_settings_api_keys(&mut sensitive_map);
|
||||
}
|
||||
|
||||
let settings = rows
|
||||
.into_iter()
|
||||
.map(|r| SettingResponse {
|
||||
key: r.key,
|
||||
value: r.value,
|
||||
updated_at: r.updated_at.to_rfc3339(),
|
||||
.map(|r| {
|
||||
let value = if sensitive_keys.contains(&r.key.as_str()) {
|
||||
sensitive_map
|
||||
.get(&r.key)
|
||||
.cloned()
|
||||
.unwrap_or(r.value.clone())
|
||||
} else {
|
||||
r.value
|
||||
};
|
||||
SettingResponse {
|
||||
key: r.key,
|
||||
value,
|
||||
updated_at: r.updated_at.to_rfc3339(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -55,9 +82,22 @@ pub async fn settings_get_handler(
|
||||
})?
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
// Mask any plaintext API keys that may exist from legacy data.
|
||||
let value = if matches!(
|
||||
key.as_str(),
|
||||
"llm_builtin_overrides" | "llm_custom_providers"
|
||||
) {
|
||||
let mut map = std::collections::HashMap::from([(key.clone(), row.value.clone())]);
|
||||
annotate_secret_key_presence(&state, &user.user_id, &mut map).await;
|
||||
mask_settings_api_keys(&mut map);
|
||||
map.remove(&key).unwrap_or(row.value)
|
||||
} else {
|
||||
row.value
|
||||
};
|
||||
|
||||
Ok(Json(SettingResponse {
|
||||
key: row.key,
|
||||
value: row.value,
|
||||
value,
|
||||
updated_at: row.updated_at.to_rfc3339(),
|
||||
}))
|
||||
}
|
||||
@@ -72,8 +112,27 @@ pub async fn settings_set_handler(
|
||||
.store
|
||||
.as_ref()
|
||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||
|
||||
// Guard: cannot remove a custom provider that is currently active.
|
||||
if key == "llm_custom_providers" {
|
||||
guard_active_provider_not_removed(store, &user.user_id, &body.value).await?;
|
||||
validate_custom_providers(&body.value)?;
|
||||
}
|
||||
|
||||
// Extract API keys from LLM settings and vault them in the secrets store.
|
||||
// The sanitized value has api_key fields removed (stored encrypted instead).
|
||||
let sanitized_value = match key.as_str() {
|
||||
"llm_builtin_overrides" => {
|
||||
extract_builtin_override_keys(&state, &user.user_id, &body.value).await?
|
||||
}
|
||||
"llm_custom_providers" => {
|
||||
extract_custom_provider_keys(&state, &user.user_id, &body.value).await?
|
||||
}
|
||||
_ => body.value.clone(),
|
||||
};
|
||||
|
||||
store
|
||||
.set_setting(&user.user_id, &key, &body.value)
|
||||
.set_setting(&user.user_id, &key, &sanitized_value)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to set setting '{}': {}", key, e);
|
||||
@@ -83,6 +142,110 @@ pub async fn settings_set_handler(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
const VALID_ADAPTERS: &[&str] = &["open_ai_completions", "anthropic", "ollama"];
|
||||
|
||||
/// Valid provider ID: lowercase alphanumeric and hyphens, 1-64 chars.
|
||||
fn is_valid_provider_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||
}
|
||||
|
||||
/// Returns `Err(422)` if any provider has an invalid ID or unrecognised adapter.
|
||||
fn validate_custom_providers(value: &serde_json::Value) -> Result<(), StatusCode> {
|
||||
let providers = match value.as_array() {
|
||||
Some(arr) => arr,
|
||||
None => return Ok(()),
|
||||
};
|
||||
for p in providers {
|
||||
let id = p.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if !is_valid_provider_id(id) {
|
||||
tracing::warn!(
|
||||
id = %id,
|
||||
"Rejected custom provider with invalid ID (must be lowercase alphanumeric/hyphens, 1-64 chars)"
|
||||
);
|
||||
return Err(StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
}
|
||||
validate_custom_providers_adapters(value)
|
||||
}
|
||||
|
||||
/// Returns `Err(422)` if any provider in the incoming list has an unrecognised adapter.
|
||||
fn validate_custom_providers_adapters(value: &serde_json::Value) -> Result<(), StatusCode> {
|
||||
let providers = match value.as_array() {
|
||||
Some(arr) => arr,
|
||||
None => return Ok(()),
|
||||
};
|
||||
for p in providers {
|
||||
let adapter = p.get("adapter").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if adapter.is_empty() {
|
||||
tracing::warn!("Rejected custom provider with missing adapter field");
|
||||
return Err(StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
if !VALID_ADAPTERS.contains(&adapter) {
|
||||
tracing::warn!(adapter = %adapter, "Rejected unknown LLM adapter");
|
||||
return Err(StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns `Err(409)` if the active `llm_backend` is a custom provider that
|
||||
/// would be removed by the incoming update to `llm_custom_providers`.
|
||||
async fn guard_active_provider_not_removed(
|
||||
store: &Arc<dyn crate::db::Database>,
|
||||
user_id: &str,
|
||||
new_value: &serde_json::Value,
|
||||
) -> Result<(), StatusCode> {
|
||||
// Get the currently active backend.
|
||||
let active_backend = match store.get_setting(user_id, "llm_backend").await {
|
||||
Ok(Some(v)) => match v.as_str() {
|
||||
Some(s) if !s.is_empty() => s.to_string(),
|
||||
_ => return Ok(()),
|
||||
},
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
// Parse the incoming provider list.
|
||||
let new_providers: Vec<serde_json::Value> = match new_value.as_array() {
|
||||
Some(arr) => arr.clone(),
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
// Check whether the active backend exists in the OLD custom providers list.
|
||||
let old_providers_value = match store.get_setting(user_id, "llm_custom_providers").await {
|
||||
Ok(Some(v)) => v,
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let old_providers: Vec<serde_json::Value> = match old_providers_value.as_array() {
|
||||
Some(arr) => arr.clone(),
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let active_was_custom = old_providers
|
||||
.iter()
|
||||
.any(|p| p.get("id").and_then(|v| v.as_str()) == Some(&active_backend));
|
||||
if !active_was_custom {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Reject if the active provider is absent from the new list.
|
||||
let still_present = new_providers
|
||||
.iter()
|
||||
.any(|p| p.get("id").and_then(|v| v.as_str()) == Some(&active_backend));
|
||||
if !still_present {
|
||||
tracing::warn!(
|
||||
active_backend = %active_backend,
|
||||
"Rejected attempt to delete the active custom LLM provider"
|
||||
);
|
||||
return Err(StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn settings_delete_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
@@ -92,6 +255,14 @@ pub async fn settings_delete_handler(
|
||||
.store
|
||||
.as_ref()
|
||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||
|
||||
// Guard: deleting llm_custom_providers is equivalent to setting it to [].
|
||||
// Reject if the active backend is a custom provider that would be removed.
|
||||
if key == "llm_custom_providers" {
|
||||
guard_active_provider_not_removed(store, &user.user_id, &serde_json::Value::Array(vec![]))
|
||||
.await?;
|
||||
}
|
||||
|
||||
store
|
||||
.delete_setting(&user.user_id, &key)
|
||||
.await
|
||||
@@ -111,11 +282,16 @@ pub async fn settings_export_handler(
|
||||
.store
|
||||
.as_ref()
|
||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||
let settings = store.get_all_settings(&user.user_id).await.map_err(|e| {
|
||||
let mut settings = store.get_all_settings(&user.user_id).await.map_err(|e| {
|
||||
tracing::error!("Failed to export settings: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
// Indicate key presence from secrets store without exposing values.
|
||||
annotate_secret_key_presence(&state, &user.user_id, &mut settings).await;
|
||||
|
||||
mask_settings_api_keys(&mut settings);
|
||||
|
||||
Ok(Json(SettingsExportResponse { settings }))
|
||||
}
|
||||
|
||||
@@ -128,8 +304,21 @@ pub async fn settings_import_handler(
|
||||
.store
|
||||
.as_ref()
|
||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||
|
||||
// Vault any API keys present in the imported settings, same as the
|
||||
// individual SET handler does, so plaintext keys never reach the DB.
|
||||
let mut sanitized = body.settings.clone();
|
||||
if let Some(v) = sanitized.get("llm_builtin_overrides").cloned() {
|
||||
let clean = extract_builtin_override_keys(&state, &user.user_id, &v).await?;
|
||||
sanitized.insert("llm_builtin_overrides".to_string(), clean);
|
||||
}
|
||||
if let Some(v) = sanitized.get("llm_custom_providers").cloned() {
|
||||
let clean = extract_custom_provider_keys(&state, &user.user_id, &v).await?;
|
||||
sanitized.insert("llm_custom_providers".to_string(), clean);
|
||||
}
|
||||
|
||||
store
|
||||
.set_all_settings(&user.user_id, &body.settings)
|
||||
.set_all_settings(&user.user_id, &sanitized)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to import settings: {}", e);
|
||||
@@ -138,3 +327,639 @@ pub async fn settings_import_handler(
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LLM API key vaulting helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Canonical secret name for a built-in provider's API key.
|
||||
fn builtin_secret_name(provider_id: &str) -> String {
|
||||
format!("llm_builtin_{}_api_key", provider_id)
|
||||
}
|
||||
|
||||
/// Canonical secret name for a custom provider's API key.
|
||||
fn custom_secret_name(provider_id: &str) -> String {
|
||||
format!("llm_custom_{}_api_key", provider_id)
|
||||
}
|
||||
|
||||
/// Returns true if the `api_key` value is a real key (not sentinel/empty).
|
||||
fn is_real_api_key(key: &str) -> bool {
|
||||
!key.is_empty() && key != API_KEY_UNCHANGED
|
||||
}
|
||||
|
||||
/// Require the secrets store when real API keys are present.
|
||||
/// Returns `Ok(None)` when no secrets store and no real keys (passthrough).
|
||||
fn require_secrets_store(
|
||||
state: &GatewayState,
|
||||
has_real_keys: bool,
|
||||
) -> Result<Option<&Arc<dyn SecretsStore + Send + Sync>>, StatusCode> {
|
||||
match state.secrets_store.as_ref() {
|
||||
Some(s) => Ok(Some(s)),
|
||||
None if has_real_keys => {
|
||||
tracing::error!("Cannot store API keys: secrets store is not available");
|
||||
Err(StatusCode::SERVICE_UNAVAILABLE)
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract API keys from builtin overrides, store in secrets, return sanitized JSON.
|
||||
async fn extract_builtin_override_keys(
|
||||
state: &GatewayState,
|
||||
user_id: &str,
|
||||
value: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, StatusCode> {
|
||||
let obj = match value.as_object() {
|
||||
Some(o) => o,
|
||||
None => return Ok(value.clone()),
|
||||
};
|
||||
|
||||
let has_real_keys = obj.values().any(|v| {
|
||||
v.get("api_key")
|
||||
.and_then(|k| k.as_str())
|
||||
.is_some_and(is_real_api_key)
|
||||
});
|
||||
let secrets = match require_secrets_store(state, has_real_keys)? {
|
||||
Some(s) => s,
|
||||
None => return Ok(value.clone()),
|
||||
};
|
||||
|
||||
let mut sanitized = obj.clone();
|
||||
|
||||
for (provider_id, override_val) in obj {
|
||||
if let Some(api_key) = override_val.get("api_key").and_then(|v| v.as_str()) {
|
||||
if !is_real_api_key(api_key) {
|
||||
// Unchanged or empty — remove from settings, keep existing secret.
|
||||
if let Some(o) = sanitized
|
||||
.get_mut(provider_id)
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
o.remove("api_key");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
vault_secret(
|
||||
secrets.as_ref(),
|
||||
user_id,
|
||||
&builtin_secret_name(provider_id),
|
||||
api_key,
|
||||
provider_id,
|
||||
)
|
||||
.await?;
|
||||
if let Some(o) = sanitized
|
||||
.get_mut(provider_id)
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
o.remove("api_key");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::Value::Object(sanitized))
|
||||
}
|
||||
|
||||
/// Extract API keys from custom providers, store in secrets, return sanitized JSON.
|
||||
async fn extract_custom_provider_keys(
|
||||
state: &GatewayState,
|
||||
user_id: &str,
|
||||
value: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, StatusCode> {
|
||||
let arr = match value.as_array() {
|
||||
Some(a) => a,
|
||||
None => return Ok(value.clone()),
|
||||
};
|
||||
|
||||
let has_real_keys = arr.iter().any(|v| {
|
||||
v.get("api_key")
|
||||
.and_then(|k| k.as_str())
|
||||
.is_some_and(is_real_api_key)
|
||||
});
|
||||
let secrets = match require_secrets_store(state, has_real_keys)? {
|
||||
Some(s) => s,
|
||||
None => return Ok(value.clone()),
|
||||
};
|
||||
|
||||
let mut sanitized = arr.clone();
|
||||
|
||||
for (idx, provider_val) in arr.iter().enumerate() {
|
||||
let provider_id = provider_val
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if provider_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(api_key) = provider_val.get("api_key").and_then(|v| v.as_str()) {
|
||||
if !is_real_api_key(api_key) {
|
||||
if let Some(o) = sanitized[idx].as_object_mut() {
|
||||
o.remove("api_key");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
vault_secret(
|
||||
secrets.as_ref(),
|
||||
user_id,
|
||||
&custom_secret_name(provider_id),
|
||||
api_key,
|
||||
provider_id,
|
||||
)
|
||||
.await?;
|
||||
if let Some(o) = sanitized[idx].as_object_mut() {
|
||||
o.remove("api_key");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::Value::Array(sanitized))
|
||||
}
|
||||
|
||||
/// Encrypt and store an API key in the secrets store.
|
||||
async fn vault_secret(
|
||||
secrets: &(dyn SecretsStore + Send + Sync),
|
||||
user_id: &str,
|
||||
secret_name: &str,
|
||||
api_key: &str,
|
||||
provider_id: &str,
|
||||
) -> Result<(), StatusCode> {
|
||||
secrets
|
||||
.create(
|
||||
user_id,
|
||||
CreateSecretParams {
|
||||
name: secret_name.to_string(),
|
||||
value: SecretString::from(api_key.to_string()),
|
||||
provider: Some(provider_id.to_string()),
|
||||
expires_at: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(
|
||||
"Failed to store secret '{}' for provider '{}': {}",
|
||||
secret_name,
|
||||
provider_id,
|
||||
e
|
||||
);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mask plaintext API keys in settings values before returning to the frontend.
|
||||
///
|
||||
/// Any `api_key` field still present in the settings JSON (legacy plaintext)
|
||||
/// is replaced with the sentinel so the frontend shows "key configured".
|
||||
fn mask_settings_api_keys(settings: &mut std::collections::HashMap<String, serde_json::Value>) {
|
||||
if let Some(obj) = settings
|
||||
.get_mut("llm_builtin_overrides")
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
for override_val in obj.values_mut() {
|
||||
if let Some(o) = override_val.as_object_mut()
|
||||
&& o.contains_key("api_key")
|
||||
{
|
||||
o.insert(
|
||||
"api_key".to_string(),
|
||||
serde_json::Value::String(API_KEY_UNCHANGED.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(arr) = settings
|
||||
.get_mut("llm_custom_providers")
|
||||
.and_then(|v| v.as_array_mut())
|
||||
{
|
||||
for provider_val in arr.iter_mut() {
|
||||
if let Some(o) = provider_val.as_object_mut()
|
||||
&& o.contains_key("api_key")
|
||||
{
|
||||
o.insert(
|
||||
"api_key".to_string(),
|
||||
serde_json::Value::String(API_KEY_UNCHANGED.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check the secrets store for vaulted API keys and annotate the settings map.
|
||||
///
|
||||
/// For builtin overrides and custom providers whose API key was stripped from
|
||||
/// settings (stored in secrets), this adds `api_key: "••••••••"` so the
|
||||
/// frontend knows a key is configured without seeing the actual value.
|
||||
async fn annotate_secret_key_presence(
|
||||
state: &GatewayState,
|
||||
user_id: &str,
|
||||
settings: &mut std::collections::HashMap<String, serde_json::Value>,
|
||||
) {
|
||||
let secrets = match state.secrets_store.as_ref() {
|
||||
Some(s) => s,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Annotate builtin overrides
|
||||
if let Some(obj) = settings
|
||||
.get_mut("llm_builtin_overrides")
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
let provider_ids: Vec<String> = obj.keys().cloned().collect();
|
||||
for provider_id in provider_ids {
|
||||
let has_key_in_settings = obj
|
||||
.get(&provider_id)
|
||||
.and_then(|v| v.get("api_key"))
|
||||
.is_some();
|
||||
if has_key_in_settings {
|
||||
continue; // Will be masked by mask_settings_api_keys
|
||||
}
|
||||
let secret_name = builtin_secret_name(&provider_id);
|
||||
if secrets.exists(user_id, &secret_name).await.unwrap_or(false)
|
||||
&& let Some(o) = obj.get_mut(&provider_id).and_then(|v| v.as_object_mut())
|
||||
{
|
||||
o.insert(
|
||||
"api_key".to_string(),
|
||||
serde_json::Value::String(API_KEY_UNCHANGED.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Annotate custom providers
|
||||
if let Some(arr) = settings
|
||||
.get_mut("llm_custom_providers")
|
||||
.and_then(|v| v.as_array_mut())
|
||||
{
|
||||
for provider_val in arr.iter_mut() {
|
||||
let provider_id = provider_val
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if provider_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let has_key_in_settings = provider_val.get("api_key").is_some();
|
||||
if has_key_in_settings {
|
||||
continue;
|
||||
}
|
||||
let secret_name = custom_secret_name(&provider_id);
|
||||
if secrets.exists(user_id, &secret_name).await.unwrap_or(false)
|
||||
&& let Some(o) = provider_val.as_object_mut()
|
||||
{
|
||||
o.insert(
|
||||
"api_key".to_string(),
|
||||
serde_json::Value::String(API_KEY_UNCHANGED.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn test_mask_settings_api_keys_builtin_overrides() {
|
||||
let mut settings = HashMap::new();
|
||||
settings.insert(
|
||||
"llm_builtin_overrides".to_string(),
|
||||
serde_json::json!({
|
||||
"openai": { "api_key": "sk-secret-123", "model": "gpt-4" },
|
||||
"anthropic": { "model": "claude-3" }
|
||||
}),
|
||||
);
|
||||
|
||||
mask_settings_api_keys(&mut settings);
|
||||
|
||||
let overrides = settings["llm_builtin_overrides"].as_object().unwrap();
|
||||
assert_eq!(
|
||||
overrides["openai"]["api_key"].as_str().unwrap(),
|
||||
API_KEY_UNCHANGED,
|
||||
);
|
||||
assert_eq!(overrides["openai"]["model"].as_str().unwrap(), "gpt-4");
|
||||
assert!(overrides["anthropic"].get("api_key").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mask_settings_api_keys_custom_providers() {
|
||||
let mut settings = HashMap::new();
|
||||
settings.insert(
|
||||
"llm_custom_providers".to_string(),
|
||||
serde_json::json!([
|
||||
{ "id": "my-llm", "api_key": "secret-key", "adapter": "open_ai_completions" },
|
||||
{ "id": "no-key", "adapter": "ollama" }
|
||||
]),
|
||||
);
|
||||
|
||||
mask_settings_api_keys(&mut settings);
|
||||
|
||||
let providers = settings["llm_custom_providers"].as_array().unwrap();
|
||||
assert_eq!(providers[0]["api_key"].as_str().unwrap(), API_KEY_UNCHANGED,);
|
||||
assert!(providers[1].get("api_key").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mask_settings_no_llm_keys_is_noop() {
|
||||
let mut settings = HashMap::new();
|
||||
settings.insert("some_other_setting".to_string(), serde_json::json!("value"));
|
||||
|
||||
mask_settings_api_keys(&mut settings);
|
||||
|
||||
assert_eq!(settings["some_other_setting"].as_str().unwrap(), "value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_builtin_secret_name_format() {
|
||||
assert_eq!(builtin_secret_name("openai"), "llm_builtin_openai_api_key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_secret_name_format() {
|
||||
assert_eq!(custom_secret_name("my-groq"), "llm_custom_my-groq_api_key");
|
||||
}
|
||||
|
||||
fn test_secrets_store() -> Arc<dyn SecretsStore + Send + Sync> {
|
||||
let crypto = Arc::new(
|
||||
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
|
||||
crate::secrets::keychain::generate_master_key_hex(),
|
||||
))
|
||||
.unwrap(),
|
||||
);
|
||||
Arc::new(crate::secrets::InMemorySecretsStore::new(crypto))
|
||||
}
|
||||
|
||||
fn test_gateway_state(secrets: Arc<dyn SecretsStore + Send + Sync>) -> GatewayState {
|
||||
GatewayState {
|
||||
msg_tx: tokio::sync::RwLock::new(None),
|
||||
sse: Arc::new(crate::channels::web::sse::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: "test".to_string(),
|
||||
default_sender_id: "test".to_string(),
|
||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||
ws_tracker: None,
|
||||
llm_provider: None,
|
||||
skill_registry: None,
|
||||
skill_catalog: None,
|
||||
chat_rate_limiter: crate::channels::web::server::PerUserRateLimiter::new(30, 60),
|
||||
oauth_rate_limiter: crate::channels::web::server::RateLimiter::new(10, 60),
|
||||
webhook_rate_limiter: crate::channels::web::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(),
|
||||
active_config: crate::channels::web::server::ActiveConfigSnapshot::default(),
|
||||
secrets_store: Some(secrets),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_extract_builtin_keys_vaults_and_strips() {
|
||||
let secrets = test_secrets_store();
|
||||
let state = test_gateway_state(Arc::clone(&secrets));
|
||||
|
||||
let input = serde_json::json!({
|
||||
"openai": { "api_key": "sk-test-key", "model": "gpt-4" },
|
||||
"anthropic": { "model": "claude-3" }
|
||||
});
|
||||
|
||||
let result = extract_builtin_override_keys(&state, "test", &input)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let obj = result.as_object().unwrap();
|
||||
assert!(
|
||||
obj["openai"].get("api_key").is_none(),
|
||||
"api_key should be stripped"
|
||||
);
|
||||
assert_eq!(obj["openai"]["model"].as_str().unwrap(), "gpt-4");
|
||||
assert_eq!(obj["anthropic"]["model"].as_str().unwrap(), "claude-3");
|
||||
|
||||
let decrypted = secrets
|
||||
.get_decrypted("test", "llm_builtin_openai_api_key")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(decrypted.expose(), "sk-test-key");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_extract_custom_keys_vaults_and_strips() {
|
||||
let secrets = test_secrets_store();
|
||||
let state = test_gateway_state(Arc::clone(&secrets));
|
||||
|
||||
let input = serde_json::json!([
|
||||
{ "id": "my-llm", "api_key": "gsk-custom-key", "adapter": "open_ai_completions" },
|
||||
{ "id": "local", "adapter": "ollama" }
|
||||
]);
|
||||
|
||||
let result = extract_custom_provider_keys(&state, "test", &input)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let arr = result.as_array().unwrap();
|
||||
assert!(
|
||||
arr[0].get("api_key").is_none(),
|
||||
"api_key should be stripped"
|
||||
);
|
||||
assert_eq!(arr[0]["id"].as_str().unwrap(), "my-llm");
|
||||
assert!(arr[1].get("api_key").is_none());
|
||||
|
||||
let decrypted = secrets
|
||||
.get_decrypted("test", "llm_custom_my-llm_api_key")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(decrypted.expose(), "gsk-custom-key");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_unchanged_sentinel_preserves_existing_secret() {
|
||||
let secrets = test_secrets_store();
|
||||
|
||||
secrets
|
||||
.create(
|
||||
"test",
|
||||
CreateSecretParams {
|
||||
name: "llm_builtin_openai_api_key".to_string(),
|
||||
value: SecretString::from("sk-original".to_string()),
|
||||
provider: Some("openai".to_string()),
|
||||
expires_at: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let state = test_gateway_state(Arc::clone(&secrets));
|
||||
|
||||
let input = serde_json::json!({
|
||||
"openai": { "api_key": "••••••••", "model": "gpt-4" }
|
||||
});
|
||||
|
||||
let result = extract_builtin_override_keys(&state, "test", &input)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result["openai"].get("api_key").is_none());
|
||||
|
||||
let decrypted = secrets
|
||||
.get_decrypted("test", "llm_builtin_openai_api_key")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(decrypted.expose(), "sk-original");
|
||||
}
|
||||
|
||||
/// When secrets store is unavailable, attempting to save a real API key
|
||||
/// must fail with 503 rather than silently storing plaintext.
|
||||
#[tokio::test]
|
||||
async fn test_extract_builtin_keys_rejects_without_secrets_store() {
|
||||
let state = GatewayState {
|
||||
secrets_store: None,
|
||||
..test_gateway_state(test_secrets_store())
|
||||
};
|
||||
|
||||
let input = serde_json::json!({
|
||||
"openai": { "api_key": "sk-real-key", "model": "gpt-4" }
|
||||
});
|
||||
|
||||
let err = extract_builtin_override_keys(&state, "test", &input)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err, StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
/// When secrets store is unavailable but no real keys are present
|
||||
/// (only sentinels or no api_key at all), the call should succeed.
|
||||
#[tokio::test]
|
||||
async fn test_extract_builtin_keys_allows_no_keys_without_secrets_store() {
|
||||
let state = GatewayState {
|
||||
secrets_store: None,
|
||||
..test_gateway_state(test_secrets_store())
|
||||
};
|
||||
|
||||
let input = serde_json::json!({
|
||||
"openai": { "api_key": "••••••••", "model": "gpt-4" },
|
||||
"anthropic": { "model": "claude-3" }
|
||||
});
|
||||
|
||||
let result = extract_builtin_override_keys(&state, "test", &input)
|
||||
.await
|
||||
.unwrap();
|
||||
// Without secrets store, the value passes through unchanged (no vaulting needed).
|
||||
assert!(result.as_object().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_extract_custom_keys_rejects_without_secrets_store() {
|
||||
let state = GatewayState {
|
||||
secrets_store: None,
|
||||
..test_gateway_state(test_secrets_store())
|
||||
};
|
||||
|
||||
let input = serde_json::json!([
|
||||
{ "id": "my-llm", "api_key": "gsk-real-key", "adapter": "open_ai_completions" }
|
||||
]);
|
||||
|
||||
let err = extract_custom_provider_keys(&state, "test", &input)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err, StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
// --- Provider ID validation tests ---
|
||||
|
||||
#[test]
|
||||
fn test_valid_provider_ids() {
|
||||
assert!(is_valid_provider_id("my-llm"));
|
||||
assert!(is_valid_provider_id("openai"));
|
||||
assert!(is_valid_provider_id("custom-provider-123"));
|
||||
assert!(is_valid_provider_id("a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_provider_ids() {
|
||||
assert!(!is_valid_provider_id(""), "empty ID");
|
||||
assert!(!is_valid_provider_id("My-LLM"), "uppercase");
|
||||
assert!(!is_valid_provider_id("my llm"), "spaces");
|
||||
assert!(!is_valid_provider_id("my_llm"), "underscores");
|
||||
assert!(!is_valid_provider_id("../../etc"), "path traversal");
|
||||
assert!(!is_valid_provider_id("a.b"), "dots");
|
||||
assert!(
|
||||
!is_valid_provider_id(&"a".repeat(65)),
|
||||
"exceeds 64 char limit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_custom_providers_rejects_bad_id() {
|
||||
let input = serde_json::json!([
|
||||
{ "id": "UPPER-CASE", "adapter": "open_ai_completions" }
|
||||
]);
|
||||
assert_eq!(
|
||||
validate_custom_providers(&input).unwrap_err(),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_custom_providers_accepts_valid() {
|
||||
let input = serde_json::json!([
|
||||
{ "id": "my-llm", "adapter": "open_ai_completions" },
|
||||
{ "id": "local-ollama", "adapter": "ollama" }
|
||||
]);
|
||||
assert!(validate_custom_providers(&input).is_ok());
|
||||
}
|
||||
|
||||
// --- Adapter validation tests ---
|
||||
|
||||
#[test]
|
||||
fn test_validate_adapters_rejects_unknown() {
|
||||
let input = serde_json::json!([
|
||||
{ "id": "test", "adapter": "not_a_real_adapter" }
|
||||
]);
|
||||
assert_eq!(
|
||||
validate_custom_providers_adapters(&input).unwrap_err(),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_adapters_rejects_missing() {
|
||||
let input = serde_json::json!([
|
||||
{ "id": "test" }
|
||||
]);
|
||||
assert_eq!(
|
||||
validate_custom_providers_adapters(&input).unwrap_err(),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_adapters_accepts_all_valid() {
|
||||
for adapter in VALID_ADAPTERS {
|
||||
let input = serde_json::json!([
|
||||
{ "id": "test", "adapter": adapter }
|
||||
]);
|
||||
assert!(
|
||||
validate_custom_providers_adapters(&input).is_ok(),
|
||||
"adapter '{}' should be accepted",
|
||||
adapter
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_adapters_non_array_is_ok() {
|
||||
let input = serde_json::json!("not-an-array");
|
||||
assert!(validate_custom_providers_adapters(&input).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
})))
|
||||
}
|
||||
@@ -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(¤t.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 {
|
||||
¤t.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,
|
||||
})))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+69
-26
@@ -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,
|
||||
@@ -117,7 +115,63 @@ impl GatewayChannel {
|
||||
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(),
|
||||
secrets_store: None,
|
||||
});
|
||||
|
||||
Self {
|
||||
@@ -145,6 +199,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(),
|
||||
@@ -159,7 +214,6 @@ impl GatewayChannel {
|
||||
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 +261,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,12 +331,12 @@ impl GatewayChannel {
|
||||
self
|
||||
}
|
||||
|
||||
/// Inject the secrets store for admin secret provisioning.
|
||||
/// Inject the secrets store for encrypting LLM API keys in settings handlers.
|
||||
pub fn with_secrets_store(
|
||||
mut self,
|
||||
store: Arc<dyn crate::secrets::SecretsStore + Send + Sync>,
|
||||
ss: Arc<dyn crate::secrets::SecretsStore + Send + Sync>,
|
||||
) -> Self {
|
||||
self.rebuild_state(|s| s.secrets_store = Some(store));
|
||||
self.rebuild_state(|s| s.secrets_store = Some(ss));
|
||||
self
|
||||
}
|
||||
|
||||
@@ -305,7 +348,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).
|
||||
|
||||
+573
-245
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
@@ -35,16 +35,27 @@ function switchLanguage(lang) {
|
||||
if (I18n.setLanguage(lang)) {
|
||||
// Update slash commands
|
||||
updateSlashCommands();
|
||||
|
||||
|
||||
// Update language menu active state
|
||||
updateLanguageMenu();
|
||||
|
||||
|
||||
// Re-render dynamically built sections that use I18n.t()
|
||||
if (typeof renderProviders === 'function' && typeof _configLoaded !== 'undefined' && _configLoaded) {
|
||||
renderProviders();
|
||||
}
|
||||
if (typeof loadInferenceSettings === 'function') {
|
||||
var inferencePanel = document.getElementById('settings-inference');
|
||||
if (inferencePanel && inferencePanel.classList.contains('active')) {
|
||||
loadInferenceSettings();
|
||||
}
|
||||
}
|
||||
|
||||
// Close menu
|
||||
const menu = document.getElementById('language-menu');
|
||||
if (menu) {
|
||||
menu.style.display = 'none';
|
||||
}
|
||||
|
||||
|
||||
// Show toast notification
|
||||
showToast(I18n.t('language.switch') + ': ' + (lang === 'zh-CN' ? '简体中文' : 'English'));
|
||||
}
|
||||
@@ -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...',
|
||||
|
||||
@@ -36,52 +38,15 @@ I18n.register('en', {
|
||||
'tab.settings': 'Settings',
|
||||
'tab.extensions': 'Extensions',
|
||||
'tab.skills': 'Skills',
|
||||
'tab.config': 'Config',
|
||||
'tab.logs': 'Logs',
|
||||
'settings.inference': 'Inference',
|
||||
'settings.agent': 'Agent',
|
||||
'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',
|
||||
|
||||
'settings.providers': 'Providers',
|
||||
|
||||
// Status
|
||||
'status.connected': 'Connected',
|
||||
'status.disconnected': 'Disconnected',
|
||||
@@ -387,6 +352,45 @@ I18n.register('en', {
|
||||
'ext.removed': 'Removed {name}',
|
||||
'ext.installFailed': 'Install failed: {message}',
|
||||
|
||||
// Config Tab — Model Providers
|
||||
'config.modelProviders': 'Model Providers',
|
||||
'config.addProvider': '+ Add Provider',
|
||||
'config.newProvider': 'New Provider',
|
||||
'config.restartNotice': 'Changes take effect after restart.',
|
||||
'config.builtin': 'built-in',
|
||||
'config.useProvider': 'Use',
|
||||
'config.configureProvider': 'Configure',
|
||||
'config.providerConfigured': 'Provider "{name}" configured (restart to apply)',
|
||||
'config.currentModel': 'Model: {model}',
|
||||
'config.providerName': 'Display Name',
|
||||
'config.providerNamePlaceholder': 'My Provider',
|
||||
'config.providerId': 'Provider ID',
|
||||
'config.providerIdPlaceholder': 'my-provider',
|
||||
'config.providerIdHint': 'Lowercase letters, numbers, hyphens',
|
||||
'config.providerAdapter': 'API Adapter',
|
||||
'config.adapterOpenAI': 'OpenAI Compatible',
|
||||
'config.adapterAnthropic': 'Anthropic',
|
||||
'config.adapterOllama': 'Ollama',
|
||||
'config.providerBaseUrl': 'Base URL',
|
||||
'config.providerApiKey': 'API Key',
|
||||
'config.providerModel': 'Default Model',
|
||||
'config.providerActivated': 'Switched to {name} (restart to apply)',
|
||||
'config.providerAdded': 'Added provider "{name}" (restart to apply)',
|
||||
'config.providerUpdated': 'Provider "{name}" updated (restart to apply)',
|
||||
'config.editProvider': 'Edit Provider',
|
||||
'config.providerDeleted': 'Provider deleted',
|
||||
'config.confirmDeleteProvider': 'Delete provider "{id}"?',
|
||||
'config.cannotDeleteActiveProvider': 'Cannot delete the active provider. Switch to another provider first.',
|
||||
'config.testConnection': 'Test',
|
||||
'config.testing': 'Testing…',
|
||||
'config.fetchModels': 'Fetch available models',
|
||||
'config.modelsFetched': '{count} model(s) loaded — type to filter',
|
||||
'config.modelsFetchFailed': 'Failed to fetch models',
|
||||
'config.providerBaseUrlRequired': 'Base URL is required to fetch models',
|
||||
'config.providerFieldsRequired': 'Display name and Provider ID are required',
|
||||
'config.providerIdInvalid': 'Provider ID: use only lowercase letters, numbers, hyphens',
|
||||
'config.providerIdTaken': 'Provider ID "{id}" is already taken',
|
||||
|
||||
// Configure
|
||||
'config.title': 'Configure {name}',
|
||||
'config.telegramOwnerHint': 'After saving, IronClaw will show a one-time code. Send `/start CODE` to your bot in Telegram and IronClaw will finish setup automatically.',
|
||||
+44
-40
@@ -9,6 +9,8 @@ I18n.register('zh-CN', {
|
||||
'auth.connect': '连接',
|
||||
'auth.errorRequired': '请输入令牌',
|
||||
'auth.errorInvalid': '令牌无效',
|
||||
'auth.hint': '输入 .env 配置文件中的 GATEWAY_AUTH_TOKEN',
|
||||
|
||||
// 聊天
|
||||
'chat.inputPlaceholder': '输入消息或 / 以使用命令...',
|
||||
|
||||
@@ -36,52 +38,15 @@ I18n.register('zh-CN', {
|
||||
'tab.settings': '设置',
|
||||
'tab.extensions': '扩展',
|
||||
'tab.skills': '技能',
|
||||
'tab.config': '配置',
|
||||
'tab.logs': '日志',
|
||||
'settings.inference': '推理',
|
||||
'settings.agent': '代理',
|
||||
'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': '操作',
|
||||
|
||||
'settings.providers': '模型提供商',
|
||||
|
||||
// 状态
|
||||
'status.connected': '已连接',
|
||||
'status.disconnected': '已断开',
|
||||
@@ -387,6 +352,45 @@ I18n.register('zh-CN', {
|
||||
'ext.removed': '已移除 {name}',
|
||||
'ext.installFailed': '安装失败: {message}',
|
||||
|
||||
// 配置页 — 模型提供商
|
||||
'config.modelProviders': '模型提供商',
|
||||
'config.addProvider': '+ 添加提供商',
|
||||
'config.newProvider': '新建提供商',
|
||||
'config.restartNotice': '更改将在重启后生效。',
|
||||
'config.builtin': '内置',
|
||||
'config.useProvider': '使用',
|
||||
'config.configureProvider': '配置',
|
||||
'config.providerConfigured': '提供商 "{name}" 已配置(重启后生效)',
|
||||
'config.currentModel': '模型:{model}',
|
||||
'config.providerName': '显示名称',
|
||||
'config.providerNamePlaceholder': '我的提供商',
|
||||
'config.providerId': '提供商 ID',
|
||||
'config.providerIdPlaceholder': 'my-provider',
|
||||
'config.providerIdHint': '小写字母、数字、连字符',
|
||||
'config.providerAdapter': 'API 适配器',
|
||||
'config.adapterOpenAI': 'OpenAI 兼容',
|
||||
'config.adapterAnthropic': 'Anthropic',
|
||||
'config.adapterOllama': 'Ollama',
|
||||
'config.providerBaseUrl': '基础 URL',
|
||||
'config.providerApiKey': 'API 密钥',
|
||||
'config.providerModel': '默认模型',
|
||||
'config.providerActivated': '已切换到 {name}(重启后生效)',
|
||||
'config.providerAdded': '已添加提供商 "{name}"(重启后生效)',
|
||||
'config.providerUpdated': '提供商 "{name}" 已更新(重启后生效)',
|
||||
'config.editProvider': '编辑提供商',
|
||||
'config.providerDeleted': '提供商已删除',
|
||||
'config.confirmDeleteProvider': '确定删除提供商 "{id}"?',
|
||||
'config.cannotDeleteActiveProvider': '无法删除当前正在使用的提供商,请先切换到其他提供商。',
|
||||
'config.testConnection': '测试',
|
||||
'config.testing': '测试中…',
|
||||
'config.fetchModels': '获取可用模型',
|
||||
'config.modelsFetched': '已加载 {count} 个模型,可输入过滤',
|
||||
'config.modelsFetchFailed': '获取模型列表失败',
|
||||
'config.providerBaseUrlRequired': '请先填写 Base URL',
|
||||
'config.providerFieldsRequired': '显示名称和提供商 ID 为必填项',
|
||||
'config.providerIdInvalid': '提供商 ID 只能包含小写字母、数字和连字符',
|
||||
'config.providerIdTaken': '提供商 ID "{id}" 已被占用',
|
||||
|
||||
// 配置
|
||||
'config.title': '配置 {name}',
|
||||
'config.telegramOwnerHint': '保存后,IronClaw 会显示一次性验证码。将 `/start CODE` 发送给你的 Telegram 机器人,IronClaw 会自动完成设置。',
|
||||
@@ -41,6 +41,59 @@
|
||||
<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>
|
||||
|
||||
<!-- Provider Add/Edit Dialog -->
|
||||
<div id="provider-dialog" class="provider-dialog" style="display:none">
|
||||
<div class="provider-dialog-overlay" id="provider-dialog-overlay"></div>
|
||||
<div class="provider-dialog-content">
|
||||
<div class="provider-dialog-header">
|
||||
<h2 id="provider-form-title" data-i18n="config.newProvider">New Provider</h2>
|
||||
<button class="provider-dialog-close" id="cancel-provider-btn" title="Close">×</button>
|
||||
</div>
|
||||
<div class="provider-dialog-body">
|
||||
<div class="config-form">
|
||||
<div class="config-form-row" id="provider-name-row">
|
||||
<label data-i18n="config.providerName">Display Name</label>
|
||||
<input type="text" id="provider-name" data-i18n="config.providerNamePlaceholder" data-i18n-attr="placeholder" placeholder="My Provider">
|
||||
</div>
|
||||
<div class="config-form-row" id="provider-id-row">
|
||||
<label data-i18n="config.providerId">Provider ID</label>
|
||||
<input type="text" id="provider-id" data-i18n="config.providerIdPlaceholder" data-i18n-attr="placeholder" placeholder="my-provider">
|
||||
<span class="config-form-hint" data-i18n="config.providerIdHint">Lowercase letters, numbers, hyphens</span>
|
||||
</div>
|
||||
<div class="config-form-row" id="provider-adapter-row">
|
||||
<label data-i18n="config.providerAdapter">API Adapter</label>
|
||||
<select id="provider-adapter">
|
||||
<option value="open_ai_completions" data-i18n="config.adapterOpenAI">OpenAI Compatible</option>
|
||||
<option value="anthropic" data-i18n="config.adapterAnthropic">Anthropic</option>
|
||||
<option value="ollama" data-i18n="config.adapterOllama">Ollama</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-form-row" id="provider-base-url-row">
|
||||
<label data-i18n="config.providerBaseUrl">Base URL</label>
|
||||
<input type="text" id="provider-base-url" placeholder="https://api.example.com/v1">
|
||||
</div>
|
||||
<div class="config-form-row" id="provider-api-key-row">
|
||||
<label data-i18n="config.providerApiKey">API Key</label>
|
||||
<input type="password" id="provider-api-key" placeholder="sk-...">
|
||||
</div>
|
||||
<div class="config-form-row">
|
||||
<label data-i18n="config.providerModel">Default Model</label>
|
||||
<input type="text" id="provider-model" placeholder="gpt-4o">
|
||||
<button id="fetch-models-btn" class="btn-fetch-models" type="button" data-i18n="config.fetchModels">↻ Fetch available models</button>
|
||||
<select id="provider-model-select" style="display:none"></select>
|
||||
</div>
|
||||
<div id="test-connection-result" class="test-connection-result" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider-dialog-footer">
|
||||
<button id="save-provider-btn" data-i18n="common.save">Save</button>
|
||||
<button id="test-provider-btn" class="btn-secondary" data-i18n="config.testConnection">Test</button>
|
||||
<button id="cancel-provider-footer-btn" class="btn-secondary" data-i18n="common.cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -292,7 +345,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">
|
||||
@@ -305,8 +357,23 @@
|
||||
<button id="settings-import-btn" class="settings-toolbar-btn" data-i18n="settings.import">Import</button>
|
||||
</div>
|
||||
<div class="settings-subpanel active" id="settings-inference">
|
||||
<div class="extensions-container" id="settings-inference-content">
|
||||
<div class="empty-state" data-i18n="common.loading">Loading settings...</div>
|
||||
<div class="extensions-container">
|
||||
<div id="settings-inference-content">
|
||||
<div class="empty-state" data-i18n="common.loading">Loading settings...</div>
|
||||
</div>
|
||||
<div class="extensions-section" id="providers-section">
|
||||
<div class="config-section-header">
|
||||
<h3 data-i18n="config.modelProviders">Model Providers</h3>
|
||||
<button id="add-provider-btn" class="btn-add-provider" data-i18n="config.addProvider">+ Add Provider</button>
|
||||
</div>
|
||||
<div class="config-notice" id="config-restart-notice" style="display:none">
|
||||
<span>⚠</span>
|
||||
<span data-i18n="config.restartNotice">Changes take effect after restart.</span>
|
||||
</div>
|
||||
<div id="providers-list" class="providers-list">
|
||||
<div class="empty-state" data-i18n="common.loading">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-subpanel" id="settings-agent">
|
||||
@@ -390,29 +457,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>
|
||||
@@ -431,6 +475,7 @@
|
||||
</div>
|
||||
|
||||
<div id="toasts"></div>
|
||||
<script src="/providers.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
<script src="/i18n-app.js"></script>
|
||||
</body>
|
||||
@@ -0,0 +1,37 @@
|
||||
// Built-in LLM provider definitions.
|
||||
// Generated from providers.json + nearai/bedrock (handled separately in llm.rs)
|
||||
// Fields: id, name, adapter, base_url, builtin, default_model, api_key_required, can_list_models
|
||||
// nearai/bedrock use special auth flows — no Configure button (api_key_required=false, can_list_models=false)
|
||||
const BUILTIN_PROVIDERS = [
|
||||
{ id: 'nearai', name: 'NEAR AI', adapter: 'nearai', base_url: 'https://cloud-api.near.ai/v1', builtin: true, default_model: 'zai-org/GLM-5-FP8', api_key_required: true, can_list_models: true },
|
||||
{ id: 'openai', name: 'OpenAI', adapter: 'open_ai_completions', base_url: 'https://api.openai.com/v1', builtin: true, default_model: 'gpt-4o-mini', api_key_required: true, can_list_models: true },
|
||||
{ id: 'anthropic', name: 'Anthropic', adapter: 'anthropic', base_url: 'https://api.anthropic.com', builtin: true, default_model: 'claude-sonnet-4-20250514', api_key_required: true, can_list_models: true },
|
||||
{ id: 'ollama', name: 'Ollama', adapter: 'ollama', base_url: 'http://localhost:11434', builtin: true, default_model: 'llama3', api_key_required: false, can_list_models: true },
|
||||
{ id: 'openai_compatible', name: 'OpenAI Compatible', adapter: 'open_ai_completions', base_url: '', builtin: true, default_model: 'default', api_key_required: false, can_list_models: false },
|
||||
{ id: 'gemini', name: 'Google Gemini', adapter: 'open_ai_completions', base_url: 'https://generativelanguage.googleapis.com/v1beta/openai', builtin: true, default_model: 'gemini-2.5-flash', api_key_required: true, can_list_models: true },
|
||||
{ id: 'groq', name: 'Groq', adapter: 'open_ai_completions', base_url: 'https://api.groq.com/openai/v1', builtin: true, default_model: 'llama-3.3-70b-versatile', api_key_required: true, can_list_models: true },
|
||||
{ id: 'openrouter', name: 'OpenRouter', adapter: 'open_ai_completions', base_url: 'https://openrouter.ai/api/v1', builtin: true, default_model: 'openai/gpt-4o', api_key_required: true, can_list_models: false },
|
||||
{ id: 'deepseek', name: 'DeepSeek', adapter: 'open_ai_completions', base_url: 'https://api.deepseek.com/v1', builtin: true, default_model: 'deepseek-chat', api_key_required: true, can_list_models: false },
|
||||
{ id: 'mistral', name: 'Mistral', adapter: 'open_ai_completions', base_url: 'https://api.mistral.ai/v1', builtin: true, default_model: 'mistral-large-latest', api_key_required: true, can_list_models: true },
|
||||
{ id: 'tinfoil', name: 'Tinfoil', adapter: 'open_ai_completions', base_url: 'https://inference.tinfoil.sh/v1', builtin: true, default_model: 'kimi-k2-5', api_key_required: true, can_list_models: false },
|
||||
{ id: 'nvidia', name: 'NVIDIA NIM', adapter: 'open_ai_completions', base_url: 'https://integrate.api.nvidia.com/v1', builtin: true, default_model: 'meta/llama-3.3-70b-instruct', api_key_required: true, can_list_models: true },
|
||||
{ id: 'together', name: 'Together AI', adapter: 'open_ai_completions', base_url: 'https://api.together.xyz/v1', builtin: true, default_model: 'meta-llama/Llama-3-70b-chat-hf', api_key_required: true, can_list_models: false },
|
||||
{ id: 'fireworks', name: 'Fireworks AI', adapter: 'open_ai_completions', base_url: 'https://api.fireworks.ai/inference/v1', builtin: true, default_model: 'accounts/fireworks/models/llama-v3p1-70b-instruct', api_key_required: true, can_list_models: false },
|
||||
{ id: 'cerebras', name: 'Cerebras', adapter: 'open_ai_completions', base_url: 'https://api.cerebras.ai/v1', builtin: true, default_model: 'llama-3.3-70b', api_key_required: true, can_list_models: false },
|
||||
{ id: 'sambanova', name: 'SambaNova', adapter: 'open_ai_completions', base_url: 'https://api.sambanova.ai/v1', builtin: true, default_model: 'Meta-Llama-3.1-70B-Instruct', api_key_required: true, can_list_models: false },
|
||||
{ id: 'zai', name: 'Z.AI', adapter: 'open_ai_completions', base_url: 'https://api.z.ai/api/paas/v4', builtin: true, default_model: 'glm-5', api_key_required: true, can_list_models: false },
|
||||
{ id: 'venice', name: 'Venice.ai', adapter: 'open_ai_completions', base_url: 'https://api.venice.ai/api/v1', builtin: true, default_model: 'llama-3.3-70b', api_key_required: true, can_list_models: false },
|
||||
{ id: 'minimax', name: 'MiniMax', adapter: 'open_ai_completions', base_url: 'https://api.minimax.io/v1', builtin: true, default_model: 'MiniMax-M2.5', api_key_required: true, can_list_models: false },
|
||||
{ id: 'ionet', name: 'io.net', adapter: 'open_ai_completions', base_url: 'https://api.intelligence.io.solutions/api/v1', builtin: true, default_model: 'deepseek-coder-v2-instruct', api_key_required: true, can_list_models: true },
|
||||
{ id: 'cloudflare', name: 'Cloudflare AI', adapter: 'open_ai_completions', base_url: '', builtin: true, default_model: '@cf/meta/llama-3.3-70b-instruct-fp8-fast', api_key_required: true, can_list_models: false },
|
||||
{ id: 'yandex', name: 'Yandex AI Studio', adapter: 'open_ai_completions', base_url: 'https://ai.api.cloud.yandex.net/v1', builtin: true, default_model: 'yandexgpt-lite', api_key_required: true, can_list_models: true },
|
||||
{ id: 'bedrock', name: 'AWS Bedrock', adapter: 'bedrock', base_url: '', builtin: true, default_model: 'anthropic.claude-3-sonnet-20240229-v1:0', api_key_required: false, can_list_models: false },
|
||||
];
|
||||
|
||||
const ADAPTER_LABELS = {
|
||||
open_ai_completions: 'OpenAI Compatible',
|
||||
anthropic: 'Anthropic',
|
||||
ollama: 'Ollama',
|
||||
bedrock: 'AWS Bedrock',
|
||||
nearai: 'NEAR AI',
|
||||
};
|
||||
@@ -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;
|
||||
@@ -2885,10 +2801,22 @@ body {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
#settings-inference > .extensions-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.extensions-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
#providers-section {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.extensions-section h3 {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
@@ -4677,6 +4605,12 @@ mark {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.settings-display-value {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text);
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
|
||||
.settings-input {
|
||||
padding: 6px 10px;
|
||||
background: var(--bg);
|
||||
@@ -5514,21 +5448,404 @@ body.theme-transition *:not(svg):not(path):not(line):not(circle):not(rect) {
|
||||
}
|
||||
}
|
||||
|
||||
/* --- 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; }
|
||||
|
||||
/* --- Config Tab --- */
|
||||
|
||||
.config-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.config-section-header h3 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.btn-add-provider {
|
||||
padding: 5px 14px;
|
||||
background: var(--accent);
|
||||
color: #09090b;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: background 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.btn-add-provider:hover {
|
||||
background: var(--accent-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.config-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(245, 166, 35, 0.1);
|
||||
border: 1px solid rgba(245, 166, 35, 0.3);
|
||||
border-radius: var(--radius);
|
||||
color: var(--warning);
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.providers-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 420px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.provider-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.provider-card:hover {
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.provider-card-active {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.provider-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.provider-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.provider-id-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.provider-badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.provider-badge-active {
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.provider-badge-builtin {
|
||||
background: rgba(161, 161, 170, 0.12);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.provider-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.provider-adapter {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.provider-url {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.provider-current-model {
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.provider-card-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.provider-action-btn {
|
||||
padding: 4px 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: color 0.2s, border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.provider-action-btn:hover {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.provider-delete-btn:hover {
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
/* Config form */
|
||||
|
||||
.provider-dialog {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.provider-dialog-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.provider-dialog-content {
|
||||
position: relative;
|
||||
z-index: 10000;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.4);
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
margin: 0 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.provider-dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.provider-dialog-header h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.provider-dialog-close {
|
||||
color: var(--text-secondary);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
padding: 2px 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.provider-dialog-close:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.provider-dialog-body {
|
||||
padding: 18px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.provider-dialog-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 14px 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.provider-dialog-footer button {
|
||||
padding: 6px 18px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.provider-dialog-footer button:first-child {
|
||||
background: var(--accent);
|
||||
color: #09090b;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.provider-dialog-footer button:first-child:hover {
|
||||
background: var(--accent-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.provider-dialog-footer .btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.provider-dialog-footer .btn-secondary:hover {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.config-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.config-form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.config-form-row label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.config-form-row input,
|
||||
.config-form-row select {
|
||||
padding: 7px 10px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.config-form-row input:focus,
|
||||
.config-form-row select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
|
||||
}
|
||||
|
||||
.config-form-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.config-form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.config-form-actions button {
|
||||
padding: 6px 18px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.config-form-actions button:first-child {
|
||||
background: var(--accent);
|
||||
color: #09090b;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.config-form-actions button:first-child:hover {
|
||||
background: var(--accent-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.config-form-actions .btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.config-form-actions .btn-secondary:hover {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.btn-fetch-models {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-top: 6px;
|
||||
padding: 5px 11px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.btn-fetch-models:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
}
|
||||
|
||||
.btn-fetch-models:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.test-connection-result {
|
||||
margin-top: 8px;
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.test-connection-result.test-ok {
|
||||
background: rgba(74, 222, 128, 0.12);
|
||||
color: #4ade80;
|
||||
border: 1px solid rgba(74, 222, 128, 0.3);
|
||||
}
|
||||
|
||||
.test-connection-result.test-fail {
|
||||
background: rgba(248, 113, 113, 0.12);
|
||||
color: #f87171;
|
||||
border: 1px solid rgba(248, 113, 113, 0.3);
|
||||
}
|
||||
@@ -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,
|
||||
@@ -92,7 +93,6 @@ impl TestGatewayBuilder {
|
||||
startup_time: std::time::Instant::now(),
|
||||
active_config: crate::channels::web::server::ActiveConfigSnapshot::default(),
|
||||
secrets_store: None,
|
||||
db_auth: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -107,7 +107,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 +121,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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -82,10 +83,43 @@ fn build_state(
|
||||
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 +226,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 +254,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 +277,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 +303,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 +338,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 +378,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 +584,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 +709,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 +832,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -535,7 +536,6 @@ mod tests {
|
||||
startup_time: std::time::Instant::now(),
|
||||
active_config: crate::channels::web::server::ActiveConfigSnapshot::default(),
|
||||
secrets_store: None,
|
||||
db_auth: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
}
|
||||
|
||||
+818
-58
File diff suppressed because it is too large
Load Diff
+210
-18
@@ -1,9 +1,13 @@
|
||||
//! Configuration for IronClaw.
|
||||
//!
|
||||
//! Settings are loaded with priority: env var > database > default.
|
||||
//! Settings are loaded from env vars, the DB settings table, TOML config,
|
||||
//! and built-in defaults. Priority varies by subsystem:
|
||||
//!
|
||||
//! - **LLM settings** (backend, model, api_key, base_url): DB > env > default
|
||||
//! - **Most other settings** (agent, channels, tunnel, …): env > DB > default
|
||||
//!
|
||||
//! `DATABASE_URL` lives in `~/.ironclaw/.env` (loaded via dotenvy early
|
||||
//! in startup). Everything else comes from env vars, the DB settings
|
||||
//! table, or auto-detection.
|
||||
//! in startup).
|
||||
|
||||
mod agent;
|
||||
mod builder;
|
||||
@@ -186,8 +190,9 @@ impl Config {
|
||||
|
||||
/// Load configuration from environment variables and the database.
|
||||
///
|
||||
/// Priority: env var > TOML config file > DB settings > default.
|
||||
/// This is the primary way to load config after DB is connected.
|
||||
/// TOML is loaded first as a base, then DB values are merged on top
|
||||
/// (DB wins over TOML). Individual subsystem resolvers then apply
|
||||
/// their own env-vs-DB priority — see module docs for details.
|
||||
pub async fn from_db(
|
||||
store: &(dyn crate::db::SettingsStore + Sync),
|
||||
user_id: &str,
|
||||
@@ -196,6 +201,10 @@ impl Config {
|
||||
}
|
||||
|
||||
/// Load from DB with an optional TOML config file overlay.
|
||||
///
|
||||
/// TOML is loaded first as a base, then DB values are merged on top
|
||||
/// (DB wins over TOML). Per-subsystem resolvers then decide whether
|
||||
/// env vars or DB values take final precedence — see module docs.
|
||||
pub async fn from_db_with_toml(
|
||||
store: &(dyn crate::db::SettingsStore + Sync),
|
||||
user_id: &str,
|
||||
@@ -204,19 +213,22 @@ impl Config {
|
||||
let _ = dotenvy::dotenv();
|
||||
crate::bootstrap::load_ironclaw_env();
|
||||
|
||||
// Load all settings from DB into a Settings struct
|
||||
let mut db_settings = match store.get_all_settings(user_id).await {
|
||||
Ok(map) => Settings::from_db_map(&map),
|
||||
// Start with TOML config as a base (lowest priority among the two).
|
||||
let mut settings = Settings::default();
|
||||
Self::apply_toml_overlay(&mut settings, toml_path)?;
|
||||
|
||||
// Overlay DB settings on top so DB values win over TOML.
|
||||
match store.get_all_settings(user_id).await {
|
||||
Ok(map) => {
|
||||
let db_settings = Settings::from_db_map(&map);
|
||||
settings.merge_from(&db_settings);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to load settings from DB, using defaults: {}", e);
|
||||
Settings::default()
|
||||
}
|
||||
};
|
||||
|
||||
// Overlay TOML config file (values win over DB settings)
|
||||
Self::apply_toml_overlay(&mut db_settings, toml_path)?;
|
||||
|
||||
Self::build(&db_settings).await
|
||||
Self::build(&settings).await
|
||||
}
|
||||
|
||||
/// Load configuration from environment variables only (no database).
|
||||
@@ -291,16 +303,38 @@ impl Config {
|
||||
user_id: &str,
|
||||
toml_path: Option<&std::path::Path>,
|
||||
) -> Result<(), ConfigError> {
|
||||
let settings = if let Some(store) = store {
|
||||
let mut s = match store.get_all_settings(user_id).await {
|
||||
Ok(map) => Settings::from_db_map(&map),
|
||||
Err(_) => Settings::default(),
|
||||
};
|
||||
self.re_resolve_llm_with_secrets(store, user_id, toml_path, None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Re-resolve LLM config, hydrating API keys from the secrets store.
|
||||
pub async fn re_resolve_llm_with_secrets(
|
||||
&mut self,
|
||||
store: Option<&(dyn crate::db::SettingsStore + Sync)>,
|
||||
user_id: &str,
|
||||
toml_path: Option<&std::path::Path>,
|
||||
secrets: Option<&(dyn crate::secrets::SecretsStore + Send + Sync)>,
|
||||
) -> Result<(), ConfigError> {
|
||||
let mut settings = if let Some(store) = store {
|
||||
// TOML as base, then DB on top (DB wins).
|
||||
let mut s = Settings::default();
|
||||
Self::apply_toml_overlay(&mut s, toml_path)?;
|
||||
if let Ok(map) = store.get_all_settings(user_id).await {
|
||||
let db_settings = Settings::from_db_map(&map);
|
||||
s.merge_from(&db_settings);
|
||||
}
|
||||
s
|
||||
} else {
|
||||
Settings::default()
|
||||
};
|
||||
|
||||
// Hydrate API keys from encrypted secrets store into the settings
|
||||
// struct so that LlmConfig::resolve() sees them without any changes
|
||||
// to its synchronous resolution logic.
|
||||
if let Some(secrets) = secrets {
|
||||
hydrate_llm_keys_from_secrets(&mut settings, secrets, user_id).await;
|
||||
}
|
||||
|
||||
self.llm = LlmConfig::resolve(&settings)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -501,3 +535,161 @@ fn inject_os_credential_store_tokens(injected: &mut HashMap<String, String>) {
|
||||
tracing::debug!("Refreshed ANTHROPIC_OAUTH_TOKEN from OS credential store");
|
||||
}
|
||||
}
|
||||
|
||||
/// Hydrate LLM API keys from the secrets store into the settings struct.
|
||||
///
|
||||
/// Called after loading settings from DB but before `LlmConfig::resolve()`.
|
||||
/// Populates `api_key` fields that were stripped from settings during the
|
||||
/// write path and stored encrypted in the secrets store instead.
|
||||
pub async fn hydrate_llm_keys_from_secrets(
|
||||
settings: &mut Settings,
|
||||
secrets: &(dyn crate::secrets::SecretsStore + Send + Sync),
|
||||
user_id: &str,
|
||||
) {
|
||||
// Hydrate builtin overrides
|
||||
for (provider_id, override_val) in settings.llm_builtin_overrides.iter_mut() {
|
||||
if override_val.api_key.is_some() {
|
||||
continue; // Already has a key (legacy plaintext or TOML)
|
||||
}
|
||||
let secret_name = format!("llm_builtin_{}_api_key", provider_id);
|
||||
if let Ok(decrypted) = secrets.get_decrypted(user_id, &secret_name).await {
|
||||
override_val.api_key = Some(decrypted.expose().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Hydrate custom providers
|
||||
for provider in settings.llm_custom_providers.iter_mut() {
|
||||
if provider.api_key.is_some() {
|
||||
continue;
|
||||
}
|
||||
let secret_name = format!("llm_custom_{}_api_key", provider.id);
|
||||
if let Ok(decrypted) = secrets.get_decrypted(user_id, &secret_name).await {
|
||||
provider.api_key = Some(decrypted.expose().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Migrate plaintext API keys from the settings table to the encrypted secrets store.
|
||||
///
|
||||
/// Idempotent: skips keys that are already in the secrets store.
|
||||
/// After migration, strips plaintext keys from the settings table.
|
||||
pub async fn migrate_plaintext_llm_keys(
|
||||
settings_store: &(dyn crate::db::SettingsStore + Sync),
|
||||
secrets: &(dyn crate::secrets::SecretsStore + Send + Sync),
|
||||
user_id: &str,
|
||||
) {
|
||||
let settings_map = match settings_store.get_all_settings(user_id).await {
|
||||
Ok(m) => m,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let mut migrated = 0u32;
|
||||
|
||||
// Migrate builtin overrides
|
||||
if let Some(obj) = settings_map
|
||||
.get("llm_builtin_overrides")
|
||||
.and_then(|v| v.as_object())
|
||||
{
|
||||
let mut sanitized = obj.clone();
|
||||
for (provider_id, override_val) in obj {
|
||||
if let Some(api_key) = override_val.get("api_key").and_then(|v| v.as_str()) {
|
||||
if api_key.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let secret_name = format!("llm_builtin_{}_api_key", provider_id);
|
||||
if !secrets.exists(user_id, &secret_name).await.unwrap_or(false)
|
||||
&& let Err(e) = secrets
|
||||
.create(
|
||||
user_id,
|
||||
crate::secrets::CreateSecretParams {
|
||||
name: secret_name.clone(),
|
||||
value: secrecy::SecretString::from(api_key.to_string()),
|
||||
provider: Some(provider_id.clone()),
|
||||
expires_at: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to migrate key for builtin '{}': {}", provider_id, e);
|
||||
continue;
|
||||
}
|
||||
if let Some(o) = sanitized
|
||||
.get_mut(provider_id)
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
o.remove("api_key");
|
||||
}
|
||||
migrated += 1;
|
||||
}
|
||||
}
|
||||
if migrated > 0 {
|
||||
let _ = settings_store
|
||||
.set_setting(
|
||||
user_id,
|
||||
"llm_builtin_overrides",
|
||||
&serde_json::Value::Object(sanitized),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate custom providers
|
||||
let before = migrated;
|
||||
if let Some(arr) = settings_map
|
||||
.get("llm_custom_providers")
|
||||
.and_then(|v| v.as_array())
|
||||
{
|
||||
let mut sanitized = arr.clone();
|
||||
for (idx, provider_val) in arr.iter().enumerate() {
|
||||
let provider_id = provider_val
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if provider_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(api_key) = provider_val.get("api_key").and_then(|v| v.as_str()) {
|
||||
if api_key.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let secret_name = format!("llm_custom_{}_api_key", provider_id);
|
||||
if !secrets.exists(user_id, &secret_name).await.unwrap_or(false)
|
||||
&& let Err(e) = secrets
|
||||
.create(
|
||||
user_id,
|
||||
crate::secrets::CreateSecretParams {
|
||||
name: secret_name.clone(),
|
||||
value: secrecy::SecretString::from(api_key.to_string()),
|
||||
provider: Some(provider_id.to_string()),
|
||||
expires_at: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to migrate key for custom '{}': {}", provider_id, e);
|
||||
continue;
|
||||
}
|
||||
if let Some(o) = sanitized[idx].as_object_mut() {
|
||||
o.remove("api_key");
|
||||
}
|
||||
migrated += 1;
|
||||
}
|
||||
}
|
||||
if migrated > before {
|
||||
let _ = settings_store
|
||||
.set_setting(
|
||||
user_id,
|
||||
"llm_custom_providers",
|
||||
&serde_json::Value::Array(sanitized),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
if migrated > 0 {
|
||||
tracing::info!(
|
||||
"Migrated {} plaintext LLM API key(s) to encrypted secrets store",
|
||||
migrated
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -234,7 +234,6 @@ fn is_transient(err: &LlmError) -> bool {
|
||||
LlmError::RequestFailed { .. }
|
||||
| LlmError::RateLimited { .. }
|
||||
| LlmError::InvalidResponse { .. }
|
||||
| LlmError::EmptyResponse { .. }
|
||||
| LlmError::SessionExpired { .. }
|
||||
| LlmError::SessionRenewalFailed { .. }
|
||||
| LlmError::Http(_)
|
||||
|
||||
@@ -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 },
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -93,6 +93,8 @@ pub async fn create_llm_provider(
|
||||
) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||
let timeout = config.request_timeout_secs;
|
||||
|
||||
tracing::info!(backend = %config.backend, "Creating LLM provider");
|
||||
|
||||
if config.backend == "nearai" || config.backend == "near_ai" || config.backend == "near" {
|
||||
return create_llm_provider_with_config(&config.nearai, session, timeout);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(_)
|
||||
|
||||
+79
-55
@@ -301,6 +301,10 @@ fn convert_messages(messages: &[ChatMessage]) -> (Option<String>, Vec<RigMessage
|
||||
}
|
||||
crate::llm::Role::User => {
|
||||
if msg.content_parts.is_empty() {
|
||||
// Skip empty user messages — some providers (e.g. Kimi) reject "content": ""
|
||||
if msg.content.is_empty() {
|
||||
continue;
|
||||
}
|
||||
history.push(RigMessage::user(&msg.content));
|
||||
} else {
|
||||
// Build multimodal user message with text + image parts
|
||||
@@ -364,6 +368,12 @@ fn convert_messages(messages: &[ChatMessage]) -> (Option<String>, Vec<RigMessage
|
||||
history.push(RigMessage::assistant(&msg.content));
|
||||
}
|
||||
} else {
|
||||
// Skip empty assistant messages — these occur when thinking-tag stripping
|
||||
// leaves a blank response; sending "content": "" causes 400 on strict
|
||||
// OpenAI-compatible providers (e.g. Kimi).
|
||||
if msg.content.is_empty() {
|
||||
continue;
|
||||
}
|
||||
history.push(RigMessage::assistant(&msg.content));
|
||||
}
|
||||
}
|
||||
@@ -601,14 +611,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;
|
||||
@@ -1456,6 +1463,70 @@ mod tests {
|
||||
assert_eq!(history.len(), 2);
|
||||
}
|
||||
|
||||
/// Empty user messages (e.g. after thinking-tag stripping) must be skipped.
|
||||
/// Strict providers like Kimi return 400 when "content": "" is sent.
|
||||
#[test]
|
||||
fn test_empty_user_message_is_skipped() {
|
||||
let empty = ChatMessage::user("");
|
||||
let non_empty = ChatMessage::user("hello");
|
||||
let messages = vec![empty, non_empty];
|
||||
let (_preamble, history) = convert_messages(&messages);
|
||||
|
||||
assert_eq!(history.len(), 1, "empty user message must be dropped");
|
||||
match &history[0] {
|
||||
RigMessage::User { content } => {
|
||||
assert_eq!(content.len(), 1);
|
||||
let first = content.iter().next().expect("one content item");
|
||||
match first {
|
||||
UserContent::Text(t) => assert_eq!(t.text, "hello"),
|
||||
other => panic!("expected Text, got {:?}", other),
|
||||
}
|
||||
}
|
||||
other => panic!("expected User message, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Empty assistant messages (e.g. after thinking-tag stripping) must be skipped.
|
||||
#[test]
|
||||
fn test_empty_assistant_message_is_skipped() {
|
||||
let empty_asst = ChatMessage {
|
||||
role: crate::llm::Role::Assistant,
|
||||
content: String::new(),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
content_parts: vec![],
|
||||
};
|
||||
let non_empty = ChatMessage::user("hi");
|
||||
let messages = vec![empty_asst, non_empty];
|
||||
let (_preamble, history) = convert_messages(&messages);
|
||||
|
||||
assert_eq!(history.len(), 1, "empty assistant message must be dropped");
|
||||
assert!(matches!(history[0], RigMessage::User { .. }));
|
||||
}
|
||||
|
||||
/// A conversation mixing normal and empty messages: only non-empty ones survive.
|
||||
#[test]
|
||||
fn test_mixed_empty_and_non_empty_messages_filtered_correctly() {
|
||||
let user1 = ChatMessage::user("first");
|
||||
let empty_asst = ChatMessage {
|
||||
role: crate::llm::Role::Assistant,
|
||||
content: String::new(),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
content_parts: vec![],
|
||||
};
|
||||
let user2 = ChatMessage::user("");
|
||||
let asst = ChatMessage::assistant("response");
|
||||
let messages = vec![user1, empty_asst, user2, asst];
|
||||
let (_preamble, history) = convert_messages(&messages);
|
||||
|
||||
assert_eq!(history.len(), 2, "only non-empty messages should survive");
|
||||
assert!(matches!(history[0], RigMessage::User { .. }));
|
||||
assert!(matches!(history[1], RigMessage::Assistant { .. }));
|
||||
}
|
||||
|
||||
// -- normalized_tool_call_id tests --
|
||||
|
||||
#[test]
|
||||
@@ -1518,51 +1589,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());
|
||||
}
|
||||
}
|
||||
|
||||
+30
-50
@@ -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,9 @@ 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 ss) = components.secrets_store {
|
||||
gw = gw.with_secrets_store(Arc::clone(ss));
|
||||
}
|
||||
if let Some(ref jm) = container_job_manager {
|
||||
gw = gw.with_job_manager(Arc::clone(jm));
|
||||
@@ -819,7 +794,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
|
||||
|
||||
+158
-42
@@ -1,14 +1,62 @@
|
||||
//! User settings persistence.
|
||||
//!
|
||||
//! Stores user preferences in ~/.ironclaw/settings.json.
|
||||
//! Settings are loaded with env var > settings.json > default priority.
|
||||
//! Stores user preferences in `~/.ironclaw` (JSON/TOML) and, for some values,
|
||||
//! in the database. Precedence between database values, environment variables,
|
||||
//! on-disk config, and built-in defaults is determined on a per-setting basis
|
||||
//! by the corresponding resolver. LLM provider settings (backend, model,
|
||||
//! api_key, base_url) prefer DB values over environment variables, as
|
||||
//! documented on their respective types.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
|
||||
/// A custom LLM provider defined by the user through the web UI.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CustomLlmProviderSettings {
|
||||
/// Unique identifier (used as `llm_backend` value).
|
||||
pub id: String,
|
||||
/// Display name.
|
||||
pub name: String,
|
||||
/// Adapter protocol: "open_ai_completions", "anthropic", "ollama".
|
||||
pub adapter: String,
|
||||
/// Base URL for the API endpoint.
|
||||
#[serde(default)]
|
||||
pub base_url: Option<String>,
|
||||
/// Default model identifier.
|
||||
#[serde(default)]
|
||||
pub default_model: Option<String>,
|
||||
/// Optional API key stored inline.
|
||||
#[serde(default)]
|
||||
pub api_key: Option<String>,
|
||||
/// Whether this is a built-in provider (should always be false for custom).
|
||||
#[serde(default)]
|
||||
pub builtin: bool,
|
||||
}
|
||||
|
||||
/// Per-provider overrides for built-in LLM providers (API key and/or model).
|
||||
///
|
||||
/// Stored as `llm_builtin_overrides` in the settings store, keyed by provider ID
|
||||
/// (e.g. `"openai"`, `"gemini"`). Resolved at startup during `LlmConfig::resolve()`.
|
||||
///
|
||||
/// Note: The global `selected_model` (if set) takes precedence over these
|
||||
/// per-provider overrides, which in turn take precedence over environment variables.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct LlmBuiltinOverride {
|
||||
/// API key override. Takes precedence over environment variables.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub api_key: Option<String>,
|
||||
/// Model override. Takes precedence over environment variables but not `selected_model`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
/// Base URL override. Takes precedence over environment variables.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub base_url: Option<String>,
|
||||
}
|
||||
|
||||
/// User settings persisted to disk.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Settings {
|
||||
@@ -59,6 +107,14 @@ pub struct Settings {
|
||||
#[serde(default)]
|
||||
pub llm_backend: Option<String>,
|
||||
|
||||
/// Custom LLM providers defined by the user through the web UI.
|
||||
#[serde(default)]
|
||||
pub llm_custom_providers: Vec<CustomLlmProviderSettings>,
|
||||
|
||||
/// Per-provider overrides for built-in providers (API key and/or model).
|
||||
#[serde(default)]
|
||||
pub llm_builtin_overrides: HashMap<String, LlmBuiltinOverride>,
|
||||
|
||||
/// Ollama base URL (when llm_backend = "ollama").
|
||||
#[serde(default)]
|
||||
pub ollama_base_url: Option<String>,
|
||||
@@ -269,6 +325,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 +398,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,
|
||||
@@ -841,7 +902,8 @@ impl Settings {
|
||||
let content = format!(
|
||||
"# IronClaw configuration file.\n\
|
||||
#\n\
|
||||
# Priority: env var > this file > database settings > defaults.\n\
|
||||
# Priority varies by subsystem. LLM: DB > env > this file > defaults.\n\
|
||||
# Most others: env > DB > this file > defaults.\n\
|
||||
# Uncomment and edit values to override defaults.\n\
|
||||
# Run `ironclaw config init` to regenerate this file.\n\
|
||||
#\n\
|
||||
@@ -1325,56 +1387,53 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: TOML overlay must not clobber a DB-persisted selected_model
|
||||
/// when the TOML file matches the DB. This is the normal case after /model
|
||||
/// successfully writes to both DB and TOML.
|
||||
/// TOML is loaded as a base, then DB is merged on top (DB wins).
|
||||
/// When both agree, the result matches.
|
||||
#[test]
|
||||
fn toml_overlay_preserves_matching_model() {
|
||||
// DB settings with new model from /model command.
|
||||
let mut db_settings = Settings {
|
||||
fn toml_and_db_matching_model_preserved() {
|
||||
// from_db_with_toml: TOML base, then DB merged on top.
|
||||
let mut toml_base = Settings {
|
||||
selected_model: Some("new-model".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let db_overlay = Settings {
|
||||
llm_backend: Some("nearai".to_string()),
|
||||
selected_model: Some("new-model".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// TOML also updated by /model command to the same value.
|
||||
let toml_settings = Settings {
|
||||
selected_model: Some("new-model".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
db_settings.merge_from(&toml_settings);
|
||||
toml_base.merge_from(&db_overlay);
|
||||
assert_eq!(
|
||||
db_settings.selected_model,
|
||||
toml_base.selected_model,
|
||||
Some("new-model".to_string()),
|
||||
"TOML overlay must not clobber matching model"
|
||||
"matching values: result should be the shared value"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: when /model updates DB but TOML write fails, a stale TOML
|
||||
/// file would overwrite the DB value. This test documents the priority:
|
||||
/// TOML > DB (by design). persist_selected_model MUST update the TOML.
|
||||
/// Regression: when TOML has a stale model but DB has been updated via
|
||||
/// /model command, DB must win. This matches from_db_with_toml where
|
||||
/// TOML is loaded first as base, then DB is merged on top.
|
||||
#[test]
|
||||
fn stale_toml_overwrites_db_model() {
|
||||
// DB has the new model from /model.
|
||||
let mut db_settings = Settings {
|
||||
selected_model: Some("new-model".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// TOML still has the old model (write failed or was not attempted).
|
||||
let stale_toml = Settings {
|
||||
fn db_model_wins_over_stale_toml() {
|
||||
// TOML base with old model.
|
||||
let mut toml_base = Settings {
|
||||
selected_model: Some("old-model".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
db_settings.merge_from(&stale_toml);
|
||||
// This documents the current priority: TOML wins over DB.
|
||||
// The fix in persist_selected_model ensures TOML is always updated.
|
||||
// DB has the new model from /model command.
|
||||
let db_overlay = Settings {
|
||||
selected_model: Some("new-model".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// from_db_with_toml: TOML first, then DB merged on top.
|
||||
toml_base.merge_from(&db_overlay);
|
||||
assert_eq!(
|
||||
db_settings.selected_model,
|
||||
Some("old-model".to_string()),
|
||||
"TOML overlay has higher priority than DB (by design)"
|
||||
toml_base.selected_model,
|
||||
Some("new-model".to_string()),
|
||||
"DB selected_model must win over stale TOML value"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1403,24 +1462,20 @@ mod tests {
|
||||
assert_eq!(reloaded.selected_model, Some("new-model".to_string()));
|
||||
}
|
||||
|
||||
/// Regression: /model must create config.toml when it doesn't exist, so the
|
||||
/// model survives restarts. Previously the Ok(None) case was a no-op.
|
||||
/// save_toml / load_toml round-trip for selected_model.
|
||||
#[test]
|
||||
fn toml_created_when_missing_for_model_persist() {
|
||||
fn toml_save_and_load_round_trip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
|
||||
// No config.toml yet (fresh install, no wizard).
|
||||
assert!(Settings::load_toml(&path).unwrap().is_none());
|
||||
|
||||
// Simulate what persist_selected_model now does for the Ok(None) case.
|
||||
let settings = Settings {
|
||||
selected_model: Some("new-model".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
settings.save_toml(&path).unwrap();
|
||||
|
||||
// Verify the model survived.
|
||||
let loaded = Settings::load_toml(&path).unwrap().unwrap();
|
||||
assert_eq!(loaded.selected_model, Some("new-model".to_string()));
|
||||
}
|
||||
@@ -2378,4 +2433,65 @@ mod tests {
|
||||
assert_eq!(current.embeddings.provider, "nearai");
|
||||
assert_eq!(current.embeddings.model, "text-embedding-3-large");
|
||||
}
|
||||
|
||||
/// DB values must win over TOML values when both set the same field.
|
||||
///
|
||||
/// This mirrors the merge order in `Config::from_db_with_toml`:
|
||||
/// TOML is loaded as the base, then DB is merged on top.
|
||||
#[test]
|
||||
fn db_settings_win_over_toml_settings() {
|
||||
// Simulate TOML base: has llm_backend and selected_model
|
||||
let mut base = Settings {
|
||||
llm_backend: Some("openai".to_string()),
|
||||
selected_model: Some("toml-model".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Simulate DB overlay: has different llm_backend and selected_model
|
||||
let db = Settings {
|
||||
llm_backend: Some("anthropic".to_string()),
|
||||
selected_model: Some("db-model".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Merge DB on top of TOML (same order as from_db_with_toml)
|
||||
base.merge_from(&db);
|
||||
|
||||
assert_eq!(
|
||||
base.llm_backend.as_deref(),
|
||||
Some("anthropic"),
|
||||
"DB llm_backend must win over TOML"
|
||||
);
|
||||
assert_eq!(
|
||||
base.selected_model.as_deref(),
|
||||
Some("db-model"),
|
||||
"DB selected_model must win over TOML"
|
||||
);
|
||||
}
|
||||
|
||||
/// When DB has no value (default), TOML value should be preserved.
|
||||
#[test]
|
||||
fn toml_settings_used_when_db_has_no_value() {
|
||||
let mut base = Settings {
|
||||
llm_backend: Some("openai".to_string()),
|
||||
selected_model: Some("toml-model".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// DB has no llm_backend or selected_model (both default/None)
|
||||
let db = Settings::default();
|
||||
|
||||
base.merge_from(&db);
|
||||
|
||||
assert_eq!(
|
||||
base.llm_backend.as_deref(),
|
||||
Some("openai"),
|
||||
"TOML llm_backend should be preserved when DB has no value"
|
||||
);
|
||||
assert_eq!(
|
||||
base.selected_model.as_deref(),
|
||||
Some("toml-model"),
|
||||
"TOML selected_model should be preserved when DB has no value"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-49
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+4
-140
@@ -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(¶ms, "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,
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 = ¤t[..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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)}"
|
||||
)
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
@@ -561,10 +559,10 @@ fn gateway_state_has_multi_tenant_fields() {
|
||||
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 +593,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 +600,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 +619,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,
|
||||
@@ -637,10 +634,9 @@ async fn start_owner_scoped_sender_server() -> (
|
||||
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 +771,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 +1005,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,
|
||||
@@ -1023,11 +1020,10 @@ async fn start_multi_user_server_with_db() -> (
|
||||
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");
|
||||
|
||||
|
||||
@@ -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),
|
||||
@@ -219,7 +220,6 @@ async fn start_test_server_with_provider(
|
||||
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 +227,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 +705,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!
|
||||
@@ -719,7 +720,6 @@ async fn test_no_llm_provider_returns_503() {
|
||||
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 +727,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 +765,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))
|
||||
|
||||
@@ -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)),
|
||||
@@ -241,7 +242,6 @@ impl GatewayWorkflowHarness {
|
||||
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 +300,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");
|
||||
|
||||
@@ -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,
|
||||
@@ -66,7 +67,6 @@ async fn start_test_server() -> (
|
||||
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 +74,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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user