mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Compare commits
81
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d9821d37a | ||
|
|
52d935d744 | ||
|
|
6232609080 | ||
|
|
1d6f7d5085 | ||
|
|
9964d5dab8 | ||
|
|
212d661e20 | ||
|
|
0d1a5c210b | ||
|
|
e6277a399f | ||
|
|
a4f6cda5c9 | ||
|
|
c6d4abdb31 | ||
|
|
47ba486990 | ||
|
|
6d847c6009 | ||
|
|
9603fefd01 | ||
|
|
d3b69e7be3 | ||
|
|
ee6f5cd62a | ||
|
|
3da9810e87 | ||
|
|
cba1bc3799 | ||
|
|
1b97ef4feb | ||
|
|
c17626160c | ||
|
|
e82f4bd2e5 | ||
|
|
b952d229f9 | ||
|
|
ef3d769742 | ||
|
|
31c3b5b041 | ||
|
|
806d402876 | ||
|
|
3a523347b0 | ||
|
|
455f543ba5 | ||
|
|
8526cde1be | ||
|
|
8920322589 | ||
|
|
6b0f84bbe0 | ||
|
|
cac6f4013c | ||
|
|
c4ab382522 | ||
|
|
65062f3cc0 | ||
|
|
86ae12747b | ||
|
|
52ca9d6588 | ||
|
|
09e1c97a27 | ||
|
|
71f41dd123 | ||
|
|
71f9012de3 | ||
|
|
38dafb96b1 | ||
|
|
9c34fe90f4 | ||
|
|
07c6ca72e9 | ||
|
|
b9e5acf66e | ||
|
|
3dcccc1e64 | ||
|
|
c8ee55ed19 | ||
|
|
8b15f8b259 | ||
|
|
94e4d9d3dd | ||
|
|
b7a1edf346 | ||
|
|
4566181f40 | ||
|
|
ec04354c6b | ||
|
|
14abd60917 | ||
|
|
a95a84ea79 | ||
|
|
2033d77579 | ||
|
|
59acab43f4 | ||
|
|
6831bb4d7b | ||
|
|
42ffefabe4 | ||
|
|
20202700db | ||
|
|
f2cd1d37bc | ||
|
|
07e6e30ee3 | ||
|
|
2d0b195321 | ||
|
|
9286978547 | ||
|
|
0be591028a | ||
|
|
33a2dd2c78 | ||
|
|
bedc71ebdc | ||
|
|
e9b0823db9 | ||
|
|
428303af11 | ||
|
|
9bb05d2dcd | ||
|
|
7a4673c11e | ||
|
|
059fd97ce6 | ||
|
|
ef5715cb96 | ||
|
|
1ad1335fea | ||
|
|
deee24c65b | ||
|
|
2b6404e8b2 | ||
|
|
0e7eb7f390 | ||
|
|
d3e392ac16 | ||
|
|
47659e9545 | ||
|
|
cb5f9796aa | ||
|
|
2961e70da1 | ||
|
|
e397546902 | ||
|
|
409a2ab9c0 | ||
|
|
8ba8def607 | ||
|
|
e212c0066d | ||
|
|
fe53f6993f |
+21
-3
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+147
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+8
-8
@@ -2339,7 +2339,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3436,7 +3436,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ironclaw"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"aho-corasick",
|
||||
@@ -5575,7 +5575,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5624,7 +5624,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki 0.103.9",
|
||||
"rustls-webpki 0.103.10",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -5696,9 +5696,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",
|
||||
@@ -6479,10 +6479,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+2
-1
@@ -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 | ✅ | ❌ | |
|
||||
|
||||
+330
@@ -0,0 +1,330 @@
|
||||
<p align="center">
|
||||
<img src="ironclaw.png?v=2" alt="IronClaw" width="200"/>
|
||||
</p>
|
||||
|
||||
<h1 align="center">IronClaw</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>あなたの味方になる、安全なパーソナルAIアシスタント</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#license"><img src="https://img.shields.io/badge/license-MIT%20OR%20Apache%202.0-blue.svg" alt="License: MIT OR Apache-2.0" /></a>
|
||||
<a href="https://t.me/ironclawAI"><img src="https://img.shields.io/badge/Telegram-%40ironclawAI-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @ironclawAI" /></a>
|
||||
<a href="https://www.reddit.com/r/ironclawAI/"><img src="https://img.shields.io/badge/Reddit-r%2FironclawAI-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/ironclawAI" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> |
|
||||
<a href="README.zh-CN.md">简体中文</a> |
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.ja.md">日本語</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#フィロソフィー">フィロソフィー</a> •
|
||||
<a href="#機能">機能</a> •
|
||||
<a href="#インストール">インストール</a> •
|
||||
<a href="#設定">設定</a> •
|
||||
<a href="#セキュリティ">セキュリティ</a> •
|
||||
<a href="#アーキテクチャ">アーキテクチャ</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## フィロソフィー
|
||||
|
||||
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/)をご覧ください。
|
||||
|
||||
<details>
|
||||
<summary>Windowsインストーラーでインストール(Windows)</summary>
|
||||
|
||||
[Windowsインストーラー](https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-x86_64-pc-windows-msvc.msi)をダウンロードして実行してください。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>PowerShellスクリプトでインストール(Windows)</summary>
|
||||
|
||||
```sh
|
||||
irm https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.ps1 | iex
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>シェルスクリプトでインストール(macOS、Linux、Windows/WSL)</summary>
|
||||
|
||||
```sh
|
||||
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.sh | sh
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Homebrewでインストール(macOS/Linux)</summary>
|
||||
|
||||
```sh
|
||||
brew install ironclaw
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>ソースコードからコンパイル(Windows、Linux、macOSでCargo)</summary>
|
||||
|
||||
`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`を実行してチャネルを再ビルドしてください。
|
||||
|
||||
</details>
|
||||
|
||||
### データベースのセットアップ
|
||||
|
||||
```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))
|
||||
|
||||
お好みに応じて選択してください。
|
||||
@@ -17,7 +17,8 @@
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> |
|
||||
<a href="README.zh-CN.md">简体中文</a> |
|
||||
<a href="README.ru.md">Русский</a>
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.ja.md">日本語</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -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.
|
||||
|
||||
+2
-1
@@ -17,7 +17,8 @@
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> |
|
||||
<a href="README.zh-CN.md">简体中文</a> |
|
||||
<a href="README.ru.md">Русский</a>
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.ja.md">日本語</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
||||
+3
-2
@@ -17,7 +17,8 @@
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> |
|
||||
<a href="README.zh-CN.md">简体中文</a> |
|
||||
<a href="README.ru.md">Русский</a>
|
||||
<a href="README.ru.md">Русский</a> |
|
||||
<a href="README.ja.md">日本語</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -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**)。
|
||||
|
||||
在向导中选择你的提供商,或直接设置环境变量:
|
||||
|
||||
|
||||
@@ -206,9 +206,17 @@ struct FeishuApiResponse<T> {
|
||||
data: Option<T>,
|
||||
}
|
||||
|
||||
/// 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<String, String> {
|
||||
));
|
||||
}
|
||||
|
||||
let token_resp: FeishuApiResponse<TenantAccessTokenData> =
|
||||
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<String, String> {
|
||||
));
|
||||
}
|
||||
|
||||
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<TenantAccessTokenResponse, _> = 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<TenantAccessTokenResponse, _> = 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
if text.chars().count() <= TELEGRAM_MAX_MESSAGE_LEN {
|
||||
return vec![text.to_string()];
|
||||
}
|
||||
|
||||
let mut chunks: Vec<String> = 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<String> {
|
||||
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<String> = (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
|
||||
|
||||
+8
-4
@@ -2,9 +2,13 @@ coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
target: auto
|
||||
threshold: 1%
|
||||
target: 80%
|
||||
threshold: 2%
|
||||
patch:
|
||||
default:
|
||||
target: 80%
|
||||
threshold: 5%
|
||||
target: 90%
|
||||
|
||||
comment:
|
||||
layout: "reach,diff,flags"
|
||||
behavior: default
|
||||
require_changes: true
|
||||
|
||||
@@ -243,6 +243,18 @@ mod tests {
|
||||
assert!(wrapped.contains("Hello <world>"));
|
||||
}
|
||||
|
||||
#[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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
+31
-2
@@ -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:
|
||||
|
||||
|
||||
+25
-2
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -3,4 +3,5 @@ git_release_enable = false
|
||||
|
||||
[[package]]
|
||||
name = "ironclaw_safety"
|
||||
publish = false
|
||||
release = false
|
||||
|
||||
@@ -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
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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."
|
||||
+1
-1
@@ -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).
|
||||
|
||||
|
||||
+220
-57
@@ -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<Arc<crate::transcription::TranscriptionMiddleware>>,
|
||||
/// Document text extraction middleware for PDF, DOCX, PPTX, etc.
|
||||
pub document_extraction: Option<Arc<crate::document_extraction::DocumentExtractionMiddleware>>,
|
||||
/// 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<Arc<dyn crate::tools::SoftwareBuilder>>,
|
||||
}
|
||||
|
||||
/// The main agent that coordinates all components.
|
||||
@@ -161,9 +184,10 @@ pub struct Agent {
|
||||
pub(super) heartbeat_config: Option<HeartbeatConfig>,
|
||||
pub(super) hygiene_config: Option<crate::config::HygieneConfig>,
|
||||
pub(super) routine_config: Option<RoutineConfig>,
|
||||
/// 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<tokio::sync::RwLock<Option<Arc<crate::agent::routine_engine::RoutineEngine>>>>>,
|
||||
Arc<tokio::sync::RwLock<Option<Arc<crate::agent::routine_engine::RoutineEngine>>>>,
|
||||
}
|
||||
|
||||
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<tokio::sync::RwLock<Option<Arc<crate::agent::routine_engine::RoutineEngine>>>>,
|
||||
) {
|
||||
self.routine_engine_slot = Some(slot);
|
||||
self.routine_engine_slot = slot;
|
||||
}
|
||||
|
||||
async fn routine_engine(&self) -> Option<Arc<crate::agent::routine_engine::RoutineEngine>> {
|
||||
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<Option<String>, 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 {
|
||||
|
||||
+46
-13
@@ -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<PendingApproval>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<dyn crate::tools::Tool>,
|
||||
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(
|
||||
|
||||
@@ -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<IncomingMessage>,
|
||||
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<IncomingMessage>,
|
||||
route: JobMonitorRoute,
|
||||
context_manager: Option<Arc<ContextManager>>,
|
||||
) -> 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<ContextManager>,
|
||||
) -> 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::<IncomingMessage>(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::<IncomingMessage>(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);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+139
-27
@@ -79,6 +79,13 @@ pub enum Trigger {
|
||||
#[serde(default)]
|
||||
filters: std::collections::HashMap<String, String>,
|
||||
},
|
||||
/// Fire on incoming webhook POST to /api/webhooks/{path}.
|
||||
Webhook {
|
||||
/// Optional webhook path suffix (defaults to routine id).
|
||||
path: Option<String>,
|
||||
/// Optional shared secret for HMAC validation.
|
||||
secret: Option<String>,
|
||||
},
|
||||
/// 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<String>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<String>`.
|
||||
pub fn parse_tool_permissions(value: &serde_json::Value) -> Vec<String> {
|
||||
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<Option<DateTime<Utc>>, 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
|
||||
|
||||
+935
-141
File diff suppressed because it is too large
Load Diff
+58
-19
@@ -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<Result<TaskOutput, Error>>,
|
||||
}
|
||||
|
||||
/// Shared scheduler-owned dependencies that are forwarded into autonomous runs.
|
||||
pub struct SchedulerDeps {
|
||||
pub tools: Arc<ToolRegistry>,
|
||||
pub extension_manager: Option<Arc<ExtensionManager>>,
|
||||
pub store: Option<Arc<dyn Database>>,
|
||||
pub hooks: Arc<HookRegistry>,
|
||||
}
|
||||
|
||||
/// Schedules and manages parallel job execution.
|
||||
pub struct Scheduler {
|
||||
config: AgentConfig,
|
||||
@@ -52,6 +64,7 @@ pub struct Scheduler {
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
safety: Arc<SafetyLayer>,
|
||||
tools: Arc<ToolRegistry>,
|
||||
extension_manager: Option<Arc<ExtensionManager>>,
|
||||
store: Option<Arc<dyn Database>>,
|
||||
hooks: Arc<HookRegistry>,
|
||||
/// SSE broadcast sender for live job event streaming.
|
||||
@@ -71,18 +84,17 @@ impl Scheduler {
|
||||
context_manager: Arc<ContextManager>,
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
safety: Arc<SafetyLayer>,
|
||||
tools: Arc<ToolRegistry>,
|
||||
store: Option<Arc<dyn Database>>,
|
||||
hooks: Arc<HookRegistry>,
|
||||
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<serde_json::Value>,
|
||||
) -> Result<Uuid, JobError> {
|
||||
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(),
|
||||
|
||||
+359
-21
@@ -66,14 +66,11 @@ pub trait SelfRepair: Send + Sync {
|
||||
/// Default self-repair implementation.
|
||||
pub struct DefaultSelfRepair {
|
||||
context_manager: Arc<ContextManager>,
|
||||
// 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<Arc<dyn Database>>,
|
||||
builder: Option<Arc<dyn SoftwareBuilder>>,
|
||||
// TODO: use for tool hot-reload after repair
|
||||
#[allow(dead_code)]
|
||||
tools: Option<Arc<ToolRegistry>>,
|
||||
}
|
||||
|
||||
@@ -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<dyn Database>) -> Self {
|
||||
pub fn with_store(mut self, store: Arc<dyn Database>) -> 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<dyn SoftwareBuilder>,
|
||||
tools: Arc<ToolRegistry>,
|
||||
@@ -117,25 +112,82 @@ impl DefaultSelfRepair {
|
||||
#[async_trait]
|
||||
impl SelfRepair for DefaultSelfRepair {
|
||||
async fn detect_stuck_jobs(&self) -> Vec<StuckJob> {
|
||||
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<crate::tools::BuildRequirement, crate::error::ToolError> {
|
||||
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<crate::tools::BuildResult, crate::error::ToolError> {
|
||||
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<crate::tools::BuildResult, crate::error::ToolError> {
|
||||
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<dyn crate::tools::SoftwareBuilder>,
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +188,15 @@ pub struct PendingApproval {
|
||||
/// through the approval flow even if the approval message lacks timezone.
|
||||
#[serde(default)]
|
||||
pub user_timezone: Option<String>,
|
||||
/// 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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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).
|
||||
|
||||
+197
-63
@@ -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) => {
|
||||
@@ -865,7 +868,9 @@ impl Agent {
|
||||
approved: bool,
|
||||
always: bool,
|
||||
) -> Result<SubmissionResult, Error> {
|
||||
// Get pending approval for this thread
|
||||
// Get pending approval for this thread.
|
||||
// Hold the session lock for the entire check-take-verify-mutate sequence
|
||||
// to eliminate the TOCTOU race window (fixes #1486).
|
||||
let pending = {
|
||||
let mut sess = session.lock().await;
|
||||
let thread = sess
|
||||
@@ -883,59 +888,52 @@ impl Agent {
|
||||
return Ok(SubmissionResult::ok_with_message(""));
|
||||
}
|
||||
|
||||
thread.take_pending_approval()
|
||||
};
|
||||
let pending = match thread.take_pending_approval() {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
tracing::debug!(
|
||||
%thread_id,
|
||||
"Ignoring stale approval: no pending approval found"
|
||||
);
|
||||
return Ok(SubmissionResult::ok_with_message(""));
|
||||
}
|
||||
};
|
||||
|
||||
let pending = match pending {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
tracing::debug!(
|
||||
%thread_id,
|
||||
"Ignoring stale approval: no pending approval found"
|
||||
);
|
||||
return Ok(SubmissionResult::ok_with_message(""));
|
||||
}
|
||||
};
|
||||
|
||||
// Verify request ID if provided
|
||||
if let Some(req_id) = request_id
|
||||
&& req_id != pending.request_id
|
||||
{
|
||||
// Put it back and return error
|
||||
let mut sess = session.lock().await;
|
||||
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
||||
// Verify request ID while still holding the lock
|
||||
if let Some(req_id) = request_id
|
||||
&& req_id != pending.request_id
|
||||
{
|
||||
// Restore the pending approval atomically without releasing the lock
|
||||
thread.await_approval(pending);
|
||||
return Ok(SubmissionResult::error(
|
||||
"Request ID mismatch. Use the correct request ID.",
|
||||
));
|
||||
}
|
||||
return Ok(SubmissionResult::error(
|
||||
"Request ID mismatch. Use the correct request ID.",
|
||||
));
|
||||
}
|
||||
|
||||
// If approved with "always", set auto-approve while we still have the lock.
|
||||
// Drop the thread borrow before calling sess methods to satisfy the borrow checker.
|
||||
if approved && always {
|
||||
let tool_name = pending.tool_name.clone();
|
||||
sess.auto_approve_tool(&tool_name);
|
||||
tracing::info!("Auto-approved tool '{}' for session {}", tool_name, sess.id);
|
||||
}
|
||||
|
||||
// Set thread state to Processing for approved requests.
|
||||
// Re-borrow the thread since the previous borrow was dropped.
|
||||
if approved && let Some(thread) = sess.threads.get_mut(&thread_id) {
|
||||
thread.state = ThreadState::Processing;
|
||||
}
|
||||
|
||||
pending
|
||||
};
|
||||
|
||||
if approved {
|
||||
// If always, add to auto-approved set
|
||||
if always {
|
||||
let mut sess = session.lock().await;
|
||||
sess.auto_approve_tool(&pending.tool_name);
|
||||
tracing::info!(
|
||||
"Auto-approved tool '{}' for session {}",
|
||||
pending.tool_name,
|
||||
sess.id
|
||||
);
|
||||
}
|
||||
|
||||
// Reset thread state to processing
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
||||
thread.state = ThreadState::Processing;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the approved tool and continue the loop
|
||||
let mut job_ctx =
|
||||
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 +1067,31 @@ impl Agent {
|
||||
usize,
|
||||
crate::llm::ToolCall,
|
||||
Arc<dyn crate::tools::Tool>,
|
||||
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 +1299,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 +1311,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 +1335,7 @@ impl Agent {
|
||||
tool_name: tool_name.clone(),
|
||||
description: description.clone(),
|
||||
parameters: parameters.clone(),
|
||||
allow_always,
|
||||
},
|
||||
&message.metadata,
|
||||
)
|
||||
@@ -1343,6 +1346,7 @@ impl Agent {
|
||||
tool_name,
|
||||
description,
|
||||
parameters,
|
||||
allow_always,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1411,7 +1415,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 +1426,7 @@ impl Agent {
|
||||
tool_name: tool_name.clone(),
|
||||
description: description.clone(),
|
||||
parameters: parameters.clone(),
|
||||
allow_always,
|
||||
},
|
||||
&message.metadata,
|
||||
)
|
||||
@@ -1430,6 +1436,7 @@ impl Agent {
|
||||
tool_name,
|
||||
description,
|
||||
parameters,
|
||||
allow_always,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -1447,17 +1454,25 @@ impl Agent {
|
||||
);
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
||||
thread.clear_pending_approval();
|
||||
thread.complete_turn(&rejection);
|
||||
// User message already persisted at turn start; save rejection response
|
||||
self.persist_assistant_response(
|
||||
thread_id,
|
||||
&message.channel,
|
||||
&message.user_id,
|
||||
&rejection,
|
||||
)
|
||||
.await;
|
||||
match sess.threads.get_mut(&thread_id) {
|
||||
Some(thread) => {
|
||||
thread.clear_pending_approval();
|
||||
thread.complete_turn(&rejection);
|
||||
// User message already persisted at turn start; save rejection response
|
||||
self.persist_assistant_response(
|
||||
thread_id,
|
||||
&message.channel,
|
||||
&message.user_id,
|
||||
&rejection,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
%thread_id,
|
||||
"Thread disappeared during approval rejection"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1949,6 +1964,7 @@ mod tests {
|
||||
context_messages: vec![],
|
||||
deferred_tool_calls: vec![],
|
||||
user_timezone: None,
|
||||
allow_always: false,
|
||||
};
|
||||
thread.await_approval(pending);
|
||||
|
||||
@@ -1998,6 +2014,124 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression test for #1486: verify that request_id mismatch atomically
|
||||
/// restores the pending approval (no TOCTOU window where it could be lost).
|
||||
#[test]
|
||||
fn test_request_id_mismatch_preserves_pending_state() {
|
||||
use crate::agent::session::{PendingApproval, Session, Thread, ThreadState};
|
||||
use uuid::Uuid;
|
||||
|
||||
let session_id = Uuid::new_v4();
|
||||
let thread_id = Uuid::new_v4();
|
||||
let correct_request_id = Uuid::new_v4();
|
||||
let wrong_request_id = Uuid::new_v4();
|
||||
|
||||
let mut thread = Thread::with_id(thread_id, session_id);
|
||||
let pending = PendingApproval {
|
||||
request_id: correct_request_id,
|
||||
tool_name: "shell".to_string(),
|
||||
parameters: serde_json::json!({"command": "rm -rf /"}),
|
||||
display_parameters: serde_json::json!({"command": "[REDACTED]"}),
|
||||
description: "Execute dangerous command".to_string(),
|
||||
tool_call_id: "call_42".to_string(),
|
||||
context_messages: vec![],
|
||||
deferred_tool_calls: vec![],
|
||||
user_timezone: None,
|
||||
allow_always: false,
|
||||
};
|
||||
thread.await_approval(pending);
|
||||
|
||||
let mut session = Session::new("test-user");
|
||||
session.threads.insert(thread_id, thread);
|
||||
|
||||
// Verify initial state
|
||||
assert_eq!(
|
||||
session.threads[&thread_id].state,
|
||||
ThreadState::AwaitingApproval
|
||||
);
|
||||
assert!(session.threads[&thread_id].pending_approval.is_some());
|
||||
|
||||
// Simulate the atomic check-take-verify sequence from the fixed code:
|
||||
// take the approval, then verify request_id, and restore on mismatch.
|
||||
let thread = session.threads.get_mut(&thread_id).unwrap();
|
||||
let taken = thread.take_pending_approval().unwrap();
|
||||
assert!(thread.pending_approval.is_none(), "take should clear it");
|
||||
|
||||
// Wrong request_id -- restore atomically (within same "lock scope" in production)
|
||||
assert_ne!(wrong_request_id, taken.request_id);
|
||||
thread.await_approval(taken);
|
||||
|
||||
// Verify the pending approval is fully restored
|
||||
assert_eq!(
|
||||
session.threads[&thread_id].state,
|
||||
ThreadState::AwaitingApproval
|
||||
);
|
||||
let restored = session.threads[&thread_id]
|
||||
.pending_approval
|
||||
.as_ref()
|
||||
.expect("pending approval must be restored after request_id mismatch");
|
||||
assert_eq!(restored.request_id, correct_request_id);
|
||||
assert_eq!(restored.tool_name, "shell");
|
||||
assert_eq!(restored.tool_call_id, "call_42");
|
||||
}
|
||||
|
||||
/// Regression test for #1486: verify that approval with "always" flag
|
||||
/// correctly transitions thread state and registers auto-approve in a
|
||||
/// single lock scope.
|
||||
#[test]
|
||||
fn test_approval_with_always_sets_auto_approve_and_processing() {
|
||||
use crate::agent::session::{PendingApproval, Session, Thread, ThreadState};
|
||||
use uuid::Uuid;
|
||||
|
||||
let session_id = Uuid::new_v4();
|
||||
let thread_id = Uuid::new_v4();
|
||||
let request_id = Uuid::new_v4();
|
||||
|
||||
let mut thread = Thread::with_id(thread_id, session_id);
|
||||
let pending = PendingApproval {
|
||||
request_id,
|
||||
tool_name: "web_fetch".to_string(),
|
||||
parameters: serde_json::json!({"url": "https://example.com"}),
|
||||
display_parameters: serde_json::json!({"url": "https://example.com"}),
|
||||
description: "Fetch a web page".to_string(),
|
||||
tool_call_id: "call_99".to_string(),
|
||||
context_messages: vec![],
|
||||
deferred_tool_calls: vec![],
|
||||
user_timezone: None,
|
||||
allow_always: true,
|
||||
};
|
||||
thread.await_approval(pending);
|
||||
|
||||
let mut session = Session::new("test-user");
|
||||
session.threads.insert(thread_id, thread);
|
||||
|
||||
// Simulate the combined lock scope: take + verify + auto_approve + state transition
|
||||
// Simulate the combined lock scope: take + verify + auto_approve + state transition.
|
||||
// Use separate scopes to mirror the borrow pattern in production code.
|
||||
let taken = {
|
||||
let thread = session.threads.get_mut(&thread_id).unwrap();
|
||||
thread.take_pending_approval().unwrap()
|
||||
};
|
||||
|
||||
// Request ID matches
|
||||
assert_eq!(taken.request_id, request_id);
|
||||
|
||||
// Auto-approve the tool (thread borrow dropped, so session is accessible)
|
||||
session.auto_approve_tool(&taken.tool_name);
|
||||
|
||||
// Re-borrow thread and set state to Processing
|
||||
{
|
||||
let thread = session.threads.get_mut(&thread_id).unwrap();
|
||||
thread.state = ThreadState::Processing;
|
||||
}
|
||||
|
||||
// Verify both mutations happened atomically
|
||||
assert!(session.is_tool_auto_approved("web_fetch"));
|
||||
assert_eq!(session.threads[&thread_id].state, ThreadState::Processing);
|
||||
// Pending approval should be consumed (not restored)
|
||||
assert!(session.threads[&thread_id].pending_approval.is_none());
|
||||
}
|
||||
|
||||
// Helper function to extract the approval message without needing a full Agent instance
|
||||
fn extract_approval_message(
|
||||
session: &crate::agent::session::Session,
|
||||
|
||||
+76
-14
@@ -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<SessionManager>,
|
||||
pub catalog_entries: Vec<crate::extensions::RegistryEntry>,
|
||||
pub dev_loaded_tool_names: Vec<String>,
|
||||
pub builder: Option<Arc<dyn crate::tools::SoftwareBuilder>>,
|
||||
}
|
||||
|
||||
/// Options that control optional init phases.
|
||||
@@ -280,6 +281,7 @@ impl AppBuilder {
|
||||
Arc<ToolRegistry>,
|
||||
Option<Arc<dyn EmbeddingProvider>>,
|
||||
Option<Arc<Workspace>>,
|
||||
Option<Arc<dyn crate::tools::SoftwareBuilder>>,
|
||||
),
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -239,6 +239,11 @@ impl ChannelManager {
|
||||
pub async fn get_channel(&self, name: &str) -> Option<Arc<dyn Channel>> {
|
||||
self.channels.read().await.get(name).cloned()
|
||||
}
|
||||
|
||||
/// Remove a channel from the manager.
|
||||
pub async fn remove(&self, name: &str) -> Option<Arc<dyn Channel>> {
|
||||
self.channels.write().await.remove(name)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ChannelManager {
|
||||
|
||||
+193
-383
@@ -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<RwLock<String>>,
|
||||
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<Option<tokio::task::JoinHandle<()>>>,
|
||||
/// Handle to the SSE parser task for clean shutdown.
|
||||
parser_handle: Arc<RwLock<Option<tokio::task::JoinHandle<()>>>>,
|
||||
/// 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<ChannelEvent>,
|
||||
/// Receiver side — taken once by `start()`.
|
||||
event_rx: tokio::sync::Mutex<Option<mpsc::Receiver<ChannelEvent>>>,
|
||||
}
|
||||
|
||||
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<ChannelEvent>,
|
||||
event_rx: mpsc::Receiver<ChannelEvent>,
|
||||
) -> 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<ChannelEvent>,
|
||||
event_rx: mpsc::Receiver<ChannelEvent>,
|
||||
) -> 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<ChannelEvent> {
|
||||
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<serde_json::Value, RelayError> {
|
||||
) -> Result<serde_json::Value, crate::channels::relay::client::RelayError> {
|
||||
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<MessageStream, ChannelError> {
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+90
-205
@@ -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<String, RelayError> {
|
||||
/// 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<String, RelayError> {
|
||||
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<String, RelayError> {
|
||||
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<serde_json::Value, RelayError> {
|
||||
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<Vec<u8>, 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<Vec<Connection>, 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<ChannelEvent>,
|
||||
}
|
||||
|
||||
impl Stream for ChannelEventStream {
|
||||
type Item = ChannelEvent;
|
||||
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
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<Item = Result<bytes::Bytes, reqwest::Error>> + Send + 'static,
|
||||
tx: mpsc::Sender<ChannelEvent>,
|
||||
) {
|
||||
use futures::StreamExt;
|
||||
|
||||
let mut buffer = Vec::<u8>::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::<ChannelEvent>(&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<Result<bytes::Bytes, reqwest::Error>> = 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Shared relay webhook signature verification helpers.
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// 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));
|
||||
}
|
||||
}
|
||||
@@ -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!();
|
||||
}
|
||||
|
||||
+11
-3
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
})
|
||||
}
|
||||
|
||||
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]
|
||||
|
||||
@@ -123,25 +123,8 @@ pub async fn memory_read_handler(
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn memory_write_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Json(req): Json<MemoryWriteRequest>,
|
||||
) -> Result<Json<MemoryWriteResponse>, (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<Arc<GatewayState>>,
|
||||
|
||||
@@ -26,3 +26,4 @@ pub mod routines;
|
||||
pub mod settings;
|
||||
#[allow(dead_code)]
|
||||
pub mod static_files;
|
||||
pub mod webhooks;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <id> --webhook-secret <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<Arc<GatewayState>>,
|
||||
Path(path): Path<String>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<serde_json::Value>, (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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+317
-243
@@ -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<tokio::sync::RwLock<Option<Arc<crate::agent::routine_engine::RoutineEngine>>>>;
|
||||
|
||||
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<String>,
|
||||
}
|
||||
|
||||
/// 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<crate::extensions::RegistryEntry>,
|
||||
@@ -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<Arc<GatewayState>>,
|
||||
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<Arc<GatewayState>>,
|
||||
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||
@@ -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(
|
||||
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
|
||||
<h2>Error</h2><p>Invalid callback parameters.</p></body></html>"
|
||||
.to_string(),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
_ => {
|
||||
return axum::response::Html(
|
||||
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
|
||||
<h2>Error</h2><p>Invalid callback parameters.</p></body></html>"
|
||||
.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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -2305,164 +2450,6 @@ async fn pairing_approve_handler(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Routines handlers ---
|
||||
|
||||
async fn routines_list_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
) -> Result<Json<RoutineListResponse>, (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<RoutineInfo> = routines.iter().map(RoutineInfo::from_routine).collect();
|
||||
|
||||
Ok(Json(RoutineListResponse { routines: items }))
|
||||
}
|
||||
|
||||
async fn routines_summary_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
) -> Result<Json<RoutineSummaryResponse>, (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<Arc<GatewayState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<RoutineDetailResponse>, (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<RoutineRunInfo> = 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<Arc<GatewayState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (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<Arc<GatewayState>>,
|
||||
Path(id): Path<String>,
|
||||
@@ -2669,6 +2656,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 +2684,9 @@ struct GatewayStatusResponse {
|
||||
actions_this_hour: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
model_usage: Option<Vec<ModelUsageEntry>>,
|
||||
llm_backend: String,
|
||||
llm_model: String,
|
||||
enabled_channels: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -2886,10 +2879,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 +3231,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 +3299,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 +3402,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 +3454,85 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_oauth_callback_accepts_versioned_hosted_state() {
|
||||
use axum::body::Body;
|
||||
use tower::ServiceExt;
|
||||
|
||||
let secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync> =
|
||||
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::<axum::http::Request<Body>>::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<GatewayState>) -> Router {
|
||||
@@ -3516,7 +3590,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 +3634,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 +3682,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())
|
||||
|
||||
+1015
-91
File diff suppressed because it is too large
Load Diff
@@ -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}',
|
||||
});
|
||||
|
||||
@@ -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}',
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
integrity="sha384-pN9zSKOnTZwXRtYZAu0PBPEgR2B7DOC1aeLxQ33oJ0oy5iN1we6gm57xldM2irDG"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
<script src="/theme-init.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Auth Screen -->
|
||||
@@ -95,8 +96,7 @@
|
||||
<button data-tab="memory" data-i18n="tab.memory">Memory</button>
|
||||
<button data-tab="jobs" data-i18n="tab.jobs">Jobs</button>
|
||||
<button data-tab="routines" data-i18n="tab.routines">Routines</button>
|
||||
<button data-tab="extensions" data-i18n="tab.extensions">Extensions</button>
|
||||
<button data-tab="skills" data-i18n="tab.skills">Skills</button>
|
||||
<button data-tab="settings" data-i18n="tab.settings">Settings</button>
|
||||
<div class="spacer"></div>
|
||||
|
||||
<!-- Language Switcher -->
|
||||
@@ -110,6 +110,18 @@
|
||||
</div>
|
||||
|
||||
<button class="status-logs-btn" data-tab="logs" data-i18n="tab.logs" title="Logs">Logs</button>
|
||||
<button class="theme-toggle-btn" id="theme-toggle" title="Toggle theme" aria-label="Toggle theme">
|
||||
<svg class="theme-icon icon-dark" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>
|
||||
<svg class="theme-icon icon-light" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||
</svg>
|
||||
<svg class="theme-icon icon-system" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span id="theme-announce" class="sr-only" aria-live="polite"></span>
|
||||
<div class="tee-shield" id="tee-shield" style="display:none" title="Running in a Trusted Execution Environment">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
@@ -136,19 +148,17 @@
|
||||
<!-- Chat Tab -->
|
||||
<div class="tab-panel active" id="tab-chat">
|
||||
<div class="thread-sidebar" id="thread-sidebar">
|
||||
<div class="thread-sidebar-header">
|
||||
<button class="thread-new-btn" id="thread-new-btn" data-i18n="chat.newThread" data-i18n-attr="title"
|
||||
title="New thread (Ctrl/Cmd+N)">+</button>
|
||||
<div class="spacer"></div>
|
||||
<button class="thread-toggle-btn" id="thread-toggle-btn" data-i18n="chat.toggleSidebar"
|
||||
data-i18n-attr="title" title="Toggle sidebar">«</button>
|
||||
</div>
|
||||
<div class="assistant-item" id="assistant-thread">
|
||||
<span class="assistant-label" id="assistant-label" data-i18n="chat.assistant">Assistant</span>
|
||||
<span class="assistant-meta" id="assistant-meta"></span>
|
||||
</div>
|
||||
<div class="threads-section-header">
|
||||
<span data-i18n="chat.conversations">Conversations</span>
|
||||
<div class="spacer"></div>
|
||||
<button class="thread-new-btn" id="thread-new-btn" data-i18n="chat.newThread" data-i18n-attr="title"
|
||||
title="New thread (Ctrl/Cmd+N)">+</button>
|
||||
<button class="thread-toggle-btn" id="thread-toggle-btn" data-i18n="chat.toggleSidebar"
|
||||
data-i18n-attr="title" title="Toggle sidebar">«</button>
|
||||
</div>
|
||||
<div class="thread-list" id="thread-list"></div>
|
||||
</div>
|
||||
@@ -271,77 +281,125 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Extensions Tab -->
|
||||
<div class="tab-panel" id="tab-extensions">
|
||||
<div class="extensions-container">
|
||||
<div class="extensions-section">
|
||||
<h3 data-i18n="extensions.installed">Installed Extensions</h3>
|
||||
<div class="extensions-list" id="extensions-list">
|
||||
<div class="empty-state" data-i18n="common.loading">Loading...</div>
|
||||
</div>
|
||||
<!-- Settings Tab -->
|
||||
<div class="tab-panel" id="tab-settings">
|
||||
<div class="settings-layout">
|
||||
<div class="settings-sidebar">
|
||||
<button class="settings-subtab active" data-settings-subtab="inference" data-i18n="settings.inference">Inference</button>
|
||||
<button class="settings-subtab" data-settings-subtab="agent" data-i18n="settings.agent">Agent</button>
|
||||
<button class="settings-subtab" data-settings-subtab="channels" data-i18n="settings.channels">Channels</button>
|
||||
<button class="settings-subtab" data-settings-subtab="networking" data-i18n="settings.networking">Networking</button>
|
||||
<button class="settings-subtab" data-settings-subtab="extensions" data-i18n="tab.extensions">Extensions</button>
|
||||
<button class="settings-subtab" data-settings-subtab="mcp" data-i18n="settings.mcp">MCP</button>
|
||||
<button class="settings-subtab" data-settings-subtab="skills" data-i18n="tab.skills">Skills</button>
|
||||
</div>
|
||||
<div class="extensions-section" id="available-wasm-section">
|
||||
<h3 data-i18n="extensions.available">Available WASM Extensions</h3>
|
||||
<div class="extensions-list" id="available-wasm-list">
|
||||
<div class="empty-state" data-i18n="common.loading">Loading...</div>
|
||||
<div class="settings-content">
|
||||
<div class="settings-toolbar">
|
||||
<div class="settings-search">
|
||||
<input type="text" id="settings-search-input" data-i18n-placeholder="settings.searchPlaceholder" placeholder="Search settings..." data-i18n-attr="aria-label" data-i18n="settings.searchPlaceholder" aria-label="Search settings...">
|
||||
</div>
|
||||
<button id="settings-export-btn" class="settings-toolbar-btn" data-i18n="settings.export">Export</button>
|
||||
<button id="settings-import-btn" class="settings-toolbar-btn" data-i18n="settings.import">Import</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extensions-section">
|
||||
<h3 data-i18n="extensions.installWasm">Install WASM Extension</h3>
|
||||
<div class="ext-install-form">
|
||||
<input type="text" id="wasm-install-name" data-i18n-placeholder="common.name" placeholder="Extension name">
|
||||
<input type="text" id="wasm-install-url" placeholder="URL to .tar.gz bundle">
|
||||
<button id="wasm-install-btn" data-i18n="extensions.install">Install</button>
|
||||
<div class="settings-subpanel active" id="settings-inference">
|
||||
<div class="extensions-container" id="settings-inference-content">
|
||||
<div class="empty-state" data-i18n="common.loading">Loading settings...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extensions-section">
|
||||
<h3 data-i18n="mcp.servers">MCP Servers</h3>
|
||||
<div class="extensions-list" id="mcp-servers-list">
|
||||
<div class="empty-state" data-i18n="common.loading">Loading...</div>
|
||||
<div class="settings-subpanel" id="settings-agent">
|
||||
<div class="extensions-container" id="settings-agent-content">
|
||||
<div class="empty-state" data-i18n="common.loading">Loading settings...</div>
|
||||
</div>
|
||||
</div>
|
||||
<h4 data-i18n="mcp.addCustom">Add Custom MCP Server</h4>
|
||||
<div class="ext-install-form">
|
||||
<input type="text" id="mcp-install-name" data-i18n-placeholder="common.name" placeholder="Server name">
|
||||
<input type="text" id="mcp-install-url" placeholder="MCP server URL (https://...)">
|
||||
<button id="mcp-add-btn" data-i18n="mcp.add">Add</button>
|
||||
<div class="settings-subpanel" id="settings-channels">
|
||||
<div class="extensions-container" id="settings-channels-content">
|
||||
<div class="empty-state" data-i18n="common.loading">Loading channels...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-subpanel" id="settings-networking">
|
||||
<div class="extensions-container" id="settings-networking-content">
|
||||
<div class="empty-state" data-i18n="common.loading">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-subpanel" id="settings-extensions">
|
||||
<div class="extensions-container">
|
||||
<div class="extensions-section">
|
||||
<h3 data-i18n="extensions.installed">Installed Extensions</h3>
|
||||
<div class="extensions-list" id="extensions-list">
|
||||
<div class="empty-state" data-i18n="common.loading">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extensions-section" id="available-wasm-section">
|
||||
<h3 data-i18n="extensions.available">Available Extensions</h3>
|
||||
<div class="extensions-list" id="available-wasm-list">
|
||||
<div class="empty-state" data-i18n="common.loading">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extensions-section">
|
||||
<h3 data-i18n="extensions.installWasm">Install Extension</h3>
|
||||
<div class="ext-install-form">
|
||||
<input type="text" id="wasm-install-name" data-i18n-placeholder="common.name" placeholder="Extension name">
|
||||
<input type="text" id="wasm-install-url" placeholder="URL to .tar.gz bundle">
|
||||
<button id="wasm-install-btn" data-i18n="extensions.install">Install</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-subpanel" id="settings-mcp">
|
||||
<div class="extensions-container">
|
||||
<div class="extensions-section">
|
||||
<h3 data-i18n="mcp.servers">MCP Servers</h3>
|
||||
<div class="extensions-list" id="mcp-servers-list">
|
||||
<div class="empty-state" data-i18n="common.loading">Loading...</div>
|
||||
</div>
|
||||
<h4 data-i18n="mcp.addCustom">Add Custom MCP Server</h4>
|
||||
<div class="ext-install-form">
|
||||
<input type="text" id="mcp-install-name" data-i18n-placeholder="common.name" placeholder="Server name">
|
||||
<input type="text" id="mcp-install-url" placeholder="MCP server URL (https://...)">
|
||||
<button id="mcp-add-btn" data-i18n="mcp.add">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-subpanel" id="settings-skills">
|
||||
<div class="extensions-container">
|
||||
<div class="extensions-section">
|
||||
<h3 data-i18n="skills.searchClawHub">Search ClawHub</h3>
|
||||
<div class="skill-search-box">
|
||||
<input type="text" id="skill-search-input" data-i18n-placeholder="skills.searchPlaceholder" placeholder="Search for skills...">
|
||||
<button id="skill-search-btn" data-i18n="skills.search">Search</button>
|
||||
</div>
|
||||
<div class="extensions-list" id="skill-search-results"></div>
|
||||
</div>
|
||||
<div class="extensions-section">
|
||||
<h3 data-i18n="skills.installed">Installed Skills</h3>
|
||||
<div class="extensions-list" id="skills-list">
|
||||
<div class="empty-state" data-i18n="skills.loading">Loading skills...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extensions-section">
|
||||
<h3 data-i18n="skills.installByUrl">Install Skill by URL</h3>
|
||||
<div class="ext-install-form">
|
||||
<input type="text" id="skill-install-name" data-i18n-placeholder="skills.namePlaceholder" placeholder="Skill name or slug">
|
||||
<input type="text" id="skill-install-url" data-i18n-placeholder="skills.urlPlaceholder" placeholder="HTTPS URL to SKILL.md (optional)">
|
||||
<button id="skill-install-btn" data-i18n="extensions.install">Install</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extensions-section">
|
||||
<h3 data-i18n="tools.registered">Registered Tools</h3>
|
||||
<table class="tools-table" id="tools-table">
|
||||
<thead><tr><th data-i18n="tools.name">Name</th><th data-i18n="tools.description">Description</th></tr></thead>
|
||||
<tbody id="tools-tbody"></tbody>
|
||||
</table>
|
||||
<div class="empty-state" id="tools-empty" style="display:none" data-i18n="tools.empty">No tools registered</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Skills Tab -->
|
||||
<div class="tab-panel" id="tab-skills">
|
||||
<div class="extensions-container">
|
||||
<div class="extensions-section">
|
||||
<h3 data-i18n="skills.searchClawHub">Search ClawHub</h3>
|
||||
<div class="skill-search-box">
|
||||
<input type="text" id="skill-search-input" data-i18n-placeholder="skills.searchPlaceholder" placeholder="Search...">
|
||||
<button id="skill-search-btn" data-i18n="skills.search">Search</button>
|
||||
</div>
|
||||
<div class="extensions-list" id="skill-search-results"></div>
|
||||
</div>
|
||||
<div class="extensions-section">
|
||||
<h3 data-i18n="skills.installed">Installed Skills</h3>
|
||||
<div class="extensions-list" id="skills-list">
|
||||
<div class="empty-state" data-i18n="skills.loading">Loading skills...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extensions-section">
|
||||
<h3 data-i18n="skills.installByUrl">Install Skill by URL</h3>
|
||||
<div class="ext-install-form">
|
||||
<input type="text" id="skill-install-name" data-i18n-placeholder="skills.namePlaceholder" placeholder="Skill name or slug">
|
||||
<input type="text" id="skill-install-url" data-i18n-placeholder="skills.urlPlaceholder" placeholder="HTTPS URL to SKILL.md (optional)">
|
||||
<button id="skill-install-btn" data-i18n="extensions.install">Install</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Confirmation Modal -->
|
||||
<div id="confirm-modal" class="modal-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="confirm-modal-title">
|
||||
<div class="modal">
|
||||
<h3 id="confirm-modal-title"></h3>
|
||||
<p id="confirm-modal-message"></p>
|
||||
<div class="modal-actions">
|
||||
<button id="confirm-modal-cancel-btn" class="btn-secondary" data-i18n="btn.cancel">Cancel</button>
|
||||
<button id="confirm-modal-btn" class="btn-danger">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+785
-140
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
// Prevent FOUC: apply saved theme before first paint.
|
||||
// This script must be loaded synchronously in <head> (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);
|
||||
})();
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -177,6 +177,8 @@ pub enum SseEvent {
|
||||
parameters: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thread_id: Option<String>,
|
||||
/// 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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
fallback_deliverable: Option<serde_json::Value>,
|
||||
},
|
||||
|
||||
/// 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<String>,
|
||||
/// 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<bool>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -810,6 +832,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 +1110,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 {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+59
-3
@@ -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<String>);
|
||||
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
|
||||
|
||||
+5
-3
@@ -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<dyn crate::db::Database>,
|
||||
embeddings: Option<Arc<dyn EmbeddingProvider>>,
|
||||
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<Arc<dyn EmbeddingProvider>>,
|
||||
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 {
|
||||
|
||||
+15
-1
@@ -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)]
|
||||
|
||||
+264
-74
@@ -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<OAuthCredentials> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<OAuthTokenResponse, OAuthCallbackError> {
|
||||
// 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<String, String>,
|
||||
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
|
||||
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<OAuthTokenResponse, OAuthCallbackError> {
|
||||
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<tokio::sync::broadcast::Sender<crate::channels::web::types::SseEvent>>,
|
||||
/// Gateway auth token for authenticating with the platform token exchange proxy.
|
||||
pub gateway_token: Option<String>,
|
||||
/// RFC 8707 resource parameter (MCP OAuth only).
|
||||
/// Sent during token exchange to scope the token to a specific MCP server.
|
||||
pub resource: Option<String>,
|
||||
/// Additional form params for the token exchange request.
|
||||
/// Used for provider-specific requirements such as RFC 8707 `resource`.
|
||||
pub token_exchange_extra_params: HashMap<String, String>,
|
||||
/// 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<String>,
|
||||
@@ -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<String> {
|
||||
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,117 @@ 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<String>,
|
||||
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<String>,
|
||||
issued_at: u64,
|
||||
}
|
||||
|
||||
fn current_instance_name() -> Option<String> {
|
||||
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<DecodedHostedOAuthState, String> {
|
||||
if let Some(rest) = state.strip_prefix(&format!("{HOSTED_STATE_PREFIX}."))
|
||||
&& let Some((payload_b64, checksum)) = rest.rsplit_once('.')
|
||||
&& let Ok(payload_json) = URL_SAFE_NO_PAD.decode(payload_b64)
|
||||
{
|
||||
let expected_checksum = hosted_state_checksum(&payload_json);
|
||||
if checksum != expected_checksum {
|
||||
return Err("Hosted OAuth state checksum mismatch".to_string());
|
||||
}
|
||||
if let Ok(payload) = serde_json::from_slice::<HostedOAuthStatePayload>(&payload_json)
|
||||
&& !payload.flow_id.trim().is_empty()
|
||||
{
|
||||
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 +644,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<String, String>,
|
||||
}
|
||||
|
||||
/// 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<OAuthTokenResponse, OAuthCallbackError> {
|
||||
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 +722,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 +731,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 +751,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 +1077,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 +1085,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 +1101,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 +1111,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 +1128,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 +1138,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 +1171,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_falls_back_for_non_envelope_ic2_prefix() {
|
||||
use crate::cli::oauth_defaults::decode_hosted_oauth_state;
|
||||
|
||||
let decoded =
|
||||
decode_hosted_oauth_state("ic2.provider-owned-state").expect("prefixed fallback");
|
||||
assert_eq!(decoded.flow_id, "ic2.provider-owned-state");
|
||||
assert_eq!(decoded.instance_name, None);
|
||||
assert!(decoded.is_legacy);
|
||||
}
|
||||
|
||||
#[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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
+14
-7
@@ -651,8 +651,8 @@ async fn auth_tool(name: String, dir: Option<PathBuf>, 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<PathBuf>, 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
|
||||
|
||||
@@ -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
|
||||
|
||||
+51
-11
@@ -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<String>,
|
||||
/// 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<IpAddr>() {
|
||||
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:[email protected]", "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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+324
-18
@@ -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<Vec<(String, String)>, 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::<String, usize>::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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+21
-6
@@ -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<Self, ConfigError> {
|
||||
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<String, ConfigErro
|
||||
/// are read by `optional_env()` before falling back to `std::env::var()`,
|
||||
/// so explicit env vars always win.
|
||||
///
|
||||
/// Also loads tokens from OS credential stores (macOS Keychain, Linux
|
||||
/// Also loads tokens from OS credential stores (macOS Keychain / Linux
|
||||
/// credentials files) which don't require the secrets DB.
|
||||
pub async fn inject_llm_keys_from_secrets(
|
||||
secrets: &dyn crate::secrets::SecretsStore,
|
||||
|
||||
+29
-35
@@ -7,7 +7,7 @@ use secrecy::SecretString;
|
||||
pub struct RelayConfig {
|
||||
/// Base URL of the channel-relay service (e.g., `http://localhost:3001`).
|
||||
pub url: String,
|
||||
/// API key for authenticated channel-relay endpoints.
|
||||
/// Bearer token for authenticated channel-relay endpoints (`sk-agent-*`).
|
||||
pub api_key: SecretString,
|
||||
/// Override for the OAuth callback URL (e.g., a tunnel URL).
|
||||
pub callback_url: Option<String>,
|
||||
@@ -15,12 +15,8 @@ pub struct RelayConfig {
|
||||
pub instance_id: Option<String>,
|
||||
/// 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> {
|
||||
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]"));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<MemoryLayer>,
|
||||
}
|
||||
|
||||
impl WorkspaceConfig {
|
||||
pub(crate) fn resolve(user_id: &str) -> Result<Self, ConfigError> {
|
||||
let memory_layers: Vec<MemoryLayer> = 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<LastAction>,
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
+201
-11
@@ -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<String>,
|
||||
description: impl Into<String>,
|
||||
) -> Result<Uuid, JobError> {
|
||||
// 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<String>,
|
||||
title: impl Into<String>,
|
||||
description: impl Into<String>,
|
||||
) -> 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<Uuid> {
|
||||
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<Duration>,
|
||||
) -> Vec<Uuid> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+79
-70
@@ -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<String>,
|
||||
output_sanitized: serde_json::Value,
|
||||
output_sanitized: Option<String>,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Option<Routine>, 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<Vec<RoutineRun>, 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)
|
||||
}
|
||||
}
|
||||
|
||||
+474
-7
@@ -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<usize> {
|
||||
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::<usize>()
|
||||
&& 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::<String>(0)
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<usize>().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<u8> = 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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#"
|
||||
|
||||
@@ -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<Option<Routine>, 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<Vec<RoutineRun>, DatabaseError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -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<Option<Routine>, DatabaseError> {
|
||||
self.store.get_webhook_routine_by_path(path).await
|
||||
}
|
||||
|
||||
async fn list_dispatched_routine_runs(&self) -> Result<Vec<RoutineRun>, DatabaseError> {
|
||||
self.store.list_dispatched_routine_runs().await
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== ToolFailureStore ====================
|
||||
|
||||
@@ -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 },
|
||||
|
||||
|
||||
+572
-220
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user