diff --git a/.env.example b/.env.example
index 55c3adb5..873931d7 100644
--- a/.env.example
+++ b/.env.example
@@ -4,7 +4,7 @@ DATABASE_POOL_SIZE=10
# LLM Provider
# LLM_BACKEND=nearai # default
-# Possible values: nearai, ollama, openai_compatible, openai, anthropic, tinfoil
+# Possible values: nearai, ollama, openai_compatible, openai, anthropic, github_copilot, tinfoil, openai_codex
# LLM_REQUEST_TIMEOUT_SECS=120 # Increase for local LLMs (Ollama, vLLM, LM Studio)
# === Anthropic Direct ===
@@ -24,6 +24,17 @@ DATABASE_POOL_SIZE=10
# LLM_USE_CODEX_AUTH=true
# CODEX_AUTH_PATH=~/.codex/auth.json
+# === GitHub Copilot ===
+# Uses the OAuth token from your Copilot IDE sign-in (for example
+# ~/.config/github-copilot/apps.json on Linux/macOS), or run `ironclaw onboard`
+# and choose the GitHub device login flow.
+# LLM_BACKEND=github_copilot
+# GITHUB_COPILOT_TOKEN=gho_...
+# GITHUB_COPILOT_MODEL=gpt-4o
+# IronClaw injects standard VS Code Copilot headers automatically.
+# Optional advanced headers for custom overrides:
+# GITHUB_COPILOT_EXTRA_HEADERS=Copilot-Integration-Id:vscode-chat
+
# === NEAR AI (Chat Completions API) ===
# Two auth modes:
# 1. Session token (default): Uses browser OAuth (GitHub/Google) on first run.
@@ -31,7 +42,7 @@ DATABASE_POOL_SIZE=10
# Base URL defaults to https://private.near.ai
# 2. API key: Set NEARAI_API_KEY to use API key auth from cloud.near.ai.
# Base URL defaults to https://cloud-api.near.ai
-NEARAI_MODEL=zai-org/GLM-5-FP8
+NEARAI_MODEL=Qwen/Qwen3.5-122B-A10B
NEARAI_BASE_URL=https://private.near.ai
NEARAI_AUTH_URL=https://private.near.ai
# NEARAI_SESSION_TOKEN=sess_... # hosting providers: set this
@@ -78,7 +89,7 @@ NEARAI_AUTH_URL=https://private.near.ai
# === MiniMax ===
# LLM_BACKEND=minimax
# MINIMAX_API_KEY=...
-# MINIMAX_MODEL=MiniMax-M2.5
+# MINIMAX_MODEL=MiniMax-M2.7
# MINIMAX_BASE_URL=https://api.minimax.io/v1 # default (global); use https://api.minimaxi.com/v1 for China
# === Anthropic Direct ===
@@ -92,6 +103,13 @@ NEARAI_AUTH_URL=https://private.near.ai
# long = 1-hour TTL, 2.0× (200%) write surcharge
# ANTHROPIC_CACHE_RETENTION=short
+# === OpenAI Codex (ChatGPT subscription, OAuth) ===
+# LLM_BACKEND=openai_codex
+# OPENAI_CODEX_MODEL=gpt-5.3-codex # default
+# OPENAI_CODEX_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann # override (rare)
+# OPENAI_CODEX_AUTH_URL=https://auth.openai.com # override (rare)
+# OPENAI_CODEX_API_URL=https://chatgpt.com/backend-api/codex # override (rare)
+
# For full provider setup guide see docs/LLM_PROVIDERS.md
# Channel Configuration
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
index e7371677..2f885b16 100644
--- a/.github/workflows/coverage.yml
+++ b/.github/workflows/coverage.yml
@@ -174,7 +174,7 @@ jobs:
- name: Run E2E tests
run: |
- pytest tests/e2e/ -v -x --timeout=120
+ pytest tests/e2e/ -v --timeout=120
env:
RUST_LOG: ironclaw=info
RUST_BACKTRACE: "1"
diff --git a/.github/workflows/regression-test-check.yml b/.github/workflows/regression-test-check.yml
index 6d97c4ce..ef1a4d92 100644
--- a/.github/workflows/regression-test-check.yml
+++ b/.github/workflows/regression-test-check.yml
@@ -43,12 +43,42 @@ jobs:
fi
fi
- if [ "$IS_FIX" = false ]; then
- echo "Not a fix PR — skipping regression test check."
+ # --- 1b. Does this PR touch high-risk state machine or resilience code? ---
+ CHANGED_FILES=$(git diff --name-only "${BASE_REF}...${HEAD_REF}")
+
+ TOUCHES_HIGH_RISK=false
+ HIGH_RISK_PATTERNS=(
+ "src/context/state.rs"
+ "src/agent/session.rs"
+ "src/llm/circuit_breaker.rs"
+ "src/llm/retry.rs"
+ "src/llm/failover.rs"
+ "src/agent/self_repair.rs"
+ "src/agent/agentic_loop.rs"
+ "src/tools/execute.rs"
+ "crates/ironclaw_safety/src/"
+ )
+
+ for pattern in "${HIGH_RISK_PATTERNS[@]}"; do
+ if echo "$CHANGED_FILES" | grep -q "$pattern"; then
+ TOUCHES_HIGH_RISK=true
+ echo "High-risk file matched: $pattern"
+ break
+ fi
+ done
+
+ # Skip only if NEITHER condition holds — no double-firing on fix PRs
+ if [ "$IS_FIX" = false ] && [ "$TOUCHES_HIGH_RISK" = false ]; then
+ echo "Not a fix PR and no high-risk files changed — skipping."
exit 0
fi
- echo "Fix PR detected."
+ if [ "$IS_FIX" = true ]; then
+ echo "Fix PR detected."
+ fi
+ if [ "$TOUCHES_HIGH_RISK" = true ]; then
+ echo "High-risk state machine or resilience code modified."
+ fi
# --- 2. Skip label or commit message marker ---
if grep -qF ',skip-regression-check,' <<< ",$PR_LABELS,"; then
@@ -63,8 +93,6 @@ jobs:
fi
# --- 3. Exempt static-only / docs-only changes ---
- CHANGED_FILES=$(git diff --name-only "${BASE_REF}...${HEAD_REF}")
-
if [ -z "$CHANGED_FILES" ]; then
echo "No changed files — skipping."
exit 0
@@ -110,5 +138,12 @@ jobs:
fi
# --- 5. No tests found ---
- echo "::warning::This PR looks like a bug fix but contains no test changes. Every fix should include a regression test. Add a #[test] or #[tokio::test], or apply the 'skip-regression-check' label if not feasible."
+ if [ "$IS_FIX" = true ]; then
+ echo "::warning::This PR looks like a bug fix but contains no test changes."
+ fi
+ if [ "$TOUCHES_HIGH_RISK" = true ]; then
+ echo "::warning::This PR modifies high-risk state machine or resilience code but includes no test changes."
+ fi
+ echo "::warning::Please add tests exercising the changed behavior, or apply the 'skip-regression-check' label if not feasible."
exit 1
+
diff --git a/AGENTS.md b/AGENTS.md
index 7be35afb..cc5e7cff 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,6 +1,94 @@
# Agent Rules
-## Feature Parity Update Policy
+## Purpose and Precedence
+- `AGENTS.md` is the quick-start contract for coding agents. It is not the full architecture spec.
+- Read the relevant subsystem spec before changing a complex area. When a repo spec exists, treat it as authoritative.
+Start with these deeper docs as needed:
+- `CLAUDE.md`
+- `src/agent/CLAUDE.md`
+- `src/channels/web/CLAUDE.md`
+- `src/db/CLAUDE.md`
+- `src/llm/CLAUDE.md`
+- `src/setup/README.md`
+- `src/tools/README.md`
+- `src/workspace/README.md`
+- `src/NETWORK_SECURITY.md`
+- `tests/e2e/CLAUDE.md`
+
+## Architecture Mental Model
+
+- Channels normalize external input into `IncomingMessage`; `ChannelManager` merges all active channel streams.
+- `Agent` owns session/thread/turn handling, submission parsing, the LLM/tool loop, approvals, routines, and background runtime behavior.
+- `AppBuilder` is the composition root that wires database, secrets, LLMs, tools, workspace, extensions, skills, hooks, and cost controls before the agent starts.
+- The web gateway is a browser-facing API/UI layered on top of the same agent/session/tool systems, not a separate product path.
+
+## Where to Work
+
+- Agent/runtime behavior: `src/agent/`
+- Web gateway/API/SSE/WebSocket: `src/channels/web/`
+- Persistence and DB abstractions: `src/db/`
+- Setup/onboarding/configuration flow: `src/setup/`
+- LLM providers and routing: `src/llm/`
+- Workspace, memory, embeddings, search: `src/workspace/`
+- Extensions, tools, channels, MCP, WASM: `src/extensions/`, `src/tools/`, `src/channels/`
+
+## Ownership and Composition Rules
+
+- Keep `src/main.rs` and `src/app.rs` orchestration-focused. Do not move module-owned logic into entrypoints.
+- Module-specific initialization should live in the owning module behind a public factory/helper, not be reimplemented ad hoc.
+- Keep feature-flag branching inside the module that owns the abstraction whenever possible.
+- Prefer extending existing traits and registries over hardcoding one-off integration paths.
+
+## Repo-Wide Coding Rules
+
+- Avoid `.unwrap()` and `.expect()` in production; prefer proper error handling. They are fine in tests, and in production only for truly infallible invariants (e.g., literals/regexes) with a safety comment.
+- Keep clippy clean with zero warnings.
+- Prefer `crate::` imports for cross-module references.
+- Use strong types and enums over stringly-typed control flow when the shape is known.
+
+## Database, Setup, and Config Rules
+
+- New persistence behavior must support both PostgreSQL and libSQL.
+- Add new DB operations to the shared DB trait first, then implement both backends.
+- Treat bootstrap config, DB-backed settings, and encrypted secrets as distinct layers; do not collapse them casually.
+- If onboarding or setup behavior changes, update `src/setup/README.md` in the same branch.
+- Do not break config precedence, bootstrap env loading, DB-backed config reload, or post-secrets LLM re-resolution.
+
+## Security and Runtime Invariants
+
+- Review any change touching listeners, routes, auth, secrets, sandboxing, approvals, or outbound HTTP with a security mindset.
+- Do not weaken bearer-token auth, webhook auth, CORS/origin checks, body limits, rate limits, allowlists, or secret-handling guarantees.
+- Treat Docker containers and external services as untrusted.
+- Session/thread/turn state matters. Submission parsing happens before normal chat handling.
+- Skills are selected deterministically. Tool approval and auth flows are special paths and must not be mixed into normal chat history carelessly.
+- Persistent memory is the workspace system, not just transcript storage; preserve file-like semantics, chunking/search behavior, and identity/system-prompt loading.
+
+## Tools, Channels, and Extensions
+
+- Use a built-in Rust tool for core internal capabilities tightly coupled to the runtime.
+- Use WASM tools or WASM channels for sandboxed extensions and plugin-style integrations.
+- Use MCP for external server integrations when the capability belongs outside the main binary.
+- Preserve extension lifecycle expectations: install, authenticate/configure, activate, remove.
+
+## Docs, Parity, and Testing
+
+- If behavior changes, update the relevant docs/specs in the same branch.
- If you change implementation status for any feature tracked in `FEATURE_PARITY.md`, update that file in the same branch.
- Do not open a PR that changes feature behavior without checking `FEATURE_PARITY.md` for needed status updates (`❌`, `🚧`, `✅`, notes, and priorities).
+- Add the narrowest tests that validate the change: unit tests for local logic, integration tests for runtime/DB/routing behavior, and E2E or trace coverage for gateway, approvals, extensions, or other user-visible flows.
+
+## Risk and Change Discipline
+
+- Keep changes scoped; avoid broad refactors unless the task truly requires them.
+- Security, database schema, runtime, worker, CI, and secrets changes are high-risk. Call out rollback risks, compatibility concerns, and hidden side effects.
+- Preserve existing defaults unless the task explicitly changes them.
+- Avoid unrelated file churn and generated-file edits unless required.
+- Respect a dirty worktree and never revert user changes you did not make.
+
+## Before Finishing
+
+- Confirm whether behavior changes require updates to `FEATURE_PARITY.md`, specs, API docs, or `CHANGELOG.md`.
+- Run the most targeted tests/checks that cover the change.
+- Re-check security-sensitive paths when touching auth, secrets, network listeners, sandboxing, or approvals.
+- Keep the final diff scoped to the task.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 36c4d103..6aad4993 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,153 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.19.0](https://github.com/nearai/ironclaw/compare/v0.18.0...v0.19.0) - 2026-03-17
+
+### Added
+
+- verify telegram owner during hot activation ([#1157](https://github.com/nearai/ironclaw/pull/1157))
+- *(config)* unify config resolution with Settings fallback (Phase 2, #1119) ([#1203](https://github.com/nearai/ironclaw/pull/1203))
+- *(sandbox)* add retry logic for transient container failures ([#1232](https://github.com/nearai/ironclaw/pull/1232))
+- *(heartbeat)* fire_at time-of-day scheduling with IANA timezone ([#1029](https://github.com/nearai/ironclaw/pull/1029))
+- Reuse Codex CLI OAuth tokens for ChatGPT backend LLM calls ([#693](https://github.com/nearai/ironclaw/pull/693))
+- add pre-push git hook with delta lint mode ([#833](https://github.com/nearai/ironclaw/pull/833))
+- *(cli)* add `logs` command for gateway log access ([#1105](https://github.com/nearai/ironclaw/pull/1105))
+- add Feishu/Lark WASM channel plugin ([#1110](https://github.com/nearai/ironclaw/pull/1110))
+- add Criterion benchmarks for safety layer hot paths ([#836](https://github.com/nearai/ironclaw/pull/836))
+- *(routines)* human-readable cron schedule summaries in web UI ([#1154](https://github.com/nearai/ironclaw/pull/1154))
+- *(web)* add follow-up suggestion chips and ghost text ([#1156](https://github.com/nearai/ironclaw/pull/1156))
+- *(ci)* include commit history in staging promotion PRs ([#952](https://github.com/nearai/ironclaw/pull/952))
+- *(tools)* add reusable sensitive JSON redaction helper ([#457](https://github.com/nearai/ironclaw/pull/457))
+- configurable hybrid search fusion strategy ([#234](https://github.com/nearai/ironclaw/pull/234))
+- *(cli)* add cron subcommand for managing scheduled routines ([#1017](https://github.com/nearai/ironclaw/pull/1017))
+- adds context-llm tool support ([#616](https://github.com/nearai/ironclaw/pull/616))
+- *(web-chat)* add hover copy button for user/assistant messages ([#948](https://github.com/nearai/ironclaw/pull/948))
+- add Slack approval buttons for tool execution in DMs ([#796](https://github.com/nearai/ironclaw/pull/796))
+- enhance HTTP tool parameter parsing ([#911](https://github.com/nearai/ironclaw/pull/911))
+- *(routines)* enable tool access in lightweight routine execution ([#257](https://github.com/nearai/ironclaw/pull/257)) ([#730](https://github.com/nearai/ironclaw/pull/730))
+- add MiniMax as a built-in LLM provider ([#940](https://github.com/nearai/ironclaw/pull/940))
+- *(cli)* add `ironclaw channels list` subcommand ([#933](https://github.com/nearai/ironclaw/pull/933))
+- *(cli)* add `ironclaw skills list/search/info` subcommands ([#918](https://github.com/nearai/ironclaw/pull/918))
+- add cargo-deny for supply chain safety ([#834](https://github.com/nearai/ironclaw/pull/834))
+- *(setup)* display ASCII art banner during onboarding ([#851](https://github.com/nearai/ironclaw/pull/851))
+- *(extensions)* unify auth and configure into single entrypoint ([#677](https://github.com/nearai/ironclaw/pull/677))
+- *(i18n)* Add internationalization support with Chinese and English translations ([#929](https://github.com/nearai/ironclaw/pull/929))
+- Import OpenClaw memory, history and settings ([#903](https://github.com/nearai/ironclaw/pull/903))
+
+### Fixed
+
+- jobs limit ([#1274](https://github.com/nearai/ironclaw/pull/1274))
+- misleading UI message ([#1265](https://github.com/nearai/ironclaw/pull/1265))
+- bump channel registry versions for promotion ([#1264](https://github.com/nearai/ironclaw/pull/1264))
+- cover staging CI all-features and routine batch regressions ([#1256](https://github.com/nearai/ironclaw/pull/1256))
+- resolve merge conflict fallout and missing config fields
+- web/CLI routine mutations do not refresh live event trigger cache ([#1255](https://github.com/nearai/ironclaw/pull/1255))
+- *(jobs)* make completed->completed transition idempotent to prevent race errors ([#1068](https://github.com/nearai/ironclaw/pull/1068))
+- *(llm)* persist refreshed Anthropic OAuth token after Keychain re-read ([#1213](https://github.com/nearai/ironclaw/pull/1213))
+- *(worker)* prevent orphaned tool_results and fix parallel merging ([#1069](https://github.com/nearai/ironclaw/pull/1069))
+- Telegram bot token validation fails intermittently (HTTP 404) ([#1166](https://github.com/nearai/ironclaw/pull/1166))
+- *(security)* prevent metadata spoofing of internal job monitor flag ([#1195](https://github.com/nearai/ironclaw/pull/1195))
+- *(security)* default webhook server to loopback when tunnel is configured ([#1194](https://github.com/nearai/ironclaw/pull/1194))
+- *(auth)* avoid false success and block chat during pending auth ([#1111](https://github.com/nearai/ironclaw/pull/1111))
+- *(config)* unify ChannelsConfig resolution to env > settings > default ([#1124](https://github.com/nearai/ironclaw/pull/1124))
+- *(web-chat)* normalize chat copy to plain text ([#1114](https://github.com/nearai/ironclaw/pull/1114))
+- *(skill)* treat empty url param as absent when installing skills ([#1128](https://github.com/nearai/ironclaw/pull/1128))
+- preserve AuthError type in oauth_http_client cache ([#1152](https://github.com/nearai/ironclaw/pull/1152))
+- *(web)* prevent Safari IME composition Enter from sending message ([#1140](https://github.com/nearai/ironclaw/pull/1140))
+- *(mcp)* handle 400 auth errors, clear auth mode after OAuth, trim tokens ([#1158](https://github.com/nearai/ironclaw/pull/1158))
+- eliminate panic paths in production code ([#1184](https://github.com/nearai/ironclaw/pull/1184))
+- N+1 query pattern in event trigger loop (routine_engine) ([#1163](https://github.com/nearai/ironclaw/pull/1163))
+- *(llm)* add stop_sequences parity for tool completions ([#1170](https://github.com/nearai/ironclaw/pull/1170))
+- *(channels)* use live owner binding during wasm hot activation ([#1171](https://github.com/nearai/ironclaw/pull/1171))
+- Non-transactional multi-step context updates between metadata/to… ([#1161](https://github.com/nearai/ironclaw/pull/1161))
+- *(webhook)* avoid lock-held awaits in server lifecycle paths ([#1168](https://github.com/nearai/ironclaw/pull/1168))
+- Google Sheets returns 403 PERMISSION_DENIED after completing OAuth ([#1164](https://github.com/nearai/ironclaw/pull/1164))
+- HTTP webhook secret transmitted in request body rather than via header, docs inconsistency and security concern ([#1162](https://github.com/nearai/ironclaw/pull/1162))
+- *(ci)* exclude ironclaw_safety from release automation ([#1146](https://github.com/nearai/ironclaw/pull/1146))
+- *(registry)* bump versions for github, web-search, and discord extensions ([#1106](https://github.com/nearai/ironclaw/pull/1106))
+- *(mcp)* address 14 audit findings across MCP module ([#1094](https://github.com/nearai/ironclaw/pull/1094))
+- *(http)* replace .expect() with match in webhook handler ([#1133](https://github.com/nearai/ironclaw/pull/1133))
+- *(time)* treat empty timezone string as absent ([#1127](https://github.com/nearai/ironclaw/pull/1127))
+- 5 critical/high-priority bugs (auth bypass, relay failures, unbounded recursion, context growth) ([#1083](https://github.com/nearai/ironclaw/pull/1083))
+- *(ci)* checkout promotion PR head for metadata refresh ([#1097](https://github.com/nearai/ironclaw/pull/1097))
+- *(ci)* add missing attachments field and crates/ dir to Dockerfiles ([#1100](https://github.com/nearai/ironclaw/pull/1100))
+- *(registry)* bump telegram channel version for capabilities change ([#1064](https://github.com/nearai/ironclaw/pull/1064))
+- *(ci)* repair staging promotion workflow behavior ([#1091](https://github.com/nearai/ironclaw/pull/1091))
+- *(wasm)* address #1086 review followups -- description hint and coercion safety ([#1092](https://github.com/nearai/ironclaw/pull/1092))
+- *(ci)* repair staging-ci workflow parsing ([#1090](https://github.com/nearai/ironclaw/pull/1090))
+- *(extensions)* fix lifecycle bugs + comprehensive E2E tests ([#1070](https://github.com/nearai/ironclaw/pull/1070))
+- add tool_info schema discovery for WASM tools ([#1086](https://github.com/nearai/ironclaw/pull/1086))
+- resolve bug_bash UX/logging issues (#1054 #1055 #1058) ([#1072](https://github.com/nearai/ironclaw/pull/1072))
+- *(http)* fail closed when webhook secret is missing at runtime ([#1075](https://github.com/nearai/ironclaw/pull/1075))
+- *(service)* set CLI_ENABLED=false in macOS launchd plist ([#1079](https://github.com/nearai/ironclaw/pull/1079))
+- relax approval requirements for low-risk tools ([#922](https://github.com/nearai/ironclaw/pull/922))
+- *(web)* make approval requests appear without page reload ([#996](https://github.com/nearai/ironclaw/pull/996)) ([#1073](https://github.com/nearai/ironclaw/pull/1073))
+- *(routines)* run cron checks immediately on ticker startup ([#1066](https://github.com/nearai/ironclaw/pull/1066))
+- *(web)* recompute cron next_fire_at when re-enabling routines ([#1080](https://github.com/nearai/ironclaw/pull/1080))
+- *(memory)* reject absolute filesystem paths with corrective routing ([#934](https://github.com/nearai/ironclaw/pull/934))
+- remove all inline event handlers for CSP script-src compliance ([#1063](https://github.com/nearai/ironclaw/pull/1063))
+- *(mcp)* include OAuth state parameter in authorization URLs ([#1049](https://github.com/nearai/ironclaw/pull/1049))
+- *(mcp)* open MCP OAuth in same browser as gateway ([#951](https://github.com/nearai/ironclaw/pull/951))
+- *(deploy)* harden production container and bootstrap security ([#1014](https://github.com/nearai/ironclaw/pull/1014))
+- release lock guards before awaiting channel send ([#869](https://github.com/nearai/ironclaw/pull/869)) ([#1003](https://github.com/nearai/ironclaw/pull/1003))
+- *(registry)* use versioned artifact URLs and checksums for all WASM manifests ([#1007](https://github.com/nearai/ironclaw/pull/1007))
+- *(setup)* preserve model selection on provider re-run ([#679](https://github.com/nearai/ironclaw/pull/679)) ([#987](https://github.com/nearai/ironclaw/pull/987))
+- *(mcp)* attach session manager for non-OAuth HTTP clients ([#793](https://github.com/nearai/ironclaw/pull/793)) ([#986](https://github.com/nearai/ironclaw/pull/986))
+- *(security)* migrate webhook auth to HMAC-SHA256 signature header ([#970](https://github.com/nearai/ironclaw/pull/970))
+- *(security)* make unsafe env::set_var calls safe with explicit invariants ([#968](https://github.com/nearai/ironclaw/pull/968))
+- *(security)* require explicit SANDBOX_ALLOW_FULL_ACCESS to enable FullAccess policy ([#967](https://github.com/nearai/ironclaw/pull/967))
+- *(security)* add Content-Security-Policy header to web gateway ([#966](https://github.com/nearai/ironclaw/pull/966))
+- *(test)* stabilize openai compat oversized-body regression ([#839](https://github.com/nearai/ironclaw/pull/839))
+- *(ci)* disambiguate WASM bundle filenames to prevent tool/channel collision ([#964](https://github.com/nearai/ironclaw/pull/964))
+- *(setup)* validate channel credentials during setup ([#684](https://github.com/nearai/ironclaw/pull/684))
+- drain tunnel pipes to prevent zombie process ([#735](https://github.com/nearai/ironclaw/pull/735))
+- *(mcp)* header safety validation and Authorization conflict bug from #704 ([#752](https://github.com/nearai/ironclaw/pull/752))
+- *(agent)* block thread_id-based context pollution across users ([#760](https://github.com/nearai/ironclaw/pull/760))
+- *(mcp)* stdio/unix transports skip initialize handshake ([#890](https://github.com/nearai/ironclaw/pull/890)) ([#935](https://github.com/nearai/ironclaw/pull/935))
+- *(setup)* drain residual events and filter key kind in onboard prompts ([#937](https://github.com/nearai/ironclaw/pull/937)) ([#949](https://github.com/nearai/ironclaw/pull/949))
+- *(security)* load WASM tool description and schema from capabilities.json ([#520](https://github.com/nearai/ironclaw/pull/520))
+- *(security)* resolve DNS once and reuse for SSRF validation to prevent rebinding ([#518](https://github.com/nearai/ironclaw/pull/518))
+- *(security)* replace regex HTML sanitizer with DOMPurify to prevent XSS ([#510](https://github.com/nearai/ironclaw/pull/510))
+- *(ci)* improve Claude Code review reliability ([#955](https://github.com/nearai/ironclaw/pull/955))
+- *(ci)* run gated test jobs during staging CI ([#956](https://github.com/nearai/ironclaw/pull/956))
+- *(ci)* prevent staging-ci tag failure and chained PR auto-close ([#900](https://github.com/nearai/ironclaw/pull/900))
+- *(ci)* WASM WIT compat sqlite3 duplicate symbol conflict ([#953](https://github.com/nearai/ironclaw/pull/953))
+- resolve deferred review items from PRs #883, #848, #788 ([#915](https://github.com/nearai/ironclaw/pull/915))
+- *(web)* improve UX readability and accessibility in chat UI ([#910](https://github.com/nearai/ironclaw/pull/910))
+
+### Other
+
+- Fix Telegram auto-verify flow and routing ([#1273](https://github.com/nearai/ironclaw/pull/1273))
+- *(e2e)* fix approval waiting regression coverage ([#1270](https://github.com/nearai/ironclaw/pull/1270))
+- isolate heavy integration tests ([#1266](https://github.com/nearai/ironclaw/pull/1266))
+- Merge branch 'main' into fix/resolve-conflicts
+- Refactor owner scope across channels and fix default routing fallback ([#1151](https://github.com/nearai/ironclaw/pull/1151))
+- *(extensions)* document relay manager init order ([#928](https://github.com/nearai/ironclaw/pull/928))
+- *(setup)* extract init logic from wizard into owning modules ([#1210](https://github.com/nearai/ironclaw/pull/1210))
+- mention MiniMax as built-in provider in all READMEs ([#1209](https://github.com/nearai/ironclaw/pull/1209))
+- Fix schema-guided tool parameter coercion ([#1143](https://github.com/nearai/ironclaw/pull/1143))
+- Make no-panics CI check test-aware ([#1160](https://github.com/nearai/ironclaw/pull/1160))
+- *(mcp)* avoid reallocating SSE buffer on each chunk ([#1153](https://github.com/nearai/ironclaw/pull/1153))
+- *(routines)* avoid full message history clone each tool iteration ([#1172](https://github.com/nearai/ironclaw/pull/1172))
+- *(registry)* align manifest versions with published artifacts ([#1169](https://github.com/nearai/ironclaw/pull/1169))
+- remove __pycache__ from repo and add to .gitignore ([#1177](https://github.com/nearai/ironclaw/pull/1177))
+- *(registry)* move MCP servers from code to JSON manifests ([#1144](https://github.com/nearai/ironclaw/pull/1144))
+- improve routine schema guidance ([#1089](https://github.com/nearai/ironclaw/pull/1089))
+- add event-trigger routine e2e coverage ([#1088](https://github.com/nearai/ironclaw/pull/1088))
+- enforce no .unwrap(), .expect(), or assert!() in production code ([#1087](https://github.com/nearai/ironclaw/pull/1087))
+- periodic sync main into staging (resolved conflicts) ([#1098](https://github.com/nearai/ironclaw/pull/1098))
+- fix formatting in cli/mod.rs and mcp/auth.rs ([#1071](https://github.com/nearai/ironclaw/pull/1071))
+- Expose the shared agent session manager via AppComponents ([#532](https://github.com/nearai/ironclaw/pull/532))
+- *(agent)* remove unnecessary Worker re-export ([#923](https://github.com/nearai/ironclaw/pull/923))
+- Fix UTF-8 unsafe truncation in WASM emit_message ([#1015](https://github.com/nearai/ironclaw/pull/1015))
+- extract safety module into ironclaw_safety crate ([#1024](https://github.com/nearai/ironclaw/pull/1024))
+- Add Z.AI provider support for GLM-5 ([#938](https://github.com/nearai/ironclaw/pull/938))
+- *(html_to_markdown)* refresh golden files after renderer bump ([#1016](https://github.com/nearai/ironclaw/pull/1016))
+- Migrate GitHub webhook normalization into github tool ([#758](https://github.com/nearai/ironclaw/pull/758))
+- Fix systemctl unit ([#472](https://github.com/nearai/ironclaw/pull/472))
+- add Russian localization (README.ru.md) ([#850](https://github.com/nearai/ironclaw/pull/850))
+- Add generic host-verified /webhook/tools/{tool} ingress ([#757](https://github.com/nearai/ironclaw/pull/757))
+
## [0.18.0](https://github.com/nearai/ironclaw/compare/v0.17.0...v0.18.0) - 2026-03-11
### Other
diff --git a/CLAUDE.md b/CLAUDE.md
index d47292e1..e2d84c1e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -158,6 +158,8 @@ src/
│
├── secrets/ # Secrets management (AES-256-GCM, OS keychain for master key)
│
+├── profile.rs # Psychographic profile types, 9-dimension analysis framework
+│
├── setup/ # 7-step onboarding wizard — see src/setup/README.md
│
├── skills/ # SKILL.md prompt extension system — see .claude/rules/skills.md
diff --git a/Cargo.lock b/Cargo.lock
index 84bdc536..9dc240c3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3406,7 +3406,7 @@ dependencies = [
[[package]]
name = "ironclaw"
-version = "0.18.0"
+version = "0.19.0"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -5537,7 +5537,7 @@ dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
- "rustls-webpki 0.103.9",
+ "rustls-webpki 0.103.10",
"subtle",
"zeroize",
]
@@ -5609,9 +5609,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
-version = "0.103.9"
+version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
+checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"aws-lc-rs",
"ring",
diff --git a/Cargo.toml b/Cargo.toml
index 92a3d22a..32645b9a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -20,7 +20,7 @@ exclude = [
[package]
name = "ironclaw"
-version = "0.18.0"
+version = "0.19.0"
edition = "2024"
rust-version = "1.92"
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md
index 85348de5..6a3f8d53 100644
--- a/FEATURE_PARITY.md
+++ b/FEATURE_PARITY.md
@@ -242,6 +242,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| OpenRouter | ✅ | ✅ | - | Via OpenAI-compatible provider (RigAdapter) |
| Tinfoil | ❌ | ✅ | - | Private inference provider (IronClaw-only) |
| OpenAI-compatible | ❌ | ✅ | - | Generic OpenAI-compatible endpoint (RigAdapter) |
+| GitHub Copilot | ✅ | ✅ | - | Dedicated provider with OAuth token exchange (`GithubCopilotProvider`) |
| Ollama (local) | ✅ | ✅ | - | via `rig::providers::ollama` (full support) |
| Perplexity | ✅ | ❌ | P3 | Freshness parameter for web_search |
| MiniMax | ✅ | ❌ | P3 | Regional endpoint selection |
@@ -465,7 +466,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Device pairing | ✅ | ❌ | |
| Tailscale identity | ✅ | ❌ | |
| Trusted-proxy auth | ✅ | ❌ | Header-based reverse proxy auth |
-| OAuth flows | ✅ | 🚧 | NEAR AI OAuth |
+| OAuth flows | ✅ | 🚧 | NEAR AI OAuth plus hosted extension/MCP OAuth broker; external auth-proxy rollout still pending |
| DM pairing verification | ✅ | ✅ | ironclaw pairing approve, host APIs |
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
| Per-group tool policies | ✅ | ❌ | |
diff --git a/README.ja.md b/README.ja.md
new file mode 100644
index 00000000..887cf67e
--- /dev/null
+++ b/README.ja.md
@@ -0,0 +1,330 @@
+
+
+
+
+IronClaw
+
+
+ あなたの味方になる、安全なパーソナルAIアシスタント
+
+
+
+
+
+
+
+
+
+ English |
+ 简体中文 |
+ Русский |
+ 日本語
+
+
+
+ フィロソフィー •
+ 機能 •
+ インストール •
+ 設定 •
+ セキュリティ •
+ アーキテクチャ
+
+
+---
+
+## フィロソフィー
+
+IronClawはシンプルな原則に基づいて構築されています:**あなたのAIアシスタントは、あなたのために働くべきであり、あなたに不利益をもたらすべきではありません。**
+
+AIシステムがデータの取り扱いについて不透明になり、企業の利益に沿って調整されることが増えている世界で、IronClawは異なるアプローチを取ります:
+
+- **あなたのデータはあなたのもの** - すべての情報はローカルに保存・暗号化され、あなたの管理下から離れることはありません
+- **設計段階からの透明性** - オープンソース、監査可能、隠れたテレメトリやデータ収集なし
+- **自己拡張する能力** - ベンダーのアップデートを待たずに、新しいツールをその場で構築
+- **多層防御** - 複数のセキュリティレイヤーがプロンプトインジェクションやデータ流出から保護
+
+IronClawは、個人生活にも仕事にも本当に信頼できるAIアシスタントです。
+
+## 機能
+
+### セキュリティファースト
+
+- **WASMサンドボックス** - 信頼されていないツールは、機能ベースの権限を持つ隔離されたWebAssemblyコンテナで実行
+- **認証情報の保護** - シークレットはツールに公開されず、リーク検出付きでホスト境界で注入
+- **プロンプトインジェクション防御** - パターン検出、コンテンツサニタイズ、ポリシー適用
+- **エンドポイントの許可リスト** - HTTPリクエストは明示的に許可されたホストとパスのみに制限
+
+### 常時利用可能
+
+- **マルチチャネル** - REPL、HTTPウェブフック、WASMチャネル(Telegram、Slack)、Webゲートウェイ
+- **Dockerサンドボックス** - ジョブごとのトークンとオーケストレーター/ワーカーパターンによる隔離されたコンテナ実行
+- **Webゲートウェイ** - リアルタイムSSE/WebSocketストリーミング対応のブラウザUI
+- **ルーティン** - cronスケジュール、イベントトリガー、ウェブフックハンドラーによるバックグラウンド自動化
+- **ハートビートシステム** - 監視・保守タスクのためのプロアクティブなバックグラウンド実行
+- **並列ジョブ** - 隔離されたコンテキストで複数のリクエストを同時に処理
+- **自己修復** - スタックした操作の自動検出と復旧
+
+### 自己拡張
+
+- **動的ツール構築** - 必要なものを説明すると、IronClawがWASMツールとして構築
+- **MCPプロトコル** - Model Context Protocolサーバーに接続して追加機能を利用
+- **プラグインアーキテクチャ** - 再起動なしで新しいWASMツールやチャネルを追加
+
+### 永続メモリ
+
+- **ハイブリッド検索** - Reciprocal Rank Fusionを使用した全文検索+ベクトル検索
+- **ワークスペースファイルシステム** - メモ、ログ、コンテキストのための柔軟なパスベースストレージ
+- **アイデンティティファイル** - セッション間で一貫した人格と設定を維持
+
+## インストール
+
+### 前提条件
+
+- Rust 1.85+
+- PostgreSQL 15+ ([pgvector](https://github.com/pgvector/pgvector)拡張機能を含む)
+- NEAR AIアカウント(セットアップウィザードで認証を処理)
+
+## ダウンロードまたはビルド
+
+最新のアップデートは[リリースページ](https://github.com/nearai/ironclaw/releases/)をご覧ください。
+
+
+ Windowsインストーラーでインストール(Windows)
+
+[Windowsインストーラー](https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-x86_64-pc-windows-msvc.msi)をダウンロードして実行してください。
+
+
+
+
+ PowerShellスクリプトでインストール(Windows)
+
+```sh
+irm https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.ps1 | iex
+```
+
+
+
+
+ シェルスクリプトでインストール(macOS、Linux、Windows/WSL)
+
+```sh
+curl --proto '=https' --tlsv1.2 -LsSf https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.sh | sh
+```
+
+
+
+ Homebrewでインストール(macOS/Linux)
+
+```sh
+brew install ironclaw
+```
+
+
+
+
+ ソースコードからコンパイル(Windows、Linux、macOSでCargo)
+
+`cargo`でインストールします。コンピューターに[Rust](https://rustup.rs)がインストールされていることを確認してください。
+
+```bash
+# リポジトリをクローン
+git clone https://github.com/nearai/ironclaw.git
+cd ironclaw
+
+# ビルド
+cargo build --release
+
+# テストを実行
+cargo test
+```
+
+**フルリリース**(チャネルソースを変更した後)の場合、まず`./scripts/build-all.sh`を実行してチャネルを再ビルドしてください。
+
+
+
+### データベースのセットアップ
+
+```bash
+# データベースを作成
+createdb ironclaw
+
+# pgvectorを有効化
+psql ironclaw -c "CREATE EXTENSION IF NOT EXISTS vector;"
+```
+
+## 設定
+
+セットアップウィザードを実行してIronClawを設定します:
+
+```bash
+ironclaw onboard
+```
+
+ウィザードは、データベース接続、NEAR AI認証(ブラウザOAuth経由)、シークレットの暗号化(システムキーチェーンを使用)を処理します。設定は接続されたデータベースに永続化されます。ブートストラップ変数(例:`DATABASE_URL`、`LLM_BACKEND`)は、データベース接続前に利用できるよう`~/.ironclaw/.env`に書き込まれます。
+
+### 代替LLMプロバイダー
+
+IronClawはデフォルトでNEAR AIを使用しますが、多くのLLMプロバイダーをすぐに利用できます。組み込みプロバイダーには**Anthropic**、**OpenAI**、**Google Gemini**、**MiniMax**、**Mistral**、**Ollama**(ローカル)が含まれます。**OpenRouter**(300以上のモデル)、**Together AI**、**Fireworks AI**、セルフホストサーバー(**vLLM**、**LiteLLM**)などのOpenAI互換サービスもサポートされています。
+
+ウィザードでプロバイダーを選択するか、環境変数を直接設定してください:
+
+```env
+# 例:MiniMax(組み込み、204Kコンテキスト)
+LLM_BACKEND=minimax
+MINIMAX_API_KEY=...
+
+# 例:OpenAI互換エンドポイント
+LLM_BACKEND=openai_compatible
+LLM_BASE_URL=https://openrouter.ai/api/v1
+LLM_API_KEY=sk-or-...
+LLM_MODEL=anthropic/claude-sonnet-4
+```
+
+完全なプロバイダーガイドは[docs/LLM_PROVIDERS.md](docs/LLM_PROVIDERS.md)をご覧ください。
+
+## セキュリティ
+
+IronClawは、データを保護し悪用を防ぐために多層防御を実装しています。
+
+### WASMサンドボックス
+
+すべての信頼されていないツールは、隔離されたWebAssemblyコンテナで実行されます:
+
+- **機能ベースの権限** - HTTP、シークレット、ツール呼び出しの明示的なオプトイン
+- **エンドポイントの許可リスト** - 許可されたホスト/パスへのHTTPリクエストのみ
+- **認証情報の注入** - シークレットはホスト境界で注入され、WASMコードに公開されない
+- **リーク検出** - リクエストとレスポンスのシークレット流出試行をスキャン
+- **レート制限** - 悪用防止のためのツールごとのリクエスト制限
+- **リソース制限** - メモリ、CPU、実行時間の制約
+
+```
+WASM ──► 許可リスト ──► リーク ──► 認証情報 ──► リクエスト ──► リーク ──► WASM
+ バリデーター スキャン 注入 実行 スキャン
+ (リクエスト) (レスポンス)
+```
+
+### プロンプトインジェクション防御
+
+外部コンテンツは複数のセキュリティレイヤーを通過します:
+
+- パターンベースのインジェクション試行検出
+- コンテンツのサニタイズとエスケープ
+- 重要度レベル付きポリシールール(ブロック/警告/レビュー/サニタイズ)
+- 安全なLLMコンテキスト注入のためのツール出力ラッピング
+
+### データ保護
+
+- すべてのデータはローカルのPostgreSQLデータベースに保存
+- AES-256-GCMでシークレットを暗号化
+- テレメトリ、分析、データ共有なし
+- すべてのツール実行の完全な監査ログ
+
+## アーキテクチャ
+
+```
+┌────────────────────────────────────────────────────────────────┐
+│ チャネル │
+│ ┌──────┐ ┌──────┐ ┌─────────────┐ ┌─────────────┐ │
+│ │ REPL │ │ HTTP │ │WASMチャネル │ │ Web │ │
+│ └──┬───┘ └──┬───┘ └──────┬──────┘ │ ゲートウェイ│ │
+│ │ │ │ │(SSE + WS) │ │
+│ │ │ │ └──────┬──────┘ │
+│ └─────────┴──────────────┴────────────────┘ │
+│ │ │
+│ ┌─────────▼─────────┐ │
+│ │ エージェントループ │ インテントルーティング│
+│ └────┬──────────┬───┘ │
+│ │ │ │
+│ ┌──────────▼────┐ ┌──▼───────────────┐ │
+│ │ スケジューラー │ │ ルーティン │ │
+│ │ (並列ジョブ) │ │ エンジン │ │
+│ └──────┬────────┘ │(cron,event,wh) │ │
+│ │ └────────┬─────────┘ │
+│ ┌─────────────┼────────────────────┘ │
+│ │ │ │
+│ ┌───▼─────┐ ┌────▼────────────────┐ │
+│ │ ローカル │ │ オーケストレーター │ │
+│ │ ワーカー │ │ ┌───────────────┐ │ │
+│ │(プロセス │ │ │ Docker │ │ │
+│ │ 内) │ │ │ サンドボックス│ │ │
+│ └───┬─────┘ │ │ コンテナ │ │ │
+│ │ │ │ ┌───────────┐ │ │ │
+│ │ │ │ │Worker / CC│ │ │ │
+│ │ │ │ └───────────┘ │ │ │
+│ │ │ └───────────────┘ │ │
+│ │ └─────────┬───────────┘ │
+│ └──────────────────┤ │
+│ │ │
+│ ┌───────────▼──────────┐ │
+│ │ ツールレジストリ │ │
+│ │ 組み込み, MCP, WASM │ │
+│ └──────────────────────┘ │
+└────────────────────────────────────────────────────────────────┘
+```
+
+### コアコンポーネント
+
+| コンポーネント | 目的 |
+|---------------|------|
+| **エージェントループ** | メインのメッセージ処理とジョブの調整 |
+| **ルーター** | ユーザーの意図を分類(コマンド、クエリ、タスク) |
+| **スケジューラー** | 優先度付きの並列ジョブ実行を管理 |
+| **ワーカー** | LLM推論とツール呼び出しでジョブを実行 |
+| **オーケストレーター** | コンテナのライフサイクル、LLMプロキシ、ジョブごとの認証 |
+| **Webゲートウェイ** | チャット、メモリ、ジョブ、ログ、拡張機能、ルーティンのブラウザUI |
+| **ルーティンエンジン** | スケジュール(cron)とリアクティブ(イベント、ウェブフック)のバックグラウンドタスク |
+| **ワークスペース** | ハイブリッド検索付き永続メモリ |
+| **セーフティレイヤー** | プロンプトインジェクション防御とコンテンツサニタイズ |
+
+## 使い方
+
+```bash
+# 初回セットアップ(データベース、認証などを設定)
+ironclaw onboard
+
+# インタラクティブREPLを起動
+cargo run
+
+# デバッグログ付き
+RUST_LOG=ironclaw=debug cargo run
+```
+
+## 開発
+
+```bash
+# コードフォーマット
+cargo fmt
+
+# リント
+cargo clippy --all --benches --tests --examples --all-features
+
+# テスト実行
+createdb ironclaw_test
+cargo test
+
+# 特定のテストを実行
+cargo test test_name
+```
+
+- **Telegramチャネル**: セットアップとDMペアリングについては[docs/TELEGRAM_SETUP.md](docs/TELEGRAM_SETUP.md)を参照してください。
+- **チャネルソースの変更**: `cargo build`の前に`./channels-src/telegram/build.sh`を実行して、更新されたWASMをバンドルしてください。
+
+## OpenClawの系譜
+
+IronClawは[OpenClaw](https://github.com/openclaw/openclaw)にインスパイアされたRust再実装です。完全な対応表は[FEATURE_PARITY.md](FEATURE_PARITY.md)をご覧ください。
+
+主な違い:
+
+- **Rust vs TypeScript** - ネイティブパフォーマンス、メモリ安全性、シングルバイナリ
+- **WASMサンドボックス vs Docker** - 軽量、機能ベースのセキュリティ
+- **PostgreSQL vs SQLite** - 本番環境対応の永続化
+- **セキュリティファースト設計** - 複数の防御レイヤー、認証情報の保護
+
+## ライセンス
+
+以下のいずれかのライセンスの下で提供されています:
+
+- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE))
+- MIT License ([LICENSE-MIT](LICENSE-MIT))
+
+お好みに応じて選択してください。
diff --git a/README.md b/README.md
index 9684ee4d..6e14d9ea 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,8 @@
English |
简体中文 |
- Русский
+ Русский |
+ 日本語
@@ -167,7 +168,7 @@ written to `~/.ironclaw/.env` so they are available before the database connects
### Alternative LLM Providers
IronClaw defaults to NEAR AI but supports many LLM providers out of the box.
-Built-in providers include **Anthropic**, **OpenAI**, **Google Gemini**, **MiniMax**,
+Built-in providers include **Anthropic**, **OpenAI**, **GitHub Copilot**, **Google Gemini**, **MiniMax**,
**Mistral**, and **Ollama** (local). OpenAI-compatible services like **OpenRouter**
(300+ models), **Together AI**, **Fireworks AI**, and self-hosted servers (**vLLM**,
**LiteLLM**) are also supported.
diff --git a/README.ru.md b/README.ru.md
index c64770a9..0546e7f4 100644
--- a/README.ru.md
+++ b/README.ru.md
@@ -17,7 +17,8 @@
English |
简体中文 |
- Русский
+ Русский |
+ 日本語
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 34023822..d818872a 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -17,7 +17,8 @@
English |
简体中文 |
- Русский
+ Русский |
+ 日本語
@@ -164,7 +165,7 @@ ironclaw onboard
### 替代 LLM 提供商
IronClaw 默认使用 NEAR AI,但开箱即用地支持多种 LLM 提供商。
-内置提供商包括 **Anthropic**、**OpenAI**、**Google Gemini**、**MiniMax**、**Mistral** 和 **Ollama**(本地部署)。同时也支持 OpenAI 兼容服务,如 **OpenRouter**(300+ 模型)、**Together AI**、**Fireworks AI** 以及自托管服务器(**vLLM**、**LiteLLM**)。
+内置提供商包括 **Anthropic**、**OpenAI**、**GitHub Copilot**、**Google Gemini**、**MiniMax**、**Mistral** 和 **Ollama**(本地部署)。同时也支持 OpenAI 兼容服务,如 **OpenRouter**(300+ 模型)、**Together AI**、**Fireworks AI** 以及自托管服务器(**vLLM**、**LiteLLM**)。
在向导中选择你的提供商,或直接设置环境变量:
diff --git a/channels-src/feishu/src/lib.rs b/channels-src/feishu/src/lib.rs
index 2e7261d8..3094eaa0 100644
--- a/channels-src/feishu/src/lib.rs
+++ b/channels-src/feishu/src/lib.rs
@@ -206,9 +206,17 @@ struct FeishuApiResponse {
data: Option,
}
-/// Tenant access token response.
-#[derive(Debug, Default, Deserialize)]
-struct TenantAccessTokenData {
+/// Tenant access token response (flat format).
+///
+/// Unlike most Feishu APIs that nest results under `data`, the
+/// `/auth/v3/tenant_access_token/internal` endpoint returns `code`, `msg`,
+/// `tenant_access_token`, and `expire` at the top level.
+#[derive(Debug, Deserialize)]
+struct TenantAccessTokenResponse {
+ #[serde(default)]
+ code: i32,
+ #[serde(default)]
+ msg: String,
tenant_access_token: String,
expire: i64,
}
@@ -770,9 +778,8 @@ fn obtain_tenant_token(api_base: &str) -> Result {
));
}
- let token_resp: FeishuApiResponse =
- serde_json::from_slice(&response.body)
- .map_err(|e| format!("Failed to parse token response: {}", e))?;
+ let token_resp: TenantAccessTokenResponse = serde_json::from_slice(&response.body)
+ .map_err(|e| format!("Failed to parse token response: {}", e))?;
if token_resp.code != 0 {
return Err(format!(
@@ -781,23 +788,33 @@ fn obtain_tenant_token(api_base: &str) -> Result {
));
}
- let data = token_resp
- .data
- .ok_or_else(|| "Token response missing data".to_string())?;
+ if token_resp.tenant_access_token.is_empty() {
+ return Err("Token response missing tenant_access_token".to_string());
+ }
+
+ if token_resp.expire <= 0 {
+ return Err(format!(
+ "Token response has invalid expire value: {}",
+ token_resp.expire
+ ));
+ }
// Cache the token with expiry.
let now = channel_host::now_millis();
- let expiry = now + (data.expire as u64) * 1000;
+ let expiry = now.saturating_add((token_resp.expire as u64).saturating_mul(1000));
- let _ = channel_host::workspace_write(TOKEN_PATH, &data.tenant_access_token);
+ let _ = channel_host::workspace_write(TOKEN_PATH, &token_resp.tenant_access_token);
let _ = channel_host::workspace_write(TOKEN_EXPIRY_PATH, &expiry.to_string());
channel_host::log(
channel_host::LogLevel::Debug,
- &format!("Tenant access token refreshed, expires in {}s", data.expire),
+ &format!(
+ "Tenant access token refreshed, expires in {}s",
+ token_resp.expire
+ ),
);
- Ok(data.tenant_access_token)
+ Ok(token_resp.tenant_access_token)
}
Err(e) => Err(format!("Token exchange request failed: {}", e)),
}
@@ -819,3 +836,60 @@ fn json_response(status: u16, body: serde_json::Value) -> OutgoingHttpResponse {
body: body_bytes,
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parse_flat_token_response() {
+ let json = r#"{
+ "code": 0,
+ "msg": "ok",
+ "tenant_access_token": "t-abc123",
+ "expire": 7200
+ }"#;
+ let resp: TenantAccessTokenResponse = serde_json::from_str(json).unwrap();
+ assert_eq!(resp.code, 0);
+ assert_eq!(resp.msg, "ok");
+ assert_eq!(resp.tenant_access_token, "t-abc123");
+ assert_eq!(resp.expire, 7200);
+ }
+
+ #[test]
+ fn parse_token_response_rejects_missing_token() {
+ let json = r#"{"code": 0, "msg": "ok", "expire": 7200}"#;
+ let result: Result = serde_json::from_str(json);
+ assert!(result.is_err(), "should fail when tenant_access_token is missing");
+ }
+
+ #[test]
+ fn parse_token_response_rejects_missing_expire() {
+ let json = r#"{"code": 0, "msg": "ok", "tenant_access_token": "t-abc"}"#;
+ let result: Result = serde_json::from_str(json);
+ assert!(result.is_err(), "should fail when expire is missing");
+ }
+
+ #[test]
+ fn parse_token_response_defaults_code_and_msg() {
+ let json = r#"{"tenant_access_token": "t-abc", "expire": 3600}"#;
+ let resp: TenantAccessTokenResponse = serde_json::from_str(json).unwrap();
+ assert_eq!(resp.code, 0);
+ assert_eq!(resp.msg, "");
+ assert_eq!(resp.tenant_access_token, "t-abc");
+ assert_eq!(resp.expire, 3600);
+ }
+
+ #[test]
+ fn parse_token_error_response() {
+ let json = r#"{
+ "code": 10003,
+ "msg": "invalid app_id",
+ "tenant_access_token": "",
+ "expire": 0
+ }"#;
+ let resp: TenantAccessTokenResponse = serde_json::from_str(json).unwrap();
+ assert_eq!(resp.code, 10003);
+ assert!(resp.tenant_access_token.is_empty());
+ }
+}
diff --git a/channels-src/telegram/src/lib.rs b/channels-src/telegram/src/lib.rs
index a095ccb3..f34ed68a 100644
--- a/channels-src/telegram/src/lib.rs
+++ b/channels-src/telegram/src/lib.rs
@@ -360,6 +360,8 @@ enum TelegramStatusAction {
}
const TELEGRAM_STATUS_MAX_CHARS: usize = 600;
+/// Telegram's hard limit for message text length.
+const TELEGRAM_MAX_MESSAGE_LEN: usize = 4096;
fn truncate_status_message(input: &str, max_chars: usize) -> String {
let mut iter = input.chars();
@@ -371,6 +373,73 @@ fn truncate_status_message(input: &str, max_chars: usize) -> String {
}
}
+/// Split a long message into chunks that fit within Telegram's 4096-char limit.
+///
+/// Tries to split at the most natural boundary available (in priority order):
+/// 1. Double newline (paragraph break)
+/// 2. Single newline
+/// 3. Sentence end (`. `, `! `, `? `)
+/// 4. Word boundary (space)
+/// 5. Hard cut at the limit (last resort for pathological input)
+fn split_message(text: &str) -> Vec {
+ if text.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN {
+ return vec![text.to_string()];
+ }
+
+ let mut chunks: Vec = Vec::new();
+ let mut remaining = text;
+
+ while !remaining.is_empty() {
+ // Count chars to find the byte offset for our window.
+ let window_bytes = remaining
+ .char_indices()
+ .take(TELEGRAM_MAX_MESSAGE_LEN)
+ .last()
+ .map(|(byte_idx, ch)| byte_idx + ch.len_utf8())
+ .unwrap_or(remaining.len());
+
+ if window_bytes >= remaining.len() {
+ // Remainder fits entirely.
+ chunks.push(remaining.to_string());
+ break;
+ }
+
+ let window = &remaining[..window_bytes];
+
+ // 1. Double newline — best paragraph boundary
+ let split_at = window.rfind("\n\n")
+ // 2. Single newline
+ .or_else(|| window.rfind('\n'))
+ // 3. Sentence-ending punctuation followed by space.
+ // Note: this only detects ASCII punctuation (. ! ?), not CJK
+ // sentence-ending marks (。!?). CJK text falls through to
+ // word-boundary or hard-cut splitting.
+ .or_else(|| {
+ let bytes = window.as_bytes();
+ // Search backwards for '. ', '! ', '? '
+ (1..bytes.len()).rev().find(|&i| {
+ matches!(bytes[i - 1], b'.' | b'!' | b'?') && bytes[i] == b' '
+ })
+ })
+ // 4. Word boundary (last space)
+ .or_else(|| window.rfind(' '))
+ // 5. Hard cut
+ .unwrap_or(window_bytes);
+
+ // Avoid empty chunks (e.g. text starting with \n\n).
+ let split_at = if split_at == 0 { window_bytes } else { split_at };
+
+ // Trim whitespace at chunk boundaries for clean Telegram display.
+ // Note: this drops leading/trailing spaces at split points, which is
+ // acceptable for chat messages but means the concatenation of chunks
+ // may not exactly equal the original text when split at spaces.
+ chunks.push(remaining[..split_at].trim_end().to_string());
+ remaining = remaining[split_at..].trim_start();
+ }
+
+ chunks
+}
+
fn status_message_for_user(update: &StatusUpdate) -> Option {
let message = update.message.trim();
if message.is_empty() {
@@ -1242,26 +1311,64 @@ fn send_response(
return Ok(());
}
- // Try Markdown, fall back to plain text on parse errors
- match send_message(
- chat_id,
- &response.content,
- reply_to_message_id,
- Some("Markdown"),
- message_thread_id,
- ) {
- Ok(_) => Ok(()),
- Err(SendError::ParseEntities(_)) => send_message(
- chat_id,
- &response.content,
- reply_to_message_id,
- None,
- message_thread_id,
- )
- .map(|_| ())
- .map_err(|e| format!("Plain-text retry also failed: {}", e)),
- Err(e) => Err(e.to_string()),
+ // Split large messages into chunks that fit Telegram's limit.
+ let chunks = split_message(&response.content);
+ let total = chunks.len();
+
+ // The first chunk replies to the original message; subsequent chunks
+ // reply to the previously sent chunk so they form a visual thread.
+ let mut reply_to = reply_to_message_id;
+
+ for (i, chunk) in chunks.into_iter().enumerate() {
+ // Try Markdown, fall back to plain text on parse errors
+ let result = send_message(chat_id, &chunk, reply_to, Some("Markdown"), message_thread_id);
+
+ let msg_id = match result {
+ Ok(id) => {
+ channel_host::log(
+ channel_host::LogLevel::Debug,
+ &format!(
+ "Sent message chunk {}/{} to chat {}: message_id={}",
+ i + 1,
+ total,
+ chat_id,
+ id,
+ ),
+ );
+ id
+ }
+ Err(SendError::ParseEntities(detail)) => {
+ channel_host::log(
+ channel_host::LogLevel::Warn,
+ &format!(
+ "Markdown parse failed on chunk {}/{} ({}), retrying as plain text",
+ i + 1,
+ total,
+ detail
+ ),
+ );
+ let id = send_message(chat_id, &chunk, reply_to, None, message_thread_id)
+ .map_err(|e| format!("Plain-text retry also failed: {}", e))?;
+ channel_host::log(
+ channel_host::LogLevel::Debug,
+ &format!(
+ "Sent plain-text chunk {}/{} to chat {}: message_id={}",
+ i + 1,
+ total,
+ chat_id,
+ id,
+ ),
+ );
+ id
+ }
+ Err(e) => return Err(e.to_string()),
+ };
+
+ // Each subsequent chunk threads off the previous sent message.
+ reply_to = Some(msg_id);
}
+
+ Ok(())
}
/// Send a single attachment, choosing sendPhoto or sendDocument based on MIME type.
@@ -2043,6 +2150,102 @@ export!(TelegramChannel);
mod tests {
use super::*;
+ #[test]
+ fn test_split_message_short() {
+ let text = "Hello, world!";
+ let chunks = split_message(text);
+ assert_eq!(chunks, vec![text]);
+ }
+
+ #[test]
+ fn test_split_message_paragraph_boundary() {
+ let para_a = "A".repeat(3000);
+ let para_b = "B".repeat(3000);
+ let text = format!("{}\n\n{}", para_a, para_b);
+ let chunks = split_message(&text);
+ assert_eq!(chunks.len(), 2);
+ assert_eq!(chunks[0], para_a);
+ assert_eq!(chunks[1], para_b);
+ }
+
+ #[test]
+ fn test_split_message_word_boundary() {
+ // Build a string well over the limit with no newlines.
+ let words: Vec = (0..1000).map(|i| format!("word{:04}", i)).collect();
+ let text = words.join(" ");
+ assert!(text.len() > TELEGRAM_MAX_MESSAGE_LEN);
+ let chunks = split_message(&text);
+ assert!(chunks.len() > 1, "expected multiple chunks");
+ for chunk in &chunks {
+ assert!(chunk.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN);
+ }
+ // Rejoined chunks must equal the original text exactly.
+ let rejoined = chunks.join(" ");
+ assert_eq!(rejoined, text);
+ }
+
+ #[test]
+ fn test_split_message_each_chunk_fits() {
+ // Stress-test: 20 000 chars of mixed text.
+ let text: String = (0..500)
+ .map(|i| format!("Sentence number {}. ", i))
+ .collect();
+ assert!(text.len() > TELEGRAM_MAX_MESSAGE_LEN);
+ let chunks = split_message(&text);
+ for chunk in &chunks {
+ assert!(chunk.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN);
+ }
+ }
+
+ #[test]
+ fn test_split_message_sentence_boundary() {
+ // Build text that exceeds the limit, with sentence boundaries inside.
+ let sentence = "This is a test sentence. ";
+ let repeat_count = TELEGRAM_MAX_MESSAGE_LEN / sentence.len() + 5;
+ let text: String = sentence.repeat(repeat_count);
+ assert!(text.chars().count() > TELEGRAM_MAX_MESSAGE_LEN);
+
+ let chunks = split_message(&text);
+ assert!(chunks.len() > 1);
+ // First chunk should end at a sentence boundary (trimmed)
+ let first = &chunks[0];
+ assert!(
+ first.ends_with('.'),
+ "First chunk should end at a sentence boundary, got: ...{}",
+ &first[first.len().saturating_sub(20)..]
+ );
+ }
+
+ #[test]
+ fn test_split_message_hard_cut_no_spaces() {
+ // Pathological input: a single huge "word" with no spaces or newlines.
+ let text = "x".repeat(TELEGRAM_MAX_MESSAGE_LEN * 2 + 100);
+ let chunks = split_message(&text);
+ assert!(chunks.len() >= 2);
+ for chunk in &chunks {
+ assert!(chunk.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN);
+ }
+ // Rejoined must preserve all characters
+ let rejoined: String = chunks.concat();
+ assert_eq!(rejoined, text);
+ }
+
+ #[test]
+ fn test_split_message_multibyte_chars() {
+ // Emoji are 4 bytes each. Ensure we don't panic or split mid-character.
+ let emoji = "\u{1F600}"; // 😀
+ let text: String = emoji.repeat(TELEGRAM_MAX_MESSAGE_LEN + 100);
+ assert!(text.chars().count() > TELEGRAM_MAX_MESSAGE_LEN);
+
+ let chunks = split_message(&text);
+ assert!(chunks.len() >= 2);
+ for chunk in &chunks {
+ assert!(chunk.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN);
+ // Every char should be a complete emoji
+ assert!(chunk.chars().all(|c| c == '\u{1F600}'));
+ }
+ }
+
#[test]
fn test_clean_message_text() {
// Without bot_username: strips any leading @mention
diff --git a/codecov.yml b/codecov.yml
index 3e31b00a..723c1175 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -2,9 +2,13 @@ coverage:
status:
project:
default:
- target: auto
- threshold: 1%
+ target: 80%
+ threshold: 2%
patch:
default:
- target: 80%
- threshold: 5%
\ No newline at end of file
+ target: 90%
+
+comment:
+ layout: "reach,diff,flags"
+ behavior: default
+ require_changes: true
diff --git a/crates/ironclaw_safety/src/lib.rs b/crates/ironclaw_safety/src/lib.rs
index 3e9a48ba..d0c3f783 100644
--- a/crates/ironclaw_safety/src/lib.rs
+++ b/crates/ironclaw_safety/src/lib.rs
@@ -243,6 +243,18 @@ mod tests {
assert!(wrapped.contains("Hello "));
}
+ #[test]
+ fn test_wrap_for_llm_escapes_attr_chars() {
+ let config = SafetyConfig {
+ max_output_length: 100_000,
+ injection_check_enabled: true,
+ };
+ let safety = SafetyLayer::new(&config);
+
+ let wrapped = safety.wrap_for_llm("bad&\"<>name", "ok", false);
+ assert!(wrapped.contains("name=\"bad&"<>name\"")); // safety: test assertion in #[cfg(test)] module
+ }
+
#[test]
fn test_sanitize_action_forces_sanitization_when_injection_check_disabled() {
let config = SafetyConfig {
diff --git a/deny.toml b/deny.toml
index 80aa2215..fddb3d43 100644
--- a/deny.toml
+++ b/deny.toml
@@ -15,6 +15,8 @@ ignore = [
"RUSTSEC-2026-0020",
# wasmtime wasi:http/types.fields panic — mitigated by fuel limits
"RUSTSEC-2026-0021",
+ # rustls-webpki CRL distributionPoint matching — 0.102.8 pinned by libsql transitive dep
+ "RUSTSEC-2026-0049",
]
[licenses]
diff --git a/docs/LLM_PROVIDERS.md b/docs/LLM_PROVIDERS.md
index a581a56b..b4454289 100644
--- a/docs/LLM_PROVIDERS.md
+++ b/docs/LLM_PROVIDERS.md
@@ -15,8 +15,9 @@ configurations.
| io.net | `ionet` | `IONET_API_KEY` | Intelligence API |
| Mistral | `mistral` | `MISTRAL_API_KEY` | Mistral models |
| Yandex AI Studio | `yandex` | `YANDEX_API_KEY` | YandexGPT models |
-| MiniMax | `minimax` | `MINIMAX_API_KEY` | MiniMax-M2.5 models |
+| MiniMax | `minimax` | `MINIMAX_API_KEY` | MiniMax-M2.7 models |
| Cloudflare Workers AI | `cloudflare` | `CLOUDFLARE_API_KEY` | Access to Workers AI |
+| GitHub Copilot | `github_copilot` | `GITHUB_COPILOT_TOKEN` | Multi-models |
| Ollama | `ollama` | No | Local inference |
| AWS Bedrock | `bedrock` | AWS credentials | Native Converse API |
| OpenRouter | `openai_compatible` | `LLM_API_KEY` | 300+ models |
@@ -61,6 +62,34 @@ Popular models: `gpt-4o`, `gpt-4o-mini`, `o3-mini`
---
+## GitHub Copilot
+
+GitHub Copilot exposes chat endpoint at
+`https://api.githubcopilot.com`. IronClaw uses that endpoint directly through the
+built-in `github_copilot` provider.
+
+```env
+LLM_BACKEND=github_copilot
+GITHUB_COPILOT_TOKEN=gho_...
+GITHUB_COPILOT_MODEL=gpt-4o
+# Optional advanced headers if your setup needs them:
+# GITHUB_COPILOT_EXTRA_HEADERS=Copilot-Integration-Id:vscode-chat
+```
+
+`ironclaw onboard` can acquire this token for you using GitHub device login. If you
+already signed into Copilot through VS Code or a JetBrains IDE, you can also reuse
+the `oauth_token` stored in `~/.config/github-copilot/apps.json`. If you prefer,
+`LLM_BACKEND=github-copilot` also works as an alias.
+
+Popular models vary by subscription, but `gpt-4o` is a safe default. IronClaw keeps
+model entry manual for this provider because GitHub Copilot model listing may require
+extra integration headers on some clients. IronClaw automatically injects the standard
+VS Code identity headers (`User-Agent`, `Editor-Version`, `Editor-Plugin-Version`,
+`Copilot-Integration-Id`) and lets you override them with
+`GITHUB_COPILOT_EXTRA_HEADERS`.
+
+---
+
## Ollama (local)
Install Ollama from [ollama.com](https://ollama.com), pull a model, then:
@@ -84,7 +113,7 @@ LLM_BACKEND=minimax
MINIMAX_API_KEY=...
```
-Available models: `MiniMax-M2.5` (default), `MiniMax-M2.5-highspeed`
+Available models: `MiniMax-M2.7` (default), `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`
To use the China mainland endpoint, set:
diff --git a/providers.json b/providers.json
index 12723a6f..517e2a26 100644
--- a/providers.json
+++ b/providers.json
@@ -77,6 +77,29 @@
"can_list_models": false
}
},
+ {
+ "id": "github_copilot",
+ "aliases": [
+ "github-copilot",
+ "githubcopilot",
+ "copilot"
+ ],
+ "protocol": "github_copilot",
+ "default_base_url": "https://api.githubcopilot.com",
+ "api_key_env": "GITHUB_COPILOT_TOKEN",
+ "api_key_required": true,
+ "model_env": "GITHUB_COPILOT_MODEL",
+ "default_model": "gpt-4o",
+ "extra_headers_env": "GITHUB_COPILOT_EXTRA_HEADERS",
+ "description": "GitHub Copilot Chat API (OAuth token from IDE sign-in)",
+ "setup": {
+ "kind": "api_key",
+ "secret_name": "llm_github_copilot_token",
+ "key_url": "https://docs.github.com/en/copilot",
+ "display_name": "GitHub Copilot",
+ "can_list_models": false
+ }
+ },
{
"id": "tinfoil",
"aliases": [],
@@ -393,8 +416,8 @@
"api_key_required": true,
"base_url_env": "MINIMAX_BASE_URL",
"model_env": "MINIMAX_MODEL",
- "default_model": "MiniMax-M2.5",
- "description": "MiniMax API (MiniMax-M2.5 and MiniMax-M2.5-highspeed models)",
+ "default_model": "MiniMax-M2.7",
+ "description": "MiniMax API (MiniMax-M2.7, MiniMax-M2.7-highspeed, MiniMax-M2.5 and MiniMax-M2.5-highspeed models)",
"setup": {
"kind": "api_key",
"secret_name": "llm_minimax_api_key",
diff --git a/registry/channels/discord.json b/registry/channels/discord.json
index 50ef85ee..dc545d75 100644
--- a/registry/channels/discord.json
+++ b/registry/channels/discord.json
@@ -18,8 +18,8 @@
},
"artifacts": {
"wasm32-wasip2": {
- "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/discord-0.2.0-wasm32-wasip2.tar.gz",
- "sha256": "efa1b9019fa33e243f8db1e1fcc732731d45836336bdd26ca19b6fe227ca8b69"
+ "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/channel-discord-0.2.1-wasm32-wasip2.tar.gz",
+ "sha256": "6159cb54aa44a9d8219e29bf0aea9404213b20ff567506fe75f23d4698d6ec18"
}
},
"auth_summary": {
diff --git a/registry/channels/feishu.json b/registry/channels/feishu.json
index 0446a442..66cecf1d 100644
--- a/registry/channels/feishu.json
+++ b/registry/channels/feishu.json
@@ -17,7 +17,12 @@
"capabilities": "feishu.capabilities.json",
"crate_name": "feishu-channel"
},
- "artifacts": {},
+ "artifacts": {
+ "wasm32-wasip2": {
+ "sha256": "5fca74022264d1c8e78a0853766276f7ffa3cf0d8065b2f51ca10985acad4714",
+ "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/channel-feishu-0.1.1-wasm32-wasip2.tar.gz"
+ }
+ },
"auth_summary": {
"method": "manual",
"provider": "Feishu / Lark",
diff --git a/registry/channels/telegram.json b/registry/channels/telegram.json
index e44061e5..85d793ed 100644
--- a/registry/channels/telegram.json
+++ b/registry/channels/telegram.json
@@ -2,7 +2,7 @@
"name": "telegram",
"display_name": "Telegram Channel",
"kind": "channel",
- "version": "0.2.4",
+ "version": "0.2.5",
"wit_version": "0.3.0",
"description": "Talk to your agent through a Telegram bot",
"keywords": [
@@ -18,8 +18,8 @@
},
"artifacts": {
"wasm32-wasip2": {
- "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/telegram-0.2.3-wasm32-wasip2.tar.gz",
- "sha256": "b9a83d5a2d1285ce0ec116b354336a1f245f893291ccb01dffbcaccf89d72aed"
+ "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/channel-telegram-0.2.4-wasm32-wasip2.tar.gz",
+ "sha256": "a7cb300ec1c946831cfceaa95c1dc8f30d0f42a3924f3cb5de8098821573f4b8"
}
},
"auth_summary": {
diff --git a/registry/tools/github.json b/registry/tools/github.json
index e775ac82..e760c4df 100644
--- a/registry/tools/github.json
+++ b/registry/tools/github.json
@@ -19,8 +19,8 @@
},
"artifacts": {
"wasm32-wasip2": {
- "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/github-0.2.0-wasm32-wasip2.tar.gz",
- "sha256": "da9fac56b6f20197a415489bbaec9fefb085a5cf6324cab79ea48a47eb19c13b"
+ "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-github-0.2.1-wasm32-wasip2.tar.gz",
+ "sha256": "92c530b3ad172e2372d819744b5233f1d8f65768e26eb5a6c213eba3ce1de758"
}
},
"auth_summary": {
diff --git a/registry/tools/llm-context.json b/registry/tools/llm-context.json
index a647a153..e4e9808c 100644
--- a/registry/tools/llm-context.json
+++ b/registry/tools/llm-context.json
@@ -21,8 +21,8 @@
},
"artifacts": {
"wasm32-wasip2": {
- "url": "https://github.com/nearai/ironclaw/releases/latest/download/llm-context-wasm32-wasip2.tar.gz",
- "sha256": "581cc5867ef3b75116b7ddc8161e63dd92befe2b53e6ad8213c007639aa243c3"
+ "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-llm-context-0.1.0-wasm32-wasip2.tar.gz",
+ "sha256": "d9ced2b1226b879135891e0ee40e072c7c95412e1b2462925a23853e1f92497e"
}
},
"auth_summary": {
diff --git a/registry/tools/slack.json b/registry/tools/slack.json
index 11bd7fff..8e1df989 100644
--- a/registry/tools/slack.json
+++ b/registry/tools/slack.json
@@ -17,8 +17,8 @@
},
"artifacts": {
"wasm32-wasip2": {
- "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/slack-0.2.1-wasm32-wasip2.tar.gz",
- "sha256": "d4667e35126986509d862bc3a0088777305d8f41c75de83c1e223b42312ede48"
+ "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-slack-0.2.0-wasm32-wasip2.tar.gz",
+ "sha256": "ccfb0415d7a04f9497726c712d15216de36e86f498b849101283c017f5ab4efb"
}
},
"auth_summary": {
diff --git a/registry/tools/telegram.json b/registry/tools/telegram.json
index 680d6fdb..12e58c68 100644
--- a/registry/tools/telegram.json
+++ b/registry/tools/telegram.json
@@ -18,8 +18,8 @@
},
"artifacts": {
"wasm32-wasip2": {
- "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/telegram-0.2.2-wasm32-wasip2.tar.gz",
- "sha256": "b9a83d5a2d1285ce0ec116b354336a1f245f893291ccb01dffbcaccf89d72aed"
+ "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-telegram-0.2.0-wasm32-wasip2.tar.gz",
+ "sha256": "c17065ca41fae5f2a7c43b36144686718cd310a2f22442313bb1aa82bbad0ae4"
}
},
"auth_summary": {
diff --git a/registry/tools/web-search.json b/registry/tools/web-search.json
index 1722c391..5c1dedef 100644
--- a/registry/tools/web-search.json
+++ b/registry/tools/web-search.json
@@ -18,8 +18,8 @@
},
"artifacts": {
"wasm32-wasip2": {
- "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/web-search-0.2.0-wasm32-wasip2.tar.gz",
- "sha256": "56834573c54ea2a33cea1eb0f04bbdf59f1ef8d8702995cf431b0921302eeccc"
+ "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-web-search-0.2.1-wasm32-wasip2.tar.gz",
+ "sha256": "bad275ca4ec314adea5241d6b92c44ccf9cebcbca8e30ba2493cc0bcb4b57218"
}
},
"auth_summary": {
diff --git a/release-plz.toml b/release-plz.toml
index ee7037df..b003952d 100644
--- a/release-plz.toml
+++ b/release-plz.toml
@@ -3,4 +3,5 @@ git_release_enable = false
[[package]]
name = "ironclaw_safety"
+publish = false
release = false
diff --git a/skills/delegation/SKILL.md b/skills/delegation/SKILL.md
new file mode 100644
index 00000000..0163dd32
--- /dev/null
+++ b/skills/delegation/SKILL.md
@@ -0,0 +1,75 @@
+---
+name: delegation
+version: 0.1.0
+description: Helps users delegate tasks, break them into steps, set deadlines, and track progress via routines and memory.
+activation:
+ keywords:
+ - delegate
+ - hand off
+ - assign task
+ - help me with
+ - take care of
+ - remind me to
+ - schedule
+ - plan my
+ - manage my
+ - track this
+ patterns:
+ - "can you.*handle"
+ - "I need (help|someone) to"
+ - "take over"
+ - "set up a reminder"
+ - "follow up on"
+ tags:
+ - personal-assistant
+ - task-management
+ - delegation
+ max_context_tokens: 1500
+---
+
+# Task Delegation Assistant
+
+When the user wants to delegate a task or get help managing something, follow this process:
+
+## 1. Clarify the Task
+
+Ask what needs to be done, by when, and any constraints. Get enough detail to act independently but don't over-interrogate. If the request is clear, skip straight to planning.
+
+## 2. Break It Down
+
+Decompose the task into concrete, actionable steps. Use `memory_write` to persist the task plan to a path like `tasks/{task-name}.md` with:
+- Clear description
+- Steps with checkboxes
+- Due date (if any)
+- Status: pending/in-progress/done
+
+## 3. Set Up Tracking
+
+If the task is recurring or has a deadline:
+- Create a routine using `routine_create` for scheduled check-ins
+- Add a heartbeat item if it needs daily monitoring
+- Set up an event-triggered routine if it depends on external input
+
+## 4. Use Profile Context
+
+Check `USER.md` for the user's preferences:
+- **Proactivity level**: High = check in frequently. Low = only report on completion.
+- **Communication style**: Match their preferred tone and detail level.
+- **Focus areas**: Prioritize tasks that align with their stated goals.
+
+## 5. Execute or Queue
+
+- If you can do it now (search, draft, organize, calculate), do it immediately.
+- If it requires waiting, external action, or follow-up, create a reminder routine.
+- If it requires tools you don't have, explain what's needed and suggest alternatives.
+
+## 6. Report Back
+
+Always confirm the plan with the user before starting execution. After completing, update the task file in memory and notify the user with a concise summary.
+
+## Communication Guidelines
+
+- Be direct and action-oriented
+- Confirm understanding before acting on ambiguous requests
+- When in doubt about autonomy level, ask once then remember the answer
+- Use `memory_write` to track delegation preferences for future reference
diff --git a/skills/ironclaw-workflow-orchestrator/references/workflow-routines.md b/skills/ironclaw-workflow-orchestrator/references/workflow-routines.md
index 8afa857d..5e64a2b2 100644
--- a/skills/ironclaw-workflow-orchestrator/references/workflow-routines.md
+++ b/skills/ironclaw-workflow-orchestrator/references/workflow-routines.md
@@ -8,15 +8,21 @@ Replace `{{...}}` placeholders before use.
{
"name": "wf-issue-plan",
"description": "Create implementation plan when a new issue arrives",
- "trigger_type": "system_event",
- "event_source": "github",
- "event_type": "issue.opened",
- "event_filters": {
- "repository_name": "{{repository}}"
- },
- "action_type": "full_job",
"prompt": "For issue #{{issue_number}} in {{repository}}, produce a concrete implementation plan with milestones, edge cases, and tests. Post/update an issue comment with the plan.",
- "cooldown_secs": 30
+ "request": {
+ "kind": "system_event",
+ "source": "github",
+ "event_type": "issue.opened",
+ "filters": {
+ "repository_name": "{{repository}}"
+ }
+ },
+ "execution": {
+ "mode": "full_job"
+ },
+ "advanced": {
+ "cooldown_secs": 30
+ }
}
```
@@ -28,16 +34,22 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
{
"name": "wf-maintainer-comment-gate-{{maintainer}}",
"description": "React to maintainer guidance comments on issues/PRs",
- "trigger_type": "system_event",
- "event_source": "github",
- "event_type": "pr.comment.created",
- "event_filters": {
- "repository_name": "{{repository}}",
- "comment_author": "{{maintainer}}"
- },
- "action_type": "full_job",
"prompt": "Read the maintainer comment and decide: update plan or start/continue implementation. If plan changes are requested, edit the plan artifact first. If implementation is requested, continue on the feature branch and update PR status/comment.",
- "cooldown_secs": 20
+ "request": {
+ "kind": "system_event",
+ "source": "github",
+ "event_type": "pr.comment.created",
+ "filters": {
+ "repository_name": "{{repository}}",
+ "comment_author": "{{maintainer}}"
+ }
+ },
+ "execution": {
+ "mode": "full_job"
+ },
+ "advanced": {
+ "cooldown_secs": 20
+ }
}
```
@@ -47,15 +59,21 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
{
"name": "wf-pr-monitor-loop",
"description": "Keep PR healthy: address review comments and refresh branch",
- "trigger_type": "system_event",
- "event_source": "github",
- "event_type": "pr.synchronize",
- "event_filters": {
- "repository_name": "{{repository}}"
- },
- "action_type": "full_job",
"prompt": "For PR #{{pr_number}}, collect open review comments and unresolved threads, apply fixes, push branch updates, and summarize remaining blockers. If conflict with {{main_branch}}, rebase/merge from origin/{{main_branch}} and resolve safely.",
- "cooldown_secs": 20
+ "request": {
+ "kind": "system_event",
+ "source": "github",
+ "event_type": "pr.synchronize",
+ "filters": {
+ "repository_name": "{{repository}}"
+ }
+ },
+ "execution": {
+ "mode": "full_job"
+ },
+ "advanced": {
+ "cooldown_secs": 20
+ }
}
```
@@ -65,16 +83,22 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
{
"name": "wf-ci-fix-loop",
"description": "Fix failing CI checks on active PRs",
- "trigger_type": "system_event",
- "event_source": "github",
- "event_type": "ci.check_run.completed",
- "event_filters": {
- "repository_name": "{{repository}}",
- "ci_conclusion": "failure"
- },
- "action_type": "full_job",
"prompt": "Find failing check details for PR #{{pr_number}}, implement minimal safe fixes, rerun or await CI, and post concise status updates. Prioritize deterministic and test-backed fixes.",
- "cooldown_secs": 20
+ "request": {
+ "kind": "system_event",
+ "source": "github",
+ "event_type": "ci.check_run.completed",
+ "filters": {
+ "repository_name": "{{repository}}",
+ "ci_conclusion": "failure"
+ }
+ },
+ "execution": {
+ "mode": "full_job"
+ },
+ "advanced": {
+ "cooldown_secs": 20
+ }
}
```
@@ -84,11 +108,17 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
{
"name": "wf-staging-batch-review",
"description": "Batch correctness review through staging, then merge to main",
- "trigger_type": "cron",
- "schedule": "0 0 */{{batch_interval_hours}} * * *",
- "action_type": "full_job",
"prompt": "Every cycle: list ready PRs, merge ready ones into {{staging_branch}}, run deep correctness analysis in batch, fix discovered issues on affected branches, ensure CI green, then merge {{staging_branch}} into {{main_branch}} if clean.",
- "cooldown_secs": 120
+ "request": {
+ "kind": "cron",
+ "schedule": "0 0 */{{batch_interval_hours}} * * *"
+ },
+ "execution": {
+ "mode": "full_job"
+ },
+ "advanced": {
+ "cooldown_secs": 120
+ }
}
```
@@ -98,16 +128,22 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
{
"name": "wf-learning-memory",
"description": "Capture merge learnings into shared memory",
- "trigger_type": "system_event",
- "event_source": "github",
- "event_type": "pr.closed",
- "event_filters": {
- "repository_name": "{{repository}}",
- "pr_merged": "true"
- },
- "action_type": "full_job",
"prompt": "From merged PR #{{pr_number}}, extract preventable mistakes, reviewer themes, CI failure causes, and successful patterns. Write/update a shared memory doc with actionable rules to reduce cycle time and regressions.",
- "cooldown_secs": 30
+ "request": {
+ "kind": "system_event",
+ "source": "github",
+ "event_type": "pr.closed",
+ "filters": {
+ "repository_name": "{{repository}}",
+ "pr_merged": "true"
+ }
+ },
+ "execution": {
+ "mode": "full_job"
+ },
+ "advanced": {
+ "cooldown_secs": 30
+ }
}
```
@@ -115,7 +151,7 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
```json
{
- "source": "github",
+ "event_source": "github",
"event_type": "issue.opened",
"payload": {
"repository_name": "{{repository}}",
diff --git a/skills/routine-advisor/SKILL.md b/skills/routine-advisor/SKILL.md
new file mode 100644
index 00000000..3bb10c72
--- /dev/null
+++ b/skills/routine-advisor/SKILL.md
@@ -0,0 +1,118 @@
+---
+name: routine-advisor
+version: 0.1.0
+description: Suggests relevant cron routines based on user context, goals, and observed patterns
+activation:
+ keywords:
+ - every day
+ - every morning
+ - every week
+ - routine
+ - automate
+ - remind me
+ - check daily
+ - monitor
+ - recurring
+ - schedule
+ - habit
+ - workflow
+ - keep forgetting
+ - always have to
+ - repetitive
+ - notifications
+ - digest
+ - summary
+ - review daily
+ - weekly review
+ patterns:
+ - "I (always|usually|often|regularly) (check|do|look at|review)"
+ - "every (morning|evening|week|day|monday|friday)"
+ - "I (wish|want) (I|it) (could|would) (automatically|auto)"
+ - "is there a way to (auto|schedule|set up)"
+ - "can you (check|monitor|watch|track).*for me"
+ - "I keep (forgetting|missing|having to)"
+ tags:
+ - automation
+ - scheduling
+ - personal-assistant
+ - productivity
+ max_context_tokens: 1500
+---
+
+# Routine Advisor
+
+When the conversation suggests the user has a repeatable task or could benefit from automation, consider suggesting a routine.
+
+## When to Suggest
+
+Suggest a routine when you notice:
+- The user describes doing something repeatedly ("I check my PRs every morning")
+- The user mentions forgetting recurring tasks ("I keep forgetting to...")
+- The user asks you to do something that sounds periodic
+- You've learned enough about the user to propose a relevant automation
+- The user has installed extensions that enable new monitoring capabilities
+
+## How to Suggest
+
+Be specific and concrete. Not "Want me to set up a routine?" but rather: "I noticed you review PRs every morning. Want me to create a daily 9am routine that checks your open PRs and sends you a summary?"
+
+Always include:
+1. What the routine would do (specific action)
+2. When it would run (specific schedule in plain language)
+3. How it would notify them (which channel they're on)
+
+Wait for the user to confirm before creating.
+
+## Pacing
+
+- First 1-3 conversations: Do NOT suggest routines. Focus on helping and learning.
+- After learning 2-3 user patterns: Suggest your first routine. Keep it simple.
+- After 5+ conversations: Suggest more routines as patterns emerge.
+- Never suggest more than 1 routine per conversation unless the user is clearly interested.
+- If the user declines, wait at least 3 conversations before suggesting again.
+
+## Creating Routines
+
+Use the `routine_create` tool. Before creating, check `routine_list` to avoid duplicates.
+
+Parameters:
+- `trigger_type`: Usually "cron" for scheduled tasks
+- `schedule`: Standard cron format. Common schedules:
+ - Daily 9am: `0 9 * * *`
+ - Weekday mornings: `0 9 * * MON-FRI`
+ - Weekly Monday: `0 9 * * MON`
+ - Every 2 hours during work: `0 9-17/2 * * MON-FRI`
+ - Sunday evening: `0 18 * * SUN`
+- `action_type`: "lightweight" for simple checks, "full_job" for multi-step tasks
+- `prompt`: Clear, specific instruction for what the routine should do
+- `context_paths`: Workspace files to load as context (e.g., `["context/profile.json", "MEMORY.md"]`)
+
+## Routine Ideas by User Type
+
+**Developer:**
+- Daily PR review digest (check open PRs, summarize what needs attention)
+- CI/CD failure alerts (monitor build status)
+- Weekly dependency update check
+- Daily standup prep (summarize yesterday's work from daily logs)
+
+**Professional:**
+- Morning briefing (today's priorities from memory + any pending tasks)
+- End-of-day summary (what was accomplished, what's pending)
+- Weekly goal review (check progress against stated goals)
+- Meeting prep reminders
+
+**Health/Personal:**
+- Daily exercise or habit check-in
+- Weekly meal planning prompt
+- Monthly budget review reminder
+
+**General:**
+- Daily news digest on topics of interest
+- Weekly reflection prompt (what went well, what to improve)
+- Periodic task/reminder check-in
+- Regular cleanup of stale tasks or notes
+- Weekly profile evolution (if the user has a profile in `context/profile.json`, suggest a Monday routine that reads the profile via `memory_read`, searches recent conversations for new patterns with `memory_search`, and updates the profile via `memory_write` if any fields should change with confidence > 0.6 — be conservative, only update with clear evidence)
+
+## Awareness
+
+Before suggesting, consider what tools and extensions are currently available. Only suggest routines the agent can actually execute. If a routine would need a tool that isn't installed, mention that too: "If you connect your calendar, I could also send you a morning briefing with today's meetings."
diff --git a/src/agent/CLAUDE.md b/src/agent/CLAUDE.md
index e55c9591..686753de 100644
--- a/src/agent/CLAUDE.md
+++ b/src/agent/CLAUDE.md
@@ -113,7 +113,7 @@ Check-insert is done under a single write lock to prevent TOCTOU races. A cleanu
4. Detects broken tools via `store.get_broken_tools(5)` (threshold: 5 failures). Requires `with_store()` to be called; returns empty without a store.
5. Attempts to rebuild broken tools via `SoftwareBuilder`. Requires `with_builder()` to be called; returns `ManualRequired` without a builder.
-Note: the `stuck_threshold` duration is stored but currently unused (marked `#[allow(dead_code)]`). Stuck detection relies on `JobState::Stuck` being set by the state machine, not wall-clock time comparison.
+The `stuck_threshold` duration is used for time-based detection of `InProgress` jobs that have been running longer than the threshold. When `detect_stuck_jobs()` finds such jobs, it transitions them to `Stuck` before returning them, enabling the normal `attempt_recovery()` path.
Repair results: `Success`, `Retry`, `Failed`, `ManualRequired`. `Retry` does NOT notify the user (to avoid spam).
diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs
index 83d971ef..a0e8278f 100644
--- a/src/agent/agent_loop.rs
+++ b/src/agent/agent_loop.rs
@@ -10,6 +10,7 @@
use std::sync::Arc;
use futures::StreamExt;
+use uuid::Uuid;
use crate::agent::context_monitor::ContextMonitor;
use crate::agent::heartbeat::spawn_heartbeat;
@@ -17,7 +18,7 @@ use crate::agent::routine_engine::{RoutineEngine, spawn_cron_ticker};
use crate::agent::self_repair::{DefaultSelfRepair, RepairResult, SelfRepair};
use crate::agent::session_manager::SessionManager;
use crate::agent::submission::{Submission, SubmissionParser, SubmissionResult};
-use crate::agent::{HeartbeatConfig as AgentHeartbeatConfig, Router, Scheduler};
+use crate::agent::{HeartbeatConfig as AgentHeartbeatConfig, Router, Scheduler, SchedulerDeps};
use crate::channels::{ChannelManager, IncomingMessage, OutgoingResponse};
use crate::config::{AgentConfig, HeartbeatConfig, RoutineConfig, SkillsConfig};
use crate::context::ContextManager;
@@ -31,6 +32,13 @@ use crate::skills::SkillRegistry;
use crate::tools::ToolRegistry;
use crate::workspace::Workspace;
+/// Static greeting persisted to DB and broadcast on first launch.
+///
+/// Sent before the LLM is involved so the user sees something immediately.
+/// The conversational onboarding (profile building, channel setup) happens
+/// organically in the subsequent turns driven by BOOTSTRAP.md.
+const BOOTSTRAP_GREETING: &str = include_str!("../workspace/seeds/GREETING.md");
+
/// Collapse a tool output string into a single-line preview for display.
pub(crate) fn truncate_for_preview(output: &str, max_chars: usize) -> String {
let collapsed: String = output
@@ -113,6 +121,17 @@ async fn resolve_routine_notification_target(
.await
}
+pub(crate) fn chat_tool_execution_metadata(message: &IncomingMessage) -> serde_json::Value {
+ serde_json::json!({
+ "notify_channel": message.channel,
+ "notify_user": message
+ .routing_target()
+ .unwrap_or_else(|| message.user_id.clone()),
+ "notify_thread_id": message.thread_id,
+ "notify_metadata": message.metadata,
+ })
+}
+
fn should_fallback_routine_notification(error: &ChannelError) -> bool {
!matches!(error, ChannelError::MissingRoutingTarget { .. })
}
@@ -146,6 +165,10 @@ pub struct AgentDeps {
pub transcription: Option>,
/// Document text extraction middleware for PDF, DOCX, PPTX, etc.
pub document_extraction: Option>,
+ /// Sandbox readiness state for full-job routine dispatch.
+ pub sandbox_readiness: crate::agent::routine_engine::SandboxReadiness,
+ /// Software builder for self-repair tool rebuilding.
+ pub builder: Option>,
}
/// The main agent that coordinates all components.
@@ -161,9 +184,10 @@ pub struct Agent {
pub(super) heartbeat_config: Option,
pub(super) hygiene_config: Option,
pub(super) routine_config: Option,
- /// Optional slot to expose the routine engine to the gateway for manual triggering.
+ /// Shared routine-engine slot used for internal event matching and for exposing
+ /// the engine to gateway/manual trigger entry points.
pub(super) routine_engine_slot:
- Option>>>>,
+ Arc>>>,
}
impl Agent {
@@ -204,9 +228,12 @@ impl Agent {
context_manager.clone(),
deps.llm.clone(),
deps.safety.clone(),
- deps.tools.clone(),
- deps.store.clone(),
- deps.hooks.clone(),
+ SchedulerDeps {
+ tools: deps.tools.clone(),
+ extension_manager: deps.extension_manager.clone(),
+ store: deps.store.clone(),
+ hooks: deps.hooks.clone(),
+ },
);
if let Some(ref tx) = deps.sse_tx {
scheduler.set_sse_sender(tx.clone());
@@ -228,16 +255,21 @@ impl Agent {
heartbeat_config,
hygiene_config,
routine_config,
- routine_engine_slot: None,
+ routine_engine_slot: Arc::new(tokio::sync::RwLock::new(None)),
}
}
- /// Set the routine engine slot for exposing the engine to the gateway.
+ /// Replace the routine-engine slot with a shared one so the gateway and
+ /// agent reference the same engine.
pub fn set_routine_engine_slot(
&mut self,
slot: Arc>>>,
) {
- self.routine_engine_slot = Some(slot);
+ self.routine_engine_slot = slot;
+ }
+
+ async fn routine_engine(&self) -> Option> {
+ self.routine_engine_slot.read().await.clone()
}
// Convenience accessors
@@ -330,15 +362,48 @@ impl Agent {
/// Run the agent main loop.
pub async fn run(self) -> Result<(), Error> {
+ // Proactive bootstrap: persist the static greeting to DB *before*
+ // starting channels so the first web client sees it via history.
+ let bootstrap_thread_id = if self
+ .workspace()
+ .is_some_and(|ws| ws.take_bootstrap_pending())
+ {
+ tracing::debug!(
+ "Fresh workspace detected — persisting static bootstrap greeting to DB"
+ );
+ if let Some(store) = self.store() {
+ let thread_id = store
+ .get_or_create_assistant_conversation("default", "gateway")
+ .await
+ .ok();
+ if let Some(id) = thread_id {
+ self.persist_assistant_response(id, "gateway", "default", BOOTSTRAP_GREETING)
+ .await;
+ }
+ thread_id
+ } else {
+ None
+ }
+ } else {
+ None
+ };
+
// Start channels
let mut message_stream = self.channels.start_all().await?;
// Start self-repair task with notification forwarding
- let repair = Arc::new(DefaultSelfRepair::new(
+ let mut self_repair = DefaultSelfRepair::new(
self.context_manager.clone(),
self.config.stuck_threshold,
self.config.max_repair_attempts,
- ));
+ );
+ if let Some(ref store) = self.deps.store {
+ self_repair = self_repair.with_store(Arc::clone(store));
+ }
+ if let Some(ref builder) = self.deps.builder {
+ self_repair = self_repair.with_builder(Arc::clone(builder), Arc::clone(self.tools()));
+ }
+ let repair = Arc::new(self_repair);
let repair_interval = self.config.repair_check_interval;
let repair_channels = self.channels.clone();
let repair_owner_id = self.owner_id().to_string();
@@ -539,8 +604,10 @@ impl Agent {
Arc::clone(workspace),
notify_tx,
Some(self.scheduler.clone()),
+ self.deps.extension_manager.clone(),
self.tools().clone(),
self.safety().clone(),
+ self.deps.sandbox_readiness,
));
// Register routine tools
@@ -633,9 +700,7 @@ impl Agent {
// via a local to use in the message loop below.
// Expose engine to gateway for manual triggering
- if let Some(ref slot) = self.routine_engine_slot {
- *slot.write().await = Some(Arc::clone(&engine));
- }
+ *self.routine_engine_slot.write().await = Some(Arc::clone(&engine));
tracing::debug!(
"Routines enabled: cron ticker every {}s, max {} concurrent",
@@ -655,8 +720,29 @@ impl Agent {
None
};
- // Extract engine ref for use in message loop
- let routine_engine_for_loop = routine_handle.as_ref().map(|(_, e)| Arc::clone(e));
+ // Bootstrap phase 2: register the thread in session manager and
+ // broadcast the greeting via SSE for any clients already connected.
+ // The greeting was already persisted to DB before start_all(), so
+ // clients that connect after this point will see it via history.
+ if let Some(id) = bootstrap_thread_id {
+ // Use get_or_create_session (not resolve_thread) to avoid creating
+ // an orphan thread. Then insert the DB-sourced thread directly.
+ let session = self.session_manager.get_or_create_session("default").await;
+ {
+ use crate::agent::session::Thread;
+ let mut sess = session.lock().await;
+ let thread = Thread::with_id(id, sess.id);
+ sess.active_thread = Some(id);
+ sess.threads.entry(id).or_insert(thread);
+ }
+ self.session_manager
+ .register_thread("default", "gateway", id, session)
+ .await;
+
+ let mut out = OutgoingResponse::text(BOOTSTRAP_GREETING.to_string());
+ out.thread_id = Some(id.to_string());
+ let _ = self.channels.broadcast("gateway", "default", out).await;
+ }
// Main message loop
tracing::debug!("Agent {} ready and listening", self.config.name);
@@ -693,29 +779,6 @@ impl Agent {
// Store successfully extracted document text in workspace for indexing
self.store_extracted_documents(&message).await;
- // Event-triggered routines consume plain user input before it enters
- // the normal chat/tool pipeline. This avoids a duplicate turn where
- // the main agent responds and the routine also fires on the same
- // inbound message.
- if !message.is_internal
- && matches!(
- SubmissionParser::parse(&message.content),
- Submission::UserInput { .. }
- )
- && let Some(ref engine) = routine_engine_for_loop
- {
- let fired = engine.check_event_triggers(&message).await;
- if fired > 0 {
- tracing::debug!(
- channel = %message.channel,
- user = %message.user_id,
- fired,
- "Consumed inbound user message with matching event-triggered routine(s)"
- );
- continue;
- }
- }
-
match self.handle_message(&message).await {
Ok(Some(response)) if !response.is_empty() => {
// Hook: BeforeOutbound — allow hooks to modify or suppress outbound
@@ -874,9 +937,6 @@ impl Agent {
}
async fn handle_message(&self, message: &IncomingMessage) -> Result, Error> {
- // Log at info level only for tracking without exposing PII (user_id can be a phone number)
- tracing::info!(message_id = %message.id, "Processing message");
-
// Log sensitive details at debug level for troubleshooting
tracing::debug!(
message_id = %message.id,
@@ -955,19 +1015,59 @@ impl Agent {
}
}
- // Resolve session and thread
- tracing::debug!(
- message_id = %message.id,
- "Resolving session and thread"
- );
- let (session, thread_id) = self
- .session_manager
- .resolve_thread(
- &message.user_id,
- &message.channel,
- message.conversation_scope(),
- )
- .await;
+ // Resolve session and thread. Approval submissions are allowed to
+ // target an already-loaded owned thread by UUID across channels so the
+ // web approval UI can approve work that originated from HTTP/other
+ // owner-scoped channels.
+ let approval_thread_uuid = if matches!(
+ submission,
+ Submission::ExecApproval { .. } | Submission::ApprovalResponse { .. }
+ ) {
+ message
+ .conversation_scope()
+ .and_then(|thread_id| Uuid::parse_str(thread_id).ok())
+ } else {
+ None
+ };
+
+ let (session, thread_id) = if let Some(target_thread_id) = approval_thread_uuid {
+ let session = self
+ .session_manager
+ .get_or_create_session(&message.user_id)
+ .await;
+ let mut sess = session.lock().await;
+ if sess.threads.contains_key(&target_thread_id) {
+ sess.active_thread = Some(target_thread_id);
+ sess.last_active_at = chrono::Utc::now();
+ drop(sess);
+ self.session_manager
+ .register_thread(
+ &message.user_id,
+ &message.channel,
+ target_thread_id,
+ Arc::clone(&session),
+ )
+ .await;
+ (session, target_thread_id)
+ } else {
+ drop(sess);
+ self.session_manager
+ .resolve_thread(
+ &message.user_id,
+ &message.channel,
+ message.conversation_scope(),
+ )
+ .await
+ }
+ } else {
+ self.session_manager
+ .resolve_thread(
+ &message.user_id,
+ &message.channel,
+ message.conversation_scope(),
+ )
+ .await
+ };
tracing::debug!(
message_id = %message.id,
thread_id = %thread_id,
@@ -1032,6 +1132,24 @@ impl Agent {
message.content.len()
);
+ if !message.is_internal
+ && let Submission::UserInput { ref content } = submission
+ && let Some(engine) = self.routine_engine().await
+ {
+ let fired = engine
+ .check_event_triggers(&message.user_id, &message.channel, content)
+ .await;
+ if fired > 0 {
+ tracing::debug!(
+ channel = %message.channel,
+ user = %message.user_id,
+ fired,
+ "Consumed inbound user message with matching event-triggered routine(s)"
+ );
+ return Ok(Some(String::new()));
+ }
+ }
+
// Process based on submission type
let result = match submission {
Submission::UserInput { content } => {
@@ -1119,9 +1237,10 @@ impl Agent {
#[cfg(test)]
mod tests {
use super::{
- resolve_routine_notification_user, should_fallback_routine_notification,
- truncate_for_preview,
+ chat_tool_execution_metadata, resolve_routine_notification_user,
+ should_fallback_routine_notification, truncate_for_preview,
};
+ use crate::channels::IncomingMessage;
use crate::error::ChannelError;
#[test]
@@ -1217,6 +1336,50 @@ mod tests {
assert_eq!(resolve_routine_notification_user(&metadata), None); // safety: test-only assertion
}
+ #[test]
+ fn chat_tool_execution_metadata_prefers_message_routing_target() {
+ let message = IncomingMessage::new("telegram", "owner-scope", "hello")
+ .with_sender_id("telegram-user")
+ .with_thread("thread-7")
+ .with_metadata(serde_json::json!({
+ "chat_id": 424242,
+ "chat_type": "private",
+ }));
+
+ let metadata = chat_tool_execution_metadata(&message);
+ assert_eq!(
+ metadata.get("notify_channel").and_then(|v| v.as_str()),
+ Some("telegram")
+ ); // safety: test-only assertion
+ assert_eq!(
+ metadata.get("notify_user").and_then(|v| v.as_str()),
+ Some("424242")
+ ); // safety: test-only assertion
+ assert_eq!(
+ metadata.get("notify_thread_id").and_then(|v| v.as_str()),
+ Some("thread-7")
+ ); // safety: test-only assertion
+ }
+
+ #[test]
+ fn chat_tool_execution_metadata_falls_back_to_user_scope_without_route() {
+ let message = IncomingMessage::new("gateway", "owner-scope", "hello").with_sender_id("");
+
+ let metadata = chat_tool_execution_metadata(&message);
+ assert_eq!(
+ metadata.get("notify_channel").and_then(|v| v.as_str()),
+ Some("gateway")
+ ); // safety: test-only assertion
+ assert_eq!(
+ metadata.get("notify_user").and_then(|v| v.as_str()),
+ Some("owner-scope")
+ ); // safety: test-only assertion
+ assert_eq!(
+ metadata.get("notify_thread_id"),
+ Some(&serde_json::Value::Null)
+ ); // safety: test-only assertion
+ }
+
#[test]
fn targeted_routine_notifications_do_not_fallback_without_owner_route() {
let error = ChannelError::MissingRoutingTarget {
diff --git a/src/agent/dispatcher.rs b/src/agent/dispatcher.rs
index 9be0d654..fc3da61b 100644
--- a/src/agent/dispatcher.rs
+++ b/src/agent/dispatcher.rs
@@ -29,7 +29,7 @@ pub(super) enum AgenticLoopResult {
/// A tool requires approval before continuing.
NeedApproval {
/// The pending approval request to store.
- pending: PendingApproval,
+ pending: Box,
},
}
@@ -144,12 +144,7 @@ impl Agent {
.with_requester_id(&message.sender_id);
job_ctx.http_interceptor = self.deps.http_interceptor.clone();
job_ctx.user_timezone = user_tz.name().to_string();
- job_ctx.metadata = serde_json::json!({
- "notify_channel": message.channel,
- "notify_user": message.user_id,
- "notify_thread_id": message.thread_id,
- "notify_metadata": message.metadata,
- });
+ job_ctx.metadata = crate::agent::agent_loop::chat_tool_execution_metadata(message);
// Build system prompts once for this turn. Two variants: with tools
// (normal iterations) and without (force_text final iteration).
@@ -217,9 +212,7 @@ impl Agent {
reason: format!("Exceeded maximum tool iterations ({max_tool_iterations})"),
}
.into()),
- LoopOutcome::NeedApproval(pending) => {
- Ok(AgenticLoopResult::NeedApproval { pending: *pending })
- }
+ LoopOutcome::NeedApproval(pending) => Ok(AgenticLoopResult::NeedApproval { pending }),
}
}
@@ -482,6 +475,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
usize,
crate::llm::ToolCall,
Arc,
+ bool, // allow_always
)> = None;
for (idx, original_tc) in tool_calls.iter().enumerate() {
@@ -551,7 +545,8 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
&& let Some(tool) = tool_opt
{
use crate::tools::ApprovalRequirement;
- let needs_approval = match tool.requires_approval(&tc.arguments) {
+ let requirement = tool.requires_approval(&tc.arguments);
+ let needs_approval = match requirement {
ApprovalRequirement::Never => false,
ApprovalRequirement::UnlessAutoApproved => {
let sess = self.session.lock().await;
@@ -586,7 +581,8 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
continue;
}
- approval_needed = Some((idx, tc, tool));
+ let allow_always = !matches!(requirement, ApprovalRequirement::Always);
+ approval_needed = Some((idx, tc, tool, allow_always));
break;
}
}
@@ -887,7 +883,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
}
// Handle approval if a tool needed it
- if let Some((approval_idx, tc, tool)) = approval_needed {
+ if let Some((approval_idx, tc, tool, allow_always)) = approval_needed {
let display_params = redact_params(&tc.arguments, tool.sensitive_params());
let pending = PendingApproval {
request_id: Uuid::new_v4(),
@@ -899,6 +895,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
context_messages: reason_ctx.messages.clone(),
deferred_tool_calls: tool_calls[approval_idx + 1..].to_vec(),
user_timezone: Some(self.user_tz.name().to_string()),
+ allow_always,
};
return Ok(Some(LoopOutcome::NeedApproval(Box::new(pending))));
@@ -1197,6 +1194,8 @@ mod tests {
http_interceptor: None,
transcription: None,
document_extraction: None,
+ sandbox_readiness: crate::agent::routine_engine::SandboxReadiness::DisabledByConfig,
+ builder: None,
};
Agent::new(
@@ -1364,6 +1363,35 @@ mod tests {
assert!(always_needs, "Always must always require approval");
}
+ /// Regression test: `allow_always` must be `false` for `Always` and
+ /// `true` for `UnlessAutoApproved`, so the UI hides the "always" button
+ /// for tools that truly cannot be auto-approved.
+ #[test]
+ fn test_allow_always_matches_approval_requirement() {
+ use crate::tools::ApprovalRequirement;
+
+ // Mirrors the expression used in dispatcher.rs and thread_ops.rs:
+ // let allow_always = !matches!(requirement, ApprovalRequirement::Always);
+
+ // UnlessAutoApproved → allow_always = true
+ let req = ApprovalRequirement::UnlessAutoApproved;
+ let allow_always = !matches!(req, ApprovalRequirement::Always);
+ assert!(
+ allow_always,
+ "UnlessAutoApproved should set allow_always = true"
+ );
+
+ // Always → allow_always = false
+ let req = ApprovalRequirement::Always;
+ let allow_always = !matches!(req, ApprovalRequirement::Always);
+ assert!(!allow_always, "Always should set allow_always = false");
+
+ // Never → allow_always = true (approval is never needed, but if it were, always would be ok)
+ let req = ApprovalRequirement::Never;
+ let allow_always = !matches!(req, ApprovalRequirement::Always);
+ assert!(allow_always, "Never should set allow_always = true");
+ }
+
#[test]
fn test_pending_approval_serialization_backcompat_without_deferred_calls() {
// PendingApproval from before the deferred_tool_calls field was added
@@ -1409,6 +1437,7 @@ mod tests {
},
],
user_timezone: None,
+ allow_always: true,
};
let json = serde_json::to_string(&pending).expect("serialize");
@@ -2037,6 +2066,8 @@ mod tests {
http_interceptor: None,
transcription: None,
document_extraction: None,
+ sandbox_readiness: crate::agent::routine_engine::SandboxReadiness::DisabledByConfig,
+ builder: None,
};
Agent::new(
@@ -2155,6 +2186,8 @@ mod tests {
http_interceptor: None,
transcription: None,
document_extraction: None,
+ sandbox_readiness: crate::agent::routine_engine::SandboxReadiness::DisabledByConfig,
+ builder: None,
};
Agent::new(
diff --git a/src/agent/job_monitor.rs b/src/agent/job_monitor.rs
index 714caeac..675d0426 100644
--- a/src/agent/job_monitor.rs
+++ b/src/agent/job_monitor.rs
@@ -14,12 +14,15 @@
//! Agent Loop
//! ```
+use std::sync::Arc;
+
use tokio::sync::{broadcast, mpsc};
use tokio::task::JoinHandle;
use uuid::Uuid;
use crate::channels::IncomingMessage;
use crate::channels::web::types::SseEvent;
+use crate::context::{ContextManager, JobState};
/// Route context for forwarding job monitor events back to the user's channel.
#[derive(Debug, Clone)]
@@ -40,10 +43,23 @@ pub struct JobMonitorRoute {
/// Tool use/result and status events are intentionally skipped (too noisy for
/// the main agent's context window).
pub fn spawn_job_monitor(
+ job_id: Uuid,
+ event_rx: broadcast::Receiver<(Uuid, SseEvent)>,
+ inject_tx: mpsc::Sender,
+ route: JobMonitorRoute,
+) -> JoinHandle<()> {
+ spawn_job_monitor_with_context(job_id, event_rx, inject_tx, route, None)
+}
+
+/// Like `spawn_job_monitor`, but also transitions the job's in-memory state
+/// when it receives a `JobResult` event. This ensures fire-and-forget sandbox
+/// jobs don't stay `InProgress` forever in the `ContextManager`.
+pub fn spawn_job_monitor_with_context(
job_id: Uuid,
mut event_rx: broadcast::Receiver<(Uuid, SseEvent)>,
inject_tx: mpsc::Sender,
route: JobMonitorRoute,
+ context_manager: Option>,
) -> JoinHandle<()> {
let short_id = job_id.to_string()[..8].to_string();
@@ -77,6 +93,26 @@ pub fn spawn_job_monitor(
}
}
SseEvent::JobResult { status, .. } => {
+ // Transition in-memory state so the job frees its
+ // max_jobs slot and query tools show the final state.
+ if let Some(ref cm) = context_manager {
+ let target = if status == "completed" {
+ JobState::Completed
+ } else {
+ JobState::Failed
+ };
+ let reason = if status != "completed" {
+ Some(format!("Container finished: {}", status))
+ } else {
+ None
+ };
+ let _ = cm
+ .update_context(job_id, |ctx| {
+ let _ = ctx.transition_to(target, reason);
+ })
+ .await;
+ }
+
let mut msg = IncomingMessage::new(
route.channel.clone(),
route.user_id.clone(),
@@ -121,6 +157,62 @@ pub fn spawn_job_monitor(
})
}
+/// Lightweight watcher that only transitions ContextManager state on job
+/// completion. Used when monitor routing metadata is absent (no channel to
+/// inject messages into) but we still need to free the `max_jobs` slot.
+pub fn spawn_completion_watcher(
+ job_id: Uuid,
+ mut event_rx: broadcast::Receiver<(Uuid, SseEvent)>,
+ context_manager: Arc,
+) -> JoinHandle<()> {
+ let short_id = job_id.to_string()[..8].to_string();
+
+ tokio::spawn(async move {
+ loop {
+ match event_rx.recv().await {
+ Ok((ev_job_id, SseEvent::JobResult { status, .. })) if ev_job_id == job_id => {
+ let target = if status == "completed" {
+ JobState::Completed
+ } else {
+ JobState::Failed
+ };
+ let reason = if status != "completed" {
+ Some(format!("Container finished: {}", status))
+ } else {
+ None
+ };
+ let _ = context_manager
+ .update_context(job_id, |ctx| {
+ let _ = ctx.transition_to(target, reason);
+ })
+ .await;
+ tracing::debug!(
+ job_id = %short_id,
+ status = %status,
+ "Completion watcher exiting (job finished)"
+ );
+ break;
+ }
+ Ok(_) => {}
+ Err(broadcast::error::RecvError::Lagged(n)) => {
+ tracing::warn!(
+ job_id = %short_id,
+ skipped = n,
+ "Completion watcher lagged"
+ );
+ }
+ Err(broadcast::error::RecvError::Closed) => {
+ tracing::debug!(
+ job_id = %short_id,
+ "Broadcast channel closed, stopping completion watcher"
+ );
+ break;
+ }
+ }
+ }
+ })
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -211,6 +303,7 @@ mod tests {
job_id: job_id.to_string(),
status: "completed".to_string(),
session_id: None,
+ fallback_deliverable: None,
},
))
.unwrap();
@@ -293,4 +386,139 @@ mod tests {
let msg = IncomingMessage::new("monitor", "system", "test").into_internal();
assert!(msg.is_internal);
}
+
+ // === Regression: fire-and-forget sandbox jobs must transition out of InProgress ===
+ // Before this fix, spawn_job_monitor only forwarded SSE messages but never
+ // updated ContextManager. Background sandbox jobs stayed InProgress forever,
+ // permanently consuming a max_jobs slot.
+
+ #[tokio::test]
+ async fn test_monitor_transitions_context_on_completion() {
+ use crate::context::{ContextManager, JobState};
+
+ let cm = Arc::new(ContextManager::new(5));
+ let job_id = Uuid::new_v4();
+ cm.register_sandbox_job(job_id, "user-1", "Build app", "desc")
+ .await
+ .unwrap();
+
+ let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
+ let (inject_tx, mut inject_rx) = mpsc::channel::(16);
+
+ let handle = spawn_job_monitor_with_context(
+ job_id,
+ event_tx.subscribe(),
+ inject_tx,
+ test_route(),
+ Some(Arc::clone(&cm)),
+ );
+
+ // Send completion event
+ event_tx
+ .send((
+ job_id,
+ SseEvent::JobResult {
+ job_id: job_id.to_string(),
+ status: "completed".to_string(),
+ session_id: None,
+ fallback_deliverable: None,
+ },
+ ))
+ .unwrap();
+
+ // Drain the injected message
+ let _ = tokio::time::timeout(std::time::Duration::from_secs(1), inject_rx.recv()).await;
+
+ // Wait for monitor to exit
+ tokio::time::timeout(std::time::Duration::from_secs(1), handle)
+ .await
+ .expect("monitor should exit")
+ .expect("monitor should not panic");
+
+ // Job should now be Completed, not InProgress
+ let ctx = cm.get_context(job_id).await.unwrap();
+ assert_eq!(ctx.state, JobState::Completed);
+ }
+
+ #[tokio::test]
+ async fn test_monitor_transitions_context_on_failure() {
+ use crate::context::{ContextManager, JobState};
+
+ let cm = Arc::new(ContextManager::new(5));
+ let job_id = Uuid::new_v4();
+ cm.register_sandbox_job(job_id, "user-1", "Build app", "desc")
+ .await
+ .unwrap();
+
+ let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
+ let (inject_tx, mut inject_rx) = mpsc::channel::(16);
+
+ let handle = spawn_job_monitor_with_context(
+ job_id,
+ event_tx.subscribe(),
+ inject_tx,
+ test_route(),
+ Some(Arc::clone(&cm)),
+ );
+
+ // Send failure event
+ event_tx
+ .send((
+ job_id,
+ SseEvent::JobResult {
+ job_id: job_id.to_string(),
+ status: "failed".to_string(),
+ session_id: None,
+ fallback_deliverable: None,
+ },
+ ))
+ .unwrap();
+
+ let _ = tokio::time::timeout(std::time::Duration::from_secs(1), inject_rx.recv()).await;
+ tokio::time::timeout(std::time::Duration::from_secs(1), handle)
+ .await
+ .expect("monitor should exit")
+ .expect("monitor should not panic");
+
+ let ctx = cm.get_context(job_id).await.unwrap();
+ assert_eq!(ctx.state, JobState::Failed);
+ }
+
+ // === Regression: completion watcher (no route metadata) ===
+ // When monitor_route_from_ctx() returns None, spawn_completion_watcher
+ // must still transition the job so the max_jobs slot is freed.
+
+ #[tokio::test]
+ async fn test_completion_watcher_transitions_on_result() {
+ use crate::context::{ContextManager, JobState};
+
+ let cm = Arc::new(ContextManager::new(5));
+ let job_id = Uuid::new_v4();
+ cm.register_sandbox_job(job_id, "user-1", "Build app", "desc")
+ .await
+ .unwrap();
+
+ let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
+ let handle = spawn_completion_watcher(job_id, event_tx.subscribe(), Arc::clone(&cm));
+
+ event_tx
+ .send((
+ job_id,
+ SseEvent::JobResult {
+ job_id: job_id.to_string(),
+ status: "completed".to_string(),
+ session_id: None,
+ fallback_deliverable: None,
+ },
+ ))
+ .unwrap();
+
+ tokio::time::timeout(std::time::Duration::from_secs(1), handle)
+ .await
+ .expect("watcher should exit")
+ .expect("watcher should not panic");
+
+ let ctx = cm.get_context(job_id).await.unwrap();
+ assert_eq!(ctx.state, JobState::Completed);
+ }
}
diff --git a/src/agent/mod.rs b/src/agent/mod.rs
index ee980233..84155666 100644
--- a/src/agent/mod.rs
+++ b/src/agent/mod.rs
@@ -39,8 +39,8 @@ pub use context_monitor::{CompactionStrategy, ContextBreakdown, ContextMonitor};
pub use heartbeat::{HeartbeatConfig, HeartbeatResult, HeartbeatRunner, spawn_heartbeat};
pub use router::{MessageIntent, Router};
pub use routine::{Routine, RoutineAction, RoutineRun, Trigger};
-pub use routine_engine::RoutineEngine;
-pub use scheduler::Scheduler;
+pub use routine_engine::{RoutineEngine, SandboxReadiness};
+pub use scheduler::{Scheduler, SchedulerDeps};
pub use self_repair::{BrokenTool, RepairResult, RepairTask, SelfRepair, StuckJob};
pub use session::{PendingApproval, PendingAuth, Session, Thread, ThreadState, Turn, TurnState};
pub use session_manager::SessionManager;
diff --git a/src/agent/routine.rs b/src/agent/routine.rs
index f3850fa0..1b8ca96a 100644
--- a/src/agent/routine.rs
+++ b/src/agent/routine.rs
@@ -79,6 +79,13 @@ pub enum Trigger {
#[serde(default)]
filters: std::collections::HashMap,
},
+ /// Fire on incoming webhook POST to /api/webhooks/{path}.
+ Webhook {
+ /// Optional webhook path suffix (defaults to routine id).
+ path: Option,
+ /// Optional shared secret for HMAC validation.
+ secret: Option,
+ },
/// Only fires via tool call or CLI.
Manual,
}
@@ -90,6 +97,7 @@ impl Trigger {
Trigger::Cron { .. } => "cron",
Trigger::Event { .. } => "event",
Trigger::SystemEvent { .. } => "system_event",
+ Trigger::Webhook { .. } => "webhook",
Trigger::Manual => "manual",
}
}
@@ -171,6 +179,17 @@ impl Trigger {
filters,
})
}
+ "webhook" => {
+ let path = config
+ .get("path")
+ .and_then(|v| v.as_str())
+ .map(String::from);
+ let secret = config
+ .get("secret")
+ .and_then(|v| v.as_str())
+ .map(String::from);
+ Ok(Trigger::Webhook { path, secret })
+ }
"manual" => Ok(Trigger::Manual),
other => Err(RoutineError::UnknownTriggerType {
trigger_type: other.to_string(),
@@ -198,6 +217,10 @@ impl Trigger {
"event_type": event_type,
"filters": filters,
}),
+ Trigger::Webhook { path, secret } => serde_json::json!({
+ "path": path,
+ "secret": secret,
+ }),
Trigger::Manual => serde_json::json!({}),
}
}
@@ -235,11 +258,6 @@ pub enum RoutineAction {
/// Max reasoning iterations (default: 10).
#[serde(default = "default_max_iterations")]
max_iterations: u32,
- /// Tool names pre-authorized for `Always`-approval tools (e.g. destructive
- /// shell commands, cross-channel messaging). `UnlessAutoApproved` tools are
- /// automatically permitted in routine jobs without listing them here.
- #[serde(default)]
- tool_permissions: Vec,
},
}
@@ -264,19 +282,6 @@ fn clamp_max_tool_rounds(value: u64) -> u32 {
value.clamp(1, MAX_TOOL_ROUNDS_LIMIT as u64) as u32
}
-/// Parse a `tool_permissions` JSON array into a `Vec`.
-pub fn parse_tool_permissions(value: &serde_json::Value) -> Vec {
- value
- .get("tool_permissions")
- .and_then(|v| v.as_array())
- .map(|arr| {
- arr.iter()
- .filter_map(|v| v.as_str().map(String::from))
- .collect()
- })
- .unwrap_or_default()
-}
-
impl RoutineAction {
/// The string tag stored in the DB action_type column.
pub fn type_tag(&self) -> &'static str {
@@ -351,12 +356,10 @@ impl RoutineAction {
.and_then(|v| v.as_u64())
.unwrap_or(default_max_iterations() as u64)
as u32;
- let tool_permissions = parse_tool_permissions(&config);
Ok(RoutineAction::FullJob {
title,
description,
max_iterations,
- tool_permissions,
})
}
other => Err(RoutineError::UnknownActionType {
@@ -385,12 +388,10 @@ impl RoutineAction {
title,
description,
max_iterations,
- tool_permissions,
} => serde_json::json!({
"title": title,
"description": description,
"max_iterations": max_iterations,
- "tool_permissions": tool_permissions,
}),
}
}
@@ -516,16 +517,36 @@ pub fn content_hash(content: &str) -> u64 {
hasher.finish()
}
+/// Normalize a cron expression to the 7-field format expected by the `cron` crate.
+///
+/// The `cron` crate requires: `sec min hour day-of-month month day-of-week year`.
+/// Standard cron uses 5 fields: `min hour day-of-month month day-of-week`.
+/// This function auto-expands:
+/// - 5-field → prepend `0` (seconds) and append `*` (year)
+/// - 6-field → append `*` (year)
+/// - 7-field → pass through unchanged
+pub fn normalize_cron_expression(schedule: &str) -> String {
+ let trimmed = schedule.trim();
+ let fields: Vec<&str> = trimmed.split_whitespace().collect();
+ match fields.len() {
+ 5 => format!("0 {} *", trimmed),
+ 6 => format!("{} *", trimmed),
+ _ => trimmed.to_string(),
+ }
+}
+
/// Parse a cron expression and compute the next fire time from now.
///
+/// Accepts standard 5-field, 6-field, or 7-field cron expressions (auto-normalized).
/// When `timezone` is provided and valid, the schedule is evaluated in that
/// timezone and the result is converted back to UTC. Otherwise UTC is used.
pub fn next_cron_fire(
schedule: &str,
timezone: Option<&str>,
) -> Result>, RoutineError> {
+ let normalized = normalize_cron_expression(schedule);
let cron_schedule =
- cron::Schedule::from_str(schedule).map_err(|e| RoutineError::InvalidCron {
+ cron::Schedule::from_str(&normalized).map_err(|e| RoutineError::InvalidCron {
reason: e.to_string(),
})?;
if let Some(tz) = timezone.and_then(crate::timezone::parse_timezone) {
@@ -705,7 +726,7 @@ pub fn describe_cron(schedule: &str, timezone: Option<&str>) -> String {
mod tests {
use crate::agent::routine::{
MAX_TOOL_ROUNDS_LIMIT, RoutineAction, RoutineGuardrails, RunStatus, Trigger, content_hash,
- describe_cron, next_cron_fire,
+ describe_cron, next_cron_fire, normalize_cron_expression,
};
#[test]
@@ -772,13 +793,47 @@ mod tests {
title: "Deploy review".to_string(),
description: "Review and deploy pending changes".to_string(),
max_iterations: 5,
- tool_permissions: vec!["shell".to_string()],
};
let json = action.to_config_json();
let parsed = RoutineAction::from_db("full_job", json).expect("parse full_job");
assert!(
- matches!(parsed, RoutineAction::FullJob { title, max_iterations, tool_permissions, .. }
- if title == "Deploy review" && max_iterations == 5 && tool_permissions == vec!["shell".to_string()])
+ matches!(parsed, RoutineAction::FullJob { title, max_iterations, .. }
+ if title == "Deploy review"
+ && max_iterations == 5)
+ );
+ }
+
+ #[test]
+ fn test_action_full_job_ignores_legacy_permission_fields() {
+ let parsed = RoutineAction::from_db(
+ "full_job",
+ serde_json::json!({
+ "title": "Deploy review",
+ "description": "Review and deploy pending changes",
+ "max_iterations": 5,
+ "tool_permissions": ["shell"],
+ "permission_mode": "inherit_owner"
+ }),
+ )
+ .expect("parse full_job");
+ assert!(matches!(
+ parsed,
+ RoutineAction::FullJob {
+ ref title,
+ ref description,
+ max_iterations,
+ ..
+ } if title == "Deploy review"
+ && description == "Review and deploy pending changes"
+ && max_iterations == 5
+ ));
+ assert_eq!(
+ parsed.to_config_json(),
+ serde_json::json!({
+ "title": "Deploy review",
+ "description": "Review and deploy pending changes",
+ "max_iterations": 5,
+ })
);
}
@@ -930,9 +985,66 @@ mod tests {
.type_tag(),
"system_event"
);
+ assert_eq!(
+ Trigger::Webhook {
+ path: None,
+ secret: None,
+ }
+ .type_tag(),
+ "webhook"
+ );
assert_eq!(Trigger::Manual.type_tag(), "manual");
}
+ #[test]
+ fn test_normalize_cron_5_field() {
+ // Standard cron: min hour dom month dow
+ assert_eq!(normalize_cron_expression("0 9 * * 1"), "0 0 9 * * 1 *");
+ assert_eq!(
+ normalize_cron_expression("0 9 * * MON-FRI"),
+ "0 0 9 * * MON-FRI *"
+ );
+ }
+
+ #[test]
+ fn test_normalize_cron_6_field() {
+ // 6-field: sec min hour dom month dow
+ assert_eq!(
+ normalize_cron_expression("0 0 9 * * MON-FRI"),
+ "0 0 9 * * MON-FRI *"
+ );
+ }
+
+ #[test]
+ fn test_normalize_cron_7_field_passthrough() {
+ // Already 7-field: no change
+ assert_eq!(
+ normalize_cron_expression("0 0 9 * * MON-FRI *"),
+ "0 0 9 * * MON-FRI *"
+ );
+ }
+
+ #[test]
+ fn test_next_cron_fire_5_field_accepted() {
+ // Standard 5-field cron should now work through normalization
+ let result = next_cron_fire("0 9 * * 1", None);
+ assert!(
+ result.is_ok(),
+ "5-field cron should be accepted: {result:?}"
+ );
+ assert!(result.unwrap().is_some());
+ }
+
+ #[test]
+ fn test_next_cron_fire_5_field_with_timezone() {
+ let result = next_cron_fire("0 9 * * MON-FRI", Some("America/New_York"));
+ assert!(
+ result.is_ok(),
+ "5-field cron with timezone should be accepted: {result:?}"
+ );
+ assert!(result.unwrap().is_some());
+ }
+
#[test]
fn test_action_lightweight_backward_compat_no_use_tools() {
// Simulate old DB record without use_tools field
diff --git a/src/agent/routine_engine.rs b/src/agent/routine_engine.rs
index 519f16c2..2a5f4474 100644
--- a/src/agent/routine_engine.rs
+++ b/src/agent/routine_engine.rs
@@ -10,6 +10,7 @@
//! Lightweight routines execute inline (single LLM call, no scheduler slot).
//! Full-job routines are delegated to the existing `Scheduler`.
+use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
@@ -23,25 +24,38 @@ use crate::agent::Scheduler;
use crate::agent::routine::{
NotifyConfig, Routine, RoutineAction, RoutineRun, RunStatus, Trigger, next_cron_fire,
};
-use crate::channels::{IncomingMessage, OutgoingResponse};
+use crate::channels::OutgoingResponse;
use crate::config::RoutineConfig;
-use crate::context::JobContext;
+use crate::context::{JobContext, JobState};
use crate::db::Database;
use crate::error::RoutineError;
+use crate::extensions::ExtensionManager;
use crate::llm::{
ChatMessage, CompletionRequest, FinishReason, LlmProvider, ToolCall, ToolCompletionRequest,
};
-use crate::safety::SafetyLayer;
use crate::tools::{
- ApprovalContext, ApprovalRequirement, ToolError, ToolRegistry, prepare_tool_params,
+ ToolError, ToolRegistry, autonomous_allowed_tool_names, autonomous_unavailable_message,
+ prepare_tool_params,
};
use crate::workspace::Workspace;
+use ironclaw_safety::SafetyLayer;
enum EventMatcher {
Message { routine: Routine, regex: Regex },
System { routine: Routine },
}
+/// Distinguishes why sandbox is unavailable so error messages are accurate.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum SandboxReadiness {
+ /// Docker is available and sandbox is enabled.
+ Available,
+ /// User explicitly disabled sandboxing (SANDBOX_ENABLED=false).
+ DisabledByConfig,
+ /// Sandbox is enabled but Docker is not running or not installed.
+ DockerUnavailable,
+}
+
/// The routine execution engine.
pub struct RoutineEngine {
config: RoutineConfig,
@@ -56,10 +70,18 @@ pub struct RoutineEngine {
event_cache: Arc>>,
/// Scheduler for dispatching jobs (FullJob mode).
scheduler: Option>,
+ /// Owner-scoped extension activation state for autonomous tool resolution.
+ extension_manager: Option>,
/// Tool registry for lightweight routine tool execution.
tools: Arc,
/// Safety layer for tool output sanitization.
safety: Arc,
+ /// Sandbox readiness state for full-job dispatch.
+ sandbox_readiness: SandboxReadiness,
+ /// Timestamp when this engine instance was created. Used by
+ /// `sync_dispatched_runs` to distinguish orphaned runs (from a previous
+ /// process) from actively-watched runs (from this process).
+ boot_time: chrono::DateTime,
}
impl RoutineEngine {
@@ -71,8 +93,10 @@ impl RoutineEngine {
workspace: Arc,
notify_tx: mpsc::Sender,
scheduler: Option>,
+ extension_manager: Option>,
tools: Arc,
safety: Arc,
+ sandbox_readiness: SandboxReadiness,
) -> Self {
Self {
config,
@@ -83,11 +107,20 @@ impl RoutineEngine {
running_count: Arc::new(AtomicUsize::new(0)),
event_cache: Arc::new(RwLock::new(Vec::new())),
scheduler,
+ extension_manager,
tools,
safety,
+ sandbox_readiness,
+ boot_time: Utc::now(),
}
}
+ /// Expose the running count for integration tests.
+ #[doc(hidden)]
+ pub fn running_count_for_test(&self) -> &Arc {
+ &self.running_count
+ }
+
/// Refresh the in-memory event trigger cache from DB.
pub async fn refresh_event_cache(&self) {
match self.store.list_event_routines().await {
@@ -135,10 +168,19 @@ impl RoutineEngine {
/// Check incoming message against event triggers. Returns number of routines fired.
///
- /// Called synchronously from the main loop after handle_message(). The actual
- /// execution is spawned async so this returns quickly.
- pub async fn check_event_triggers(&self, message: &IncomingMessage) -> usize {
+ /// Accepts only the three fields needed for matching (user scope, channel,
+ /// message content) so callers never need to clone a full `IncomingMessage`.
+ pub async fn check_event_triggers(&self, user_id: &str, channel: &str, content: &str) -> usize {
let cache = self.event_cache.read().await;
+
+ // Early return if there are no message matchers at all.
+ if !cache
+ .iter()
+ .any(|m| matches!(m, EventMatcher::Message { .. }))
+ {
+ return 0;
+ }
+
let mut fired = 0;
// Collect routine IDs for batch query
@@ -155,16 +197,9 @@ impl RoutineEngine {
}
// Single batch query instead of N queries
- let concurrent_counts = match self
- .store
- .count_running_routine_runs_batch(&routine_ids)
- .await
- {
- Ok(counts) => counts,
- Err(e) => {
- tracing::error!("Failed to batch-load concurrent counts: {}", e);
- return 0;
- }
+ let concurrent_counts = match self.batch_concurrent_counts(&routine_ids).await {
+ Some(counts) => counts,
+ None => return 0,
};
for matcher in cache.iter() {
@@ -173,7 +208,7 @@ impl RoutineEngine {
EventMatcher::System { .. } => continue,
};
- if routine.user_id != message.user_id {
+ if routine.user_id != user_id {
continue;
}
@@ -181,13 +216,13 @@ impl RoutineEngine {
if let Trigger::Event {
channel: Some(ch), ..
} = &routine.trigger
- && ch != &message.channel
+ && ch != channel
{
continue;
}
// Regex match
- if !re.is_match(&message.content) {
+ if !re.is_match(content) {
continue;
}
@@ -210,7 +245,7 @@ impl RoutineEngine {
continue;
}
- let detail = truncate(&message.content, 200);
+ let detail = truncate(content, 200);
self.spawn_fire(routine.clone(), "event", Some(detail));
fired += 1;
}
@@ -229,6 +264,15 @@ impl RoutineEngine {
user_id: Option<&str>,
) -> usize {
let cache = self.event_cache.read().await;
+
+ // Early return if there are no system-event matchers at all.
+ if !cache
+ .iter()
+ .any(|m| matches!(m, EventMatcher::System { .. }))
+ {
+ return 0;
+ }
+
let mut fired = 0;
// Collect routine IDs for batch query
@@ -245,19 +289,9 @@ impl RoutineEngine {
}
// Single batch query instead of N queries
- let concurrent_counts = match self
- .store
- .count_running_routine_runs_batch(&routine_ids)
- .await
- {
- Ok(counts) => counts,
- Err(e) => {
- tracing::error!(
- "Failed to batch-load concurrent counts for system events: {}",
- e
- );
- return 0;
- }
+ let concurrent_counts = match self.batch_concurrent_counts(&routine_ids).await {
+ Some(counts) => counts,
+ None => return 0,
};
for matcher in cache.iter() {
@@ -331,6 +365,23 @@ impl RoutineEngine {
fired
}
+ /// Batch-load concurrent run counts for a set of routine IDs.
+ ///
+ /// Returns `None` on database error (already logged).
+ async fn batch_concurrent_counts(&self, routine_ids: &[Uuid]) -> Option> {
+ match self
+ .store
+ .count_running_routine_runs_batch(routine_ids)
+ .await
+ {
+ Ok(counts) => Some(counts),
+ Err(e) => {
+ tracing::error!("Failed to batch-load concurrent counts: {}", e);
+ None
+ }
+ }
+ }
+
/// Check all due cron routines and fire them. Called by the cron ticker.
pub async fn check_cron_triggers(&self) {
let routines = match self.store.list_due_cron_routines().await {
@@ -365,6 +416,230 @@ impl RoutineEngine {
}
}
+ /// Reconcile orphaned full_job routine runs with their linked job outcomes.
+ ///
+ /// Called on each cron tick. Finds routine runs that are still `running`
+ /// with a linked `job_id`, checks the job state, and finalizes the run
+ /// when the job reaches a completed or terminal state.
+ ///
+ /// Only processes runs started **before** this engine's boot time, so it
+ /// never races with `FullJobWatcher` instances from the current process.
+ /// This makes it safe to call on every tick as a crash-recovery mechanism.
+ pub async fn sync_dispatched_runs(&self) {
+ let runs = match self.store.list_dispatched_routine_runs().await {
+ Ok(r) => r,
+ Err(e) => {
+ tracing::error!("Failed to list dispatched routine runs: {}", e);
+ return;
+ }
+ };
+
+ // Only process runs from a previous process instance. Runs started
+ // after boot_time are actively watched by a FullJobWatcher in this
+ // process and should not be finalized here.
+ let orphaned: Vec<_> = runs
+ .into_iter()
+ .filter(|r| r.started_at < self.boot_time)
+ .collect();
+
+ if orphaned.is_empty() {
+ return;
+ }
+
+ tracing::info!(
+ "Recovering {} orphaned dispatched routine runs",
+ orphaned.len()
+ );
+
+ for run in orphaned {
+ let job_id = match run.job_id {
+ Some(id) => id,
+ None => continue, // Should not happen (query filters), but guard anyway
+ };
+
+ // Fetch the linked job
+ let job = match self.store.get_job(job_id).await {
+ Ok(Some(j)) => j,
+ Ok(None) => {
+ // Orphaned: job record was deleted or never persisted
+ tracing::warn!(
+ run_id = %run.id,
+ job_id = %job_id,
+ "Linked job not found, marking routine run as failed"
+ );
+ self.complete_dispatched_run(
+ &run,
+ RunStatus::Failed,
+ &format!("Linked job {job_id} not found (orphaned)"),
+ )
+ .await;
+ continue;
+ }
+ Err(e) => {
+ tracing::error!(
+ run_id = %run.id,
+ job_id = %job_id,
+ "Failed to fetch linked job: {}", e
+ );
+ continue;
+ }
+ };
+
+ // Map job state to final run status
+ let final_status = match job.state {
+ JobState::Completed | JobState::Submitted | JobState::Accepted => {
+ Some(RunStatus::Ok)
+ }
+ JobState::Failed | JobState::Cancelled => Some(RunStatus::Failed),
+ // Pending, InProgress, Stuck — still running
+ _ => None,
+ };
+
+ let status = match final_status {
+ Some(s) => s,
+ None => continue, // Job still active, check again next tick
+ };
+
+ // Build summary
+ let summary = if status == RunStatus::Failed {
+ match self.store.get_agent_job_failure_reason(job_id).await {
+ Ok(Some(reason)) => format!("Job {job_id} failed: {reason}"),
+ _ => format!("Job {job_id} {}", job.state),
+ }
+ } else {
+ format!("Job {job_id} completed successfully")
+ };
+
+ self.complete_dispatched_run(&run, status, &summary).await;
+ }
+ }
+
+ /// Finalize a dispatched routine run: update DB, update routine runtime,
+ /// persist to conversation thread, and send notification.
+ async fn complete_dispatched_run(&self, run: &RoutineRun, status: RunStatus, summary: &str) {
+ // Complete the run record in DB
+ if let Err(e) = self
+ .store
+ .complete_routine_run(run.id, status, Some(summary), None)
+ .await
+ {
+ tracing::error!(
+ run_id = %run.id,
+ "Failed to complete dispatched routine run: {}", e
+ );
+ return;
+ }
+
+ tracing::info!(
+ run_id = %run.id,
+ status = %status,
+ "Finalized dispatched routine run"
+ );
+
+ // Load the routine to update consecutive_failures and send notification
+ let routine = match self.store.get_routine(run.routine_id).await {
+ Ok(Some(r)) => r,
+ Ok(None) => {
+ tracing::warn!(
+ run_id = %run.id,
+ routine_id = %run.routine_id,
+ "Routine not found for dispatched run finalization"
+ );
+ return;
+ }
+ Err(e) => {
+ tracing::error!(
+ run_id = %run.id,
+ "Failed to load routine for dispatched run: {}", e
+ );
+ return;
+ }
+ };
+
+ // Update runtime fields. In crash recovery, execute_routine() never
+ // reached its normal runtime update, so we must advance all fields here.
+ let new_failures = if status == RunStatus::Failed {
+ routine.consecutive_failures + 1
+ } else {
+ 0
+ };
+
+ let now = Utc::now();
+ let next_fire = if let Trigger::Cron {
+ ref schedule,
+ ref timezone,
+ } = routine.trigger
+ {
+ next_cron_fire(schedule, timezone.as_deref()).unwrap_or(None)
+ } else {
+ None
+ };
+
+ if let Err(e) = self
+ .store
+ .update_routine_runtime(
+ routine.id,
+ now,
+ next_fire,
+ routine.run_count + 1,
+ new_failures,
+ &routine.state,
+ )
+ .await
+ {
+ tracing::error!(
+ routine = %routine.name,
+ "Failed to update routine runtime after dispatched run: {}", e
+ );
+ }
+
+ // Persist result to the routine's conversation thread
+ let thread_id = match self
+ .store
+ .get_or_create_routine_conversation(routine.id, &routine.name, &routine.user_id)
+ .await
+ {
+ Ok(conv_id) => {
+ let msg = format!("[dispatched] {}: {}", status, summary);
+ if let Err(e) = self
+ .store
+ .add_conversation_message(conv_id, "assistant", &msg)
+ .await
+ {
+ tracing::error!(
+ routine = %routine.name,
+ "Failed to persist dispatched run message: {}", e
+ );
+ }
+ Some(conv_id.to_string())
+ }
+ Err(e) => {
+ tracing::error!(
+ routine = %routine.name,
+ "Failed to get routine conversation: {}", e
+ );
+ None
+ }
+ };
+
+ // Send notification
+ send_notification(
+ &self.notify_tx,
+ &routine.notify,
+ &routine.user_id,
+ &routine.name,
+ status,
+ Some(summary),
+ thread_id.as_deref(),
+ )
+ .await;
+
+ // Note: we do NOT decrement running_count here. In normal flow,
+ // execute_routine() handles that after FullJobWatcher returns.
+ // This sync path only runs for crash recovery (process restarted),
+ // where running_count was already reset to 0.
+ }
+
/// Fire a routine manually (from tool call or CLI).
///
/// Bypasses cooldown checks (those only apply to cron/event triggers).
@@ -432,8 +707,95 @@ impl RoutineEngine {
notify_tx: self.notify_tx.clone(),
running_count: self.running_count.clone(),
scheduler: self.scheduler.clone(),
+ extension_manager: self.extension_manager.clone(),
tools: self.tools.clone(),
safety: self.safety.clone(),
+ sandbox_readiness: self.sandbox_readiness,
+ };
+
+ tokio::spawn(async move {
+ execute_routine(engine, routine, run).await;
+ });
+
+ Ok(run_id)
+ }
+
+ /// Fire a routine from a webhook trigger.
+ ///
+ /// Similar to `fire_manual` but records the trigger as `"webhook"` with the
+ /// webhook path as detail. Skips ownership check (auth is via webhook secret).
+ /// Enforces enabled check, cooldown, and concurrent run limit.
+ pub async fn fire_webhook(
+ &self,
+ routine_id: Uuid,
+ webhook_path: &str,
+ ) -> Result {
+ let routine = self
+ .store
+ .get_routine(routine_id)
+ .await
+ .map_err(|e| RoutineError::Database {
+ reason: e.to_string(),
+ })?
+ .ok_or(RoutineError::NotFound { id: routine_id })?;
+
+ if !routine.enabled {
+ return Err(RoutineError::Disabled {
+ name: routine.name.clone(),
+ });
+ }
+
+ if !self.check_cooldown(&routine) {
+ return Err(RoutineError::Cooldown {
+ name: routine.name.clone(),
+ });
+ }
+
+ if !self.check_concurrent(&routine).await {
+ return Err(RoutineError::MaxConcurrent {
+ name: routine.name.clone(),
+ });
+ }
+
+ if self.running_count.load(Ordering::Relaxed) >= self.config.max_concurrent_routines {
+ return Err(RoutineError::MaxConcurrent {
+ name: routine.name.clone(),
+ });
+ }
+
+ let run_id = Uuid::new_v4();
+ let run = RoutineRun {
+ id: run_id,
+ routine_id: routine.id,
+ trigger_type: "webhook".to_string(),
+ trigger_detail: Some(webhook_path.to_string()),
+ started_at: Utc::now(),
+ completed_at: None,
+ status: RunStatus::Running,
+ result_summary: None,
+ tokens_used: None,
+ job_id: None,
+ created_at: Utc::now(),
+ };
+
+ if let Err(e) = self.store.create_routine_run(&run).await {
+ return Err(RoutineError::Database {
+ reason: format!("failed to create run record: {e}"),
+ });
+ }
+
+ let engine = EngineContext {
+ config: self.config.clone(),
+ store: self.store.clone(),
+ llm: self.llm.clone(),
+ workspace: self.workspace.clone(),
+ notify_tx: self.notify_tx.clone(),
+ running_count: self.running_count.clone(),
+ scheduler: self.scheduler.clone(),
+ extension_manager: self.extension_manager.clone(),
+ tools: self.tools.clone(),
+ safety: self.safety.clone(),
+ sandbox_readiness: self.sandbox_readiness,
};
tokio::spawn(async move {
@@ -467,8 +829,10 @@ impl RoutineEngine {
notify_tx: self.notify_tx.clone(),
running_count: self.running_count.clone(),
scheduler: self.scheduler.clone(),
+ extension_manager: self.extension_manager.clone(),
tools: self.tools.clone(),
safety: self.safety.clone(),
+ sandbox_readiness: self.sandbox_readiness,
};
// Record the run in DB, then spawn execution
@@ -508,6 +872,92 @@ impl RoutineEngine {
}
}
+/// Watches a dispatched full_job until the linked scheduler job completes.
+///
+/// Polls `store.get_job(job_id)` at a fixed interval until the job leaves
+/// an active state (Pending/InProgress/Stuck). Maps the final `JobState` to
+/// a `RunStatus` for the routine run.
+struct FullJobWatcher {
+ store: Arc,
+ job_id: Uuid,
+ routine_name: String,
+}
+
+impl FullJobWatcher {
+ /// Poll interval between DB checks.
+ const POLL_INTERVAL: Duration = Duration::from_secs(5);
+ /// Safety ceiling: 24 hours, derived from POLL_INTERVAL.
+ const MAX_POLLS: u32 = (24 * 60 * 60) / Self::POLL_INTERVAL.as_secs() as u32;
+
+ fn new(store: Arc, job_id: Uuid, routine_name: String) -> Self {
+ Self {
+ store,
+ job_id,
+ routine_name,
+ }
+ }
+
+ /// Block until the linked job finishes and return the mapped status + summary.
+ async fn wait_for_completion(&self) -> (RunStatus, Option) {
+ let mut polls = 0u32;
+
+ let final_status = loop {
+ // Check job state before sleeping so we finalize promptly
+ // if the job is already done (e.g. fast-failing jobs).
+ match self.store.get_job(self.job_id).await {
+ Ok(Some(job_ctx)) => {
+ // Use is_parallel_blocking (Pending/InProgress/Stuck) instead
+ // of is_active (!is_terminal) because routine jobs typically
+ // stop at Completed — which is NOT terminal but IS finished
+ // from an execution standpoint.
+ if !job_ctx.state.is_parallel_blocking() {
+ break Self::map_job_state(&job_ctx.state);
+ }
+ }
+ Ok(None) => {
+ tracing::warn!(
+ routine = %self.routine_name,
+ job_id = %self.job_id,
+ "full_job disappeared from DB while polling"
+ );
+ break RunStatus::Failed;
+ }
+ Err(e) => {
+ tracing::error!(
+ routine = %self.routine_name,
+ job_id = %self.job_id,
+ "Error polling full_job state: {}", e
+ );
+ break RunStatus::Failed;
+ }
+ }
+
+ polls += 1;
+ if polls >= Self::MAX_POLLS {
+ tracing::error!(
+ routine = %self.routine_name,
+ job_id = %self.job_id,
+ "full_job timed out after 24 hours, treating as failed"
+ );
+ break RunStatus::Failed;
+ }
+
+ tokio::time::sleep(Self::POLL_INTERVAL).await;
+ };
+
+ let summary = format!("Job {} finished ({})", self.job_id, final_status);
+ (final_status, Some(summary))
+ }
+
+ fn map_job_state(state: &crate::context::JobState) -> RunStatus {
+ use crate::context::JobState;
+ match state {
+ JobState::Failed | JobState::Cancelled => RunStatus::Failed,
+ _ => RunStatus::Ok, // Completed / Submitted / Accepted
+ }
+ }
+}
+
/// Shared context passed to the execution function.
struct EngineContext {
config: RoutineConfig,
@@ -517,8 +967,10 @@ struct EngineContext {
notify_tx: mpsc::Sender,
running_count: Arc,
scheduler: Option>,
+ extension_manager: Option>,
tools: Arc,
safety: Arc,
+ sandbox_readiness: SandboxReadiness,
}
/// Execute a routine run. Handles both lightweight and full_job modes.
@@ -549,18 +1001,13 @@ async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun)
title,
description,
max_iterations,
- tool_permissions,
} => {
- execute_full_job(
- &ctx,
- &routine,
- &run,
+ let execution = FullJobExecutionConfig {
title,
description,
- *max_iterations,
- tool_permissions,
- )
- .await
+ max_iterations: *max_iterations,
+ };
+ execute_full_job(&ctx, &routine, &run, &execution).await
}
};
@@ -682,17 +1129,40 @@ fn sanitize_routine_name(name: &str) -> String {
///
/// Fire-and-forget: creates a job via `Scheduler::dispatch_job` (which handles
/// creation, metadata, persistence, and scheduling), links the routine run to
-/// the job, and returns immediately. The job runs independently via the
-/// existing Worker/Scheduler with full tool access.
+/// the job, then watches it via `FullJobWatcher` until it reaches a
+/// non-active state (not Pending/InProgress/Stuck). Returns the final
+/// `RunStatus` mapped from the job outcome. This keeps the routine run
+/// active for the full job lifetime so concurrency guardrails apply.
+struct FullJobExecutionConfig<'a> {
+ title: &'a str,
+ description: &'a str,
+ max_iterations: u32,
+}
+
async fn execute_full_job(
ctx: &EngineContext,
routine: &Routine,
run: &RoutineRun,
- title: &str,
- description: &str,
- max_iterations: u32,
- tool_permissions: &[String],
+ execution: &FullJobExecutionConfig<'_>,
) -> Result<(RunStatus, Option, Option), RoutineError> {
+ match ctx.sandbox_readiness {
+ SandboxReadiness::Available => {}
+ SandboxReadiness::DisabledByConfig => {
+ return Err(RoutineError::JobDispatchFailed {
+ reason: "Sandboxing is disabled (SANDBOX_ENABLED=false). \
+ Full-job routines require sandbox."
+ .to_string(),
+ });
+ }
+ SandboxReadiness::DockerUnavailable => {
+ return Err(RoutineError::JobDispatchFailed {
+ reason: "Sandbox is enabled but Docker is not available. \
+ Install Docker or set SANDBOX_ENABLED=false."
+ .to_string(),
+ });
+ }
+ }
+
let scheduler = ctx
.scheduler
.as_ref()
@@ -700,8 +1170,10 @@ async fn execute_full_job(
reason: "scheduler not available".to_string(),
})?;
- let mut metadata =
- serde_json::json!({ "max_iterations": max_iterations, "owner_id": routine.user_id });
+ let mut metadata = serde_json::json!({
+ "max_iterations": execution.max_iterations,
+ "owner_id": routine.user_id
+ });
// Carry the routine's notify config in job metadata so the message tool
// can resolve channel/target per-job without global state mutation.
if let Some(channel) = &routine.notify.channel {
@@ -709,42 +1181,42 @@ async fn execute_full_job(
}
metadata["notify_user"] = serde_json::json!(&routine.notify.user);
- // Build approval context: UnlessAutoApproved tools are auto-approved for routines;
- // Always tools require explicit listing in tool_permissions.
- let approval_context = ApprovalContext::autonomous_with_tools(tool_permissions.iter().cloned());
-
let job_id = scheduler
- .dispatch_job_with_context(
+ .dispatch_job(
&routine.user_id,
- title,
- description,
+ execution.title,
+ execution.description,
Some(metadata),
- approval_context,
)
.await
.map_err(|e| RoutineError::JobDispatchFailed {
reason: format!("failed to dispatch job: {e}"),
})?;
- // Link the routine run to the dispatched job
- if let Err(e) = ctx.store.link_routine_run_to_job(run.id, job_id).await {
- tracing::error!(
- routine = %routine.name,
- "Failed to link run to job: {}", e
- );
- }
+ // Link the routine run to the dispatched job.
+ // This MUST succeed — if it fails, sync_dispatched_runs() will never find
+ // this run (it filters on job_id IS NOT NULL), leaving it stuck as 'running'
+ // with running_count permanently elevated.
+ ctx.store
+ .link_routine_run_to_job(run.id, job_id)
+ .await
+ .map_err(|e| RoutineError::Database {
+ reason: format!("failed to link run to job: {e}"),
+ })?;
tracing::info!(
routine = %routine.name,
job_id = %job_id,
- max_iterations = max_iterations,
- "Dispatched full job for routine"
+ max_iterations = execution.max_iterations,
+ "Dispatched full job for routine, watching for completion"
);
- let summary = format!(
- "Dispatched job {job_id} for full execution with tool access (max_iterations: {max_iterations})"
- );
- Ok((RunStatus::Ok, Some(summary), None))
+ // Watch the job until it finishes — keeps the routine run active
+ // so concurrency guardrails (running_count, routine_runs status)
+ // remain enforced for the full job lifetime.
+ let watcher = FullJobWatcher::new(ctx.store.clone(), job_id, routine.name.clone());
+ let (status, summary) = watcher.wait_for_completion().await;
+ Ok((status, summary, None))
}
/// Execute a lightweight routine with optional tool support.
@@ -784,23 +1256,12 @@ async fn execute_lightweight(
Err(_) => None,
};
- // Build the user-facing prompt
- let mut full_prompt = String::new();
- full_prompt.push_str(prompt);
-
- if !context_parts.is_empty() {
- full_prompt.push_str("\n\n---\n\n# Context\n\n");
- full_prompt.push_str(&context_parts.join("\n\n"));
- }
-
- if let Some(state) = &state_content {
- full_prompt.push_str("\n\n---\n\n# Previous State\n\n");
- full_prompt.push_str(state);
- }
-
- full_prompt.push_str(
- "\n\n---\n\nIf nothing needs attention, reply EXACTLY with: ROUTINE_OK\n\
- If something needs attention, provide a concise summary.",
+ let full_prompt = build_lightweight_prompt(
+ prompt,
+ &context_parts,
+ state_content.as_deref(),
+ &routine.notify,
+ use_tools,
);
// Get system prompt
@@ -844,6 +1305,65 @@ async fn execute_lightweight(
}
}
+fn build_lightweight_prompt(
+ prompt: &str,
+ context_parts: &[String],
+ state_content: Option<&str>,
+ notify: &NotifyConfig,
+ use_tools: bool,
+) -> String {
+ let mut full_prompt = String::new();
+ full_prompt.push_str(prompt);
+
+ if notify.on_attention {
+ full_prompt.push_str("\n\n---\n\n# Delivery\n\n");
+ full_prompt.push_str(
+ "If you reply with anything other than ROUTINE_OK, the host will deliver your \
+ reply as the routine notification. Return the message exactly as it should be sent.\n",
+ );
+
+ if let Some(channel) = notify.channel.as_deref() {
+ full_prompt.push_str(&format!(
+ "The configured delivery channel for this routine is `{channel}`.\n"
+ ));
+ }
+
+ if let Some(user) = notify.user.as_deref() {
+ full_prompt.push_str(&format!(
+ "The configured delivery target for this routine is `{user}`.\n"
+ ));
+ }
+
+ full_prompt.push_str(
+ "Do not claim you lack messaging integrations or ask the user to set one up when \
+ a plain reply is sufficient.\n",
+ );
+ }
+
+ if !use_tools {
+ full_prompt.push_str(
+ "\nTools are disabled for this routine run. Do not ask to call tools or describe tool limitations unless they prevent a necessary external action.\n",
+ );
+ }
+
+ if !context_parts.is_empty() {
+ full_prompt.push_str("\n\n---\n\n# Context\n\n");
+ full_prompt.push_str(&context_parts.join("\n\n"));
+ }
+
+ if let Some(state) = state_content {
+ full_prompt.push_str("\n\n---\n\n# Previous State\n\n");
+ full_prompt.push_str(state);
+ }
+
+ full_prompt.push_str(
+ "\n\n---\n\nIf nothing needs attention, reply EXACTLY with: ROUTINE_OK\n\
+ If something needs attention, provide a concise summary.",
+ );
+
+ full_prompt
+}
+
/// Execute a lightweight routine without tool support (original single-call behavior).
async fn execute_lightweight_no_tools(
ctx: &EngineContext,
@@ -901,8 +1421,8 @@ fn handle_text_response(
};
}
- // Check for the "nothing to do" sentinel
- if content == "ROUTINE_OK" || content.contains("ROUTINE_OK") {
+ // Check for the "nothing to do" sentinel (exact match on trimmed content).
+ if content == "ROUTINE_OK" {
let total_tokens = Some((total_input_tokens + total_output_tokens) as i32);
return Ok((RunStatus::Ok, None, total_tokens));
}
@@ -955,6 +1475,9 @@ async fn execute_lightweight_with_tools(
description: routine.name.clone(),
..Default::default()
};
+ let allowed_tools =
+ autonomous_allowed_tool_names(&ctx.tools, ctx.extension_manager.as_ref(), &routine.user_id)
+ .await;
loop {
iteration += 1;
@@ -989,8 +1512,11 @@ async fn execute_lightweight_with_tools(
// Tool-enabled iteration
let tool_defs = ctx
.tools
- .tool_definitions_excluding(ROUTINE_TOOL_DENYLIST)
- .await;
+ .tool_definitions()
+ .await
+ .into_iter()
+ .filter(|tool| allowed_tools.contains(&tool.name))
+ .collect();
let request_messages = snapshot_messages_for_tool_iteration(&messages);
let request = ToolCompletionRequest::new(request_messages, tool_defs)
@@ -1025,7 +1551,7 @@ async fn execute_lightweight_with_tools(
// Execute tools sequentially
for tc in response.tool_calls {
- let result = execute_routine_tool(ctx, &job_ctx, &tc).await;
+ let result = execute_routine_tool(ctx, &job_ctx, &allowed_tools, &tc).await;
// Sanitize and wrap result (including errors)
let result_content = match result {
@@ -1094,31 +1620,16 @@ fn snapshot_messages_for_tool_iteration(messages: &[ChatMessage]) -> Vec,
tc: &ToolCall,
) -> Result> {
- // Block tools that pose autonomy-escalation risks
- if ROUTINE_TOOL_DENYLIST.contains(&tc.name.as_str()) {
- return Err(format!(
- "Tool '{}' is not available in lightweight routines",
- tc.name
- )
- .into());
+ if !allowed_tools.contains(&tc.name) {
+ let message = autonomous_unavailable_message(&tc.name, &job_ctx.user_id);
+ return Err(message.into());
}
// Check if tool exists
@@ -1129,22 +1640,6 @@ async fn execute_routine_tool(
.ok_or_else(|| format!("Tool '{}' not found", tc.name))?;
let normalized_params = prepare_tool_params(tool.as_ref(), &tc.arguments);
- // Check approval requirement: only allow Never tools in lightweight routines.
- // UnlessAutoApproved and Always tools are blocked to prevent prompt injection attacks.
- // Lightweight routines can be triggered by external events and may process untrusted data,
- // making them vulnerable to prompt injection that could trick the LLM into calling
- // sensitive tools. Blocking these tools entirely is the safest approach.
- match tool.requires_approval(&normalized_params) {
- ApprovalRequirement::Never => {}
- ApprovalRequirement::UnlessAutoApproved | ApprovalRequirement::Always => {
- return Err(format!(
- "Tool '{}' requires manual approval and cannot be used in lightweight routines",
- tc.name
- )
- .into());
- }
- }
-
// Validate tool parameters
let validation = ctx
.safety
@@ -1268,15 +1763,24 @@ pub fn spawn_cron_ticker(
interval: Duration,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
- // Run one check immediately so routines due at startup don't wait
- // an extra full polling interval.
+ // Recover orphaned runs from a previous process crash before
+ // dispatching any new work, so we don't confuse fresh dispatches
+ // with crash orphans.
+ engine.sync_dispatched_runs().await;
+
+ // Run one cron check immediately so routines due at startup don't
+ // wait an extra full polling interval.
engine.check_cron_triggers().await;
let mut ticker = tokio::time::interval(interval);
loop {
ticker.tick().await;
+ // Sync first: only processes runs from before boot_time, so it
+ // never races with FullJobWatcher instances from this process.
+ engine.sync_dispatched_runs().await;
engine.check_cron_triggers().await;
+ engine.sync_dispatched_runs().await;
}
})
}
@@ -1290,6 +1794,56 @@ fn truncate(s: &str, max: usize) -> String {
}
}
+/// Sanitize a summary string from job transitions before using in notifications.
+///
+/// `last_reason` comes from untrusted container code, so we:
+/// 1. Strip control characters (except newline) to prevent terminal injection
+/// 2. Strip HTML tags to prevent injection in web-rendered notifications
+/// 3. Collapse multiple whitespace/newlines to single spaces for cleaner output
+/// 4. Truncate to 500 chars to prevent oversized notifications
+#[cfg(test)]
+fn sanitize_summary(s: &str) -> String {
+ // Strip control characters (keep newline for now, collapse later)
+ let no_control: String = s
+ .chars()
+ .filter(|c| !c.is_control() || *c == '\n')
+ .collect();
+
+ // Strip HTML tags (e.g. world"),
+ "Hello alert('xss') world"
+ );
+ assert_eq!(
+ sanitize_summary("bold and link "),
+ "bold and link"
+ );
+ assert_eq!(sanitize_summary(" "), "");
+ }
+
+ #[test]
+ fn test_sanitize_summary_multibyte_truncation() {
+ use super::sanitize_summary;
+
+ // Ensure truncation doesn't panic on multi-byte chars near the boundary
+ let s = "a".repeat(498) + "\u{1F600}\u{1F600}"; // 498 + two 4-byte emoji
+ let result = sanitize_summary(&s);
+ assert!(result.len() <= 503);
+ assert!(result.ends_with("..."));
+ }
}
diff --git a/src/agent/scheduler.rs b/src/agent/scheduler.rs
index fa7364a4..2e23b35f 100644
--- a/src/agent/scheduler.rs
+++ b/src/agent/scheduler.rs
@@ -14,10 +14,14 @@ use crate::config::AgentConfig;
use crate::context::{ContextManager, JobContext, JobState};
use crate::db::Database;
use crate::error::{Error, JobError};
+use crate::extensions::ExtensionManager;
use crate::hooks::HookRegistry;
use crate::llm::LlmProvider;
use crate::safety::SafetyLayer;
-use crate::tools::{ApprovalContext, ToolRegistry, prepare_tool_params};
+use crate::tools::{
+ ApprovalContext, ToolRegistry, autonomous_allowed_tool_names, autonomous_unavailable_error,
+ prepare_tool_params,
+};
use crate::worker::job::{Worker, WorkerDeps};
/// Message to send to a worker.
@@ -45,6 +49,14 @@ struct ScheduledSubtask {
handle: JoinHandle>,
}
+/// Shared scheduler-owned dependencies that are forwarded into autonomous runs.
+pub struct SchedulerDeps {
+ pub tools: Arc,
+ pub extension_manager: Option>,
+ pub store: Option>,
+ pub hooks: Arc,
+}
+
/// Schedules and manages parallel job execution.
pub struct Scheduler {
config: AgentConfig,
@@ -52,6 +64,7 @@ pub struct Scheduler {
llm: Arc,
safety: Arc,
tools: Arc,
+ extension_manager: Option>,
store: Option>,
hooks: Arc,
/// SSE broadcast sender for live job event streaming.
@@ -71,18 +84,17 @@ impl Scheduler {
context_manager: Arc,
llm: Arc,
safety: Arc,
- tools: Arc,
- store: Option>,
- hooks: Arc,
+ deps: SchedulerDeps,
) -> Self {
Self {
config,
context_manager,
llm,
safety,
- tools,
- store,
- hooks,
+ tools: deps.tools,
+ extension_manager: deps.extension_manager,
+ store: deps.store,
+ hooks: deps.hooks,
sse_tx: None,
http_interceptor: None,
jobs: Arc::new(RwLock::new(HashMap::new())),
@@ -120,14 +132,21 @@ impl Scheduler {
description: &str,
metadata: Option,
) -> Result {
- self.dispatch_job_inner(user_id, title, description, metadata, None)
- .await
+ let approval_context = self.autonomous_approval_context(user_id).await;
+ self.dispatch_job_inner(
+ user_id,
+ title,
+ description,
+ metadata,
+ Some(approval_context),
+ )
+ .await
}
/// Dispatch a job with an explicit approval context for autonomous execution.
///
/// Same as `dispatch_job`, but the worker will use the given `ApprovalContext`
- /// to determine which tools are pre-approved (instead of blocking all non-`Never` tools).
+ /// to determine the explicit autonomous allowlist for that job.
pub async fn dispatch_job_with_context(
&self,
user_id: &str,
@@ -216,6 +235,13 @@ impl Scheduler {
Ok(job_id)
}
+ async fn autonomous_approval_context(&self, user_id: &str) -> ApprovalContext {
+ ApprovalContext::autonomous_with_tools(
+ autonomous_allowed_tool_names(&self.tools, self.extension_manager.as_ref(), user_id)
+ .await,
+ )
+ }
+
/// Schedule a job for execution.
pub async fn schedule(&self, job_id: Uuid) -> Result<(), JobError> {
self.schedule_with_context(job_id, None).await
@@ -518,10 +544,7 @@ impl Scheduler {
let blocked =
ApprovalContext::is_blocked_or_default(&approval_context, tool_name, requirement);
if blocked {
- return Err(crate::error::ToolError::AuthRequired {
- name: tool_name.to_string(),
- }
- .into());
+ return Err(autonomous_unavailable_error(tool_name, &job_ctx.user_id).into());
}
// Delegate to shared tool execution pipeline
@@ -776,7 +799,18 @@ mod tests {
let tools = Arc::new(ToolRegistry::new());
let hooks = Arc::new(HookRegistry::default());
- Scheduler::new(config, cm, llm, safety, tools, None, hooks)
+ Scheduler::new(
+ config,
+ cm,
+ llm,
+ safety,
+ SchedulerDeps {
+ tools,
+ extension_manager: None,
+ store: None,
+ hooks,
+ },
+ )
}
#[tokio::test]
@@ -1003,12 +1037,14 @@ mod tests {
async fn test_execute_tool_task_autonomous_unblocks_soft() {
let (tools, cm, safety, job_id) = setup_tools_and_job().await;
- // Autonomous context auto-approves UnlessAutoApproved
+ // Autonomous execution only allows tools explicitly in scope.
let result = Scheduler::execute_tool_task(
tools.clone(),
cm.clone(),
safety.clone(),
- Some(ApprovalContext::autonomous()),
+ Some(ApprovalContext::autonomous_with_tools([
+ "soft_gate".to_string()
+ ])),
job_id,
"soft_gate",
serde_json::json!({}),
@@ -1040,8 +1076,11 @@ mod tests {
async fn test_execute_tool_task_autonomous_with_permissions() {
let (tools, cm, safety, job_id) = setup_tools_and_job().await;
- // Autonomous context with explicit permission for hard_gate
- let ctx = ApprovalContext::autonomous_with_tools(["hard_gate".to_string()]);
+ // Autonomous context with explicit permission for both tools.
+ let ctx = ApprovalContext::autonomous_with_tools([
+ "soft_gate".to_string(),
+ "hard_gate".to_string(),
+ ]);
let result = Scheduler::execute_tool_task(
tools.clone(),
diff --git a/src/agent/self_repair.rs b/src/agent/self_repair.rs
index a67fe23e..4e58cb15 100644
--- a/src/agent/self_repair.rs
+++ b/src/agent/self_repair.rs
@@ -66,14 +66,11 @@ pub trait SelfRepair: Send + Sync {
/// Default self-repair implementation.
pub struct DefaultSelfRepair {
context_manager: Arc,
- // TODO: use for time-based stuck detection (currently only max_repair_attempts is checked)
- #[allow(dead_code)]
+ /// Jobs in `InProgress` longer than this are treated as stuck.
stuck_threshold: Duration,
max_repair_attempts: u32,
store: Option>,
builder: Option>,
- // TODO: use for tool hot-reload after repair
- #[allow(dead_code)]
tools: Option>,
}
@@ -95,15 +92,13 @@ impl DefaultSelfRepair {
}
/// Add a Store for tool failure tracking.
- #[allow(dead_code)] // TODO: wire up in main.rs when persistence is needed
- pub(crate) fn with_store(mut self, store: Arc) -> Self {
+ pub fn with_store(mut self, store: Arc) -> Self {
self.store = Some(store);
self
}
/// Add a Builder and ToolRegistry for automatic tool repair.
- #[allow(dead_code)] // TODO: wire up in main.rs when auto-repair is needed
- pub(crate) fn with_builder(
+ pub fn with_builder(
mut self,
builder: Arc,
tools: Arc,
@@ -117,25 +112,82 @@ impl DefaultSelfRepair {
#[async_trait]
impl SelfRepair for DefaultSelfRepair {
async fn detect_stuck_jobs(&self) -> Vec {
- let stuck_ids = self.context_manager.find_stuck_jobs().await;
+ let stuck_ids = self
+ .context_manager
+ .find_stuck_jobs_with_threshold(Some(self.stuck_threshold))
+ .await;
let mut stuck_jobs = Vec::new();
for job_id in stuck_ids {
if let Ok(ctx) = self.context_manager.get_context(job_id).await
- && ctx.state == JobState::Stuck
+ && matches!(ctx.state, JobState::Stuck | JobState::InProgress)
{
- let stuck_duration = ctx
- .started_at
- .map(|start| {
- let now = Utc::now();
- let duration = now.signed_duration_since(start);
+ // InProgress jobs detected by threshold need to be transitioned
+ // to Stuck before they can be repaired (attempt_recovery requires
+ // Stuck state). These jobs already passed the threshold check in
+ // find_stuck_jobs_with_threshold, so skip the duration filter below.
+ let just_transitioned = ctx.state == JobState::InProgress;
+ if just_transitioned {
+ let reason = "exceeded stuck_threshold";
+ let transition = self
+ .context_manager
+ .update_context(job_id, |ctx| ctx.mark_stuck(reason))
+ .await;
+ match transition {
+ Ok(Ok(())) => {}
+ Ok(Err(e)) => {
+ tracing::warn!(
+ job = %job_id,
+ "Failed to mark InProgress job as Stuck: {}",
+ e
+ );
+ continue;
+ }
+ Err(e) => {
+ tracing::warn!(
+ job = %job_id,
+ "Failed to transition InProgress job to Stuck: {}",
+ e
+ );
+ continue;
+ }
+ }
+ }
+
+ // Re-fetch context after potential InProgress->Stuck transition
+ // so that stuck_since picks up the new transition timestamp.
+ let ctx = match self.context_manager.get_context(job_id).await {
+ Ok(c) => c,
+ Err(_) => continue,
+ };
+
+ // Use the timestamp of the most recent Stuck transition, not started_at.
+ // A job that ran for hours before becoming stuck should not immediately
+ // exceed the threshold — we measure from when it actually became stuck.
+ let stuck_since = ctx
+ .transitions
+ .iter()
+ .rev()
+ .find(|t| t.to == JobState::Stuck)
+ .map(|t| t.timestamp);
+
+ let stuck_duration = stuck_since
+ .map(|ts| {
+ let duration = Utc::now().signed_duration_since(ts);
Duration::from_secs(duration.num_seconds().max(0) as u64)
})
.unwrap_or_default();
+ // Only report already-Stuck jobs that have been stuck long enough.
+ // Jobs just transitioned from InProgress skip this check — they
+ // were already vetted by find_stuck_jobs_with_threshold.
+ if !just_transitioned && stuck_duration < self.stuck_threshold {
+ continue;
+ }
+
stuck_jobs.push(StuckJob {
job_id,
- last_activity: ctx.started_at.unwrap_or(ctx.created_at),
+ last_activity: stuck_since.unwrap_or(ctx.created_at),
stuck_duration,
last_error: None,
repair_attempts: ctx.repair_attempts,
@@ -157,10 +209,17 @@ impl SelfRepair for DefaultSelfRepair {
});
}
- // Try to recover the job
+ // Try to recover the job.
+ // If the job is still InProgress (detected via stuck_threshold), transition
+ // it to Stuck first so that attempt_recovery() can move it back to InProgress.
let result = self
.context_manager
- .update_context(job.job_id, |ctx| ctx.attempt_recovery())
+ .update_context(job.job_id, |ctx| {
+ if ctx.state == JobState::InProgress {
+ ctx.transition_to(JobState::Stuck, Some("exceeded stuck_threshold".into()))?;
+ }
+ ctx.attempt_recovery()
+ })
.await;
match result {
@@ -273,9 +332,8 @@ impl SelfRepair for DefaultSelfRepair {
tracing::warn!("Failed to mark tool as repaired: {}", e);
}
- // Log if the tool was auto-registered
if result.registered {
- tracing::info!("Repaired tool '{}' auto-registered", tool.name);
+ tracing::info!("Repaired tool '{}' auto-registered by builder", tool.name);
}
Ok(RepairResult::Success {
@@ -417,7 +475,8 @@ mod tests {
.unwrap()
.unwrap();
- let repair = DefaultSelfRepair::new(cm, Duration::from_secs(60), 3);
+ // Use zero threshold so the just-stuck job is detected immediately.
+ let repair = DefaultSelfRepair::new(cm, Duration::from_secs(0), 3);
let stuck = repair.detect_stuck_jobs().await;
assert_eq!(stuck.len(), 1);
assert_eq!(stuck[0].job_id, job_id);
@@ -483,6 +542,49 @@ mod tests {
);
}
+ #[tokio::test]
+ async fn detect_and_repair_in_progress_job_via_threshold() {
+ let cm = Arc::new(ContextManager::new(10));
+ let job_id = cm.create_job("Long running", "desc").await.unwrap();
+
+ // Transition to InProgress.
+ cm.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
+ .await
+ .unwrap()
+ .unwrap();
+
+ // Backdate started_at to simulate a job running for 10 minutes.
+ cm.update_context(job_id, |ctx| {
+ ctx.started_at = Some(Utc::now() - chrono::Duration::seconds(600));
+ })
+ .await
+ .unwrap();
+
+ // Use a 5-minute threshold so the 10-minute job is detected.
+ let repair = DefaultSelfRepair::new(Arc::clone(&cm), Duration::from_secs(300), 3);
+
+ // detect_stuck_jobs should find it and transition InProgress -> Stuck.
+ let stuck = repair.detect_stuck_jobs().await;
+ assert_eq!(stuck.len(), 1);
+ assert_eq!(stuck[0].job_id, job_id);
+
+ // After detection the job should now be in Stuck state.
+ let ctx = cm.get_context(job_id).await.unwrap();
+ assert_eq!(ctx.state, JobState::Stuck);
+
+ // Repair should recover it: Stuck -> InProgress.
+ let result = repair.repair_stuck_job(&stuck[0]).await.unwrap();
+ assert!(
+ matches!(result, RepairResult::Success { .. }),
+ "Expected Success, got: {:?}",
+ result
+ );
+
+ // Job should be back to InProgress after recovery.
+ let ctx = cm.get_context(job_id).await.unwrap();
+ assert_eq!(ctx.state, JobState::InProgress);
+ }
+
#[tokio::test]
async fn detect_broken_tools_returns_empty_without_store() {
let cm = Arc::new(ContextManager::new(10));
@@ -515,4 +617,240 @@ mod tests {
result
);
}
+
+ #[tokio::test]
+ async fn detect_stuck_jobs_filters_by_threshold() {
+ let cm = Arc::new(ContextManager::new(10));
+ let job_id = cm.create_job("Stuck job", "desc").await.unwrap();
+
+ // Transition to InProgress, then to Stuck.
+ cm.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
+ .await
+ .unwrap()
+ .unwrap();
+ cm.update_context(job_id, |ctx| {
+ ctx.transition_to(JobState::Stuck, Some("timed out".to_string()))
+ })
+ .await
+ .unwrap()
+ .unwrap();
+
+ // Use a very large threshold (1 hour). Job just became stuck, so
+ // stuck_duration < threshold. It should be filtered out.
+ let repair = DefaultSelfRepair::new(cm, Duration::from_secs(3600), 3);
+ let stuck = repair.detect_stuck_jobs().await;
+ assert!(
+ stuck.is_empty(),
+ "Job stuck for <1s should be filtered by 1h threshold"
+ );
+ }
+
+ #[tokio::test]
+ async fn detect_stuck_jobs_includes_when_over_threshold() {
+ let cm = Arc::new(ContextManager::new(10));
+ let job_id = cm.create_job("Stuck job", "desc").await.unwrap();
+
+ // Transition to InProgress, then to Stuck.
+ cm.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
+ .await
+ .unwrap()
+ .unwrap();
+ cm.update_context(job_id, |ctx| {
+ ctx.transition_to(JobState::Stuck, Some("timed out".to_string()))
+ })
+ .await
+ .unwrap()
+ .unwrap();
+
+ // Use a zero threshold -- any stuck duration should be included.
+ let repair = DefaultSelfRepair::new(cm, Duration::from_secs(0), 3);
+ let stuck = repair.detect_stuck_jobs().await;
+ assert_eq!(stuck.len(), 1, "Job should be detected with zero threshold");
+ assert_eq!(stuck[0].job_id, job_id);
+ }
+
+ /// Regression: stuck_duration must be measured from the Stuck transition,
+ /// not from started_at. A job that ran for 2 hours before becoming stuck
+ /// should NOT immediately exceed a 5-minute threshold.
+ #[tokio::test]
+ async fn stuck_duration_measured_from_stuck_transition_not_started_at() {
+ let cm = Arc::new(ContextManager::new(10));
+ let job_id = cm.create_job("Long runner", "desc").await.unwrap();
+
+ // Transition to InProgress (sets started_at to now).
+ cm.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
+ .await
+ .unwrap()
+ .unwrap();
+
+ // Backdate started_at to 2 hours ago to simulate a long-running job.
+ cm.update_context(job_id, |ctx| {
+ ctx.started_at = Some(Utc::now() - chrono::Duration::hours(2));
+ Ok::<(), crate::error::Error>(())
+ })
+ .await
+ .unwrap()
+ .unwrap();
+
+ // Now transition to Stuck (stuck transition timestamp is ~now).
+ cm.update_context(job_id, |ctx| {
+ ctx.transition_to(JobState::Stuck, Some("wedged".into()))
+ })
+ .await
+ .unwrap()
+ .unwrap();
+
+ // With a 5-minute threshold, the job JUST became stuck — should NOT be detected.
+ let repair = DefaultSelfRepair::new(cm, Duration::from_secs(300), 3);
+ let stuck = repair.detect_stuck_jobs().await;
+ assert!(
+ stuck.is_empty(),
+ "Job stuck for <1s should not exceed 5min threshold, \
+ but stuck_duration was computed from started_at (2h ago)"
+ );
+ }
+
+ /// Mock SoftwareBuilder that returns a successful build result.
+ struct MockBuilder {
+ build_count: std::sync::atomic::AtomicU32,
+ }
+
+ impl MockBuilder {
+ fn new() -> Self {
+ Self {
+ build_count: std::sync::atomic::AtomicU32::new(0),
+ }
+ }
+
+ fn builds(&self) -> u32 {
+ self.build_count.load(std::sync::atomic::Ordering::Relaxed)
+ }
+ }
+
+ #[async_trait]
+ impl crate::tools::SoftwareBuilder for MockBuilder {
+ async fn analyze(
+ &self,
+ _description: &str,
+ ) -> Result {
+ Ok(crate::tools::BuildRequirement {
+ name: "mock-tool".to_string(),
+ description: "mock".to_string(),
+ software_type: crate::tools::SoftwareType::WasmTool,
+ language: crate::tools::Language::Rust,
+ input_spec: None,
+ output_spec: None,
+ dependencies: vec![],
+ capabilities: vec![],
+ })
+ }
+
+ async fn build(
+ &self,
+ requirement: &crate::tools::BuildRequirement,
+ ) -> Result {
+ self.build_count
+ .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+ Ok(crate::tools::BuildResult {
+ build_id: Uuid::new_v4(),
+ requirement: requirement.clone(),
+ artifact_path: std::path::PathBuf::from("/tmp/mock.wasm"),
+ logs: vec![],
+ success: true,
+ error: None,
+ started_at: Utc::now(),
+ completed_at: Utc::now(),
+ iterations: 1,
+ validation_warnings: vec![],
+ tests_passed: 1,
+ tests_failed: 0,
+ registered: true,
+ })
+ }
+
+ async fn repair(
+ &self,
+ _result: &crate::tools::BuildResult,
+ _error: &str,
+ ) -> Result {
+ unimplemented!("not needed for this test")
+ }
+ }
+
+ /// E2E test: stuck job detected -> repaired -> transitions back to InProgress,
+ /// and broken tool detected -> builder invoked -> tool marked repaired.
+ #[cfg(feature = "libsql")]
+ #[tokio::test]
+ async fn e2e_stuck_job_repair_and_tool_rebuild() {
+ // --- Setup ---
+ let cm = Arc::new(ContextManager::new(10));
+ let job_id = cm.create_job("E2E stuck job", "desc").await.unwrap();
+
+ // Transition job: Pending -> InProgress -> Stuck
+ cm.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
+ .await
+ .unwrap()
+ .unwrap();
+ cm.update_context(job_id, |ctx| {
+ ctx.transition_to(JobState::Stuck, Some("deadlocked".to_string()))
+ })
+ .await
+ .unwrap()
+ .unwrap();
+
+ // Create a mock builder and a real test database (for store)
+ let builder = Arc::new(MockBuilder::new());
+ let tools = Arc::new(ToolRegistry::new());
+ let (db, _tmp_dir) = crate::testing::test_db().await;
+
+ // Create self-repair with zero threshold (detect immediately),
+ // wired with store, builder, and tools.
+ let repair = DefaultSelfRepair::new(Arc::clone(&cm), Duration::from_secs(0), 3)
+ .with_store(Arc::clone(&db))
+ .with_builder(
+ Arc::clone(&builder) as Arc,
+ tools,
+ );
+
+ // --- Phase 1: Detect and repair stuck job ---
+ let stuck_jobs = repair.detect_stuck_jobs().await;
+ assert_eq!(stuck_jobs.len(), 1, "Should detect the stuck job");
+ assert_eq!(stuck_jobs[0].job_id, job_id);
+
+ let result = repair.repair_stuck_job(&stuck_jobs[0]).await.unwrap();
+ assert!(
+ matches!(result, RepairResult::Success { .. }),
+ "Job repair should succeed: {:?}",
+ result
+ );
+
+ // Verify job transitioned back to InProgress
+ let ctx = cm.get_context(job_id).await.unwrap();
+ assert_eq!(
+ ctx.state,
+ JobState::InProgress,
+ "Job should be back to InProgress after repair"
+ );
+
+ // --- Phase 2: Repair a broken tool via builder ---
+ let broken = BrokenTool {
+ name: "broken-wasm-tool".to_string(),
+ failure_count: 10,
+ last_error: Some("panic in tool execution".to_string()),
+ first_failure: Utc::now() - chrono::Duration::hours(1),
+ last_failure: Utc::now(),
+ last_build_result: None,
+ repair_attempts: 0,
+ };
+
+ let tool_result = repair.repair_broken_tool(&broken).await.unwrap();
+ assert!(
+ matches!(tool_result, RepairResult::Success { .. }),
+ "Tool repair should succeed with mock builder: {:?}",
+ tool_result
+ );
+
+ // Verify builder was actually invoked
+ assert_eq!(builder.builds(), 1, "Builder should have been called once");
+ }
}
diff --git a/src/agent/session.rs b/src/agent/session.rs
index 4abbea61..3e84afc0 100644
--- a/src/agent/session.rs
+++ b/src/agent/session.rs
@@ -188,6 +188,15 @@ pub struct PendingApproval {
/// through the approval flow even if the approval message lacks timezone.
#[serde(default)]
pub user_timezone: Option,
+ /// Whether the "always" auto-approve option should be offered to the user.
+ /// `false` when the tool returned `ApprovalRequirement::Always` (e.g.
+ /// destructive shell commands), meaning every invocation must be confirmed.
+ #[serde(default = "default_true")]
+ pub allow_always: bool,
+}
+
+fn default_true() -> bool {
+ true
}
/// A conversation thread within a session.
@@ -1106,6 +1115,7 @@ mod tests {
context_messages: vec![ChatMessage::user("do it")],
deferred_tool_calls: vec![],
user_timezone: None,
+ allow_always: false,
};
thread.await_approval(approval);
@@ -1132,6 +1142,7 @@ mod tests {
context_messages: vec![],
deferred_tool_calls: vec![],
user_timezone: None,
+ allow_always: true,
};
thread.await_approval(approval);
diff --git a/src/agent/session_manager.rs b/src/agent/session_manager.rs
index 3db275cc..3bf20697 100644
--- a/src/agent/session_manager.rs
+++ b/src/agent/session_manager.rs
@@ -772,6 +772,33 @@ mod tests {
assert_ne!(resolved, tid);
}
+ #[tokio::test]
+ async fn test_register_then_resolve_same_uuid_on_second_channel_reuses_thread() {
+ use crate::agent::session::{Session, Thread};
+
+ let manager = SessionManager::new();
+ let tid = Uuid::new_v4();
+
+ let session = Arc::new(Mutex::new(Session::new("user-cross")));
+ {
+ let mut sess = session.lock().await;
+ let thread = Thread::with_id(tid, sess.id);
+ sess.threads.insert(tid, thread);
+ }
+
+ manager
+ .register_thread("user-cross", "http", tid, Arc::clone(&session))
+ .await;
+ manager
+ .register_thread("user-cross", "gateway", tid, Arc::clone(&session))
+ .await;
+
+ let (_, resolved) = manager
+ .resolve_thread("user-cross", "gateway", Some(&tid.to_string()))
+ .await;
+ assert_eq!(resolved, tid);
+ }
+
// === QA Plan P3 - 4.2: Concurrent session stress tests ===
#[tokio::test]
diff --git a/src/agent/submission.rs b/src/agent/submission.rs
index a3ae2524..8594c969 100644
--- a/src/agent/submission.rs
+++ b/src/agent/submission.rs
@@ -382,6 +382,8 @@ pub enum SubmissionResult {
description: String,
/// Parameters being passed.
parameters: serde_json::Value,
+ /// Whether "always" auto-approve should be offered to the user.
+ allow_always: bool,
},
/// Successfully processed (for control commands).
diff --git a/src/agent/thread_ops.rs b/src/agent/thread_ops.rs
index 877a4e27..0fb968f1 100644
--- a/src/agent/thread_ops.rs
+++ b/src/agent/thread_ops.rs
@@ -506,7 +506,8 @@ impl Agent {
let tool_name = pending.tool_name.clone();
let description = pending.description.clone();
let parameters = pending.display_parameters.clone();
- thread.await_approval(pending);
+ let allow_always = pending.allow_always;
+ thread.await_approval(*pending);
let _ = self
.channels
.send_status(
@@ -516,6 +517,7 @@ impl Agent {
tool_name: tool_name.clone(),
description: description.clone(),
parameters: parameters.clone(),
+ allow_always,
},
&message.metadata,
)
@@ -525,6 +527,7 @@ impl Agent {
tool_name,
description,
parameters,
+ allow_always,
})
}
Err(e) => {
@@ -936,6 +939,7 @@ impl Agent {
JobContext::with_user(&message.user_id, "chat", "Interactive chat session")
.with_requester_id(&message.sender_id);
job_ctx.http_interceptor = self.deps.http_interceptor.clone();
+ job_ctx.metadata = crate::agent::agent_loop::chat_tool_execution_metadata(message);
// Prefer a valid timezone from the approval message, fall back to the
// resolved timezone stored when the approval was originally requested.
let tz_candidate = message
@@ -1069,28 +1073,31 @@ impl Agent {
usize,
crate::llm::ToolCall,
Arc,
+ bool, // allow_always
)> = None;
for (idx, tc) in deferred_tool_calls.iter().enumerate() {
if let Some(tool) = self.tools().get(&tc.name).await {
// Match dispatcher.rs: when auto_approve_tools is true, skip
// all approval checks (including ApprovalRequirement::Always).
- let needs_approval = if self.config.auto_approve_tools {
- false
+ let (needs_approval, allow_always) = if self.config.auto_approve_tools {
+ (false, true)
} else {
use crate::tools::ApprovalRequirement;
- match tool.requires_approval(&tc.arguments) {
+ let requirement = tool.requires_approval(&tc.arguments);
+ let needs = match requirement {
ApprovalRequirement::Never => false,
ApprovalRequirement::UnlessAutoApproved => {
let sess = session.lock().await;
!sess.is_tool_auto_approved(&tc.name)
}
ApprovalRequirement::Always => true,
- }
+ };
+ (needs, !matches!(requirement, ApprovalRequirement::Always))
};
if needs_approval {
- approval_needed = Some((idx, tc.clone(), tool));
+ approval_needed = Some((idx, tc.clone(), tool, allow_always));
break; // remaining tools stay deferred
}
}
@@ -1298,7 +1305,7 @@ impl Agent {
}
// Handle approval if a tool needed it
- if let Some((approval_idx, tc, tool)) = approval_needed {
+ if let Some((approval_idx, tc, tool, allow_always)) = approval_needed {
let new_pending = PendingApproval {
request_id: Uuid::new_v4(),
tool_name: tc.name.clone(),
@@ -1310,6 +1317,7 @@ impl Agent {
deferred_tool_calls: deferred_tool_calls[approval_idx + 1..].to_vec(),
// Carry forward the resolved timezone from the original pending approval
user_timezone: pending.user_timezone.clone(),
+ allow_always,
};
let request_id = new_pending.request_id;
@@ -1333,6 +1341,7 @@ impl Agent {
tool_name: tool_name.clone(),
description: description.clone(),
parameters: parameters.clone(),
+ allow_always,
},
&message.metadata,
)
@@ -1343,6 +1352,7 @@ impl Agent {
tool_name,
description,
parameters,
+ allow_always,
});
}
@@ -1411,7 +1421,8 @@ impl Agent {
let tool_name = new_pending.tool_name.clone();
let description = new_pending.description.clone();
let parameters = new_pending.display_parameters.clone();
- thread.await_approval(new_pending);
+ let allow_always = new_pending.allow_always;
+ thread.await_approval(*new_pending);
let _ = self
.channels
.send_status(
@@ -1421,6 +1432,7 @@ impl Agent {
tool_name: tool_name.clone(),
description: description.clone(),
parameters: parameters.clone(),
+ allow_always,
},
&message.metadata,
)
@@ -1430,6 +1442,7 @@ impl Agent {
tool_name,
description,
parameters,
+ allow_always,
})
}
Err(e) => {
@@ -1949,6 +1962,7 @@ mod tests {
context_messages: vec![],
deferred_tool_calls: vec![],
user_timezone: None,
+ allow_always: false,
};
thread.await_approval(pending);
diff --git a/src/app.rs b/src/app.rs
index 0ffe7820..28e7ada5 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -25,7 +25,7 @@ use crate::tools::ToolRegistry;
use crate::tools::mcp::{McpProcessManager, McpSessionManager};
use crate::tools::wasm::SharedCredentialRegistry;
use crate::tools::wasm::WasmToolRuntime;
-use crate::workspace::{EmbeddingProvider, Workspace};
+use crate::workspace::{EmbeddingCacheConfig, EmbeddingProvider, Workspace};
/// Fully initialized application components, ready for channel wiring
/// and agent construction.
@@ -56,6 +56,7 @@ pub struct AppComponents {
pub session: Arc,
pub catalog_entries: Vec,
pub dev_loaded_tool_names: Vec,
+ pub builder: Option>,
}
/// Options that control optional init phases.
@@ -280,6 +281,7 @@ impl AppBuilder {
Arc,
Option>,
Option>,
+ Option>,
),
anyhow::Error,
> {
@@ -310,12 +312,23 @@ impl AppBuilder {
.create_provider(&self.config.llm.nearai.base_url, self.session.clone());
// Register memory tools if database is available
+ let workspace_user_id = self
+ .config
+ .channels
+ .gateway
+ .as_ref()
+ .map(|gw| gw.user_id.as_str())
+ .unwrap_or("default");
let workspace = if let Some(ref db) = self.db {
- let mut ws = Workspace::new_with_db(&self.config.owner_id, db.clone())
+ let emb_cache_config = EmbeddingCacheConfig {
+ max_entries: self.config.embeddings.cache_size,
+ };
+ let mut ws = Workspace::new_with_db(workspace_user_id, db.clone())
.with_search_config(&self.config.search);
if let Some(ref emb) = embeddings {
- ws = ws.with_embeddings(emb.clone());
+ ws = ws.with_embeddings_cached(emb.clone(), emb_cache_config);
}
+ ws = ws.with_memory_layers(self.config.workspace.memory_layers.clone());
let ws = Arc::new(ws);
tools.register_memory_tools(Arc::clone(&ws));
Some(ws)
@@ -367,16 +380,19 @@ impl AppBuilder {
}
// Register builder tool if enabled
- if self.config.builder.enabled
+ let builder = if self.config.builder.enabled
&& (self.config.agent.allow_local_tools || !self.config.sandbox.enabled)
{
- tools
+ let b = tools
.register_builder_tool(llm.clone(), Some(self.config.builder.to_builder_config()))
.await;
- tracing::debug!("Builder mode enabled");
- }
+ tracing::info!("Builder mode enabled");
+ Some(b)
+ } else {
+ None
+ };
- Ok((safety, tools, embeddings, workspace))
+ Ok((safety, tools, embeddings, workspace, builder))
}
/// Phase 5: Load WASM tools, MCP servers, and create extension manager.
@@ -520,7 +536,7 @@ impl AppBuilder {
server_name,
e
);
- return;
+ return None;
}
};
@@ -537,6 +553,10 @@ impl AppBuilder {
tool_count,
server_name
);
+ return Some((
+ server_name,
+ Arc::new(client),
+ ));
}
Err(e) => {
tracing::warn!(
@@ -567,14 +587,27 @@ impl AppBuilder {
}
}
}
+ None
});
}
+ let mut startup_clients = Vec::new();
while let Some(result) = join_set.join_next().await {
- if let Err(e) = result {
- tracing::warn!("MCP server loading task panicked: {}", e);
+ match result {
+ Ok(Some(client_pair)) => {
+ startup_clients.push(client_pair);
+ }
+ Ok(None) => {}
+ Err(e) => {
+ if e.is_panic() {
+ tracing::error!("MCP server loading task panicked: {}", e);
+ } else {
+ tracing::warn!("MCP server loading task failed: {}", e);
+ }
+ }
}
}
+ return startup_clients;
}
Err(e) => {
if matches!(
@@ -592,10 +625,12 @@ impl AppBuilder {
}
}
}
+ Vec::new()
}
};
- let (dev_loaded_tool_names, _) = tokio::join!(wasm_tools_future, mcp_servers_future);
+ let (dev_loaded_tool_names, startup_mcp_clients) =
+ tokio::join!(wasm_tools_future, mcp_servers_future);
// Load registry catalog entries for extension discovery
let mut catalog_entries = match crate::registry::RegistryCatalog::load_or_embedded() {
@@ -657,6 +692,17 @@ impl AppBuilder {
));
tools.register_extension_tools(Arc::clone(&manager));
tracing::debug!("Extension manager initialized with in-chat discovery tools");
+
+ if !startup_mcp_clients.is_empty() {
+ tracing::info!(
+ count = startup_mcp_clients.len(),
+ "Injecting startup MCP clients into extension manager"
+ );
+ for (name, client) in startup_mcp_clients {
+ manager.inject_mcp_client(name, client).await;
+ }
+ }
+
Some(manager)
};
@@ -686,7 +732,11 @@ impl AppBuilder {
// Post-init validation: if a non-nearai backend was selected but
// credentials were never resolved (deferred resolution found no keys),
// fail early with a clear error instead of a confusing runtime failure.
- if self.config.llm.backend != "nearai" && self.config.llm.provider.is_none() {
+ if self.config.llm.backend != "nearai"
+ && self.config.llm.backend != "bedrock"
+ && self.config.llm.backend != "openai_codex"
+ && self.config.llm.provider.is_none()
+ {
let backend = &self.config.llm.backend;
anyhow::bail!(
"LLM_BACKEND={backend} is configured but no credentials were found. \
@@ -699,7 +749,7 @@ impl AppBuilder {
} else {
self.init_llm().await?
};
- let (safety, tools, embeddings, workspace) = self.init_tools(&llm).await?;
+ let (safety, tools, embeddings, workspace, builder) = self.init_tools(&llm).await?;
// Create hook registry early so runtime extension activation can register hooks.
let hooks = Arc::new(HookRegistry::new());
@@ -715,6 +765,17 @@ impl AppBuilder {
dev_loaded_tool_names,
) = self.init_extensions(&tools, &hooks).await?;
+ // Load bootstrap-completed flag from settings so that existing users
+ // who already completed onboarding don't re-get bootstrap injection.
+ if let Some(ref ws) = workspace {
+ let toml_path = crate::settings::Settings::default_toml_path();
+ if let Ok(Some(settings)) = crate::settings::Settings::load_toml(&toml_path)
+ && settings.profile_onboarding_completed
+ {
+ ws.mark_bootstrap_completed();
+ }
+ }
+
// Seed workspace and backfill embeddings
if let Some(ref ws) = workspace {
// Import workspace files from disk FIRST if WORKSPACE_IMPORT_DIR is set.
@@ -819,6 +880,7 @@ impl AppBuilder {
session: self.session,
catalog_entries,
dev_loaded_tool_names,
+ builder,
})
}
}
diff --git a/src/channels/channel.rs b/src/channels/channel.rs
index 43e35688..a85cf8c5 100644
--- a/src/channels/channel.rs
+++ b/src/channels/channel.rs
@@ -305,6 +305,11 @@ pub enum StatusUpdate {
tool_name: String,
description: String,
parameters: serde_json::Value,
+ /// When `true`, the UI should offer an "always" option that auto-approves
+ /// future calls to this tool for the rest of the session. When `false`
+ /// (i.e. `ApprovalRequirement::Always`), the tool must be approved every
+ /// time and the "always" button should be hidden.
+ allow_always: bool,
},
/// Extension needs user authentication (token or OAuth).
AuthRequired {
diff --git a/src/channels/manager.rs b/src/channels/manager.rs
index b026ff85..0c9a3da7 100644
--- a/src/channels/manager.rs
+++ b/src/channels/manager.rs
@@ -239,6 +239,11 @@ impl ChannelManager {
pub async fn get_channel(&self, name: &str) -> Option> {
self.channels.read().await.get(name).cloned()
}
+
+ /// Remove a channel from the manager.
+ pub async fn remove(&self, name: &str) -> Option> {
+ self.channels.write().await.remove(name)
+ }
}
impl Default for ChannelManager {
diff --git a/src/channels/relay/channel.rs b/src/channels/relay/channel.rs
index 52aea478..3b6c3379 100644
--- a/src/channels/relay/channel.rs
+++ b/src/channels/relay/channel.rs
@@ -1,16 +1,16 @@
-//! Channel trait implementation for channel-relay SSE streams.
+//! Channel trait implementation for channel-relay webhook callbacks.
//!
-//! `RelayChannel` connects to a channel-relay service via SSE, converts
-//! incoming events to `IncomingMessage`s, and sends responses via the
-//! relay's provider-specific proxy API (Slack).
+//! `RelayChannel` receives events from channel-relay via HTTP POST callbacks
+//! (pushed through an mpsc channel by the webhook handler), converts them
+//! to `IncomingMessage`s, and sends responses via the relay's provider-specific
+//! proxy API (Slack).
use std::collections::HashMap;
-use std::sync::Arc;
use async_trait::async_trait;
-use tokio::sync::{RwLock, mpsc};
+use tokio::sync::mpsc;
-use crate::channels::relay::client::{RelayClient, RelayError};
+use crate::channels::relay::client::{ChannelEvent, RelayClient};
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
use crate::error::ChannelError;
@@ -39,44 +39,34 @@ impl RelayProvider {
}
}
-/// Channel implementation that connects to a channel-relay SSE stream.
+/// Channel implementation that receives events from channel-relay via webhook callbacks.
pub struct RelayChannel {
client: RelayClient,
provider: RelayProvider,
- stream_token: Arc>,
team_id: String,
instance_id: String,
- user_id: String,
- /// SSE stream long-poll timeout in seconds.
- stream_timeout_secs: u64,
- /// Initial exponential backoff in milliseconds.
- backoff_initial_ms: u64,
- /// Maximum exponential backoff in milliseconds.
- backoff_max_ms: u64,
- /// Handle to the reconnect task for clean shutdown.
- reconnect_handle: RwLock>>,
- /// Handle to the SSE parser task for clean shutdown.
- parser_handle: Arc>>>,
- /// Maximum consecutive reconnect failures before giving up.
- max_consecutive_failures: u64,
+ /// Sender side of the event channel — shared with the webhook handler.
+ event_tx: mpsc::Sender,
+ /// Receiver side — taken once by `start()`.
+ event_rx: tokio::sync::Mutex>>,
}
impl RelayChannel {
/// Create a new relay channel for Slack (default provider).
pub fn new(
client: RelayClient,
- stream_token: String,
team_id: String,
instance_id: String,
- user_id: String,
+ event_tx: mpsc::Sender,
+ event_rx: mpsc::Receiver,
) -> Self {
Self::new_with_provider(
client,
RelayProvider::Slack,
- stream_token,
team_id,
instance_id,
- user_id,
+ event_tx,
+ event_rx,
)
}
@@ -84,44 +74,24 @@ impl RelayChannel {
pub fn new_with_provider(
client: RelayClient,
provider: RelayProvider,
- stream_token: String,
team_id: String,
instance_id: String,
- user_id: String,
+ event_tx: mpsc::Sender,
+ event_rx: mpsc::Receiver,
) -> Self {
Self {
client,
provider,
- stream_token: Arc::new(RwLock::new(stream_token)),
team_id,
instance_id,
- user_id,
- stream_timeout_secs: 86400,
- backoff_initial_ms: 1000,
- backoff_max_ms: 60000,
- reconnect_handle: RwLock::new(None),
- parser_handle: Arc::new(RwLock::new(None)),
- max_consecutive_failures: 50,
+ event_tx,
+ event_rx: tokio::sync::Mutex::new(Some(event_rx)),
}
}
- /// Set backoff/timeout parameters from relay config values.
- pub fn with_timeouts(
- mut self,
- stream_timeout_secs: u64,
- backoff_initial_ms: u64,
- backoff_max_ms: u64,
- ) -> Self {
- self.stream_timeout_secs = stream_timeout_secs;
- self.backoff_initial_ms = backoff_initial_ms;
- self.backoff_max_ms = backoff_max_ms;
- self
- }
-
- /// Set the maximum number of consecutive reconnect failures before giving up.
- pub fn with_max_failures(mut self, max: u64) -> Self {
- self.max_consecutive_failures = max;
- self
+ /// Get a clone of the event sender for wiring into the webhook endpoint.
+ pub fn event_sender(&self) -> mpsc::Sender {
+ self.event_tx.clone()
}
/// Build a provider-appropriate proxy body for sending a message.
@@ -151,15 +121,9 @@ impl RelayChannel {
team_id: &str,
method: &str,
body: serde_json::Value,
- ) -> Result {
+ ) -> Result {
self.client
- .proxy_provider(
- self.provider.as_str(),
- team_id,
- method,
- body,
- Some(&self.instance_id),
- )
+ .proxy_provider(self.provider.as_str(), team_id, method, body)
.await
}
}
@@ -172,204 +136,82 @@ impl Channel for RelayChannel {
async fn start(&self) -> Result {
let channel_name = self.name().to_string();
- let token = self.stream_token.read().await.clone();
- let (stream, initial_parser_handle) = self
- .client
- .connect_stream(&token, self.stream_timeout_secs)
- .await
- .map_err(|e| ChannelError::StartupFailed {
- name: channel_name.clone(),
- reason: e.to_string(),
- })?;
- *self.parser_handle.write().await = Some(initial_parser_handle);
+ // Take the receiver (can only start once)
+ let mut event_rx =
+ self.event_rx
+ .lock()
+ .await
+ .take()
+ .ok_or_else(|| ChannelError::StartupFailed {
+ name: channel_name.clone(),
+ reason: "RelayChannel already started".to_string(),
+ })?;
let (tx, rx) = mpsc::channel(64);
-
- // Spawn the stream reader + reconnect task
- let client = self.client.clone();
- let stream_token = Arc::clone(&self.stream_token);
- let instance_id = self.instance_id.clone();
- let user_id = self.user_id.clone();
- let team_id = self.team_id.clone();
- let stream_timeout_secs = self.stream_timeout_secs;
- let backoff_initial_ms = self.backoff_initial_ms;
- let backoff_max_ms = self.backoff_max_ms;
- let max_consecutive_failures = self.max_consecutive_failures;
- let parser_handle = Arc::clone(&self.parser_handle);
let provider_str = self.provider.as_str().to_string();
let relay_name = channel_name.clone();
- let handle = tokio::spawn(async move {
- use futures::StreamExt;
-
- let mut current_stream = stream;
- let mut backoff_ms = backoff_initial_ms;
- let mut consecutive_failures: u64 = 0;
-
- loop {
- // Read events from the current stream
- while let Some(event) = current_stream.next().await {
- // Reset backoff and failure count on successful event
- backoff_ms = backoff_initial_ms;
- consecutive_failures = 0;
-
- // Validate required fields
- if event.sender_id.is_empty()
- || event.channel_id.is_empty()
- || event.provider_scope.is_empty()
- {
- tracing::debug!(
- event_type = %event.event_type,
- sender_id = %event.sender_id,
- channel_id = %event.channel_id,
- "Relay: skipping event with missing required fields"
- );
- continue;
- }
-
- // Skip non-message events
- if !event.is_message() {
- tracing::debug!(
- event_type = %event.event_type,
- "Relay: skipping non-message event"
- );
- continue;
- }
-
- tracing::info!(
+ // Spawn a task that reads events from the webhook handler and converts to IncomingMessage
+ tokio::spawn(async move {
+ while let Some(event) = event_rx.recv().await {
+ // Validate required fields
+ if event.sender_id.is_empty()
+ || event.channel_id.is_empty()
+ || event.provider_scope.is_empty()
+ {
+ tracing::debug!(
event_type = %event.event_type,
- sender = %event.sender_id,
- channel = %event.channel_id,
- provider = %provider_str,
- "Relay: received message from {}", provider_str
+ sender_id = %event.sender_id,
+ channel_id = %event.channel_id,
+ "Relay: skipping event with missing required fields"
);
-
- let msg = IncomingMessage::new(&relay_name, &event.sender_id, event.text())
- .with_user_name(event.display_name())
- .with_metadata(serde_json::json!({
- "team_id": event.team_id(),
- "channel_id": event.channel_id,
- "sender_id": event.sender_id,
- "sender_name": event.display_name(),
- "event_type": event.event_type,
- "thread_id": event.thread_id,
- "provider": event.provider,
- }));
-
- let msg = if let Some(ref thread_id) = event.thread_id {
- msg.with_thread(thread_id)
- } else {
- msg.with_thread(&event.channel_id)
- };
-
- if tx.send(msg).await.is_err() {
- tracing::info!("Relay channel receiver dropped, stopping");
- return;
- }
+ continue;
}
- // Stream ended, attempt reconnect with backoff
- consecutive_failures += 1;
- if consecutive_failures >= max_consecutive_failures {
- tracing::error!(
- channel = %relay_name,
- failures = consecutive_failures,
- "Relay channel giving up after {} consecutive failures",
- consecutive_failures
+ // Skip non-message events
+ if !event.is_message() {
+ tracing::debug!(
+ event_type = %event.event_type,
+ "Relay: skipping non-message event"
);
- break;
+ continue;
}
- tracing::warn!(
- backoff_ms = backoff_ms,
- failures = consecutive_failures,
- "Relay SSE stream ended, reconnecting..."
+ tracing::info!(
+ event_type = %event.event_type,
+ sender = %event.sender_id,
+ channel = %event.channel_id,
+ provider = %provider_str,
+ "Relay: received message from {}", provider_str
);
- tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await;
- backoff_ms = (backoff_ms * 2).min(backoff_max_ms);
- // Try to reconnect
- let token = stream_token.read().await.clone();
- match client.connect_stream(&token, stream_timeout_secs).await {
- Ok((new_stream, new_parser)) => {
- tracing::info!("Relay SSE stream reconnected");
- consecutive_failures = 0;
- backoff_ms = backoff_initial_ms;
- current_stream = new_stream;
- // Abort old parser before replacing
- if let Some(old) = parser_handle.write().await.take() {
- old.abort();
- }
- *parser_handle.write().await = Some(new_parser);
- }
- Err(RelayError::TokenExpired) => {
- // Attempt token renewal
- tracing::info!("Relay stream token expired, renewing...");
- match client.renew_token(&instance_id, &user_id).await {
- Ok(new_token) => {
- *stream_token.write().await = new_token.clone();
- match client.connect_stream(&new_token, stream_timeout_secs).await {
- Ok((new_stream, new_parser)) => {
- tracing::info!(
- "Relay SSE stream reconnected with new token"
- );
- consecutive_failures = 0;
- backoff_ms = backoff_initial_ms;
- current_stream = new_stream;
- if let Some(old) = parser_handle.write().await.take() {
- old.abort();
- }
- *parser_handle.write().await = Some(new_parser);
- }
- Err(e) => {
- tracing::error!(
- error = %e,
- "Failed to reconnect after token renewal"
- );
- }
- }
- }
- Err(e) => {
- tracing::error!(
- error = %e,
- "Failed to renew relay stream token"
- );
- }
- }
- }
- Err(e) => {
- tracing::error!(error = %e, "Failed to reconnect relay SSE stream");
- }
- }
+ let msg = IncomingMessage::new(&relay_name, &event.sender_id, event.text())
+ .with_user_name(event.display_name())
+ .with_metadata(serde_json::json!({
+ "team_id": event.team_id(),
+ "channel_id": event.channel_id,
+ "sender_id": event.sender_id,
+ "sender_name": event.display_name(),
+ "event_type": event.event_type,
+ "thread_id": event.thread_id,
+ "provider": event.provider,
+ }));
- // Check if the team is still valid (skip when team_id is unknown,
- // e.g. when no DB store was available at activation time)
- if !team_id.is_empty() {
- match client.list_connections(&instance_id).await {
- Ok(conns) => {
- let has_team =
- conns.iter().any(|c| c.team_id == team_id && c.connected);
- if !has_team {
- tracing::warn!(
- team_id = %team_id,
- "Team no longer connected, stopping relay channel"
- );
- return;
- }
- }
- Err(e) => {
- tracing::warn!(
- error = %e,
- "Could not verify team connection, will retry next iteration"
- );
- }
- }
+ let msg = if let Some(ref thread_id) = event.thread_id {
+ msg.with_thread(thread_id)
+ } else {
+ msg.with_thread(&event.channel_id)
+ };
+
+ if tx.send(msg).await.is_err() {
+ tracing::info!("Relay channel receiver dropped, stopping");
+ return;
}
}
- });
- *self.reconnect_handle.write().await = Some(handle);
+ tracing::info!("Relay event channel closed");
+ });
let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
Ok(Box::pin(stream))
@@ -423,6 +265,7 @@ impl Channel for RelayChannel {
tool_name,
description,
parameters,
+ allow_always: _,
} = status
else {
return Ok(());
@@ -450,28 +293,24 @@ impl Channel for RelayChannel {
name: self.name().to_string(),
reason: "Missing channel_id for approval buttons".into(),
})?;
- let sender_id = metadata
- .get("sender_id")
- .and_then(|v| v.as_str())
- .ok_or_else(|| ChannelError::SendFailed {
- name: self.name().to_string(),
- reason: "Missing sender_id for approval buttons".into(),
- })?;
let thread_id = metadata.get("thread_id").and_then(|v| v.as_str());
let team_id = metadata
.get("team_id")
.and_then(|v| v.as_str())
.unwrap_or(&self.team_id);
- // Button value payload (Slack limits button values to 2000 chars;
- // safe with typical UUIDs but documented here as a constraint)
+ // Register server-side approval record and get opaque token.
+ // The button value contains ONLY the token — no routing fields.
+ let approval_token = self
+ .client
+ .create_approval(team_id, channel_id, thread_id, &request_id)
+ .await
+ .map_err(|e| ChannelError::SendFailed {
+ name: self.name().to_string(),
+ reason: format!("Failed to register approval: {e}"),
+ })?;
let value_payload = serde_json::json!({
- "instance_id": self.instance_id,
- "team_id": team_id,
- "channel_id": channel_id,
- "thread_ts": thread_id,
- "request_id": request_id,
- "sender_id": sender_id,
+ "approval_token": approval_token,
});
let value_str = value_payload.to_string();
@@ -582,12 +421,8 @@ impl Channel for RelayChannel {
}
async fn shutdown(&self) -> Result<(), ChannelError> {
- if let Some(handle) = self.reconnect_handle.write().await.take() {
- handle.abort();
- }
- if let Some(handle) = self.parser_handle.write().await.take() {
- handle.abort();
- }
+ // Relay cleanup is driven by the extension manager dropping the shared
+ // sender and removing the channel from the channel manager.
Ok(())
}
}
@@ -605,27 +440,20 @@ mod tests {
.expect("client")
}
+ fn make_channel() -> RelayChannel {
+ let (tx, rx) = mpsc::channel(64);
+ RelayChannel::new(test_client(), "T123".into(), "inst1".into(), tx, rx)
+ }
+
#[test]
fn relay_channel_name() {
- let channel = RelayChannel::new(
- test_client(),
- "token".into(),
- "T123".into(),
- "inst1".into(),
- "user1".into(),
- );
+ let channel = make_channel();
assert_eq!(channel.name(), DEFAULT_RELAY_NAME);
}
#[test]
fn conversation_context_extracts_metadata() {
- let channel = RelayChannel::new(
- test_client(),
- "token".into(),
- "T123".into(),
- "inst1".into(),
- "user1".into(),
- );
+ let channel = make_channel();
let metadata = serde_json::json!({
"sender_name": "bob",
@@ -640,8 +468,6 @@ mod tests {
#[test]
fn metadata_shape_includes_event_type_and_sender_name() {
- // Regression: metadata JSON must include event_type and sender_name
- // for downstream routing (DM vs channel) and conversation_context().
let metadata = serde_json::json!({
"team_id": "T123",
"channel_id": "C456",
@@ -651,43 +477,19 @@ mod tests {
"thread_id": null,
"provider": "slack",
});
- // event_type must be present for DM-vs-channel routing
assert_eq!(
metadata.get("event_type").and_then(|v| v.as_str()),
Some("direct_message")
);
- // sender_name must be present for conversation_context
assert_eq!(
metadata.get("sender_name").and_then(|v| v.as_str()),
Some("alice")
);
}
- #[test]
- fn with_timeouts_sets_values() {
- let channel = RelayChannel::new(
- test_client(),
- "token".into(),
- "T123".into(),
- "inst1".into(),
- "user1".into(),
- )
- .with_timeouts(43200, 2000, 120000);
-
- assert_eq!(channel.stream_timeout_secs, 43200);
- assert_eq!(channel.backoff_initial_ms, 2000);
- assert_eq!(channel.backoff_max_ms, 120000);
- }
-
#[test]
fn build_send_body_slack() {
- let channel = RelayChannel::new(
- test_client(),
- "token".into(),
- "T123".into(),
- "inst1".into(),
- "user1".into(),
- );
+ let channel = make_channel();
let (method, body) = channel.build_send_body("C456", "hello", Some("1234567.890"));
assert_eq!(method, "chat.postMessage");
assert_eq!(body["channel"], "C456");
@@ -695,72 +497,95 @@ mod tests {
assert_eq!(body["thread_ts"], "1234567.890");
}
- #[test]
- fn parser_handle_is_shared_arc() {
- let channel = RelayChannel::new(
- test_client(),
- "token".into(),
- "T123".into(),
- "inst1".into(),
- "user1".into(),
- );
- // parser_handle should be an Arc — cloning should give a second reference
- let handle_clone = Arc::clone(&channel.parser_handle);
- // Both point to the same allocation
- assert!(Arc::ptr_eq(&channel.parser_handle, &handle_clone));
+ #[tokio::test]
+ async fn start_processes_events() {
+ let (tx, rx) = mpsc::channel(64);
+ let channel =
+ RelayChannel::new(test_client(), "T123".into(), "inst1".into(), tx.clone(), rx);
+
+ let mut stream = channel.start().await.unwrap();
+
+ // Send an event
+ tx.send(ChannelEvent {
+ id: "1".into(),
+ event_type: "message".into(),
+ provider: "slack".into(),
+ provider_scope: "T123".into(),
+ channel_id: "C456".into(),
+ sender_id: "U789".into(),
+ sender_name: Some("alice".into()),
+ content: Some("hello".into()),
+ thread_id: None,
+ raw: serde_json::Value::Null,
+ timestamp: None,
+ })
+ .await
+ .unwrap();
+
+ use futures::StreamExt;
+ let msg = tokio::time::timeout(std::time::Duration::from_secs(1), stream.next())
+ .await
+ .unwrap()
+ .unwrap();
+
+ assert_eq!(msg.content, "hello");
+ assert_eq!(msg.user_id, "U789");
}
- #[test]
- fn with_max_failures_sets_value() {
- let channel = RelayChannel::new(
- test_client(),
- "token".into(),
- "T123".into(),
- "inst1".into(),
- "user1".into(),
- )
- .with_max_failures(10);
+ #[tokio::test]
+ async fn start_skips_non_message_events() {
+ let (tx, rx) = mpsc::channel(64);
+ let channel =
+ RelayChannel::new(test_client(), "T123".into(), "inst1".into(), tx.clone(), rx);
- assert_eq!(channel.max_consecutive_failures, 10);
- }
+ let mut stream = channel.start().await.unwrap();
- #[test]
- fn default_max_failures_is_50() {
- let channel = RelayChannel::new(
- test_client(),
- "token".into(),
- "T123".into(),
- "inst1".into(),
- "user1".into(),
- );
- assert_eq!(channel.max_consecutive_failures, 50);
- }
+ // Send a non-message event (should be skipped)
+ tx.send(ChannelEvent {
+ id: "1".into(),
+ event_type: "reaction".into(),
+ provider: "slack".into(),
+ provider_scope: "T123".into(),
+ channel_id: "C456".into(),
+ sender_id: "U789".into(),
+ sender_name: None,
+ content: None,
+ thread_id: None,
+ raw: serde_json::Value::Null,
+ timestamp: None,
+ })
+ .await
+ .unwrap();
- #[test]
- fn empty_team_id_accepted_at_construction() {
- // Regression: empty team_id (when no DB store is available) must not
- // prevent channel construction or cause immediate shutdown.
- let channel = RelayChannel::new(
- test_client(),
- "token".into(),
- String::new(), // empty team_id
- "inst1".into(),
- "user1".into(),
- );
- assert_eq!(channel.team_id, "");
- // The reconnect loop now skips team validation when team_id is empty,
- // so the channel remains alive.
+ // Send a real message
+ tx.send(ChannelEvent {
+ id: "2".into(),
+ event_type: "message".into(),
+ provider: "slack".into(),
+ provider_scope: "T123".into(),
+ channel_id: "C456".into(),
+ sender_id: "U789".into(),
+ sender_name: None,
+ content: Some("real message".into()),
+ thread_id: None,
+ raw: serde_json::Value::Null,
+ timestamp: None,
+ })
+ .await
+ .unwrap();
+
+ use futures::StreamExt;
+ let msg = tokio::time::timeout(std::time::Duration::from_secs(1), stream.next())
+ .await
+ .unwrap()
+ .unwrap();
+
+ assert_eq!(msg.content, "real message");
}
#[tokio::test]
async fn test_send_status_non_approval_is_noop() {
- let channel = RelayChannel::new(
- test_client(),
- "token".into(),
- "T123".into(),
- "inst1".into(),
- "user1".into(),
- );
+ let channel = make_channel();
let metadata = serde_json::json!({});
let result = channel
.send_status(
@@ -775,13 +600,7 @@ mod tests {
#[tokio::test]
async fn test_send_status_approval_non_dm_skips() {
- let channel = RelayChannel::new(
- test_client(),
- "token".into(),
- "T123".into(),
- "inst1".into(),
- "user1".into(),
- );
+ let channel = make_channel();
let metadata = serde_json::json!({
"event_type": "message",
"channel_id": "C456",
@@ -794,6 +613,7 @@ mod tests {
tool_name: "shell".into(),
description: "run command".into(),
parameters: serde_json::json!({}),
+ allow_always: true,
},
&metadata,
)
@@ -804,13 +624,7 @@ mod tests {
#[tokio::test]
async fn test_send_status_approval_dm_missing_channel_id_errors() {
- let channel = RelayChannel::new(
- test_client(),
- "token".into(),
- "T123".into(),
- "inst1".into(),
- "user1".into(),
- );
+ let channel = make_channel();
let metadata = serde_json::json!({
"event_type": "direct_message",
"sender_id": "U789",
@@ -822,6 +636,7 @@ mod tests {
tool_name: "shell".into(),
description: "run command".into(),
parameters: serde_json::json!({}),
+ allow_always: true,
},
&metadata,
)
@@ -835,14 +650,8 @@ mod tests {
}
#[tokio::test]
- async fn test_send_status_approval_dm_missing_sender_id_errors() {
- let channel = RelayChannel::new(
- test_client(),
- "token".into(),
- "T123".into(),
- "inst1".into(),
- "user1".into(),
- );
+ async fn test_send_status_approval_dm_without_sender_id_is_ok() {
+ let channel = make_channel();
let metadata = serde_json::json!({
"event_type": "direct_message",
"channel_id": "C456",
@@ -854,6 +663,7 @@ mod tests {
tool_name: "shell".into(),
description: "run command".into(),
parameters: serde_json::json!({}),
+ allow_always: true,
},
&metadata,
)
@@ -861,8 +671,8 @@ mod tests {
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
- err.contains("sender_id"),
- "expected sender_id error, got: {err}"
+ !err.contains("sender_id"),
+ "sender_id should not be required anymore, got: {err}"
);
}
}
diff --git a/src/channels/relay/client.rs b/src/channels/relay/client.rs
index d1c03a51..81fbb56c 100644
--- a/src/channels/relay/client.rs
+++ b/src/channels/relay/client.rs
@@ -1,15 +1,10 @@
//! HTTP client for the channel-relay service.
//!
//! Wraps reqwest for all channel-relay API calls: OAuth initiation,
-//! SSE streaming, token renewal, and Slack API proxy.
+//! approvals, signing-secret fetch, and Slack API proxy.
-use std::pin::Pin;
-use std::task::{Context, Poll};
-
-use futures::Stream;
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
-use tokio::sync::mpsc;
/// Known relay event types.
pub mod event_types {
@@ -18,7 +13,7 @@ pub mod event_types {
pub const MENTION: &str = "mention";
}
-/// A parsed SSE event from the channel-relay stream.
+/// A parsed event from the channel-relay webhook callback.
///
/// Field names match the channel-relay `ChannelEvent` struct exactly.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -123,21 +118,19 @@ impl RelayClient {
///
/// Calls `GET /oauth/slack/auth` with `redirect(Policy::none())` and
/// returns the `Location` header (Slack OAuth URL) without following it.
- pub async fn initiate_oauth(
- &self,
- instance_id: &str,
- user_id: &str,
- callback_url: &str,
- ) -> Result {
+ /// Initiate Slack OAuth. Channel-relay derives all URLs from the trusted
+ /// instance_url in chat-api. IronClaw only passes an optional CSRF nonce
+ /// for validating the callback — no URLs.
+ pub async fn initiate_oauth(&self, state_nonce: Option<&str>) -> Result {
+ let mut query: Vec<(&str, &str)> = vec![];
+ if let Some(nonce) = state_nonce {
+ query.push(("state_nonce", nonce));
+ }
let resp = self
.http
.get(format!("{}/oauth/slack/auth", self.base_url))
- .header("X-API-Key", self.api_key.expose_secret())
- .query(&[
- ("instance_id", instance_id),
- ("user_id", user_id),
- ("callback", callback_url),
- ])
+ .bearer_auth(self.api_key.expose_secret())
+ .query(&query)
.send()
.await
.map_err(|e| RelayError::Network(e.to_string()))?;
@@ -173,104 +166,69 @@ impl RelayClient {
}
}
- /// Connect to the SSE event stream.
+ /// Register a pending approval and return the opaque approval token.
///
- /// Returns a stream of parsed `ChannelEvent`s and the `JoinHandle` of the
- /// background SSE parser task. The caller is responsible for reconnection
- /// logic on stream end/error and for aborting the handle on shutdown.
- pub async fn connect_stream(
+ /// Calls `POST /approvals` with the target team/channel/request identifiers.
+ /// The returned token is embedded in Slack button values instead of routing fields.
+ /// The relay derives the authorized approver from the connection's authed_user_id.
+ pub async fn create_approval(
&self,
- stream_token: &str,
- stream_timeout_secs: u64,
- ) -> Result<(ChannelEventStream, tokio::task::JoinHandle<()>), RelayError> {
- let resp = self
- .http
- .get(format!("{}/stream", self.base_url))
- .query(&[("token", stream_token)])
- .timeout(std::time::Duration::from_secs(stream_timeout_secs))
- .send()
- .await
- .map_err(|e| RelayError::Network(e.to_string()))?;
-
- let status = resp.status();
- if status == reqwest::StatusCode::UNAUTHORIZED {
- return Err(RelayError::TokenExpired);
- }
- if !status.is_success() {
- let body = resp.text().await.unwrap_or_default();
- return Err(RelayError::Api {
- status: status.as_u16(),
- message: body,
- });
- }
-
- // Spawn a background task that reads the SSE stream and sends parsed events
- let (tx, rx) = mpsc::channel(64);
- let byte_stream = resp.bytes_stream();
- let handle = tokio::spawn(parse_sse_stream(byte_stream, tx));
-
- Ok((ChannelEventStream { rx }, handle))
- }
-
- /// Renew an expired stream token.
- ///
- /// Calls `POST /stream/renew` with API key auth, returns a new stream token.
- pub async fn renew_token(
- &self,
- instance_id: &str,
- user_id: &str,
+ team_id: &str,
+ channel_id: &str,
+ thread_ts: Option<&str>,
+ request_id: &str,
) -> Result {
+ let mut body = serde_json::json!({
+ "team_id": team_id,
+ "channel_id": channel_id,
+ "request_id": request_id,
+ });
+ if let Some(ts) = thread_ts {
+ body["thread_ts"] = serde_json::Value::String(ts.to_string());
+ }
+
let resp = self
.http
- .post(format!("{}/stream/renew", self.base_url))
- .header("X-API-Key", self.api_key.expose_secret())
- .json(&serde_json::json!({
- "instance_id": instance_id,
- "user_id": user_id,
- }))
+ .post(format!("{}/approvals", self.base_url))
+ .bearer_auth(self.api_key.expose_secret())
+ .json(&body)
.send()
.await
.map_err(|e| RelayError::Network(e.to_string()))?;
- let status = resp.status();
- if !status.is_success() {
+ if !resp.status().is_success() {
+ let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(RelayError::Api {
- status: status.as_u16(),
+ status,
message: body,
});
}
- let body: serde_json::Value = resp
+ let result: serde_json::Value = resp
.json()
.await
.map_err(|e| RelayError::Protocol(e.to_string()))?;
- body.get("stream_token")
- .or_else(|| body.get("token"))
+
+ result
+ .get("approval_token")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
- .ok_or_else(|| RelayError::Protocol("Response missing stream_token field".to_string()))
+ .ok_or_else(|| RelayError::Protocol("missing approval_token in response".to_string()))
}
- /// Proxy an API call through channel-relay for any provider.
- ///
- /// Calls `POST /proxy/{provider}/{method}?team_id=X&instance_id=Y` with the given JSON body.
pub async fn proxy_provider(
&self,
provider: &str,
team_id: &str,
method: &str,
body: serde_json::Value,
- instance_id: Option<&str>,
) -> Result {
- let mut query: Vec<(&str, &str)> = vec![("team_id", team_id)];
- if let Some(iid) = instance_id {
- query.push(("instance_id", iid));
- }
+ let query: Vec<(&str, &str)> = vec![("team_id", team_id)];
let resp = self
.http
.post(format!("{}/proxy/{}/{}", self.base_url, provider, method))
- .header("X-API-Key", self.api_key.expose_secret())
+ .bearer_auth(self.api_key.expose_secret())
.query(&query)
.json(&body)
.send()
@@ -291,12 +249,58 @@ impl RelayClient {
.map_err(|e| RelayError::Protocol(e.to_string()))
}
+ /// Fetch the per-instance callback signing secret from channel-relay.
+ ///
+ /// Calls `GET /relay/signing-secret` (authenticated) and returns the decoded
+ /// 32-byte secret. Called once at activation time; the result is cached in the
+ /// extension manager so subsequent calls to `relay_signing_secret()` use it.
+ pub async fn get_signing_secret(&self, team_id: &str) -> Result, RelayError> {
+ let resp = self
+ .http
+ .get(format!("{}/relay/signing-secret", self.base_url))
+ .bearer_auth(self.api_key.expose_secret())
+ .query(&[("team_id", team_id)])
+ .send()
+ .await
+ .map_err(|e| RelayError::Network(e.to_string()))?;
+
+ if !resp.status().is_success() {
+ let status = resp.status().as_u16();
+ let body = resp.text().await.unwrap_or_default();
+ return Err(RelayError::Api {
+ status,
+ message: body,
+ });
+ }
+
+ let body: serde_json::Value = resp
+ .json()
+ .await
+ .map_err(|e| RelayError::Protocol(e.to_string()))?;
+
+ body.get("signing_secret")
+ .and_then(|v| v.as_str())
+ .ok_or_else(|| RelayError::Protocol("missing signing_secret in response".to_string()))
+ .and_then(|raw| {
+ let decoded = hex::decode(raw).map_err(|e| {
+ RelayError::Protocol(format!("invalid signing_secret hex: {e}"))
+ })?;
+ if decoded.len() != 32 {
+ return Err(RelayError::Protocol(format!(
+ "invalid signing_secret length: expected 32 bytes, got {}",
+ decoded.len()
+ )));
+ }
+ Ok(decoded)
+ })
+ }
+
/// List active connections for an instance.
pub async fn list_connections(&self, instance_id: &str) -> Result, RelayError> {
let resp = self
.http
.get(format!("{}/connections", self.base_url))
- .header("X-API-Key", self.api_key.expose_secret())
+ .bearer_auth(self.api_key.expose_secret())
.query(&[("instance_id", instance_id)])
.send()
.await
@@ -317,91 +321,6 @@ impl RelayClient {
}
}
-/// Async stream of parsed channel events from SSE.
-pub struct ChannelEventStream {
- rx: mpsc::Receiver,
-}
-
-impl Stream for ChannelEventStream {
- type Item = ChannelEvent;
-
- fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> {
- self.rx.poll_recv(cx)
- }
-}
-
-/// Parse SSE format from a reqwest bytes stream.
-///
-/// SSE format:
-/// ```text
-/// event: message
-/// data: {"key": "value"}
-///
-/// ```
-/// Blank line terminates an event.
-async fn parse_sse_stream(
- byte_stream: impl futures::Stream- > + Send + 'static,
- tx: mpsc::Sender
,
-) {
- use futures::StreamExt;
-
- let mut buffer = Vec::::new();
- let mut event_type = String::new();
- let mut data_lines = Vec::new();
-
- let mut byte_stream = std::pin::pin!(byte_stream);
- while let Some(chunk_result) = byte_stream.next().await {
- let chunk = match chunk_result {
- Ok(c) => c,
- Err(e) => {
- tracing::debug!(error = %e, "SSE stream chunk error");
- break;
- }
- };
-
- buffer.extend_from_slice(&chunk);
-
- // Process complete lines (decode UTF-8 only on full lines to avoid
- // corruption when multi-byte characters span chunk boundaries)
- while let Some(newline_pos) = buffer.iter().position(|&b| b == b'\n') {
- let line = String::from_utf8_lossy(&buffer[..newline_pos])
- .trim_end_matches('\r')
- .to_string();
- buffer.drain(..=newline_pos);
-
- if line.is_empty() {
- // Blank line = end of event
- if !data_lines.is_empty() {
- let data = data_lines.join("\n");
- if let Ok(mut event) = serde_json::from_str::(&data) {
- if event.event_type.is_empty() && !event_type.is_empty() {
- event.event_type = event_type.clone();
- }
- if tx.send(event).await.is_err() {
- return; // receiver dropped
- }
- } else {
- tracing::debug!(
- event_type = %event_type,
- data_len = data.len(),
- "Failed to parse SSE event data as ChannelEvent"
- );
- }
- }
- event_type.clear();
- data_lines.clear();
- } else if let Some(value) = line.strip_prefix("event:") {
- event_type = value.trim().to_string();
- } else if let Some(value) = line.strip_prefix("data:") {
- data_lines.push(value.trim().to_string());
- }
- // Ignore other fields (id:, retry:, comments)
- }
- }
-
- tracing::debug!("SSE stream ended");
-}
-
/// Errors from relay client operations.
#[derive(Debug, thiserror::Error)]
pub enum RelayError {
@@ -413,9 +332,6 @@ pub enum RelayError {
#[error("Protocol error: {0}")]
Protocol(String),
-
- #[error("Stream token expired")]
- TokenExpired,
}
#[cfg(test)]
@@ -494,9 +410,6 @@ mod tests {
message: "unauthorized".into(),
};
assert_eq!(err.to_string(), "API error (HTTP 401): unauthorized");
-
- let err = RelayError::TokenExpired;
- assert_eq!(err.to_string(), "Stream token expired");
}
#[test]
@@ -518,32 +431,4 @@ mod tests {
assert!(make(event_types::DIRECT_MESSAGE).is_message());
assert!(make(event_types::MENTION).is_message());
}
-
- #[tokio::test]
- async fn parse_sse_handles_multibyte_utf8_across_chunks() {
- // The crab emoji (🦀) is 4 bytes: [0xF0, 0x9F, 0xA6, 0x80].
- // Split it across two chunks to verify no U+FFFD corruption.
- let event_json = r#"{"event_type":"message","content":"hello 🦀 world","provider_scope":"T1","channel_id":"C1","sender_id":"U1"}"#;
- let full = format!("event: message\ndata: {}\n\n", event_json);
- let bytes = full.as_bytes();
-
- // Find the crab emoji and split mid-character
- let crab_pos = bytes
- .windows(4)
- .position(|w| w == [0xF0, 0x9F, 0xA6, 0x80])
- .expect("crab emoji not found");
- let split_at = crab_pos + 2; // split in the middle of the 4-byte emoji
-
- let chunk1 = bytes::Bytes::copy_from_slice(&bytes[..split_at]);
- let chunk2 = bytes::Bytes::copy_from_slice(&bytes[split_at..]);
-
- let chunks: Vec> = vec![Ok(chunk1), Ok(chunk2)];
- let stream = futures::stream::iter(chunks);
-
- let (tx, mut rx) = mpsc::channel(8);
- parse_sse_stream(stream, tx).await;
-
- let event = rx.recv().await.expect("should receive event");
- assert_eq!(event.text(), "hello 🦀 world");
- }
}
diff --git a/src/channels/relay/mod.rs b/src/channels/relay/mod.rs
index 1582319f..05f5870c 100644
--- a/src/channels/relay/mod.rs
+++ b/src/channels/relay/mod.rs
@@ -1,12 +1,13 @@
//! Channel-relay integration for connecting to external messaging platforms
//! (Slack) via the channel-relay service.
//!
-//! The relay service handles OAuth, credential storage, webhook ingestion,
-//! and SSE event streaming. IronClaw consumes the SSE stream and sends
-//! messages via the relay's proxy API.
+//! The relay service handles OAuth, credential storage, and webhook ingestion.
+//! IronClaw receives events via webhook callbacks and sends messages via the
+//! relay's proxy API.
pub mod channel;
pub mod client;
+pub mod webhook;
pub use channel::{DEFAULT_RELAY_NAME, RelayChannel};
pub use client::RelayClient;
diff --git a/src/channels/relay/webhook.rs b/src/channels/relay/webhook.rs
new file mode 100644
index 00000000..c5a9f82a
--- /dev/null
+++ b/src/channels/relay/webhook.rs
@@ -0,0 +1,66 @@
+//! Shared relay webhook signature verification helpers.
+
+use hmac::{Hmac, Mac};
+use sha2::Sha256;
+
+type HmacSha256 = Hmac;
+
+/// Verify a relay callback HMAC signature.
+pub fn verify_relay_signature(
+ secret: &[u8],
+ timestamp: &str,
+ body: &[u8],
+ signature: &str,
+) -> bool {
+ verify_signature(secret, timestamp, body, signature)
+}
+
+fn verify_signature(secret: &[u8], timestamp: &str, body: &[u8], signature: &str) -> bool {
+ let mut mac = match HmacSha256::new_from_slice(secret) {
+ Ok(m) => m,
+ Err(_) => return false,
+ };
+ mac.update(timestamp.as_bytes());
+ mac.update(b".");
+ mac.update(body);
+ let expected = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
+ subtle::ConstantTimeEq::ct_eq(expected.as_bytes(), signature.as_bytes()).into()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn make_signature(secret: &[u8], timestamp: &str, body: &[u8]) -> String {
+ let mut mac = HmacSha256::new_from_slice(secret).unwrap();
+ mac.update(timestamp.as_bytes());
+ mac.update(b".");
+ mac.update(body);
+ format!("sha256={}", hex::encode(mac.finalize().into_bytes()))
+ }
+
+ #[test]
+ fn verify_valid_signature() {
+ let secret = b"test-secret";
+ let body = b"hello";
+ let ts = "1234567890";
+ let sig = make_signature(secret, ts, body);
+ assert!(verify_signature(secret, ts, body, &sig));
+ }
+
+ #[test]
+ fn verify_wrong_secret_fails() {
+ let body = b"hello";
+ let ts = "1234567890";
+ let sig = make_signature(b"correct", ts, body);
+ assert!(!verify_signature(b"wrong", ts, body, &sig));
+ }
+
+ #[test]
+ fn verify_tampered_body_fails() {
+ let secret = b"secret";
+ let ts = "1234567890";
+ let sig = make_signature(secret, ts, b"original");
+ assert!(!verify_signature(secret, ts, b"tampered", &sig));
+ }
+}
diff --git a/src/channels/repl.rs b/src/channels/repl.rs
index 40d66919..36ca7c28 100644
--- a/src/channels/repl.rs
+++ b/src/channels/repl.rs
@@ -539,6 +539,7 @@ impl Channel for ReplChannel {
tool_name,
description,
parameters,
+ allow_always,
} => {
let term_width = crossterm::terminal::size()
.map(|(w, _)| w as usize)
@@ -582,9 +583,13 @@ impl Channel for ReplChannel {
}
eprintln!(" \u{2502}");
- eprintln!(
- " \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[34malways\x1b[0m (a) / \x1b[31mno\x1b[0m (n)"
- );
+ if allow_always {
+ eprintln!(
+ " \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[34malways\x1b[0m (a) / \x1b[31mno\x1b[0m (n)"
+ );
+ } else {
+ eprintln!(" \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[31mno\x1b[0m (n)");
+ }
eprintln!(" {bot_border}");
eprintln!();
}
diff --git a/src/channels/signal.rs b/src/channels/signal.rs
index b8934c5c..84afccd5 100644
--- a/src/channels/signal.rs
+++ b/src/channels/signal.rs
@@ -915,20 +915,28 @@ impl Channel for SignalChannel {
tool_name,
description: _,
parameters,
+ allow_always,
} = &status
&& let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str())
{
let params_json = serde_json::to_string_pretty(parameters).unwrap_or_default();
+ let always_line = if *allow_always {
+ format!(
+ "\n• `always` or `a` - Approve and auto-approve future {} requests",
+ tool_name
+ )
+ } else {
+ String::new()
+ };
let message = format!(
"⚠️ *Approval Required*\n\n\
*Request ID:* `{}`\n\
*Tool:* {}\n\
*Parameters:*\n```\n{}\n```\n\n\
Reply with:\n\
- • `yes` or `y` - Approve this request\n\
- • `always` or `a` - Approve and auto-approve future {} requests\n\
+ • `yes` or `y` - Approve this request{}\n\
• `no` or `n` - Deny",
- request_id, tool_name, params_json, tool_name
+ request_id, tool_name, params_json, always_line
);
self.send_status_message(target_str, &message).await;
}
diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs
index 6ca79831..be7768d0 100644
--- a/src/channels/wasm/wrapper.rs
+++ b/src/channels/wasm/wrapper.rs
@@ -492,8 +492,16 @@ impl near::agent::channel_host::Host for ChannelStoreData {
tracing::debug!(body = %truncated, "Response body");
}
- // Leak detection on response body (best-effort)
- if let Ok(body_str) = std::str::from_utf8(&body) {
+ // Leak detection on response body (best-effort).
+ //
+ // Telegram `getUpdates` is special: it is inbound polling data, so
+ // user-pasted secrets can legitimately appear in the response body.
+ // Those messages are still checked later by the inbound message
+ // safety layer before they reach the LLM, so we allow the polling
+ // response to continue here to avoid poisoning the offset state.
+ if let Ok(body_str) = std::str::from_utf8(&body)
+ && !should_skip_response_leak_scan(&url)
+ {
leak_detector
.scan_and_clean(body_str)
.map_err(|e| format!("Potential secret leak in response: {}", e))?;
@@ -2035,6 +2043,7 @@ impl WasmChannel {
tool_name,
description,
parameters,
+ allow_always,
..
} => {
// WASM channels (Telegram, Slack, etc.) cannot render
@@ -2073,6 +2082,11 @@ impl WasmChannel {
})
.unwrap_or_default();
+ let reply_hint = if *allow_always {
+ "Reply \"yes\" to approve, \"no\" to deny, or \"always\" to auto-approve."
+ } else {
+ "Reply \"yes\" to approve or \"no\" to deny."
+ };
let prompt = format!(
"Approval needed: {tool_name}\n\
{description}\n\
@@ -2080,7 +2094,7 @@ impl WasmChannel {
Parameters:\n\
{params_preview}\n\
\n\
- Reply \"yes\" to approve, \"no\" to deny, or \"always\" to auto-approve."
+ {reply_hint}"
);
let metadata_json = serde_json::to_string(metadata).unwrap_or_default();
@@ -2973,15 +2987,23 @@ fn status_to_wit(
request_id,
tool_name,
description,
+ allow_always,
..
- } => wit_channel::StatusUpdate {
- status: wit_channel::StatusType::ApprovalNeeded,
- message: format!(
- "Approval needed for tool '{}'. {}\nRequest ID: {}\nReply with: yes (or /approve), no (or /deny), or always (or /always).",
- tool_name, description, request_id
- ),
- metadata_json,
- },
+ } => {
+ let reply_hint = if *allow_always {
+ "yes (or /approve), no (or /deny), or always (or /always)"
+ } else {
+ "yes (or /approve) or no (or /deny)"
+ };
+ wit_channel::StatusUpdate {
+ status: wit_channel::StatusType::ApprovalNeeded,
+ message: format!(
+ "Approval needed for tool '{}'. {}\nRequest ID: {}\nReply with: {}.",
+ tool_name, description, request_id, reply_hint
+ ),
+ metadata_json,
+ }
+ }
StatusUpdate::JobStarted {
job_id,
title,
@@ -3122,6 +3144,19 @@ fn extract_host_from_url(url: &str) -> Option {
})
}
+fn should_skip_response_leak_scan(url: &str) -> bool {
+ url::Url::parse(url).is_ok_and(|parsed| {
+ matches!(parsed.scheme(), "http" | "https")
+ && parsed
+ .host_str()
+ .is_some_and(|host| host.eq_ignore_ascii_case("api.telegram.org"))
+ && parsed
+ .path_segments()
+ .and_then(|segments| segments.rev().find(|segment| !segment.is_empty()))
+ .is_some_and(|segment| segment == "getUpdates")
+ })
+}
+
/// Pre-resolve host credentials for all HTTP capability mappings.
///
/// Called once per callback (in async context, before spawn_blocking) so the
@@ -3279,6 +3314,7 @@ mod tests {
use std::sync::Arc;
use crate::channels::Channel;
+ use crate::channels::OutgoingResponse;
use crate::channels::wasm::capabilities::ChannelCapabilities;
use crate::channels::wasm::runtime::{
PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig,
@@ -3366,6 +3402,16 @@ mod tests {
assert!(channel.health_check().await.is_err());
}
+ #[tokio::test]
+ async fn test_broadcast_delegates_to_call_on_broadcast() {
+ let channel = create_test_channel();
+ // With `component: None`, call_on_broadcast short-circuits to Ok(()).
+ let result = channel
+ .broadcast("146032821", OutgoingResponse::text("hello"))
+ .await;
+ assert!(result.is_ok());
+ }
+
#[tokio::test]
async fn test_execute_poll_no_wasm_returns_empty() {
// When there's no WASM module (None component), execute_poll
@@ -3649,6 +3695,7 @@ mod tests {
tool_name: "http_request".into(),
description: "Fetch weather".into(),
parameters: serde_json::json!({"url": "https://wttr.in"}),
+ allow_always: true,
},
&metadata,
)
@@ -4110,6 +4157,7 @@ mod tests {
tool_name: "http_request".to_string(),
description: "Fetch weather data".to_string(),
parameters: serde_json::json!({"url": "https://api.weather.test"}),
+ allow_always: true,
},
&metadata,
)
@@ -4135,6 +4183,7 @@ mod tests {
tool_name: "http_request".to_string(),
description: "Fetch weather data".to_string(),
parameters: serde_json::json!({"url": "https://api.weather.test"}),
+ allow_always: true,
},
&metadata,
)
@@ -4386,6 +4435,22 @@ mod tests {
assert_eq!(store.redact_credentials(input), input);
}
+ #[test]
+ fn test_should_skip_response_leak_scan_only_for_telegram_getupdates() {
+ use super::should_skip_response_leak_scan;
+
+ assert!(should_skip_response_leak_scan(
+ "https://api.telegram.org/bot123/getUpdates?offset=1"
+ ));
+ assert!(!should_skip_response_leak_scan(
+ "https://api.telegram.org/bot123/sendMessage"
+ ));
+ assert!(!should_skip_response_leak_scan(
+ "https://api.example.com/getUpdates"
+ ));
+ assert!(!should_skip_response_leak_scan("not a url"));
+ }
+
/// Verify that WASM HTTP host functions work using a dedicated
/// current-thread runtime inside spawn_blocking.
#[tokio::test]
diff --git a/src/channels/web/handlers/memory.rs b/src/channels/web/handlers/memory.rs
index 8e50f25e..fc0e1fe4 100644
--- a/src/channels/web/handlers/memory.rs
+++ b/src/channels/web/handlers/memory.rs
@@ -123,25 +123,8 @@ pub async fn memory_read_handler(
}))
}
-pub async fn memory_write_handler(
- State(state): State>,
- Json(req): Json,
-) -> Result, (StatusCode, String)> {
- let workspace = state.workspace.as_ref().ok_or((
- StatusCode::SERVICE_UNAVAILABLE,
- "Workspace not available".to_string(),
- ))?;
-
- workspace
- .write(&req.path, &req.content)
- .await
- .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
-
- Ok(Json(MemoryWriteResponse {
- path: req.path,
- status: "written",
- }))
-}
+// memory_write_handler lives in server.rs (layer-aware version with append,
+// privacy redirect, and proper error status codes).
pub async fn memory_search_handler(
State(state): State>,
diff --git a/src/channels/web/handlers/mod.rs b/src/channels/web/handlers/mod.rs
index 0573a067..2f942058 100644
--- a/src/channels/web/handlers/mod.rs
+++ b/src/channels/web/handlers/mod.rs
@@ -26,3 +26,4 @@ pub mod routines;
pub mod settings;
#[allow(dead_code)]
pub mod static_files;
+pub mod webhooks;
diff --git a/src/channels/web/handlers/routines.rs b/src/channels/web/handlers/routines.rs
index 41bfee5a..368a28ae 100644
--- a/src/channels/web/handlers/routines.rs
+++ b/src/channels/web/handlers/routines.rs
@@ -303,7 +303,9 @@ fn routine_error_status(err: &RoutineError) -> StatusCode {
match err {
RoutineError::NotFound { .. } => StatusCode::NOT_FOUND,
RoutineError::NotAuthorized { .. } => StatusCode::FORBIDDEN,
- RoutineError::Disabled { .. } | RoutineError::MaxConcurrent { .. } => StatusCode::CONFLICT,
+ RoutineError::Disabled { .. }
+ | RoutineError::Cooldown { .. }
+ | RoutineError::MaxConcurrent { .. } => StatusCode::CONFLICT,
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
}
diff --git a/src/channels/web/handlers/webhooks.rs b/src/channels/web/handlers/webhooks.rs
new file mode 100644
index 00000000..7b041a06
--- /dev/null
+++ b/src/channels/web/handlers/webhooks.rs
@@ -0,0 +1,197 @@
+//! Public webhook trigger endpoint for routine webhook triggers.
+//!
+//! `POST /api/webhooks/{path}` — matches the path against routines with
+//! `Trigger::Webhook { path, secret }`, validates the secret via constant-time
+//! comparison, and fires the matching routine through the `RoutineEngine`.
+
+use std::sync::Arc;
+
+use axum::{
+ Json,
+ extract::{Path, State},
+ http::{HeaderMap, StatusCode},
+};
+use subtle::ConstantTimeEq;
+
+use crate::agent::routine::Trigger;
+use crate::channels::web::server::GatewayState;
+
+/// Validate the webhook secret for a routine.
+///
+/// Returns `Ok(())` if the routine has a configured secret and the provided
+/// secret matches via constant-time comparison. Returns an appropriate HTTP
+/// error if the secret is missing (403) or invalid (401).
+fn validate_webhook_secret(
+ trigger: &Trigger,
+ provided_secret: &str,
+) -> Result<(), (StatusCode, String)> {
+ // Require webhook secret — routines without a secret cannot be triggered via webhook
+ let expected_secret = match trigger {
+ Trigger::Webhook {
+ secret: Some(s), ..
+ } => s,
+ _ => {
+ return Err((
+ StatusCode::FORBIDDEN,
+ "Webhook secret not configured for this routine. \
+ Set a secret with: ironclaw routine update --webhook-secret "
+ .to_string(),
+ ));
+ }
+ };
+
+ if !bool::from(provided_secret.as_bytes().ct_eq(expected_secret.as_bytes())) {
+ return Err((
+ StatusCode::UNAUTHORIZED,
+ "Invalid webhook secret".to_string(),
+ ));
+ }
+
+ Ok(())
+}
+
+/// Handle incoming webhook POST to `/api/webhooks/{path}`.
+///
+/// This endpoint is **public** (no gateway auth token required) but protected
+/// by the per-routine webhook secret sent via the `X-Webhook-Secret` header.
+pub async fn webhook_trigger_handler(
+ State(state): State>,
+ Path(path): Path,
+ headers: HeaderMap,
+) -> Result, (StatusCode, String)> {
+ // Rate limit check
+ if !state.webhook_rate_limiter.check() {
+ return Err((
+ StatusCode::TOO_MANY_REQUESTS,
+ "Rate limit exceeded. Try again shortly.".to_string(),
+ ));
+ }
+
+ let store = state.store.as_ref().ok_or((
+ StatusCode::SERVICE_UNAVAILABLE,
+ "Database not available".to_string(),
+ ))?;
+
+ // Targeted query instead of loading all routines
+ let routine = store
+ .get_webhook_routine_by_path(&path)
+ .await
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
+ .ok_or((
+ StatusCode::NOT_FOUND,
+ "No routine matches this webhook path".to_string(),
+ ))?;
+
+ let provided_secret = headers
+ .get("x-webhook-secret")
+ .and_then(|v| v.to_str().ok())
+ .unwrap_or("");
+
+ validate_webhook_secret(&routine.trigger, provided_secret)?;
+
+ // Fire through the RoutineEngine so guardrails, run tracking,
+ // notifications, and FullJob dispatch all work correctly.
+ let engine = {
+ let guard = state.routine_engine.read().await;
+ guard.as_ref().cloned().ok_or((
+ StatusCode::SERVICE_UNAVAILABLE,
+ "Routine engine not available".to_string(),
+ ))?
+ };
+
+ let run_id = engine.fire_webhook(routine.id, &path).await.map_err(|e| {
+ let status = match &e {
+ crate::error::RoutineError::NotFound { .. } => StatusCode::NOT_FOUND,
+ crate::error::RoutineError::Disabled { .. }
+ | crate::error::RoutineError::Cooldown { .. }
+ | crate::error::RoutineError::MaxConcurrent { .. } => StatusCode::CONFLICT,
+ _ => StatusCode::INTERNAL_SERVER_ERROR,
+ };
+ (status, e.to_string())
+ })?;
+
+ Ok(Json(serde_json::json!({
+ "status": "triggered",
+ "routine_id": routine.id,
+ "routine_name": routine.name,
+ "run_id": run_id,
+ })))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Routines with `secret: None` must be rejected with 403.
+ #[test]
+ fn test_validate_rejects_missing_secret() {
+ let trigger = Trigger::Webhook {
+ path: Some("my-hook".to_string()),
+ secret: None,
+ };
+ let result = validate_webhook_secret(&trigger, "any-secret");
+ let (status, msg) = result.unwrap_err();
+ assert_eq!(status, StatusCode::FORBIDDEN);
+ assert!(
+ msg.contains("not configured"),
+ "Error should tell user to configure a secret, got: {msg}"
+ );
+ }
+
+ /// Non-webhook triggers must be rejected with 403.
+ #[test]
+ fn test_validate_rejects_non_webhook_trigger() {
+ let trigger = Trigger::Manual;
+ let result = validate_webhook_secret(&trigger, "any-secret");
+ let (status, _) = result.unwrap_err();
+ assert_eq!(status, StatusCode::FORBIDDEN);
+ }
+
+ /// Correct secret passes validation.
+ #[test]
+ fn test_validate_accepts_correct_secret() {
+ let trigger = Trigger::Webhook {
+ path: Some("my-hook".to_string()),
+ secret: Some("s3cret-token".to_string()),
+ };
+ assert!(validate_webhook_secret(&trigger, "s3cret-token").is_ok());
+ }
+
+ /// Wrong secret returns 401.
+ #[test]
+ fn test_validate_rejects_wrong_secret() {
+ let trigger = Trigger::Webhook {
+ path: Some("my-hook".to_string()),
+ secret: Some("correct-secret".to_string()),
+ };
+ let result = validate_webhook_secret(&trigger, "wrong-secret");
+ let (status, msg) = result.unwrap_err();
+ assert_eq!(status, StatusCode::UNAUTHORIZED);
+ assert!(msg.contains("Invalid"), "Expected 'Invalid' in: {msg}");
+ }
+
+ /// Empty provided secret returns 401 (not a false positive).
+ #[test]
+ fn test_validate_rejects_empty_provided_secret() {
+ let trigger = Trigger::Webhook {
+ path: Some("my-hook".to_string()),
+ secret: Some("real-secret".to_string()),
+ };
+ let result = validate_webhook_secret(&trigger, "");
+ let (status, _) = result.unwrap_err();
+ assert_eq!(status, StatusCode::UNAUTHORIZED);
+ }
+
+ /// Constant-time comparison: secrets of different lengths are still rejected
+ /// (not short-circuited in a way that leaks length info).
+ #[test]
+ fn test_validate_rejects_different_length_secret() {
+ let trigger = Trigger::Webhook {
+ path: None,
+ secret: Some("short".to_string()),
+ };
+ let result = validate_webhook_secret(&trigger, "a-much-longer-secret-value");
+ let (status, _) = result.unwrap_err();
+ assert_eq!(status, StatusCode::UNAUTHORIZED);
+ }
+}
diff --git a/src/channels/web/mod.rs b/src/channels/web/mod.rs
index 0d970569..1fdb4455 100644
--- a/src/channels/web/mod.rs
+++ b/src/channels/web/mod.rs
@@ -98,10 +98,12 @@ impl GatewayChannel {
skill_catalog: None,
chat_rate_limiter: server::RateLimiter::new(30, 60),
oauth_rate_limiter: server::RateLimiter::new(10, 60),
+ webhook_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(),
+ active_config: server::ActiveConfigSnapshot::default(),
});
Self {
@@ -135,10 +137,12 @@ impl GatewayChannel {
skill_catalog: self.state.skill_catalog.clone(),
chat_rate_limiter: server::RateLimiter::new(30, 60),
oauth_rate_limiter: server::RateLimiter::new(10, 60),
+ webhook_rate_limiter: server::RateLimiter::new(10, 60),
registry_entries: self.state.registry_entries.clone(),
cost_guard: self.state.cost_guard.clone(),
routine_engine: Arc::clone(&self.state.routine_engine),
startup_time: self.state.startup_time,
+ active_config: self.state.active_config.clone(),
};
mutate(&mut new_state);
self.state = Arc::new(new_state);
@@ -250,6 +254,12 @@ impl GatewayChannel {
self
}
+ /// Inject the active (resolved) configuration snapshot for the status endpoint.
+ pub fn with_active_config(mut self, config: server::ActiveConfigSnapshot) -> Self {
+ self.rebuild_state(|s| s.active_config = config);
+ self
+ }
+
/// Get the auth token (for printing to console on startup).
pub fn auth_token(&self) -> &str {
&self.auth_token
@@ -366,6 +376,7 @@ impl Channel for GatewayChannel {
tool_name,
description,
parameters,
+ allow_always,
} => SseEvent::ApprovalNeeded {
request_id,
tool_name,
@@ -373,6 +384,7 @@ impl Channel for GatewayChannel {
parameters: serde_json::to_string_pretty(¶meters)
.unwrap_or_else(|_| parameters.to_string()),
thread_id,
+ allow_always,
},
StatusUpdate::AuthRequired {
extension_name,
diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs
index 27ef7cdc..7b24805c 100644
--- a/src/channels/web/server.rs
+++ b/src/channels/web/server.rs
@@ -19,6 +19,7 @@ use axum::{
routing::{get, post},
};
use serde::Deserialize;
+use sha2::{Digest, Sha256};
use tokio::sync::{mpsc, oneshot};
use tokio_stream::StreamExt;
use tower_http::cors::{AllowHeaders, CorsLayer};
@@ -35,7 +36,10 @@ use crate::channels::web::handlers::jobs::{
jobs_events_handler, jobs_list_handler, jobs_prompt_handler, jobs_restart_handler,
jobs_summary_handler,
};
-use crate::channels::web::handlers::routines::{routines_delete_handler, routines_toggle_handler};
+use crate::channels::web::handlers::routines::{
+ routines_delete_handler, routines_detail_handler, routines_list_handler,
+ routines_summary_handler, routines_toggle_handler, routines_trigger_handler,
+};
use crate::channels::web::handlers::skills::{
skills_install_handler, skills_list_handler, skills_remove_handler, skills_search_handler,
};
@@ -63,6 +67,16 @@ pub type PromptQueue = Arc<
pub type RoutineEngineSlot =
Arc>>>;
+fn redact_oauth_state_for_logs(state: &str) -> String {
+ let digest = Sha256::digest(state.as_bytes());
+ let mut short_hash = String::with_capacity(12);
+ for byte in &digest[..6] {
+ use std::fmt::Write as _;
+ let _ = write!(&mut short_hash, "{byte:02x}");
+ }
+ format!("sha256:{short_hash}:len={}", state.len())
+}
+
/// Simple sliding-window rate limiter.
///
/// Tracks the number of requests in the current window. Resets when the window expires.
@@ -126,6 +140,14 @@ impl RateLimiter {
}
}
+/// Snapshot of the active (resolved) configuration exposed to the frontend.
+#[derive(Debug, Clone, Default, serde::Serialize)]
+pub struct ActiveConfigSnapshot {
+ pub llm_backend: String,
+ pub llm_model: String,
+ pub enabled_channels: Vec,
+}
+
/// Shared state for all gateway handlers.
pub struct GatewayState {
/// Channel to send messages to the agent loop.
@@ -168,6 +190,8 @@ pub struct GatewayState {
pub chat_rate_limiter: RateLimiter,
/// Rate limiter for OAuth callback endpoints (10 requests per 60 seconds).
pub oauth_rate_limiter: RateLimiter,
+ /// Rate limiter for webhook trigger endpoints (10 requests per 60 seconds).
+ pub webhook_rate_limiter: RateLimiter,
/// Registry catalog entries for the available extensions API.
/// Populated at startup from `registry/` manifests, independent of extension manager.
pub registry_entries: Vec,
@@ -177,6 +201,8 @@ pub struct GatewayState {
pub routine_engine: RoutineEngineSlot,
/// Server startup time for uptime calculation.
pub startup_time: std::time::Instant,
+ /// Snapshot of active (resolved) configuration for the frontend.
+ pub active_config: ActiveConfigSnapshot,
}
/// Start the gateway HTTP server.
@@ -208,6 +234,11 @@ pub async fn start_server(
.route(
"/oauth/slack/callback",
get(slack_relay_oauth_callback_handler),
+ )
+ .route("/relay/events", post(relay_events_handler))
+ .route(
+ "/api/webhooks/{path}",
+ post(crate::channels::web::handlers::webhooks::webhook_trigger_handler),
);
// Protected routes (require auth)
@@ -319,6 +350,7 @@ pub async fn start_server(
.route("/", get(index_handler))
.route("/style.css", get(css_handler))
.route("/app.js", get(js_handler))
+ .route("/theme-init.js", get(theme_init_handler))
.route("/favicon.ico", get(favicon_handler))
.route("/i18n/index.js", get(i18n_index_handler))
.route("/i18n/en.js", get(i18n_en_handler))
@@ -440,6 +472,16 @@ async fn js_handler() -> impl IntoResponse {
)
}
+async fn theme_init_handler() -> impl IntoResponse {
+ (
+ [
+ (header::CONTENT_TYPE, "application/javascript"),
+ (header::CACHE_CONTROL, "no-cache"),
+ ],
+ include_str!("static/theme-init.js"),
+ )
+}
+
async fn favicon_handler() -> impl IntoResponse {
(
[
@@ -555,22 +597,35 @@ async fn oauth_callback_handler(
}
};
- // Strip instance prefix from state for registry lookup.
- // Platform nginx sends `state=instance:nonce` but flows are keyed by nonce only.
- let lookup_key = oauth_defaults::strip_instance_prefix(&state_param);
+ let decoded_state = match oauth_defaults::decode_hosted_oauth_state(&state_param) {
+ Ok(decoded) => decoded,
+ Err(error) => {
+ let redacted_state = redact_oauth_state_for_logs(&state_param);
+ tracing::warn!(
+ state = %redacted_state,
+ error = %error,
+ "OAuth callback received with malformed state"
+ );
+ clear_auth_mode(&state).await;
+ return oauth_error_page("IronClaw");
+ }
+ };
+ let lookup_key = decoded_state.flow_id.clone();
let flow = ext_mgr
.pending_oauth_flows()
.write()
.await
- .remove(lookup_key);
+ .remove(&lookup_key);
let flow = match flow {
Some(f) => f,
None => {
+ let redacted_state = redact_oauth_state_for_logs(&state_param);
+ let redacted_lookup_key = redact_oauth_state_for_logs(&lookup_key);
tracing::warn!(
- state = %state_param,
- lookup_key = %lookup_key,
+ state = %redacted_state,
+ lookup_key = %redacted_lookup_key,
"OAuth callback received with unknown or expired state"
);
clear_auth_mode(&state).await;
@@ -597,33 +652,29 @@ async fn oauth_callback_handler(
}
// Exchange the authorization code for tokens.
- // Use the platform exchange proxy when configured (keeps client_secret off container),
- // otherwise call the provider's token URL directly.
- let exchange_proxy_url = std::env::var("IRONCLAW_OAUTH_EXCHANGE_URL").ok();
+ // Use the platform exchange proxy when configured, otherwise call the
+ // provider's token URL directly.
+ let exchange_proxy_url = oauth_defaults::exchange_proxy_url();
let result: Result<(), String> = async {
- let token_response = if let (Some(proxy_url), None) = (&exchange_proxy_url, &flow.resource)
- {
- // Use the platform exchange proxy when configured and no resource
- // parameter is needed. The proxy holds client_secret server-side so
- // the container never sees it. MCP flows (resource.is_some()) bypass
- // the proxy because it doesn't forward the RFC 8707 resource param.
+ let token_response = if let Some(proxy_url) = &exchange_proxy_url {
let gateway_token = flow.gateway_token.as_deref().unwrap_or_default();
- oauth_defaults::exchange_via_proxy(
+ oauth_defaults::exchange_via_proxy(oauth_defaults::ProxyTokenExchangeRequest {
proxy_url,
gateway_token,
- &code,
- &flow.redirect_uri,
- flow.code_verifier.as_deref(),
- &flow.access_token_field,
- )
+ token_url: &flow.token_url,
+ client_id: &flow.client_id,
+ client_secret: flow.client_secret.as_deref(),
+ code: &code,
+ redirect_uri: &flow.redirect_uri,
+ code_verifier: flow.code_verifier.as_deref(),
+ access_token_field: &flow.access_token_field,
+ extra_token_params: &flow.token_exchange_extra_params,
+ })
.await
.map_err(|e| e.to_string())?
} else {
- // Direct token exchange: uses exchange_oauth_code_with_resource so MCP
- // flows can include the RFC 8707 `resource` parameter to scope the
- // issued token to the specific MCP server.
- oauth_defaults::exchange_oauth_code_with_resource(
+ oauth_defaults::exchange_oauth_code_with_params(
&flow.token_url,
&flow.client_id,
flow.client_secret.as_deref(),
@@ -631,7 +682,7 @@ async fn oauth_callback_handler(
&flow.redirect_uri,
flow.code_verifier.as_deref(),
&flow.access_token_field,
- flow.resource.as_deref(),
+ &flow.token_exchange_extra_params,
)
.await
.map_err(|e| e.to_string())?
@@ -658,10 +709,8 @@ async fn oauth_callback_handler(
.await
.map_err(|e| e.to_string())?;
- // For MCP OAuth flows (identified by resource field), persist the
- // client_id so token refresh works without re-authentication.
- // The CLI flow stores this in authorize_mcp_server(); the gateway
- // callback must do the same.
+ // Persist the client_id for flows that need it after the session ends
+ // (for example DCR-based MCP refresh).
if let Some(ref client_id_secret) = flow.client_id_secret_name {
let params = crate::secrets::CreateSecretParams::new(client_id_secret, &flow.client_id)
.with_provider(flow.provider.as_ref().cloned().unwrap_or_default());
@@ -742,11 +791,103 @@ async fn oauth_callback_handler(
axum::response::Html(html).into_response()
}
+/// Webhook endpoint for receiving relay events from channel-relay.
+///
+/// PUBLIC route — authenticated via HMAC signature (X-Relay-Signature header).
+async fn relay_events_handler(
+ State(state): State>,
+ headers: axum::http::HeaderMap,
+ body: axum::body::Bytes,
+) -> impl IntoResponse {
+ let ext_mgr = match state.extension_manager.as_ref() {
+ Some(mgr) => mgr,
+ None => {
+ return (StatusCode::SERVICE_UNAVAILABLE, "not ready").into_response();
+ }
+ };
+
+ let signing_secret = match ext_mgr.relay_signing_secret() {
+ Some(s) => s,
+ None => {
+ return (StatusCode::SERVICE_UNAVAILABLE, "relay not configured").into_response();
+ }
+ };
+
+ // Verify signature
+ let signature = match headers
+ .get("x-relay-signature")
+ .and_then(|v| v.to_str().ok())
+ {
+ Some(s) => s.to_string(),
+ None => {
+ return (StatusCode::UNAUTHORIZED, "missing signature").into_response();
+ }
+ };
+
+ let timestamp = match headers
+ .get("x-relay-timestamp")
+ .and_then(|v| v.to_str().ok())
+ {
+ Some(t) => t.to_string(),
+ None => {
+ return (StatusCode::UNAUTHORIZED, "missing timestamp").into_response();
+ }
+ };
+
+ // Check timestamp freshness (5 min window)
+ let ts: i64 = match timestamp.parse() {
+ Ok(t) => t,
+ Err(_) => {
+ return (StatusCode::BAD_REQUEST, "malformed timestamp").into_response();
+ }
+ };
+ let now = chrono::Utc::now().timestamp();
+ if (now - ts).abs() > 300 {
+ return (StatusCode::UNAUTHORIZED, "stale timestamp").into_response();
+ }
+
+ // Verify HMAC: sha256(secret, timestamp + "." + body)
+ if !crate::channels::relay::webhook::verify_relay_signature(
+ &signing_secret,
+ ×tamp,
+ &body,
+ &signature,
+ ) {
+ return (StatusCode::UNAUTHORIZED, "invalid signature").into_response();
+ }
+
+ // Parse event
+ let event: crate::channels::relay::client::ChannelEvent = match serde_json::from_slice(&body) {
+ Ok(e) => e,
+ Err(e) => {
+ tracing::warn!(error = %e, "relay callback invalid JSON");
+ return (StatusCode::BAD_REQUEST, "invalid JSON").into_response();
+ }
+ };
+
+ // Push to relay channel
+ let event_tx_guard = ext_mgr.relay_event_tx();
+ let event_tx = event_tx_guard.lock().await;
+ match event_tx.as_ref() {
+ Some(tx) => {
+ if let Err(e) = tx.try_send(event) {
+ tracing::warn!(error = %e, "relay event channel full or closed");
+ return (StatusCode::SERVICE_UNAVAILABLE, "event queue full").into_response();
+ }
+ }
+ None => {
+ return (StatusCode::SERVICE_UNAVAILABLE, "relay channel not active").into_response();
+ }
+ }
+
+ Json(serde_json::json!({"ok": true})).into_response()
+}
+
/// OAuth callback for Slack via channel-relay.
///
/// This is a PUBLIC route (no Bearer token required) because channel-relay
/// redirects the user's browser here after Slack OAuth completes.
-/// Query params: `stream_token`, `provider`, `team_id`.
+/// Query params: `provider`, `team_id`.
async fn slack_relay_oauth_callback_handler(
State(state): State>,
Query(params): Query>,
@@ -763,27 +904,6 @@ async fn slack_relay_oauth_callback_handler(
.into_response();
}
- // Validate stream_token: required, non-empty, max 2048 bytes
- let stream_token = match params.get("stream_token") {
- Some(t) if !t.is_empty() && t.len() <= 2048 => t.clone(),
- Some(t) if t.len() > 2048 => {
- return axum::response::Html(
- "\
- Error Invalid callback parameters.
"
- .to_string(),
- )
- .into_response();
- }
- _ => {
- return axum::response::Html(
- "\
- Error Invalid callback parameters.
"
- .to_string(),
- )
- .into_response();
- }
- };
-
// Validate team_id format: empty or T followed by alphanumeric (max 20 chars)
let team_id = params.get("team_id").cloned().unwrap_or_default();
if !team_id.is_empty() {
@@ -869,30 +989,16 @@ async fn slack_relay_oauth_callback_handler(
let _ = ext_mgr.secrets().delete(&state.user_id, &state_key).await;
let result: Result<(), String> = async {
- // Store the stream token as a secret
- let token_key = format!("relay:{}:stream_token", DEFAULT_RELAY_NAME);
- let _ = ext_mgr.secrets().delete(&state.user_id, &token_key).await;
- ext_mgr
- .secrets()
- .create(
- &state.user_id,
- crate::secrets::CreateSecretParams {
- name: token_key,
- value: secrecy::SecretString::from(stream_token),
- provider: Some(provider.clone()),
- expires_at: None,
- },
- )
- .await
- .map_err(|e| format!("Failed to store stream token: {}", e))?;
+ let store = state.store.as_ref().ok_or_else(|| {
+ "Relay activation requires persistent settings storage; no-db mode is unsupported."
+ .to_string()
+ })?;
// Store team_id in settings
- if let Some(ref store) = state.store {
- let team_id_key = format!("relay:{}:team_id", DEFAULT_RELAY_NAME);
- let _ = store
- .set_setting(&state.user_id, &team_id_key, &serde_json::json!(team_id))
- .await;
- }
+ let team_id_key = format!("relay:{}:team_id", DEFAULT_RELAY_NAME);
+ let _ = store
+ .set_setting(&state.user_id, &team_id_key, &serde_json::json!(team_id))
+ .await;
// Activate the relay channel
ext_mgr
@@ -1716,14 +1822,53 @@ async fn memory_write_handler(
"Workspace not available".to_string(),
))?;
- workspace
- .write(&req.path, &req.content)
- .await
- .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+ // Route through layer-aware methods when a layer is specified
+ if let Some(ref layer_name) = req.layer {
+ let result = if req.append {
+ workspace
+ .append_to_layer(layer_name, &req.path, &req.content, req.force)
+ .await
+ } else {
+ workspace
+ .write_to_layer(layer_name, &req.path, &req.content, req.force)
+ .await
+ }
+ .map_err(|e| {
+ use crate::error::WorkspaceError;
+ let status = match &e {
+ WorkspaceError::LayerNotFound { .. } => StatusCode::BAD_REQUEST,
+ WorkspaceError::LayerReadOnly { .. } => StatusCode::FORBIDDEN,
+ WorkspaceError::PrivacyRedirectFailed => StatusCode::UNPROCESSABLE_ENTITY,
+ _ => StatusCode::INTERNAL_SERVER_ERROR,
+ };
+ (status, e.to_string())
+ })?;
+ return Ok(Json(MemoryWriteResponse {
+ path: req.path,
+ status: "written",
+ redirected: Some(result.redirected),
+ actual_layer: Some(result.actual_layer),
+ }));
+ }
+
+ // Non-layer path: honor the append field
+ if req.append {
+ workspace
+ .append(&req.path, &req.content)
+ .await
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+ } else {
+ workspace
+ .write(&req.path, &req.content)
+ .await
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+ }
Ok(Json(MemoryWriteResponse {
path: req.path,
status: "written",
+ redirected: None,
+ actual_layer: None,
}))
}
@@ -2198,7 +2343,7 @@ async fn extensions_setup_handler(
"Extension manager not available (secrets store required)".to_string(),
))?;
- let secrets = ext_mgr
+ let setup = ext_mgr
.get_setup_schema(&name)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
@@ -2214,7 +2359,8 @@ async fn extensions_setup_handler(
Ok(Json(ExtensionSetupResponse {
name,
kind,
- secrets,
+ secrets: setup.secrets,
+ fields: setup.fields,
}))
}
@@ -2232,7 +2378,7 @@ async fn extensions_setup_submit_handler(
// through to the LLM instead of being intercepted as a token.
clear_auth_mode(&state).await;
- match ext_mgr.configure(&name, &req.secrets).await {
+ match ext_mgr.configure(&name, &req.secrets, &req.fields).await {
Ok(result) => {
let mut resp = if result.verification.is_some() || result.activated {
ActionResponse::ok(result.message)
@@ -2240,6 +2386,9 @@ async fn extensions_setup_submit_handler(
ActionResponse::fail(result.message)
};
resp.activated = Some(result.activated);
+ if result.restart_required || !result.activated {
+ resp.needs_restart = Some(true);
+ }
resp.auth_url = result.auth_url.clone();
resp.verification = result.verification.clone();
resp.instructions = result.verification.as_ref().map(|v| v.instructions.clone());
@@ -2305,164 +2454,6 @@ async fn pairing_approve_handler(
}
}
-// --- Routines handlers ---
-
-async fn routines_list_handler(
- State(state): State>,
-) -> Result, (StatusCode, String)> {
- let store = state.store.as_ref().ok_or((
- StatusCode::SERVICE_UNAVAILABLE,
- "Database not available".to_string(),
- ))?;
-
- let routines = store
- .list_all_routines()
- .await
- .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
-
- let items: Vec = routines.iter().map(RoutineInfo::from_routine).collect();
-
- Ok(Json(RoutineListResponse { routines: items }))
-}
-
-async fn routines_summary_handler(
- State(state): State>,
-) -> Result, (StatusCode, String)> {
- let store = state.store.as_ref().ok_or((
- StatusCode::SERVICE_UNAVAILABLE,
- "Database not available".to_string(),
- ))?;
-
- let routines = store
- .list_all_routines()
- .await
- .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
-
- let total = routines.len() as u64;
- let enabled = routines.iter().filter(|r| r.enabled).count() as u64;
- let disabled = total - enabled;
- let failing = routines
- .iter()
- .filter(|r| r.consecutive_failures > 0)
- .count() as u64;
-
- let today_start = chrono::Utc::now()
- .date_naive()
- .and_hms_opt(0, 0, 0)
- .map(|dt| dt.and_utc());
- let runs_today = if let Some(start) = today_start {
- routines
- .iter()
- .filter(|r| r.last_run_at.is_some_and(|ts| ts >= start))
- .count() as u64
- } else {
- 0
- };
-
- Ok(Json(RoutineSummaryResponse {
- total,
- enabled,
- disabled,
- failing,
- runs_today,
- }))
-}
-
-async fn routines_detail_handler(
- State(state): State>,
- Path(id): Path,
-) -> Result, (StatusCode, String)> {
- let store = state.store.as_ref().ok_or((
- StatusCode::SERVICE_UNAVAILABLE,
- "Database not available".to_string(),
- ))?;
-
- let routine_id = Uuid::parse_str(&id)
- .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?;
-
- let routine = store
- .get_routine(routine_id)
- .await
- .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
- .ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?;
-
- let runs = store
- .list_routine_runs(routine_id, 20)
- .await
- .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
-
- let recent_runs: Vec = runs
- .iter()
- .map(|run| RoutineRunInfo {
- id: run.id,
- trigger_type: run.trigger_type.clone(),
- started_at: run.started_at.to_rfc3339(),
- completed_at: run.completed_at.map(|dt| dt.to_rfc3339()),
- status: format!("{:?}", run.status),
- result_summary: run.result_summary.clone(),
- tokens_used: run.tokens_used,
- job_id: run.job_id,
- })
- .collect();
- let routine_info = RoutineInfo::from_routine(&routine);
-
- Ok(Json(RoutineDetailResponse {
- id: routine.id,
- name: routine.name.clone(),
- description: routine.description.clone(),
- enabled: routine.enabled,
- trigger_type: routine_info.trigger_type,
- trigger_raw: routine_info.trigger_raw,
- trigger_summary: routine_info.trigger_summary,
- trigger: serde_json::to_value(&routine.trigger).unwrap_or_default(),
- action: serde_json::to_value(&routine.action).unwrap_or_default(),
- guardrails: serde_json::to_value(&routine.guardrails).unwrap_or_default(),
- notify: serde_json::to_value(&routine.notify).unwrap_or_default(),
- last_run_at: routine.last_run_at.map(|dt| dt.to_rfc3339()),
- next_fire_at: routine.next_fire_at.map(|dt| dt.to_rfc3339()),
- run_count: routine.run_count,
- consecutive_failures: routine.consecutive_failures,
- created_at: routine.created_at.to_rfc3339(),
- recent_runs,
- }))
-}
-
-async fn routines_trigger_handler(
- State(state): State>,
- Path(id): Path,
-) -> Result, (StatusCode, String)> {
- let engine = {
- let guard = state.routine_engine.read().await;
- guard.as_ref().cloned().ok_or((
- StatusCode::SERVICE_UNAVAILABLE,
- "Routine engine not available".to_string(),
- ))?
- };
-
- let routine_id = Uuid::parse_str(&id)
- .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?;
-
- let run_id = engine
- .fire_manual(routine_id, Some(&state.user_id))
- .await
- .map_err(|e| {
- let status = match &e {
- crate::error::RoutineError::NotFound { .. } => StatusCode::NOT_FOUND,
- crate::error::RoutineError::NotAuthorized { .. } => StatusCode::FORBIDDEN,
- crate::error::RoutineError::Disabled { .. }
- | crate::error::RoutineError::MaxConcurrent { .. } => StatusCode::CONFLICT,
- _ => StatusCode::INTERNAL_SERVER_ERROR,
- };
- (status, e.to_string())
- })?;
-
- Ok(Json(serde_json::json!({
- "status": "triggered",
- "routine_id": routine_id,
- "run_id": run_id,
- })))
-}
-
async fn routines_runs_handler(
State(state): State>,
Path(id): Path,
@@ -2669,6 +2660,9 @@ async fn gateway_status_handler(
daily_cost,
actions_this_hour,
model_usage,
+ llm_backend: state.active_config.llm_backend.clone(),
+ llm_model: state.active_config.llm_model.clone(),
+ enabled_channels: state.active_config.enabled_channels.clone(),
})
}
@@ -2694,6 +2688,9 @@ struct GatewayStatusResponse {
actions_this_hour: Option,
#[serde(skip_serializing_if = "Option::is_none")]
model_usage: Option>,
+ llm_backend: String,
+ llm_model: String,
+ enabled_channels: Vec,
}
#[cfg(test)]
@@ -2886,10 +2883,12 @@ mod tests {
scheduler: None,
chat_rate_limiter: RateLimiter::new(30, 60),
oauth_rate_limiter: RateLimiter::new(10, 60),
+ webhook_rate_limiter: RateLimiter::new(10, 60),
registry_entries: vec![],
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
+ active_config: ActiveConfigSnapshot::default(),
})
}
@@ -3236,7 +3235,7 @@ mod tests {
secrets,
sse_sender: None,
gateway_token: None,
- resource: None,
+ token_exchange_extra_params: std::collections::HashMap::new(),
client_id_secret_name: None,
created_at,
};
@@ -3304,7 +3303,7 @@ mod tests {
secrets,
sse_sender: Some(sender),
gateway_token: None,
- resource: None,
+ token_exchange_extra_params: std::collections::HashMap::new(),
client_id_secret_name: None,
created_at,
};
@@ -3407,7 +3406,7 @@ mod tests {
secrets,
sse_sender: None,
gateway_token: None,
- resource: None,
+ token_exchange_extra_params: std::collections::HashMap::new(),
client_id_secret_name: None,
// Expired — handler will reject after lookup (no network I/O)
created_at,
@@ -3459,6 +3458,85 @@ mod tests {
);
}
+ #[tokio::test]
+ async fn test_oauth_callback_accepts_versioned_hosted_state() {
+ use axum::body::Body;
+ use tower::ServiceExt;
+
+ let secrets: Arc =
+ Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
+ crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
+ TEST_GATEWAY_CRYPTO_KEY.to_string(),
+ ))
+ .expect("crypto"),
+ )));
+ let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(secrets.clone());
+
+ let Some(created_at) = expired_flow_created_at() else {
+ eprintln!("Skipping versioned OAuth state test: monotonic uptime below expiry window");
+ return;
+ };
+ let flow = crate::cli::oauth_defaults::PendingOAuthFlow {
+ extension_name: "test_tool".to_string(),
+ display_name: "Test Tool".to_string(),
+ token_url: "https://example.com/token".to_string(),
+ client_id: "client123".to_string(),
+ client_secret: None,
+ redirect_uri: "https://example.com/oauth/callback".to_string(),
+ code_verifier: None,
+ access_token_field: "access_token".to_string(),
+ secret_name: "test_token".to_string(),
+ provider: None,
+ validation_endpoint: None,
+ scopes: vec![],
+ user_id: "test".to_string(),
+ secrets,
+ sse_sender: None,
+ gateway_token: None,
+ token_exchange_extra_params: std::collections::HashMap::new(),
+ client_id_secret_name: None,
+ created_at,
+ };
+
+ ext_mgr
+ .pending_oauth_flows()
+ .write()
+ .await
+ .insert("test_nonce".to_string(), flow);
+
+ let state = test_gateway_state(Some(ext_mgr.clone()));
+ let app = test_oauth_router(state);
+ let versioned_state =
+ crate::cli::oauth_defaults::encode_hosted_oauth_state("test_nonce", Some("myinstance"));
+
+ let req = axum::http::Request::builder()
+ .uri(format!(
+ "/oauth/callback?code=fake_code&state={}",
+ urlencoding::encode(&versioned_state)
+ ))
+ .body(Body::empty())
+ .expect("request");
+
+ let resp = ServiceExt::>::oneshot(app, req)
+ .await
+ .expect("response");
+ assert_eq!(resp.status(), StatusCode::OK);
+
+ let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
+ .await
+ .expect("body");
+ let html = String::from_utf8_lossy(&body);
+ assert!(html.contains("Authorization Failed"));
+ assert!(
+ ext_mgr
+ .pending_oauth_flows()
+ .read()
+ .await
+ .get("test_nonce")
+ .is_none()
+ );
+ }
+
// --- Slack relay OAuth CSRF tests ---
fn test_relay_oauth_router(state: Arc) -> Router {
@@ -3516,7 +3594,7 @@ mod tests {
// Callback without state param should be rejected
let req = axum::http::Request::builder()
- .uri("/oauth/slack/callback?stream_token=tok123&team_id=T123&provider=slack")
+ .uri("/oauth/slack/callback?team_id=T123&provider=slack")
.body(Body::empty())
.expect("request");
@@ -3560,7 +3638,7 @@ mod tests {
// Callback with wrong state param
let req = axum::http::Request::builder()
- .uri("/oauth/slack/callback?stream_token=tok123&team_id=T123&provider=slack&state=wrong-nonce")
+ .uri("/oauth/slack/callback?team_id=T123&provider=slack&state=wrong-nonce")
.body(Body::empty())
.expect("request");
@@ -3608,7 +3686,7 @@ mod tests {
// we just verify it doesn't return a CSRF error.
let req = axum::http::Request::builder()
.uri(format!(
- "/oauth/slack/callback?stream_token=tok123&team_id=T123&provider=slack&state={}",
+ "/oauth/slack/callback?team_id=T123&provider=slack&state={}",
nonce
))
.body(Body::empty())
diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js
index 9d931500..075aa7cc 100644
--- a/src/channels/web/static/app.js
+++ b/src/channels/web/static/app.js
@@ -1,5 +1,69 @@
// IronClaw Web Gateway - Client
+// --- Theme Management (dark / light / system) ---
+// Icon switching is handled by pure CSS via data-theme-mode on .
+
+function getSystemTheme() {
+ return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
+}
+
+const VALID_THEME_MODES = { dark: true, light: true, system: true };
+
+function getThemeMode() {
+ const stored = localStorage.getItem('ironclaw-theme');
+ return (stored && VALID_THEME_MODES[stored]) ? stored : 'system';
+}
+
+function resolveTheme(mode) {
+ return mode === 'system' ? getSystemTheme() : mode;
+}
+
+function applyTheme(mode) {
+ const resolved = resolveTheme(mode);
+ document.documentElement.setAttribute('data-theme', resolved);
+ document.documentElement.setAttribute('data-theme-mode', mode);
+ const titleKeys = { dark: 'theme.tooltipDark', light: 'theme.tooltipLight', system: 'theme.tooltipSystem' };
+ const btn = document.getElementById('theme-toggle');
+ if (btn) btn.title = (typeof I18n !== 'undefined' && titleKeys[mode]) ? I18n.t(titleKeys[mode]) : ('Theme: ' + mode);
+ const announce = document.getElementById('theme-announce');
+ if (announce) announce.textContent = (typeof I18n !== 'undefined') ? I18n.t('theme.announce', { mode: mode }) : ('Theme: ' + mode);
+}
+
+function toggleTheme() {
+ const cycle = { dark: 'light', light: 'system', system: 'dark' };
+ const current = getThemeMode();
+ const next = cycle[current] || 'dark';
+ localStorage.setItem('ironclaw-theme', next);
+ applyTheme(next);
+}
+
+// Apply theme immediately (FOUC prevention is done via inline script in ,
+// but we call again here to ensure tooltip is set after DOM is ready).
+applyTheme(getThemeMode());
+
+// Delay enabling theme transition to avoid flash on initial load.
+requestAnimationFrame(function() {
+ requestAnimationFrame(function() {
+ document.body.classList.add('theme-transition');
+ });
+});
+
+// Listen for OS theme changes — only re-apply when in 'system' mode.
+const mql = window.matchMedia('(prefers-color-scheme: light)');
+const onSchemeChange = function() {
+ if (getThemeMode() === 'system') {
+ applyTheme('system');
+ }
+};
+if (mql.addEventListener) {
+ mql.addEventListener('change', onSchemeChange);
+} else if (mql.addListener) {
+ mql.addListener(onSchemeChange);
+}
+
+// Bind theme toggle button (CSP-compliant — no inline onclick).
+document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
+
let token = '';
let eventSource = null;
let logEventSource = null;
@@ -21,6 +85,7 @@ const MEMORY_SEARCH_QUERY_MAX_LENGTH = 100;
let stagedImages = [];
let authFlowPending = false;
let _ghostSuggestion = '';
+let currentSettingsSubtab = 'inference';
// --- Slash Commands ---
@@ -99,6 +164,30 @@ document.getElementById('token-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') authenticate();
});
+// --- Static element event bindings (CSP-compliant, no inline handlers) ---
+document.getElementById('auth-connect-btn').addEventListener('click', () => authenticate());
+document.getElementById('restart-overlay').addEventListener('click', () => cancelRestart());
+document.getElementById('restart-close-btn').addEventListener('click', () => cancelRestart());
+document.getElementById('restart-cancel-btn').addEventListener('click', () => cancelRestart());
+document.getElementById('restart-confirm-btn').addEventListener('click', () => confirmRestart());
+document.getElementById('language-btn').addEventListener('click', () => toggleLanguageMenu());
+// Language option clicks handled by delegated data-action="switch-language" handler.
+document.getElementById('restart-btn').addEventListener('click', () => triggerRestart());
+document.getElementById('thread-new-btn').addEventListener('click', () => createNewThread());
+document.getElementById('thread-toggle-btn').addEventListener('click', () => toggleThreadSidebar());
+document.getElementById('assistant-thread').addEventListener('click', () => switchToAssistant());
+document.getElementById('send-btn').addEventListener('click', () => sendMessage());
+document.getElementById('memory-edit-btn').addEventListener('click', () => startMemoryEdit());
+document.getElementById('memory-save-btn').addEventListener('click', () => saveMemoryEdit());
+document.getElementById('memory-cancel-btn').addEventListener('click', () => cancelMemoryEdit());
+document.getElementById('logs-server-level').addEventListener('change', function() { setServerLogLevel(this.value); });
+document.getElementById('logs-pause-btn').addEventListener('click', () => toggleLogsPause());
+document.getElementById('logs-clear-btn').addEventListener('click', () => clearLogs());
+document.getElementById('wasm-install-btn').addEventListener('click', () => installWasmExtension());
+document.getElementById('mcp-add-btn').addEventListener('click', () => addMcpServer());
+document.getElementById('skill-search-btn').addEventListener('click', () => searchClawHub());
+document.getElementById('skill-install-btn').addEventListener('click', () => installSkillFromForm());
+
// Auto-authenticate from URL param or saved session
(function autoAuth() {
const params = new URLSearchParams(window.location.search);
@@ -135,6 +224,7 @@ function apiFetch(path, options) {
throw new Error(body || (res.status + ' ' + res.statusText));
});
}
+ if (res.status === 204) return null;
return res.json();
});
}
@@ -364,8 +454,8 @@ function connectSSE() {
debouncedLoadThreads();
}
- // Extension setup flows can surface approvals while user is on Extensions tab.
- if (currentTab === 'extensions') loadExtensions();
+ // Extension setup flows can surface approvals from any settings subtab.
+ if (currentTab === 'settings') refreshCurrentSettingsTab();
});
eventSource.addEventListener('auth_required', (e) => {
@@ -373,11 +463,12 @@ function connectSSE() {
});
eventSource.addEventListener('auth_completed', (e) => {
- handleAuthCompleted(JSON.parse(e.data));
+ const data = JSON.parse(e.data);
+ handleAuthCompleted(data);
});
eventSource.addEventListener('extension_status', (e) => {
- if (currentTab === 'extensions') loadExtensions();
+ if (currentTab === 'settings') refreshCurrentSettingsTab();
});
eventSource.addEventListener('image_generated', (e) => {
@@ -1135,18 +1226,19 @@ function showApproval(data) {
approveBtn.textContent = I18n.t('approval.approve');
approveBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'approve'));
- const alwaysBtn = document.createElement('button');
- alwaysBtn.className = 'always';
- alwaysBtn.textContent = I18n.t('approval.always');
- alwaysBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'always'));
-
const denyBtn = document.createElement('button');
denyBtn.className = 'deny';
denyBtn.textContent = I18n.t('approval.deny');
denyBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'deny'));
actions.appendChild(approveBtn);
- actions.appendChild(alwaysBtn);
+ if (data.allow_always !== false) {
+ const alwaysBtn = document.createElement('button');
+ alwaysBtn.className = 'always';
+ alwaysBtn.textContent = I18n.t('approval.always');
+ alwaysBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'always'));
+ actions.appendChild(alwaysBtn);
+ }
actions.appendChild(denyBtn);
card.appendChild(actions);
@@ -1232,7 +1324,7 @@ function handleAuthCompleted(data) {
if (shouldShowChannelConnectedMessage(data.extension_name, data.success)) {
addMessage('system', 'Telegram is now connected. You can message me there and I can send you notifications.');
}
- if (currentTab === 'extensions') loadExtensions();
+ if (currentTab === 'settings') refreshCurrentSettingsTab();
enableChatInput();
}
@@ -1877,13 +1969,11 @@ function switchTab(tab) {
if (tab === 'jobs') loadJobs();
if (tab === 'routines') loadRoutines();
if (tab === 'logs') applyLogFilters();
- if (tab === 'extensions') {
- loadExtensions();
- startPairingPoll();
+ if (tab === 'settings') {
+ loadSettingsSubtab(currentSettingsSubtab);
} else {
stopPairingPoll();
}
- if (tab === 'skills') loadSkills();
}
// --- Memory (filesystem tree) ---
@@ -2270,61 +2360,42 @@ var kindLabels = { 'wasm_channel': 'Channel', 'wasm_tool': 'Tool', 'mcp_server':
function loadExtensions() {
const extList = document.getElementById('extensions-list');
const wasmList = document.getElementById('available-wasm-list');
- const mcpList = document.getElementById('mcp-servers-list');
- const toolsTbody = document.getElementById('tools-tbody');
- const toolsEmpty = document.getElementById('tools-empty');
+ extList.innerHTML = renderCardsSkeleton(3);
- // Fetch all three in parallel
+ // Fetch extensions and registry in parallel
Promise.all([
apiFetch('/api/extensions').catch(() => ({ extensions: [] })),
- apiFetch('/api/extensions/tools').catch(() => ({ tools: [] })),
apiFetch('/api/extensions/registry').catch(function(err) { console.warn('registry fetch failed:', err); return { entries: [] }; }),
- ]).then(([extData, toolData, registryData]) => {
- // Render installed extensions
- if (extData.extensions.length === 0) {
+ ]).then(([extData, registryData]) => {
+ // Render installed extensions (exclude wasm_channel and mcp_server — shown in their own tabs)
+ var nonChannelExts = extData.extensions.filter(function(e) {
+ return e.kind !== 'wasm_channel' && e.kind !== 'mcp_server';
+ });
+ if (nonChannelExts.length === 0) {
extList.innerHTML = '' + I18n.t('extensions.noInstalled') + '
';
} else {
extList.innerHTML = '';
- for (const ext of extData.extensions) {
+ for (const ext of nonChannelExts) {
extList.appendChild(renderExtensionCard(ext));
}
}
- // Split registry entries by kind
- var wasmEntries = registryData.entries.filter(function(e) { return e.kind !== 'mcp_server' && !e.installed; });
- var mcpEntries = registryData.entries.filter(function(e) { return e.kind === 'mcp_server'; });
+ // Available extensions (exclude MCP servers and channels — they have their own tabs)
+ var wasmEntries = registryData.entries.filter(function(e) {
+ return e.kind !== 'mcp_server' && e.kind !== 'wasm_channel' && e.kind !== 'channel' && !e.installed;
+ });
- // Available WASM extensions
+ var wasmSection = document.getElementById('available-wasm-section');
if (wasmEntries.length === 0) {
- wasmList.innerHTML = '' + I18n.t('extensions.noAvailable') + '
';
+ if (wasmSection) wasmSection.style.display = 'none';
} else {
+ if (wasmSection) wasmSection.style.display = '';
wasmList.innerHTML = '';
for (const entry of wasmEntries) {
wasmList.appendChild(renderAvailableExtensionCard(entry));
}
}
- // MCP servers (show both installed and uninstalled)
- if (mcpEntries.length === 0) {
- mcpList.innerHTML = '' + I18n.t('mcp.noServers') + '
';
- } else {
- mcpList.innerHTML = '';
- for (const entry of mcpEntries) {
- var installedExt = extData.extensions.find(function(e) { return e.name === entry.name; });
- mcpList.appendChild(renderMcpServerCard(entry, installedExt));
- }
- }
-
- // Render tools
- if (toolData.tools.length === 0) {
- toolsTbody.innerHTML = '';
- toolsEmpty.style.display = 'block';
- } else {
- toolsEmpty.style.display = 'none';
- toolsTbody.innerHTML = toolData.tools.map((t) =>
- '' + escapeHtml(t.name) + ' ' + escapeHtml(t.description) + ' '
- ).join('');
- }
});
}
@@ -2390,18 +2461,18 @@ function renderAvailableExtensionCard(entry) {
showToast('Opening authentication for ' + entry.display_name, 'info');
openOAuthUrl(res.auth_url);
}
- loadExtensions();
+ refreshCurrentSettingsTab();
// Auto-open configure for WASM channels
if (entry.kind === 'wasm_channel') {
showConfigureModal(entry.name);
}
} else {
showToast('Install: ' + (res.message || 'unknown error'), 'error');
- loadExtensions();
+ refreshCurrentSettingsTab();
}
}).catch(function(err) {
showToast('Install failed: ' + err.message, 'error');
- loadExtensions();
+ refreshCurrentSettingsTab();
});
});
actions.appendChild(installBtn);
@@ -2457,6 +2528,13 @@ function renderMcpServerCard(entry, installedExt) {
activeLabel.textContent = I18n.t('ext.active');
actions.appendChild(activeLabel);
}
+ if (installedExt.needs_setup || (installedExt.has_auth && installedExt.authenticated)) {
+ var configBtn = document.createElement('button');
+ configBtn.className = 'btn-ext configure';
+ configBtn.textContent = installedExt.authenticated ? I18n.t('ext.reconfigure') : I18n.t('ext.configure');
+ configBtn.addEventListener('click', function() { showConfigureModal(installedExt.name); });
+ actions.appendChild(configBtn);
+ }
var removeBtn = document.createElement('button');
removeBtn.className = 'btn-ext remove';
removeBtn.textContent = I18n.t('ext.remove');
@@ -2478,10 +2556,10 @@ function renderMcpServerCard(entry, installedExt) {
} else {
showToast(I18n.t('ext.install') + ': ' + (res.message || 'unknown error'), 'error');
}
- loadExtensions();
+ loadMcpServers();
}).catch(function(err) {
showToast(I18n.t('ext.installFailed', { message: err.message }), 'error');
- loadExtensions();
+ loadMcpServers();
});
});
actions.appendChild(installBtn);
@@ -2501,7 +2579,16 @@ function createReconfigureButton(extName) {
function renderExtensionCard(ext) {
const card = document.createElement('div');
- card.className = 'ext-card';
+ var stateClass = 'state-inactive';
+ if (ext.kind === 'wasm_channel') {
+ var s = ext.activation_status || 'installed';
+ if (s === 'active') stateClass = 'state-active';
+ else if (s === 'failed') stateClass = 'state-error';
+ else if (s === 'pairing') stateClass = 'state-pairing';
+ } else if (ext.active) {
+ stateClass = 'state-active';
+ }
+ card.className = 'ext-card ' + stateClass;
const header = document.createElement('div');
header.className = 'ext-header';
@@ -2646,6 +2733,12 @@ function renderExtensionCard(ext) {
return card;
}
+function refreshCurrentSettingsTab() {
+ if (currentSettingsSubtab === 'extensions') loadExtensions();
+ if (currentSettingsSubtab === 'channels') loadChannelsStatus();
+ if (currentSettingsSubtab === 'mcp') loadMcpServers();
+}
+
function activateExtension(name) {
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/activate', { method: 'POST' })
.then((res) => {
@@ -2659,7 +2752,7 @@ function activateExtension(name) {
showToast('Opening authentication for ' + name, 'info');
openOAuthUrl(res.auth_url);
}
- loadExtensions();
+ refreshCurrentSettingsTab();
return;
}
@@ -2675,38 +2768,41 @@ function activateExtension(name) {
} else {
showToast('Activate failed: ' + res.message, 'error');
}
- loadExtensions();
+ refreshCurrentSettingsTab();
})
.catch((err) => showToast('Activate failed: ' + err.message, 'error'));
}
function removeExtension(name) {
- if (!confirm(I18n.t('ext.confirmRemove', { name: name }))) return;
- apiFetch('/api/extensions/' + encodeURIComponent(name) + '/remove', { method: 'POST' })
- .then((res) => {
- if (!res.success) {
- showToast(I18n.t('ext.removeFailed', { message: res.message }), 'error');
- } else {
- showToast(I18n.t('ext.removed', { name: name }), 'success');
- }
- loadExtensions();
- })
- .catch((err) => showToast(I18n.t('ext.removeFailed', { message: err.message }), 'error'));
+ showConfirmModal(I18n.t('ext.confirmRemove', { name: name }), '', function() {
+ apiFetch('/api/extensions/' + encodeURIComponent(name) + '/remove', { method: 'POST' })
+ .then((res) => {
+ if (!res.success) {
+ showToast(I18n.t('ext.removeFailed', { message: res.message }), 'error');
+ } else {
+ showToast(I18n.t('ext.removed', { name: name }), 'success');
+ }
+ refreshCurrentSettingsTab();
+ })
+ .catch((err) => showToast(I18n.t('ext.removeFailed', { message: err.message }), 'error'));
+ }, I18n.t('common.remove'), 'btn-danger');
}
function showConfigureModal(name) {
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/setup')
.then((setup) => {
- if (!setup.secrets || setup.secrets.length === 0) {
+ const secrets = Array.isArray(setup.secrets) ? setup.secrets : [];
+ const setupFields = Array.isArray(setup.fields) ? setup.fields : [];
+ if (secrets.length === 0 && setupFields.length === 0) {
showToast('No configuration needed for ' + name, 'info');
return;
}
- renderConfigureModal(name, setup.secrets);
+ renderConfigureModal(name, secrets, setupFields);
})
.catch((err) => showToast('Failed to load setup: ' + err.message, 'error'));
}
-function renderConfigureModal(name, secrets) {
+function renderConfigureModal(name, secrets, setupFields) {
closeConfigureModal();
const overlay = document.createElement('div');
overlay.className = 'configure-overlay';
@@ -2779,7 +2875,46 @@ function renderConfigureModal(name, secrets) {
field.appendChild(inputRow);
form.appendChild(field);
- fields.push({ name: secret.name, input: input });
+ fields.push({ kind: 'secret', name: secret.name, input: input });
+ }
+
+ for (const setupField of setupFields) {
+ const field = document.createElement('div');
+ field.className = 'configure-field';
+
+ const label = document.createElement('label');
+ label.textContent = setupField.prompt;
+ if (setupField.optional) {
+ const opt = document.createElement('span');
+ opt.className = 'field-optional';
+ opt.textContent = I18n.t('config.optional');
+ label.appendChild(opt);
+ }
+ field.appendChild(label);
+
+ const inputRow = document.createElement('div');
+ inputRow.className = 'configure-input-row';
+
+ const input = document.createElement('input');
+ input.type = setupField.input_type === 'password' ? 'password' : 'text';
+ input.name = setupField.name;
+ input.placeholder = setupField.provided ? I18n.t('config.alreadySet') : '';
+ input.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') submitConfigureModal(name, fields);
+ });
+ inputRow.appendChild(input);
+
+ if (setupField.provided) {
+ const badge = document.createElement('span');
+ badge.className = 'field-provided';
+ badge.textContent = '\u2713';
+ badge.title = I18n.t('config.alreadyConfigured');
+ inputRow.appendChild(badge);
+ }
+
+ field.appendChild(inputRow);
+ form.appendChild(field);
+ fields.push({ kind: 'field', name: setupField.name, input: input });
}
modal.appendChild(form);
@@ -2921,9 +3056,16 @@ function startTelegramAutoVerify(name, fields) {
function submitConfigureModal(name, fields, options) {
options = options || {};
const secrets = {};
+ const setupFields = {};
for (const f of fields) {
- if (f.input.value.trim()) {
- secrets[f.name] = f.input.value.trim();
+ const value = f.input.value.trim();
+ if (!value) {
+ continue;
+ }
+ if (f.kind === 'secret') {
+ secrets[f.name] = value;
+ } else {
+ setupFields[f.name] = value;
}
}
@@ -2940,7 +3082,7 @@ function submitConfigureModal(name, fields, options) {
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/setup', {
method: 'POST',
- body: { secrets },
+ body: { secrets, fields: setupFields },
})
.then((res) => {
if (res.success) {
@@ -2969,7 +3111,9 @@ function submitConfigureModal(name, fields, options) {
});
showToast('Opening OAuth authorization for ' + name, 'info');
openOAuthUrl(res.auth_url);
- loadExtensions();
+ refreshCurrentSettingsTab();
+ } else if (res.needs_restart) {
+ showToast('Configured ' + name + '. Restart IronClaw to apply all changes.', 'info');
}
// For non-OAuth success: the server always broadcasts auth_completed SSE,
// which will show the toast and refresh extensions — no need to do it here too.
@@ -3078,7 +3222,7 @@ function approvePairing(channel, code, container) {
}).then(res => {
if (res.success) {
showToast('Pairing approved', 'success');
- loadExtensions();
+ refreshCurrentSettingsTab();
} else {
showToast(res.message || 'Approve failed', 'error');
}
@@ -3848,7 +3992,6 @@ function renderRoutineDetail(routine) {
+ '' + escapeHtml(JSON.stringify(routine.trigger, null, 2)) + ' ';
}
- // Action config
html += 'Action '
+ '
' + escapeHtml(JSON.stringify(routine.action, null, 2)) + ' ';
@@ -3919,7 +4062,7 @@ function formatRelativeTime(isoString) {
const absDiff = Math.abs(diffMs);
const future = diffMs < 0;
- if (absDiff < 60000)
+ if (absDiff < 60000)
return future ? I18n.t('time.lessThan1MinuteFromNow') : I18n.t('time.lessThan1MinuteAgo');
if (absDiff < 3600000) {
const m = Math.floor(absDiff / 60000);
@@ -4184,7 +4327,7 @@ function addMcpServer() {
showToast('Added MCP server ' + name, 'success');
document.getElementById('mcp-install-name').value = '';
document.getElementById('mcp-install-url').value = '';
- loadExtensions();
+ loadMcpServers();
} else {
showToast('Failed to add MCP server: ' + (res.message || 'unknown error'), 'error');
}
@@ -4197,6 +4340,7 @@ function addMcpServer() {
function loadSkills() {
var skillsList = document.getElementById('skills-list');
+ skillsList.innerHTML = renderCardsSkeleton(3);
apiFetch('/api/skills').then(function(data) {
if (!data.skills || data.skills.length === 0) {
skillsList.innerHTML = '' + I18n.t('skills.noInstalled') + '
';
@@ -4213,7 +4357,7 @@ function loadSkills() {
function renderSkillCard(skill) {
var card = document.createElement('div');
- card.className = 'ext-card';
+ card.className = 'ext-card state-active';
var header = document.createElement('div');
header.className = 'ext-header';
@@ -4480,20 +4624,21 @@ function installSkill(nameOrSlug, url, btn) {
}
function removeSkill(name) {
- if (!confirm(I18n.t('skills.confirmRemove', { name: name }))) return;
- apiFetch('/api/skills/' + encodeURIComponent(name), {
- method: 'DELETE',
- headers: { 'X-Confirm-Action': 'true' },
- }).then(function(res) {
- if (res.success) {
- showToast(I18n.t('skills.removed', { name: name }), 'success');
- } else {
- showToast(I18n.t('skills.removeFailed', { message: res.message || 'unknown error' }), 'error');
- }
- loadSkills();
- }).catch(function(err) {
- showToast(I18n.t('skills.removeFailed', { message: err.message }), 'error');
- });
+ showConfirmModal(I18n.t('skills.confirmRemove', { name: name }), '', function() {
+ apiFetch('/api/skills/' + encodeURIComponent(name), {
+ method: 'DELETE',
+ headers: { 'X-Confirm-Action': 'true' },
+ }).then(function(res) {
+ if (res.success) {
+ showToast(I18n.t('skills.removed', { name: name }), 'success');
+ } else {
+ showToast(I18n.t('skills.removeFailed', { message: res.message || 'unknown error' }), 'error');
+ }
+ loadSkills();
+ }).catch(function(err) {
+ showToast(I18n.t('skills.removeFailed', { message: err.message }), 'error');
+ });
+ }, I18n.t('common.remove'), 'btn-danger');
}
function installSkillFromForm() {
@@ -4522,10 +4667,10 @@ document.addEventListener('keydown', (e) => {
const tag = (e.target.tagName || '').toLowerCase();
const inInput = tag === 'input' || tag === 'textarea';
- // Mod+1-6: switch tabs
- if (mod && e.key >= '1' && e.key <= '6') {
+ // Mod+1-5: switch tabs
+ if (mod && e.key >= '1' && e.key <= '5') {
e.preventDefault();
- const tabs = ['chat', 'memory', 'jobs', 'routines', 'extensions', 'skills'];
+ const tabs = ['chat', 'memory', 'jobs', 'routines', 'settings'];
const idx = parseInt(e.key) - 1;
if (tabs[idx]) switchTab(tabs[idx]);
return;
@@ -4565,6 +4710,711 @@ document.addEventListener('keydown', (e) => {
}
});
+// --- Settings Tab ---
+
+document.querySelectorAll('.settings-subtab').forEach(function(btn) {
+ btn.addEventListener('click', function() {
+ switchSettingsSubtab(btn.getAttribute('data-settings-subtab'));
+ });
+});
+
+function switchSettingsSubtab(subtab) {
+ currentSettingsSubtab = subtab;
+ document.querySelectorAll('.settings-subtab').forEach(function(b) {
+ b.classList.toggle('active', b.getAttribute('data-settings-subtab') === subtab);
+ });
+ document.querySelectorAll('.settings-subpanel').forEach(function(p) {
+ p.classList.toggle('active', p.id === 'settings-' + subtab);
+ });
+ // Clear search when switching subtabs so stale filters don't apply
+ var searchInput = document.getElementById('settings-search-input');
+ if (searchInput && searchInput.value) {
+ searchInput.value = '';
+ searchInput.dispatchEvent(new Event('input'));
+ }
+ loadSettingsSubtab(subtab);
+}
+
+function loadSettingsSubtab(subtab) {
+ if (subtab === 'inference') loadInferenceSettings();
+ else if (subtab === 'agent') loadAgentSettings();
+ else if (subtab === 'channels') { loadChannelsStatus(); startPairingPoll(); }
+ else if (subtab === 'networking') loadNetworkingSettings();
+ else if (subtab === 'extensions') { loadExtensions(); startPairingPoll(); }
+ else if (subtab === 'mcp') loadMcpServers();
+ else if (subtab === 'skills') loadSkills();
+ if (subtab !== 'extensions' && subtab !== 'channels') stopPairingPoll();
+}
+
+// --- Structured Settings Definitions ---
+
+var INFERENCE_SETTINGS = [
+ {
+ group: 'cfg.group.llm',
+ settings: [
+ { key: 'llm_backend', label: 'cfg.llm_backend.label', description: 'cfg.llm_backend.desc',
+ type: 'select', options: ['nearai', 'anthropic', 'openai', 'ollama', 'openai_compatible', 'tinfoil', 'bedrock'] },
+ { key: 'selected_model', label: 'cfg.selected_model.label', description: 'cfg.selected_model.desc', type: 'text' },
+ { key: 'ollama_base_url', label: 'cfg.ollama_base_url.label', description: 'cfg.ollama_base_url.desc', type: 'text',
+ showWhen: { key: 'llm_backend', value: 'ollama' } },
+ { key: 'openai_compatible_base_url', label: 'cfg.openai_compatible_base_url.label', description: 'cfg.openai_compatible_base_url.desc', type: 'text',
+ showWhen: { key: 'llm_backend', value: 'openai_compatible' } },
+ { key: 'bedrock_region', label: 'cfg.bedrock_region.label', description: 'cfg.bedrock_region.desc', type: 'text',
+ showWhen: { key: 'llm_backend', value: 'bedrock' } },
+ { key: 'bedrock_cross_region', label: 'cfg.bedrock_cross_region.label', description: 'cfg.bedrock_cross_region.desc',
+ type: 'select', options: ['us', 'eu', 'apac', 'global'],
+ showWhen: { key: 'llm_backend', value: 'bedrock' } },
+ { key: 'bedrock_profile', label: 'cfg.bedrock_profile.label', description: 'cfg.bedrock_profile.desc', type: 'text',
+ showWhen: { key: 'llm_backend', value: 'bedrock' } },
+ ]
+ },
+ {
+ group: 'cfg.group.embeddings',
+ settings: [
+ { key: 'embeddings.enabled', label: 'cfg.embeddings_enabled.label', description: 'cfg.embeddings_enabled.desc', type: 'boolean' },
+ { key: 'embeddings.provider', label: 'cfg.embeddings_provider.label', description: 'cfg.embeddings_provider.desc',
+ type: 'select', options: ['openai', 'nearai'] },
+ { key: 'embeddings.model', label: 'cfg.embeddings_model.label', description: 'cfg.embeddings_model.desc', type: 'text' },
+ ]
+ },
+];
+
+var AGENT_SETTINGS = [
+ {
+ group: 'cfg.group.agent',
+ settings: [
+ { key: 'agent.name', label: 'cfg.agent_name.label', description: 'cfg.agent_name.desc', type: 'text' },
+ { key: 'agent.max_parallel_jobs', label: 'cfg.agent_max_parallel_jobs.label', description: 'cfg.agent_max_parallel_jobs.desc', type: 'number' },
+ { key: 'agent.job_timeout_secs', label: 'cfg.agent_job_timeout.label', description: 'cfg.agent_job_timeout.desc', type: 'number' },
+ { key: 'agent.max_tool_iterations', label: 'cfg.agent_max_tool_iterations.label', description: 'cfg.agent_max_tool_iterations.desc', type: 'number' },
+ { key: 'agent.use_planning', label: 'cfg.agent_use_planning.label', description: 'cfg.agent_use_planning.desc', type: 'boolean' },
+ { key: 'agent.auto_approve_tools', label: 'cfg.agent_auto_approve.label', description: 'cfg.agent_auto_approve.desc', type: 'boolean' },
+ { key: 'agent.default_timezone', label: 'cfg.agent_timezone.label', description: 'cfg.agent_timezone.desc', type: 'text' },
+ { key: 'agent.session_idle_timeout_secs', label: 'cfg.agent_session_idle.label', description: 'cfg.agent_session_idle.desc', type: 'number' },
+ { key: 'agent.stuck_threshold_secs', label: 'cfg.agent_stuck_threshold.label', description: 'cfg.agent_stuck_threshold.desc', type: 'number' },
+ { key: 'agent.max_repair_attempts', label: 'cfg.agent_max_repair.label', description: 'cfg.agent_max_repair.desc', type: 'number' },
+ { key: 'agent.max_cost_per_day_cents', label: 'cfg.agent_max_cost.label', description: 'cfg.agent_max_cost.desc', type: 'number', min: 0 },
+ { key: 'agent.max_actions_per_hour', label: 'cfg.agent_max_actions.label', description: 'cfg.agent_max_actions.desc', type: 'number', min: 0 },
+ { key: 'agent.allow_local_tools', label: 'cfg.agent_allow_local.label', description: 'cfg.agent_allow_local.desc', type: 'boolean' },
+ ]
+ },
+ {
+ group: 'cfg.group.heartbeat',
+ settings: [
+ { key: 'heartbeat.enabled', label: 'cfg.heartbeat_enabled.label', description: 'cfg.heartbeat_enabled.desc', type: 'boolean' },
+ { key: 'heartbeat.interval_secs', label: 'cfg.heartbeat_interval.label', description: 'cfg.heartbeat_interval.desc', type: 'number' },
+ { key: 'heartbeat.notify_channel', label: 'cfg.heartbeat_notify_channel.label', description: 'cfg.heartbeat_notify_channel.desc', type: 'text' },
+ { key: 'heartbeat.notify_user', label: 'cfg.heartbeat_notify_user.label', description: 'cfg.heartbeat_notify_user.desc', type: 'text' },
+ { key: 'heartbeat.quiet_hours_start', label: 'cfg.heartbeat_quiet_start.label', description: 'cfg.heartbeat_quiet_start.desc', type: 'number', min: 0, max: 23 },
+ { key: 'heartbeat.quiet_hours_end', label: 'cfg.heartbeat_quiet_end.label', description: 'cfg.heartbeat_quiet_end.desc', type: 'number', min: 0, max: 23 },
+ { key: 'heartbeat.timezone', label: 'cfg.heartbeat_timezone.label', description: 'cfg.heartbeat_timezone.desc', type: 'text' },
+ ]
+ },
+ {
+ group: 'cfg.group.sandbox',
+ settings: [
+ { key: 'sandbox.enabled', label: 'cfg.sandbox_enabled.label', description: 'cfg.sandbox_enabled.desc', type: 'boolean' },
+ { key: 'sandbox.policy', label: 'cfg.sandbox_policy.label', description: 'cfg.sandbox_policy.desc',
+ type: 'select', options: ['readonly', 'workspace_write', 'full_access'] },
+ { key: 'sandbox.timeout_secs', label: 'cfg.sandbox_timeout.label', description: 'cfg.sandbox_timeout.desc', type: 'number', min: 0 },
+ { key: 'sandbox.memory_limit_mb', label: 'cfg.sandbox_memory.label', description: 'cfg.sandbox_memory.desc', type: 'number', min: 0 },
+ { key: 'sandbox.image', label: 'cfg.sandbox_image.label', description: 'cfg.sandbox_image.desc', type: 'text' },
+ ]
+ },
+ {
+ group: 'cfg.group.routines',
+ settings: [
+ { key: 'routines.max_concurrent', label: 'cfg.routines_max_concurrent.label', description: 'cfg.routines_max_concurrent.desc', type: 'number', min: 0 },
+ { key: 'routines.default_cooldown_secs', label: 'cfg.routines_cooldown.label', description: 'cfg.routines_cooldown.desc', type: 'number', min: 0 },
+ ]
+ },
+ {
+ group: 'cfg.group.safety',
+ settings: [
+ { key: 'safety.max_output_length', label: 'cfg.safety_max_output.label', description: 'cfg.safety_max_output.desc', type: 'number', min: 0 },
+ { key: 'safety.injection_check_enabled', label: 'cfg.safety_injection_check.label', description: 'cfg.safety_injection_check.desc', type: 'boolean' },
+ ]
+ },
+ {
+ group: 'cfg.group.skills',
+ settings: [
+ { key: 'skills.max_active', label: 'cfg.skills_max_active.label', description: 'cfg.skills_max_active.desc', type: 'number', min: 0 },
+ { key: 'skills.max_context_tokens', label: 'cfg.skills_max_tokens.label', description: 'cfg.skills_max_tokens.desc', type: 'number', min: 0 },
+ ]
+ },
+ {
+ group: 'cfg.group.search',
+ settings: [
+ { key: 'search.fusion_strategy', label: 'cfg.search_fusion.label', description: 'cfg.search_fusion.desc',
+ type: 'select', options: ['rrf', 'weighted'] },
+ ]
+ },
+];
+
+function renderSettingsSkeleton(rows) {
+ var html = '';
+ for (var i = 0; i < (rows || 5); i++) {
+ var w1 = 100 + Math.floor(Math.random() * 60);
+ var w2 = 140 + Math.floor(Math.random() * 60);
+ html += '
';
+ }
+ html += '
';
+ return html;
+}
+
+function renderCardsSkeleton(count) {
+ var html = '';
+ for (var i = 0; i < (count || 3); i++) {
+ html += '';
+ }
+ return html;
+}
+
+function loadInferenceSettings() {
+ var container = document.getElementById('settings-inference-content');
+ container.innerHTML = renderSettingsSkeleton(6);
+
+ Promise.all([
+ apiFetch('/api/settings/export'),
+ apiFetch('/api/gateway/status').catch(function() { return {}; }),
+ apiFetch('/v1/models').catch(function() { return { data: [] }; })
+ ]).then(function(results) {
+ var settings = results[0].settings || {};
+ var status = results[1];
+ var modelsData = results[2];
+ var activeValues = {
+ 'llm_backend': status.llm_backend,
+ 'selected_model': status.llm_model
+ };
+ // Inject available model IDs as suggestions for the selected_model field
+ var modelIds = (modelsData.data || []).map(function(m) { return m.id; }).filter(Boolean);
+ var llmGroup = INFERENCE_SETTINGS[0];
+ for (var i = 0; i < llmGroup.settings.length; i++) {
+ if (llmGroup.settings[i].key === 'selected_model') {
+ llmGroup.settings[i].suggestions = modelIds;
+ break;
+ }
+ }
+ container.innerHTML = '';
+ renderStructuredSettingsInto(container, INFERENCE_SETTINGS, settings, activeValues);
+ }).catch(function(err) {
+ container.innerHTML = '' + I18n.t('common.loadFailed') + ': '
+ + escapeHtml(err.message) + '
';
+ });
+}
+
+function loadAgentSettings() {
+ loadStructuredSettings('settings-agent-content', AGENT_SETTINGS);
+}
+
+function loadStructuredSettings(containerId, settingsDefs) {
+ var container = document.getElementById(containerId);
+ container.innerHTML = renderSettingsSkeleton(8);
+
+ apiFetch('/api/settings/export').then(function(data) {
+ var settings = data.settings || {};
+ container.innerHTML = '';
+ renderStructuredSettingsInto(container, settingsDefs, settings, {});
+ }).catch(function(err) {
+ container.innerHTML = '' + I18n.t('common.loadFailed') + ': '
+ + escapeHtml(err.message) + '
';
+ });
+}
+
+function renderStructuredSettingsInto(container, settingsDefs, settings, activeValues) {
+ for (var gi = 0; gi < settingsDefs.length; gi++) {
+ var groupDef = settingsDefs[gi];
+ var group = document.createElement('div');
+ group.className = 'settings-group';
+
+ var title = document.createElement('div');
+ title.className = 'settings-group-title';
+ title.textContent = I18n.t(groupDef.group);
+ group.appendChild(title);
+
+ var rows = [];
+ for (var si = 0; si < groupDef.settings.length; si++) {
+ var def = groupDef.settings[si];
+ var activeVal = activeValues ? activeValues[def.key] : undefined;
+ var row = renderStructuredSettingsRow(def, settings[def.key], activeVal);
+ if (def.showWhen) {
+ row.setAttribute('data-show-when-key', def.showWhen.key);
+ row.setAttribute('data-show-when-value', def.showWhen.value);
+ var currentVal = settings[def.showWhen.key];
+ if (currentVal === def.showWhen.value) {
+ row.classList.remove('hidden');
+ } else {
+ row.classList.add('hidden');
+ }
+ }
+ rows.push(row);
+ group.appendChild(row);
+ }
+
+ container.appendChild(group);
+
+ // Wire up showWhen reactivity for select fields in this group
+ (function(groupRows, allSettings) {
+ for (var ri = 0; ri < groupRows.length; ri++) {
+ var sel = groupRows[ri].querySelector('.settings-select');
+ if (sel) {
+ sel.addEventListener('change', function() {
+ var changedKey = this.getAttribute('data-setting-key');
+ var changedVal = this.value;
+ for (var rj = 0; rj < groupRows.length; rj++) {
+ var whenKey = groupRows[rj].getAttribute('data-show-when-key');
+ var whenVal = groupRows[rj].getAttribute('data-show-when-value');
+ if (whenKey === changedKey) {
+ if (changedVal === whenVal) {
+ groupRows[rj].classList.remove('hidden');
+ } else {
+ groupRows[rj].classList.add('hidden');
+ }
+ }
+ }
+ });
+ }
+ }
+ })(rows, settings);
+ }
+
+ if (container.children.length === 0) {
+ container.innerHTML = '' + I18n.t('settings.noSettings') + '
';
+ }
+}
+
+function renderStructuredSettingsRow(def, value, activeValue) {
+ var row = document.createElement('div');
+ row.className = 'settings-row';
+
+ var labelWrap = document.createElement('div');
+ labelWrap.className = 'settings-label-wrap';
+
+ var label = document.createElement('div');
+ label.className = 'settings-label';
+ label.textContent = I18n.t(def.label);
+ labelWrap.appendChild(label);
+
+ if (def.description) {
+ var desc = document.createElement('div');
+ desc.className = 'settings-description';
+ desc.textContent = I18n.t(def.description);
+ labelWrap.appendChild(desc);
+ }
+
+ row.appendChild(labelWrap);
+
+ var inputWrap = document.createElement('div');
+ inputWrap.style.display = 'flex';
+ inputWrap.style.alignItems = 'center';
+ inputWrap.style.gap = '8px';
+
+ var ariaLabel = I18n.t(def.label) + (def.description ? '. ' + I18n.t(def.description) : '');
+ function formatSettingValue(raw) {
+ if (Array.isArray(raw)) return raw.join(', ');
+ if (raw === null || raw === undefined) return '';
+ return String(raw);
+ }
+
+ var activeValueText = formatSettingValue(activeValue);
+ var placeholderText = activeValueText ? I18n.t('settings.envValue', { value: activeValueText }) : (def.placeholder || I18n.t('settings.envDefault'));
+
+ if (def.type === 'boolean') {
+ var boolSel = document.createElement('select');
+ boolSel.className = 'settings-select';
+ boolSel.setAttribute('data-setting-key', def.key);
+ boolSel.setAttribute('aria-label', ariaLabel);
+ var boolDefault = document.createElement('option');
+ boolDefault.value = '';
+ boolDefault.textContent = activeValue !== undefined && activeValue !== null
+ ? '\u2014 ' + I18n.t('settings.envValue', { value: String(activeValue) }) + ' \u2014'
+ : '\u2014 ' + I18n.t('settings.useEnvDefault') + ' \u2014';
+ if (value === null || value === undefined) boolDefault.selected = true;
+ boolSel.appendChild(boolDefault);
+ var boolOn = document.createElement('option');
+ boolOn.value = 'true';
+ boolOn.textContent = I18n.t('settings.on');
+ if (value === true) boolOn.selected = true;
+ boolSel.appendChild(boolOn);
+ var boolOff = document.createElement('option');
+ boolOff.value = 'false';
+ boolOff.textContent = I18n.t('settings.off');
+ if (value === false) boolOff.selected = true;
+ boolSel.appendChild(boolOff);
+ boolSel.addEventListener('change', (function(k, el) {
+ return function() {
+ if (el.value === '') saveSetting(k, null);
+ else saveSetting(k, el.value === 'true');
+ };
+ })(def.key, boolSel));
+ inputWrap.appendChild(boolSel);
+ } else if (def.type === 'select' && def.options) {
+ var sel = document.createElement('select');
+ sel.className = 'settings-select';
+ sel.setAttribute('data-setting-key', def.key);
+ sel.setAttribute('aria-label', ariaLabel);
+ var emptyOpt = document.createElement('option');
+ emptyOpt.value = '';
+ emptyOpt.textContent = activeValue ? '\u2014 ' + I18n.t('settings.envValue', { value: activeValue }) + ' \u2014' : '\u2014 ' + I18n.t('settings.useEnvDefault') + ' \u2014';
+ if (!value && value !== false && value !== 0) emptyOpt.selected = true;
+ sel.appendChild(emptyOpt);
+ for (var oi = 0; oi < def.options.length; oi++) {
+ var opt = document.createElement('option');
+ opt.value = def.options[oi];
+ opt.textContent = def.options[oi];
+ if (String(value) === def.options[oi]) opt.selected = true;
+ sel.appendChild(opt);
+ }
+ sel.addEventListener('change', (function(k, el) {
+ return function() { saveSetting(k, el.value === '' ? null : el.value); };
+ })(def.key, sel));
+ inputWrap.appendChild(sel);
+ } else if (def.type === 'number') {
+ var numInp = document.createElement('input');
+ numInp.type = 'number';
+ numInp.step = '1';
+ numInp.className = 'settings-input';
+ numInp.setAttribute('aria-label', ariaLabel);
+ numInp.value = (value === null || value === undefined) ? '' : value;
+ if (!value && value !== 0) numInp.placeholder = placeholderText;
+ if (def.min !== undefined) numInp.min = def.min;
+ if (def.max !== undefined) numInp.max = def.max;
+ numInp.addEventListener('change', (function(k, el) {
+ return function() {
+ if (el.value === '') return saveSetting(k, null);
+ var parsed = parseInt(el.value, 10);
+ if (isNaN(parsed)) return;
+ el.value = parsed;
+ saveSetting(k, parsed);
+ };
+ })(def.key, numInp));
+ inputWrap.appendChild(numInp);
+ } else if (def.type === 'list') {
+ var listInp = document.createElement('input');
+ listInp.type = 'text';
+ listInp.className = 'settings-input';
+ listInp.setAttribute('aria-label', ariaLabel);
+ var listValue = '';
+ if (Array.isArray(value)) listValue = value.join(', ');
+ else if (typeof value === 'string') listValue = value;
+ listInp.value = listValue;
+ if (!listValue) listInp.placeholder = placeholderText;
+ listInp.addEventListener('change', (function(k, el) {
+ return function() {
+ if (el.value.trim() === '') return saveSetting(k, null);
+ var items = el.value.split(/[\n,]/).map(function(item) {
+ return item.trim();
+ }).filter(Boolean);
+ saveSetting(k, items);
+ };
+ })(def.key, listInp));
+ inputWrap.appendChild(listInp);
+ } else {
+ var textInp = document.createElement('input');
+ textInp.type = 'text';
+ textInp.className = 'settings-input';
+ textInp.setAttribute('aria-label', ariaLabel);
+ textInp.value = (value === null || value === undefined) ? '' : String(value);
+ if (!value) textInp.placeholder = placeholderText;
+ // Attach datalist for autocomplete suggestions (e.g., model list)
+ if (def.suggestions && def.suggestions.length > 0) {
+ var dlId = 'dl-' + def.key.replace(/\./g, '-');
+ var dl = document.createElement('datalist');
+ dl.id = dlId;
+ for (var di = 0; di < def.suggestions.length; di++) {
+ var dlOpt = document.createElement('option');
+ dlOpt.value = def.suggestions[di];
+ dl.appendChild(dlOpt);
+ }
+ textInp.setAttribute('list', dlId);
+ inputWrap.appendChild(dl);
+ }
+ textInp.addEventListener('change', (function(k, el) {
+ return function() { saveSetting(k, el.value === '' ? null : el.value); };
+ })(def.key, textInp));
+ inputWrap.appendChild(textInp);
+ }
+
+ var saved = document.createElement('span');
+ saved.className = 'settings-saved-indicator';
+ saved.textContent = '\u2713 ' + I18n.t('settings.saved');
+ saved.setAttribute('data-key', def.key);
+ saved.setAttribute('role', 'status');
+ saved.setAttribute('aria-live', 'polite');
+ inputWrap.appendChild(saved);
+
+ row.appendChild(inputWrap);
+ return row;
+}
+
+var RESTART_REQUIRED_KEYS = ['llm_backend', 'selected_model', 'ollama_base_url', 'openai_compatible_base_url',
+ 'bedrock_region', 'bedrock_cross_region', 'bedrock_profile', 'embeddings.enabled', 'embeddings.provider', 'embeddings.model',
+ 'agent.auto_approve_tools', 'tunnel.provider', 'tunnel.public_url', 'gateway.rate_limit', 'gateway.max_connections'];
+
+var _settingsSavedTimers = {};
+
+function saveSetting(key, value) {
+ var method = (value === null || value === undefined) ? 'DELETE' : 'PUT';
+ var opts = { method: method };
+ if (method === 'PUT') opts.body = { value: value };
+ apiFetch('/api/settings/' + encodeURIComponent(key), opts).then(function() {
+ var indicator = document.querySelector('.settings-saved-indicator[data-key="' + key + '"]');
+ if (indicator) {
+ if (_settingsSavedTimers[key]) clearTimeout(_settingsSavedTimers[key]);
+ indicator.classList.add('visible');
+ _settingsSavedTimers[key] = setTimeout(function() { indicator.classList.remove('visible'); }, 2000);
+ }
+ // Show restart banner for inference settings
+ if (RESTART_REQUIRED_KEYS.indexOf(key) !== -1) {
+ showRestartBanner();
+ }
+ }).catch(function(err) {
+ showToast('Failed to save ' + key + ': ' + err.message, 'error');
+ });
+}
+
+function showRestartBanner() {
+ var container = document.querySelector('.settings-content');
+ if (!container || container.querySelector('.restart-banner')) return;
+ var banner = document.createElement('div');
+ banner.className = 'restart-banner';
+ banner.setAttribute('role', 'alert');
+ var textSpan = document.createElement('span');
+ textSpan.className = 'restart-banner-text';
+ textSpan.textContent = '\u26A0\uFE0F ' + I18n.t('settings.restartRequired');
+ banner.appendChild(textSpan);
+ var restartBtn = document.createElement('button');
+ restartBtn.className = 'restart-banner-btn';
+ restartBtn.textContent = I18n.t('settings.restartNow');
+ restartBtn.addEventListener('click', function() { triggerRestart(); });
+ banner.appendChild(restartBtn);
+ container.insertBefore(banner, container.firstChild);
+}
+
+function loadMcpServers() {
+ var mcpList = document.getElementById('mcp-servers-list');
+ mcpList.innerHTML = renderCardsSkeleton(2);
+
+ Promise.all([
+ apiFetch('/api/extensions').catch(function() { return { extensions: [] }; }),
+ apiFetch('/api/extensions/registry').catch(function() { return { entries: [] }; }),
+ ]).then(function(results) {
+ var extData = results[0];
+ var registryData = results[1];
+ var mcpEntries = (registryData.entries || []).filter(function(e) { return e.kind === 'mcp_server'; });
+ var installedMcp = (extData.extensions || []).filter(function(e) { return e.kind === 'mcp_server'; });
+
+ mcpList.innerHTML = '';
+ var renderedNames = {};
+
+ // Registry entries (cross-referenced with installed)
+ for (var i = 0; i < mcpEntries.length; i++) {
+ renderedNames[mcpEntries[i].name] = true;
+ var installedExt = installedMcp.find(function(e) { return e.name === mcpEntries[i].name; });
+ mcpList.appendChild(renderMcpServerCard(mcpEntries[i], installedExt));
+ }
+
+ // Custom installed MCP servers not in registry
+ for (var j = 0; j < installedMcp.length; j++) {
+ if (!renderedNames[installedMcp[j].name]) {
+ mcpList.appendChild(renderExtensionCard(installedMcp[j]));
+ }
+ }
+
+ if (mcpList.children.length === 0) {
+ mcpList.innerHTML = '' + I18n.t('mcp.noServers') + '
';
+ }
+ }).catch(function(err) {
+ mcpList.innerHTML = '' + I18n.t('common.loadFailed') + ': '
+ + escapeHtml(err.message) + '
';
+ });
+}
+
+function loadChannelsStatus() {
+ var container = document.getElementById('settings-channels-content');
+ container.innerHTML = renderCardsSkeleton(4);
+
+ Promise.all([
+ apiFetch('/api/gateway/status').catch(function() { return {}; }),
+ apiFetch('/api/extensions').catch(function() { return { extensions: [] }; }),
+ apiFetch('/api/extensions/registry').catch(function() { return { entries: [] }; }),
+ ]).then(function(results) {
+ var status = results[0];
+ var extensions = results[1].extensions || [];
+ var registry = results[2].entries || [];
+
+ container.innerHTML = '';
+
+ // Built-in Channels section
+ var builtinSection = document.createElement('div');
+ builtinSection.className = 'extensions-section';
+ var builtinTitle = document.createElement('h3');
+ builtinTitle.textContent = I18n.t('channels.builtin');
+ builtinSection.appendChild(builtinTitle);
+ var builtinList = document.createElement('div');
+ builtinList.className = 'extensions-list';
+
+ builtinList.appendChild(renderBuiltinChannelCard(
+ I18n.t('channels.webGateway'),
+ I18n.t('channels.webGatewayDesc'),
+ true,
+ 'SSE: ' + (status.sse_connections || 0) + ' \u00B7 WS: ' + (status.ws_connections || 0)
+ ));
+
+ var enabledChannels = status.enabled_channels || [];
+
+ builtinList.appendChild(renderBuiltinChannelCard(
+ I18n.t('channels.httpWebhook'),
+ I18n.t('channels.httpWebhookDesc'),
+ enabledChannels.indexOf('http') !== -1,
+ I18n.t('channels.configureVia', { env: 'ENABLE_HTTP=true' })
+ ));
+
+ builtinList.appendChild(renderBuiltinChannelCard(
+ I18n.t('channels.cli'),
+ I18n.t('channels.cliDesc'),
+ enabledChannels.indexOf('cli') !== -1,
+ I18n.t('channels.runWith', { cmd: 'ironclaw run --cli' })
+ ));
+
+ builtinList.appendChild(renderBuiltinChannelCard(
+ I18n.t('channels.repl'),
+ I18n.t('channels.replDesc'),
+ enabledChannels.indexOf('repl') !== -1,
+ I18n.t('channels.runWith', { cmd: 'ironclaw run --repl' })
+ ));
+
+ builtinSection.appendChild(builtinList);
+ container.appendChild(builtinSection);
+
+ // Messaging Channels section — use extension cards with full stepper/pairing UI
+ var channelEntries = registry.filter(function(e) {
+ return e.kind === 'wasm_channel' || e.kind === 'channel';
+ });
+ var installedChannels = extensions.filter(function(e) {
+ return e.kind === 'wasm_channel';
+ });
+
+ if (channelEntries.length > 0 || installedChannels.length > 0) {
+ var messagingSection = document.createElement('div');
+ messagingSection.className = 'extensions-section';
+ var messagingTitle = document.createElement('h3');
+ messagingTitle.textContent = I18n.t('channels.messaging');
+ messagingSection.appendChild(messagingTitle);
+ var messagingList = document.createElement('div');
+ messagingList.className = 'extensions-list';
+
+ var renderedNames = {};
+
+ // Registry entries: show full ext card if installed, available card if not
+ for (var i = 0; i < channelEntries.length; i++) {
+ var entry = channelEntries[i];
+ renderedNames[entry.name] = true;
+ var installed = null;
+ for (var k = 0; k < installedChannels.length; k++) {
+ if (installedChannels[k].name === entry.name) { installed = installedChannels[k]; break; }
+ }
+ if (installed) {
+ messagingList.appendChild(renderExtensionCard(installed));
+ } else {
+ messagingList.appendChild(renderAvailableExtensionCard(entry));
+ }
+ }
+
+ // Installed channels not in registry (custom installs)
+ for (var j = 0; j < installedChannels.length; j++) {
+ if (!renderedNames[installedChannels[j].name]) {
+ messagingList.appendChild(renderExtensionCard(installedChannels[j]));
+ }
+ }
+
+ messagingSection.appendChild(messagingList);
+ container.appendChild(messagingSection);
+ }
+ });
+}
+
+function renderBuiltinChannelCard(name, description, active, detail) {
+ var card = document.createElement('div');
+ card.className = 'ext-card ' + (active ? 'state-active' : 'state-inactive');
+
+ var header = document.createElement('div');
+ header.className = 'ext-header';
+
+ var nameEl = document.createElement('span');
+ nameEl.className = 'ext-name';
+ nameEl.textContent = name;
+ header.appendChild(nameEl);
+
+ var kindEl = document.createElement('span');
+ kindEl.className = 'ext-kind kind-builtin';
+ kindEl.textContent = I18n.t('ext.builtin');
+ header.appendChild(kindEl);
+
+ var statusDot = document.createElement('span');
+ statusDot.className = 'ext-auth-dot ' + (active ? 'authed' : 'unauthed');
+ statusDot.title = active ? I18n.t('ext.active') : I18n.t('ext.inactive');
+ header.appendChild(statusDot);
+
+ card.appendChild(header);
+
+ var desc = document.createElement('div');
+ desc.className = 'ext-desc';
+ desc.textContent = description;
+ card.appendChild(desc);
+
+ if (detail) {
+ var detailEl = document.createElement('div');
+ detailEl.className = 'ext-url';
+ detailEl.textContent = detail;
+ card.appendChild(detailEl);
+ }
+
+ var actions = document.createElement('div');
+ actions.className = 'ext-actions';
+ var label = document.createElement('span');
+ label.className = 'ext-active-label';
+ label.textContent = active ? I18n.t('ext.active') : I18n.t('ext.inactive');
+ actions.appendChild(label);
+ card.appendChild(actions);
+
+ return card;
+}
+
+// --- Networking Settings ---
+
+var NETWORKING_SETTINGS = [
+ {
+ group: 'cfg.group.tunnel',
+ settings: [
+ { key: 'tunnel.provider', label: 'cfg.tunnel_provider.label', description: 'cfg.tunnel_provider.desc',
+ type: 'select', options: ['none', 'cloudflare', 'ngrok', 'tailscale', 'custom'] },
+ { key: 'tunnel.public_url', label: 'cfg.tunnel_public_url.label', description: 'cfg.tunnel_public_url.desc', type: 'text' },
+ ]
+ },
+ {
+ group: 'cfg.group.gateway',
+ settings: [
+ { key: 'gateway.rate_limit', label: 'cfg.gateway_rate_limit.label', description: 'cfg.gateway_rate_limit.desc', type: 'number', min: 0 },
+ { key: 'gateway.max_connections', label: 'cfg.gateway_max_connections.label', description: 'cfg.gateway_max_connections.desc', type: 'number', min: 0 },
+ ]
+ },
+];
+
+function loadNetworkingSettings() {
+ var container = document.getElementById('settings-networking-content');
+ container.innerHTML = renderSettingsSkeleton(4);
+
+ apiFetch('/api/settings/export').then(function(data) {
+ var settings = data.settings || {};
+ container.innerHTML = '';
+ renderStructuredSettingsInto(container, NETWORKING_SETTINGS, settings, {});
+ }).catch(function(err) {
+ container.innerHTML = '' + I18n.t('common.loadFailed') + ': '
+ + escapeHtml(err.message) + '
';
+ });
+}
+
// --- Toasts ---
function showToast(message, type) {
@@ -4617,6 +5467,8 @@ document.getElementById('wasm-install-btn').addEventListener('click', () => inst
document.getElementById('mcp-add-btn').addEventListener('click', () => addMcpServer());
document.getElementById('skill-search-btn').addEventListener('click', () => searchClawHub());
document.getElementById('skill-install-btn').addEventListener('click', () => installSkillFromForm());
+document.getElementById('settings-export-btn').addEventListener('click', () => exportSettings());
+document.getElementById('settings-import-btn').addEventListener('click', () => importSettings());
// --- Delegated Event Handlers (for dynamically generated HTML) ---
@@ -4685,3 +5537,125 @@ document.addEventListener('click', function(e) {
document.getElementById('language-btn').addEventListener('click', function() {
if (typeof toggleLanguageMenu === 'function') toggleLanguageMenu();
});
+
+// --- Confirmation Modal ---
+
+var _confirmModalCallback = null;
+
+function showConfirmModal(title, message, onConfirm, confirmLabel, confirmClass) {
+ var modal = document.getElementById('confirm-modal');
+ document.getElementById('confirm-modal-title').textContent = title;
+ document.getElementById('confirm-modal-message').textContent = message || '';
+ document.getElementById('confirm-modal-message').style.display = message ? '' : 'none';
+ var btn = document.getElementById('confirm-modal-btn');
+ btn.textContent = confirmLabel || I18n.t('btn.confirm');
+ btn.className = confirmClass || 'btn-danger';
+ _confirmModalCallback = onConfirm;
+ modal.style.display = 'flex';
+ btn.focus();
+}
+
+function closeConfirmModal() {
+ document.getElementById('confirm-modal').style.display = 'none';
+ _confirmModalCallback = null;
+}
+
+document.getElementById('confirm-modal-btn').addEventListener('click', function() {
+ if (_confirmModalCallback) _confirmModalCallback();
+ closeConfirmModal();
+});
+document.getElementById('confirm-modal-cancel-btn').addEventListener('click', closeConfirmModal);
+document.getElementById('confirm-modal').addEventListener('click', function(e) {
+ if (e.target === this) closeConfirmModal();
+});
+document.addEventListener('keydown', function(e) {
+ if (e.key === 'Escape' && document.getElementById('confirm-modal').style.display === 'flex') {
+ closeConfirmModal();
+ }
+});
+
+// --- Settings Import/Export ---
+
+function exportSettings() {
+ apiFetch('/api/settings/export').then(function(data) {
+ var blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
+ var url = URL.createObjectURL(blob);
+ var a = document.createElement('a');
+ a.href = url;
+ a.download = 'ironclaw-settings.json';
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ showToast(I18n.t('settings.exportSuccess'), 'success');
+ }).catch(function(err) {
+ showToast(I18n.t('settings.exportFailed', { message: err.message }), 'error');
+ });
+}
+
+function importSettings() {
+ var input = document.createElement('input');
+ input.type = 'file';
+ input.accept = '.json,application/json';
+ input.addEventListener('change', function() {
+ if (!input.files || !input.files[0]) return;
+ var reader = new FileReader();
+ reader.onload = function() {
+ try {
+ var data = JSON.parse(reader.result);
+ apiFetch('/api/settings/import', {
+ method: 'POST',
+ body: data,
+ }).then(function() {
+ showToast(I18n.t('settings.importSuccess'), 'success');
+ loadSettingsSubtab(currentSettingsSubtab);
+ }).catch(function(err) {
+ showToast(I18n.t('settings.importFailed', { message: err.message }), 'error');
+ });
+ } catch (e) {
+ showToast(I18n.t('settings.importFailed', { message: e.message }), 'error');
+ }
+ };
+ reader.readAsText(input.files[0]);
+ });
+ input.click();
+}
+
+// --- Settings Search ---
+
+document.getElementById('settings-search-input').addEventListener('input', function() {
+ var query = this.value.toLowerCase();
+ var activePanel = document.querySelector('.settings-subpanel.active');
+ if (!activePanel) return;
+ var rows = activePanel.querySelectorAll('.settings-row');
+ if (rows.length === 0) return;
+ var visibleCount = 0;
+ rows.forEach(function(row) {
+ var text = row.textContent.toLowerCase();
+ if (query === '' || text.indexOf(query) !== -1) {
+ row.classList.remove('search-hidden');
+ if (!row.classList.contains('hidden')) visibleCount++;
+ } else {
+ row.classList.add('search-hidden');
+ }
+ });
+ // Show/hide group titles based on visible children
+ var groups = activePanel.querySelectorAll('.settings-group');
+ groups.forEach(function(group) {
+ var visibleRows = group.querySelectorAll('.settings-row:not(.search-hidden):not(.hidden)');
+ if (visibleRows.length === 0 && query !== '') {
+ group.style.display = 'none';
+ } else {
+ group.style.display = '';
+ }
+ });
+ // Show/hide empty state
+ var existingEmpty = activePanel.querySelector('.settings-search-empty');
+ if (existingEmpty) existingEmpty.remove();
+ if (query !== '' && visibleCount === 0) {
+ var empty = document.createElement('div');
+ empty.className = 'settings-search-empty';
+ empty.textContent = I18n.t('settings.noMatchingSettings', { query: this.value });
+ activePanel.appendChild(empty);
+ }
+});
diff --git a/src/channels/web/static/i18n/en.js b/src/channels/web/static/i18n/en.js
index 49bec762..6029075d 100644
--- a/src/channels/web/static/i18n/en.js
+++ b/src/channels/web/static/i18n/en.js
@@ -24,14 +24,26 @@ I18n.register('en', {
'restart.progressSubtitle': 'Please wait for the process to restart...',
'restart.checkLogs': 'Check the Logs tab for details after restart completes.',
+ // Theme
+ 'theme.tooltipDark': 'Theme: Dark (click for Light)',
+ 'theme.tooltipLight': 'Theme: Light (click for System)',
+ 'theme.tooltipSystem': 'Theme: System (click for Dark)',
+ 'theme.announce': 'Theme: {mode}',
+
// Tabs
'tab.chat': 'Chat',
'tab.memory': 'Memory',
'tab.jobs': 'Jobs',
'tab.routines': 'Routines',
+ 'tab.settings': 'Settings',
'tab.extensions': 'Extensions',
'tab.skills': 'Skills',
'tab.logs': 'Logs',
+ 'settings.inference': 'Inference',
+ 'settings.agent': 'Agent',
+ 'settings.channels': 'Channels',
+ 'settings.networking': 'Networking',
+ 'settings.mcp': 'MCP',
// Status
'status.connected': 'Connected',
@@ -131,10 +143,10 @@ I18n.register('en', {
// Extensions Tab
'extensions.installed': 'Installed Extensions',
- 'extensions.available': 'Available WASM Extensions',
- 'extensions.installWasm': 'Install WASM Extension',
+ 'extensions.available': 'Available Extensions',
+ 'extensions.installWasm': 'Install Extension',
'extensions.noInstalled': 'No extensions installed',
- 'extensions.noAvailable': 'No additional WASM extensions available',
+ 'extensions.noAvailable': 'No additional extensions available',
'extensions.loading': 'Loading...',
'extensions.install': 'Install',
'extensions.installing': 'Installing...',
@@ -156,13 +168,8 @@ I18n.register('en', {
'mcp.addCustom': 'Add Custom MCP Server',
'mcp.add': 'Add',
'mcp.addedSuccess': 'Added MCP server {name}',
-
- // Registered Tools
- 'tools.registered': 'Registered Tools',
- 'tools.name': 'Name',
- 'tools.description': 'Description',
- 'tools.empty': 'No tools registered',
-
+
+
// Skills Tab
'skills.installed': 'Installed Skills',
'skills.noInstalled': 'No skills installed',
@@ -302,6 +309,7 @@ I18n.register('en', {
// Common
'common.loading': 'Loading...',
+ 'common.loadFailed': 'Failed to load',
'common.noData': 'No data',
'common.search': 'Search',
'common.add': 'Add',
@@ -328,6 +336,8 @@ I18n.register('en', {
// Extensions
'ext.active': 'Active',
+ 'ext.inactive': 'Inactive',
+ 'ext.builtin': 'Built-in',
'ext.remove': 'Remove',
'ext.install': 'Install',
'ext.installing': 'Installing...',
@@ -355,4 +365,160 @@ I18n.register('en', {
'config.autoGenerate': 'Auto-generated if empty',
'config.save': 'Save',
'config.cancel': 'Cancel',
+
+ // Settings toolbar
+ 'settings.export': 'Export',
+ 'settings.import': 'Import',
+ 'settings.searchPlaceholder': 'Search settings...',
+ 'settings.exportSuccess': 'Settings exported',
+ 'settings.exportFailed': 'Export failed: {message}',
+ 'settings.importSuccess': 'Settings imported successfully',
+ 'settings.importFailed': 'Import failed: {message}',
+ 'settings.restartRequired': 'Restart required for changes to take effect.',
+ 'settings.restartNow': 'Restart Now',
+ 'settings.noMatchingSettings': 'No settings matching "{query}"',
+ 'settings.noSettings': 'No settings found',
+ 'settings.saved': 'Saved',
+ 'settings.on': 'On',
+ 'settings.off': 'Off',
+ 'settings.envValue': 'env: {value}',
+ 'settings.envDefault': 'env default',
+ 'settings.useEnvDefault': 'use env default',
+
+ // Settings groups
+ 'cfg.group.llm': 'LLM Provider',
+ 'cfg.group.embeddings': 'Embeddings',
+ 'cfg.group.agent': 'Agent',
+ 'cfg.group.heartbeat': 'Heartbeat',
+ 'cfg.group.sandbox': 'Sandbox',
+ 'cfg.group.routines': 'Routines',
+ 'cfg.group.safety': 'Safety',
+ 'cfg.group.skills': 'Skills',
+ 'cfg.group.search': 'Search',
+ 'cfg.group.tunnel': 'Tunnel',
+ 'cfg.group.gateway': 'Gateway',
+
+ // Inference settings
+ 'cfg.llm_backend.label': 'Backend',
+ 'cfg.llm_backend.desc': 'LLM inference provider',
+ 'cfg.selected_model.label': 'Model',
+ 'cfg.selected_model.desc': 'Model name or ID for the selected backend',
+ 'cfg.ollama_base_url.label': 'Ollama URL',
+ 'cfg.ollama_base_url.desc': 'Base URL for Ollama API',
+ 'cfg.openai_compatible_base_url.label': 'OpenAI-compatible URL',
+ 'cfg.openai_compatible_base_url.desc': 'Base URL for OpenAI-compatible API',
+ 'cfg.bedrock_region.label': 'Bedrock Region',
+ 'cfg.bedrock_region.desc': 'AWS region for Bedrock',
+ 'cfg.bedrock_cross_region.label': 'Cross-Region',
+ 'cfg.bedrock_cross_region.desc': 'Enable cross-region inference',
+ 'cfg.bedrock_profile.label': 'AWS Profile',
+ 'cfg.bedrock_profile.desc': 'AWS profile for Bedrock auth',
+ 'cfg.embeddings_enabled.label': 'Enabled',
+ 'cfg.embeddings_enabled.desc': 'Enable vector embeddings for memory search',
+ 'cfg.embeddings_provider.label': 'Provider',
+ 'cfg.embeddings_provider.desc': 'Embeddings API provider',
+ 'cfg.embeddings_model.label': 'Model',
+ 'cfg.embeddings_model.desc': 'Embedding model name',
+
+ // Agent settings
+ 'cfg.agent_name.label': 'Name',
+ 'cfg.agent_name.desc': 'Agent display name',
+ 'cfg.agent_max_parallel_jobs.label': 'Max Parallel Jobs',
+ 'cfg.agent_max_parallel_jobs.desc': 'Maximum concurrent background jobs',
+ 'cfg.agent_job_timeout.label': 'Job Timeout',
+ 'cfg.agent_job_timeout.desc': 'Max duration per job in seconds',
+ 'cfg.agent_max_tool_iterations.label': 'Max Tool Iterations',
+ 'cfg.agent_max_tool_iterations.desc': 'Max tool calls per turn',
+ 'cfg.agent_use_planning.label': 'Planning',
+ 'cfg.agent_use_planning.desc': 'Enable multi-step planning before execution',
+ 'cfg.agent_auto_approve.label': 'Auto-approve Tools',
+ 'cfg.agent_auto_approve.desc': 'Skip manual approval for tool calls',
+ 'cfg.agent_timezone.label': 'Timezone',
+ 'cfg.agent_timezone.desc': 'Default timezone (IANA)',
+ 'cfg.agent_session_idle.label': 'Session Idle Timeout',
+ 'cfg.agent_session_idle.desc': 'Seconds before idle session expires',
+ 'cfg.agent_stuck_threshold.label': 'Stuck Threshold',
+ 'cfg.agent_stuck_threshold.desc': 'Seconds before a job is considered stuck',
+ 'cfg.agent_max_repair.label': 'Max Repair Attempts',
+ 'cfg.agent_max_repair.desc': 'Auto-recovery attempts for stuck jobs',
+ 'cfg.agent_max_cost.label': 'Max Daily Cost',
+ 'cfg.agent_max_cost.desc': 'Daily LLM spend cap in cents (0 = unlimited)',
+ 'cfg.agent_max_actions.label': 'Max Actions/Hour',
+ 'cfg.agent_max_actions.desc': 'Hourly tool call rate limit (0 = unlimited)',
+ 'cfg.agent_allow_local.label': 'Allow Local Tools',
+ 'cfg.agent_allow_local.desc': 'Enable local filesystem tool execution',
+
+ // Heartbeat settings
+ 'cfg.heartbeat_enabled.label': 'Enabled',
+ 'cfg.heartbeat_enabled.desc': 'Run periodic background checks',
+ 'cfg.heartbeat_interval.label': 'Interval',
+ 'cfg.heartbeat_interval.desc': 'Seconds between heartbeats (default: 1800)',
+ 'cfg.heartbeat_notify_channel.label': 'Notify Channel',
+ 'cfg.heartbeat_notify_channel.desc': 'Channel to send heartbeat findings to',
+ 'cfg.heartbeat_notify_user.label': 'Notify User',
+ 'cfg.heartbeat_notify_user.desc': 'User ID to notify',
+ 'cfg.heartbeat_quiet_start.label': 'Quiet Hours Start',
+ 'cfg.heartbeat_quiet_start.desc': 'Hour (0-23) to stop heartbeats',
+ 'cfg.heartbeat_quiet_end.label': 'Quiet Hours End',
+ 'cfg.heartbeat_quiet_end.desc': 'Hour (0-23) to resume heartbeats',
+ 'cfg.heartbeat_timezone.label': 'Timezone',
+ 'cfg.heartbeat_timezone.desc': 'Timezone for quiet hours (IANA)',
+
+ // Sandbox settings
+ 'cfg.sandbox_enabled.label': 'Enabled',
+ 'cfg.sandbox_enabled.desc': 'Enable Docker sandbox for background jobs',
+ 'cfg.sandbox_policy.label': 'Policy',
+ 'cfg.sandbox_policy.desc': 'Sandbox security policy',
+ 'cfg.sandbox_timeout.label': 'Timeout',
+ 'cfg.sandbox_timeout.desc': 'Max job duration in seconds',
+ 'cfg.sandbox_memory.label': 'Memory Limit',
+ 'cfg.sandbox_memory.desc': 'Container memory limit (MB)',
+ 'cfg.sandbox_image.label': 'Docker Image',
+ 'cfg.sandbox_image.desc': 'Container image for sandbox jobs',
+
+ // Routines settings
+ 'cfg.routines_max_concurrent.label': 'Max Concurrent',
+ 'cfg.routines_max_concurrent.desc': 'Maximum routines running simultaneously',
+ 'cfg.routines_cooldown.label': 'Default Cooldown',
+ 'cfg.routines_cooldown.desc': 'Minimum seconds between routine fires',
+
+ // Safety settings
+ 'cfg.safety_max_output.label': 'Max Output Length',
+ 'cfg.safety_max_output.desc': 'Maximum output tokens per response',
+ 'cfg.safety_injection_check.label': 'Injection Check',
+ 'cfg.safety_injection_check.desc': 'Enable prompt injection detection',
+
+ // Skills settings
+ 'cfg.skills_max_active.label': 'Max Active Skills',
+ 'cfg.skills_max_active.desc': 'Maximum skills active simultaneously',
+ 'cfg.skills_max_tokens.label': 'Max Context Tokens',
+ 'cfg.skills_max_tokens.desc': 'Token budget for skill prompts',
+
+ // Search settings
+ 'cfg.search_fusion.label': 'Fusion Strategy',
+ 'cfg.search_fusion.desc': 'Hybrid search ranking method',
+
+ // Networking settings
+ 'cfg.tunnel_provider.label': 'Provider',
+ 'cfg.tunnel_provider.desc': 'Public URL tunnel provider',
+ 'cfg.tunnel_public_url.label': 'Public URL',
+ 'cfg.tunnel_public_url.desc': 'Static public URL (if not using tunnel provider)',
+ 'cfg.gateway_rate_limit.label': 'Rate Limit',
+ 'cfg.gateway_rate_limit.desc': 'Max chat messages per minute',
+ 'cfg.gateway_max_connections.label': 'Max Connections',
+ 'cfg.gateway_max_connections.desc': 'Max simultaneous SSE/WS connections',
+
+ // Channels subtab
+ 'channels.builtin': 'Built-in Channels',
+ 'channels.messaging': 'Messaging Channels',
+ 'channels.webGateway': 'Web Gateway',
+ 'channels.webGatewayDesc': 'Browser-based chat interface',
+ 'channels.httpWebhook': 'HTTP Webhook',
+ 'channels.httpWebhookDesc': 'Incoming webhook endpoint for external integrations',
+ 'channels.cli': 'CLI',
+ 'channels.cliDesc': 'Terminal UI with Ratatui',
+ 'channels.repl': 'REPL',
+ 'channels.replDesc': 'Simple read-eval-print loop for testing',
+ 'channels.configureVia': 'Configure via {env}',
+ 'channels.runWith': 'Run with: {cmd}',
});
diff --git a/src/channels/web/static/i18n/zh-CN.js b/src/channels/web/static/i18n/zh-CN.js
index d31cc0df..480724c9 100644
--- a/src/channels/web/static/i18n/zh-CN.js
+++ b/src/channels/web/static/i18n/zh-CN.js
@@ -24,14 +24,26 @@ I18n.register('zh-CN', {
'restart.progressSubtitle': '请等待进程重启...',
'restart.checkLogs': '重启完成后,请查看日志标签页了解详情。',
+ // 主题
+ 'theme.tooltipDark': '主题:深色(点击切换浅色)',
+ 'theme.tooltipLight': '主题:浅色(点击切换跟随系统)',
+ 'theme.tooltipSystem': '主题:跟随系统(点击切换深色)',
+ 'theme.announce': '主题:{mode}',
+
// 标签页
'tab.chat': '聊天',
'tab.memory': '记忆',
'tab.jobs': '任务',
'tab.routines': '定时任务',
+ 'tab.settings': '设置',
'tab.extensions': '扩展',
'tab.skills': '技能',
'tab.logs': '日志',
+ 'settings.inference': '推理',
+ 'settings.agent': '代理',
+ 'settings.channels': '频道',
+ 'settings.networking': '网络',
+ 'settings.mcp': 'MCP',
// 状态
'status.connected': '已连接',
@@ -131,10 +143,10 @@ I18n.register('zh-CN', {
// 扩展标签页
'extensions.installed': '已安装扩展',
- 'extensions.available': '可用 WASM 扩展',
- 'extensions.installWasm': '安装 WASM 扩展',
+ 'extensions.available': '可用扩展',
+ 'extensions.installWasm': '安装扩展',
'extensions.noInstalled': '没有安装扩展',
- 'extensions.noAvailable': '没有其他可用的 WASM 扩展',
+ 'extensions.noAvailable': '没有其他可用扩展',
'extensions.loading': '加载中...',
'extensions.install': '安装',
'extensions.installing': '安装中...',
@@ -156,13 +168,8 @@ I18n.register('zh-CN', {
'mcp.addCustom': '添加自定义 MCP 服务器',
'mcp.add': '添加',
'mcp.addedSuccess': '已添加 MCP 服务器 {name}',
-
- // 注册工具
- 'tools.registered': '注册工具',
- 'tools.name': '名称',
- 'tools.description': '描述',
- 'tools.empty': '没有注册工具',
-
+
+
// 技能标签页
'skills.installed': '已安装技能',
'skills.noInstalled': '没有安装技能',
@@ -302,6 +309,7 @@ I18n.register('zh-CN', {
// 通用
'common.loading': '加载中...',
+ 'common.loadFailed': '加载失败',
'common.noData': '暂无数据',
'common.search': '搜索',
'common.add': '添加',
@@ -328,6 +336,8 @@ I18n.register('zh-CN', {
// 扩展
'ext.active': '已激活',
+ 'ext.inactive': '未激活',
+ 'ext.builtin': '内置',
'ext.remove': '移除',
'ext.install': '安装',
'ext.installing': '安装中...',
@@ -354,4 +364,160 @@ I18n.register('zh-CN', {
'config.autoGenerate': '如果为空则自动生成',
'config.save': '保存',
'config.cancel': '取消',
+
+ // 设置工具栏
+ 'settings.export': '导出',
+ 'settings.import': '导入',
+ 'settings.searchPlaceholder': '搜索设置...',
+ 'settings.exportSuccess': '设置已导出',
+ 'settings.exportFailed': '导出失败: {message}',
+ 'settings.importSuccess': '设置导入成功',
+ 'settings.importFailed': '导入失败: {message}',
+ 'settings.restartRequired': '需要重启才能使更改生效。',
+ 'settings.restartNow': '立即重启',
+ 'settings.noMatchingSettings': '没有匹配 "{query}" 的设置',
+ 'settings.noSettings': '未找到设置',
+ 'settings.saved': '已保存',
+ 'settings.on': '开启',
+ 'settings.off': '关闭',
+ 'settings.envValue': '环境变量: {value}',
+ 'settings.envDefault': '使用环境变量默认值',
+ 'settings.useEnvDefault': '使用环境变量默认值',
+
+ // 设置分组
+ 'cfg.group.llm': 'LLM 提供商',
+ 'cfg.group.embeddings': '嵌入向量',
+ 'cfg.group.agent': '代理',
+ 'cfg.group.heartbeat': '心跳',
+ 'cfg.group.sandbox': '沙箱',
+ 'cfg.group.routines': '定时任务',
+ 'cfg.group.safety': '安全',
+ 'cfg.group.skills': '技能',
+ 'cfg.group.search': '搜索',
+ 'cfg.group.tunnel': '隧道',
+ 'cfg.group.gateway': '网关',
+
+ // 推理设置
+ 'cfg.llm_backend.label': '后端',
+ 'cfg.llm_backend.desc': 'LLM 推理提供商',
+ 'cfg.selected_model.label': '模型',
+ 'cfg.selected_model.desc': '所选后端的模型名称或 ID',
+ 'cfg.ollama_base_url.label': 'Ollama URL',
+ 'cfg.ollama_base_url.desc': 'Ollama API 基础 URL',
+ 'cfg.openai_compatible_base_url.label': 'OpenAI 兼容 URL',
+ 'cfg.openai_compatible_base_url.desc': 'OpenAI 兼容 API 基础 URL',
+ 'cfg.bedrock_region.label': 'Bedrock 区域',
+ 'cfg.bedrock_region.desc': 'Bedrock 的 AWS 区域',
+ 'cfg.bedrock_cross_region.label': '跨区域',
+ 'cfg.bedrock_cross_region.desc': '启用跨区域推理',
+ 'cfg.bedrock_profile.label': 'AWS 配置文件',
+ 'cfg.bedrock_profile.desc': 'Bedrock 认证的 AWS 配置文件',
+ 'cfg.embeddings_enabled.label': '启用',
+ 'cfg.embeddings_enabled.desc': '启用向量嵌入以支持记忆搜索',
+ 'cfg.embeddings_provider.label': '提供商',
+ 'cfg.embeddings_provider.desc': '嵌入向量 API 提供商',
+ 'cfg.embeddings_model.label': '模型',
+ 'cfg.embeddings_model.desc': '嵌入向量模型名称',
+
+ // 代理设置
+ 'cfg.agent_name.label': '名称',
+ 'cfg.agent_name.desc': '代理显示名称',
+ 'cfg.agent_max_parallel_jobs.label': '最大并行任务数',
+ 'cfg.agent_max_parallel_jobs.desc': '最大并发后台任务数',
+ 'cfg.agent_job_timeout.label': '任务超时',
+ 'cfg.agent_job_timeout.desc': '每个任务的最大持续时间(秒)',
+ 'cfg.agent_max_tool_iterations.label': '最大工具迭代次数',
+ 'cfg.agent_max_tool_iterations.desc': '每轮最大工具调用次数',
+ 'cfg.agent_use_planning.label': '规划',
+ 'cfg.agent_use_planning.desc': '执行前启用多步规划',
+ 'cfg.agent_auto_approve.label': '自动批准工具',
+ 'cfg.agent_auto_approve.desc': '跳过工具调用的手动审批',
+ 'cfg.agent_timezone.label': '时区',
+ 'cfg.agent_timezone.desc': '默认时区(IANA)',
+ 'cfg.agent_session_idle.label': '会话空闲超时',
+ 'cfg.agent_session_idle.desc': '空闲会话过期前的秒数',
+ 'cfg.agent_stuck_threshold.label': '卡住阈值',
+ 'cfg.agent_stuck_threshold.desc': '任务被认为卡住前的秒数',
+ 'cfg.agent_max_repair.label': '最大修复尝试次数',
+ 'cfg.agent_max_repair.desc': '卡住任务的自动恢复尝试次数',
+ 'cfg.agent_max_cost.label': '每日最大费用',
+ 'cfg.agent_max_cost.desc': '每日 LLM 支出上限(美分,0 = 无限制)',
+ 'cfg.agent_max_actions.label': '每小时最大操作数',
+ 'cfg.agent_max_actions.desc': '每小时工具调用速率限制(0 = 无限制)',
+ 'cfg.agent_allow_local.label': '允许本地工具',
+ 'cfg.agent_allow_local.desc': '启用本地文件系统工具执行',
+
+ // 心跳设置
+ 'cfg.heartbeat_enabled.label': '启用',
+ 'cfg.heartbeat_enabled.desc': '运行定期后台检查',
+ 'cfg.heartbeat_interval.label': '间隔',
+ 'cfg.heartbeat_interval.desc': '心跳间隔秒数(默认:1800)',
+ 'cfg.heartbeat_notify_channel.label': '通知频道',
+ 'cfg.heartbeat_notify_channel.desc': '发送心跳发现的频道',
+ 'cfg.heartbeat_notify_user.label': '通知用户',
+ 'cfg.heartbeat_notify_user.desc': '要通知的用户 ID',
+ 'cfg.heartbeat_quiet_start.label': '静默时段开始',
+ 'cfg.heartbeat_quiet_start.desc': '停止心跳的小时(0-23)',
+ 'cfg.heartbeat_quiet_end.label': '静默时段结束',
+ 'cfg.heartbeat_quiet_end.desc': '恢复心跳的小时(0-23)',
+ 'cfg.heartbeat_timezone.label': '时区',
+ 'cfg.heartbeat_timezone.desc': '静默时段的时区(IANA)',
+
+ // 沙箱设置
+ 'cfg.sandbox_enabled.label': '启用',
+ 'cfg.sandbox_enabled.desc': '启用 Docker 沙箱以运行后台任务',
+ 'cfg.sandbox_policy.label': '策略',
+ 'cfg.sandbox_policy.desc': '沙箱安全策略',
+ 'cfg.sandbox_timeout.label': '超时',
+ 'cfg.sandbox_timeout.desc': '最大任务持续时间(秒)',
+ 'cfg.sandbox_memory.label': '内存限制',
+ 'cfg.sandbox_memory.desc': '容器内存限制(MB)',
+ 'cfg.sandbox_image.label': 'Docker 镜像',
+ 'cfg.sandbox_image.desc': '沙箱任务的容器镜像',
+
+ // 定时任务设置
+ 'cfg.routines_max_concurrent.label': '最大并发数',
+ 'cfg.routines_max_concurrent.desc': '同时运行的最大定时任务数',
+ 'cfg.routines_cooldown.label': '默认冷却时间',
+ 'cfg.routines_cooldown.desc': '定时任务触发间的最小秒数',
+
+ // 安全设置
+ 'cfg.safety_max_output.label': '最大输出长度',
+ 'cfg.safety_max_output.desc': '每次响应的最大输出令牌数',
+ 'cfg.safety_injection_check.label': '注入检查',
+ 'cfg.safety_injection_check.desc': '启用提示注入检测',
+
+ // 技能设置
+ 'cfg.skills_max_active.label': '最大活跃技能数',
+ 'cfg.skills_max_active.desc': '同时活跃的最大技能数',
+ 'cfg.skills_max_tokens.label': '最大上下文令牌数',
+ 'cfg.skills_max_tokens.desc': '技能提示的令牌预算',
+
+ // 搜索设置
+ 'cfg.search_fusion.label': '融合策略',
+ 'cfg.search_fusion.desc': '混合搜索排名方法',
+
+ // 网络设置
+ 'cfg.tunnel_provider.label': '提供商',
+ 'cfg.tunnel_provider.desc': '公网 URL 隧道提供商',
+ 'cfg.tunnel_public_url.label': '公网 URL',
+ 'cfg.tunnel_public_url.desc': '静态公网 URL(不使用隧道提供商时)',
+ 'cfg.gateway_rate_limit.label': '速率限制',
+ 'cfg.gateway_rate_limit.desc': '每分钟最大聊天消息数',
+ 'cfg.gateway_max_connections.label': '最大连接数',
+ 'cfg.gateway_max_connections.desc': '最大同时 SSE/WS 连接数',
+
+ // 频道子标签
+ 'channels.builtin': '内置频道',
+ 'channels.messaging': '消息频道',
+ 'channels.webGateway': 'Web 网关',
+ 'channels.webGatewayDesc': '基于浏览器的聊天界面',
+ 'channels.httpWebhook': 'HTTP Webhook',
+ 'channels.httpWebhookDesc': '用于外部集成的传入 webhook 端点',
+ 'channels.cli': 'CLI',
+ 'channels.cliDesc': '使用 Ratatui 的终端 UI',
+ 'channels.repl': 'REPL',
+ 'channels.replDesc': '用于测试的简单读取-求值-打印循环',
+ 'channels.configureVia': '通过 {env} 配置',
+ 'channels.runWith': '运行命令: {cmd}',
});
diff --git a/src/channels/web/static/index.html b/src/channels/web/static/index.html
index 4e1074d0..113d144e 100644
--- a/src/channels/web/static/index.html
+++ b/src/channels/web/static/index.html
@@ -25,6 +25,7 @@
integrity="sha384-pN9zSKOnTZwXRtYZAu0PBPEgR2B7DOC1aeLxQ33oJ0oy5iN1we6gm57xldM2irDG"
crossorigin="anonymous"
>
+
@@ -95,8 +96,7 @@
Memory
Jobs
Routines
- Extensions
- Skills
+ Settings
@@ -110,6 +110,18 @@
Logs
+
+
+
+
+
+
+
+
+
+
+
+
@@ -136,19 +148,17 @@
@@ -271,77 +281,125 @@
-
-
-
-
-
Installed Extensions
-
+
+
+
+
-
-
Available WASM Extensions
-
-
Loading...
+
-
-
Install WASM Extension
-
-
-
MCP Servers
-
-
Loading...
+
-
Add Custom MCP Server
-
-
-
Registered Tools
-
-
No tools registered
+
-
-
-
-
-
Search ClawHub
-
-
- Search
-
-
-
-
-
+
+
diff --git a/src/channels/web/static/style.css b/src/channels/web/static/style.css
index 06d9665a..31f259c9 100644
--- a/src/channels/web/static/style.css
+++ b/src/channels/web/static/style.css
@@ -18,6 +18,48 @@
--radius-lg: 12px;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
--font-mono: 'IBM Plex Mono', 'SF Mono', 'Fira Code', Consolas, monospace;
+ --bg-overlay: rgba(0, 0, 0, 0.5);
+ --bg-modal: #1a1a1a;
+ --border-modal: #333;
+ --border-soft: #2a2a2a;
+ --text-tertiary: #e0e0e0;
+ --text-muted: #888;
+ --text-dimmed: #666;
+ --text-on-accent: #09090b;
+ --accent-brand: #00D894;
+ --accent-brand-hover: #00be82;
+ --warning-bg: #1e1400;
+ --warning-border: #3a2a00;
+ --warning-text: #facc15;
+ --tab-bg: rgba(9, 9, 11, 0.75);
+ --popover-bg: rgba(15, 15, 17, 0.9);
+ --badge-sandbox-bg: rgba(136, 132, 216, 0.15);
+ --badge-sandbox-text: #b4b0e8;
+ --hover-surface: rgba(255, 255, 255, 0.03);
+ --focus-ring: rgba(52, 211, 153, 0.1);
+ --accent-subtle: rgba(52, 211, 153, 0.15);
+ --accent-border-subtle: rgba(52, 211, 153, 0.3);
+ --danger-subtle: rgba(230, 76, 76, 0.15);
+ --danger-border-subtle: rgba(230, 76, 76, 0.3);
+ --warning-subtle: rgba(245, 166, 35, 0.15);
+ --border-hover: rgba(255, 255, 255, 0.15);
+ --user-msg-bg: rgba(52, 211, 153, 0.08);
+ --user-msg-border: rgba(52, 211, 153, 0.2);
+ --danger-error-bg: rgba(230, 76, 76, 0.1);
+ --accent-tee-bg: rgba(52, 211, 153, 0.1);
+ --accent-tee-border: rgba(52, 211, 153, 0.25);
+ --accent-tee-hover: rgba(52, 211, 153, 0.18);
+ --text-on-danger: #fff;
+ --shadow-card: 0 4px 24px rgba(0, 0, 0, 0.4);
+ --shadow-toast: 0 4px 12px rgba(0, 0, 0, 0.4);
+ --shadow-lg: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
+ --danger-error-border: rgba(230, 76, 76, 0.2);
+ --note-bg: rgba(255, 255, 255, 0.04);
+ --overlay-heavy: rgba(0, 0, 0, 0.6);
+ --highlight-bg: rgba(52, 211, 153, 0.3);
+ --hover-subtle: rgba(255, 255, 255, 0.06);
+ --transition-fast: 150ms ease;
+ --transition-base: 0.2s ease;
}
* {
@@ -56,7 +98,7 @@ body {
display: flex;
flex-direction: column;
gap: 24px;
- box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
+ box-shadow: var(--shadow-card);
}
.auth-brand {
@@ -100,13 +142,13 @@ body {
#auth-screen input:focus {
outline: none;
border-color: var(--accent);
- box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.3);
+ box-shadow: 0 0 0 3px var(--accent-border-subtle);
}
#auth-screen button {
padding: 10px 16px;
background: var(--accent);
- color: #09090b;
+ color: var(--text-on-accent);
border: none;
border-radius: var(--radius);
cursor: pointer;
@@ -150,7 +192,7 @@ body {
/* Tab Bar */
.tab-bar {
display: flex;
- background: rgba(9, 9, 11, 0.75);
+ background: var(--tab-bg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
will-change: backdrop-filter;
@@ -206,7 +248,7 @@ body {
.tab-bar .status-logs-btn.active {
color: var(--accent);
border-color: var(--accent);
- background: rgba(52, 211, 153, 0.1);
+ background: var(--accent-tee-bg);
}
.tab-bar .status {
@@ -239,8 +281,8 @@ body {
color: var(--success);
padding: 4px 10px;
border-radius: 12px;
- background: rgba(52, 211, 153, 0.1);
- border: 1px solid rgba(52, 211, 153, 0.25);
+ background: var(--accent-tee-bg);
+ border: 1px solid var(--accent-tee-border);
cursor: pointer;
position: relative;
margin-right: 8px;
@@ -248,7 +290,7 @@ body {
}
.tee-shield:hover {
- background: rgba(52, 211, 153, 0.18);
+ background: var(--accent-tee-hover);
}
.tee-shield svg {
@@ -269,20 +311,20 @@ body {
padding: 0.25rem 0.75rem;
border-radius: 0.5rem;
font-size: 0.8rem;
- border: 1px solid #00d894;
- color: #00d894;
+ border: 1px solid var(--accent-brand);
+ color: var(--accent-brand);
background-color: transparent;
cursor: pointer;
transition: color 150ms, background-color 150ms, border-color 150ms;
}
.tab-bar .restart-btn:hover:not(:disabled) {
- background-color: rgba(0, 216, 148, 0.1);
+ background-color: var(--accent-tee-bg);
}
.tab-bar .restart-btn:disabled {
- border-color: #333;
- color: #666;
+ border-color: var(--border-modal);
+ color: var(--text-dimmed);
cursor: not-allowed;
}
@@ -324,7 +366,7 @@ body {
left: 0;
right: 0;
bottom: 0;
- background: rgba(0, 0, 0, 0.5);
+ background: var(--bg-overlay);
backdrop-filter: blur(4px);
z-index: -1;
}
@@ -332,10 +374,10 @@ body {
.restart-loader-content {
position: relative;
z-index: 10000;
- background-color: #1a1a1a;
- border: 1px solid #333;
+ background-color: var(--bg-modal);
+ border: 1px solid var(--border-modal);
border-radius: 0.75rem;
- box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
+ box-shadow: var(--shadow-lg);
width: 100%;
max-width: 28rem;
margin: 0 1rem;
@@ -352,7 +394,7 @@ body {
}
.restart-title {
- color: #e0e0e0;
+ color: var(--text-tertiary);
font-size: 0.85rem;
margin-bottom: 1rem;
margin-top: 0;
@@ -381,17 +423,17 @@ body {
left: 0;
right: 0;
bottom: 0;
- background: rgba(0, 0, 0, 0.5);
+ background: var(--bg-overlay);
backdrop-filter: blur(4px);
}
.restart-modal-content {
position: relative;
z-index: 10000;
- background-color: #1a1a1a;
- border: 1px solid #333;
+ background-color: var(--bg-modal);
+ border: 1px solid var(--border-modal);
border-radius: 0.75rem;
- box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
+ box-shadow: var(--shadow-lg);
width: 100%;
max-width: 28rem;
margin: 0 1rem;
@@ -403,17 +445,17 @@ body {
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
- border-bottom: 1px solid #2a2a2a;
+ border-bottom: 1px solid var(--border-soft);
}
.restart-modal-header h2 {
- color: #e0e0e0;
+ color: var(--text-tertiary);
font-size: 0.95rem;
margin: 0;
}
.restart-modal-close {
- color: #888;
+ color: var(--text-muted);
padding: 0.25rem;
border-radius: 0.25rem;
background-color: transparent;
@@ -426,8 +468,8 @@ body {
}
.restart-modal-close:hover {
- color: #ccc;
- background-color: #2a2a2a;
+ color: var(--text);
+ background-color: var(--border-soft);
}
.restart-modal-body {
@@ -435,21 +477,21 @@ body {
}
.restart-modal-description {
- color: #aaa;
+ color: var(--text-secondary);
font-size: 0.85rem;
margin: 0;
}
.restart-modal-warning {
margin-top: 1rem;
- background-color: #1e1400;
- border: 1px solid #3a2a00;
+ background-color: var(--warning-bg);
+ border: 1px solid var(--warning-border);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
}
.restart-modal-warning p {
- color: #facc15;
+ color: var(--warning-text);
font-size: 0.8rem;
margin: 0;
}
@@ -460,7 +502,7 @@ body {
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.25rem;
- border-top: 1px solid #2a2a2a;
+ border-top: 1px solid var(--border-soft);
}
.restart-modal-btn {
@@ -473,28 +515,28 @@ body {
}
.restart-modal-btn.cancel {
- color: #ccc;
+ color: var(--text);
background-color: transparent;
}
.restart-modal-btn.cancel:hover {
- background-color: #2a2a2a;
+ background-color: var(--border-soft);
}
.restart-modal-btn.confirm {
- background-color: #00D894;
- color: #111;
+ background-color: var(--accent-brand);
+ color: var(--text-on-accent);
}
.restart-modal-btn.confirm:hover {
- background-color: #00be82;
+ background-color: var(--accent-brand-hover);
}
/* Progress Bar for Restart */
.restart-progress-bar {
width: 100%;
height: 0.375rem;
- background-color: #2a2a2a;
+ background-color: var(--border-soft);
border-radius: 9999px;
overflow: hidden;
}
@@ -502,7 +544,7 @@ body {
.restart-progress-fill {
height: 100%;
border-radius: 9999px;
- background-color: #00D894;
+ background-color: var(--accent-brand);
width: 40%;
animation: indeterminate 1.5s ease-in-out infinite;
}
@@ -523,14 +565,14 @@ body {
}
.restart-modal-info {
- color: #666;
+ color: var(--text-dimmed);
font-size: 0.8rem;
margin-top: 1.25rem;
margin-bottom: 0;
}
.restart-modal-info a {
- color: #00D894;
+ color: var(--accent-brand);
text-decoration: none;
}
@@ -544,7 +586,7 @@ body {
top: 100%;
right: 0;
margin-top: 8px;
- background: rgba(15, 15, 17, 0.9);
+ background: var(--popover-bg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--border);
@@ -881,11 +923,11 @@ body {
}
.activity-tool-card[data-status="running"] {
- border-color: rgba(52, 211, 153, 0.3);
+ border-color: var(--accent-border-subtle);
}
.activity-tool-card[data-status="fail"] {
- border-color: rgba(230, 76, 76, 0.3);
+ border-color: var(--danger-border-subtle);
}
.activity-tool-card[data-status="fail"] .activity-tool-name {
@@ -1126,21 +1168,21 @@ body {
.approval-card .approval-actions button.approve {
background: var(--success);
border-color: var(--success);
- color: #09090b;
+ color: var(--text-on-accent);
font-weight: 600;
}
.approval-card .approval-actions button.always {
background: var(--accent);
border-color: var(--accent);
- color: #09090b;
+ color: var(--text-on-accent);
font-weight: 600;
}
.approval-card .approval-actions button.deny {
background: var(--danger);
border-color: var(--danger);
- color: #fff;
+ color: var(--text-on-danger);
}
.approval-resolved {
@@ -1302,7 +1344,7 @@ body {
.auth-card .auth-token-input input:focus {
outline: none;
border-color: var(--accent);
- box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
+ box-shadow: 0 0 0 3px var(--focus-ring);
}
.auth-card .auth-actions {
@@ -1329,7 +1371,7 @@ body {
.auth-card .auth-actions button.auth-submit {
background: var(--accent);
border-color: var(--accent);
- color: #09090b;
+ color: var(--text-on-accent);
font-weight: 600;
}
@@ -1341,7 +1383,7 @@ body {
.auth-card .auth-actions button.auth-oauth {
background: var(--success);
border-color: var(--success);
- color: #09090b;
+ color: var(--text-on-accent);
font-weight: 600;
}
@@ -1407,7 +1449,7 @@ body {
.chat-input-wrapper textarea:focus {
outline: none;
border-color: var(--accent);
- box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
+ box-shadow: 0 0 0 3px var(--focus-ring);
}
.chat-input-wrapper textarea:disabled {
@@ -1445,7 +1487,7 @@ body {
.chat-input button {
padding: 8px 20px;
background: var(--accent);
- color: #09090b;
+ color: var(--text-on-accent);
border: none;
border-radius: var(--radius);
cursor: pointer;
@@ -1512,7 +1554,7 @@ body {
.memory-sidebar input:focus {
outline: none;
border-color: var(--accent);
- box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
+ box-shadow: 0 0 0 3px var(--focus-ring);
}
.memory-tree {
@@ -1706,7 +1748,7 @@ body {
}
.summary-card:hover {
- border-color: rgba(255, 255, 255, 0.15);
+ border-color: var(--border-hover);
}
.summary-card .count {
@@ -1750,7 +1792,7 @@ body {
}
.jobs-table tr:hover td {
- background: rgba(255, 255, 255, 0.03);
+ background: var(--hover-surface);
}
.badge {
@@ -1762,13 +1804,13 @@ body {
}
.badge.pending { background: var(--bg-tertiary); color: var(--text-secondary); }
-.badge.in_progress { background: rgba(52, 211, 153, 0.15); color: var(--accent); }
-.badge.completed { background: rgba(52, 211, 153, 0.15); color: var(--success); }
-.badge.failed { background: rgba(230, 76, 76, 0.15); color: var(--danger); }
-.badge.stuck { background: rgba(245, 166, 35, 0.15); color: var(--warning); }
+.badge.in_progress { background: var(--accent-subtle); color: var(--accent); }
+.badge.completed { background: var(--accent-subtle); color: var(--success); }
+.badge.failed { background: var(--danger-subtle); color: var(--danger); }
+.badge.stuck { background: var(--warning-subtle); color: var(--warning); }
.badge.cancelled { background: var(--bg-tertiary); color: var(--text-secondary); }
-.badge.interrupted { background: rgba(245, 166, 35, 0.15); color: var(--warning); }
-.badge.source-sandbox { background: rgba(136, 132, 216, 0.15); color: #b4b0e8; }
+.badge.interrupted { background: var(--warning-subtle); color: var(--warning); }
+.badge.source-sandbox { background: var(--badge-sandbox-bg); color: var(--badge-sandbox-text); }
.badge.source-direct { background: var(--bg-tertiary); color: var(--text-secondary); }
.btn-cancel {
@@ -1782,7 +1824,7 @@ body {
}
.btn-cancel:hover {
- background: rgba(230, 76, 76, 0.15);
+ background: var(--danger-subtle);
}
.btn-restart {
@@ -1796,7 +1838,7 @@ body {
}
.btn-restart:hover {
- background: rgba(52, 211, 153, 0.15);
+ background: var(--accent-subtle);
}
.btn-browse {
@@ -1811,7 +1853,7 @@ body {
}
.btn-browse:hover {
- background: rgba(52, 211, 153, 0.15);
+ background: var(--accent-subtle);
}
/* Job started card in chat */
@@ -1829,7 +1871,7 @@ body {
}
.job-card:hover {
- border-color: rgba(255, 255, 255, 0.15);
+ border-color: var(--border-hover);
}
.job-card-icon {
@@ -1866,7 +1908,7 @@ body {
}
.job-card-view:hover {
- background: rgba(52, 211, 153, 0.15);
+ background: var(--accent-subtle);
}
.job-card-browse {
@@ -1876,7 +1918,7 @@ body {
}
.job-card-browse:hover {
- background: rgba(52, 211, 153, 0.15);
+ background: var(--accent-subtle);
}
/* Clickable job rows */
@@ -2139,7 +2181,7 @@ body {
}
.action-error {
- background: rgba(230, 76, 76, 0.1);
+ background: var(--danger-error-bg);
padding: 8px 12px;
border-radius: var(--radius);
font-size: 12px;
@@ -2181,8 +2223,8 @@ body {
.conv-system .conv-body { color: var(--text-secondary); font-size: 13px; }
.conv-user {
- background: rgba(52, 211, 153, 0.08);
- border: 1px solid rgba(52, 211, 153, 0.2);
+ background: var(--user-msg-bg);
+ border: 1px solid var(--user-msg-border);
}
.conv-user .conv-role { color: var(--accent); }
@@ -2329,7 +2371,7 @@ body {
}
.routines-table tr:hover td {
- background: rgba(255, 255, 255, 0.03);
+ background: var(--hover-surface);
}
.routine-row {
@@ -2340,9 +2382,9 @@ body {
padding: 16px 0;
}
-.badge.enabled { background: rgba(52, 211, 153, 0.15); color: var(--success); }
+.badge.enabled { background: var(--accent-subtle); color: var(--success); }
.badge.disabled { background: var(--bg-tertiary); color: var(--text-secondary); }
-.badge.failing { background: rgba(230, 76, 76, 0.15); color: var(--danger); }
+.badge.failing { background: var(--danger-subtle); color: var(--danger); }
.btn-trigger {
padding: 4px 10px;
@@ -2355,7 +2397,7 @@ body {
}
.btn-trigger:hover {
- background: rgba(52, 211, 153, 0.15);
+ background: var(--accent-subtle);
}
.btn-toggle {
@@ -2369,7 +2411,7 @@ body {
}
.btn-toggle:hover {
- background: rgba(245, 166, 35, 0.15);
+ background: var(--warning-subtle);
}
/* Logs Tab */
@@ -2413,7 +2455,7 @@ body {
.logs-toolbar input:focus {
outline: none;
border-color: var(--accent);
- box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
+ box-shadow: 0 0 0 3px var(--focus-ring);
}
.logs-checkbox {
@@ -2522,17 +2564,21 @@ body {
}
.extensions-section h3 {
- font-size: 15px;
+ font-size: 11px;
font-weight: 600;
margin-bottom: 12px;
- color: var(--text);
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
}
.extensions-section h4 {
- font-size: 13px;
+ font-size: 11px;
font-weight: 600;
margin: 16px 0 8px;
- color: var(--text-secondary);
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
}
.extensions-list {
@@ -2544,16 +2590,33 @@ body {
.ext-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
+ border-left: 3px solid transparent;
border-radius: var(--radius-lg);
padding: 14px;
display: flex;
flex-direction: column;
gap: 8px;
- transition: border-color 0.2s, transform 0.2s;
+ transition: border-color var(--transition-base), box-shadow var(--transition-base), transform 0.2s;
+}
+
+.ext-card.state-active {
+ border-left-color: var(--success);
+}
+
+.ext-card.state-inactive {
+ border-left-color: var(--text-muted);
+}
+
+.ext-card.state-error {
+ border-left-color: var(--danger);
+}
+
+.ext-card.state-pairing {
+ border-left-color: var(--warning);
}
.ext-card:hover {
- border-color: rgba(255, 255, 255, 0.15);
+ border-color: var(--border-hover);
}
.ext-header {
@@ -2578,20 +2641,25 @@ body {
}
.ext-kind.kind-mcp_server {
- background: rgba(52, 211, 153, 0.15);
+ background: var(--accent-subtle);
color: var(--accent);
}
.ext-kind.kind-wasm_tool {
- background: rgba(52, 211, 153, 0.15);
+ background: var(--accent-subtle);
color: var(--success);
}
.ext-kind.kind-wasm_channel {
- background: rgba(245, 166, 35, 0.15);
+ background: var(--warning-subtle);
color: var(--warning);
}
+.ext-kind.kind-builtin {
+ background: rgba(161, 161, 170, 0.15);
+ color: var(--text-secondary);
+}
+
.ext-version {
font-size: 11px;
color: var(--text-muted);
@@ -2688,7 +2756,7 @@ body {
.stepper-step.failed .stepper-circle {
background: var(--danger);
- color: #fff;
+ color: var(--text-on-danger);
}
.stepper-step.failed .stepper-label {
@@ -2741,8 +2809,8 @@ body {
.ext-error {
font-size: 11px;
color: var(--danger);
- background: rgba(230, 76, 76, 0.1);
- border: 1px solid rgba(230, 76, 76, 0.2);
+ background: var(--danger-error-bg);
+ border: 1px solid var(--danger-error-border);
border-radius: var(--radius);
padding: 6px 8px;
margin-top: 6px;
@@ -2751,7 +2819,7 @@ body {
.ext-note {
font-size: 11px;
color: var(--text-secondary);
- background: rgba(255, 255, 255, 0.04);
+ background: var(--note-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 6px 8px;
@@ -2767,13 +2835,20 @@ body {
border-radius: var(--radius);
cursor: pointer;
font-size: 12px;
+ font-weight: 500;
border: 1px solid var(--border);
background: var(--bg-tertiary);
color: var(--text);
+ transition: all var(--transition-fast);
}
.btn-ext:hover {
background: var(--border);
+ transform: translateY(-1px);
+}
+
+.btn-ext:active {
+ transform: scale(0.97);
}
.btn-ext.activate {
@@ -2782,7 +2857,7 @@ body {
}
.btn-ext.activate:hover {
- background: rgba(52, 211, 153, 0.15);
+ background: var(--accent-subtle);
}
.btn-ext.remove {
@@ -2791,7 +2866,7 @@ body {
}
.btn-ext.remove:hover {
- background: rgba(230, 76, 76, 0.15);
+ background: var(--danger-subtle);
}
.btn-ext.install {
@@ -2800,7 +2875,7 @@ body {
}
.btn-ext.install:hover {
- background: rgba(52, 211, 153, 0.15);
+ background: var(--accent-subtle);
}
.btn-ext.install:disabled {
@@ -2824,7 +2899,7 @@ body {
}
.btn-ext.configure:hover {
- background: rgba(136, 132, 216, 0.15);
+ background: var(--badge-sandbox-bg);
}
/* Pairing requests */
@@ -2872,7 +2947,8 @@ body {
left: 0;
width: 100%;
height: 100%;
- background: rgba(0, 0, 0, 0.6);
+ background: var(--overlay-heavy);
+ backdrop-filter: blur(4px);
z-index: 1000;
display: flex;
align-items: center;
@@ -2893,7 +2969,7 @@ body {
.configure-modal h3 {
margin: 0 0 16px 0;
font-size: 16px;
- color: var(--text-primary);
+ color: var(--text);
}
.configure-hint {
@@ -3058,9 +3134,10 @@ body {
}
.tools-table tr:hover td {
- background: rgba(255, 255, 255, 0.03);
+ background: var(--hover-surface);
}
+
/* --- Activity tab (unified sandbox job events) --- */
.activity-terminal {
@@ -3079,7 +3156,7 @@ body {
.activity-event {
padding: 4px 0;
- border-bottom: 1px solid rgba(255, 255, 255, 0.04);
+ border-bottom: 1px solid var(--note-bg);
}
.activity-event-message .activity-role {
@@ -3182,13 +3259,13 @@ body {
.activity-input-bar input:focus {
outline: none;
border-color: var(--accent);
- box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
+ box-shadow: 0 0 0 3px var(--focus-ring);
}
.activity-input-bar button {
padding: 8px 16px;
background: var(--accent);
- color: #09090b;
+ color: var(--text-on-accent);
border: none;
border-radius: var(--radius);
cursor: pointer;
@@ -3265,13 +3342,13 @@ body {
padding: 10px 16px;
border-radius: var(--radius);
font-size: 13px;
- color: #fff;
+ color: var(--text-on-danger);
pointer-events: auto;
transform: translateX(120%);
transition: transform 0.25s ease;
max-width: 360px;
word-break: break-word;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
+ box-shadow: var(--shadow-toast);
}
.toast.visible {
@@ -3293,7 +3370,7 @@ body {
/* --- Memory search highlighting --- */
mark {
- background: rgba(52, 211, 153, 0.3);
+ background: var(--highlight-bg);
color: inherit;
border-radius: 2px;
padding: 0 1px;
@@ -3322,7 +3399,6 @@ mark {
width: 36px;
}
-.thread-sidebar.collapsed .thread-sidebar-header span,
.thread-sidebar.collapsed .thread-new-btn,
.thread-sidebar.collapsed .thread-list,
.thread-sidebar.collapsed .assistant-item,
@@ -3330,19 +3406,6 @@ mark {
display: none;
}
-.thread-sidebar-header {
- display: flex;
- align-items: center;
- padding: 10px 10px;
- font-size: 13px;
- font-weight: 600;
- gap: 8px;
-}
-
-.thread-sidebar-header span {
- flex: 1;
-}
-
.thread-new-btn {
background: none;
border: 1px solid var(--border);
@@ -3360,7 +3423,7 @@ mark {
}
.thread-new-btn:hover {
- background: rgba(52, 211, 153, 0.15);
+ background: var(--accent-subtle);
}
.assistant-item {
@@ -3378,11 +3441,11 @@ mark {
}
.assistant-item:hover {
- background: rgba(255, 255, 255, 0.06);
+ background: var(--hover-subtle);
}
.assistant-item.active {
- background: rgba(52, 211, 153, 0.1);
+ background: var(--accent-tee-bg);
color: var(--accent);
border-left: 2px solid var(--accent);
}
@@ -3400,12 +3463,15 @@ mark {
}
.threads-section-header {
+ display: flex;
+ align-items: center;
padding: 10px 10px 4px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
+ gap: 4px;
}
.thread-toggle-btn {
@@ -3467,14 +3533,14 @@ mark {
letter-spacing: 0.5px;
padding: 1px 5px;
border-radius: 3px;
- background: rgba(255, 255, 255, 0.08);
+ background: var(--border);
color: var(--text-secondary);
margin-right: 6px;
flex-shrink: 0;
}
-.thread-badge-routine { background: rgba(52, 211, 153, 0.15); color: var(--accent); }
-.thread-badge-heartbeat { background: rgba(245, 166, 35, 0.15); color: var(--warning); }
+.thread-badge-routine { background: var(--accent-subtle); color: var(--accent); }
+.thread-badge-heartbeat { background: var(--warning-subtle); color: var(--warning); }
.thread-badge-telegram { background: rgba(0, 136, 204, 0.15); color: #0088cc; }
.thread-badge-signal { background: rgba(59, 118, 240, 0.15); color: #3b76f0; }
.thread-badge-slack { background: rgba(74, 21, 75, 0.15); color: #e01e5a; }
@@ -3542,7 +3608,7 @@ mark {
.memory-editor textarea:focus {
outline: none;
border-color: var(--accent);
- box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
+ box-shadow: 0 0 0 3px var(--focus-ring);
}
.memory-editor-actions {
@@ -3553,7 +3619,7 @@ mark {
.btn-save {
padding: 6px 16px;
background: var(--accent);
- color: #09090b;
+ color: var(--text-on-accent);
border: none;
border-radius: var(--radius);
cursor: pointer;
@@ -3634,7 +3700,7 @@ mark {
top: 100%;
right: 0;
margin-top: 8px;
- background: rgba(15, 15, 17, 0.9);
+ background: var(--popover-bg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--border);
@@ -3714,10 +3780,14 @@ mark {
gap: 8px;
align-items: center;
flex-wrap: wrap;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 14px;
}
.ext-install-form input {
- padding: 6px 10px;
+ padding: 8px 12px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
@@ -3728,13 +3798,13 @@ mark {
.ext-install-form input:focus {
outline: none;
border-color: var(--accent);
- box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
+ box-shadow: 0 0 0 3px var(--focus-ring);
}
.ext-install-form button {
padding: 6px 16px;
background: var(--accent);
- color: #09090b;
+ color: var(--text-on-accent);
border: none;
border-radius: var(--radius);
cursor: pointer;
@@ -3759,6 +3829,10 @@ mark {
gap: 8px;
align-items: center;
margin-bottom: 12px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 14px;
}
.skill-search-box input {
@@ -3774,13 +3848,13 @@ mark {
.skill-search-box input:focus {
outline: none;
border-color: var(--accent);
- box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
+ box-shadow: 0 0 0 3px var(--focus-ring);
}
.skill-search-box button {
padding: 8px 20px;
background: var(--accent);
- color: #09090b;
+ color: var(--text-on-accent);
border: none;
border-radius: var(--radius);
cursor: pointer;
@@ -3795,16 +3869,16 @@ mark {
}
.skill-trust {
- font-size: 10px;
- padding: 2px 6px;
- border-radius: 8px;
- font-weight: 500;
+ font-size: 11px;
+ padding: 3px 8px;
+ border-radius: 9999px;
+ font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.skill-trust.trust-trusted {
- background: rgba(52, 211, 153, 0.15);
+ background: var(--accent-subtle);
color: var(--success);
}
@@ -3849,7 +3923,7 @@ mark {
.activity-toolbar select:focus {
outline: none;
border-color: var(--accent);
- box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
+ box-shadow: 0 0 0 3px var(--focus-ring);
}
/* --- Mobile responsive --- */
@@ -3878,7 +3952,6 @@ mark {
width: 36px;
}
- .thread-sidebar .thread-sidebar-header span,
.thread-sidebar .thread-new-btn,
.thread-sidebar .thread-list,
.thread-sidebar .assistant-item,
@@ -3895,7 +3968,6 @@ mark {
z-index: 50;
}
- .thread-sidebar.expanded-mobile .thread-sidebar-header span,
.thread-sidebar.expanded-mobile .thread-new-btn,
.thread-sidebar.expanded-mobile .thread-list,
.thread-sidebar.expanded-mobile .assistant-item,
@@ -3942,6 +4014,27 @@ mark {
border-bottom: 1px solid var(--border);
}
+ /* Settings layout: horizontal subtabs on mobile */
+ .settings-layout { flex-direction: column; }
+ .settings-sidebar {
+ width: 100%;
+ flex-direction: row;
+ overflow-x: auto;
+ border-right: none;
+ border-bottom: 1px solid var(--border);
+ padding: 0;
+ }
+ .settings-subtab {
+ border-left: none;
+ border-bottom: 2px solid transparent;
+ white-space: nowrap;
+ padding: 8px 16px;
+ }
+ .settings-subtab.active {
+ border-left-color: transparent;
+ border-bottom-color: var(--accent);
+ }
+
/* Extension install form */
.ext-install-form {
flex-direction: column;
@@ -3968,6 +4061,238 @@ mark {
}
}
+/* --- Settings Tab Layout --- */
+.settings-layout {
+ flex: 1;
+ display: flex;
+ overflow: hidden;
+}
+
+.settings-sidebar {
+ width: 180px;
+ border-right: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ background: var(--bg-secondary);
+ padding: 12px 0;
+ flex-shrink: 0;
+}
+
+.settings-subtab {
+ display: block;
+ width: 100%;
+ padding: 10px 20px;
+ background: none;
+ border: none;
+ border-left: 2px solid transparent;
+ color: var(--text-secondary);
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: 500;
+ text-align: left;
+ transition: color 0.2s, background 0.2s, border-color 0.2s;
+}
+
+.settings-subtab:hover {
+ color: var(--text);
+ background: var(--bg-tertiary);
+}
+
+.settings-subtab.active {
+ color: var(--accent);
+ border-left-color: var(--accent);
+ background: var(--bg-tertiary);
+}
+
+.settings-content {
+ flex: 1;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+.settings-subpanel {
+ display: none;
+ flex: 1;
+ overflow: hidden;
+ flex-direction: column;
+ opacity: 0;
+}
+
+.settings-subpanel.active {
+ display: flex;
+ animation: settingsFadeIn 0.2s ease forwards;
+}
+
+@keyframes settingsFadeIn {
+ from { opacity: 0; transform: translateY(6px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+/* Settings form styles (General subtab) */
+.settings-group {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 16px;
+ margin-bottom: 16px;
+}
+
+.settings-group-title {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ margin-bottom: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ padding-bottom: 8px;
+ border-bottom: 1px solid var(--border);
+}
+
+.settings-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 12px;
+ margin: 0 -12px;
+ border-bottom: 1px solid rgba(255,255,255,0.04);
+ border-radius: 6px;
+ gap: 16px;
+ max-height: 80px;
+ overflow: hidden;
+ transition: max-height 0.2s ease, opacity 0.2s ease, margin 0.2s ease, padding 0.2s ease, background var(--transition-fast);
+ opacity: 1;
+}
+
+.settings-row:hover {
+ background: var(--hover-surface);
+}
+
+.settings-row.hidden {
+ max-height: 0;
+ opacity: 0;
+ margin: 0;
+ padding: 0;
+ border-bottom: none;
+}
+
+.settings-row.search-hidden {
+ display: none;
+}
+
+.settings-row:last-child { border-bottom: none; }
+
+.settings-label {
+ font-size: 13px;
+ color: var(--text);
+ font-weight: 500;
+ flex-shrink: 0;
+ min-width: 180px;
+}
+
+.settings-input {
+ padding: 6px 10px;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ color: var(--text);
+ font-size: 13px;
+ font-family: 'IBM Plex Mono', monospace;
+ width: 240px;
+ max-width: 100%;
+}
+
+.settings-input:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.15);
+}
+
+.settings-saved-indicator {
+ font-size: 11px;
+ color: var(--success);
+ opacity: 0;
+ transform: translateY(4px);
+ transition: opacity 0.3s ease, transform 0.3s ease;
+}
+
+.settings-saved-indicator.visible {
+ opacity: 1;
+ transform: translateY(0);
+}
+
+.settings-description {
+ font-size: 11px;
+ color: var(--text-secondary);
+ margin-top: 2px;
+}
+
+.restart-banner {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 14px;
+ background: var(--warning-subtle);
+ border: 1px solid var(--warning-border);
+ border-radius: var(--radius);
+ color: var(--text);
+ font-size: 12px;
+ margin: 8px 16px;
+ animation: settingsFadeIn 0.25s ease forwards;
+}
+
+.restart-banner-text {
+ flex: 1;
+}
+
+.restart-banner-btn {
+ padding: 4px 12px;
+ background: var(--warning);
+ color: #09090b;
+ border: none;
+ border-radius: var(--radius);
+ cursor: pointer;
+ font-size: 11px;
+ font-weight: 600;
+ white-space: nowrap;
+ transition: opacity var(--transition-fast);
+}
+
+.restart-banner-btn:hover {
+ opacity: 0.85;
+}
+
+.settings-label-wrap {
+ display: flex;
+ flex-direction: column;
+ flex-shrink: 0;
+ min-width: 180px;
+}
+
+.settings-select {
+ padding: 6px 10px;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ color: var(--text);
+ font-size: 13px;
+ font-family: 'IBM Plex Mono', monospace;
+ width: 240px;
+ max-width: 100%;
+ cursor: pointer;
+}
+
+.settings-select:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.15);
+}
+
+input[type="checkbox"]:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
/* Slash command autocomplete dropdown */
.slash-autocomplete {
position: relative;
@@ -4068,7 +4393,7 @@ mark {
height: 18px;
border-radius: 50%;
background: var(--danger);
- color: #fff;
+ color: var(--text-on-danger);
border: none;
font-size: 12px;
line-height: 18px;
@@ -4078,7 +4403,7 @@ mark {
}
.image-preview-remove:hover {
- background: #c33;
+ filter: brightness(1.2);
}
/* Generated Image */
@@ -4156,3 +4481,323 @@ mark {
padding: 4px 8px;
background: var(--bg-secondary);
}
+
+/* Settings toolbar (search + import/export) */
+.settings-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 16px;
+ border-bottom: 1px solid var(--border);
+ background: var(--bg-secondary);
+ flex-shrink: 0;
+}
+
+.settings-search {
+ flex: 1;
+}
+
+.settings-search input {
+ width: 100%;
+ padding: 6px 10px 6px 32px;
+ background: var(--bg);
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%2371717a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='M21 21l-4.35-4.35'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: 10px center;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ color: var(--text);
+ font-size: 13px;
+ font-family: 'IBM Plex Mono', monospace;
+}
+
+.settings-search input:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.15);
+}
+
+.settings-toolbar-btn {
+ padding: 6px 12px;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ color: var(--text-secondary);
+ font-size: 12px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ white-space: nowrap;
+}
+
+.settings-toolbar-btn:hover {
+ background: var(--bg-secondary);
+ color: var(--text);
+ border-color: rgba(255, 255, 255, 0.15);
+ transform: translateY(-1px);
+}
+
+.settings-toolbar-btn:active {
+ transform: scale(0.98);
+}
+
+/* Confirmation modal */
+.modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.6);
+ backdrop-filter: blur(4px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ animation: modalFadeIn 0.15s ease;
+}
+
+@keyframes modalFadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes modalSlideIn {
+ from { opacity: 0; transform: translateY(10px) scale(0.98); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
+}
+
+.modal {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 0;
+ max-width: 420px;
+ width: 90%;
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
+ animation: modalSlideIn 0.2s ease;
+}
+
+.modal h3 {
+ margin: 0;
+ padding: 16px 20px;
+ font-size: 16px;
+ color: var(--text);
+ border-bottom: 1px solid var(--border);
+}
+
+.modal p {
+ margin: 0;
+ padding: 16px 20px;
+ font-size: 13px;
+ color: var(--text-secondary);
+}
+
+.modal-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ padding: 12px 20px;
+ border-top: 1px solid var(--border);
+}
+
+.btn-secondary {
+ padding: 8px 16px;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ color: var(--text);
+ cursor: pointer;
+ font-size: 13px;
+}
+
+.btn-secondary:hover {
+ background: var(--bg);
+}
+
+.btn-danger {
+ padding: 8px 16px;
+ background: var(--danger);
+ border: 1px solid var(--danger);
+ border-radius: var(--radius);
+ color: white;
+ cursor: pointer;
+ font-size: 13px;
+}
+
+.btn-danger:hover {
+ opacity: 0.9;
+}
+
+/* Mobile settings responsiveness */
+@media (max-width: 768px) {
+ .settings-row {
+ flex-direction: column;
+ align-items: stretch;
+ max-height: 140px;
+ }
+ .settings-label-wrap {
+ min-width: unset;
+ }
+ .settings-input, .settings-select {
+ width: 100%;
+ }
+ .settings-toolbar {
+ flex-wrap: wrap;
+ }
+ .settings-search {
+ min-width: 150px;
+ }
+}
+
+/* Loading skeletons */
+@keyframes shimmer {
+ 0% { background-position: -200% 0; }
+ 100% { background-position: 200% 0; }
+}
+
+.skeleton-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 12px;
+ gap: 16px;
+}
+
+.skeleton-bar {
+ height: 12px;
+ border-radius: 6px;
+ background: linear-gradient(90deg, var(--bg-tertiary) 25%, rgba(255,255,255,0.06) 50%, var(--bg-tertiary) 75%);
+ background-size: 200% 100%;
+ animation: shimmer 1.5s ease-in-out infinite;
+}
+
+.skeleton-card {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+/* Settings search empty state */
+.settings-search-empty {
+ padding: 32px 16px;
+ text-align: center;
+ color: var(--text-muted);
+ font-size: 13px;
+}
+
+/* Screen-reader only utility */
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+/* ============================================================
+ Light Theme
+ ============================================================ */
+
+[data-theme="light"] {
+ --bg: #ffffff;
+ --bg-secondary: #f5f5f7;
+ --bg-tertiary: #ebebed;
+ --border: rgba(0, 0, 0, 0.1);
+ --text: #1a1a2e;
+ --text-secondary: #555555;
+ --accent: #059669;
+ --accent-hover: #047857;
+ --success: #059669;
+ --warning: #d97706;
+ --danger: #dc2626;
+ --code-bg: #f0f0f2;
+ --shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+ --bg-overlay: rgba(0, 0, 0, 0.3);
+ --bg-modal: #ffffff;
+ --border-modal: #e0e0e0;
+ --border-soft: #e5e5e5;
+ --text-tertiary: #333333;
+ --text-muted: #777777;
+ --text-dimmed: #999999;
+ --text-on-accent: #ffffff;
+ --accent-brand: #059669;
+ --accent-brand-hover: #047857;
+ --warning-bg: #fffbeb;
+ --warning-border: #fde68a;
+ --warning-text: #92400e;
+ --tab-bg: rgba(255, 255, 255, 0.9);
+ --popover-bg: rgba(255, 255, 255, 0.95);
+ --badge-sandbox-bg: rgba(136, 132, 216, 0.1);
+ --badge-sandbox-text: #6b67b0;
+ --hover-surface: rgba(0, 0, 0, 0.03);
+ --focus-ring: rgba(5, 150, 105, 0.15);
+ --accent-subtle: rgba(5, 150, 105, 0.1);
+ --accent-border-subtle: rgba(5, 150, 105, 0.3);
+ --danger-subtle: rgba(220, 38, 38, 0.1);
+ --danger-border-subtle: rgba(220, 38, 38, 0.2);
+ --warning-subtle: rgba(217, 119, 6, 0.1);
+ --border-hover: rgba(0, 0, 0, 0.15);
+ --user-msg-bg: rgba(5, 150, 105, 0.08);
+ --user-msg-border: rgba(5, 150, 105, 0.2);
+ --danger-error-bg: rgba(220, 38, 38, 0.06);
+ --accent-tee-bg: rgba(5, 150, 105, 0.08);
+ --accent-tee-border: rgba(5, 150, 105, 0.2);
+ --accent-tee-hover: rgba(5, 150, 105, 0.15);
+ --text-on-danger: #fff;
+ --shadow-card: 0 4px 24px rgba(0, 0, 0, 0.08);
+ --shadow-toast: 0 4px 12px rgba(0, 0, 0, 0.08);
+ --shadow-lg: 0 25px 50px -12px rgba(0, 0, 0, 0.1);
+ --danger-error-border: rgba(220, 38, 38, 0.15);
+ --note-bg: rgba(0, 0, 0, 0.02);
+ --overlay-heavy: rgba(0, 0, 0, 0.4);
+ --highlight-bg: rgba(5, 150, 105, 0.2);
+ --hover-subtle: rgba(0, 0, 0, 0.04);
+}
+
+/* ============================================================
+ Theme transition (delayed via JS to avoid FOUC)
+ ============================================================ */
+
+body.theme-transition,
+body.theme-transition *:not(svg):not(path):not(line):not(circle):not(rect) {
+ transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
+}
+
+/* ============================================================
+ Theme toggle button
+ ============================================================ */
+
+.theme-toggle-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 6px;
+ background: none;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ color: var(--text-secondary);
+ cursor: pointer;
+ align-self: center;
+ margin-right: 8px;
+ transition: color 0.2s, border-color 0.2s;
+}
+
+.theme-toggle-btn:hover {
+ color: var(--text);
+ border-color: var(--text-secondary);
+}
+
+/* CSS-only icon switching via data-theme-mode on */
+.theme-icon { display: none; }
+[data-theme-mode="dark"] .icon-dark { display: block; }
+[data-theme-mode="light"] .icon-light { display: block; }
+[data-theme-mode="system"] .icon-system { display: block; }
diff --git a/src/channels/web/static/theme-init.js b/src/channels/web/static/theme-init.js
new file mode 100644
index 00000000..32288940
--- /dev/null
+++ b/src/channels/web/static/theme-init.js
@@ -0,0 +1,12 @@
+// Prevent FOUC: apply saved theme before first paint.
+// This script must be loaded synchronously in (no defer/async).
+(function() {
+ const stored = localStorage.getItem('ironclaw-theme');
+ const mode = (stored === 'dark' || stored === 'light' || stored === 'system') ? stored : 'system';
+ let resolved = mode;
+ if (mode === 'system') {
+ resolved = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
+ }
+ document.documentElement.setAttribute('data-theme', resolved);
+ document.documentElement.setAttribute('data-theme-mode', mode);
+})();
diff --git a/src/channels/web/test_helpers.rs b/src/channels/web/test_helpers.rs
index 981eacdd..8751be6a 100644
--- a/src/channels/web/test_helpers.rs
+++ b/src/channels/web/test_helpers.rs
@@ -83,10 +83,12 @@ impl TestGatewayBuilder {
scheduler: None,
chat_rate_limiter: RateLimiter::new(30, 60),
oauth_rate_limiter: RateLimiter::new(10, 60),
+ webhook_rate_limiter: 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(),
})
}
diff --git a/src/channels/web/types.rs b/src/channels/web/types.rs
index 3fad9f35..50c261c5 100644
--- a/src/channels/web/types.rs
+++ b/src/channels/web/types.rs
@@ -177,6 +177,8 @@ pub enum SseEvent {
parameters: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option
,
+ /// Whether the "always" auto-approve option should be shown.
+ allow_always: bool,
},
#[serde(rename = "auth_required")]
AuthRequired {
@@ -230,6 +232,8 @@ pub enum SseEvent {
status: String,
#[serde(skip_serializing_if = "Option::is_none")]
session_id: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ fallback_deliverable: Option,
},
/// An image was generated by a tool.
@@ -298,12 +302,30 @@ pub struct MemoryReadResponse {
pub struct MemoryWriteRequest {
pub path: String,
pub content: String,
+ /// Optional layer to write to. When present, uses `write_to_layer()`
+ /// which enables privacy classification and redirect.
+ pub layer: Option,
+ /// When true and a layer is specified, appends to existing content
+ /// instead of replacing it.
+ #[serde(default)]
+ pub append: bool,
+ /// Skip privacy classification and write directly to the specified layer.
+ #[serde(default)]
+ pub force: bool,
}
#[derive(Debug, Serialize)]
pub struct MemoryWriteResponse {
pub path: String,
pub status: &'static str,
+ /// Whether the write was redirected to a different layer (e.g., sensitive
+ /// content redirected from shared to private).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub redirected: Option,
+ /// The layer the content was actually written to (may differ from requested
+ /// layer if privacy redirect occurred).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub actual_layer: Option,
}
#[derive(Debug, Deserialize)]
@@ -503,6 +525,7 @@ pub struct ExtensionSetupResponse {
pub name: String,
pub kind: String,
pub secrets: Vec,
+ pub fields: Vec,
}
#[derive(Debug, Serialize)]
@@ -516,9 +539,23 @@ pub struct SecretFieldInfo {
pub auto_generate: bool,
}
+#[derive(Debug, Serialize)]
+pub struct SetupFieldInfo {
+ pub name: String,
+ pub prompt: String,
+ pub optional: bool,
+ /// Whether this field already has a stored value.
+ pub provided: bool,
+ /// Input type for web UI rendering.
+ pub input_type: crate::tools::wasm::ToolSetupFieldInputType,
+}
+
#[derive(Debug, Deserialize)]
pub struct ExtensionSetupRequest {
+ #[serde(default)]
pub secrets: std::collections::HashMap,
+ #[serde(default)]
+ pub fields: std::collections::HashMap,
}
#[derive(Debug, Serialize)]
@@ -537,6 +574,9 @@ pub struct ActionResponse {
/// Whether the channel was successfully activated after setup.
#[serde(skip_serializing_if = "Option::is_none")]
pub activated: Option,
+ /// Whether a restart is required for the new configuration to take effect.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub needs_restart: Option,
/// Pending manual verification challenge (for Telegram owner binding, etc.).
#[serde(skip_serializing_if = "Option::is_none")]
pub verification: Option,
@@ -551,6 +591,7 @@ impl ActionResponse {
awaiting_token: None,
instructions: None,
activated: None,
+ needs_restart: None,
verification: None,
}
}
@@ -563,6 +604,7 @@ impl ActionResponse {
awaiting_token: None,
instructions: None,
activated: None,
+ needs_restart: None,
verification: None,
}
}
@@ -810,6 +852,14 @@ impl RoutineInfo {
String::new(),
format!("event: {}.{}", source, event_type),
),
+ crate::agent::routine::Trigger::Webhook { path, .. } => {
+ let p = path.as_deref().unwrap_or("default");
+ (
+ "webhook".to_string(),
+ String::new(),
+ format!("webhook: /api/webhooks/{}", p),
+ )
+ }
crate::agent::routine::Trigger::Manual => (
"manual".to_string(),
String::new(),
@@ -1080,6 +1130,7 @@ mod tests {
description: "Run ls".to_string(),
parameters: "{}".to_string(),
thread_id: Some("t1".to_string()),
+ allow_always: true,
};
let ws = WsServerMessage::from_sse_event(&sse);
match ws {
@@ -1215,6 +1266,40 @@ mod tests {
assert_eq!(req.extension_name, "telegram");
}
+ #[test]
+ fn test_extension_setup_request_defaults() {
+ let json = r#"{}"#;
+ let req: ExtensionSetupRequest = serde_json::from_str(json).unwrap();
+ assert!(req.secrets.is_empty());
+ assert!(req.fields.is_empty());
+ }
+
+ #[test]
+ fn test_extension_setup_request_deserialize_with_fields() {
+ let json = r#"{
+ "secrets": { "api_key": "sk-123" },
+ "fields": { "llm_backend": "openai", "selected_model": "gpt-4o" }
+ }"#;
+ let req: ExtensionSetupRequest = serde_json::from_str(json).unwrap();
+ assert_eq!(req.secrets.get("api_key").unwrap(), "sk-123");
+ assert_eq!(req.fields.get("llm_backend").unwrap(), "openai");
+ assert_eq!(req.fields.get("selected_model").unwrap(), "gpt-4o");
+ }
+
+ #[test]
+ fn test_setup_field_info_serializes_input_type_as_enum_string() {
+ let field = SetupFieldInfo {
+ name: "selected_model".to_string(),
+ prompt: "Model".to_string(),
+ optional: false,
+ provided: true,
+ input_type: crate::tools::wasm::ToolSetupFieldInputType::Password,
+ };
+
+ let json = serde_json::to_value(field).unwrap();
+ assert_eq!(json["input_type"], "password");
+ }
+
// ---- ThreadInfo channel field tests ----
#[test]
diff --git a/src/channels/web/ws.rs b/src/channels/web/ws.rs
index 7bf50e52..470c3422 100644
--- a/src/channels/web/ws.rs
+++ b/src/channels/web/ws.rs
@@ -517,10 +517,12 @@ mod tests {
skill_catalog: None,
chat_rate_limiter: crate::channels::web::server::RateLimiter::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(),
}
}
}
diff --git a/src/cli/doctor.rs b/src/cli/doctor.rs
index dfc04de7..7510635a 100644
--- a/src/cli/doctor.rs
+++ b/src/cli/doctor.rs
@@ -33,7 +33,7 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
check(
"NEAR AI session",
- check_nearai_session().await,
+ check_nearai_session(&settings).await,
&mut passed,
&mut failed,
&mut skipped,
@@ -215,7 +215,22 @@ fn check_settings_file() -> CheckResult {
// ── NEAR AI session ─────────────────────────────────────────
-async fn check_nearai_session() -> CheckResult {
+async fn check_nearai_session(settings: &Settings) -> CheckResult {
+ // Skip entirely when the configured backend is not NEAR AI.
+ let llm_config = match crate::config::LlmConfig::resolve(settings) {
+ Ok(config) => config,
+ Err(e) => {
+ // check_llm_config will report the full error; just skip here.
+ return CheckResult::Skip(format!("LLM config error: {e}"));
+ }
+ };
+ if llm_config.backend != "nearai" {
+ return CheckResult::Skip(format!(
+ "not using NEAR AI backend (backend={})",
+ llm_config.backend
+ ));
+ }
+
// Check if session file exists
let session_path = crate::config::llm::default_session_path();
if !session_path.exists() {
@@ -620,12 +635,53 @@ mod tests {
#[tokio::test]
async fn check_nearai_session_does_not_panic() {
- let result = check_nearai_session().await;
+ let settings = Settings::default();
+ let result = check_nearai_session(&settings).await;
match result {
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
}
}
+ #[test]
+ fn check_nearai_session_skips_for_non_nearai_backend() {
+ struct EnvGuard(&'static str, Option);
+ impl Drop for EnvGuard {
+ fn drop(&mut self) {
+ // SAFETY: Under ENV_MUTEX.
+ unsafe {
+ match &self.1 {
+ Some(val) => std::env::set_var(self.0, val),
+ None => std::env::remove_var(self.0),
+ }
+ }
+ }
+ }
+
+ let _mutex = crate::config::helpers::ENV_MUTEX.lock().expect("env mutex");
+ let prev = std::env::var("LLM_BACKEND").ok();
+ // SAFETY: Under ENV_MUTEX, no concurrent env access.
+ unsafe {
+ std::env::set_var("LLM_BACKEND", "anthropic");
+ }
+ let _env_guard = EnvGuard("LLM_BACKEND", prev);
+
+ let settings = Settings::default();
+ let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
+ let result = rt.block_on(check_nearai_session(&settings));
+ match result {
+ CheckResult::Skip(msg) => {
+ assert!(
+ msg.contains("backend=anthropic"),
+ "expected backend name in skip message, got: {msg}"
+ );
+ }
+ other => panic!(
+ "expected Skip for non-nearai backend, got: {}",
+ format_result(&other)
+ ),
+ }
+ }
+
#[test]
fn check_settings_file_handles_missing() {
// Settings::default_path() might or might not exist, but must not panic
diff --git a/src/cli/memory.rs b/src/cli/memory.rs
index a3df3625..2d0606a8 100644
--- a/src/cli/memory.rs
+++ b/src/cli/memory.rs
@@ -7,17 +7,18 @@ use std::sync::Arc;
use clap::Subcommand;
-use crate::workspace::{EmbeddingProvider, SearchConfig, Workspace};
+use crate::workspace::{EmbeddingCacheConfig, EmbeddingProvider, SearchConfig, Workspace};
/// Run a memory command using the Database trait (works with any backend).
pub async fn run_memory_command_with_db(
cmd: MemoryCommand,
db: std::sync::Arc,
embeddings: Option>,
+ cache_config: EmbeddingCacheConfig,
) -> anyhow::Result<()> {
let mut workspace = Workspace::new_with_db("default", db);
if let Some(emb) = embeddings {
- workspace = workspace.with_embeddings(emb);
+ workspace = workspace.with_embeddings_cached(emb, cache_config);
}
match cmd {
@@ -85,10 +86,11 @@ pub async fn run_memory_command(
cmd: MemoryCommand,
pool: deadpool_postgres::Pool,
embeddings: Option>,
+ cache_config: EmbeddingCacheConfig,
) -> anyhow::Result<()> {
let mut workspace = Workspace::new("default", pool);
if let Some(emb) = embeddings {
- workspace = workspace.with_embeddings(emb);
+ workspace = workspace.with_embeddings_cached(emb, cache_config);
}
match cmd {
diff --git a/src/cli/mod.rs b/src/cli/mod.rs
index cf3c793e..dffcc2c5 100644
--- a/src/cli/mod.rs
+++ b/src/cli/mod.rs
@@ -239,6 +239,17 @@ pub enum Command {
)]
Import(ImportCommand),
+ /// Authenticate with a provider (re-login)
+ #[command(
+ about = "Authenticate with a provider",
+ long_about = "Re-authenticate with an LLM provider.\nExample: ironclaw login --openai-codex"
+ )]
+ Login {
+ /// Authenticate with OpenAI Codex (ChatGPT subscription)
+ #[arg(long)]
+ openai_codex: bool,
+ },
+
/// Run as a sandboxed worker inside a Docker container (internal use).
/// This is invoked automatically by the orchestrator, not by users directly.
#[command(hide = true)]
@@ -336,7 +347,10 @@ pub async fn run_memory_command(mem_cmd: &MemoryCommand) -> anyhow::Result<()> {
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
- run_memory_command_with_db(mem_cmd.clone(), db, embeddings).await
+ let cache_config = crate::workspace::EmbeddingCacheConfig {
+ max_entries: config.embeddings.cache_size,
+ };
+ run_memory_command_with_db(mem_cmd.clone(), db, embeddings, cache_config).await
}
#[cfg(test)]
diff --git a/src/cli/oauth_defaults.rs b/src/cli/oauth_defaults.rs
index a625f718..b4e93704 100644
--- a/src/cli/oauth_defaults.rs
+++ b/src/cli/oauth_defaults.rs
@@ -5,17 +5,10 @@
//!
//! # Built-in Credentials
//!
-//! Many CLI tools (gcloud, rclone, gdrive) ship with default OAuth credentials
-//! so users don't need to register their own OAuth app. Google explicitly
-//! documents that client_secret for "Desktop App" / "Installed App" types
-//! is NOT actually secret.
-//!
-//! Default credentials are hardcoded below. They can be overridden at:
-//!
-//! - **Compile time**: Set IRONCLAW_GOOGLE_CLIENT_ID / IRONCLAW_GOOGLE_CLIENT_SECRET
-//! env vars before building to replace the hardcoded defaults.
-//! - **Runtime**: Users can set GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET
-//! env vars, which take priority over built-in defaults.
+//! Some providers ship with built-in OAuth credentials so users don't need to
+//! register their own OAuth app just to get started. Today this module only
+//! includes built-in defaults for Google-family tools, and those defaults can
+//! be overridden by provider-specific environment variables when needed.
use std::collections::HashMap;
use std::sync::Arc;
@@ -23,6 +16,7 @@ use std::time::Duration;
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use rand::RngCore;
+use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tokio::sync::RwLock;
@@ -60,6 +54,14 @@ pub fn builtin_credentials(secret_name: &str) -> Option {
}
}
+/// Returns the compile-time override env var name, if this provider supports one.
+pub fn builtin_client_id_override_env(secret_name: &str) -> Option<&'static str> {
+ match secret_name {
+ "google_oauth_token" => Some("IRONCLAW_GOOGLE_CLIENT_ID"),
+ _ => None,
+ }
+}
+
// ── Shared callback server ──────────────────────────────────────────────
// Core OAuth callback infrastructure is defined in `crate::llm::oauth_helpers`
@@ -173,9 +175,8 @@ pub async fn exchange_oauth_code(
code_verifier: Option<&str>,
access_token_field: &str,
) -> Result {
- // Delegates to exchange_oauth_code_with_resource with resource=None.
- // Non-MCP OAuth flows don't need the RFC 8707 resource parameter.
- exchange_oauth_code_with_resource(
+ let extra_token_params = HashMap::new();
+ exchange_oauth_code_with_params(
token_url,
client_id,
client_secret,
@@ -183,16 +184,14 @@ pub async fn exchange_oauth_code(
redirect_uri,
code_verifier,
access_token_field,
- None,
+ &extra_token_params,
)
.await
}
-/// Exchange an OAuth authorization code for tokens, with optional RFC 8707 `resource` parameter.
-///
-/// The `resource` parameter scopes the issued token to a specific server (used by MCP OAuth).
+/// Exchange an OAuth authorization code for tokens with generic extra form parameters.
#[allow(clippy::too_many_arguments)]
-pub async fn exchange_oauth_code_with_resource(
+pub async fn exchange_oauth_code_with_params(
token_url: &str,
client_id: &str,
client_secret: Option<&str>,
@@ -200,7 +199,7 @@ pub async fn exchange_oauth_code_with_resource(
redirect_uri: &str,
code_verifier: Option<&str>,
access_token_field: &str,
- resource: Option<&str>,
+ extra_token_params: &HashMap,
) -> Result {
let client = reqwest::Client::new();
let mut token_params = vec![
@@ -213,10 +212,8 @@ pub async fn exchange_oauth_code_with_resource(
token_params.push(("code_verifier", verifier.to_string()));
}
- // RFC 8707: include the `resource` parameter so the authorization server
- // scopes the issued token to the specific MCP server (protected resource).
- if let Some(resource) = resource {
- token_params.push(("resource", resource.to_string()));
+ for (key, value) in extra_token_params {
+ token_params.push((key.as_str(), value.clone()));
}
let mut request = client.post(token_url);
@@ -276,6 +273,37 @@ pub async fn exchange_oauth_code_with_resource(
})
}
+/// Exchange an OAuth authorization code for tokens, with optional RFC 8707 `resource` parameter.
+///
+/// The `resource` parameter scopes the issued token to a specific server (used by MCP OAuth).
+#[allow(clippy::too_many_arguments)]
+pub async fn exchange_oauth_code_with_resource(
+ token_url: &str,
+ client_id: &str,
+ client_secret: Option<&str>,
+ code: &str,
+ redirect_uri: &str,
+ code_verifier: Option<&str>,
+ access_token_field: &str,
+ resource: Option<&str>,
+) -> Result {
+ let mut extra_token_params = HashMap::new();
+ if let Some(resource) = resource {
+ extra_token_params.insert("resource".to_string(), resource.to_string());
+ }
+ exchange_oauth_code_with_params(
+ token_url,
+ client_id,
+ client_secret,
+ code,
+ redirect_uri,
+ code_verifier,
+ access_token_field,
+ &extra_token_params,
+ )
+ .await
+}
+
/// Store OAuth tokens (access + refresh) in the secrets store.
///
/// Also stores the granted scopes as `{secret_name}_scopes` so that scope
@@ -423,9 +451,9 @@ pub struct PendingOAuthFlow {
pub sse_sender: Option>,
/// Gateway auth token for authenticating with the platform token exchange proxy.
pub gateway_token: Option,
- /// RFC 8707 resource parameter (MCP OAuth only).
- /// Sent during token exchange to scope the token to a specific MCP server.
- pub resource: Option,
+ /// Additional form params for the token exchange request.
+ /// Used for provider-specific requirements such as RFC 8707 `resource`.
+ pub token_exchange_extra_params: HashMap,
/// Secret name for persisting the client ID (MCP OAuth only).
/// Needed so token refresh can find the client_id after the session ends.
pub client_id_secret_name: Option,
@@ -459,9 +487,7 @@ pub fn new_pending_oauth_registry() -> PendingOAuthRegistry {
/// URL, meaning the user's browser will redirect to a hosted gateway rather than
/// localhost.
pub fn use_gateway_callback() -> bool {
- std::env::var("IRONCLAW_OAUTH_CALLBACK_URL")
- .ok()
- .filter(|v| !v.is_empty())
+ crate::config::helpers::env_or_override("IRONCLAW_OAUTH_CALLBACK_URL")
.map(|raw| {
url::Url::parse(&raw)
.ok()
@@ -472,6 +498,13 @@ pub fn use_gateway_callback() -> bool {
.unwrap_or(false)
}
+/// Returns the configured OAuth token-exchange proxy URL, if any.
+pub fn exchange_proxy_url() -> Option {
+ crate::config::helpers::env_or_override("IRONCLAW_OAUTH_EXCHANGE_URL")
+ .map(|url| url.trim().to_string())
+ .filter(|url| !url.is_empty())
+}
+
/// Maximum age for pending OAuth flows (5 minutes, matching TCP listener timeout).
pub const OAUTH_FLOW_EXPIRY: Duration = Duration::from_secs(300);
@@ -486,23 +519,121 @@ pub async fn sweep_expired_flows(registry: &PendingOAuthRegistry) {
// ── Platform routing helpers ────────────────────────────────────────
-/// Prepend instance name to CSRF state for platform routing.
+const HOSTED_STATE_PREFIX: &str = "ic2";
+const HOSTED_STATE_CHECKSUM_BYTES: usize = 12;
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct DecodedHostedOAuthState {
+ pub flow_id: String,
+ pub instance_name: Option,
+ pub is_legacy: bool,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct HostedOAuthStatePayload {
+ flow_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ instance_name: Option,
+ issued_at: u64,
+}
+
+fn current_instance_name() -> Option {
+ crate::config::helpers::env_or_override("IRONCLAW_INSTANCE_NAME")
+ .or_else(|| crate::config::helpers::env_or_override("OPENCLAW_INSTANCE_NAME"))
+ .filter(|v| !v.is_empty())
+}
+
+fn hosted_state_checksum(payload_bytes: &[u8]) -> String {
+ let digest = Sha256::digest(payload_bytes);
+ URL_SAFE_NO_PAD.encode(&digest[..HOSTED_STATE_CHECKSUM_BYTES])
+}
+
+/// Build a versioned hosted OAuth state envelope.
///
-/// The NEAR AI platform nginx proxy at `auth.DOMAIN` parses the instance name
-/// from the `state` query parameter (format: `instance:nonce`) to route the
-/// OAuth callback to the correct container.
-///
-/// Returns the nonce unchanged when `IRONCLAW_INSTANCE_NAME` is not set
-/// (local/non-platform mode).
-pub fn build_platform_state(nonce: &str) -> String {
- let instance = std::env::var("IRONCLAW_INSTANCE_NAME")
- .or_else(|_| std::env::var("OPENCLAW_INSTANCE_NAME"))
- .ok()
- .filter(|v| !v.is_empty());
- match instance {
- Some(name) => format!("{}:{}", name, nonce),
- None => nonce.to_string(),
+/// The encoded value is opaque to providers and can be decoded by both
+/// IronClaw and the external auth proxy for routing and callback lookup.
+pub fn encode_hosted_oauth_state(flow_id: &str, instance_name: Option<&str>) -> String {
+ let payload = HostedOAuthStatePayload {
+ flow_id: flow_id.to_string(),
+ instance_name: instance_name
+ .map(str::trim)
+ .filter(|v| !v.is_empty())
+ .map(str::to_string),
+ issued_at: std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs(),
+ };
+ let payload_json = match serde_json::to_vec(&payload) {
+ Ok(payload_json) => payload_json,
+ Err(error) => {
+ tracing::warn!(%error, flow_id, "Failed to serialize hosted OAuth state payload");
+ return payload.flow_id;
+ }
+ };
+ let payload = URL_SAFE_NO_PAD.encode(&payload_json);
+ let checksum = hosted_state_checksum(&payload_json);
+ format!("{HOSTED_STATE_PREFIX}.{payload}.{checksum}")
+}
+
+/// Decode hosted OAuth state in either the new versioned format or the
+/// legacy `instance:nonce`/`nonce` forms.
+pub fn decode_hosted_oauth_state(state: &str) -> Result {
+ if let Some(rest) = state.strip_prefix(&format!("{HOSTED_STATE_PREFIX}.")) {
+ let (payload_b64, checksum) = rest
+ .rsplit_once('.')
+ .ok_or("Hosted OAuth versioned state missing checksum separator")?;
+ let payload_json = URL_SAFE_NO_PAD
+ .decode(payload_b64)
+ .map_err(|e| format!("Hosted OAuth versioned state base64 decode failed: {e}"))?;
+ let expected_checksum = hosted_state_checksum(&payload_json);
+ if checksum != expected_checksum {
+ return Err("Hosted OAuth state checksum mismatch".to_string());
+ }
+ let payload: HostedOAuthStatePayload = serde_json::from_slice(&payload_json)
+ .map_err(|e| format!("Hosted OAuth versioned state JSON parse failed: {e}"))?;
+ if payload.flow_id.trim().is_empty() {
+ return Err("Hosted OAuth versioned state has empty flow_id".to_string());
+ }
+ return Ok(DecodedHostedOAuthState {
+ flow_id: payload.flow_id,
+ instance_name: payload.instance_name.filter(|v| !v.is_empty()),
+ is_legacy: false,
+ });
}
+
+ if let Some((instance_name, flow_id)) = state.split_once(':') {
+ if flow_id.is_empty() {
+ return Err("Hosted OAuth legacy state is missing flow_id".to_string());
+ }
+ return Ok(DecodedHostedOAuthState {
+ flow_id: flow_id.to_string(),
+ instance_name: if instance_name.is_empty() {
+ None
+ } else {
+ Some(instance_name.to_string())
+ },
+ is_legacy: true,
+ });
+ }
+
+ if state.is_empty() {
+ return Err("Hosted OAuth state is empty".to_string());
+ }
+
+ Ok(DecodedHostedOAuthState {
+ flow_id: state.to_string(),
+ instance_name: None,
+ is_legacy: true,
+ })
+}
+
+/// Build the hosted callback state used by the public OAuth callback endpoint.
+///
+/// New flows emit a versioned opaque envelope, while callback decoding accepts
+/// both the envelope and the legacy `instance:nonce` contract.
+pub fn build_platform_state(nonce: &str) -> String {
+ encode_hosted_oauth_state(nonce, current_instance_name().as_deref())
}
/// Strip the instance prefix from a state parameter to recover the lookup nonce.
@@ -517,43 +648,62 @@ pub fn strip_instance_prefix(state: &str) -> &str {
.unwrap_or(state)
}
+pub struct ProxyTokenExchangeRequest<'a> {
+ pub proxy_url: &'a str,
+ pub gateway_token: &'a str,
+ pub token_url: &'a str,
+ pub client_id: &'a str,
+ pub client_secret: Option<&'a str>,
+ pub code: &'a str,
+ pub redirect_uri: &'a str,
+ pub code_verifier: Option<&'a str>,
+ pub access_token_field: &'a str,
+ pub extra_token_params: &'a HashMap,
+}
+
/// Exchange an OAuth authorization code via the platform's token exchange proxy.
///
-/// The proxy holds `client_secret` server-side so the container never sees it.
-/// Authenticated via the gateway auth token (Bearer header).
+/// Authenticated via the gateway auth token (Bearer header). The caller may
+/// either rely on proxy-side secret lookup or forward a `client_secret` when
+/// the provider requires it.
///
-/// The proxy expects form params `{code, redirect_uri, code_verifier}` and
-/// returns a standard Google token response `{access_token, refresh_token, expires_in}`.
+/// The proxy expects standard OAuth form params plus optional provider-specific
+/// token params and returns a standard token response such as
+/// `{access_token, refresh_token, expires_in}`.
pub async fn exchange_via_proxy(
- proxy_url: &str,
- gateway_token: &str,
- code: &str,
- redirect_uri: &str,
- code_verifier: Option<&str>,
- access_token_field: &str,
+ request: ProxyTokenExchangeRequest<'_>,
) -> Result {
- if gateway_token.is_empty() {
+ if request.gateway_token.is_empty() {
return Err(OAuthCallbackError::Io(
"Gateway auth token is required for proxy token exchange".to_string(),
));
}
- let exchange_url = format!("{}/oauth/exchange", proxy_url.trim_end_matches('/'));
+ let exchange_url = format!("{}/oauth/exchange", request.proxy_url.trim_end_matches('/'));
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(60))
.build()
.map_err(|e| OAuthCallbackError::Io(format!("Failed to build HTTP client: {}", e)))?;
let mut params = vec![
- ("code", code.to_string()),
- ("redirect_uri", redirect_uri.to_string()),
+ ("code", request.code.to_string()),
+ ("redirect_uri", request.redirect_uri.to_string()),
+ ("token_url", request.token_url.to_string()),
+ ("client_id", request.client_id.to_string()),
+ ("access_token_field", request.access_token_field.to_string()),
];
- if let Some(verifier) = code_verifier {
+ if let Some(verifier) = request.code_verifier {
params.push(("code_verifier", verifier.to_string()));
}
+ if let Some(secret) = request.client_secret {
+ params.push(("client_secret", secret.to_string()));
+ }
+ for (key, value) in request.extra_token_params {
+ params.push((key.as_str(), value.clone()));
+ }
let response = client
.post(&exchange_url)
- .bearer_auth(gateway_token)
+ .bearer_auth(request.gateway_token)
.form(¶ms)
.send()
.await
@@ -576,7 +726,7 @@ pub async fn exchange_via_proxy(
.map_err(|e| OAuthCallbackError::Io(format!("Failed to parse proxy response: {}", e)))?;
let access_token = token_data
- .get(access_token_field)
+ .get(request.access_token_field)
.and_then(|v| v.as_str())
.ok_or_else(|| {
let fields: Vec<&str> = token_data
@@ -585,7 +735,7 @@ pub async fn exchange_via_proxy(
.unwrap_or_default();
OAuthCallbackError::Io(format!(
"No '{}' field in proxy response (fields present: {:?})",
- access_token_field, fields
+ request.access_token_field, fields
))
})?
.to_string();
@@ -605,14 +755,10 @@ pub async fn exchange_via_proxy(
#[cfg(test)]
mod tests {
- use std::sync::Mutex;
-
use crate::cli::oauth_defaults::{
builtin_credentials, callback_host, callback_url, is_loopback_host, landing_html,
};
-
- /// Serializes env-mutating tests to prevent parallel races.
- static ENV_MUTEX: Mutex<()> = Mutex::new(());
+ use crate::config::helpers::ENV_MUTEX;
#[test]
fn test_is_loopback_host() {
@@ -935,7 +1081,7 @@ mod tests {
#[test]
fn test_build_platform_state_with_instance() {
- use crate::cli::oauth_defaults::build_platform_state;
+ use crate::cli::oauth_defaults::{build_platform_state, decode_hosted_oauth_state};
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let original = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
@@ -943,7 +1089,11 @@ mod tests {
unsafe {
std::env::set_var("IRONCLAW_INSTANCE_NAME", "kind-deer");
}
- assert_eq!(build_platform_state("abc123"), "kind-deer:abc123");
+ let encoded = build_platform_state("abc123");
+ let decoded = decode_hosted_oauth_state(&encoded).expect("decode hosted state");
+ assert_eq!(decoded.flow_id, "abc123");
+ assert_eq!(decoded.instance_name.as_deref(), Some("kind-deer"));
+ assert!(!decoded.is_legacy);
unsafe {
if let Some(val) = original {
std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
@@ -955,7 +1105,7 @@ mod tests {
#[test]
fn test_build_platform_state_without_instance() {
- use crate::cli::oauth_defaults::build_platform_state;
+ use crate::cli::oauth_defaults::{build_platform_state, decode_hosted_oauth_state};
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let original = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
@@ -965,7 +1115,11 @@ mod tests {
std::env::remove_var("IRONCLAW_INSTANCE_NAME");
std::env::remove_var("OPENCLAW_INSTANCE_NAME");
}
- assert_eq!(build_platform_state("abc123"), "abc123");
+ let encoded = build_platform_state("abc123");
+ let decoded = decode_hosted_oauth_state(&encoded).expect("decode hosted state");
+ assert_eq!(decoded.flow_id, "abc123");
+ assert_eq!(decoded.instance_name, None);
+ assert!(!decoded.is_legacy);
unsafe {
if let Some(val) = original {
std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
@@ -978,7 +1132,7 @@ mod tests {
#[test]
fn test_build_platform_state_with_openclaw_instance() {
- use crate::cli::oauth_defaults::build_platform_state;
+ use crate::cli::oauth_defaults::{build_platform_state, decode_hosted_oauth_state};
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let original_ic = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
@@ -988,7 +1142,11 @@ mod tests {
std::env::remove_var("IRONCLAW_INSTANCE_NAME");
std::env::set_var("OPENCLAW_INSTANCE_NAME", "quiet-lion");
}
- assert_eq!(build_platform_state("xyz789"), "quiet-lion:xyz789");
+ let encoded = build_platform_state("xyz789");
+ let decoded = decode_hosted_oauth_state(&encoded).expect("decode hosted state");
+ assert_eq!(decoded.flow_id, "xyz789");
+ assert_eq!(decoded.instance_name.as_deref(), Some("quiet-lion"));
+ assert!(!decoded.is_legacy);
unsafe {
if let Some(val) = original_ic {
std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
@@ -1017,6 +1175,42 @@ mod tests {
assert_eq!(strip_instance_prefix(""), "");
}
+ #[test]
+ 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: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("abc123").expect("legacy raw");
+ assert_eq!(decoded.flow_id, "abc123");
+ assert_eq!(decoded.instance_name, None);
+ assert!(decoded.is_legacy);
+ }
+
+ #[test]
+ fn test_decode_hosted_oauth_state_rejects_non_envelope_ic2_prefix() {
+ use crate::cli::oauth_defaults::decode_hosted_oauth_state;
+
+ // "ic2." prefix must parse as a valid versioned envelope — never fall
+ // through to legacy handling, which would use the full malformed
+ // envelope as the flow_id and break OAuth callback lookup (#1441).
+ decode_hosted_oauth_state("ic2.provider-owned-state")
+ .expect_err("ic2-prefixed non-envelope state should fail");
+ }
+
+ #[test]
+ fn test_decode_hosted_oauth_state_rejects_tampered_checksum() {
+ use crate::cli::oauth_defaults::{decode_hosted_oauth_state, encode_hosted_oauth_state};
+
+ let encoded = encode_hosted_oauth_state("abc123", Some("kind-deer"));
+ let tampered = format!("{encoded}broken");
+ let err = decode_hosted_oauth_state(&tampered).expect_err("tampered state should fail");
+ assert!(err.contains("checksum"), "unexpected error: {err}");
+ }
+
/// Verify that `build_oauth_url` includes the RFC 8707 `resource` parameter
/// when passed through `extra_params`, which is how MCP OAuth gateway mode
/// scopes tokens to a specific MCP server.
@@ -1054,4 +1248,65 @@ mod tests {
assert!(result.url.contains("code_challenge="));
assert!(result.code_verifier.is_some());
}
+
+ /// Malformed `ic2.*` states must return Err, never fall through to legacy
+ /// handling where the full envelope would be used as the flow_id (#1441).
+ #[test]
+ fn test_decode_versioned_state_rejects_malformed_envelopes() {
+ use crate::cli::oauth_defaults::decode_hosted_oauth_state;
+
+ // Missing checksum separator (no second dot after prefix)
+ let err =
+ decode_hosted_oauth_state("ic2.nodots").expect_err("missing separator should fail");
+ assert!(
+ err.contains("checksum separator"),
+ "unexpected error: {err}"
+ );
+
+ // Bad base64 payload
+ let err = decode_hosted_oauth_state("ic2.!!!badbase64!!!.fakechecksum")
+ .expect_err("bad base64 should fail");
+ assert!(err.contains("base64"), "unexpected error: {err}");
+
+ // Valid base64 but not JSON: use correct checksum so we exercise JSON parsing
+ use base64::Engine;
+ use sha2::Digest;
+ let not_json_bytes = b"not json";
+ let not_json_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(not_json_bytes);
+ let digest = sha2::Sha256::digest(not_json_bytes);
+ let checksum = base64::engine::general_purpose::URL_SAFE_NO_PAD
+ .encode(&digest[..super::HOSTED_STATE_CHECKSUM_BYTES]);
+ let err = decode_hosted_oauth_state(&format!("ic2.{not_json_b64}.{checksum}"))
+ .expect_err("non-JSON payload should fail with JSON parse error");
+ assert!(
+ err.contains("JSON"),
+ "unexpected error (expected JSON parse failure): {err}"
+ );
+ }
+
+ /// Round-trip: encode_hosted_oauth_state(nonce) → decode → flow_id == nonce.
+ /// Ensures the registration key and lookup key are always identical (#1441).
+ #[test]
+ fn test_oauth_flow_key_round_trip_consistency() {
+ use crate::cli::oauth_defaults::{decode_hosted_oauth_state, encode_hosted_oauth_state};
+
+ let nonce = "test-nonce-abc123";
+ let encoded = encode_hosted_oauth_state(nonce, Some("my-instance"));
+ let decoded = decode_hosted_oauth_state(&encoded).expect("round-trip decode");
+
+ assert_eq!(
+ decoded.flow_id, nonce,
+ "flow_id must match the original nonce"
+ );
+ assert_eq!(decoded.instance_name.as_deref(), Some("my-instance"));
+ assert!(!decoded.is_legacy);
+
+ // Also test without instance name
+ let encoded_no_instance = encode_hosted_oauth_state(nonce, None);
+ let decoded_no_instance =
+ decode_hosted_oauth_state(&encoded_no_instance).expect("round-trip without instance");
+ assert_eq!(decoded_no_instance.flow_id, nonce);
+ assert_eq!(decoded_no_instance.instance_name, None);
+ assert!(!decoded_no_instance.is_legacy);
+ }
}
diff --git a/src/cli/snapshots/ironclaw__cli__tests__help_output.snap b/src/cli/snapshots/ironclaw__cli__tests__help_output.snap
index a554acae..81fed592 100644
--- a/src/cli/snapshots/ironclaw__cli__tests__help_output.snap
+++ b/src/cli/snapshots/ironclaw__cli__tests__help_output.snap
@@ -24,6 +24,7 @@ Commands:
status Show system status
completion Generate completions
import Import from other AI systems
+ login Authenticate with a provider
help Print this message or the help of the given subcommand(s)
Options:
diff --git a/src/cli/snapshots/ironclaw__cli__tests__help_output_without_import.snap b/src/cli/snapshots/ironclaw__cli__tests__help_output_without_import.snap
index 3f3cf4fc..a6237fde 100644
--- a/src/cli/snapshots/ironclaw__cli__tests__help_output_without_import.snap
+++ b/src/cli/snapshots/ironclaw__cli__tests__help_output_without_import.snap
@@ -23,6 +23,7 @@ Commands:
logs View and manage gateway logs
status Show system status
completion Generate completions
+ login Authenticate with a provider
help Print this message or the help of the given subcommand(s)
Options:
diff --git a/src/cli/snapshots/ironclaw__cli__tests__long_help_output.snap b/src/cli/snapshots/ironclaw__cli__tests__long_help_output.snap
index 99b3ef53..c124bad3 100644
--- a/src/cli/snapshots/ironclaw__cli__tests__long_help_output.snap
+++ b/src/cli/snapshots/ironclaw__cli__tests__long_help_output.snap
@@ -27,6 +27,7 @@ Commands:
status Show system status
completion Generate completions
import Import from other AI systems
+ login Authenticate with a provider
help Print this message or the help of the given subcommand(s)
Options:
diff --git a/src/cli/snapshots/ironclaw__cli__tests__long_help_output_without_import.snap b/src/cli/snapshots/ironclaw__cli__tests__long_help_output_without_import.snap
index aa7ae8b0..6aa05e75 100644
--- a/src/cli/snapshots/ironclaw__cli__tests__long_help_output_without_import.snap
+++ b/src/cli/snapshots/ironclaw__cli__tests__long_help_output_without_import.snap
@@ -26,6 +26,7 @@ Commands:
logs View and manage gateway logs
status Show system status
completion Generate completions
+ login Authenticate with a provider
help Print this message or the help of the given subcommand(s)
Options:
diff --git a/src/cli/tool.rs b/src/cli/tool.rs
index ac5d1b37..be684580 100644
--- a/src/cli/tool.rs
+++ b/src/cli/tool.rs
@@ -651,8 +651,8 @@ async fn auth_tool(name: String, dir: Option, user_id: String) -> anyho
// Check for OAuth configuration
if let Some(ref oauth) = auth.oauth {
- // For providers with shared tokens (e.g., all Google tools share google_oauth_token),
- // combine scopes from all installed tools so one auth covers everything.
+ // For providers with shared tokens, combine scopes from all installed
+ // tools so one auth covers everything.
let combined = combine_provider_scopes(&tools_dir, &auth.secret_name, oauth).await;
if combined.scopes.len() > oauth.scopes.len() {
let extra = combined.scopes.len() - oauth.scopes.len();
@@ -670,8 +670,8 @@ async fn auth_tool(name: String, dir: Option, user_id: String) -> anyho
}
/// Scan the tools directory for all capabilities files sharing the same secret_name
-/// and combine their OAuth scopes. This way, authing any Google tool requests scopes
-/// for ALL installed Google tools, so one login covers everything.
+/// and combine their OAuth scopes so one authorization covers the full shared
+/// credential set.
async fn combine_provider_scopes(
tools_dir: &Path,
secret_name: &str,
@@ -736,11 +736,18 @@ async fn auth_tool_oauth(
})
.or_else(|| builtin.as_ref().map(|c| c.client_id.to_string()))
.ok_or_else(|| {
- anyhow::anyhow!(
+ let mut message = format!(
"OAuth client_id not configured.\n\
- Set {} env var, or build with IRONCLAW_GOOGLE_CLIENT_ID.",
+ Set {} env var",
oauth.client_id_env.as_deref().unwrap_or("the client_id")
- )
+ );
+ if let Some(override_env) =
+ oauth_defaults::builtin_client_id_override_env(&auth.secret_name)
+ {
+ message.push_str(&format!(", or build with {override_env}"));
+ }
+ message.push('.');
+ anyhow::anyhow!(message)
})?;
// Get client_secret: capabilities file > runtime env var > built-in defaults
diff --git a/src/config/channels.rs b/src/config/channels.rs
index 6b1058a0..bc704445 100644
--- a/src/config/channels.rs
+++ b/src/config/channels.rs
@@ -111,6 +111,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(|| "default".to_string());
+
Some(GatewayConfig {
host: optional_env("GATEWAY_HOST")?
.or_else(|| cs.gateway_host.clone())
@@ -121,7 +125,7 @@ impl ChannelsConfig {
)?,
auth_token: optional_env("GATEWAY_AUTH_TOKEN")?
.or_else(|| cs.gateway_auth_token.clone()),
- user_id: owner_id.to_string(),
+ user_id,
})
} else {
None
diff --git a/src/config/embeddings.rs b/src/config/embeddings.rs
index a1c3ecd7..68b0ff2c 100644
--- a/src/config/embeddings.rs
+++ b/src/config/embeddings.rs
@@ -2,12 +2,15 @@ use std::sync::Arc;
use secrecy::{ExposeSecret, SecretString};
-use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
+use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env, validate_base_url};
use crate::error::ConfigError;
use crate::llm::SessionManager;
use crate::settings::Settings;
use crate::workspace::EmbeddingProvider;
+/// Default maximum number of cached embeddings.
+pub const DEFAULT_EMBEDDING_CACHE_SIZE: usize = 10_000;
+
/// Embeddings provider configuration.
#[derive(Debug, Clone)]
pub struct EmbeddingsConfig {
@@ -26,6 +29,12 @@ pub struct EmbeddingsConfig {
/// Custom base URL for OpenAI-compatible embedding providers.
/// When set, overrides the default `https://api.openai.com`.
pub openai_base_url: Option,
+ /// Maximum entries in the embedding LRU cache (default 10,000).
+ ///
+ /// Approximate raw embedding payload: `cache_size × dimension × 4 bytes`.
+ /// 10,000 × 1536 floats ≈ 58 MB (payload only; actual memory is higher
+ /// due to HashMap buckets, per-entry Vec/timestamp overhead).
+ pub cache_size: usize,
}
impl Default for EmbeddingsConfig {
@@ -40,6 +49,7 @@ impl Default for EmbeddingsConfig {
ollama_base_url: "http://localhost:11434".to_string(),
dimension,
openai_base_url: None,
+ cache_size: DEFAULT_EMBEDDING_CACHE_SIZE,
}
}
}
@@ -47,7 +57,7 @@ impl Default for EmbeddingsConfig {
/// Infer the embedding dimension from a well-known model name.
///
/// Falls back to 1536 (OpenAI text-embedding-3-small default) for unknown models.
-fn default_dimension_for_model(model: &str) -> usize {
+pub(crate) fn default_dimension_for_model(model: &str) -> usize {
match model {
"text-embedding-3-small" => 1536,
"text-embedding-3-large" => 3072,
@@ -80,6 +90,21 @@ impl EmbeddingsConfig {
let openai_base_url = optional_env("EMBEDDING_BASE_URL")?;
+ // Validate base URLs to prevent SSRF attacks (#1103).
+ validate_base_url(&ollama_base_url, "OLLAMA_BASE_URL")?;
+ if let Some(ref url) = openai_base_url {
+ validate_base_url(url, "EMBEDDING_BASE_URL")?;
+ }
+
+ let cache_size = parse_optional_env("EMBEDDING_CACHE_SIZE", DEFAULT_EMBEDDING_CACHE_SIZE)?;
+
+ if cache_size == 0 {
+ return Err(ConfigError::InvalidValue {
+ key: "EMBEDDING_CACHE_SIZE".to_string(),
+ message: "must be at least 1".to_string(),
+ });
+ }
+
Ok(Self {
enabled,
provider,
@@ -88,6 +113,7 @@ impl EmbeddingsConfig {
ollama_base_url,
dimension,
openai_base_url,
+ cache_size,
})
}
@@ -183,13 +209,13 @@ mod tests {
std::env::remove_var("EMBEDDING_MODEL");
std::env::remove_var("OPENAI_API_KEY");
std::env::remove_var("EMBEDDING_BASE_URL");
+ std::env::remove_var("EMBEDDING_CACHE_SIZE");
}
}
#[test]
fn embeddings_disabled_not_overridden_by_openai_key() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
-
clear_embedding_env();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
@@ -240,7 +266,6 @@ mod tests {
#[test]
fn embeddings_env_override_takes_precedence() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
-
clear_embedding_env();
// SAFETY: Under ENV_MUTEX.
unsafe {
@@ -274,17 +299,12 @@ mod tests {
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
- std::env::set_var("EMBEDDING_BASE_URL", "https://custom.example.com");
+ std::env::set_var("EMBEDDING_BASE_URL", "https://8.8.8.8");
}
let settings = Settings::default();
let config = EmbeddingsConfig::resolve(&settings).expect("resolve should succeed");
- assert_eq!(
- config.openai_base_url.as_deref(),
- Some("https://custom.example.com"),
- "EMBEDDING_BASE_URL env var should be parsed into openai_base_url"
- );
-
+ assert_eq!(config.openai_base_url.as_deref(), Some("https://8.8.8.8"));
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::remove_var("EMBEDDING_BASE_URL");
@@ -303,4 +323,24 @@ mod tests {
"openai_base_url should be None when EMBEDDING_BASE_URL is not set"
);
}
+
+ #[test]
+ fn cache_size_zero_rejected() {
+ let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
+ clear_embedding_env();
+ // SAFETY: Under ENV_MUTEX.
+ unsafe {
+ std::env::set_var("EMBEDDING_CACHE_SIZE", "0");
+ }
+
+ let settings = Settings::default();
+ let result = EmbeddingsConfig::resolve(&settings);
+ assert!(result.is_err(), "cache_size=0 should be rejected");
+ let err = result.unwrap_err().to_string();
+ assert!(err.contains("at least 1"), "should mention minimum: {err}");
+ // SAFETY: Under ENV_MUTEX.
+ unsafe {
+ std::env::remove_var("EMBEDDING_CACHE_SIZE");
+ }
+ }
}
diff --git a/src/config/helpers.rs b/src/config/helpers.rs
index ce6ce092..dc40fc9f 100644
--- a/src/config/helpers.rs
+++ b/src/config/helpers.rs
@@ -176,6 +176,151 @@ pub(crate) fn parse_string_env(
Ok(optional_env(key)?.unwrap_or_else(|| default.into()))
}
+/// Validate a user-configurable base URL to prevent SSRF attacks (#1103).
+///
+/// Rejects:
+/// - Non-HTTP(S) schemes (file://, ftp://, etc.)
+/// - HTTPS URLs pointing at private/loopback/link-local IPs
+/// - HTTP URLs pointing at anything other than localhost/127.0.0.1/::1
+///
+/// This is intended for config-time validation of base URLs like
+/// `OLLAMA_BASE_URL`, `EMBEDDING_BASE_URL`, `NEARAI_BASE_URL`, etc.
+pub(crate) fn validate_base_url(url: &str, field_name: &str) -> Result<(), ConfigError> {
+ use std::net::{IpAddr, Ipv4Addr};
+
+ let parsed = reqwest::Url::parse(url).map_err(|e| ConfigError::InvalidValue {
+ key: field_name.to_string(),
+ message: format!("invalid URL '{}': {}", url, e),
+ })?;
+
+ let scheme = parsed.scheme();
+ if scheme != "http" && scheme != "https" {
+ return Err(ConfigError::InvalidValue {
+ key: field_name.to_string(),
+ message: format!("only http/https URLs are allowed, got '{}'", scheme),
+ });
+ }
+
+ let host = parsed.host_str().ok_or_else(|| ConfigError::InvalidValue {
+ key: field_name.to_string(),
+ message: "URL is missing a host".to_string(),
+ })?;
+
+ let host_lower = host.to_lowercase();
+
+ // For HTTP (non-TLS), only allow localhost — remote HTTP endpoints
+ // risk credential leakage (e.g. NEAR AI bearer tokens sent over plaintext).
+ if scheme == "http" {
+ let is_localhost = host_lower == "localhost"
+ || host_lower == "127.0.0.1"
+ || host_lower == "::1"
+ || host_lower == "[::1]"
+ || host_lower.ends_with(".localhost");
+ if !is_localhost {
+ return Err(ConfigError::InvalidValue {
+ key: field_name.to_string(),
+ message: format!(
+ "HTTP (non-TLS) is only allowed for localhost, got '{}'. \
+ Use HTTPS for remote endpoints.",
+ host
+ ),
+ });
+ }
+ return Ok(());
+ }
+
+ // Check whether an IP is in a blocked range (private, loopback,
+ // link-local, multicast, metadata, CGN, ULA).
+ let is_dangerous_ip = |ip: &IpAddr| -> bool {
+ match ip {
+ IpAddr::V4(v4) => {
+ v4.is_private()
+ || v4.is_loopback()
+ || v4.is_link_local()
+ || v4.is_multicast()
+ || v4.is_unspecified()
+ || *v4 == Ipv4Addr::new(169, 254, 169, 254)
+ || (v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64) // CGN
+ }
+ IpAddr::V6(v6) => {
+ if let Some(v4) = v6.to_ipv4_mapped() {
+ v4.is_private()
+ || v4.is_loopback()
+ || v4.is_link_local()
+ || v4.is_multicast()
+ || v4.is_unspecified()
+ || v4 == Ipv4Addr::new(169, 254, 169, 254)
+ || (v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64) // CGN
+ } else {
+ v6.is_loopback()
+ || v6.is_unspecified()
+ || (v6.octets()[0] & 0xfe) == 0xfc // ULA (fc00::/7)
+ || (v6.segments()[0] & 0xffc0) == 0xfe80 // link-local (fe80::/10)
+ || v6.octets()[0] == 0xff // multicast (ff00::/8)
+ }
+ }
+ }
+ };
+
+ // For HTTPS, reject private/loopback/link-local/metadata IPs.
+ // Check both IP literals and resolved hostnames to prevent DNS-based SSRF.
+ if let Ok(ip) = host.parse::() {
+ if is_dangerous_ip(&ip) {
+ return Err(ConfigError::InvalidValue {
+ key: field_name.to_string(),
+ message: format!(
+ "URL points to a private/internal IP '{}'. \
+ This is blocked to prevent SSRF attacks.",
+ ip
+ ),
+ });
+ }
+ } else {
+ // Hostname — resolve and check all resulting IPs as defense-in-depth.
+ // NOTE: This does NOT fully prevent DNS rebinding attacks (the hostname
+ // could resolve to a different IP at request time). Full protection
+ // would require pinning the resolved IP in the HTTP client's connector.
+ // This validation catches the common case of misconfigured or malicious URLs.
+ //
+ // NOTE: `to_socket_addrs()` performs blocking DNS resolution. This is
+ // acceptable because `validate_base_url` runs at config-load time only,
+ // before the async runtime is fully driving I/O. If this ever moves to
+ // a hot path, wrap in `tokio::task::spawn_blocking` or use
+ // `tokio::net::lookup_host`.
+ use std::net::ToSocketAddrs;
+ let port = parsed.port().unwrap_or(443);
+ match (host, port).to_socket_addrs() {
+ Ok(addrs) => {
+ for addr in addrs {
+ if is_dangerous_ip(&addr.ip()) {
+ return Err(ConfigError::InvalidValue {
+ key: field_name.to_string(),
+ message: format!(
+ "hostname '{}' resolves to private/internal IP '{}'. \
+ This is blocked to prevent SSRF attacks.",
+ host,
+ addr.ip()
+ ),
+ });
+ }
+ }
+ }
+ Err(e) => {
+ return Err(ConfigError::InvalidValue {
+ key: field_name.to_string(),
+ message: format!(
+ "failed to resolve hostname '{}': {}. \
+ Base URLs must be resolvable at config time.",
+ host, e
+ ),
+ });
+ }
+ }
+ }
+
+ Ok(())
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -226,4 +371,122 @@ mod tests {
// Now the runtime override is visible again
assert_eq!(env_or_override(key), Some("override_value".to_string()));
}
+
+ // --- validate_base_url tests (regression for #1103) ---
+
+ #[test]
+ fn validate_base_url_allows_https() {
+ // Use IP literals to avoid DNS resolution in sandboxed test environments.
+ assert!(validate_base_url("https://8.8.8.8", "TEST").is_ok());
+ assert!(validate_base_url("https://8.8.8.8/v1", "TEST").is_ok());
+ }
+
+ #[test]
+ fn validate_base_url_allows_http_localhost() {
+ assert!(validate_base_url("http://localhost:11434", "TEST").is_ok());
+ assert!(validate_base_url("http://127.0.0.1:11434", "TEST").is_ok());
+ assert!(validate_base_url("http://[::1]:11434", "TEST").is_ok());
+ }
+
+ #[test]
+ fn validate_base_url_rejects_http_remote() {
+ assert!(validate_base_url("http://evil.example.com", "TEST").is_err());
+ assert!(validate_base_url("http://192.168.1.1", "TEST").is_err());
+ }
+
+ #[test]
+ fn validate_base_url_rejects_non_http_schemes() {
+ assert!(validate_base_url("file:///etc/passwd", "TEST").is_err());
+ assert!(validate_base_url("ftp://evil.com", "TEST").is_err());
+ }
+
+ #[test]
+ fn validate_base_url_rejects_cloud_metadata() {
+ assert!(validate_base_url("https://169.254.169.254", "TEST").is_err());
+ }
+
+ #[test]
+ fn validate_base_url_rejects_private_ips() {
+ assert!(validate_base_url("https://10.0.0.1", "TEST").is_err());
+ assert!(validate_base_url("https://192.168.1.1", "TEST").is_err());
+ assert!(validate_base_url("https://172.16.0.1", "TEST").is_err());
+ }
+
+ #[test]
+ fn validate_base_url_rejects_cgn_range() {
+ // Carrier-grade NAT: 100.64.0.0/10
+ assert!(validate_base_url("https://100.64.0.1", "TEST").is_err());
+ assert!(validate_base_url("https://100.127.255.254", "TEST").is_err());
+ }
+
+ #[test]
+ fn validate_base_url_rejects_ipv4_mapped_ipv6() {
+ // ::ffff:10.0.0.1 is an IPv4-mapped IPv6 address pointing to private IP
+ assert!(validate_base_url("https://[::ffff:10.0.0.1]", "TEST").is_err());
+ assert!(validate_base_url("https://[::ffff:169.254.169.254]", "TEST").is_err());
+ }
+
+ #[test]
+ fn validate_base_url_rejects_ula_ipv6() {
+ // fc00::/7 — unique local addresses
+ assert!(validate_base_url("https://[fc00::1]", "TEST").is_err());
+ assert!(validate_base_url("https://[fd12:3456:789a::1]", "TEST").is_err());
+ }
+
+ #[test]
+ fn validate_base_url_handles_url_with_credentials() {
+ // URLs with embedded credentials — validate_base_url checks the host,
+ // not the credentials. Use IP literal to avoid DNS in sandboxed envs.
+ let result = validate_base_url("https://user:pass@8.8.8.8", "TEST");
+ assert!(result.is_ok());
+ }
+
+ #[test]
+ fn validate_base_url_rejects_empty_and_invalid() {
+ assert!(validate_base_url("", "TEST").is_err());
+ assert!(validate_base_url("not-a-url", "TEST").is_err());
+ assert!(validate_base_url("://missing-scheme", "TEST").is_err());
+ }
+
+ #[test]
+ fn validate_base_url_rejects_unspecified_ipv4() {
+ assert!(validate_base_url("https://0.0.0.0", "TEST").is_err());
+ }
+
+ #[test]
+ fn validate_base_url_rejects_ipv6_loopback_https() {
+ // IPv6 loopback is allowed over HTTP (localhost equivalent),
+ // but must be rejected over HTTPS as a dangerous IP.
+ assert!(validate_base_url("https://[::1]", "TEST").is_err());
+ }
+
+ #[test]
+ fn validate_base_url_rejects_ipv6_link_local() {
+ // fe80::/10 — link-local addresses
+ assert!(validate_base_url("https://[fe80::1]", "TEST").is_err());
+ }
+
+ #[test]
+ fn validate_base_url_rejects_ipv6_multicast() {
+ // ff00::/8 — multicast addresses
+ assert!(validate_base_url("https://[ff02::1]", "TEST").is_err());
+ }
+
+ #[test]
+ fn validate_base_url_rejects_ipv6_unspecified() {
+ // :: — unspecified address
+ assert!(validate_base_url("https://[::]", "TEST").is_err());
+ }
+
+ #[test]
+ fn validate_base_url_rejects_dns_failure() {
+ // .invalid TLD is guaranteed to never resolve (RFC 6761)
+ let result = validate_base_url("https://ssrf-test.invalid", "TEST");
+ assert!(result.is_err());
+ let err = result.unwrap_err().to_string();
+ assert!(
+ err.contains("failed to resolve"),
+ "Expected DNS resolution failure, got: {err}"
+ );
+ }
}
diff --git a/src/config/llm.rs b/src/config/llm.rs
index 64bf4ab8..f8b09800 100644
--- a/src/config/llm.rs
+++ b/src/config/llm.rs
@@ -3,7 +3,7 @@ use std::path::PathBuf;
use secrecy::SecretString;
use crate::bootstrap::ironclaw_base_dir;
-use crate::config::helpers::{optional_env, parse_optional_env};
+use crate::config::helpers::{optional_env, parse_optional_env, validate_base_url};
use crate::error::ConfigError;
use crate::llm::config::*;
use crate::llm::registry::{ProviderProtocol, ProviderRegistry};
@@ -37,6 +37,7 @@ impl LlmConfig {
},
provider: None,
bedrock: None,
+ openai_codex: None,
request_timeout_secs: 120,
cheap_model: None,
smart_routing_cascade: false,
@@ -72,8 +73,12 @@ impl LlmConfig {
backend_lower == "nearai" || backend_lower == "near_ai" || backend_lower == "near";
let is_bedrock =
backend_lower == "bedrock" || backend_lower == "aws_bedrock" || backend_lower == "aws";
+ let is_openai_codex = backend_lower == "openai_codex"
+ || backend_lower == "openai-codex"
+ || backend_lower == "codex";
- if !is_nearai && !is_bedrock && registry.find(&backend_lower).is_none() {
+ if !is_nearai && !is_bedrock && !is_openai_codex && registry.find(&backend_lower).is_none()
+ {
tracing::warn!(
"Unknown LLM backend '{}'. Will attempt as openai_compatible fallback.",
backend
@@ -81,9 +86,11 @@ impl LlmConfig {
}
// Session config (used by NearAI provider for OAuth/session-token auth)
+ let nearai_auth_url = optional_env("NEARAI_AUTH_URL")?
+ .unwrap_or_else(|| "https://private.near.ai".to_string());
+ validate_base_url(&nearai_auth_url, "NEARAI_AUTH_URL")?;
let session = SessionConfig {
- auth_base_url: optional_env("NEARAI_AUTH_URL")?
- .unwrap_or_else(|| "https://private.near.ai".to_string()),
+ auth_base_url: nearai_auth_url,
session_path: optional_env("NEARAI_SESSION_PATH")?
.map(PathBuf::from)
.unwrap_or_else(default_session_path),
@@ -92,15 +99,19 @@ impl LlmConfig {
// Always resolve NEAR AI config (used for embeddings even when not the primary backend)
let nearai_api_key = optional_env("NEARAI_API_KEY")?.map(SecretString::from);
let nearai = NearAiConfig {
- model: Self::resolve_model("NEARAI_MODEL", settings, "zai-org/GLM-latest")?,
+ model: Self::resolve_model("NEARAI_MODEL", settings, crate::llm::DEFAULT_MODEL)?,
cheap_model: optional_env("NEARAI_CHEAP_MODEL")?,
- base_url: optional_env("NEARAI_BASE_URL")?.unwrap_or_else(|| {
- if nearai_api_key.is_some() {
- "https://cloud-api.near.ai".to_string()
- } else {
- "https://private.near.ai".to_string()
- }
- }),
+ base_url: {
+ let url = optional_env("NEARAI_BASE_URL")?.unwrap_or_else(|| {
+ if nearai_api_key.is_some() {
+ "https://cloud-api.near.ai".to_string()
+ } else {
+ "https://private.near.ai".to_string()
+ }
+ });
+ validate_base_url(&url, "NEARAI_BASE_URL")?;
+ url
+ },
api_key: nearai_api_key,
fallback_model: optional_env("NEARAI_FALLBACK_MODEL")?,
max_retries: parse_optional_env("NEARAI_MAX_RETRIES", 3)?,
@@ -120,8 +131,8 @@ impl LlmConfig {
smart_routing_cascade: parse_optional_env("SMART_ROUTING_CASCADE", true)?,
};
- // Resolve registry provider config (for non-NearAI, non-Bedrock backends)
- let provider = if is_nearai || is_bedrock {
+ // Resolve registry provider config (for non-NearAI, non-Bedrock, non-Codex backends)
+ let provider = if is_nearai || is_bedrock || is_openai_codex {
None
} else {
Some(Self::resolve_registry_provider(
@@ -168,6 +179,38 @@ impl LlmConfig {
None
};
+ // Resolve OpenAI Codex config
+ let openai_codex = if is_openai_codex {
+ // Model: OPENAI_CODEX_MODEL > OPENAI_MODEL > settings.selected_model > default
+ let model = optional_env("OPENAI_CODEX_MODEL")?
+ .or(optional_env("OPENAI_MODEL")?)
+ .or_else(|| settings.selected_model.clone())
+ .unwrap_or_else(|| "gpt-5.3-codex".to_string());
+ let auth_endpoint = optional_env("OPENAI_CODEX_AUTH_URL")?
+ .unwrap_or_else(|| "https://auth.openai.com".to_string());
+ validate_base_url(&auth_endpoint, "OPENAI_CODEX_AUTH_URL")?;
+ let api_base_url = optional_env("OPENAI_CODEX_API_URL")?
+ .unwrap_or_else(|| "https://chatgpt.com/backend-api/codex".to_string());
+ validate_base_url(&api_base_url, "OPENAI_CODEX_API_URL")?;
+ let client_id = optional_env("OPENAI_CODEX_CLIENT_ID")?
+ .unwrap_or_else(|| "app_EMoamEEZ73f0CkXaXp7hrann".to_string());
+ let session_path = optional_env("OPENAI_CODEX_SESSION_PATH")?
+ .map(PathBuf::from)
+ .unwrap_or_else(|| ironclaw_base_dir().join("openai_codex_session.json"));
+ let token_refresh_margin_secs =
+ parse_optional_env("OPENAI_CODEX_REFRESH_MARGIN_SECS", 300)?;
+ Some(OpenAiCodexConfig {
+ model,
+ auth_endpoint,
+ api_base_url,
+ client_id,
+ session_path,
+ token_refresh_margin_secs,
+ })
+ } else {
+ None
+ };
+
let request_timeout_secs = parse_optional_env("LLM_REQUEST_TIMEOUT_SECS", 120)?;
// Generic cheap model (works with any backend).
@@ -183,6 +226,8 @@ impl LlmConfig {
"nearai".to_string()
} else if is_bedrock {
"bedrock".to_string()
+ } else if is_openai_codex {
+ "openai_codex".to_string()
} else if let Some(ref p) = provider {
p.provider_id.clone()
} else {
@@ -192,6 +237,7 @@ impl LlmConfig {
nearai,
provider,
bedrock,
+ openai_codex,
request_timeout_secs,
cheap_model,
smart_routing_cascade,
@@ -325,6 +371,12 @@ impl LlmConfig {
});
}
+ // Validate base URL to prevent SSRF (#1103).
+ if !base_url.is_empty() {
+ let field = base_url_env.unwrap_or("LLM_BASE_URL");
+ validate_base_url(&base_url, field)?;
+ }
+
// Resolve model
let model = Self::resolve_model(model_env, settings, default_model)?;
@@ -337,6 +389,14 @@ impl LlmConfig {
} else {
Vec::new()
};
+ let extra_headers = if canonical_id == "github_copilot" {
+ merge_extra_headers(
+ crate::llm::github_copilot_auth::default_headers(),
+ extra_headers,
+ )
+ } else {
+ extra_headers
+ };
// Resolve OAuth token (Anthropic-specific: `claude login` flow).
// Only check for OAuth token when the provider is actually Anthropic.
@@ -421,6 +481,26 @@ fn parse_extra_headers(val: &str) -> Result, ConfigError>
Ok(headers)
}
+fn merge_extra_headers(
+ defaults: Vec<(String, String)>,
+ overrides: Vec<(String, String)>,
+) -> Vec<(String, String)> {
+ let mut merged = Vec::new();
+ let mut positions = std::collections::HashMap::::new();
+
+ for (key, value) in defaults.into_iter().chain(overrides) {
+ let normalized = key.to_ascii_lowercase();
+ if let Some(existing_index) = positions.get(&normalized).copied() {
+ merged[existing_index] = (key, value);
+ } else {
+ positions.insert(normalized, merged.len());
+ merged.push((key, value));
+ }
+ }
+
+ merged
+}
+
/// Get the default session file path (~/.ironclaw/session.json).
pub fn default_session_path() -> PathBuf {
ironclaw_base_dir().join("session.json")
@@ -552,6 +632,29 @@ mod tests {
);
}
+ #[test]
+ fn merge_extra_headers_prefers_overrides_case_insensitively() {
+ let merged = merge_extra_headers(
+ vec![
+ ("User-Agent".to_string(), "default-agent".to_string()),
+ ("X-Test".to_string(), "default".to_string()),
+ ],
+ vec![
+ ("user-agent".to_string(), "override-agent".to_string()),
+ ("X-Extra".to_string(), "present".to_string()),
+ ],
+ );
+
+ assert_eq!(
+ merged,
+ vec![
+ ("user-agent".to_string(), "override-agent".to_string()),
+ ("X-Test".to_string(), "default".to_string()),
+ ("X-Extra".to_string(), "present".to_string()),
+ ]
+ );
+ }
+
/// Clear all ollama-related env vars.
fn clear_ollama_env() {
// SAFETY: Only called under ENV_MUTEX in tests.
@@ -704,6 +807,54 @@ mod tests {
assert_eq!(provider.protocol, ProviderProtocol::OpenAiCompletions);
}
+ #[test]
+ fn registry_provider_resolves_github_copilot_alias() {
+ let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
+ // SAFETY: Under ENV_MUTEX.
+ unsafe {
+ std::env::set_var("LLM_BACKEND", "github-copilot");
+ std::env::set_var("GITHUB_COPILOT_TOKEN", "gho_test_token");
+ std::env::set_var(
+ "GITHUB_COPILOT_EXTRA_HEADERS",
+ "Copilot-Integration-Id:custom-chat,X-Test:enabled",
+ );
+ }
+
+ let settings = Settings::default();
+
+ let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
+ assert_eq!(cfg.backend, "github_copilot");
+ let provider = cfg.provider.expect("provider config should be present");
+ assert_eq!(provider.provider_id, "github_copilot");
+ assert_eq!(provider.base_url, "https://api.githubcopilot.com");
+ assert_eq!(provider.model, "gpt-4o");
+ assert!(
+ provider
+ .extra_headers
+ .iter()
+ .any(|(key, value)| { key == "Copilot-Integration-Id" && value == "custom-chat" })
+ );
+ assert!(
+ provider
+ .extra_headers
+ .iter()
+ .any(|(key, value)| key == "User-Agent" && value == "GitHubCopilotChat/0.26.7")
+ );
+ assert!(
+ provider
+ .extra_headers
+ .iter()
+ .any(|(key, value)| key == "X-Test" && value == "enabled")
+ );
+
+ // SAFETY: Under ENV_MUTEX.
+ unsafe {
+ std::env::remove_var("LLM_BACKEND");
+ std::env::remove_var("GITHUB_COPILOT_TOKEN");
+ std::env::remove_var("GITHUB_COPILOT_EXTRA_HEADERS");
+ }
+ }
+
#[test]
fn nearai_backend_has_no_registry_provider() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
@@ -803,19 +954,19 @@ mod tests {
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::set_var("LLM_BACKEND", "openai_compatible");
- std::env::set_var("LLM_BASE_URL", "http://env-url/v1");
+ std::env::set_var("LLM_BASE_URL", "http://localhost:8000/v1");
}
let settings = Settings {
llm_backend: Some("openai_compatible".to_string()),
- openai_compatible_base_url: Some("http://settings-url/v1".to_string()),
+ openai_compatible_base_url: Some("http://localhost:9000/v1".to_string()),
..Default::default()
};
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
let provider = cfg.provider.expect("should have provider config");
assert_eq!(
- provider.base_url, "http://env-url/v1",
+ provider.base_url, "http://localhost:8000/v1",
"env var should take priority over settings"
);
@@ -827,7 +978,7 @@ mod tests {
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
let provider = cfg.provider.expect("should have provider config");
assert_eq!(
- provider.base_url, "http://settings-url/v1",
+ provider.base_url, "http://localhost:9000/v1",
"settings should take priority over registry default"
);
@@ -1057,4 +1208,159 @@ mod tests {
std::env::remove_var("LLM_REQUEST_TIMEOUT_SECS");
}
}
+
+ // ── OpenAI Codex tests ──────────────────────────────────────────
+
+ /// Clear all openai-codex-related env vars.
+ fn clear_openai_codex_env() {
+ // SAFETY: Only called under ENV_MUTEX in tests.
+ unsafe {
+ std::env::remove_var("LLM_BACKEND");
+ std::env::remove_var("OPENAI_CODEX_MODEL");
+ std::env::remove_var("OPENAI_MODEL");
+ }
+ }
+
+ #[test]
+ fn openai_codex_resolves_config() {
+ let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
+ clear_openai_codex_env();
+
+ let settings = Settings {
+ llm_backend: Some("openai_codex".to_string()),
+ ..Default::default()
+ };
+
+ let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
+ assert_eq!(cfg.backend, "openai_codex");
+ let codex = cfg.openai_codex.expect("codex config should be present");
+ assert_eq!(codex.model, "gpt-5.3-codex"); // default
+ assert!(
+ cfg.provider.is_none(),
+ "codex should not use registry provider"
+ );
+ }
+
+ #[test]
+ fn openai_codex_model_env_resolution() {
+ let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
+ clear_openai_codex_env();
+ // SAFETY: Under ENV_MUTEX.
+ unsafe {
+ std::env::set_var("OPENAI_CODEX_MODEL", "o3-pro");
+ }
+
+ let settings = Settings {
+ llm_backend: Some("openai_codex".to_string()),
+ ..Default::default()
+ };
+
+ let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
+ let codex = cfg.openai_codex.expect("codex config should be present");
+ assert_eq!(codex.model, "o3-pro");
+
+ // SAFETY: Under ENV_MUTEX.
+ unsafe {
+ std::env::remove_var("OPENAI_CODEX_MODEL");
+ }
+ }
+
+ #[test]
+ fn openai_codex_falls_back_to_openai_model() {
+ let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
+ clear_openai_codex_env();
+ // SAFETY: Under ENV_MUTEX.
+ unsafe {
+ std::env::set_var("OPENAI_MODEL", "gpt-4o");
+ }
+
+ let settings = Settings {
+ llm_backend: Some("openai_codex".to_string()),
+ ..Default::default()
+ };
+
+ let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
+ let codex = cfg.openai_codex.expect("codex config should be present");
+ assert_eq!(codex.model, "gpt-4o");
+
+ // SAFETY: Under ENV_MUTEX.
+ unsafe {
+ std::env::remove_var("OPENAI_MODEL");
+ }
+ }
+
+ #[test]
+ fn openai_codex_falls_back_to_selected_model() {
+ let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
+ clear_openai_codex_env();
+
+ let settings = Settings {
+ llm_backend: Some("openai_codex".to_string()),
+ selected_model: Some("gpt-4o-mini".to_string()),
+ ..Default::default()
+ };
+
+ let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
+ let codex = cfg.openai_codex.expect("codex config should be present");
+ assert_eq!(codex.model, "gpt-4o-mini");
+ }
+
+ /// Regression: SSRF validation on OPENAI_CODEX_API_URL (#1103).
+ #[test]
+ fn openai_codex_rejects_ssrf_api_url() {
+ let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
+ clear_openai_codex_env();
+ // SAFETY: Under ENV_MUTEX.
+ unsafe {
+ std::env::set_var(
+ "OPENAI_CODEX_API_URL",
+ "http://169.254.169.254/latest/meta-data",
+ );
+ }
+
+ let settings = Settings {
+ llm_backend: Some("openai_codex".to_string()),
+ ..Default::default()
+ };
+
+ let err = LlmConfig::resolve(&settings).unwrap_err();
+ let msg = err.to_string();
+ assert!(
+ msg.contains("OPENAI_CODEX_API_URL"),
+ "error should reference the field name: {msg}"
+ );
+
+ // SAFETY: Under ENV_MUTEX.
+ unsafe {
+ std::env::remove_var("OPENAI_CODEX_API_URL");
+ }
+ }
+
+ /// Regression: SSRF validation on OPENAI_CODEX_AUTH_URL (#1103).
+ #[test]
+ fn openai_codex_rejects_ssrf_auth_url() {
+ let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
+ clear_openai_codex_env();
+ // SAFETY: Under ENV_MUTEX.
+ unsafe {
+ std::env::set_var("OPENAI_CODEX_AUTH_URL", "http://10.0.0.1");
+ }
+
+ let settings = Settings {
+ llm_backend: Some("openai_codex".to_string()),
+ ..Default::default()
+ };
+
+ let err = LlmConfig::resolve(&settings).unwrap_err();
+ let msg = err.to_string();
+ assert!(
+ msg.contains("OPENAI_CODEX_AUTH_URL"),
+ "error should reference the field name: {msg}"
+ );
+
+ // SAFETY: Under ENV_MUTEX.
+ unsafe {
+ std::env::remove_var("OPENAI_CODEX_AUTH_URL");
+ }
+ }
}
diff --git a/src/config/mod.rs b/src/config/mod.rs
index 38c80880..2cbb15db 100644
--- a/src/config/mod.rs
+++ b/src/config/mod.rs
@@ -9,7 +9,7 @@ mod agent;
mod builder;
mod channels;
mod database;
-mod embeddings;
+pub(crate) mod embeddings;
mod heartbeat;
pub(crate) mod helpers;
mod hygiene;
@@ -24,6 +24,7 @@ mod skills;
mod transcription;
mod tunnel;
mod wasm;
+mod workspace;
use std::collections::HashMap;
use std::sync::{LazyLock, Mutex, Once};
@@ -38,7 +39,7 @@ pub use self::channels::{
ChannelsConfig, CliConfig, DEFAULT_GATEWAY_PORT, GatewayConfig, HttpConfig, SignalConfig,
};
pub use self::database::{DatabaseBackend, DatabaseConfig, SslMode, default_libsql_path};
-pub use self::embeddings::EmbeddingsConfig;
+pub use self::embeddings::{DEFAULT_EMBEDDING_CACHE_SIZE, EmbeddingsConfig};
pub use self::heartbeat::HeartbeatConfig;
pub use self::hygiene::HygieneConfig;
pub use self::llm::default_session_path;
@@ -53,8 +54,9 @@ pub use self::skills::SkillsConfig;
pub use self::transcription::TranscriptionConfig;
pub use self::tunnel::TunnelConfig;
pub use self::wasm::WasmConfig;
+pub use self::workspace::WorkspaceConfig;
pub use crate::llm::config::{
- BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER,
+ BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER, OpenAiCodexConfig,
RegistryProviderConfig,
};
pub use crate::llm::session::SessionConfig;
@@ -98,6 +100,7 @@ pub struct Config {
pub skills: SkillsConfig,
pub transcription: TranscriptionConfig,
pub search: WorkspaceSearchConfig,
+ pub workspace: WorkspaceConfig,
pub observability: crate::observability::ObservabilityConfig,
/// Channel-relay integration (Slack via external relay service).
/// Present only when both `CHANNEL_RELAY_URL` and `CHANNEL_RELAY_API_KEY` are set.
@@ -175,6 +178,9 @@ impl Config {
},
transcription: TranscriptionConfig::default(),
search: WorkspaceSearchConfig::default(),
+ workspace: WorkspaceConfig {
+ memory_layers: vec![],
+ },
observability: crate::observability::ObservabilityConfig::default(),
relay: None,
}
@@ -305,13 +311,21 @@ impl Config {
async fn build(settings: &Settings) -> Result {
let owner_id = resolve_owner_id(settings)?;
+ let tunnel = TunnelConfig::resolve(settings)?;
+ let channels = ChannelsConfig::resolve(settings, &owner_id)?;
+ let workspace_user_id = channels
+ .gateway
+ .as_ref()
+ .map(|gw| gw.user_id.clone())
+ .unwrap_or_else(|| "default".to_string());
+
Ok(Self {
owner_id: owner_id.clone(),
database: DatabaseConfig::resolve()?,
llm: LlmConfig::resolve(settings)?,
embeddings: EmbeddingsConfig::resolve(settings)?,
- tunnel: TunnelConfig::resolve(settings)?,
- channels: ChannelsConfig::resolve(settings, &owner_id)?,
+ tunnel,
+ channels,
agent: AgentConfig::resolve(settings)?,
safety: resolve_safety_config(settings)?,
wasm: WasmConfig::resolve(settings)?,
@@ -325,6 +339,7 @@ impl Config {
skills: SkillsConfig::resolve()?,
transcription: TranscriptionConfig::resolve(settings)?,
search: WorkspaceSearchConfig::resolve()?,
+ workspace: WorkspaceConfig::resolve(&workspace_user_id)?,
observability: crate::observability::ObservabilityConfig {
backend: std::env::var("OBSERVABILITY_BACKEND").unwrap_or_else(|_| "none".into()),
},
@@ -377,7 +392,7 @@ pub(crate) fn resolve_owner_id(settings: &Settings) -> Result,
@@ -15,12 +15,8 @@ pub struct RelayConfig {
pub instance_id: Option,
/// HTTP request timeout in seconds (default: 30).
pub request_timeout_secs: u64,
- /// SSE stream long-poll timeout in seconds (default: 86400 = 24 h).
- pub stream_timeout_secs: u64,
- /// Initial exponential backoff in milliseconds (default: 1000).
- pub backoff_initial_ms: u64,
- /// Maximum exponential backoff in milliseconds (default: 60000).
- pub backoff_max_ms: u64,
+ /// Path for the webhook callback endpoint (default: `/relay/events`).
+ pub webhook_path: String,
}
impl std::fmt::Debug for RelayConfig {
@@ -31,9 +27,7 @@ impl std::fmt::Debug for RelayConfig {
.field("callback_url", &self.callback_url)
.field("instance_id", &self.instance_id)
.field("request_timeout_secs", &self.request_timeout_secs)
- .field("stream_timeout_secs", &self.stream_timeout_secs)
- .field("backoff_initial_ms", &self.backoff_initial_ms)
- .field("backoff_max_ms", &self.backoff_max_ms)
+ .field("webhook_path", &self.webhook_path)
.finish()
}
}
@@ -41,8 +35,10 @@ impl std::fmt::Debug for RelayConfig {
impl RelayConfig {
/// Load relay config from environment variables.
///
- /// Returns `None` if either `CHANNEL_RELAY_URL` or `CHANNEL_RELAY_API_KEY`
- /// is not set, making the relay integration opt-in.
+ /// Returns `None` if either of the required env vars (`CHANNEL_RELAY_URL`,
+ /// `CHANNEL_RELAY_API_KEY`) is not set, making the relay integration opt-in.
+ /// The signing secret is fetched from channel-relay at activation time via
+ /// the authenticated `/relay/signing-secret` endpoint — no env var required.
pub fn from_env() -> Option {
Self::from_env_reader(|key| std::env::var(key).ok())
}
@@ -55,9 +51,7 @@ impl RelayConfig {
callback_url: None,
instance_id: None,
request_timeout_secs: 30,
- stream_timeout_secs: 86400,
- backoff_initial_ms: 1000,
- backoff_max_ms: 60000,
+ webhook_path: "/relay/events".into(),
}
}
@@ -73,15 +67,7 @@ impl RelayConfig {
request_timeout_secs: env("RELAY_REQUEST_TIMEOUT_SECS")
.and_then(|v| v.parse().ok())
.unwrap_or(30),
- stream_timeout_secs: env("RELAY_STREAM_TIMEOUT_SECS")
- .and_then(|v| v.parse().ok())
- .unwrap_or(86400),
- backoff_initial_ms: env("RELAY_BACKOFF_INITIAL_MS")
- .and_then(|v| v.parse().ok())
- .unwrap_or(1000),
- backoff_max_ms: env("RELAY_BACKOFF_MAX_MS")
- .and_then(|v| v.parse().ok())
- .unwrap_or(60000),
+ webhook_path: env("RELAY_WEBHOOK_PATH").unwrap_or_else(|| "/relay/events".into()),
})
}
}
@@ -97,7 +83,21 @@ mod tests {
}
#[test]
- fn from_env_reader_loads_defaults() {
+ fn from_env_reader_requires_only_url_and_api_key() {
+ // Signing secret is fetched at activation time — only URL + API key needed.
+ let config = RelayConfig::from_env_reader(|key| match key {
+ "CHANNEL_RELAY_URL" => Some("http://localhost:3001".into()),
+ "CHANNEL_RELAY_API_KEY" => Some("test-key".into()),
+ _ => None,
+ });
+ assert!(
+ config.is_some(),
+ "relay config should load with just URL + API key"
+ );
+ }
+
+ #[test]
+ fn from_env_reader_loads_all_required() {
let config = RelayConfig::from_env_reader(|key| match key {
"CHANNEL_RELAY_URL" => Some("http://localhost:3001".into()),
"CHANNEL_RELAY_API_KEY" => Some("test-key".into()),
@@ -107,9 +107,7 @@ mod tests {
assert_eq!(config.url, "http://localhost:3001");
assert_eq!(config.request_timeout_secs, 30);
- assert_eq!(config.stream_timeout_secs, 86400);
- assert_eq!(config.backoff_initial_ms, 1000);
- assert_eq!(config.backoff_max_ms, 60000);
+ assert_eq!(config.webhook_path, "/relay/events");
assert!(config.callback_url.is_none());
assert!(config.instance_id.is_none());
}
@@ -122,9 +120,7 @@ mod tests {
"IRONCLAW_OAUTH_CALLBACK_URL" => Some("https://tunnel.example.com".into()),
"IRONCLAW_INSTANCE_ID" => Some("my-instance".into()),
"RELAY_REQUEST_TIMEOUT_SECS" => Some("60".into()),
- "RELAY_STREAM_TIMEOUT_SECS" => Some("43200".into()),
- "RELAY_BACKOFF_INITIAL_MS" => Some("2000".into()),
- "RELAY_BACKOFF_MAX_MS" => Some("120000".into()),
+ "RELAY_WEBHOOK_PATH" => Some("/custom/events".into()),
_ => None,
})
.expect("config should be Some");
@@ -135,9 +131,7 @@ mod tests {
);
assert_eq!(config.instance_id.as_deref(), Some("my-instance"));
assert_eq!(config.request_timeout_secs, 60);
- assert_eq!(config.stream_timeout_secs, 43200);
- assert_eq!(config.backoff_initial_ms, 2000);
- assert_eq!(config.backoff_max_ms, 120000);
+ assert_eq!(config.webhook_path, "/custom/events");
}
#[test]
@@ -148,7 +142,7 @@ mod tests {
}
#[test]
- fn debug_redacts_api_key() {
+ fn debug_redacts_secrets() {
let config = RelayConfig::from_values("http://localhost:3001", "super-secret");
let debug = format!("{:?}", config);
assert!(debug.contains("[REDACTED]"));
diff --git a/src/config/transcription.rs b/src/config/transcription.rs
index da2bac25..fc296c9a 100644
--- a/src/config/transcription.rs
+++ b/src/config/transcription.rs
@@ -1,6 +1,6 @@
use secrecy::SecretString;
-use crate::config::helpers::{optional_env, parse_bool_env};
+use crate::config::helpers::{optional_env, parse_bool_env, validate_base_url};
use crate::error::ConfigError;
use crate::settings::Settings;
@@ -60,6 +60,11 @@ impl TranscriptionConfig {
let base_url = optional_env("TRANSCRIPTION_BASE_URL")?;
+ // Validate base URL to prevent SSRF (#1103).
+ if let Some(ref url) = base_url {
+ validate_base_url(url, "TRANSCRIPTION_BASE_URL")?;
+ }
+
Ok(Self {
enabled,
provider,
diff --git a/src/config/workspace.rs b/src/config/workspace.rs
new file mode 100644
index 00000000..5f89c655
--- /dev/null
+++ b/src/config/workspace.rs
@@ -0,0 +1,208 @@
+use crate::config::helpers::optional_env;
+use crate::error::ConfigError;
+use crate::workspace::layer::MemoryLayer;
+
+/// Workspace memory configuration.
+///
+/// Controls memory layer definitions for privacy-aware writes.
+/// Layers are parsed from the `MEMORY_LAYERS` env var (JSON array)
+/// or default to a single private layer scoped to the gateway user.
+#[derive(Debug, Clone)]
+pub struct WorkspaceConfig {
+ pub memory_layers: Vec,
+}
+
+impl WorkspaceConfig {
+ pub(crate) fn resolve(user_id: &str) -> Result {
+ let memory_layers: Vec = match optional_env("MEMORY_LAYERS")? {
+ Some(json_str) => {
+ serde_json::from_str(&json_str).map_err(|e| ConfigError::InvalidValue {
+ key: "MEMORY_LAYERS".to_string(),
+ message: format!("must be valid JSON array of layer objects: {e}"),
+ })?
+ }
+ None => MemoryLayer::default_for_user(user_id),
+ };
+
+ // Validate layer names and scopes
+ for layer in &memory_layers {
+ if layer.name.trim().is_empty() {
+ return Err(ConfigError::InvalidValue {
+ key: "MEMORY_LAYERS".to_string(),
+ message: "layer name must not be empty".to_string(),
+ });
+ }
+ if layer.name.len() > 64 {
+ return Err(ConfigError::InvalidValue {
+ key: "MEMORY_LAYERS".to_string(),
+ message: format!("layer name '{}' exceeds 64 characters", layer.name),
+ });
+ }
+ if !layer
+ .name
+ .chars()
+ .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
+ {
+ return Err(ConfigError::InvalidValue {
+ key: "MEMORY_LAYERS".to_string(),
+ message: format!(
+ "layer name '{}' contains invalid characters (only alphanumeric, _, - allowed)",
+ layer.name
+ ),
+ });
+ }
+ if layer.scope.trim().is_empty() {
+ return Err(ConfigError::InvalidValue {
+ key: "MEMORY_LAYERS".to_string(),
+ message: format!("layer '{}' has an empty scope", layer.name),
+ });
+ }
+ }
+
+ // Check for duplicate layer names
+ {
+ let mut seen = std::collections::HashSet::new();
+ for layer in &memory_layers {
+ if !seen.insert(&layer.name) {
+ return Err(ConfigError::InvalidValue {
+ key: "MEMORY_LAYERS".to_string(),
+ message: format!("duplicate layer name '{}'", layer.name),
+ });
+ }
+ }
+ }
+
+ Ok(Self { memory_layers })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::sync::Mutex;
+
+ // Serialize env-var-dependent tests to avoid races.
+ static ENV_LOCK: Mutex<()> = Mutex::new(());
+
+ fn with_env(key: &str, val: Option<&str>, f: impl FnOnce()) {
+ let _guard = ENV_LOCK.lock().unwrap();
+ let prev = std::env::var(key).ok();
+ match val {
+ Some(v) => unsafe { std::env::set_var(key, v) },
+ None => unsafe { std::env::remove_var(key) },
+ }
+ f();
+ match prev {
+ Some(v) => unsafe { std::env::set_var(key, v) },
+ None => unsafe { std::env::remove_var(key) },
+ }
+ }
+
+ #[test]
+ fn valid_json_parses_correctly() {
+ let json = r#"[{"name":"private","scope":"alice","writable":true,"sensitivity":"private"},{"name":"shared","scope":"shared","writable":true,"sensitivity":"shared"}]"#;
+ with_env("MEMORY_LAYERS", Some(json), || {
+ let config = WorkspaceConfig::resolve("alice").expect("should parse");
+ assert_eq!(config.memory_layers.len(), 2);
+ assert_eq!(config.memory_layers[0].name, "private");
+ assert_eq!(config.memory_layers[1].name, "shared");
+ });
+ }
+
+ #[test]
+ fn invalid_json_returns_error() {
+ with_env("MEMORY_LAYERS", Some("not json"), || {
+ let result = WorkspaceConfig::resolve("alice");
+ assert!(result.is_err(), "invalid JSON should fail");
+ let err = result.unwrap_err().to_string();
+ assert!(
+ err.contains("valid JSON"),
+ "error should mention JSON: {err}"
+ );
+ });
+ }
+
+ #[test]
+ fn empty_layer_name_returns_error() {
+ let json = r#"[{"name":"","scope":"alice"}]"#;
+ with_env("MEMORY_LAYERS", Some(json), || {
+ let result = WorkspaceConfig::resolve("alice");
+ assert!(result.is_err(), "empty layer name should fail");
+ let err = result.unwrap_err().to_string();
+ assert!(err.contains("empty"), "error should mention empty: {err}");
+ });
+ }
+
+ #[test]
+ fn layer_name_exceeding_64_chars_returns_error() {
+ let long_name = "a".repeat(65);
+ let json = format!(r#"[{{"name":"{long_name}","scope":"alice"}}]"#);
+ with_env("MEMORY_LAYERS", Some(&json), || {
+ let result = WorkspaceConfig::resolve("alice");
+ assert!(result.is_err(), "long layer name should fail");
+ let err = result.unwrap_err().to_string();
+ assert!(
+ err.contains("exceeds 64"),
+ "error should mention 64 chars: {err}"
+ );
+ });
+ }
+
+ #[test]
+ fn layer_name_with_invalid_chars_returns_error() {
+ for bad_name in ["has space", "has@at", "has.dot", "has/slash"] {
+ let json = format!(r#"[{{"name":"{bad_name}","scope":"alice"}}]"#);
+ with_env("MEMORY_LAYERS", Some(&json), || {
+ let result = WorkspaceConfig::resolve("alice");
+ assert!(
+ result.is_err(),
+ "layer name '{bad_name}' should fail validation"
+ );
+ let err = result.unwrap_err().to_string();
+ assert!(
+ err.contains("invalid characters"),
+ "error for '{bad_name}' should mention invalid characters: {err}"
+ );
+ });
+ }
+ }
+
+ #[test]
+ fn empty_scope_returns_error() {
+ let json = r#"[{"name":"private","scope":""}]"#;
+ with_env("MEMORY_LAYERS", Some(json), || {
+ let result = WorkspaceConfig::resolve("alice");
+ assert!(result.is_err(), "empty scope should fail");
+ let err = result.unwrap_err().to_string();
+ assert!(
+ err.contains("empty scope"),
+ "error should mention empty scope: {err}"
+ );
+ });
+ }
+
+ #[test]
+ fn duplicate_layer_names_returns_error() {
+ let json = r#"[{"name":"private","scope":"alice"},{"name":"private","scope":"bob"}]"#;
+ with_env("MEMORY_LAYERS", Some(json), || {
+ let result = WorkspaceConfig::resolve("alice");
+ assert!(result.is_err(), "duplicate names should fail");
+ let err = result.unwrap_err().to_string();
+ assert!(
+ err.contains("duplicate"),
+ "error should mention duplicate: {err}"
+ );
+ });
+ }
+
+ #[test]
+ fn missing_env_defaults_to_single_private_layer() {
+ with_env("MEMORY_LAYERS", None, || {
+ let config = WorkspaceConfig::resolve("alice").expect("should default");
+ assert_eq!(config.memory_layers.len(), 1);
+ assert_eq!(config.memory_layers[0].name, "private");
+ assert_eq!(config.memory_layers[0].scope, "alice");
+ assert!(config.memory_layers[0].writable);
+ });
+ }
+}
diff --git a/src/context/fallback.rs b/src/context/fallback.rs
new file mode 100644
index 00000000..6e765573
--- /dev/null
+++ b/src/context/fallback.rs
@@ -0,0 +1,319 @@
+//! Structured fallback deliverables for failed or stuck jobs.
+//!
+//! When a job fails or is detected as stuck, a [`FallbackDeliverable`] captures
+//! what was accomplished before the failure: partial results, action statistics,
+//! cost, and timing. This gives users visibility into terminal jobs instead of
+//! just an error string.
+//!
+//! Fallback deliverables are stored in `JobContext.metadata["fallback_deliverable"]`
+//! and surfaced through the `job_status` tool.
+
+use serde::{Deserialize, Serialize};
+
+use crate::context::memory::Memory;
+use crate::context::state::JobContext;
+
+/// Structured summary of a failed or stuck job.
+///
+/// Stored in `JobContext.metadata["fallback_deliverable"]` when a job fails
+/// or is marked stuck. Surfaced through the `job_status` tool.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct FallbackDeliverable {
+ /// True if at least one action succeeded before failure.
+ pub partial: bool,
+ /// Why the job failed.
+ pub failure_reason: String,
+ /// Last action taken before failure.
+ pub last_action: Option,
+ /// Aggregate action statistics.
+ pub action_stats: ActionStats,
+ /// Total tokens consumed.
+ pub tokens_used: u64,
+ /// Total cost incurred (decimal as string for JSON safety).
+ pub cost: String,
+ /// Wall-clock elapsed time in seconds.
+ pub elapsed_secs: f64,
+ /// Number of self-repair attempts.
+ pub repair_attempts: u32,
+}
+
+/// Summary of the last action taken before failure.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct LastAction {
+ pub tool_name: String,
+ /// Truncated to 200 bytes (UTF-8 safe).
+ pub output_preview: String,
+ pub success: bool,
+}
+
+/// Aggregate action counts.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ActionStats {
+ pub total: u32,
+ pub successful: u32,
+ pub failed: u32,
+}
+
+impl FallbackDeliverable {
+ /// Build a fallback deliverable from a job context and its memory.
+ pub fn build(ctx: &JobContext, memory: &Memory, reason: &str) -> Self {
+ let successful = memory.successful_actions() as u32;
+ let failed = memory.failed_actions() as u32;
+ let total = memory.actions.len() as u32;
+
+ let last_action = memory.last_action().map(|a| {
+ // Use sanitized output to avoid leaking secrets through the fallback API surface.
+ // For failed actions (no sanitized output), fall back to the error message.
+ // Borrow the string slice directly when possible to avoid cloning
+ // potentially large outputs just for truncation.
+ let owned_fallback;
+ let preview_str: &str = if let Some(v) = a.output_sanitized.as_ref() {
+ match v {
+ serde_json::Value::String(s) => s.as_str(),
+ other => {
+ owned_fallback = serde_json::to_string(other).unwrap_or_default();
+ &owned_fallback
+ }
+ }
+ } else if let Some(ref err) = a.error {
+ err.as_str()
+ } else {
+ ""
+ };
+ let preview = truncate_str(preview_str, 200);
+ LastAction {
+ tool_name: a.tool_name.clone(),
+ output_preview: preview.to_string(),
+ success: a.success,
+ }
+ });
+
+ let elapsed_secs = ctx.elapsed().map_or(0.0, |d| d.as_secs_f64());
+
+ Self {
+ partial: successful > 0,
+ failure_reason: truncate_str(reason, 1000).to_string(),
+ last_action,
+ action_stats: ActionStats {
+ total,
+ successful,
+ failed,
+ },
+ tokens_used: ctx.total_tokens_used,
+ cost: ctx.actual_cost.to_string(),
+ elapsed_secs,
+ repair_attempts: ctx.repair_attempts,
+ }
+ }
+}
+
+/// Truncate a string to at most `max_len` bytes on a char boundary.
+fn truncate_str(s: &str, max_len: usize) -> &str {
+ &s[..crate::util::floor_char_boundary(s, max_len)]
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::context::memory::Memory;
+ use crate::context::state::JobContext;
+ use chrono::{Duration, Utc};
+ use rust_decimal::Decimal;
+ use std::time::Duration as StdDuration;
+
+ #[test]
+ fn test_fallback_zero_actions() {
+ let ctx = JobContext::new("Test", "Empty job");
+ let memory = Memory::new(ctx.job_id);
+
+ let fb = FallbackDeliverable::build(&ctx, &memory, "timed out");
+
+ assert!(!fb.partial); // safety: test
+ assert_eq!(fb.failure_reason, "timed out"); // safety: test
+ assert!(fb.last_action.is_none()); // safety: test
+ assert_eq!(fb.action_stats.total, 0); // safety: test
+ assert_eq!(fb.action_stats.successful, 0); // safety: test
+ assert_eq!(fb.action_stats.failed, 0); // safety: test
+ assert_eq!(fb.tokens_used, 0); // safety: test
+ assert_eq!(fb.cost, "0"); // safety: test
+ assert_eq!(fb.repair_attempts, 0); // safety: test
+ }
+
+ #[test]
+ fn test_fallback_mixed_actions() {
+ let mut ctx = JobContext::new("Test", "Mixed job");
+ ctx.total_tokens_used = 5000;
+ ctx.actual_cost = Decimal::new(42, 2); // 0.42
+ ctx.repair_attempts = 1;
+
+ let mut memory = Memory::new(ctx.job_id);
+
+ // 3 successes
+ for _ in 0..3 {
+ let action = memory
+ .create_action("tool_a", serde_json::json!({}))
+ .succeed(
+ Some("output".to_string()),
+ serde_json::json!({}),
+ StdDuration::from_secs(1),
+ );
+ memory.record_action(action);
+ }
+ // 2 failures
+ for _ in 0..2 {
+ let action = memory
+ .create_action("tool_b", serde_json::json!({}))
+ .fail("broke", StdDuration::from_secs(1));
+ memory.record_action(action);
+ }
+
+ let fb = FallbackDeliverable::build(&ctx, &memory, "max iterations");
+
+ assert!(fb.partial); // safety: test
+ assert_eq!(fb.action_stats.total, 5); // safety: test
+ assert_eq!(fb.action_stats.successful, 3); // safety: test
+ assert_eq!(fb.action_stats.failed, 2); // safety: test
+ assert_eq!(fb.tokens_used, 5000); // safety: test
+ assert_eq!(fb.cost, "0.42"); // safety: test
+ assert_eq!(fb.repair_attempts, 1); // safety: test
+ assert!(fb.last_action.is_some()); // safety: test
+ let la = fb.last_action.unwrap(); // safety: test
+ assert_eq!(la.tool_name, "tool_b"); // safety: test
+ assert!(!la.success); // safety: test
+ // Failed actions should surface the error message as the output preview
+ assert_eq!(la.output_preview, "broke"); // safety: test
+ }
+
+ #[test]
+ fn test_fallback_failed_action_shows_error() {
+ let ctx = JobContext::new("Test", "Error preview");
+ let mut memory = Memory::new(ctx.job_id);
+
+ let action = memory
+ .create_action("broken_tool", serde_json::json!({}))
+ .fail("connection timed out after 30s", StdDuration::from_secs(30));
+ memory.record_action(action);
+
+ let fb = FallbackDeliverable::build(&ctx, &memory, "tool failure");
+ let la = fb.last_action.unwrap(); // safety: test
+ assert!(!la.success); // safety: test
+ assert_eq!(la.output_preview, "connection timed out after 30s"); // safety: test
+ }
+
+ #[test]
+ fn test_fallback_last_action_truncation() {
+ let ctx = JobContext::new("Test", "Truncation");
+ let mut memory = Memory::new(ctx.job_id);
+
+ let long_output = "x".repeat(500);
+ let action = memory
+ .create_action("tool_c", serde_json::json!({}))
+ .succeed(
+ Some(long_output.clone()),
+ serde_json::Value::String(long_output),
+ StdDuration::from_secs(1),
+ );
+ memory.record_action(action);
+
+ let fb = FallbackDeliverable::build(&ctx, &memory, "failed");
+ let la = fb.last_action.unwrap(); // safety: test
+ assert!(la.output_preview.len() <= 200); // safety: test
+ assert!(!la.output_preview.is_empty()); // safety: test
+ }
+
+ #[test]
+ fn test_fallback_uses_sanitized_output() {
+ let ctx = JobContext::new("Test", "Sanitized");
+ let mut memory = Memory::new(ctx.job_id);
+
+ let action = memory
+ .create_action("tool_d", serde_json::json!({}))
+ .succeed(
+ Some("[REDACTED]".to_string()),
+ serde_json::json!({"api_key": "sk-secret-key-12345"}),
+ StdDuration::from_secs(1),
+ );
+ memory.record_action(action);
+
+ let fb = FallbackDeliverable::build(&ctx, &memory, "failed");
+ let la = fb.last_action.unwrap(); // safety: test
+ // Must use sanitized output, not raw
+ assert!(!la.output_preview.contains("sk-secret")); // safety: test
+ assert!(la.output_preview.contains("REDACTED")); // safety: test
+ }
+
+ #[test]
+ fn test_fallback_elapsed_time() {
+ let mut ctx = JobContext::new("Test", "Timing");
+ let now = Utc::now();
+ ctx.started_at = Some(now - Duration::seconds(10));
+ ctx.completed_at = Some(now);
+
+ let memory = Memory::new(ctx.job_id);
+ let fb = FallbackDeliverable::build(&ctx, &memory, "failed");
+
+ // Should be approximately 10 seconds
+ assert!((fb.elapsed_secs - 10.0).abs() < 0.1); // safety: test
+ }
+
+ #[test]
+ fn test_fallback_no_started_at() {
+ let ctx = JobContext::new("Test", "Never started");
+ let memory = Memory::new(ctx.job_id);
+
+ let fb = FallbackDeliverable::build(&ctx, &memory, "failed");
+ assert!((fb.elapsed_secs - 0.0).abs() < 0.001); // safety: test
+ }
+
+ #[test]
+ fn test_fallback_elapsed_time_no_completed_at() {
+ let mut ctx = JobContext::new("Test", "Still running");
+ ctx.started_at = Some(Utc::now() - Duration::seconds(5));
+ // completed_at is None — should use Utc::now() as fallback
+
+ let memory = Memory::new(ctx.job_id);
+ let fb = FallbackDeliverable::build(&ctx, &memory, "stuck");
+
+ // Should be approximately 5 seconds (using now as end time)
+ assert!(fb.elapsed_secs >= 4.0 && fb.elapsed_secs <= 7.0); // safety: test
+ }
+
+ #[test]
+ fn test_fallback_failure_reason_truncation() {
+ let ctx = JobContext::new("Test", "Long reason");
+ let memory = Memory::new(ctx.job_id);
+
+ let long_reason = "x".repeat(5000);
+ let fb = FallbackDeliverable::build(&ctx, &memory, &long_reason);
+
+ assert!(fb.failure_reason.len() <= 1000); // safety: test
+ assert!(!fb.failure_reason.is_empty()); // safety: test
+ }
+
+ #[test]
+ fn test_truncate_str_ascii() {
+ assert_eq!(truncate_str("hello", 10), "hello"); // safety: test
+ assert_eq!(truncate_str("hello world", 5), "hello"); // safety: test
+ }
+
+ #[test]
+ fn test_truncate_str_unicode() {
+ // "é" is 2 bytes in UTF-8
+ let s = "café";
+ assert_eq!(truncate_str(s, 10), "café"); // safety: test
+ // Truncating at 4 would split "é", should back up to 3
+ assert_eq!(truncate_str(s, 4), "caf"); // safety: test
+ }
+
+ #[test]
+ fn test_fallback_serialization() {
+ let ctx = JobContext::new("Test", "Serialize");
+ let memory = Memory::new(ctx.job_id);
+ let fb = FallbackDeliverable::build(&ctx, &memory, "test error");
+
+ // Should serialize to JSON and back without error
+ let json = serde_json::to_value(&fb).unwrap(); // safety: test
+ let deserialized: FallbackDeliverable = serde_json::from_value(json).unwrap(); // safety: test
+ assert_eq!(deserialized.failure_reason, "test error"); // safety: test
+ }
+}
diff --git a/src/context/manager.rs b/src/context/manager.rs
index 6eb63260..28343003 100644
--- a/src/context/manager.rs
+++ b/src/context/manager.rs
@@ -1,11 +1,12 @@
//! Context manager for handling multiple job contexts.
use std::collections::HashMap;
+use std::time::Duration;
use tokio::sync::RwLock;
use uuid::Uuid;
-use crate::context::{JobContext, Memory};
+use crate::context::{JobContext, JobState, Memory};
use crate::error::JobError;
/// Manages contexts for multiple concurrent jobs.
@@ -45,12 +46,41 @@ impl ContextManager {
title: impl Into,
description: impl Into,
) -> Result {
- // Hold write lock for the entire check-insert to prevent TOCTOU races
- // where two concurrent calls both pass the parallel_count check.
+ let context = JobContext::with_user(user_id, title, description);
+ let job_id = context.job_id;
+ self.insert_context(context).await?;
+ Ok(job_id)
+ }
+
+ /// Register a sandbox job with a pre-determined ID.
+ ///
+ /// Unlike `create_job_for_user` (which generates its own UUID), this method
+ /// accepts an existing `job_id` — used by `execute_sandbox()` which creates
+ /// the UUID before the container so it can be shared with Docker labels and
+ /// DB persistence.
+ ///
+ /// The job starts in `InProgress` state since the container is about to be
+ /// created. Counts against `max_jobs` like any other job.
+ pub async fn register_sandbox_job(
+ &self,
+ job_id: Uuid,
+ user_id: impl Into,
+ title: impl Into,
+ description: impl Into,
+ ) -> Result<(), JobError> {
+ let mut context = JobContext::with_user(user_id, title, description);
+ context.job_id = job_id;
+ context.state = JobState::InProgress;
+ context.started_at = Some(chrono::Utc::now());
+ self.insert_context(context).await
+ }
+
+ /// Check max_jobs limit, insert context, and allocate memory.
+ ///
+ /// Holds the write lock for the entire check-insert to prevent TOCTOU
+ /// races where two concurrent calls both pass the parallel_count check.
+ async fn insert_context(&self, context: JobContext) -> Result<(), JobError> {
let mut contexts = self.contexts.write().await;
- // Only count jobs that consume execution slots (Pending, InProgress, Stuck).
- // Completed and Submitted jobs are no longer actively executing and shouldn't
- // block new job creation.
let parallel_count = contexts
.values()
.filter(|c| c.state.is_parallel_blocking())
@@ -60,15 +90,16 @@ impl ContextManager {
return Err(JobError::MaxJobsExceeded { max: self.max_jobs });
}
- let context = JobContext::with_user(user_id, title, description);
let job_id = context.job_id;
contexts.insert(job_id, context);
drop(contexts);
- let memory = Memory::new(job_id);
- self.memories.write().await.insert(job_id, memory);
+ self.memories
+ .write()
+ .await
+ .insert(job_id, Memory::new(job_id));
- Ok(job_id)
+ Ok(())
}
/// Get a job context by ID.
@@ -205,12 +236,46 @@ impl ContextManager {
}
/// Find stuck jobs.
+ ///
+ /// Returns jobs that are explicitly in `Stuck` state, plus `InProgress`
+ /// jobs that have been running longer than `elapsed_threshold` (if provided).
+ /// The threshold-based detection catches jobs that never transitioned to
+ /// `Stuck` (e.g., due to a deadlock or unhandled timeout).
pub async fn find_stuck_jobs(&self) -> Vec {
+ self.find_stuck_jobs_with_threshold(None).await
+ }
+
+ /// Find stuck jobs with an optional elapsed threshold for `InProgress` detection.
+ pub async fn find_stuck_jobs_with_threshold(
+ &self,
+ elapsed_threshold: Option,
+ ) -> Vec {
+ let now = chrono::Utc::now();
self.contexts
.read()
.await
.iter()
- .filter(|(_, c)| c.state == crate::context::JobState::Stuck)
+ .filter(|(_, c)| {
+ // Always include explicitly Stuck jobs.
+ if c.state == crate::context::JobState::Stuck {
+ return true;
+ }
+ // Detect InProgress jobs that have been running beyond the elapsed threshold.
+ // NOTE: `started_at` is set on the first transition to InProgress and is
+ // NOT reset when a job recovers from Stuck back to InProgress. This means
+ // a recovered job may be re-detected on the next scan. A future improvement
+ // could track `in_progress_since` or use the most recent StateTransition
+ // with `to == InProgress` to avoid false positives on recovered jobs.
+ if c.state == crate::context::JobState::InProgress
+ && let Some(threshold) = elapsed_threshold
+ && let Some(started) = c.started_at
+ {
+ let elapsed = now.signed_duration_since(started);
+ let elapsed_secs = elapsed.num_seconds().max(0) as u64;
+ return elapsed_secs > threshold.as_secs();
+ }
+ false
+ })
.map(|(id, _)| *id)
.collect()
}
@@ -629,6 +694,48 @@ mod tests {
assert_eq!(stuck[0], id2);
}
+ /// Regression test for #1223: InProgress jobs exceeding the threshold
+ /// should be detected as stuck even if they never transitioned to Stuck.
+ #[tokio::test]
+ async fn find_stuck_jobs_with_threshold_detects_idle_in_progress() {
+ let manager = ContextManager::new(10);
+
+ let id1 = manager.create_job("Active job", "desc").await.unwrap();
+ let id2 = manager.create_job("Idle job", "desc").await.unwrap();
+
+ // Both transition to InProgress
+ for id in [id1, id2] {
+ manager
+ .update_context(id, |ctx| {
+ ctx.transition_to(crate::context::JobState::InProgress, None)
+ })
+ .await
+ .unwrap()
+ .unwrap();
+ }
+
+ // Backdate id2's started_at to simulate a long-running job
+ manager
+ .update_context(id2, |ctx| -> Result<(), crate::error::JobError> {
+ ctx.started_at = Some(chrono::Utc::now() - chrono::Duration::seconds(600));
+ Ok(())
+ })
+ .await
+ .unwrap()
+ .unwrap();
+
+ // With a 5-minute threshold, only id2 (10 min) should be detected
+ let stuck = manager
+ .find_stuck_jobs_with_threshold(Some(Duration::from_secs(300)))
+ .await;
+ assert_eq!(stuck.len(), 1);
+ assert_eq!(stuck[0], id2);
+
+ // Without threshold, neither InProgress job is detected (no explicit Stuck state)
+ let stuck_no_threshold = manager.find_stuck_jobs().await;
+ assert!(stuck_no_threshold.is_empty());
+ }
+
#[tokio::test]
async fn active_count_tracks_non_terminal_jobs() {
let manager = ContextManager::new(10);
@@ -1185,4 +1292,87 @@ mod tests {
}
}
}
+
+ // === Regression: sandbox jobs must be visible to query tools ===
+ // Before the fix, execute_sandbox() only persisted to DB but never
+ // registered in ContextManager, making sandbox jobs invisible to
+ // list_jobs, job_status, job_events, and resolve_job_id.
+
+ #[tokio::test]
+ async fn register_sandbox_job_visible_to_queries() {
+ let manager = ContextManager::new(5);
+ let job_id = Uuid::new_v4();
+
+ manager
+ .register_sandbox_job(
+ job_id,
+ "user-42",
+ "Run tests",
+ "Execute test suite in sandbox",
+ )
+ .await
+ .unwrap();
+
+ // Job should be retrievable by ID (used by job_status, job_events)
+ let ctx = manager.get_context(job_id).await.unwrap();
+ assert_eq!(ctx.job_id, job_id);
+ assert_eq!(ctx.user_id, "user-42");
+ assert_eq!(ctx.title, "Run tests");
+ assert_eq!(ctx.state, JobState::InProgress);
+ assert!(ctx.started_at.is_some());
+
+ // Job should appear in all_jobs (used by resolve_job_id prefix matching)
+ let all = manager.all_jobs().await;
+ assert!(all.contains(&job_id));
+
+ // Job should appear in user-scoped listing (used by list_jobs)
+ let user_jobs = manager.all_jobs_for("user-42").await;
+ assert!(user_jobs.contains(&job_id));
+
+ // Job should appear in active jobs listing
+ let active = manager.active_jobs_for("user-42").await;
+ assert!(active.contains(&job_id));
+ }
+
+ #[tokio::test]
+ async fn register_sandbox_job_respects_max_jobs() {
+ let manager = ContextManager::new(2);
+
+ // Fill up the slots with sandbox jobs
+ manager
+ .register_sandbox_job(Uuid::new_v4(), "user-1", "Job 1", "desc")
+ .await
+ .unwrap();
+ manager
+ .register_sandbox_job(Uuid::new_v4(), "user-1", "Job 2", "desc")
+ .await
+ .unwrap();
+
+ // Third should fail
+ let result = manager
+ .register_sandbox_job(Uuid::new_v4(), "user-1", "Job 3", "desc")
+ .await;
+ assert!(matches!(result, Err(JobError::MaxJobsExceeded { max: 2 })));
+ }
+
+ #[tokio::test]
+ async fn register_sandbox_job_transitions_correctly() {
+ let manager = ContextManager::new(5);
+ let job_id = Uuid::new_v4();
+
+ manager
+ .register_sandbox_job(job_id, "user-1", "Task", "desc")
+ .await
+ .unwrap();
+
+ // Should be able to transition InProgress -> Completed
+ manager
+ .update_context(job_id, |ctx| ctx.transition_to(JobState::Completed, None))
+ .await
+ .unwrap()
+ .unwrap();
+
+ let ctx = manager.get_context(job_id).await.unwrap();
+ assert_eq!(ctx.state, JobState::Completed);
+ }
}
diff --git a/src/context/memory.rs b/src/context/memory.rs
index 9452c649..05313e67 100644
--- a/src/context/memory.rs
+++ b/src/context/memory.rs
@@ -58,15 +58,19 @@ impl ActionRecord {
}
/// Mark the action as successful.
+ ///
+ /// `output_sanitized` is the tool output after safety processing (string).
+ /// `output_raw` is the original tool result (JSON value, stored as a
+ /// pretty-printed JSON string in `ActionRecord.output_raw`).
pub fn succeed(
mut self,
- output_raw: Option,
- output_sanitized: serde_json::Value,
+ output_sanitized: Option,
+ output_raw: serde_json::Value,
duration: Duration,
) -> Self {
self.success = true;
- self.output_raw = output_raw;
- self.output_sanitized = Some(output_sanitized);
+ self.output_raw = Some(serde_json::to_string_pretty(&output_raw).unwrap_or_default());
+ self.output_sanitized = output_sanitized.map(serde_json::Value::String);
self.duration = duration;
self
}
@@ -248,15 +252,15 @@ mod tests {
#[test]
fn test_action_record() {
let action = ActionRecord::new(0, "test", serde_json::json!({"key": "value"}));
- assert_eq!(action.sequence, 0);
- assert!(!action.success);
+ assert_eq!(action.sequence, 0); // safety: test
+ assert!(!action.success); // safety: test
let action = action.succeed(
Some("raw".to_string()),
serde_json::json!({"result": "ok"}),
Duration::from_millis(100),
);
- assert!(action.success);
+ assert!(action.success); // safety: test
}
#[test]
@@ -267,7 +271,7 @@ mod tests {
memory.add(ChatMessage::user("How are you?"));
memory.add(ChatMessage::assistant("Good!"));
- assert_eq!(memory.len(), 3); // Oldest removed
+ assert_eq!(memory.len(), 3); // Oldest removed // safety: test
}
#[test]
@@ -286,9 +290,9 @@ mod tests {
.with_cost(Decimal::new(20, 1));
memory.record_action(action2);
- assert_eq!(memory.total_cost(), Decimal::new(30, 1));
- assert_eq!(memory.total_duration(), Duration::from_secs(3));
- assert_eq!(memory.successful_actions(), 2);
+ assert_eq!(memory.total_cost(), Decimal::new(30, 1)); // safety: test
+ assert_eq!(memory.total_duration(), Duration::from_secs(3)); // safety: test
+ assert_eq!(memory.successful_actions(), 2); // safety: test
}
#[test]
@@ -296,11 +300,11 @@ mod tests {
let action = ActionRecord::new(1, "broken_tool", serde_json::json!({"x": 1}));
let action = action.fail("something went wrong", Duration::from_millis(50));
- assert!(!action.success);
- assert_eq!(action.error.as_deref(), Some("something went wrong"));
- assert_eq!(action.duration, Duration::from_millis(50));
- assert!(action.output_raw.is_none());
- assert!(action.output_sanitized.is_none());
+ assert!(!action.success); // safety: test
+ assert_eq!(action.error.as_deref(), Some("something went wrong")); // safety: test
+ assert_eq!(action.duration, Duration::from_millis(50)); // safety: test
+ assert!(action.output_raw.is_none()); // safety: test
+ assert!(action.output_sanitized.is_none()); // safety: test
}
#[test]
@@ -308,9 +312,9 @@ mod tests {
let action = ActionRecord::new(0, "risky_tool", serde_json::json!({}));
let action = action.with_warnings(vec!["suspicious pattern".into(), "possible xss".into()]);
- assert_eq!(action.sanitization_warnings.len(), 2);
- assert_eq!(action.sanitization_warnings[0], "suspicious pattern");
- assert_eq!(action.sanitization_warnings[1], "possible xss");
+ assert_eq!(action.sanitization_warnings.len(), 2); // safety: test
+ assert_eq!(action.sanitization_warnings[0], "suspicious pattern"); // safety: test
+ assert_eq!(action.sanitization_warnings[1], "possible xss"); // safety: test
}
#[test]
@@ -319,41 +323,46 @@ mod tests {
let cost = Decimal::new(42, 2); // 0.42
let action = action.with_cost(cost);
- assert_eq!(action.cost, Some(Decimal::new(42, 2)));
+ assert_eq!(action.cost, Some(Decimal::new(42, 2))); // safety: test
}
#[test]
fn test_action_record_new_defaults() {
let action = ActionRecord::new(5, "my_tool", serde_json::json!({"key": "val"}));
- assert_eq!(action.sequence, 5);
- assert_eq!(action.tool_name, "my_tool");
- assert_eq!(action.input, serde_json::json!({"key": "val"}));
- assert!(!action.success);
- assert!(action.output_raw.is_none());
- assert!(action.output_sanitized.is_none());
- assert!(action.sanitization_warnings.is_empty());
- assert!(action.cost.is_none());
- assert_eq!(action.duration, Duration::ZERO);
- assert!(action.error.is_none());
+ assert_eq!(action.sequence, 5); // safety: test
+ assert_eq!(action.tool_name, "my_tool"); // safety: test
+ assert_eq!(action.input, serde_json::json!({"key": "val"})); // safety: test
+ assert!(!action.success); // safety: test
+ assert!(action.output_raw.is_none()); // safety: test
+ assert!(action.output_sanitized.is_none()); // safety: test
+ assert!(action.sanitization_warnings.is_empty()); // safety: test
+ assert!(action.cost.is_none()); // safety: test
+ assert_eq!(action.duration, Duration::ZERO); // safety: test
+ assert!(action.error.is_none()); // safety: test
}
#[test]
fn test_action_record_succeed_sets_fields() {
let action = ActionRecord::new(0, "tool", serde_json::json!({}));
let action = action.succeed(
- Some("raw output here".into()),
+ Some("sanitized output".into()),
serde_json::json!({"clean": true}),
Duration::from_secs(7),
);
- assert!(action.success);
- assert_eq!(action.output_raw.as_deref(), Some("raw output here"));
+ assert!(action.success); // safety: test
+ // output_raw is the JSON value pretty-printed
+ let expected_raw =
+ serde_json::to_string_pretty(&serde_json::json!({"clean": true})).unwrap(); // safety: test
+ assert_eq!(action.output_raw.as_deref(), Some(expected_raw.as_str())); // safety: test
+ // output_sanitized wraps the string in a JSON string value
assert_eq!(
+ /* safety: test */
action.output_sanitized,
- Some(serde_json::json!({"clean": true}))
+ Some(serde_json::json!("sanitized output"))
);
- assert_eq!(action.duration, Duration::from_secs(7));
+ assert_eq!(action.duration, Duration::from_secs(7)); // safety: test
}
#[test]
@@ -361,13 +370,13 @@ mod tests {
let mut mem = ConversationMemory::new(10);
mem.add(ChatMessage::user("hello"));
mem.add(ChatMessage::assistant("hi"));
- assert_eq!(mem.len(), 2);
- assert!(!mem.is_empty());
+ assert_eq!(mem.len(), 2); // safety: test
+ assert!(!mem.is_empty()); // safety: test
mem.clear();
- assert_eq!(mem.len(), 0);
- assert!(mem.is_empty());
- assert!(mem.messages().is_empty());
+ assert_eq!(mem.len(), 0); // safety: test
+ assert!(mem.is_empty()); // safety: test
+ assert!(mem.messages().is_empty()); // safety: test
}
#[test]
@@ -379,20 +388,20 @@ mod tests {
mem.add(ChatMessage::assistant("four"));
let last_2 = mem.last_n(2);
- assert_eq!(last_2.len(), 2);
- assert_eq!(last_2[0].content, "three");
- assert_eq!(last_2[1].content, "four");
+ assert_eq!(last_2.len(), 2); // safety: test
+ assert_eq!(last_2[0].content, "three"); // safety: test
+ assert_eq!(last_2[1].content, "four"); // safety: test
// Requesting more than available returns all
let last_100 = mem.last_n(100);
- assert_eq!(last_100.len(), 4);
+ assert_eq!(last_100.len(), 4); // safety: test
}
#[test]
fn test_conversation_memory_last_n_empty() {
let mem = ConversationMemory::new(10);
let result = mem.last_n(5);
- assert!(result.is_empty());
+ assert!(result.is_empty()); // safety: test
}
#[test]
@@ -405,13 +414,13 @@ mod tests {
// At capacity (3). Adding one more should trim, but keep system.
mem.add(ChatMessage::user("msg3"));
- assert_eq!(mem.len(), 3);
+ assert_eq!(mem.len(), 3); // safety: test
// System message must survive
- assert_eq!(mem.messages()[0].role, crate::llm::Role::System);
- assert_eq!(mem.messages()[0].content, "You are helpful");
+ assert_eq!(mem.messages()[0].role, crate::llm::Role::System); // safety: test
+ assert_eq!(mem.messages()[0].content, "You are helpful"); // safety: test
// Oldest non-system message (msg1) should be gone
- assert_eq!(mem.messages()[1].content, "msg2");
- assert_eq!(mem.messages()[2].content, "msg3");
+ assert_eq!(mem.messages()[1].content, "msg2"); // safety: test
+ assert_eq!(mem.messages()[2].content, "msg3"); // safety: test
}
#[test]
@@ -422,9 +431,9 @@ mod tests {
// Now at capacity. Add another.
mem.add(ChatMessage::user("b"));
- assert_eq!(mem.len(), 2);
- assert_eq!(mem.messages()[0].role, crate::llm::Role::System);
- assert_eq!(mem.messages()[1].content, "b");
+ assert_eq!(mem.len(), 2); // safety: test
+ assert_eq!(mem.messages()[0].role, crate::llm::Role::System); // safety: test
+ assert_eq!(mem.messages()[1].content, "b"); // safety: test
}
#[test]
@@ -440,7 +449,7 @@ mod tests {
mem.add(ChatMessage::user("hello"));
// Should have broken out rather than looping forever.
// The system message is protected, so len may exceed max.
- assert!(mem.len() <= 2);
+ assert!(mem.len() <= 2); // safety: test
}
#[test]
@@ -459,14 +468,14 @@ mod tests {
.fail("oops", Duration::from_millis(2));
memory.record_action(err);
- assert_eq!(memory.successful_actions(), 1);
- assert_eq!(memory.failed_actions(), 1);
+ assert_eq!(memory.successful_actions(), 1); // safety: test
+ assert_eq!(memory.failed_actions(), 1); // safety: test
}
#[test]
fn test_memory_last_action() {
let mut memory = Memory::new(Uuid::new_v4());
- assert!(memory.last_action().is_none());
+ assert!(memory.last_action().is_none()); // safety: test
let a1 = memory
.create_action("first", serde_json::json!({}))
@@ -478,8 +487,8 @@ mod tests {
.fail("nope", Duration::ZERO);
memory.record_action(a2);
- let last = memory.last_action().unwrap();
- assert_eq!(last.tool_name, "second");
+ let last = memory.last_action().unwrap(); // safety: test
+ assert_eq!(last.tool_name, "second"); // safety: test
}
#[test]
@@ -499,9 +508,9 @@ mod tests {
);
memory.record_action(a);
- assert_eq!(memory.actions_by_tool("shell").len(), 3);
- assert_eq!(memory.actions_by_tool("http").len(), 1);
- assert_eq!(memory.actions_by_tool("nonexistent").len(), 0);
+ assert_eq!(memory.actions_by_tool("shell").len(), 3); // safety: test
+ assert_eq!(memory.actions_by_tool("http").len(), 1); // safety: test
+ assert_eq!(memory.actions_by_tool("nonexistent").len(), 0); // safety: test
}
#[test]
@@ -509,25 +518,25 @@ mod tests {
let mut memory = Memory::new(Uuid::new_v4());
let a0 = memory.create_action("t", serde_json::json!({}));
- assert_eq!(a0.sequence, 0);
+ assert_eq!(a0.sequence, 0); // safety: test
let a1 = memory.create_action("t", serde_json::json!({}));
- assert_eq!(a1.sequence, 1);
+ assert_eq!(a1.sequence, 1); // safety: test
let a2 = memory.create_action("t", serde_json::json!({}));
- assert_eq!(a2.sequence, 2);
+ assert_eq!(a2.sequence, 2); // safety: test
}
#[test]
fn test_memory_add_message_delegates_to_conversation() {
let mut memory = Memory::new(Uuid::new_v4());
- assert!(memory.conversation.is_empty());
+ assert!(memory.conversation.is_empty()); // safety: test
memory.add_message(ChatMessage::user("hello"));
memory.add_message(ChatMessage::assistant("hi"));
- assert_eq!(memory.conversation.len(), 2);
- assert_eq!(memory.conversation.messages()[0].content, "hello");
+ assert_eq!(memory.conversation.len(), 2); // safety: test
+ assert_eq!(memory.conversation.messages()[0].content, "hello"); // safety: test
}
#[test]
@@ -540,7 +549,7 @@ mod tests {
.succeed(None, serde_json::json!({}), Duration::ZERO);
memory.record_action(a);
- assert_eq!(memory.total_cost(), Decimal::ZERO);
+ assert_eq!(memory.total_cost(), Decimal::ZERO); // safety: test
}
#[test]
@@ -560,6 +569,6 @@ mod tests {
memory.record_action(a2);
// Both successful and failed actions contribute to total duration
- assert_eq!(memory.total_duration(), Duration::from_millis(300));
+ assert_eq!(memory.total_duration(), Duration::from_millis(300)); // safety: test
}
}
diff --git a/src/context/mod.rs b/src/context/mod.rs
index a7dd61de..4b482038 100644
--- a/src/context/mod.rs
+++ b/src/context/mod.rs
@@ -6,10 +6,12 @@
//! - State machine
//! - Resource tracking
+pub mod fallback;
mod manager;
mod memory;
mod state;
+pub use fallback::FallbackDeliverable;
pub use manager::ContextManager;
pub use memory::{ActionRecord, ConversationMemory, Memory};
pub use state::{JobContext, JobState, StateTransition, TokenBudgetExceeded};
diff --git a/src/db/CLAUDE.md b/src/db/CLAUDE.md
index 123b9d95..22edc8f1 100644
--- a/src/db/CLAUDE.md
+++ b/src/db/CLAUDE.md
@@ -75,7 +75,7 @@ The `Database` supertrait is composed of seven sub-traits. Leaf consumers can de
| Numeric/Decimal | `NUMERIC` | `TEXT` (preserves `rust_decimal` precision) |
| Arrays | `TEXT[]` | `TEXT` (JSON-encoded array) |
| Booleans | `BOOLEAN` | `INTEGER` (0/1) |
-| Vector embeddings | `VECTOR` (any dim, V9 removed fixed 1536) | `F32_BLOB(1536)` via `libsql_vector_idx` |
+| Vector embeddings | `VECTOR` (any dim, V9 removed fixed 1536) | `F32_BLOB(N)` via `libsql_vector_idx` (dimension set dynamically by `ensure_vector_index`) |
| Full-text search | `tsvector` + `ts_rank_cd` | FTS5 virtual table + sync triggers |
| JSON path update | `jsonb_set(col, '{key}', val)` | `json_patch(col, '{"key": val}')` |
| PL/pgSQL | Functions | Triggers (no stored procs in SQLite) |
@@ -90,7 +90,7 @@ The `Database` supertrait is composed of seven sub-traits. Leaf consumers can de
**Timestamp write format:** Always write timestamps with `fmt_ts(dt)` (RFC 3339, millisecond precision). Read with `get_ts()` / `get_opt_ts()` which handle legacy naive formats too.
-**Vector dimension:** PostgreSQL V9 migration changed the column to unbounded `vector` (removing the HNSW index). libSQL still uses `F32_BLOB(1536)` — if you use a different-dimension embedding model, the libSQL schema needs updating too.
+**Vector dimension:** PostgreSQL V9 migration changed the column to unbounded `vector` (removing the HNSW index). libSQL dynamically creates `F32_BLOB(N)` with the correct dimension via `ensure_vector_index()` during `run_migrations()`, reading `EMBEDDING_DIMENSION` / `EMBEDDING_MODEL` from env vars.
**Connection per operation:** `LibSqlBackend::connect()` creates a fresh connection for every operation, sets `PRAGMA busy_timeout = 5000`, and closes it when the `Connection` is dropped. This is intentional — the libSQL SDK does not offer a pool. Avoid holding connections open across `await` points.
@@ -134,7 +134,7 @@ The `Database` supertrait is composed of seven sub-traits. Leaf consumers can de
- **Settings reload** — `Config::from_db` skipped (requires `Store`)
- **No incremental migrations** — schema is idempotent CREATE IF NOT EXISTS; no ALTER TABLE support; column additions require a new versioned approach
- **No encryption at rest** — only secrets (API tokens) are AES-256-GCM encrypted; all other data is plaintext SQLite
-- **Hybrid search** — both FTS5 and vector search (`libsql_vector_idx`) are implemented; however, the vector index is fixed at `F32_BLOB(1536)` while PostgreSQL switched to unbounded `vector` in V9
+- **Hybrid search** — both FTS5 and vector search (`libsql_vector_idx`) are implemented; `ensure_vector_index()` dynamically creates the index with the correct `F32_BLOB(N)` dimension from env vars during `run_migrations()`
- **Write serialization** — WAL mode allows concurrent readers but only one writer at a time; busy timeout is 5 s, which may cause timeouts under high write concurrency
## Running Locally with libSQL
diff --git a/src/db/libsql/mod.rs b/src/db/libsql/mod.rs
index d19089c1..890aea0c 100644
--- a/src/db/libsql/mod.rs
+++ b/src/db/libsql/mod.rs
@@ -341,6 +341,14 @@ impl Database for LibSqlBackend {
.map_err(|e| DatabaseError::Migration(format!("libSQL migration failed: {}", e)))?;
// Apply incremental migrations (V9+) tracked in _migrations table.
libsql_migrations::run_incremental(&conn).await?;
+
+ // Set up vector index if embeddings are configured.
+ // This dynamically creates a libsql_vector_idx on memory_chunks.embedding
+ // with the correct F32_BLOB(N) dimension inferred from env vars.
+ if let Some(dimension) = workspace::resolve_embedding_dimension() {
+ self.ensure_vector_index(dimension).await?;
+ }
+
Ok(())
}
}
diff --git a/src/db/libsql/routines.rs b/src/db/libsql/routines.rs
index b75afb47..6702cc1b 100644
--- a/src/db/libsql/routines.rs
+++ b/src/db/libsql/routines.rs
@@ -476,4 +476,56 @@ impl RoutineStore for LibSqlBackend {
.map_err(|e| DatabaseError::Query(e.to_string()))?;
Ok(())
}
+
+ async fn get_webhook_routine_by_path(
+ &self,
+ path: &str,
+ ) -> Result, DatabaseError> {
+ let conn = self.connect().await?;
+ let mut rows = conn
+ .query(
+ &format!(
+ "SELECT {} FROM routines WHERE enabled = 1 AND trigger_type = 'webhook' \
+ AND (json_extract(trigger_config, '$.path') = ?1 \
+ OR (json_extract(trigger_config, '$.path') IS NULL AND CAST(id AS TEXT) = ?1))",
+ ROUTINE_COLUMNS
+ ),
+ params![path],
+ )
+ .await
+ .map_err(|e| DatabaseError::Query(e.to_string()))?;
+
+ match rows
+ .next()
+ .await
+ .map_err(|e| DatabaseError::Query(e.to_string()))?
+ {
+ Some(row) => Ok(Some(row_to_routine_libsql(&row)?)),
+ None => Ok(None),
+ }
+ }
+
+ async fn list_dispatched_routine_runs(&self) -> Result, DatabaseError> {
+ let conn = self.connect().await?;
+ let mut rows = conn
+ .query(
+ &format!(
+ "SELECT {} FROM routine_runs WHERE status = 'running' AND job_id IS NOT NULL",
+ ROUTINE_RUN_COLUMNS
+ ),
+ params![],
+ )
+ .await
+ .map_err(|e| DatabaseError::Query(e.to_string()))?;
+
+ let mut runs = Vec::new();
+ while let Some(row) = rows
+ .next()
+ .await
+ .map_err(|e| DatabaseError::Query(e.to_string()))?
+ {
+ runs.push(row_to_routine_run_libsql(&row)?);
+ }
+ Ok(runs)
+ }
}
diff --git a/src/db/libsql/workspace.rs b/src/db/libsql/workspace.rs
index 68bd58ba..01c47742 100644
--- a/src/db/libsql/workspace.rs
+++ b/src/db/libsql/workspace.rs
@@ -11,7 +11,7 @@ use super::{
row_to_memory_document,
};
use crate::db::WorkspaceStore;
-use crate::error::WorkspaceError;
+use crate::error::{DatabaseError, WorkspaceError};
use crate::workspace::{
MemoryChunk, MemoryDocument, RankedResult, SearchConfig, SearchResult, WorkspaceEntry,
fuse_results,
@@ -19,6 +19,227 @@ use crate::workspace::{
use chrono::Utc;
+/// Resolve the embedding dimension from environment variables.
+///
+/// Reads `EMBEDDING_ENABLED`, `EMBEDDING_DIMENSION`, and `EMBEDDING_MODEL`
+/// from env vars. Returns `None` if embeddings are disabled.
+///
+/// Note: this only reads env vars, not persisted `Settings`, because it runs
+/// during `run_migrations()` before the full config stack is available. Users
+/// who configure embeddings via the settings UI must also set
+/// `EMBEDDING_ENABLED=true` in their environment for the vector index to be
+/// created. The model→dimension mapping is shared with `EmbeddingsConfig` via
+/// `default_dimension_for_model()`.
+pub(crate) fn resolve_embedding_dimension() -> Option {
+ let enabled = std::env::var("EMBEDDING_ENABLED")
+ .map(|v| v.eq_ignore_ascii_case("true") || v == "1")
+ .unwrap_or(false);
+
+ if !enabled {
+ tracing::info!("Vector index setup skipped (EMBEDDING_ENABLED not set in env)");
+ return None;
+ }
+
+ if let Ok(dim_str) = std::env::var("EMBEDDING_DIMENSION")
+ && let Ok(dim) = dim_str.parse::()
+ && dim > 0
+ {
+ return Some(dim);
+ }
+
+ let model =
+ std::env::var("EMBEDDING_MODEL").unwrap_or_else(|_| "text-embedding-3-small".to_string());
+
+ Some(crate::config::embeddings::default_dimension_for_model(
+ &model,
+ ))
+}
+
+impl LibSqlBackend {
+ /// Ensure the `libsql_vector_idx` on `memory_chunks.embedding` matches the
+ /// configured embedding dimension.
+ ///
+ /// The V9 migration dropped the vector index (and changed `F32_BLOB(1536)`
+ /// to `BLOB`) to support flexible dimensions. This method restores a
+ /// properly-typed `F32_BLOB(N)` column and creates the vector index.
+ ///
+ /// Tracks the active dimension in `_migrations` version `0` — a reserved
+ /// metadata row where `name` stores the dimension as a string. Version 0
+ /// is never used by incremental migrations (which start at 9), so there
+ /// is no collision. If the stored dimension matches, this is a no-op.
+ ///
+ /// **Precondition:** `run_migrations()` must have been called first so that
+ /// the `_migrations` table exists. This is guaranteed when called from
+ /// `Database::run_migrations()`, but callers using this directly must
+ /// ensure migrations have run.
+ pub async fn ensure_vector_index(&self, dimension: usize) -> Result<(), DatabaseError> {
+ if dimension == 0 || dimension > 65536 {
+ return Err(DatabaseError::Migration(format!(
+ "ensure_vector_index: dimension {dimension} out of valid range (1..=65536)"
+ )));
+ }
+
+ let conn = self.connect().await?;
+
+ // Check current dimension from _migrations version=0 (reserved metadata row).
+ // The block scope ensures `rows` is dropped before `conn.transaction()` —
+ // holding a result set open would cause "database table is locked" errors.
+ let current_dim = {
+ let mut rows = conn
+ .query("SELECT name FROM _migrations WHERE version = 0", ())
+ .await
+ .map_err(|e| {
+ DatabaseError::Migration(format!("Failed to check vector index metadata: {e}"))
+ })?;
+
+ rows.next().await.ok().flatten().and_then(|row| {
+ row.get::(0)
+ .ok()
+ .and_then(|s| s.parse::().ok())
+ })
+ };
+
+ if current_dim == Some(dimension) {
+ tracing::debug!(
+ dimension,
+ "Vector index already matches configured dimension"
+ );
+ return Ok(());
+ }
+
+ tracing::info!(
+ old_dimension = ?current_dim,
+ new_dimension = dimension,
+ "Rebuilding memory_chunks table for vector index"
+ );
+
+ let tx = conn.transaction().await.map_err(|e| {
+ DatabaseError::Migration(format!(
+ "ensure_vector_index: failed to start transaction: {e}"
+ ))
+ })?;
+
+ // 1. Drop FTS triggers that reference the old table
+ tx.execute_batch(
+ "DROP TRIGGER IF EXISTS memory_chunks_fts_insert;
+ DROP TRIGGER IF EXISTS memory_chunks_fts_delete;
+ DROP TRIGGER IF EXISTS memory_chunks_fts_update;",
+ )
+ .await
+ .map_err(|e| DatabaseError::Migration(format!("Failed to drop FTS triggers: {e}")))?;
+
+ // 2. Drop old vector index
+ tx.execute_batch("DROP INDEX IF EXISTS idx_memory_chunks_embedding;")
+ .await
+ .map_err(|e| {
+ DatabaseError::Migration(format!("Failed to drop old vector index: {e}"))
+ })?;
+
+ // 3. Drop stale temp table (if a previous attempt crashed) and create fresh
+ tx.execute_batch("DROP TABLE IF EXISTS memory_chunks_new;")
+ .await
+ .map_err(|e| {
+ DatabaseError::Migration(format!("Failed to drop stale memory_chunks_new: {e}"))
+ })?;
+
+ let create_sql = format!(
+ "CREATE TABLE memory_chunks_new (
+ _rowid INTEGER PRIMARY KEY AUTOINCREMENT,
+ id TEXT NOT NULL UNIQUE,
+ document_id TEXT NOT NULL REFERENCES memory_documents(id) ON DELETE CASCADE,
+ chunk_index INTEGER NOT NULL,
+ content TEXT NOT NULL,
+ embedding F32_BLOB({dimension}),
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
+ UNIQUE (document_id, chunk_index)
+ )"
+ );
+ tx.execute_batch(&create_sql).await.map_err(|e| {
+ DatabaseError::Migration(format!(
+ "Failed to create memory_chunks_new with F32_BLOB({dimension}): {e}"
+ ))
+ })?;
+
+ // 4. Copy data — embeddings with wrong byte length get NULLed
+ // (they will be re-embedded on next background pass).
+ // _rowid is explicitly preserved so the FTS5 content table
+ // (memory_chunks_fts, content_rowid='_rowid') stays in sync.
+ let expected_bytes = dimension * 4;
+ let copy_sql = format!(
+ "INSERT INTO memory_chunks_new
+ (_rowid, id, document_id, chunk_index, content, embedding, created_at)
+ SELECT _rowid, id, document_id, chunk_index, content,
+ CASE WHEN length(embedding) = {expected_bytes} THEN embedding ELSE NULL END,
+ created_at
+ FROM memory_chunks"
+ );
+ tx.execute_batch(©_sql).await.map_err(|e| {
+ DatabaseError::Migration(format!("Failed to copy data to memory_chunks_new: {e}"))
+ })?;
+
+ // 5. Swap tables
+ tx.execute_batch(
+ "DROP TABLE memory_chunks;
+ ALTER TABLE memory_chunks_new RENAME TO memory_chunks;",
+ )
+ .await
+ .map_err(|e| {
+ DatabaseError::Migration(format!("Failed to swap memory_chunks tables: {e}"))
+ })?;
+
+ // 6. Recreate document index + vector index
+ tx.execute_batch(
+ "CREATE INDEX IF NOT EXISTS idx_memory_chunks_document ON memory_chunks(document_id);
+ CREATE INDEX IF NOT EXISTS idx_memory_chunks_embedding ON memory_chunks(libsql_vector_idx(embedding));",
+ )
+ .await
+ .map_err(|e| {
+ DatabaseError::Migration(format!("Failed to create indexes: {e}"))
+ })?;
+
+ // 7. Recreate FTS triggers
+ tx.execute_batch(
+ "CREATE TRIGGER IF NOT EXISTS memory_chunks_fts_insert AFTER INSERT ON memory_chunks BEGIN
+ INSERT INTO memory_chunks_fts(rowid, content) VALUES (new._rowid, new.content);
+ END;
+
+ CREATE TRIGGER IF NOT EXISTS memory_chunks_fts_delete AFTER DELETE ON memory_chunks BEGIN
+ INSERT INTO memory_chunks_fts(memory_chunks_fts, rowid, content)
+ VALUES ('delete', old._rowid, old.content);
+ END;
+
+ CREATE TRIGGER IF NOT EXISTS memory_chunks_fts_update AFTER UPDATE ON memory_chunks BEGIN
+ INSERT INTO memory_chunks_fts(memory_chunks_fts, rowid, content)
+ VALUES ('delete', old._rowid, old.content);
+ INSERT INTO memory_chunks_fts(rowid, content) VALUES (new._rowid, new.content);
+ END;",
+ )
+ .await
+ .map_err(|e| {
+ DatabaseError::Migration(format!("Failed to recreate FTS triggers: {e}"))
+ })?;
+
+ // 8. Upsert dimension into _migrations(version=0)
+ tx.execute(
+ "INSERT INTO _migrations (version, name) VALUES (0, ?1)
+ ON CONFLICT(version) DO UPDATE SET name = ?1,
+ applied_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
+ params![dimension.to_string()],
+ )
+ .await
+ .map_err(|e| {
+ DatabaseError::Migration(format!("Failed to record vector index dimension: {e}"))
+ })?;
+
+ tx.commit().await.map_err(|e| {
+ DatabaseError::Migration(format!("ensure_vector_index: commit failed: {e}"))
+ })?;
+
+ tracing::info!(dimension, "Vector index created successfully");
+ Ok(())
+ }
+}
+
#[async_trait]
impl WorkspaceStore for LibSqlBackend {
async fn get_document_by_path(
@@ -395,6 +616,9 @@ impl WorkspaceStore for LibSqlBackend {
reason: e.to_string(),
})?;
let id = Uuid::new_v4();
+ // Note: embedding dimension is not validated here — the F32_BLOB(N)
+ // column type created by ensure_vector_index() enforces byte length at
+ // the libSQL level and will reject mismatched dimensions.
let embedding_blob = embedding.map(|e| {
let bytes: Vec = e.iter().flat_map(|f| f.to_le_bytes()).collect();
bytes
@@ -561,9 +785,9 @@ impl WorkspaceStore for LibSqlBackend {
.join(",")
);
- // vector_top_k requires a libsql_vector_idx index. After the V9
- // migration the index is dropped (to support flexible embedding
- // dimensions), so this query may fail. Fall back to FTS-only.
+ // vector_top_k requires a libsql_vector_idx index created by
+ // ensure_vector_index(). If the index is missing (embeddings not
+ // configured or dimension mismatch), fall back to FTS-only.
match conn
.query(
r#"
@@ -597,9 +821,9 @@ impl WorkspaceStore for LibSqlBackend {
results
}
Err(e) => {
- tracing::debug!(
- "Vector index query failed (expected after V9 migration), \
- falling back to FTS-only: {e}"
+ tracing::warn!(
+ "Vector index query failed (ensure_vector_index may not have run \
+ or dimension mismatch), falling back to FTS-only: {e}"
);
Vec::new()
}
@@ -617,3 +841,246 @@ impl WorkspaceStore for LibSqlBackend {
Ok(fuse_results(fts_results, vector_results, config))
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::db::Database;
+
+ /// Helper: create a file-backed backend with migrations applied.
+ async fn setup_backend() -> (LibSqlBackend, tempfile::TempDir) {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let db_path = dir.path().join("test_vector.db");
+ let backend = LibSqlBackend::new_local(&db_path).await.expect("new_local");
+ backend.run_migrations().await.expect("migrations");
+ (backend, dir)
+ }
+
+ /// Helper: insert a document and chunk with an optional embedding.
+ async fn insert_test_chunk(
+ backend: &LibSqlBackend,
+ user_id: &str,
+ path: &str,
+ content: &str,
+ embedding: Option<&[f32]>,
+ ) -> (Uuid, Uuid) {
+ let conn = backend.connect().await.expect("connect");
+ let doc_id = Uuid::new_v4();
+ let now = super::fmt_ts(&Utc::now());
+ conn.execute(
+ "INSERT INTO memory_documents (id, user_id, path, content, created_at, updated_at, metadata)
+ VALUES (?1, ?2, ?3, '', ?4, ?4, '{}')",
+ params![doc_id.to_string(), user_id, path, now],
+ )
+ .await
+ .expect("insert doc");
+ let chunk_id = backend
+ .insert_chunk(doc_id, 0, content, embedding)
+ .await
+ .expect("insert chunk");
+ (doc_id, chunk_id)
+ }
+
+ #[tokio::test]
+ async fn test_ensure_vector_index_enables_vector_search() {
+ let (backend, _dir) = setup_backend().await;
+
+ // Create vector index with dim=4
+ backend.ensure_vector_index(4).await.expect("ensure dim=4");
+ // Insert a chunk with a 4-dim embedding
+ let embedding = [1.0_f32, 0.0, 0.0, 0.0];
+ let (_doc_id, _chunk_id) = insert_test_chunk(
+ &backend,
+ "test",
+ "notes.md",
+ "hello world",
+ Some(&embedding),
+ )
+ .await;
+
+ // Query using vector_top_k — should find the chunk
+ let conn = backend.connect().await.expect("connect");
+ let mut rows = conn
+ .query(
+ r#"SELECT c.id
+ FROM vector_top_k('idx_memory_chunks_embedding', vector('[1,0,0,0]'), 5) AS top_k
+ JOIN memory_chunks c ON c._rowid = top_k.id"#,
+ (),
+ )
+ .await
+ .expect("vector_top_k query");
+ let row = rows
+ .next()
+ .await
+ .expect("row fetch")
+ .expect("expected a result row");
+ let id: String = row.get(0).expect("get id");
+ assert!(!id.is_empty(), "vector search should return the chunk");
+ }
+
+ #[tokio::test]
+ async fn test_ensure_vector_index_dimension_change() {
+ let (backend, _dir) = setup_backend().await;
+
+ // Create with dim=4 and insert data
+ backend.ensure_vector_index(4).await.expect("ensure dim=4");
+ let embedding_4d = [1.0_f32, 2.0, 3.0, 4.0];
+ insert_test_chunk(&backend, "test", "a.md", "content a", Some(&embedding_4d)).await;
+
+ // Recreate with dim=8 — old 4-dim embeddings should be NULLed
+ backend.ensure_vector_index(8).await.expect("ensure dim=8");
+ // Verify metadata updated
+ let conn = backend.connect().await.expect("connect");
+ let mut rows = conn
+ .query("SELECT name FROM _migrations WHERE version = 0", ())
+ .await
+ .expect("query metadata");
+ let row = rows.next().await.expect("fetch").expect("metadata row");
+ let dim_str: String = row.get(0).expect("get name");
+ assert_eq!(dim_str, "8");
+ // Verify old embedding was NULLed (wrong byte length for dim=8)
+ let mut rows = conn
+ .query("SELECT embedding IS NULL FROM memory_chunks LIMIT 1", ())
+ .await
+ .expect("query embedding");
+ let row = rows.next().await.expect("fetch").expect("chunk row");
+ let is_null: i64 = row.get(0).expect("get is_null");
+ assert_eq!(
+ is_null, 1,
+ "old 4-dim embedding should be NULLed after dim change to 8"
+ );
+ }
+
+ #[tokio::test]
+ async fn test_ensure_vector_index_noop_when_unchanged() {
+ let (backend, _dir) = setup_backend().await;
+
+ // Create with dim=4 and insert data
+ backend.ensure_vector_index(4).await.expect("ensure dim=4");
+ let embedding = [1.0_f32, 0.0, 0.0, 0.0];
+ insert_test_chunk(&backend, "test", "b.md", "content b", Some(&embedding)).await;
+
+ // Run again with same dimension — should be a no-op
+ backend
+ .ensure_vector_index(4)
+ .await
+ .expect("ensure dim=4 again");
+ // Verify data is untouched (embedding not NULLed)
+ let conn = backend.connect().await.expect("connect");
+ let mut rows = conn
+ .query(
+ "SELECT embedding IS NOT NULL FROM memory_chunks LIMIT 1",
+ (),
+ )
+ .await
+ .expect("query embedding");
+ let row = rows.next().await.expect("fetch").expect("chunk row");
+ let has_embedding: i64 = row.get(0).expect("get");
+ assert_eq!(
+ has_embedding, 1,
+ "embedding should be preserved on no-op call"
+ );
+ }
+
+ #[tokio::test]
+ async fn test_hybrid_search_returns_vector_results() {
+ let (backend, _dir) = setup_backend().await;
+
+ // Create vector index with dim=4
+ backend.ensure_vector_index(4).await.expect("ensure dim=4");
+ // Insert chunk with embedding and searchable content
+ let embedding = [0.5_f32, 0.5, 0.0, 0.0];
+ insert_test_chunk(
+ &backend,
+ "user1",
+ "notes.md",
+ "quantum computing research",
+ Some(&embedding),
+ )
+ .await;
+
+ // Search via the WorkspaceStore trait with vector enabled
+ let query_emb = [0.5_f32, 0.5, 0.0, 0.0];
+ let config = SearchConfig::default().with_limit(5);
+ let results = backend
+ .hybrid_search("user1", None, "quantum", Some(&query_emb), &config)
+ .await
+ .expect("hybrid_search");
+ assert!(!results.is_empty(), "hybrid search should return results");
+ let first = &results[0];
+ assert!(
+ first.vector_rank.is_some(),
+ "result should have a vector_rank"
+ );
+ assert_eq!(first.content, "quantum computing research");
+ }
+
+ mod resolve_dimension {
+ use super::*;
+ use crate::config::helpers::ENV_MUTEX;
+
+ fn clear_embedding_env() {
+ // SAFETY: called under ENV_MUTEX
+ unsafe {
+ std::env::remove_var("EMBEDDING_ENABLED");
+ std::env::remove_var("EMBEDDING_DIMENSION");
+ std::env::remove_var("EMBEDDING_MODEL");
+ }
+ }
+
+ #[test]
+ fn returns_none_when_disabled() {
+ let _guard = ENV_MUTEX.lock().expect("env mutex");
+ clear_embedding_env();
+ assert!(resolve_embedding_dimension().is_none());
+ }
+
+ #[test]
+ fn returns_explicit_dimension() {
+ let _guard = ENV_MUTEX.lock().expect("env mutex");
+ clear_embedding_env();
+ // SAFETY: under ENV_MUTEX
+ unsafe {
+ std::env::set_var("EMBEDDING_ENABLED", "true");
+ std::env::set_var("EMBEDDING_DIMENSION", "768");
+ }
+ assert_eq!(resolve_embedding_dimension(), Some(768));
+ unsafe {
+ std::env::remove_var("EMBEDDING_ENABLED");
+ std::env::remove_var("EMBEDDING_DIMENSION");
+ }
+ }
+
+ #[test]
+ fn infers_from_model() {
+ let _guard = ENV_MUTEX.lock().expect("env mutex");
+ clear_embedding_env();
+ // SAFETY: under ENV_MUTEX
+ unsafe {
+ std::env::set_var("EMBEDDING_ENABLED", "1");
+ std::env::set_var("EMBEDDING_MODEL", "all-minilm");
+ }
+ assert_eq!(resolve_embedding_dimension(), Some(384));
+ unsafe {
+ std::env::remove_var("EMBEDDING_ENABLED");
+ std::env::remove_var("EMBEDDING_MODEL");
+ }
+ }
+
+ #[test]
+ fn defaults_to_1536_for_unknown_model() {
+ let _guard = ENV_MUTEX.lock().expect("env mutex");
+ clear_embedding_env();
+ // SAFETY: under ENV_MUTEX
+ unsafe {
+ std::env::set_var("EMBEDDING_ENABLED", "true");
+ std::env::set_var("EMBEDDING_MODEL", "some-unknown-model");
+ }
+ assert_eq!(resolve_embedding_dimension(), Some(1536));
+ unsafe {
+ std::env::remove_var("EMBEDDING_ENABLED");
+ std::env::remove_var("EMBEDDING_MODEL");
+ }
+ }
+ }
+}
diff --git a/src/db/libsql_migrations.rs b/src/db/libsql_migrations.rs
index 5b42f18c..d0ec20ef 100644
--- a/src/db/libsql_migrations.rs
+++ b/src/db/libsql_migrations.rs
@@ -240,9 +240,9 @@ CREATE TABLE IF NOT EXISTS memory_chunks (
CREATE INDEX IF NOT EXISTS idx_memory_chunks_document ON memory_chunks(document_id);
--- No vector index: BLOB column accepts any embedding dimension.
--- Vector search uses brute-force cosine distance (fast enough for
--- personal assistant workspaces). Matches PostgreSQL after V9 migration.
+-- No vector index in base schema: BLOB column accepts any embedding dimension.
+-- Vector index is created dynamically by ensure_vector_index() during
+-- run_migrations() when embeddings are configured (EMBEDDING_ENABLED=true).
-- FTS5 virtual table for full-text search
CREATE VIRTUAL TABLE IF NOT EXISTS memory_chunks_fts USING fts5(
@@ -593,10 +593,9 @@ pub const INCREMENTAL_MIGRATIONS: &[(i64, &str, &str)] = &[
// constraint so any embedding dimension works. Existing embeddings
// are preserved; users only need to re-embed if they change models.
//
- // The vector index (libsql_vector_idx) requires a fixed-dimension
- // F32_BLOB(N), so we drop it entirely. Vector search falls back to
- // brute-force cosine distance which is fast enough for personal
- // assistant workspaces. This matches PostgreSQL after its V9 migration.
+ // The vector index is dropped here; ensure_vector_index() recreates
+ // it with the correct F32_BLOB(N) dimension during run_migrations()
+ // when embeddings are configured.
//
// SQLite cannot ALTER COLUMN types, so we recreate the table.
r#"
diff --git a/src/db/mod.rs b/src/db/mod.rs
index 6d2eb296..d960ebaf 100644
--- a/src/db/mod.rs
+++ b/src/db/mod.rs
@@ -525,6 +525,14 @@ pub trait RoutineStore: Send + Sync {
run_id: Uuid,
job_id: Uuid,
) -> Result<(), DatabaseError>;
+ async fn get_webhook_routine_by_path(
+ &self,
+ path: &str,
+ ) -> Result, DatabaseError>;
+
+ /// List routine runs that were dispatched as full_job but have not yet
+ /// been finalized (status='running' with a linked job_id).
+ async fn list_dispatched_routine_runs(&self) -> Result, DatabaseError>;
}
#[async_trait]
diff --git a/src/db/postgres.rs b/src/db/postgres.rs
index 8c18e252..e77452db 100644
--- a/src/db/postgres.rs
+++ b/src/db/postgres.rs
@@ -503,6 +503,17 @@ impl RoutineStore for PgBackend {
) -> Result<(), DatabaseError> {
self.store.link_routine_run_to_job(run_id, job_id).await
}
+
+ async fn get_webhook_routine_by_path(
+ &self,
+ path: &str,
+ ) -> Result, DatabaseError> {
+ self.store.get_webhook_routine_by_path(path).await
+ }
+
+ async fn list_dispatched_routine_runs(&self) -> Result, DatabaseError> {
+ self.store.list_dispatched_routine_runs().await
+ }
}
// ==================== ToolFailureStore ====================
diff --git a/src/error.rs b/src/error.rs
index 11864de7..30ec58f4 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -168,6 +168,9 @@ pub enum ToolError {
#[error("Tool {name} requires authentication")]
AuthRequired { name: String },
+ #[error("Tool {name} is not available for autonomous execution: {reason}")]
+ AutonomousUnavailable { name: String, reason: String },
+
#[error("Tool {name} is rate limited, retry after {retry_after:?}")]
RateLimited {
name: String,
@@ -300,6 +303,21 @@ pub enum WorkspaceError {
#[error("I/O error: {reason}")]
IoError { reason: String },
+
+ #[error("Not found: {path}")]
+ NotFound { path: String },
+
+ #[error("Layer not found: {name}")]
+ LayerNotFound { name: String },
+
+ #[error("Layer '{name}' is read-only")]
+ LayerReadOnly { name: String },
+
+ #[error("Cannot write sensitive content: no private layer available for redirect")]
+ PrivacyRedirectFailed,
+
+ #[error("Write rejected for '{path}': prompt injection detected ({reason})")]
+ InjectionRejected { path: String, reason: String },
}
/// Orchestrator errors (internal API, container management).
@@ -370,6 +388,9 @@ pub enum RoutineError {
#[error("Not authorized to trigger routine {id}")]
NotAuthorized { id: Uuid },
+ #[error("Routine {name} is in cooldown period")]
+ Cooldown { name: String },
+
#[error("Routine {name} at max concurrent runs")]
MaxConcurrent { name: String },
diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs
index 00d787a5..3ecf3657 100644
--- a/src/extensions/manager.rs
+++ b/src/extensions/manager.rs
@@ -45,6 +45,56 @@ struct PendingAuth {
task_handle: Option