Compare commits

..
Author SHA1 Message Date
Henry ParkandClaude Sonnet 4.6 dcd8bc3697 fix(ci): secrets can't be used in step if conditions [skip-regression-check]
GitHub Actions step-level `if:` doesn't have access to `secrets` context.
Replace `if: secrets.X != ''` with `continue-on-error: true` and let
the Set token step handle the fallback.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-09 16:41:31 -07:00
Henry ParkandClaude Sonnet 4.6 b125832787 fix(ci): remove Sonnet reference from prompt — model is Haiku [skip-regression-check]
The orchestrator model is Haiku but the prompt said "Launch 4 parallel
Sonnet agents". Haiku can't spawn Sonnet subagents. Changed to just
"4 parallel agents" so it matches the configured model.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-09 16:15:28 -07:00
Henry ParkandClaude Sonnet 4.6 294449065b fix(ci): fetch tags for staging-tested + fix issue counter [skip-regression-check]
1. Add fetch-tags: true to checkout — staging-tested is a tag and
   without it every run behaves like a first run (no incremental batching).

2. Only increment ISSUES_CREATED when gh issue create succeeds.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-09 16:00:17 -07:00
Henry ParkandClaude Sonnet 4.6 8a3f0c0870 fix(ci): 20-min polling + timeout-minutes on gate job [skip-regression-check]
- Set polling timeout to 20 min (down from 30)
- Add timeout-minutes: 25 on gate job for hard kill on cancel
- Revert 1s sleep loop to sleep $INTERVAL (1s loop doesn't help
  with cancellation — GitHub only cancels between steps)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-09 15:55:32 -07:00
Henry ParkandClaude Sonnet 4.6 6685f2ca9d fix(ci): gate long-running tests by target branch, not label [skip-regression-check]
Replace label-based gating with branch-based: run full suite unless
PR targets staging. Fixes timing bug where gh pr create --label adds
the label after the opened event, causing gated jobs to never run.

- Developer PRs (-> staging): fast tests only
- Promotion PRs (-> main): full suite
- Chained PRs (-> staging-promote/*): full suite
- Push to main: full suite
- workflow_call: fast tests only

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-09 15:44:49 -07:00
Henry ParkandClaude Sonnet 4.6 fb8ac7455e fix(ci): handle "No issues found" comment + restore version-check [skip-regression-check]
1. Handle Claude's "No issues found" comment as a clean pass instead
   of treating empty BODY as blocking. Checks for this before looking
   for "Found N issues" comments.

2. Restore version-check to run on all PRs (not just staging-promotion).
   It's 4-8 seconds and catches missing version bumps early.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-09 15:33:15 -07:00
Henry ParkandClaude Sonnet 4.6 b5b9934df0 fix(ci): Haiku model, 30-min timeout, HAS_BLOCKING injection fix [skip-regression-check]
- Switch orchestrator to claude-haiku-4-5-20251001 (cheaper per review)
- Increase polling timeout from 20 to 30 minutes for larger diffs
- Move HAS_BLOCKING from ${{ }} interpolation to env var (same
  injection fix applied to FORCE_RUN and SKIP_GATE earlier)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-09 15:23:52 -07:00
Henry ParkandClaude Sonnet 4.6 0e4d50454b fix(ci): remove --delete-branch — it closes chained PRs [skip-regression-check]
Verified in ironclaw-ci-test: when a promotion branch is deleted on
merge, GitHub CLOSES (not retargets) any chained PRs using it as
their base. This silently breaks the promotion chain.

Remove --delete-branch from gh pr merge. Stale promotion branches
will be cleaned up separately.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-09 15:04:49 -07:00
Henry ParkandClaude Sonnet 4.6 ab37518008 fix(ci): address review round 2 — injection, cancellable polling, concurrency [skip-regression-check]
1. Script injection: use env vars for workflow_dispatch inputs instead
   of ${{ inputs.* }} interpolation in run blocks.

2. Cancellable polling: sleep in 1s increments so GitHub can cancel
   between iterations instead of blocking on a single long sleep.

3. Remove workflow_dispatch from claude-review — fails without PR
   context. Re-trigger by removing/re-adding staging-promotion label.

4. Add concurrency group to claude-review to prevent duplicate
   reviews consuming API credits.

5. Fix dead code in test.yml roll-up (redundant variable init).

6. Add comment explaining --delete-branch safety (GitHub auto-retargets
   chained PRs, verified in ironclaw-ci-test).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-09 14:58:15 -07:00
Henry ParkandClaude Sonnet 4.6 e55b160a2e fix(ci): add labeled trigger — gh pr create adds label after opened event [skip-regression-check]
gh pr create --label adds the label AFTER the PR is created, so the
opened event fires without the label. The if condition checking for
staging-promotion label fails. Adding labeled trigger type fixes this.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-09 14:50:51 -07:00
Henry ParkandClaude Sonnet 4.6 fdfc5fbccc fix(ci): add workflow_dispatch to claude-review for manual re-trigger [skip-regression-check]
Allows manually re-triggering a Claude review without closing and
reopening the PR. Addresses review feedback from @zmanian.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-09 14:44:27 -07:00
Henry ParkandClaude Sonnet 4.6 402c71d642 fix(ci): address Copilot review — workflow_call, branch collision, merge failure [skip-regression-check]
1. Add workflow_call trigger to test.yml and e2e.yml so staging-ci
   can invoke them as reusable workflows.

2. Add github.run_id suffix to promotion branch name to prevent
   collision on workflow reruns.

3. Merge step now fails hard instead of swallowing errors. If merge
   fails, the step fails and the PR stays open.

4. update-tag intentionally advances regardless of gate outcome —
   this is by design so the next batch covers only new commits.
   Blocked batches stay as open PRs with review comments.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-09 14:41:35 -07:00
Henry ParkandClaude Sonnet 4.6 831484abab fix(ci): poll check-run status + split merge step [skip-regression-check]
1. Replace 15-min sleep with polling: check Claude Code Review
   check-run status every 30s with 20-min timeout. Exits as soon
   as job completes. Process-findings then reads the last comment.

2. Split merge into its own step conditioned on
   steps.evaluate.outputs.passed == 'true'. Prevents accidental
   merge if gate exit logic is refactored.

3. Add checks:read permission for check-runs API.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-09 14:38:32 -07:00
Henry ParkandClaude Sonnet 4.6 ecc026843c refactor(ci): gate long-running checks to promotion PRs + push to main [skip-regression-check]
Skip Docker Build, WASM WIT, Windows Build, Telegram tests, and
version-check on developer PRs (branch→staging). These only run on
promotion PRs (staging→main, labeled staging-promotion) and push
to main.

Developer PRs only run unit tests for fast feedback. Full integration
suite runs at promotion time as the quality gate.

Roll-up job updated to accept skipped as passing for gated jobs.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-09 14:27:32 -07:00
Henry ParkandClaude Sonnet 4.6 87674420c5 fix(ci): address Copilot review — fail-closed gate + subshell fix [skip-regression-check]
1. Gate fails closed: when no Claude review comment is found (API key
   missing, workflow failure, etc.), gate now blocks instead of
   passing. Use skip_claude_gate to override.

2. Process substitution: use `< <(...)` instead of pipe so the while
   loop runs in the current shell. ISSUES_CREATED counter and
   HAS_BLOCKING flag now propagate correctly. Removes /tmp file hack.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-09 14:06:37 -07:00
Henry ParkandClaude Sonnet 4.6 c474972a49 feat(ci): chained promotion PRs with multi-agent Claude review [skip-regression-check]
Staging CI workflow with batched promotion PRs:
- Creates staging-promote/<sha> branches per batch
- Chains PRs onto previous promotion branch (incremental diffs)
- Claude Code reviews only the incremental changes per batch
- Blocked PRs stay open as records of findings
- staging-tested tag advances regardless of gate outcome
- Runs every 60 min on cron + manual dispatch

Multi-agent Claude review (Sonnet orchestrator + Haiku agents):
- 4 parallel Sonnet review agents (security, architecture, bugs, performance)
- Haiku agents for severity/confidence scoring
- [SEVERITY:CONFIDENCE] output format
- Severity/confidence matrix for issue creation and gate blocking:
  CRITICAL: always create issue, block if confidence >=80
  HIGH: create issue if confidence >=50
  MEDIUM/LOW: create issue if confidence >=80

Gate waits 15 min for Claude review then processes comments.
Validated end-to-end in nearai/ironclaw-ci-test.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-03-09 13:55:46 -07:00
198 changed files with 4904 additions and 16332 deletions
+1 -1
View File
@@ -64,7 +64,7 @@ If the event needs custom UI (cards, badges, etc.), add styles. Follow the exist
Identify where in the backend this event should be triggered. Common locations:
- `src/agent/agent_loop.rs` - During message processing or tool execution
- `src/worker/job.rs` - During job execution
- `src/agent/worker.rs` - During job execution
- `src/agent/heartbeat.rs` - During periodic execution
Use the existing pattern:
-303
View File
@@ -1,303 +0,0 @@
---
description: Full PR lifecycle — review, fix findings, address comments, quality gate, push, CI fix loop, merge
disable-model-invocation: true
allowed-tools: Bash(gh pr view:*), Bash(gh pr diff:*), Bash(gh pr comment:*), Bash(gh pr merge:*), Bash(gh pr checks:*), Bash(gh pr edit:*), Bash(gh pr list:*), Bash(gh pr checkout:*), Bash(gh api:*), Bash(gh repo view:*), Bash(gh run view:*), Bash(gh run watch:*), Bash(git diff:*), Bash(git log:*), Bash(git fetch:*), Bash(git checkout:*), Bash(git status:*), Bash(git branch:*), Bash(git add:*), Bash(git commit:*), Bash(git push:*), Bash(git merge:*), Bash(git rebase:*), Bash(cargo fmt:*), Bash(cargo clippy:*), Bash(cargo test:*), Bash(cargo check:*), Read, Edit, Write, Grep, Glob, Agent
argument-hint: "<pr-number or url> [--fix] [--merge] [--review-only]"
---
# PR Shepherd
Full PR lifecycle: review → fix → quality gate → push → CI → merge.
Parse `$ARGUMENTS`:
- Extract PR number from bare number or `https://github.com/owner/repo/pull/123` URL.
- Flags: `--fix` (auto-fix without asking), `--merge` (merge when CI green), `--review-only` (stop after review, don't fix).
- If no PR number, detect from current branch: `gh pr list --head $(git branch --show-current) --json number --jq '.[0].number'`
- If still nothing, stop and ask the user.
---
## Phase 1: Situational Awareness
Gather everything in parallel:
**PR metadata:**
```
gh pr view {number} --json number,title,body,author,baseRefName,headRefName,headRefOid,state,isDraft,mergeable,mergeStateStatus,files,additions,deletions,labels,reviewRequests
```
**Diff:**
```
gh pr diff {number}
gh pr diff {number} --name-only
```
**CI status:**
```
gh pr checks {number} --json name,status,conclusion,detailsUrl
```
**Review comments (human + bot):**
```
gh api --paginate repos/{owner}/{repo}/pulls/{number}/comments
gh api --paginate repos/{owner}/{repo}/pulls/{number}/reviews
```
Resolve `{owner}/{repo}`:
```
gh repo view --json owner,name --jq '"\(.owner.login)/\(.name)"'
```
Save `headRefOid` — needed for posting line comments later.
**Assess the situation and print a status card:**
```
PR #{number}: {title}
Author: {author} Base: {base} ← {head}
Size: +{additions} -{deletions} across {file_count} files
CI: {PASS|FAIL|PENDING|NONE} Mergeable: {yes|no|conflict}
Reviews: {N approved, N changes_requested, N comments-only, N bot-only}
Unresolved comments: {N}
Draft: {yes|no}
```
**Decide the mode** based on situation:
- **Has unresolved review comments** → Phase 2a (address comments first, then review remaining)
- **No reviews yet / bot-only reviews** → Phase 2b (full deep review)
- **CI failing, no review issues** → Phase 4 (jump to CI fix)
- **Everything green + approved** → Phase 6 (ready to merge)
---
## Phase 2a: Address Existing Review Comments
For each unresolved review comment or review with CHANGES_REQUESTED:
1. **Read the referenced code** at the file and line mentioned. Never assess without reading.
2. **Classify each comment:**
-**Valid & unresolved** — needs a code fix
-**Already fixed** — a later commit addressed it
-**False positive** — explain why the code is correct
- 🔧 **Nit** — optional improvement, not blocking
3. **Deduplicate** — bots (Copilot, Gemini) often post the same finding. Group by actual issue.
Present a table:
| # | Source | File:Line | Issue | Status | Planned Fix |
|---|--------|-----------|-------|--------|-------------|
Wait for user confirmation (unless `--fix` flag set), then proceed to Phase 3.
---
## Phase 2b: Deep Review (6 Lenses)
Read EVERY changed file in full (not just diff hunks). For PRs touching >20 files, prioritize: service logic > handlers > types > tests > docs. Batch reads in parallel via Agent tool.
### IronClaw-specific checks (always)
- No `.unwrap()` or `.expect()` in production code
- Prefer `crate::` for cross-module imports (`super::` OK in tests/intra-module)
- Error types use `thiserror`
- If persistence touched, both backends updated (postgres.rs AND libsql/)
- New tools implement `Tool` trait correctly and registered
- External tool output passes through safety layer
- Tool parameters redacted before logging/SSE
- No byte-index slicing on external strings
- Case-insensitive comparisons where needed
### Correctness
Off-by-one, wrong operators, inverted conditions, unreachable code, type confusion, error propagation, broken invariants, TOCTOU races.
### Edge cases & failure handling
Empty/None/zero-length input, external service failures, integer boundaries, malformed/adversarial input, partial failure handling.
### Security (assume adversarial actors)
Auth/authz bypass, IDOR, injection (SQL/command/log/header), data leakage in logs/errors/API responses, resource exhaustion, replay/race conditions.
### Test coverage
New public functions tested? Error paths tested? Edge cases covered? Existing tests still valid?
### Architecture
Follows existing patterns? Unnecessary abstractions? Duplicated logic? Clean module dependencies?
**Present findings as a table:**
| # | Severity | Category | File:Line | Finding | Suggested Fix |
|---|----------|----------|-----------|---------|---------------|
Severity: Critical > High > Medium > Low > Nit
If `--review-only` flag is set, post findings as GitHub comments (see Phase 2c) and STOP.
Otherwise, ask which findings to fix (default: all Critical + High + Medium). Then proceed to Phase 3.
---
## Phase 2c: Post Review Comments on GitHub
For each finding the user approved (or all Critical/High/Medium if `--fix`):
**Line-specific findings** — post as PR review comments:
```
gh api repos/{owner}/{repo}/pulls/{number}/comments \
-f body="**{Severity}**: {finding}\n\n{explanation}\n\n**Suggested fix:** {suggestion}" \
-f path="{file}" \
-f commit_id="{headRefOid}" \
-F line={line} \
-f side="RIGHT"
```
**Cross-cutting/architectural findings** — post as regular PR comment:
```
gh pr comment {number} --body "..."
```
---
## Phase 3: Fix
Checkout the PR branch if not already on it (handles fork PRs automatically):
```
gh pr checkout {number}
```
**Implement fixes** for:
1. All approved review comment fixes (from Phase 2a)
2. All approved review findings (from Phase 2b)
Follow IronClaw conventions:
- `thiserror` for errors
- `crate::` imports
- No `.unwrap()` in production
- Both DB backends if persistence touched
- Regression test for every bug fix (enforced by commit-msg hook; bypass only with `[skip-regression-check]` if genuinely not feasible)
After all fixes implemented, proceed to Phase 4.
---
## Phase 4: Quality Gate
Run the full IronClaw shipping checklist:
```bash
cargo fmt
```
```bash
cargo clippy --all --benches --tests --examples --all-features
```
```bash
cargo test --lib
```
If persistence changes are present, also verify feature isolation:
```bash
cargo check --no-default-features --features libsql
cargo check --all-features
```
**If any step fails:** fix the issue and re-run. Do NOT proceed past a failing step. Loop up to 3 times per step. If still failing after 3 attempts, report the failure and stop.
---
## Phase 5: Commit & Push
Stage changed files by name (never `git add -A` — it can include unintended files):
```bash
git add path/to/changed/file1 path/to/changed/file2
git commit -m "{message}"
```
Commit message format:
- For review fixes: `fix: address review findings on PR #{number}`
- For comment responses: `fix: address review comments on PR #{number}`
- For CI fixes: `fix: resolve CI failures on PR #{number}`
- Include specifics in the body (which findings/comments were addressed)
Push:
```bash
git push origin {headRefName}
```
**Reply to addressed review comments on GitHub.** For each comment that was fixed, reply with the commit SHA and a brief description of what was done. For false positives, reply explaining why no change was needed.
---
## Phase 6: CI Monitor & Fix Loop
Wait briefly for CI to start, then poll (do NOT use `--watch` as it can hang indefinitely):
```
gh pr checks {number} --json name,status,conclusion
```
Re-check every 30 seconds, up to 10 minutes. If still pending after 10 minutes, report status and ask the user whether to keep waiting.
**If CI passes** → proceed to Phase 7.
**If CI fails** (up to 3 fix attempts):
1. Identify the failing check:
```
gh run view {run_id} --log-failed
```
If `--log-failed` shows nothing useful:
```
gh run view {run_id} --log | tail -100
```
2. Diagnose and fix the failure.
3. Re-run Phase 4 (quality gate).
4. Commit and push (Phase 5).
5. Go back to top of Phase 6.
**After 3 failed CI fix attempts:** Report what's failing and why, then stop. Don't keep looping.
---
## Phase 7: Merge Decision
Print final status:
```
PR #{number}: {title}
CI: ✅ PASS
Reviews: {summary}
Findings fixed: {N}
Comments addressed: {N}
Commits added: {N}
```
**Auto-merge conditions** (if `--merge` flag or user confirms):
- CI is passing
- No unresolved CHANGES_REQUESTED reviews
- PR is not draft
- PR is mergeable (no conflicts)
If all conditions met, ask the user for merge strategy:
"CI is green. Merge this PR? [squash/rebase/merge/no]"
Then execute:
```
gh pr merge {number} --{strategy} --delete-branch
```
If any condition NOT met, report what's blocking and let the user decide.
---
## Rules
- **Read before judging.** Never comment on code you haven't read in full. Verify line numbers.
- **Be specific.** "Line 42 returns 404 but should return 400 because X" not "this might have issues."
- **Fix the pattern, not just the instance.** When fixing a bug, grep for the same pattern across `src/`.
- **Respect the commit-msg hook.** Bug fixes need regression tests. Use `[skip-regression-check]` only if genuinely not feasible.
- **Don't over-fix.** Only change what was flagged. Don't refactor surrounding code or add improvements beyond the review scope.
- **Credit original authors.** If taking over someone else's PR, credit them in commits and comments.
- **No secrets in comments.** Never include customer data, credentials, or PII in GitHub comments.
- **Distinguish certainty.** "This IS a bug" vs "This COULD be a bug if X." Be honest.
- **Round up severity when uncertain.** Cheaper to dismiss a false alarm than miss a real bug.
- **Parallel where possible.** Use Agent tool for parallel file reads on large PRs. Batch `gh api` calls.
-63
View File
@@ -1,63 +0,0 @@
---
paths:
- "src/db/**"
- "src/history/**"
- "migrations/**"
---
# Database Rules
Dual-backend persistence: PostgreSQL + libSQL/Turso. **All new persistence features must support both backends.**
See `src/db/CLAUDE.md` for full schema, dialect differences, and libSQL limitations.
## Adding a New Operation
1. Decide which sub-trait it belongs to (`ConversationStore`, `JobStore`, `SandboxStore`, `RoutineStore`, `ToolFailureStore`, `SettingsStore`, `WorkspaceStore`) or create a new one
2. Add the async method signature to that sub-trait in `src/db/mod.rs`
3. Implement in `src/db/postgres.rs` (delegate to `Store`/`Repository`)
4. Implement in `src/db/libsql/<module>.rs` (use `self.connect().await?` per operation)
5. Add migration if needed:
- PostgreSQL: new `migrations/VN__description.sql`
- libSQL: add `CREATE TABLE IF NOT EXISTS` to `libsql_migrations.rs`
6. Test feature isolation:
```bash
cargo check # postgres (default)
cargo check --no-default-features --features libsql # libsql only
cargo check --all-features # both
```
## SQL Dialect Translation Checklist
When writing SQL for both backends, translate these types:
| PostgreSQL | libSQL |
|-----------|--------|
| `UUID` | `TEXT` |
| `TIMESTAMPTZ` | `TEXT` (ISO-8601, write with `fmt_ts()`, read with `get_ts()`) |
| `JSONB` | `TEXT` (JSON string) |
| `BOOLEAN` | `INTEGER` (0/1 -- use `get_i64(row, idx) != 0` to read) |
| `NUMERIC` | `TEXT` (preserves `rust_decimal` precision) |
| `TEXT[]` | `TEXT` (JSON-encoded array) |
| `VECTOR` | `BLOB` (flexible dimensions; vector index dropped, brute-force search fallback) |
| `jsonb_set(col, '{key}', val)` | `json_patch(col, '{"key": val}')` -- replaces top-level keys entirely, cannot do partial nested updates |
| `DEFAULT NOW()` | `DEFAULT (datetime('now'))` |
| `tsvector` + `ts_rank_cd` | FTS5 virtual table + sync triggers |
## Schema Translation Beyond DDL
Don't just translate `CREATE TABLE`. Also check:
- **Indexes** -- diff `CREATE INDEX` statements between backends
- **Seed data** -- check for `INSERT INTO` in migrations (e.g., `leak_detection_patterns`)
- **Triggers** -- PostgreSQL functions vs SQLite triggers (no stored procs in SQLite)
## Transaction Safety
Multi-step operations (INSERT+INSERT, UPDATE+DELETE, read-modify-write) MUST be wrapped in a transaction. Ask: "If this crashes between step N and N+1, is the database consistent?" If not, wrap in a transaction. Applies to both backends.
## libSQL Connection Model
`LibSqlBackend::connect()` creates a fresh connection per operation with `PRAGMA busy_timeout = 5000`. This is intentional -- no pool exists. Never hold connections open across `await` points. Satellite stores (`LibSqlSecretsStore`, `LibSqlWasmToolStore`) receive `Arc<LibSqlDatabase>` via `shared_db()` and call `.connect()` themselves -- never pass a live `Connection`.
## Fix the Pattern, Not the Instance
When fixing a bug in one backend's SQL, always grep for the same pattern in the other. A fix to `postgres.rs` that doesn't also fix `libsql/jobs.rs` is half a fix. Same applies to satellite stores.
-48
View File
@@ -1,48 +0,0 @@
---
paths:
- "src/**/*.rs"
---
# Review & Fix Discipline
Hard-won lessons from code review -- follow these when fixing bugs or addressing review feedback.
**Fix the pattern, not just the instance:** When a reviewer flags a bug (e.g., TOCTOU race in INSERT + SELECT-back), search the entire codebase for all instances of that same pattern. A fix in `SecretsStore::create()` that doesn't also fix `WasmToolStore::store()` is half a fix.
**Propagate architectural fixes to satellite types:** If a core type changes its concurrency model (e.g., `LibSqlBackend` switches to connection-per-operation), every type that was handed a resource from the old model must also be updated. Grep for the old type across the codebase.
**Schema translation is more than DDL:** When translating a database schema between backends (PostgreSQL to libSQL, etc.), check for:
- **Indexes** -- diff `CREATE INDEX` statements between the two schemas
- **Seed data** -- check for `INSERT INTO` in migrations (e.g., `leak_detection_patterns`)
- **Semantic differences** -- document where SQL functions behave differently (e.g., `json_patch` vs `jsonb_set`)
**Feature flag testing:** When adding feature-gated code, test compilation with each feature in isolation:
```bash
cargo check # default features
cargo check --no-default-features --features libsql # libsql only
cargo check --all-features # all features
```
**Regression test with every fix:** Every bug fix must include a test that would have caught the bug. Add a `#[test]` or `#[tokio::test]` that reproduces the original failure. Exempt: changes limited to `src/channels/web/static/` or `.md` files. Use `[skip-regression-check]` in commit message or PR label if genuinely not feasible. The `commit-msg` hook and CI workflow enforce this automatically.
**Zero clippy warnings policy:** Fix ALL clippy warnings before committing, including pre-existing ones in files you didn't change. Never leave warnings behind.
**Transaction safety:** Multi-step database operations (INSERT+INSERT, UPDATE+DELETE, read-then-write) MUST be wrapped in a transaction. Never assume sequential calls are atomic. This applies to both postgres and libsql backends.
**UTF-8 string safety:** Never use byte-index slicing (`&s[..n]`) on user-supplied or external strings -- it panics on multi-byte characters. Use `is_char_boundary()` or `char_indices()`. Grep for `[..` in changed files.
**Case-insensitive comparisons:** When comparing user-supplied strings (file paths, media types, extension names), normalize to lowercase with `.to_ascii_lowercase()`. Path comparisons must be case-insensitive on macOS/Windows.
**Decorator/wrapper trait delegation:** When adding a new method to `LlmProvider` (or any trait with decorator wrappers), update ALL wrapper types to delegate. Grep for `impl LlmProvider for` to find all implementations. Test through the full provider chain.
**Sensitive data in logs & events:** Tool parameters and outputs MUST be redacted before logging or broadcasting via SSE/WebSocket. Use `redact_params()` before any `tracing::info!`, `JobEvent`, or SSE emission that includes tool call data.
**Test temporary files:** Use the `tempfile` crate. Never hardcode `/tmp/...` paths.
**Trust boundaries in multi-process architecture:** Data from worker containers is untrusted. The orchestrator MUST validate: tool domain, nesting depth (server-side tracking), and parameter sensitivity.
**Mechanical verification before committing:**
- `cargo clippy --all --benches --tests --examples --all-features` -- zero warnings
- `grep -rnE '\.unwrap\(|\.expect\(' <files>` -- no panics in production
- `grep -rn 'super::' <files>` -- prefer `crate::` for cross-module imports (`super::` OK in tests/intra-module)
- If you fixed a pattern bug, `grep` for other instances across `src/`
- Run `scripts/pre-commit-safety.sh` to catch UTF-8, case-sensitivity, hardcoded /tmp, and logging issues
-34
View File
@@ -1,34 +0,0 @@
---
paths:
- "src/safety/**"
- "src/sandbox/**"
- "src/secrets/**"
- "src/tools/wasm/**"
---
# Safety Layer & Sandbox Rules
## Safety Layer
All external tool output passes through `SafetyLayer`:
1. **Sanitizer** - Detects injection patterns, escapes dangerous content
2. **Validator** - Checks length, encoding, forbidden patterns
3. **Policy** - Rules with severity (Critical/High/Medium/Low) and actions (Block/Warn/Review/Sanitize)
4. **Leak Detector** - Scans for 15+ secret patterns at two points: tool output before LLM, and LLM responses before user
Tool outputs are wrapped in `<tool_output>` XML before reaching the LLM.
## Shell Environment Scrubbing
The shell tool scrubs sensitive env vars before executing commands. The sanitizer detects command injection patterns (chained commands, subshells, path traversal).
## Sandbox Policies
| Policy | Filesystem | Network |
|--------|-----------|---------|
| ReadOnly | Read-only workspace | Allowlisted domains |
| WorkspaceWrite | Read-write workspace | Allowlisted domains |
| FullAccess | Full filesystem | Unrestricted |
## Zero-Exposure Credential Model
Secrets are stored encrypted on the host and injected into HTTP requests by the proxy at transit time. Container processes never see raw credential values.
-56
View File
@@ -1,56 +0,0 @@
---
paths:
- "src/skills/**"
- "skills/**"
---
# Skills System
SKILL.md files extend the agent's prompt with domain-specific instructions. Each skill is a YAML frontmatter block (metadata, activation criteria, required tools) followed by a markdown body injected into the LLM context.
## Trust Model
| Trust Level | Source | Tool Access |
|-------------|--------|-------------|
| **Trusted** | User-placed in `~/.ironclaw/skills/` or workspace `skills/` | All tools available to the agent |
| **Installed** | Downloaded from ClawHub registry (`~/.ironclaw/installed_skills/`) | Read-only tools only (no shell, file write, HTTP) |
## SKILL.md Format
```yaml
---
name: my-skill
version: 0.1.0
description: Does something useful
activation:
patterns:
- "deploy to.*production"
keywords:
- "deployment"
exclude_keywords:
- "rollback"
tags:
- "devops"
max_context_tokens: 2000
metadata:
openclaw:
requires:
bins: [docker, kubectl]
env: [KUBECONFIG]
---
# Skill instructions here...
```
## Selection Pipeline
1. **Gating** -- Check binary/env/config requirements; skip skills whose prerequisites are missing
2. **Scoring** -- Deterministic scoring: keywords (10/5 pts, cap 30) + patterns (20 pts, cap 40) + tags (3 pts, cap 15). `exclude_keywords` veto (score = 0 if any present)
3. **Budget** -- Select top-scoring skills within `SKILLS_MAX_TOKENS` prompt budget
4. **Attenuation** -- Minimum trust across active skills determines tool ceiling; installed skills lose dangerous tools
## Skill Tools
- `skill_list` -- List all discovered skills with trust level and status
- `skill_search` -- Search ClawHub registry for available skills
- `skill_install` -- Download and install a skill from ClawHub
- `skill_remove` -- Remove an installed skill
-25
View File
@@ -1,25 +0,0 @@
---
paths:
- "src/**/*.rs"
- "tests/**"
---
# Testing Rules
## Test Tiers
| Tier | Command | External deps |
|------|---------|---------------|
| Unit | `cargo test` | None |
| Integration | `cargo test --features integration` | Running PostgreSQL |
| Live | `cargo test --features integration -- --ignored` | PostgreSQL + LLM API keys |
Run `bash scripts/check-boundaries.sh` to verify test tier gating.
## Key Patterns
- Unit tests in `mod tests {}` at the bottom of each file
- Async tests with `#[tokio::test]`
- No mocks, prefer real implementations or stubs
- Use `tempfile` crate for test directories, never hardcode `/tmp/`
- Regression test with every bug fix (enforced by commit-msg hook)
- Integration tests (`--test workspace_integration`) require PostgreSQL; skipped if DB is unreachable
-39
View File
@@ -1,39 +0,0 @@
---
paths:
- "src/tools/**"
- "tools-src/**"
---
# Tool Architecture
**Keep tool-specific logic out of the main agent codebase.** The main agent provides generic infrastructure; tools are self-contained units that declare requirements through `<name>.capabilities.json` sidecar files (in dev mode: `tools-src/<name>/<name>-tool.capabilities.json`).
Tools can be WASM (sandboxed, credential-injected, single binary) or MCP servers (ecosystem, any language, no sandbox). Both are first-class via `ironclaw tool install`.
See `src/tools/README.md` for full architecture, adding new tools, auth JSON examples, and WASM vs MCP decision guide.
## Tool Implementation Pattern
```rust
#[async_trait]
impl Tool for MyTool {
fn name(&self) -> &str { "my_tool" }
fn description(&self) -> &str { "Does something useful" }
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"param": { "type": "string", "description": "A parameter" }
},
"required": ["param"]
})
}
async fn execute(&self, params: serde_json::Value, ctx: &JobContext)
-> Result<ToolOutput, ToolError>
{
let start = std::time::Instant::now();
// ... do work ...
Ok(ToolOutput::text("result", start.elapsed()))
}
fn requires_sanitization(&self) -> bool { true } // External data
}
```
-2
View File
@@ -115,8 +115,6 @@ AGENT_NAME=ironclaw
AGENT_MAX_PARALLEL_JOBS=5
AGENT_JOB_TIMEOUT_SECS=3600
AGENT_STUCK_THRESHOLD_SECS=300
# Maximum tokens per job (0 = unlimited, also settable via settings.json agent.max_tokens_per_job)
# AGENT_MAX_TOKENS_PER_JOB=0
# Enable planning phase before tool execution (default: true)
AGENT_USE_PLANNING=true
-50
View File
@@ -1,50 +0,0 @@
## Summary
<!-- 2-5 bullet points: what changed and why -->
-
## Change Type
<!-- Check one -->
- [ ] Bug fix
- [ ] New feature
- [ ] Refactor
- [ ] Documentation
- [ ] CI/Infrastructure
- [ ] Security
- [ ] Dependencies
## Linked Issue
<!-- Closes #N, or "None" -->
## Validation
<!-- How did you verify this works? -->
- [ ] `cargo fmt`
- [ ] `cargo clippy --all --benches --tests --examples --all-features`
- [ ] Relevant tests pass: <!-- list specific tests -->
- [ ] Manual testing: <!-- describe what you tested -->
## Security Impact
<!-- Does this change affect: permissions, network calls, secrets, file access, tool execution, sandbox policy? If yes, describe. If no, write "None". -->
## Database Impact
<!-- Does this add/modify migrations, change schema, or affect both PostgreSQL and libSQL? If yes, describe. If no, write "None". -->
## Blast Radius
<!-- What subsystems does this touch? What could break? -->
## Rollback Plan
<!-- How to revert if this causes problems? For Track C changes, this is mandatory. -->
---
**Review track**: <!-- A (docs/tests/chore) | B (feature/refactor) | C (security/runtime/DB/CI) -->
+1 -2
View File
@@ -2,7 +2,7 @@ name: Claude Code Review
on:
pull_request:
types: [labeled]
types: [opened, labeled]
permissions:
contents: read
@@ -28,7 +28,6 @@ jobs:
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
allowed_bots: "ironclaw-ci[bot]"
claude_args: "--max-turns 50 --model claude-haiku-4-5-20251001 --allowedTools 'Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh search:*),Bash(git blame:*),Bash(git log:*),Bash(git diff:*)'"
prompt: |
Code review this pull request. Follow these steps precisely:
+1 -7
View File
@@ -44,7 +44,6 @@ jobs:
clippy-windows:
name: Clippy Windows (${{ matrix.name }})
if: github.base_ref == 'main'
runs-on: windows-latest
strategy:
fail-fast: false
@@ -77,12 +76,7 @@ jobs:
needs: [format, clippy, clippy-windows]
steps:
- run: |
if [[ "${{ needs.format.result }}" != "success" || "${{ needs.clippy.result }}" != "success" ]]; then
if [[ "${{ needs.format.result }}" != "success" || "${{ needs.clippy.result }}" != "success" || "${{ needs.clippy-windows.result }}" != "success" ]]; then
echo "One or more jobs failed"
exit 1
fi
# clippy-windows only runs on main PRs, so skip/success are both acceptable
if [[ "${{ needs.clippy-windows.result }}" == "failure" ]]; then
echo "Windows clippy failed"
exit 1
fi
+23 -63
View File
@@ -144,8 +144,6 @@ jobs:
- name: Patch manifests with WASM checksums
if: ${{ needs.plan.outputs.publishing == 'true' }}
shell: bash
env:
RELEASE_TAG: ${{ github.ref_name }}
run: |
CHECKSUMS="target/distrib/checksums.txt"
if [ ! -f "$CHECKSUMS" ]; then
@@ -156,17 +154,12 @@ jobs:
while IFS= read -r line; do
sha256=$(echo "$line" | awk '{print $1}')
filename=$(echo "$line" | awk '{print $2}')
# Strip -{version}-wasm32-wasip2.tar.gz to get the extension name.
# Use '.*' (greedy) so pre-release suffixes like -alpha.1 are consumed too.
name=$(echo "$filename" | sed 's/-[0-9].*-wasm32-wasip2\.tar\.gz$//')
url="https://github.com/nearai/ironclaw/releases/download/${RELEASE_TAG}/${filename}"
name=$(echo "$filename" | sed 's/-wasm32-wasip2\.tar\.gz$//')
for manifest in registry/tools/${name}.json registry/channels/${name}.json; do
if [ -f "$manifest" ]; then
jq --arg sha "$sha256" --arg url "$url" \
'.artifacts["wasm32-wasip2"].sha256 = $sha | .artifacts["wasm32-wasip2"].url = $url' \
"$manifest" > "${manifest}.tmp" && mv "${manifest}.tmp" "$manifest"
echo "Patched $manifest with sha256=$sha256 url=$url"
jq --arg sha "$sha256" '.artifacts["wasm32-wasip2"].sha256 = $sha' "$manifest" > "${manifest}.tmp" && mv "${manifest}.tmp" "$manifest"
echo "Patched $manifest with sha256=$sha256"
fi
done
done < "$CHECKSUMS"
@@ -275,41 +268,21 @@ jobs:
for manifest in registry/tools/*.json registry/channels/*.json; do
[ -f "$manifest" ] || continue
# file_stem: JSON filename without extension (e.g. "slack" for slack.json).
# Used for the bundle filename and CI manifest lookup, so patching always
# finds the right file regardless of whether manifest.name matches the filename.
file_stem=$(basename "$manifest" .json)
# ext_name: the manifest's .name field (e.g. "slack-tool").
# Used for file names *inside* the archive — the installer extracts by manifest.name.
ext_name=$(jq -r '.name' "$manifest")
name=$(jq -r '.name' "$manifest")
source_dir=$(jq -r '.source.dir' "$manifest")
caps_file=$(jq -r '.source.capabilities' "$manifest")
crate_name=$(jq -r '.source.crate_name' "$manifest")
ext_version=$(jq -r '.version // ""' "$manifest")
if [ ! -d "$source_dir" ]; then
echo "::warning::Source dir '$source_dir' not found for '$file_stem', skipping"
echo "::warning::Source dir '$source_dir' not found for '$name', skipping"
continue
fi
# Skip rebuild if this exact version was already built and checksummed.
# Checks that (1) the manifest already has a sha256, and (2) the version
# embedded in the existing artifact URL matches the current manifest version.
# This ensures stable checksums: only rebuild when the source version changes.
existing_sha=$(jq -r '.artifacts["wasm32-wasip2"].sha256 // ""' "$manifest")
existing_url=$(jq -r '.artifacts["wasm32-wasip2"].url // ""' "$manifest")
url_version=$(echo "$existing_url" | sed -n 's/.*-\([0-9].*\)-wasm32-wasip2\.tar\.gz$/\1/p')
if [[ -n "$ext_version" && "$url_version" == "$ext_version" && -n "$existing_sha" ]]; then
echo "=== Skipping $file_stem v$ext_version — already checksummed at $existing_url ==="
continue
fi
echo "=== Building $file_stem ($ext_name) v$ext_version from $source_dir ==="
echo "=== Building $name from $source_dir ==="
# Build WASM component
cargo component build --release --manifest-path "$source_dir/Cargo.toml" || {
echo "::warning::Build failed for '$file_stem', skipping"
echo "::warning::Build failed for '$name', skipping"
continue
}
@@ -325,36 +298,30 @@ jobs:
done
if [ -z "$wasm_path" ]; then
echo "::warning::No WASM output found for '$file_stem', skipping"
echo "::warning::No WASM output found for '$name', skipping"
continue
fi
# Archive contents use ext_name (manifest .name) — the installer extracts
# files by manifest.name, so these must match even when file_stem differs.
cp "$wasm_path" "target/wasm-bundles/${ext_name}.wasm"
# Copy files with standardized names for the archive
cp "$wasm_path" "target/wasm-bundles/${name}.wasm"
caps_path="$source_dir/$caps_file"
if [ -f "$caps_path" ]; then
cp "$caps_path" "target/wasm-bundles/${ext_name}.capabilities.json"
cp "$caps_path" "target/wasm-bundles/${name}.capabilities.json"
else
echo "::warning::No capabilities file at '$caps_path' for '$file_stem'"
echo "::warning::No capabilities file at '$caps_path' for '$name'"
fi
# Bundle filename uses file_stem so CI patching can find the manifest by
# filename (e.g. slack-0.1.0-wasm32-wasip2.tar.gz → registry/tools/slack.json).
bundle="target/wasm-bundles/${file_stem}-${ext_version}-wasm32-wasip2.tar.gz"
(cd target/wasm-bundles && if [ -f "${ext_name}.capabilities.json" ]; then
tar czf "${file_stem}-${ext_version}-wasm32-wasip2.tar.gz" "${ext_name}.wasm" "${ext_name}.capabilities.json"
else
tar czf "${file_stem}-${ext_version}-wasm32-wasip2.tar.gz" "${ext_name}.wasm"
fi)
# Create tar.gz bundle
bundle="target/wasm-bundles/${name}-wasm32-wasip2.tar.gz"
(cd target/wasm-bundles && if [ -f "${name}.capabilities.json" ]; then tar czf "${name}-wasm32-wasip2.tar.gz" "${name}.wasm" "${name}.capabilities.json"; else tar czf "${name}-wasm32-wasip2.tar.gz" "${name}.wasm"; fi)
# Compute SHA256
sha256=$(sha256sum "$bundle" | cut -d' ' -f1)
echo "$sha256 ${file_stem}-${ext_version}-wasm32-wasip2.tar.gz" >> target/wasm-bundles/checksums.txt
echo "$sha256 ${name}-wasm32-wasip2.tar.gz" >> target/wasm-bundles/checksums.txt
# Clean up intermediate files
rm -f "target/wasm-bundles/${ext_name}.wasm" "target/wasm-bundles/${ext_name}.capabilities.json"
rm -f "target/wasm-bundles/${name}.wasm" "target/wasm-bundles/${name}.capabilities.json"
echo " -> $bundle ($sha256)"
done
@@ -460,10 +427,8 @@ jobs:
with:
name: artifacts-wasm-extensions
path: target/wasm-bundles/
- name: Patch manifests with SHA256 and version-pinned URL
- name: Patch manifests with SHA256
shell: bash
env:
RELEASE_TAG: ${{ github.ref_name }}
run: |
CHECKSUMS="target/wasm-bundles/checksums.txt"
if [ ! -f "$CHECKSUMS" ]; then
@@ -474,17 +439,12 @@ jobs:
while IFS= read -r line; do
sha256=$(echo "$line" | awk '{print $1}')
filename=$(echo "$line" | awk '{print $2}')
# Strip -{version}-wasm32-wasip2.tar.gz to get the extension name.
# Use '.*' (greedy) so pre-release suffixes like -alpha.1 are consumed too.
name=$(echo "$filename" | sed 's/-[0-9].*-wasm32-wasip2\.tar\.gz$//')
url="https://github.com/nearai/ironclaw/releases/download/${RELEASE_TAG}/${filename}"
name=$(echo "$filename" | sed 's/-wasm32-wasip2\.tar\.gz$//')
for manifest in registry/tools/${name}.json registry/channels/${name}.json; do
if [ -f "$manifest" ]; then
jq --arg sha "$sha256" --arg url "$url" \
'.artifacts["wasm32-wasip2"].sha256 = $sha | .artifacts["wasm32-wasip2"].url = $url' \
"$manifest" > "${manifest}.tmp" && mv "${manifest}.tmp" "$manifest"
echo "Patched $manifest with sha256=$sha256 url=$url"
jq --arg sha "$sha256" '.artifacts["wasm32-wasip2"].sha256 = $sha' "$manifest" > "${manifest}.tmp" && mv "${manifest}.tmp" "$manifest"
echo "Patched $manifest with sha256=$sha256"
fi
done
done < "$CHECKSUMS"
@@ -501,8 +461,8 @@ jobs:
git commit -m "chore: update WASM artifact SHA256 checksums [skip ci]"
git push origin "$BRANCH"
gh pr create \
--title "chore: update WASM artifact checksums and version-pinned URLs" \
--body "Auto-generated by release CI. Updates SHA256 checksums and version-pinned artifact URLs in registry manifests to match the released WASM artifacts. Only extensions whose version changed since the last release are included." \
--title "chore: update WASM artifact SHA256 checksums" \
--body "Auto-generated by release CI. Updates SHA256 checksums in registry manifests to match the released WASM artifacts." \
--base main \
--head "$BRANCH"
fi
+2
View File
@@ -115,6 +115,7 @@ jobs:
- name: Generate GitHub App token
id: app-token
continue-on-error: true
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
@@ -229,6 +230,7 @@ jobs:
- name: Generate GitHub App token
id: app-token
continue-on-error: true
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
-2
View File
@@ -2,8 +2,6 @@ name: Run Tests
on:
workflow_call:
pull_request:
branches:
- main
push:
branches:
- main
-4
View File
@@ -25,7 +25,3 @@ bench-results/
# Traces
trace_*.json
# Local Claude Code settings (machine-specific, should not be committed)
.claude/settings.local.json
.worktrees/
-84
View File
@@ -7,90 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.18.0](https://github.com/nearai/ironclaw/compare/v0.17.0...v0.18.0) - 2026-03-11
### Other
- Merge pull request #907 from nearai/staging-promote/b0214fef-22930316561
- promote staging to main (2026-03-10 15:19 UTC) ([#865](https://github.com/nearai/ironclaw/pull/865))
- Merge pull request #830 from nearai/staging-promote/3a2989d0-22888378864
- update WASM artifact SHA256 checksums [skip ci] ([#876](https://github.com/nearai/ironclaw/pull/876))
## [0.17.0](https://github.com/nearai/ironclaw/compare/v0.16.1...v0.17.0) - 2026-03-10
### Added
- *(llm)* per-provider unsupported parameter filtering (#749, #728) ([#809](https://github.com/nearai/ironclaw/pull/809))
- persist user_id in save_job and expose job_id on routine runs ([#709](https://github.com/nearai/ironclaw/pull/709))
- *(ci)* chained promotion PRs with multi-agent Claude review ([#776](https://github.com/nearai/ironclaw/pull/776))
- add background sandbox reaper for orphaned Docker containers ([#634](https://github.com/nearai/ironclaw/pull/634))
- *(wasm)* lazy schema injection on WASM tool errors ([#638](https://github.com/nearai/ironclaw/pull/638))
- add AWS Bedrock LLM provider via native Converse API ([#713](https://github.com/nearai/ironclaw/pull/713))
- full image support across all channels ([#725](https://github.com/nearai/ironclaw/pull/725))
- *(skills)* exclude_keywords veto in skill activation scoring ([#688](https://github.com/nearai/ironclaw/pull/688))
- *(mcp)* transport abstraction, stdio/UDS transports, and OAuth fixes ([#721](https://github.com/nearai/ironclaw/pull/721))
- add PID-based gateway lock to prevent multiple instances ([#717](https://github.com/nearai/ironclaw/pull/717))
- configurable LLM request timeout via LLM_REQUEST_TIMEOUT_SECS ([#615](https://github.com/nearai/ironclaw/pull/615)) ([#630](https://github.com/nearai/ironclaw/pull/630))
- *(timezone)* add timezone-aware session context ([#671](https://github.com/nearai/ironclaw/pull/671))
- *(setup)* Anthropic OAuth onboarding with setup-token support ([#384](https://github.com/nearai/ironclaw/pull/384))
- *(llm)* add Google Gemini, AWS Bedrock, io.net, Mistral, Yandex, and Cloudflare WS AI providers ([#676](https://github.com/nearai/ironclaw/pull/676))
- unified thread model for web gateway ([#607](https://github.com/nearai/ironclaw/pull/607))
- WASM channel attachments with LLM pipeline integration ([#596](https://github.com/nearai/ironclaw/pull/596))
- enable Anthropic prompt caching via automatic cache_control injection ([#660](https://github.com/nearai/ironclaw/pull/660))
- *(routines)* approval context for autonomous job execution ([#577](https://github.com/nearai/ironclaw/pull/577))
- *(llm)* declarative provider registry ([#618](https://github.com/nearai/ironclaw/pull/618))
- *(gateway)* show IronClaw version in status popover [skip-regression-check] ([#636](https://github.com/nearai/ironclaw/pull/636))
- Wire memory hygiene retention policy into heartbeat loop ([#629](https://github.com/nearai/ironclaw/pull/629))
### Fixed
- *(ci)* run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] ([#802](https://github.com/nearai/ironclaw/pull/802))
- *(ci)* clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] ([#794](https://github.com/nearai/ironclaw/pull/794))
- *(ci)* secrets can't be used in step if conditions [skip-regression-check] ([#787](https://github.com/nearai/ironclaw/pull/787))
- prevent irreversible context loss when compaction archive write fails ([#754](https://github.com/nearai/ironclaw/pull/754))
- button styles ([#637](https://github.com/nearai/ironclaw/pull/637))
- *(mcp)* JSON-RPC spec compliance — flexible id, correct notification format ([#685](https://github.com/nearai/ironclaw/pull/685))
- preserve tool-call history across thread hydration ([#568](https://github.com/nearai/ironclaw/pull/568)) ([#670](https://github.com/nearai/ironclaw/pull/670))
- CLI commands ignore runtime DATABASE_BACKEND when both features compiled ([#740](https://github.com/nearai/ironclaw/pull/740))
- *(web)* prevent fetch error when hostname is an IP address in TEE check ([#672](https://github.com/nearai/ironclaw/pull/672))
- add timezone conversion support to time tool ([#687](https://github.com/nearai/ironclaw/pull/687))
- standardize libSQL timestamps as RFC 3339 UTC ([#683](https://github.com/nearai/ironclaw/pull/683))
- *(docker)* bind postgres to localhost only ([#686](https://github.com/nearai/ironclaw/pull/686))
- *(repl)* skip /quit on EOF when stdin is not a TTY ([#724](https://github.com/nearai/ironclaw/pull/724))
- *(web)* prevent Enter key from sending message during IME composition ([#715](https://github.com/nearai/ironclaw/pull/715))
- *(config)* init_secrets no longer overwrites entire config ([#726](https://github.com/nearai/ironclaw/pull/726))
- *(cli)* status command ignores config.toml and settings.json ([#354](https://github.com/nearai/ironclaw/pull/354)) ([#734](https://github.com/nearai/ironclaw/pull/734))
- *(setup)* preserve model name when re-running onboarding with same provider ([#600](https://github.com/nearai/ironclaw/pull/600)) ([#694](https://github.com/nearai/ironclaw/pull/694))
- *(setup)* initialize secrets crypto for env-var security option ([#666](https://github.com/nearai/ironclaw/pull/666)) ([#706](https://github.com/nearai/ironclaw/pull/706))
- persist /model selection across restarts ([#707](https://github.com/nearai/ironclaw/pull/707))
- *(routines)* resolve message tool channel/target from per-job metadata ([#708](https://github.com/nearai/ironclaw/pull/708))
- sanitize HTML error bodies from MCP servers to prevent web UI white screen ([#263](https://github.com/nearai/ironclaw/pull/263)) ([#656](https://github.com/nearai/ironclaw/pull/656))
- prevent Instant duration overflow on Windows ([#657](https://github.com/nearai/ironclaw/pull/657)) ([#664](https://github.com/nearai/ironclaw/pull/664))
- enable libsql remote + tls features for Turso cloud sync ([#587](https://github.com/nearai/ironclaw/pull/587))
- *(tests)* replace hardcoded /tmp paths with tempdir + add 300 unit tests ([#659](https://github.com/nearai/ironclaw/pull/659))
- *(llm)* nudge LLM when it expresses tool intent without calling tools ([#653](https://github.com/nearai/ironclaw/pull/653))
- *(llm)* report zero cost for OpenRouter free-tier models ([#463](https://github.com/nearai/ironclaw/pull/463)) ([#613](https://github.com/nearai/ironclaw/pull/613))
- reliable network tests and improved tool error messages ([#626](https://github.com/nearai/ironclaw/pull/626))
- *(wasm)* use per-engine cache dirs on Windows to avoid file lock error ([#624](https://github.com/nearai/ironclaw/pull/624))
- *(libsql)* support flexible embedding dimensions ([#534](https://github.com/nearai/ironclaw/pull/534))
### Other
- Restructure CLAUDE.md into modular rules + add pr-shepherd command ([#750](https://github.com/nearai/ironclaw/pull/750))
- make src/llm/ self-contained for crate extraction ([#767](https://github.com/nearai/ironclaw/pull/767))
- add simplified Chinese (zh-CN) README translation ([#488](https://github.com/nearai/ironclaw/pull/488))
- *(job)* cover job tool validation and state transitions ([#681](https://github.com/nearai/ironclaw/pull/681))
- *(agent)* wire TestRig job tools through the scheduler ([#716](https://github.com/nearai/ironclaw/pull/716))
- Fix single-message mode to exit after one turn when background channels are enabled ([#719](https://github.com/nearai/ironclaw/pull/719))
- remove dead code ([#648](https://github.com/nearai/ironclaw/pull/648)) ([#703](https://github.com/nearai/ironclaw/pull/703))
- add reviewer-feedback guardrails (CLAUDE.md, pre-commit hook, skill) ([#665](https://github.com/nearai/ironclaw/pull/665))
- update WASM artifact SHA256 checksums [skip ci] ([#631](https://github.com/nearai/ironclaw/pull/631))
- add explanatory comments to coverage workflow ([#610](https://github.com/nearai/ironclaw/pull/610))
- build system prompt once per turn, skip tools on force-text ([#583](https://github.com/nearai/ironclaw/pull/583))
- add comprehensive subdirectory CLAUDE.md files and update root ([#589](https://github.com/nearai/ironclaw/pull/589))
- Improve test infrastructure: StubChannel, gateway helpers, security tests, search edge cases ([#623](https://github.com/nearai/ironclaw/pull/623))
- *(workspace)* regression test for document_path in search results ([#509](https://github.com/nearai/ironclaw/pull/509))
### Added
- AWS Bedrock LLM provider via native Converse API with IAM and SSO auth support (feature-gated: `--features bedrock`)
+562 -84
View File
@@ -1,37 +1,73 @@
# IronClaw Development Guide
**IronClaw** is a secure personal AI assistant — user-first security, self-expanding tools, defense in depth, multi-channel access with proactive background execution.
## Project Overview
**IronClaw** is a secure personal AI assistant that protects your data and expands its capabilities on the fly.
### Core Philosophy
- **User-first security** - Your data stays yours, encrypted and local
- **Self-expanding** - Build new tools dynamically without vendor dependency
- **Defense in depth** - Multiple security layers against prompt injection and data exfiltration
- **Always available** - Multi-channel access with proactive background execution
### Features
- **Multi-channel input**: TUI (Ratatui), HTTP webhooks, WASM channels (Telegram, Slack), web gateway
- **Parallel job execution** with state machine and self-repair for stuck jobs
- **Sandbox execution**: Docker container isolation with network proxy and credential injection
- **Claude Code mode**: Delegate jobs to Claude CLI inside containers
- **Skills system**: SKILL.md prompt extensions with trust model, tool attenuation, and ClawHub registry
- **Routines**: Scheduled (cron) and reactive (event, webhook) task execution
- **Web gateway**: Browser UI with SSE/WebSocket real-time streaming
- **Extension management**: Install, auth, activate MCP/WASM extensions
- **Extensible tools**: Built-in tools, WASM sandbox, MCP client, dynamic builder
- **Persistent memory**: Workspace with hybrid search (FTS + vector via RRF)
- **Prompt injection defense**: Sanitizer, validator, policy rules, leak detection, shell env scrubbing
- **Multi-provider LLM**: NEAR AI, OpenAI, Anthropic, Ollama, OpenAI-compatible, Tinfoil private inference
- **Setup wizard**: 7-step interactive onboarding for first-run configuration
- **Heartbeat system**: Proactive periodic execution with checklist
## Build & Test
```bash
cargo fmt # format
cargo clippy --all --benches --tests --examples --all-features # lint (zero warnings)
cargo test # unit tests
cargo test --features integration # + PostgreSQL tests
RUST_LOG=ironclaw=debug cargo run # run with logging
# Format code
cargo fmt
# Lint (fix ALL warnings before committing, including pre-existing ones)
cargo clippy --all --benches --tests --examples --all-features
# Run all tests
cargo test
# Run specific test
cargo test test_name
# Run with logging
RUST_LOG=ironclaw=debug cargo run
# Run integration tests (may require running services/DB)
cargo test --test workspace_integration
cargo test --test ws_gateway_integration
cargo test --test heartbeat_integration
# Run E2E tests (Python/Playwright — requires a running ironclaw instance)
# See tests/e2e/CLAUDE.md for full setup instructions
cd tests/e2e
python -m venv .venv && source .venv/bin/activate # On Windows: .venv\Scripts\activate
pip install -e .
playwright install chromium
pytest scenarios/ # all scenarios
pytest scenarios/test_chat.py # specific scenario
```
E2E tests: see `tests/e2e/CLAUDE.md`.
### Test Tiers
## Code Style
| Tier | Command | What runs | External deps |
|------|---------|-----------|---------------|
| Unit | `cargo test` | All `mod tests` + self-contained integration tests | None |
| Integration | `cargo test --features integration` | + PostgreSQL-dependent tests | Running PostgreSQL |
| Live | `cargo test --features integration -- --ignored` | + LLM-dependent tests | PostgreSQL + LLM API keys |
- Prefer `crate::` for cross-module imports; `super::` is fine in tests and intra-module refs
- No `pub use` re-exports unless exposing to downstream consumers
- No `.unwrap()` or `.expect()` in production code (tests are fine)
- Use `thiserror` for error types in `error.rs`
- Map errors with context: `.map_err(|e| SomeError::Variant { reason: e.to_string() })?`
- Prefer strong types over strings (enums, newtypes)
- Keep functions focused, extract helpers when logic is reused
- Comments for non-obvious logic only
## Architecture
Prefer generic/extensible architectures over hardcoding specific integrations. Ask clarifying questions about the desired abstraction level before implementing.
Key traits for extensibility: `Database`, `Channel`, `Tool`, `LlmProvider`, `SuccessEvaluator`, `EmbeddingProvider`, `NetworkPolicyDecider`, `Hook`, `Observer`, `Tunnel`.
All I/O is async with tokio. Use `Arc<T>` for shared state, `RwLock` for concurrent access.
Run `bash scripts/check-boundaries.sh` to verify test tier gating and other architecture rules.
## Project Structure
@@ -59,6 +95,12 @@ src/
│ ├── channel.rs # Channel trait, IncomingMessage, OutgoingResponse
│ ├── manager.rs # ChannelManager merges streams
│ ├── cli/ # Full TUI with Ratatui
│ │ ├── mod.rs # TuiChannel implementation
│ │ ├── app.rs # Application state
│ │ ├── render.rs # UI rendering
│ │ ├── events.rs # Input handling
│ │ ├── overlay.rs # Approval overlays
│ │ └── composer.rs # Message composition
│ ├── http.rs # HTTP webhook (axum) with secret validation
│ ├── webhook_server.rs # Unified HTTP server composing all webhook routes
│ ├── repl.rs # Simple REPL (for testing)
@@ -69,39 +111,62 @@ src/
│ ├── capabilities.rs # Channel-specific capabilities (HTTP endpoint, emit rate)
│ ├── error.rs # WASM channel error types
│ ├── runtime.rs # WASM channel execution runtime
│ ├── setup.rs # WasmChannelSetup, setup_wasm_channels(), inject_channel_credentials()
│ └── wrapper.rs # Channel trait wrapper for WASM modules
├── cli/ # CLI subcommands (clap)
│ ├── mod.rs # Cli struct, Command enum (run/onboard/config/tool/registry/mcp/memory/pairing/service/doctor/status/completion)
── config.rs, tool.rs, registry.rs, mcp.rs, memory.rs, pairing.rs, service.rs, doctor.rs, status.rs, completion.rs
── config.rs # config list/get/set subcommands
│ ├── tool.rs # tool install/list/remove subcommands
│ ├── registry.rs # registry list/install subcommands
│ ├── mcp.rs # mcp add/auth/list/test subcommands
│ ├── memory.rs # memory search/read/write subcommands
│ ├── pairing.rs # pairing list/approve subcommands
│ ├── service.rs # service install/start/stop subcommands
│ ├── doctor.rs # Active health diagnostics
│ ├── status.rs # System health/status display
│ ├── completion.rs # Shell completion script generation
│ └── oauth_defaults.rs # Default OAuth redirect URIs
├── registry/ # Extension registry catalog
│ ├── mod.rs # Public API; re-exports RegistryCatalog, RegistryInstaller, manifest types
│ ├── manifest.rs # ExtensionManifest, ArtifactSpec, BundleDefinition types
│ ├── catalog.rs # RegistryCatalog: load from filesystem and embedded JSON
── installer.rs # RegistryInstaller: download, verify, install WASM artifacts
── installer.rs # RegistryInstaller: download, verify, install WASM artifacts
│ ├── artifacts.rs # Artifact download and caching
│ └── embedded.rs # Catalog compiled into binary at build time (via build.rs)
├── hooks/ # Lifecycle hooks (6 points: BeforeInbound, BeforeToolCall, BeforeOutbound, OnSessionStart, OnSessionEnd, TransformResponse)
├── hooks/ # Lifecycle hooks for intercepting agent operations
│ ├── mod.rs # 6 HookPoints: BeforeInbound, BeforeToolCall, BeforeOutbound, OnSessionStart, OnSessionEnd, TransformResponse
│ ├── hook.rs # Hook trait, HookContext, HookEvent, HookOutcome, HookFailureMode
│ ├── registry.rs # HookRegistry: register, prioritize, execute hooks
│ └── bundled.rs # Built-in hooks: rule-based filters, webhook forwarders, HookBundleConfig
├── tunnel/ # Tunnel abstraction for public internet exposure
│ ├── mod.rs # Tunnel trait, TunnelProviderConfig, create_tunnel(), start_managed_tunnel()
│ ├── mod.rs # Tunnel trait, TunnelProviderConfig, create_tunnel() factory
│ ├── cloudflare.rs # CloudflareTunnel (cloudflared binary)
│ ├── ngrok.rs # NgrokTunnel
│ ├── tailscale.rs # TailscaleTunnel (serve/funnel modes)
│ ├── custom.rs # CustomTunnel (arbitrary command with {host}/{port})
│ └── none.rs # NoneTunnel (local-only, no exposure)
├── observability/ # Pluggable event/metric recording (noop, log, multi)
├── observability/ # Pluggable event/metric recording
│ ├── mod.rs # create_observer() factory, ObservabilityConfig
│ ├── traits.rs # Observer trait, ObserverEvent, ObserverMetric
│ ├── noop.rs # NoopObserver (zero overhead, default)
│ ├── log.rs # LogObserver (tracing-based)
│ └── multi.rs # MultiObserver (fan-out to multiple backends)
├── orchestrator/ # Internal HTTP API for sandbox containers
│ ├── mod.rs
│ ├── api.rs # Axum endpoints (LLM proxy, events, prompts)
│ ├── auth.rs # Per-job bearer token store
│ └── job_manager.rs # Container lifecycle (create, stop, cleanup)
├── worker/ # Runs inside Docker containers
│ ├── container.rs # Container worker runtime (ContainerDelegate + shared agentic loop)
│ ├── job.rs # Background job worker (JobDelegate + shared agentic loop)
│ ├── mod.rs
│ ├── runtime.rs # Worker execution loop (tool calls, LLM)
│ ├── claude_bridge.rs # Claude Code bridge (spawns claude CLI)
│ ├── api.rs # HTTP client to orchestrator
│ └── proxy_llm.rs # LlmProvider that proxies through orchestrator
├── safety/ # Prompt injection defense
@@ -109,15 +174,30 @@ src/
│ ├── validator.rs # Input validation (length, encoding, patterns)
│ ├── policy.rs # PolicyRule system with severity/actions
│ ├── leak_detector.rs # Secret detection (API keys, tokens, etc.)
│ └── credential_detect.rs # HTTP request credential detection
│ └── credential_detect.rs # HTTP request credential detection (headers, URL params)
├── llm/ # Multi-provider LLM integration — see src/llm/CLAUDE.md
├── tools/ # Extensible tool system
│ ├── tool.rs # Tool trait, ToolOutput, ToolError
│ ├── registry.rs # ToolRegistry for discovery
│ ├── rate_limiter.rs # Shared sliding-window rate limiter
│ ├── builtin/ # Built-in tools (echo, time, json, http, web_fetch, file, shell, memory, message, job, routine, extension_tools, skill_tools, secrets_tools)
│ ├── sandbox.rs # Process-based sandbox (stub, superseded by wasm/)
│ ├── rate_limiter.rs # Shared sliding-window rate limiter for built-in and WASM tools
│ ├── builtin/ # Built-in tools
│ │ ├── echo.rs, time.rs, json.rs, http.rs
│ │ ├── web_fetch.rs # GET URL → clean Markdown (readability + html-to-md conversion)
│ │ ├── file.rs # ReadFile, WriteFile, ListDir, ApplyPatch
│ │ ├── shell.rs # Shell command execution
│ │ ├── memory.rs # Memory tools (search, write, read, tree)
│ │ ├── message.rs # MessageTool: agent proactively messages users on any channel
│ │ ├── job.rs # CreateJob, ListJobs, JobStatus, CancelJob
│ │ ├── routine.rs # routine_create/list/update/delete/history
│ │ ├── extension_tools.rs # Extension install/auth/activate/remove
│ │ ├── skill_tools.rs # skill_list/search/install/remove tools
│ │ ├── secrets_tools.rs # secret_list/secret_delete (zero-exposure: no values exposed)
│ │ ├── html_converter.rs # HTML→Markdown via readability + html-to-markdown-rs
│ │ ├── path_utils.rs # Shared path validation/canonicalization helpers
│ │ └── marketplace.rs, ecommerce.rs, taskrabbit.rs, restaurant.rs (stubs)
│ ├── builder/ # Dynamic tool building
│ │ ├── core.rs # BuildRequirement, SoftwareType, Language
│ │ ├── templates.rs # Project scaffolding
@@ -125,7 +205,6 @@ src/
│ │ └── validation.rs # WASM validation
│ ├── mcp/ # Model Context Protocol
│ │ ├── client.rs # MCP client over HTTP
│ │ ├── factory.rs # create_client_from_config() — transport dispatch factory
│ │ ├── protocol.rs # JSON-RPC types
│ │ └── session.rs # MCP session management (Mcp-Session-Id header, per-server state)
│ └── wasm/ # Full WASM sandbox (wasmtime)
@@ -142,55 +221,132 @@ src/
├── db/ # Dual-backend persistence (PostgreSQL + libSQL) — see src/db/CLAUDE.md
├── workspace/ # Persistent memory system — see src/workspace/README.md
├── workspace/ # Persistent memory system (OpenClaw-inspired)
│ ├── mod.rs # Workspace struct, memory operations
│ ├── document.rs # MemoryDocument, MemoryChunk, WorkspaceEntry
│ ├── chunker.rs # Document chunking (800 tokens, 15% overlap)
│ ├── embeddings.rs # EmbeddingProvider trait, OpenAI implementation
│ ├── search.rs # Hybrid search with RRF algorithm
│ └── repository.rs # PostgreSQL CRUD and search operations
├── context/ # Job context isolation (JobState, JobContext, ContextManager)
├── estimation/ # Cost/time/value estimation with EMA learning
├── evaluation/ # Success evaluation (rule-based, LLM-based)
├── context/ # Job context isolation
├── state.rs # JobState enum, JobContext, state machine
│ ├── memory.rs # ActionRecord, ConversationMemory
│ └── manager.rs # ContextManager for concurrent jobs
├── estimation/ # Cost/time/value estimation
│ ├── cost.rs # CostEstimator
│ ├── time.rs # TimeEstimator
│ ├── value.rs # ValueEstimator (profit margins)
│ └── learner.rs # Exponential moving average learning
├── evaluation/ # Success evaluation
│ ├── success.rs # SuccessEvaluator trait, RuleBasedEvaluator, LlmEvaluator
│ └── metrics.rs # MetricsCollector, QualityMetrics
├── sandbox/ # Docker execution sandbox
│ ├── config.rs # SandboxConfig, SandboxPolicy enum (ReadOnly/WorkspaceWrite/FullAccess)
│ ├── mod.rs # Public API, default allowlist
│ ├── config.rs # SandboxConfig, SandboxPolicy enum
│ ├── manager.rs # SandboxManager orchestration
│ ├── container.rs # ContainerRunner, Docker lifecycle
── proxy/ # Network proxy: domain allowlist, credential injection, CONNECT tunnel
── error.rs # SandboxError types
│ └── proxy/ # Network proxy for containers
│ ├── mod.rs # NetworkProxyBuilder
│ ├── http.rs # HttpProxy, CredentialResolver trait
│ ├── policy.rs # NetworkPolicyDecider trait
│ └── allowlist.rs # DomainAllowlist validation
├── secrets/ # Secrets management (AES-256-GCM, OS keychain for master key)
├── secrets/ # Secrets management
│ ├── mod.rs # SecretsStore trait, public API
│ ├── types.rs # Core types (Secret, SecretRef, SecretMetadata)
│ ├── crypto.rs # AES-256-GCM encryption
│ ├── keychain.rs # OS keychain integration (macOS Keychain, GNOME Keyring) for master key
│ └── store.rs # Encrypted secret storage
├── setup/ # 7-step onboarding wizard — see src/setup/README.md
├── setup/ # Onboarding wizard (spec: src/setup/README.md)
│ ├── mod.rs # Entry point, check_onboard_needed()
│ ├── wizard.rs # 7-step interactive wizard
│ ├── channels.rs # Channel setup helpers
│ └── prompts.rs # Terminal prompts (select, confirm, secret)
├── skills/ # SKILL.md prompt extension system — see .claude/rules/skills.md
├── skills/ # SKILL.md prompt extension system
│ ├── mod.rs # Core types (SkillTrust, LoadedSkill)
│ ├── registry.rs # SkillRegistry: discover, install, remove
│ ├── selector.rs # Deterministic scoring prefilter
│ ├── attenuation.rs # Trust-based tool ceiling
│ ├── gating.rs # Requirement checks (bins, env, config)
│ ├── parser.rs # SKILL.md frontmatter + markdown parser
│ └── catalog.rs # ClawHub registry client
└── history/ # Persistence (PostgreSQL repositories, analytics)
└── history/ # Persistence
├── store.rs # PostgreSQL repositories
└── analytics.rs # Aggregation queries (JobStats, ToolStats)
tests/
├── *.rs # Integration tests (workspace, heartbeat, WS gateway, pairing, etc.)
├── test-pages/ # HTML→Markdown conversion fixtures
├── test-pages/ # HTML→Markdown conversion fixtures (CNN, Medium, Yahoo)
└── e2e/ # Python/Playwright E2E scenarios (see tests/e2e/CLAUDE.md)
```
## Database
## Key Patterns
Dual-backend: PostgreSQL + libSQL/Turso. **All new persistence features must support both backends.** See `src/db/CLAUDE.md` and `.claude/rules/database.md`.
### Architecture
## Module Specs
When designing new features or systems, always prefer generic/extensible architectures over hardcoding specific integrations. Ask clarifying questions about the desired abstraction level before implementing.
When modifying a module with a spec, read the spec first. Code follows spec; spec is the tiebreaker.
### Error Handling
- Use `thiserror` for error types in `error.rs`
- Never use `.unwrap()` or `.expect()` in production code (tests are fine)
- Map errors with context: `.map_err(|e| SomeError::Variant { reason: e.to_string() })?`
- Before committing, grep for `.unwrap()` and `.expect(` in changed files to catch violations mechanically
**Module-owned initialization:** Module-specific initialization logic (database connection, transport creation, channel setup) must live in the owning module as a public factory function — not in `main.rs` or `app.rs`. These entry-point files orchestrate calls to module factories. Feature-flag branching (`#[cfg(feature = ...)]`) must be confined to the module that owns the abstraction.
### Async
- All I/O is async with tokio
- Use `Arc<T>` for shared state across tasks
- Use `RwLock` for concurrent read/write access
| Module | Spec |
|--------|------|
| `src/agent/` | `src/agent/CLAUDE.md` |
| `src/channels/web/` | `src/channels/web/CLAUDE.md` |
| `src/db/` | `src/db/CLAUDE.md` |
| `src/llm/` | `src/llm/CLAUDE.md` |
| `src/setup/` | `src/setup/README.md` |
| `src/tools/` | `src/tools/README.md` |
| `src/workspace/` | `src/workspace/README.md` |
| `tests/e2e/` | `tests/e2e/CLAUDE.md` |
### Traits for Extensibility
- `Database` - Add new database backends (must implement all ~78 methods)
- `Channel` - Add new input sources
- `Tool` - Add new capabilities
- `LlmProvider` - Add new LLM backends
- `SuccessEvaluator` - Custom evaluation logic
- `EmbeddingProvider` - Add embedding backends (workspace search)
- `NetworkPolicyDecider` - Custom network access policies for sandbox containers
- `Hook` - Lifecycle hook at 6 interception points (BeforeInbound, BeforeToolCall, BeforeOutbound, OnSessionStart, OnSessionEnd, TransformResponse)
- `Observer` - Observability backend (noop/log/multi; future: OpenTelemetry, Prometheus)
- `Tunnel` - Tunnel provider for public internet exposure
## Job State Machine
### Tool Implementation
```rust
#[async_trait]
impl Tool for MyTool {
fn name(&self) -> &str { "my_tool" }
fn description(&self) -> &str { "Does something useful" }
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"param": { "type": "string", "description": "A parameter" }
},
"required": ["param"]
})
}
async fn execute(&self, params: serde_json::Value, ctx: &JobContext)
-> Result<ToolOutput, ToolError>
{
let start = std::time::Instant::now();
// ... do work ...
Ok(ToolOutput::text("result", start.elapsed()))
}
fn requires_sanitization(&self) -> bool { true } // External data
}
```
### State Transitions
Job states follow a defined state machine in `context/state.rs`:
```
Pending -> InProgress -> Completed -> Submitted -> Accepted
\-> Failed
@@ -198,17 +354,315 @@ Pending -> InProgress -> Completed -> Submitted -> Accepted
\-> Failed
```
## Skills System
### Code Style
SKILL.md files extend the agent's prompt with domain-specific instructions. See `.claude/rules/skills.md` for full details.
- Use `crate::` imports, not `super::`
- No `pub use` re-exports unless exposing to downstream consumers
- Prefer strong types over strings (enums, newtypes)
- Keep functions focused, extract helpers when logic is reused
- Comments for non-obvious logic only
- **Trust model**: Trusted (user-placed in `~/.ironclaw/skills/` or workspace `skills/`, full tool access) vs Installed (registry, read-only tools)
- **Selection pipeline**: gating (check bin/env/config requirements) -> scoring (keywords/patterns/tags) -> budget (fit within `SKILLS_MAX_TOKENS`) -> attenuation (trust-based tool ceiling)
- **Skill tools**: `skill_list`, `skill_search`, `skill_install`, `skill_remove`
### Review & Fix Discipline
Hard-won lessons from code review -- follow these when fixing bugs or addressing review feedback.
**Fix the pattern, not just the instance:** When a reviewer flags a bug (e.g., TOCTOU race in INSERT + SELECT-back), search the entire codebase for all instances of that same pattern. A fix in `SecretsStore::create()` that doesn't also fix `WasmToolStore::store()` is half a fix.
**Propagate architectural fixes to satellite types:** If a core type changes its concurrency model (e.g., `LibSqlBackend` switches to connection-per-operation), every type that was handed a resource from the old model (e.g., `LibSqlSecretsStore`, `LibSqlWasmToolStore` holding a single `Connection`) must also be updated. Grep for the old type across the codebase.
**Schema translation is more than DDL:** When translating a database schema between backends (PostgreSQL to libSQL, etc.), check for:
- **Indexes** -- diff `CREATE INDEX` statements between the two schemas
- **Seed data** -- check for `INSERT INTO` in migrations (e.g., `leak_detection_patterns`)
- **Semantic differences** -- document where SQL functions behave differently (e.g., `json_patch` vs `jsonb_set`)
**Feature flag testing:** When adding feature-gated code, test compilation with each feature in isolation:
```bash
cargo check # default features
cargo check --no-default-features --features libsql # libsql only
cargo check --all-features # all features
```
Dead code behind the wrong `#[cfg]` gate will only show up when building with a single feature.
**Regression test with every fix:** Every bug fix must include a test that would have caught the bug. Add a `#[test]` or `#[tokio::test]` that reproduces the original failure. Exempt: changes limited to `src/channels/web/static/` or `.md` files. Use `[skip-regression-check]` in commit message or PR label if genuinely not feasible. The `commit-msg` hook and CI workflow enforce this automatically.
**Zero clippy warnings policy:** Fix ALL clippy warnings before committing, including pre-existing ones in files you didn't change. Never leave warnings behind — treat `cargo clippy` output as a zero-tolerance gate.
**Transaction safety:** Multi-step database operations (INSERT+INSERT, UPDATE+DELETE, read-then-write) MUST be wrapped in a transaction. Never assume sequential calls are atomic. Before committing DB code, ask: "If this crashes between step N and N+1, is the database consistent?" If not, wrap in a transaction. This applies to both postgres and libsql backends.
**UTF-8 string safety:** Never use byte-index slicing (`&s[..n]`) on user-supplied or external strings — it panics on multi-byte characters. Use `is_char_boundary()` to walk backwards from the desired length, or iterate with `char_indices()`. Grep for `[..` in changed files to catch violations.
**Case-insensitive comparisons:** When comparing user-supplied strings (file paths, media types, extension names), always normalize to lowercase first with `.to_ascii_lowercase()`. On case-insensitive filesystems (macOS, Windows), path comparisons must be case-insensitive. File extension checks (`.png`, `.jpg`) and media type checks (`image/jpeg`) are common offenders.
**Decorator/wrapper trait delegation:** When adding a new method to `LlmProvider` (or any trait with decorator wrappers), you MUST update ALL wrapper types to delegate to their inner provider. Grep for `impl LlmProvider for` to find all implementations. Add a test that exercises the method through the full provider chain (`build_provider_chain()`), not just the base impl.
**Sensitive data in logs & events:** Tool parameters and outputs MUST be redacted before logging or broadcasting via SSE/WebSocket. Use `redact_params()` before any `tracing::info!`, `JobEvent`, or SSE emission that includes tool call data. Never log raw parameters from tool calls.
**Test temporary files:** Use the `tempfile` crate for test directories/files. Never hardcode `/tmp/...` paths — they collide in parallel test runs and break on non-Unix platforms.
**Trust boundaries in multi-process architecture:** Data from worker containers is untrusted. The orchestrator MUST validate: tool domain (never execute `Container`-domain tools on the host), nesting depth (server-side tracking, not client-supplied), and parameter sensitivity (redact before logging/broadcasting).
**Mechanical verification before committing:** Run these checks on changed files before committing:
- `cargo clippy --all --benches --tests --examples --all-features` -- zero warnings
- `grep -rnE '\.unwrap\(|\.expect\(' <files>` -- no panics in production
- `grep -rn 'super::' <files>` -- use `crate::` imports
- If you fixed a pattern bug, `grep` for other instances of that pattern across `src/`
- Fix commits must include regression tests (enforced by `commit-msg` hook; bypass with `[skip-regression-check]`)
- Run `scripts/pre-commit-safety.sh` to catch UTF-8, case-sensitivity, hardcoded /tmp, and logging issues
## Configuration
See `.env.example` for all environment variables. LLM backends (`nearai`, `openai`, `anthropic`, `ollama`, `openai_compatible`, `tinfoil`, `bedrock`) documented in `src/llm/CLAUDE.md`.
Environment variables (see `.env.example`):
```bash
# Database backend (default: postgres)
DATABASE_BACKEND=postgres # or "libsql" / "turso"
DATABASE_URL=postgres://user:pass@localhost/ironclaw
LIBSQL_PATH=~/.ironclaw/ironclaw.db # libSQL local path (default)
# LIBSQL_URL=libsql://xxx.turso.io # Turso cloud (optional)
# LIBSQL_AUTH_TOKEN=xxx # Required with LIBSQL_URL
# NEAR AI (when LLM_BACKEND=nearai, the default)
# Two auth modes: session token (default) or API key
# Session token auth (default): uses browser OAuth on first run
NEARAI_SESSION_TOKEN=sess_... # hosting providers: set this
NEARAI_BASE_URL=https://private.near.ai
# API key auth: set NEARAI_API_KEY, base URL defaults to cloud-api.near.ai
# NEARAI_API_KEY=... # API key from cloud.near.ai
NEARAI_MODEL=claude-3-5-sonnet-20241022
# Agent settings
AGENT_NAME=ironclaw
MAX_PARALLEL_JOBS=5
# Embeddings (for semantic memory search)
OPENAI_API_KEY=sk-... # For OpenAI embeddings
# Or use NEAR AI embeddings:
# EMBEDDING_PROVIDER=nearai
# EMBEDDING_ENABLED=true
EMBEDDING_MODEL=text-embedding-3-small # or text-embedding-3-large
# Heartbeat (proactive periodic execution)
HEARTBEAT_ENABLED=true
HEARTBEAT_INTERVAL_SECS=1800 # 30 minutes
HEARTBEAT_NOTIFY_CHANNEL=tui
HEARTBEAT_NOTIFY_USER=default
# Web gateway
GATEWAY_ENABLED=true
GATEWAY_HOST=127.0.0.1
GATEWAY_PORT=3001
GATEWAY_AUTH_TOKEN=changeme # Required for API access
GATEWAY_USER_ID=default
# Docker sandbox
SANDBOX_ENABLED=true
SANDBOX_IMAGE=ironclaw-worker:latest
SANDBOX_MEMORY_LIMIT_MB=512
SANDBOX_TIMEOUT_SECS=1800
SANDBOX_CPU_LIMIT=1.0 # CPU cores per container
SANDBOX_NETWORK_PROXY=true # Enable network proxy for containers
SANDBOX_PROXY_PORT=8080 # Proxy listener port
SANDBOX_DEFAULT_POLICY=workspace_write # ReadOnly, WorkspaceWrite, FullAccess
# Claude Code mode (runs inside sandbox containers)
CLAUDE_CODE_ENABLED=false
CLAUDE_CODE_MODEL=claude-sonnet-4-20250514
CLAUDE_CODE_MAX_TURNS=50
CLAUDE_CODE_CONFIG_DIR=/home/worker/.claude
# Routines (scheduled/reactive execution)
ROUTINES_ENABLED=true
ROUTINES_CRON_INTERVAL=60 # Tick interval in seconds
ROUTINES_MAX_CONCURRENT=3
# Skills system
SKILLS_ENABLED=true
SKILLS_MAX_TOKENS=4000 # Max prompt budget per turn
SKILLS_CATALOG_URL=https://clawhub.dev # ClawHub registry URL
SKILLS_AUTO_DISCOVER=true # Scan skill directories on startup
# Tinfoil private inference
TINFOIL_API_KEY=... # Required when LLM_BACKEND=tinfoil
TINFOIL_MODEL=kimi-k2-5 # Default model
# AWS Bedrock (native Converse API, requires --features bedrock)
# LLM_BACKEND=bedrock
# BEDROCK_REGION=us-east-1 # AWS region
# BEDROCK_MODEL=anthropic.claude-opus-4-6-v1 # Required model ID
# BEDROCK_CROSS_REGION=us # Cross-region prefix (us/eu/apac/global)
# AWS_PROFILE=my-profile # Named profile (SSO/assume-role)
# Tunnel (public internet exposure for webhooks)
TUNNEL_URL=https://abc123.ngrok.io # Static public URL (manual tunnel)
# Or use a managed tunnel provider:
TUNNEL_PROVIDER=none # none (default), cloudflare, tailscale, ngrok, custom
TUNNEL_CF_TOKEN=... # Required for TUNNEL_PROVIDER=cloudflare
TUNNEL_NGROK_TOKEN=... # Required for TUNNEL_PROVIDER=ngrok
# TUNNEL_NGROK_DOMAIN=... # Custom domain (paid ngrok plan)
# TUNNEL_TS_FUNNEL=true # Use tailscale funnel (public) vs serve (tailnet)
TUNNEL_CUSTOM_COMMAND=... # Command with {host}/{port} for custom providers
# Observability backend
OBSERVABILITY_BACKEND=none # none/noop (default) or log
```
### LLM Providers
Backends: `nearai` (default), `openai`, `anthropic`, `ollama`, `openai_compatible`, `tinfoil`, `bedrock` (requires `--features bedrock`) — set via `LLM_BACKEND`. See [src/llm/CLAUDE.md](src/llm/CLAUDE.md) for per-provider auth and configuration details.
**AWS Bedrock** -- Uses the native Converse API via `aws-sdk-bedrockruntime`. Requires `--features bedrock` at build time (not included in default features due to heavy AWS SDK dependencies). Supports standard AWS auth methods: IAM credentials (`AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`), SSO profiles (`AWS_PROFILE`), and instance roles. Configure with `BEDROCK_REGION` (default: `us-east-1`), `BEDROCK_MODEL` (required, e.g., `anthropic.claude-opus-4-6-v1`), and `BEDROCK_CROSS_REGION` (optional: `us`, `eu`, `apac`, `global` for cross-region inference profiles). The SDK credential chain resolves auth automatically from the environment.
## Database
Dual-backend persistence (PostgreSQL + libSQL/Turso). **All new persistence features must support both backends** — see [src/db/CLAUDE.md](src/db/CLAUDE.md) for schema, SQL dialect differences, adding operations, and libSQL limitations.
Implement every new operation in both `src/db/postgres.rs` and `src/db/libsql/mod.rs`. Test in isolation:
```bash
cargo check # postgres (default)
cargo check --no-default-features --features libsql # libsql only
cargo check --all-features # both
```
Database configuration: see Configuration section above.
## Safety Layer
All external tool output passes through `SafetyLayer`:
1. **Sanitizer** - Detects injection patterns, escapes dangerous content
2. **Validator** - Checks length, encoding, forbidden patterns
3. **Policy** - Rules with severity (Critical/High/Medium/Low) and actions (Block/Warn/Review/Sanitize)
4. **Leak Detector** - Scans for 15+ secret patterns (API keys, tokens, private keys, connection strings) at two points: tool output before it reaches the LLM, and LLM responses before they reach the user. Actions per pattern: Block (reject entirely), Redact (mask the secret), or Warn (flag but allow)
Tool outputs are wrapped before reaching LLM:
```xml
<tool_output name="search" sanitized="true">
[escaped content]
</tool_output>
```
### Shell Environment Scrubbing
The shell tool (`src/tools/builtin/shell.rs`) scrubs sensitive environment variables before executing commands, preventing secrets from leaking through `env`, `printenv`, or `$VAR` expansion. The sanitizer (`src/safety/sanitizer.rs`) also detects command injection patterns (chained commands, subshells, path traversal) and blocks or escapes them based on policy rules.
## Skills System
Skills are SKILL.md files that extend the agent's prompt with domain-specific instructions. Each skill is a YAML frontmatter block (metadata, activation criteria, required tools) followed by a markdown body that gets injected into the LLM context when the skill activates.
### Trust Model
| Trust Level | Source | Tool Access |
|-------------|--------|-------------|
| **Trusted** | User-placed in `~/.ironclaw/skills/` or workspace `skills/` | All tools available to the agent |
| **Installed** | Downloaded from ClawHub registry | Read-only tools only (no shell, file write, HTTP) |
### SKILL.md Format
```yaml
---
name: my-skill
version: 0.1.0
description: Does something useful
activation:
patterns:
- "deploy to.*production"
keywords:
- "deployment"
max_context_tokens: 2000
metadata:
openclaw:
requires:
bins: [docker, kubectl]
env: [KUBECONFIG]
---
# Deployment Skill
Instructions for the agent when this skill activates...
```
### Selection Pipeline
1. **Gating** -- Check binary/env/config requirements; skip skills whose prerequisites are missing
2. **Scoring** -- Deterministic scoring against message content using keywords, tags, and regex patterns
3. **Budget** -- Select top-scoring skills that fit within `SKILLS_MAX_TOKENS` prompt budget
4. **Attenuation** -- Apply trust-based tool ceiling; installed skills lose access to dangerous tools
### Skill Tools
Four built-in tools for managing skills at runtime:
- **`skill_list`** -- List all discovered skills with trust level and status
- **`skill_search`** -- Search ClawHub registry for available skills
- **`skill_install`** -- Download and install a skill from ClawHub
- **`skill_remove`** -- Remove an installed skill
### Skill Directories
- `~/.ironclaw/skills/` -- User's global skills (trusted)
- `<workspace>/skills/` -- Per-workspace skills (trusted)
- `~/.ironclaw/installed_skills/` -- Registry-installed skills (installed trust)
### Testing Skills
- `skills/web-ui-test/` -- Manual test checklist for the web gateway UI via Claude for Chrome extension. Covers connection, chat, skills search/install/remove, and other tabs.
Skills configuration: see Configuration section above.
## Docker Sandbox
The `src/sandbox/` module provides Docker-based isolation for job execution with a network proxy that controls outbound access and injects credentials.
### Sandbox Policies
| Policy | Filesystem | Network | Use Case |
|--------|-----------|---------|----------|
| **ReadOnly** | Read-only workspace mount | Allowlisted domains only | Analysis, code review |
| **WorkspaceWrite** | Read-write workspace mount | Allowlisted domains only | Code generation, file edits |
| **FullAccess** | Full filesystem | Unrestricted | Trusted admin tasks |
### Network Proxy
Containers route all HTTP/HTTPS traffic through a host-side proxy (`src/sandbox/proxy/`):
- **Domain allowlist** -- Only allowlisted domains are reachable (default: package registries, docs sites, GitHub, common APIs)
- **Credential injection** -- The `CredentialResolver` trait injects auth headers into proxied requests so secrets never enter the container environment
- **CONNECT tunnel** -- HTTPS traffic uses CONNECT method; the proxy validates the target domain against the allowlist before establishing the tunnel
- **Policy decisions** -- The `NetworkPolicyDecider` trait allows custom logic for allow/deny/inject decisions per request
### Zero-Exposure Credential Model
Secrets (API keys, tokens) are stored encrypted on the host and injected into HTTP requests by the proxy at transit time. Container processes never have access to raw credential values, preventing exfiltration even if container code is compromised.
Sandbox configuration: see Configuration section above.
## Testing
Tests are in `mod tests {}` blocks at the bottom of each file. Run specific module tests:
```bash
cargo test safety::sanitizer::tests
cargo test tools::registry::tests
```
Key test patterns:
- Unit tests for pure functions
- Async tests with `#[tokio::test]`
- No mocks, prefer real implementations or stubs
## Current Limitations / TODOs
1. **Domain-specific tools** - `marketplace.rs`, `restaurant.rs`, `taskrabbit.rs`, `ecommerce.rs` return placeholder responses; need real API integrations
2. **Integration tests** - Need testcontainers setup for PostgreSQL
3. **MCP stdio transport** - Only HTTP transport implemented
4. **WIT bindgen integration** - Auto-extract tool description/schema from WASM modules (stubbed)
5. **Capability granting after tool build** - Built tools get empty capabilities; need UX for granting HTTP/secrets access
6. **Tool versioning workflow** - No version tracking or rollback for dynamically built tools
7. **Full channel status view** - Gateway status widget exists, but no per-channel connection dashboard
8. **Observability backends** - Only `log` and `noop` implemented; OpenTelemetry/Prometheus not yet supported
## Tool Architecture
**Keep tool-specific logic out of the main agent codebase.** The main agent provides generic infrastructure; tools are self-contained units that declare their requirements through `capabilities.json` files (API endpoints, credentials, rate limits, auth setup). Service-specific auth flows, CLI commands, and configuration do not belong in the main agent.
Tools can be built as **WASM** (sandboxed, credential-injected, single binary) or **MCP servers** (ecosystem of pre-built servers, any language, but no sandbox). Both are first-class via `ironclaw tool install`. Auth is declared in capabilities files with OAuth and manual token entry support.
See `src/tools/README.md` for full tool architecture, adding new tools (built-in Rust and WASM), auth JSON examples, and WASM vs MCP decision guide.
## Adding a New Channel
@@ -217,24 +671,48 @@ See `.env.example` for all environment variables. LLM backends (`nearai`, `opena
3. Add config in `src/config/channels.rs`
4. Wire up in `src/app.rs` channel setup section
## Workspace & Memory
Persistent memory with hybrid search (FTS + vector via RRF). Four tools: `memory_search`, `memory_write`, `memory_read`, `memory_tree`. Identity files (AGENTS.md, SOUL.md, USER.md, IDENTITY.md) injected into system prompt. Heartbeat system runs proactive periodic execution (default: 30 minutes), reading `HEARTBEAT.md` and notifying via channel if findings. See `src/workspace/README.md`.
## Debugging
```bash
RUST_LOG=ironclaw=trace cargo run # verbose
RUST_LOG=ironclaw::agent=debug cargo run # agent module only
RUST_LOG=ironclaw=debug,tower_http=debug cargo run # + HTTP request logging
# Verbose logging
RUST_LOG=ironclaw=trace cargo run
# Just the agent module
RUST_LOG=ironclaw::agent=debug cargo run
# With HTTP request logging
RUST_LOG=ironclaw=debug,tower_http=debug cargo run
```
## Current Limitations
## Module Specifications
1. Domain-specific tools (`marketplace.rs`, `restaurant.rs`, etc.) are stubs
2. Integration tests need testcontainers for PostgreSQL
3. MCP: no streaming support; stdio/HTTP/Unix transports all use request-response
4. WIT bindgen: auto-extract tool schema from WASM is stubbed
5. Built tools get empty capabilities; need UX for granting access
6. No tool versioning or rollback
7. Observability: only `log` and `noop` backends (no OpenTelemetry)
Some modules have a `README.md` that serves as the authoritative specification
for that module's behavior. When modifying code in a module that has a spec:
1. **Read the spec first** before making changes
2. **Code follows spec**: if the spec says X, the code must do X
3. **Update both sides**: if you change behavior, update the spec to match;
if you're implementing a spec change, update the code to match
4. **Spec is the tiebreaker**: when code and spec disagree, the spec is correct
(unless the spec is clearly outdated, in which case fix the spec first)
| Module | Spec File |
|--------|-----------|
| `src/setup/` | `src/setup/README.md` |
| `src/workspace/` | `src/workspace/README.md` |
| `src/tools/` | `src/tools/README.md` |
| `src/agent/` | `src/agent/CLAUDE.md` |
| `src/channels/web/` | `src/channels/web/CLAUDE.md` |
| `src/db/` | `src/db/CLAUDE.md` |
| `src/llm/` | `src/llm/CLAUDE.md` |
| `tests/e2e/` | `tests/e2e/CLAUDE.md` |
## Workspace & Memory System
OpenClaw-inspired persistent memory with a flexible filesystem-like structure. Principle: "Memory is database, not RAM" -- if you want to remember something, write it explicitly. Uses hybrid search combining FTS (keyword) + vector (semantic) via Reciprocal Rank Fusion.
Four memory tools for LLM use: `memory_search` (hybrid search -- call before answering questions about prior work), `memory_write`, `memory_read`, `memory_tree`. Identity files (AGENTS.md, SOUL.md, USER.md, IDENTITY.md) are injected into the LLM system prompt.
The heartbeat system runs proactive periodic execution (default: 30 minutes), reading `HEARTBEAT.md` and notifying via channel if findings are detected.
See `src/workspace/README.md` for full API documentation, filesystem structure, hybrid search details, chunking strategy, and heartbeat system.
-49
View File
@@ -1,34 +1,5 @@
# Contributing
## Getting Started
```bash
git clone https://github.com/nearai/ironclaw.git
cd ironclaw
./scripts/dev-setup.sh
```
This installs the Rust toolchain, WASM targets, git hooks, and runs initial checks.
## Development Workflow
```bash
cargo fmt # format
cargo clippy --all --benches --tests --examples --all-features # lint (zero warnings)
cargo test # unit tests
cargo test --features integration # + PostgreSQL tests
```
## Code Style
- Zero clippy warnings policy
- No `.unwrap()` or `.expect()` in production code (tests are fine)
- Use `thiserror` for error types, map errors with context
- Prefer `crate::` for cross-module imports
- Comments for non-obvious logic only
See `CLAUDE.md` for full style guidelines.
## Feature Parity Requirement
When your change affects a tracked capability, update `FEATURE_PARITY.md` in the same branch.
@@ -38,23 +9,3 @@ When your change affects a tracked capability, update `FEATURE_PARITY.md` in the
1. Review the relevant parity rows in `FEATURE_PARITY.md`.
2. Update status/notes if behavior changed.
3. Include the `FEATURE_PARITY.md` diff in your commit when applicable.
## Review Tracks
All PRs follow a risk-based review process:
| Track | Scope | Requirements |
|-------|-------|-------------|
| **A** | Docs, tests, chore, dependency bumps | 1 approval + CI green |
| **B** | Features, refactors, new tools/channels | 1 approval + CI green + test evidence |
| **C** | Security (`src/safety/`, `src/secrets/`), runtime (`src/agent/`, `src/worker/`), database schema, CI workflows | 2 approvals + rollback plan documented |
Select the appropriate track in the PR template based on what your changes touch.
## Database Changes
IronClaw uses dual-backend persistence (PostgreSQL + libSQL). All new persistence features must support both backends. See `src/db/CLAUDE.md`.
## Adding Dependencies
Run `cargo deny check` before adding new dependencies to verify license compatibility and check for known advisories.
+4 -4
View File
@@ -63,12 +63,12 @@ These files account for the vast majority of the coverage gap:
| `src/main.rs` | 740 | 522 | 29.4% | 485 |
| `src/channels/web/handlers/jobs.rs` | 513 | 456 | 11.1% | 430 |
| `src/tools/builder/core.rs` | 524 | 456 | 13.0% | 429 |
| `src/worker/job.rs` | 1,078 | 467 | 56.7% | 413 |
| `src/agent/worker.rs` | 1,078 | 467 | 56.7% | 413 |
| `src/channels/web/handlers/chat.rs` | 564 | 417 | 26.1% | 388 |
| `src/tools/wasm/wrapper.rs` | 1,005 | 436 | 56.6% | 385 |
| `src/channels/signal.rs` | 1,814 | 472 | 74.0% | 381 |
| `src/tools/mcp/auth.rs` | 472 | 378 | 19.9% | 354 |
| `src/worker/container.rs` | 350 | 330 | 5.7% | 312 |
| `src/worker/runtime.rs` | 350 | 330 | 5.7% | 312 |
| `src/tools/builtin/job.rs` | 1,014 | 359 | 64.6% | 308 |
| `src/cli/mcp.rs` | 322 | 319 | 0.9% | 302 |
| `src/cli/oauth_defaults.rs` | 730 | 335 | 54.1% | 298 |
@@ -346,7 +346,7 @@ Test slash commands through the agent loop.
### Trace: Worker Multi-Turn Execution
**Covers:** `worker/job.rs` (+413 lines), `agent/agent_loop.rs` (+207 lines)
**Covers:** `agent/worker.rs` (+413 lines), `agent/agent_loop.rs` (+207 lines)
Test multi-turn tool calling, error recovery, and completion flows.
@@ -769,7 +769,7 @@ HTTP proxy for container network access.
- `test_proxy_connect_tunnel` -- HTTPS CONNECT method handling
- `test_proxy_logging` -- request/response logging
### `src/worker/container.rs` -- 5.7% -> 95% (+312 lines)
### `src/worker/runtime.rs` -- 5.7% -> 95% (+312 lines)
Worker execution loop (runs inside containers).
Generated
+1 -1
View File
@@ -3350,7 +3350,7 @@ dependencies = [
[[package]]
name = "ironclaw"
version = "0.18.0"
version = "0.16.1"
dependencies = [
"aes-gcm",
"aho-corasick",
+2 -7
View File
@@ -14,12 +14,11 @@ exclude = [
"tools-src/google-slides",
"tools-src/slack",
"tools-src/telegram",
"fuzz",
]
[package]
name = "ironclaw"
version = "0.18.0"
version = "0.16.1"
edition = "2024"
rust-version = "1.92"
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
@@ -215,14 +214,10 @@ bedrock = ["dep:aws-config", "dep:aws-sdk-bedrockruntime", "dep:aws-smithy-types
name = "html_to_markdown"
required-features = ["html-to-markdown"]
[profile.release]
strip = true # Remove debug symbols from release binaries
# The profile that 'cargo dist' will build with
[profile.dist]
inherits = "release"
lto = "fat" # Full cross-crate LTO (slow build, better codegen)
codegen-units = 1 # Single codegen unit for maximum optimization
lto = "thin"
# Config for 'dist'
[workspace.metadata.dist]
+47 -54
View File
@@ -10,8 +10,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
- 🚫 Out of scope (intentionally skipped)
- N/A (not applicable to Rust implementation)
**Last reviewed against OpenClaw PRs:** 2026-03-10 (merged 2026-02-24 through 2026-03-10)
---
## 1. Architecture
@@ -41,19 +39,19 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Network modes (loopback/LAN/remote) | ✅ | 🚧 | HTTP only |
| OpenAI-compatible HTTP API | ✅ | ✅ | /v1/chat/completions, per-request `model` override |
| Canvas hosting | ✅ | ❌ | Agent-driven UI |
| Gateway lock (PID-based) | ✅ | | |
| Gateway lock (PID-based) | ✅ | | `fs4` flock-based, acquired in `main.rs` before agent startup |
| launchd/systemd integration | ✅ | ❌ | |
| Bonjour/mDNS discovery | ✅ | ❌ | |
| Tailscale integration | ✅ | ❌ | |
| Health check endpoints | ✅ | ✅ | /api/health + /api/gateway/status + /healthz + /readyz, with channel-backed readiness probes |
| `doctor` diagnostics | ✅ | 🚧 | 16 checks: settings, LLM, DB, embeddings, routines, gateway, MCP, skills, secrets, service, Docker daemon, tunnel binaries |
| Health check endpoints | ✅ | ✅ | /api/health + /api/gateway/status |
| `doctor` diagnostics | ✅ | | |
| Agent event broadcast | ✅ | 🚧 | SSE broadcast manager exists (SseManager) but tool/job-state events not fully wired |
| Channel health monitor | ✅ | ❌ | Auto-restart with configurable interval |
| Presence system | ✅ | ❌ | Beacons on connect, system presence for agents |
| Trusted-proxy auth mode | ✅ | ❌ | Header-based auth for reverse proxies |
| APNs push pipeline | ✅ | ❌ | Wake disconnected iOS nodes via push |
| Oversized payload guard | ✅ | 🚧 | HTTP webhook has 64KB body limit + Content-Length check; no chat.history cap |
| Pre-prompt context diagnostics | ✅ | 🚧 | Token breakdown logged before LLM call (conversational dispatcher path); other LLM entry points not yet covered |
| Pre-prompt context diagnostics | ✅ | | Context size logging before prompt |
### Owner: _Unassigned_
@@ -68,17 +66,17 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| REPL (simple) | ✅ | ✅ | - | For testing |
| WASM channels | ❌ | ✅ | - | IronClaw innovation |
| WhatsApp | ✅ | ❌ | P1 | Baileys (Web), same-phone mode with echo detection |
| Telegram | ✅ | ✅ | - | WASM channel(MTProto), DM pairing, caption, /start, bot_username, DM topics |
| Telegram | ✅ | ✅ | - | WASM channel(MTProto), DM pairing, caption, /start, bot_username |
| Discord | ✅ | ❌ | P2 | discord.js, thread parent binding inheritance |
| Signal | ✅ | ✅ | P2 | signal-cli daemonPC, SSE listener HTTP/JSON-R, user/group allowlists, DM pairing |
| Slack | ✅ | ✅ | - | WASM tool |
| iMessage | ✅ | ❌ | P3 | BlueBubbles or Linq recommended |
| Linq | ✅ | ❌ | P3 | Real iMessage via API, no Mac required |
| Feishu/Lark | ✅ | ❌ | P3 | Bitable create app/field tools, Docx table/image/file actions, rich-text media extraction |
| Feishu/Lark | ✅ | ❌ | P3 | Bitable create app/field tools |
| LINE | ✅ | ❌ | P3 | |
| WebChat | ✅ | ✅ | - | Web gateway chat |
| Matrix | ✅ | ❌ | P3 | E2EE support |
| Mattermost | ✅ | ❌ | P3 | Emoji reactions, interactive buttons, model picker |
| Mattermost | ✅ | ❌ | P3 | Emoji reactions |
| Google Chat | ✅ | ❌ | P3 | |
| MS Teams | ✅ | ❌ | P3 | |
| Twitch | ✅ | ❌ | P3 | |
@@ -94,8 +92,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| User message reactions | ✅ | ❌ | Surface inbound reactions |
| sendPoll | ✅ | ❌ | Poll creation via agent |
| Cron/heartbeat topic targeting | ✅ | ❌ | Messages land in correct topic |
| DM topics support | ✅ | ❌ | Agent/topic bindings in DMs and agent-scoped SessionKeys |
| Persistent ACP topic binding | ✅ | ❌ | ACP harness sessions can pin to Telegram forum or DM topics |
### Discord-Specific Features (since Feb 2025)
@@ -111,36 +107,21 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|---------|----------|----------|-------|
| Streaming draft replies | ✅ | ❌ | Partial replies via draft message updates |
| Configurable stream modes | ✅ | ❌ | Per-channel stream behavior |
| Thread ownership | ✅ | ❌ | Thread-level ownership tracking plus reply participation memory |
| Download-file action | ✅ | ❌ | On-demand attachment downloads via message actions |
### Mattermost-Specific Features (since Mar 2026)
| Feature | OpenClaw | IronClaw | Notes |
|---------|----------|----------|-------|
| Interactive buttons | ✅ | ❌ | Clickable message buttons with signed callback flow |
| Interactive model picker | ✅ | ❌ | In-channel provider/model chooser |
### Feishu/Lark-Specific Features (since Mar 2026)
| Feature | OpenClaw | IronClaw | Notes |
|---------|----------|----------|-------|
| Doc/table actions | ✅ | ❌ | `feishu_doc` supports tables, positional insert, color_text, image upload, and file upload |
| Rich-text embedded media extraction | ✅ | ❌ | Pull video/media attachments from post messages |
| Thread ownership | ✅ | ❌ | Thread-level ownership tracking |
### Channel Features
| Feature | OpenClaw | IronClaw | Notes |
|---------|----------|----------|-------|
| DM pairing codes | ✅ | ✅ | `ironclaw pairing list/approve`, host APIs |
| Allowlist/blocklist | ✅ | 🚧 | `allow_from` + pairing store + hardened command/group allowlists |
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
| Self-message bypass | ✅ | ❌ | Own messages skip pairing |
| Mention-based activation | ✅ | ✅ | bot_username + respond_to_all_group_messages |
| Per-group tool policies | ✅ | ❌ | Allow/deny specific tools |
| Thread isolation | ✅ | ✅ | Separate sessions per thread/topic |
| Per-channel media limits | ✅ | 🚧 | Caption support plus `mediaMaxMb` enforcement for WhatsApp, Telegram, and Discord |
| Typing indicators | ✅ | 🚧 | TUI + channel typing, with configurable silence timeout; richer parity pending |
| Per-channel ackReaction config | ✅ | ❌ | Customizable acknowledgement reactions/scopes |
| Thread isolation | ✅ | ✅ | Separate sessions per thread |
| Per-channel media limits | ✅ | | Attachment type in WIT; max 10 per msg, 20MB total, MIME allowlist |
| Typing indicators | ✅ | 🚧 | TUI + Telegram typing/actionable status prompts; richer parity pending |
| Per-channel ackReaction config | ✅ | ❌ | Customizable acknowledgement reactions |
| Group session priming | ✅ | ❌ | Member roster injected for context |
| Sender_id in trusted metadata | ✅ | ❌ | Exposed in system metadata |
@@ -157,8 +138,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| `gateway start/stop` | ✅ | ❌ | P2 | |
| `onboard` (wizard) | ✅ | ✅ | - | Interactive setup |
| `tui` | ✅ | ✅ | - | Ratatui TUI |
| `config` | ✅ | ✅ | - | Read/write config plus validate/path helpers |
| `backup` | ✅ | ❌ | P3 | Create/verify local backup archives |
| `config` | ✅ | ✅ | - | Read/write config |
| `channels` | ✅ | ❌ | P2 | Channel management |
| `models` | ✅ | 🚧 | - | Model selector in TUI |
| `status` | ✅ | ✅ | - | System status (enriched session details) |
@@ -175,7 +155,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| `message send` | ✅ | ❌ | P2 | Send to channels |
| `browser` | ✅ | ❌ | P3 | Browser automation |
| `sandbox` | ✅ | ✅ | - | WASM sandbox |
| `doctor` | ✅ | 🚧 | P2 | 16 subsystem checks |
| `doctor` | ✅ | | P2 | Diagnostics |
| `logs` | ✅ | ❌ | P3 | Query logs |
| `update` | ✅ | ❌ | P3 | Self-update |
| `completion` | ✅ | ✅ | - | Shell completion |
@@ -197,15 +177,14 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Global sessions | ✅ | ❌ | Optional shared context |
| Session pruning | ✅ | ❌ | Auto cleanup old sessions |
| Context compaction | ✅ | ✅ | Auto summarization |
| Compaction model override | ✅ | ❌ | Use a dedicated provider/model for summarization only |
| Post-compaction read audit | ✅ | ❌ | Layer 3: workspace rules appended to summaries |
| Post-compaction context injection | ✅ | ❌ | Workspace context as system event |
| Custom system prompts | ✅ | ✅ | Template variables, safety guardrails |
| Skills (modular capabilities) | ✅ | ✅ | Prompt-based skills with trust gating, attenuation, activation criteria, catalog, selector |
| Skill routing blocks | ✅ | 🚧 | ActivationCriteria (keywords, patterns, tags) but no "Use when / Don't use when" blocks |
| Skill path compaction | ✅ | ❌ | ~ prefix to reduce prompt tokens |
| Thinking modes (off/minimal/low/medium/high/xhigh/adaptive) | ✅ | ❌ | Configurable reasoning depth |
| Per-model thinkingDefault override | ✅ | ❌ | Override thinking level per model; Anthropic Claude 4.6 defaults to adaptive |
| Thinking modes (low/med/high) | ✅ | ❌ | Configurable reasoning depth |
| Per-model thinkingDefault override | ✅ | ❌ | Override thinking level per model |
| Block-level streaming | ✅ | ❌ | |
| Tool-level streaming | ✅ | ❌ | |
| Z.AI tool_stream | ✅ | ❌ | Real-time tool call streaming |
@@ -234,11 +213,15 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Provider | OpenClaw | IronClaw | Priority | Notes |
|----------|----------|----------|----------|-------|
| NEAR AI | ✅ | ✅ | - | Primary provider |
| Anthropic (Claude) | ✅ | 🚧 | - | Via NEAR AI proxy; Opus 4.5, Sonnet 4, Sonnet 4.6, adaptive thinking default |
| OpenAI | ✅ | 🚧 | - | Via NEAR AI proxy; GPT-5.4 + Codex OAuth |
| AWS Bedrock | ✅ | ❌ | P3 | |
| Google Gemini | ✅ | | P3 | |
| NVIDIA API | ✅ | | P3 | New provider |
| Anthropic (Claude) | ✅ | 🚧 | - | Via NEAR AI proxy; Opus 4.5, Sonnet 4, Sonnet 4.6 |
| OpenAI | ✅ | 🚧 | - | Via NEAR AI proxy |
| AWS Bedrock | ✅ | ✅ | - | Native Converse API via aws-sdk-bedrockruntime (requires `--features bedrock`) |
| Google Gemini | ✅ | | P3 | Via `gemini` adapter |
| io.net | ✅ | | P3 | Via `ionet` adapter |
| Mistral | ✅ | ✅ | P3 | Via `mistral` adapter |
| Yandex AI Studio | ✅ | ✅ | P3 | Via `yandex` adapter |
| Cloudflare Workers AI | ✅ | ✅ | P3 | Via `cloudflare` adapter |
| NVIDIA API | ✅ | ✅ | P3 | Via `nvidia` adapter and `providers.json` |
| OpenRouter | ✅ | ✅ | - | Via OpenAI-compatible provider (RigAdapter) |
| Tinfoil | ❌ | ✅ | - | Private inference provider (IronClaw-only) |
| OpenAI-compatible | ❌ | ✅ | - | Generic OpenAI-compatible endpoint (RigAdapter) |
@@ -259,7 +242,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Per-session model override | ✅ | ✅ | Model selector in TUI |
| Model selection UI | ✅ | ✅ | TUI keyboard shortcut |
| Per-model thinkingDefault | ✅ | ❌ | Override thinking level per model in config |
| 1M context support | ✅ | ❌ | Anthropic extended context beta + OpenAI Codex GPT-5.4 1M context |
| 1M context beta header | ✅ | ❌ | Anthropic extended context support |
### Owner: _Unassigned_
@@ -269,20 +252,32 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Feature | OpenClaw | IronClaw | Priority | Notes |
|---------|----------|----------|----------|-------|
| WIT inbound-attachment type | N/A | ✅ | P1 | `inbound-attachment` record in channel-host (id, mime_type, filename, size_bytes, source_url, storage_key, extracted_text) |
| WIT outbound attachment type | N/A | ✅ | P1 | `attachment` record in channel (filename, mime_type, data) on `agent-response` |
| WIT on-broadcast export | N/A | ✅ | P1 | Proactive message sending without prior incoming message |
| IncomingMessage attachments | N/A | ✅ | P1 | `IncomingAttachment` struct on `IncomingMessage`, populated from WASM channels |
| OutgoingResponse attachments | N/A | ✅ | P1 | File paths on `OutgoingResponse`, read from disk and sent as WIT attachments |
| Attachment security (size/MIME) | N/A | ✅ | P1 | Inbound: max 10, 20MB total, MIME allowlist. Outbound: 50MB total |
| Telegram media parsing | ✅ | ✅ | P1 | Photo, document, audio, video, voice, sticker parsed and emitted as attachments |
| Telegram media sending | ✅ | ✅ | P1 | sendPhoto/sendDocument multipart upload, auto photo→document fallback >10MB |
| Slack file parsing | ✅ | ✅ | P1 | `files` array from Events API parsed into attachments |
| WhatsApp media parsing | ✅ | ✅ | P1 | Image, audio, video, document parsed with caption as extracted_text |
| Discord attachment parsing | ✅ | ❌ | P2 | Discord interaction payloads don't include file attachments (needs message events) |
| HTTP tool save_to | N/A | ✅ | P1 | Download binary files to /tmp/ for attachment sending (50MB limit, path traversal protection) |
| Credential env var fallback | N/A | ✅ | P2 | Channels can use env vars (e.g., TELEGRAM_BOT_TOKEN) when secrets store not configured |
| Image processing (Sharp) | ✅ | ❌ | P2 | Resize, format convert |
| Configurable image resize dims | ✅ | ❌ | P2 | Per-agent dimension config |
| Multiple images per tool call | ✅ | ❌ | P2 | Single tool invocation, multiple images |
| Audio transcription | ✅ | ❌ | P2 | |
| Video support | ✅ | ❌ | P3 | |
| PDF analysis tool | ✅ | ❌ | P2 | Native Anthropic/Gemini path with text/image extraction fallback |
| PDF parsing | ✅ | | P2 | `pdfjs-dist` fallback path |
| MIME detection | ✅ | ❌ | P2 | |
| PDF parsing | ✅ | ❌ | P2 | pdfjs-dist |
| MIME detection | ✅ | | P2 | MIME allowlist in host validates attachment types |
| Media caching | ✅ | ❌ | P3 | |
| Vision model integration | ✅ | ❌ | P2 | Image understanding |
| TTS (Edge TTS) | ✅ | ❌ | P3 | Text-to-speech |
| TTS (OpenAI) | ✅ | ❌ | P3 | |
| Incremental TTS playback | ✅ | ❌ | P3 | iOS progressive playback |
| Sticker-to-image | ✅ | | P3 | Telegram stickers |
| Sticker-to-image | ✅ | | P3 | Telegram stickers emitted as image/webp attachments |
### Owner: _Unassigned_
@@ -298,8 +293,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Workspace-relative install | ✅ | ✅ | ~/.ironclaw/tools/ |
| Channel plugins | ✅ | ✅ | WASM channels |
| Auth plugins | ✅ | ❌ | |
| Memory plugins | ✅ | ❌ | Custom backends + selectable memory slot |
| Context-engine plugins | ✅ | ❌ | Custom context management + subagent/context hooks |
| Memory plugins | ✅ | ❌ | Custom backends |
| Tool plugins | ✅ | ✅ | WASM tools |
| Hook plugins | ✅ | ✅ | Declarative hooks from extension capabilities |
| Provider plugins | ✅ | ❌ | |
@@ -321,7 +315,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| JSON5 support | ✅ | ❌ | Comments, trailing commas |
| YAML alternative | ✅ | ❌ | |
| Environment variable interpolation | ✅ | ✅ | `${VAR}` |
| Config validation/schema | ✅ | ✅ | Type-safe Config struct + `openclaw config validate` |
| Config validation/schema | ✅ | ✅ | Type-safe Config struct |
| Hot-reload | ✅ | ❌ | |
| Legacy migration | ✅ | | |
| State directory | ✅ `~/.openclaw-state/` | ✅ `~/.ironclaw/` | |
@@ -428,7 +422,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Feature | OpenClaw | IronClaw | Priority | Notes |
|---------|----------|----------|----------|-------|
| Cron jobs | ✅ | ✅ | - | Routines with cron trigger |
| Per-job model fallback override | ✅ | ❌ | P2 | `payload.fallbacks` overrides agent-level fallbacks |
| Cron stagger controls | ✅ | ❌ | P3 | Default stagger for scheduled jobs |
| Cron finished-run webhook | ✅ | ❌ | P3 | Webhook on job completion |
| Timezone support | ✅ | ✅ | - | Via cron expressions |
@@ -482,10 +475,10 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Elevated mode | ✅ | ❌ | |
| Safe bins allowlist | ✅ | ❌ | Hardened path trust |
| LD*/DYLD* validation | ✅ | ❌ | |
| Path traversal prevention | ✅ | ✅ | Including config includes (OC-06) + workspace-only tool mounts |
| Path traversal prevention | ✅ | ✅ | Including config includes (OC-06) |
| Credential theft via env injection | ✅ | 🚧 | Shell env scrubbing + command injection detection; no full OC-09 defense |
| Session file permissions (0o600) | ✅ | ✅ | Session token file set to 0o600 in llm/session.rs |
| Skill download path restriction | ✅ | ❌ | Validated download roots prevent arbitrary write targets |
| Skill download path restriction | ✅ | ❌ | Prevent arbitrary write targets |
| Webhook signature verification | ✅ | ✅ | |
| Media URL validation | ✅ | ❌ | |
| Prompt injection defense | ✅ | ✅ | Pattern detection, sanitization |
-40
View File
@@ -1,40 +0,0 @@
[package]
name = "ironclaw-fuzz"
version = "0.0.0"
publish = false
edition = "2021"
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
serde_json = "1"
[dependencies.ironclaw]
path = ".."
[[bin]]
name = "fuzz_safety_sanitizer"
path = "fuzz_targets/fuzz_safety_sanitizer.rs"
doc = false
[[bin]]
name = "fuzz_safety_validator"
path = "fuzz_targets/fuzz_safety_validator.rs"
doc = false
[[bin]]
name = "fuzz_leak_detector"
path = "fuzz_targets/fuzz_leak_detector.rs"
doc = false
[[bin]]
name = "fuzz_tool_params"
path = "fuzz_targets/fuzz_tool_params.rs"
doc = false
[[bin]]
name = "fuzz_config_env"
path = "fuzz_targets/fuzz_config_env.rs"
doc = false
-43
View File
@@ -1,43 +0,0 @@
# IronClaw Fuzz Targets
Fuzz testing for security-critical input parsing paths using [cargo-fuzz](https://github.com/rust-fuzz/cargo-fuzz) (libFuzzer).
## Targets
| Target | What it exercises |
|--------|-------------------|
| `fuzz_safety_sanitizer` | Prompt injection pattern detection (Aho-Corasick + regex) |
| `fuzz_safety_validator` | Input validation (length, encoding, forbidden patterns) |
| `fuzz_leak_detector` | Secret leak detection (API keys, tokens, credentials) |
| `fuzz_tool_params` | Tool parameter and schema JSON validation |
| `fuzz_config_env` | SafetyLayer end-to-end (sanitize, validate, policy check) |
## Setup
```bash
cargo install cargo-fuzz
rustup install nightly
```
## Running
```bash
# Run a specific target (runs until stopped or crash found)
cargo +nightly fuzz run fuzz_safety_sanitizer
# Run with a time limit (5 minutes)
cargo +nightly fuzz run fuzz_leak_detector -- -max_total_time=300
# Run all targets for 60 seconds each
for target in fuzz_safety_sanitizer fuzz_safety_validator fuzz_leak_detector fuzz_tool_params fuzz_config_env; do
echo "==> $target"
cargo +nightly fuzz run "$target" -- -max_total_time=60
done
```
## Adding New Targets
1. Create `fuzz/fuzz_targets/fuzz_<name>.rs` following the existing pattern
2. Add a `[[bin]]` entry in `fuzz/Cargo.toml`
3. Create `fuzz/corpus/fuzz_<name>/` for seed inputs
4. Exercise real IronClaw code paths, not just generic serde
-55
View File
@@ -1,55 +0,0 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
use ironclaw::safety::{LeakDetector, Sanitizer, Validator};
fuzz_target!(|data: &[u8]| {
if let Ok(input) = std::str::from_utf8(data) {
// Exercise Sanitizer: detect and neutralize prompt injection attempts.
let sanitizer = Sanitizer::new();
let sanitized = sanitizer.sanitize(input);
// The sanitized content must never be empty when input is non-empty,
// because sanitization wraps/escapes rather than deleting.
if !input.is_empty() {
assert!(
!sanitized.content.is_empty(),
"sanitize() produced empty content for non-empty input"
);
}
// If no modification occurred, content must equal input.
if !sanitized.was_modified {
assert_eq!(sanitized.content, input);
}
// Exercise Validator: input validation (length, encoding, patterns).
let validator = Validator::new();
let result = validator.validate(input);
// ValidationResult must always be well-formed: if valid, no errors.
if result.is_valid {
assert!(
result.errors.is_empty(),
"valid result should have no errors"
);
}
// Exercise LeakDetector: secret detection (API keys, tokens, etc.).
let detector = LeakDetector::new();
let scan = detector.scan(input);
// scan_and_clean must not panic and must return valid UTF-8.
let cleaned = detector.scan_and_clean(input);
if let Ok(ref clean_str) = cleaned {
// Cleaned output must never be longer than original + redaction markers.
// At minimum it should be valid UTF-8 (guaranteed by String type).
let _ = clean_str.len();
}
// If scan found no matches, scan_and_clean should return the input unchanged.
if scan.matches.is_empty() {
if let Ok(ref clean_str) = cleaned {
assert_eq!(
clean_str, input,
"scan_and_clean changed content despite no matches"
);
}
}
}
});
-23
View File
@@ -1,23 +0,0 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
use ironclaw::safety::LeakDetector;
fuzz_target!(|data: &[u8]| {
if let Ok(s) = std::str::from_utf8(data) {
let detector = LeakDetector::new();
// Exercise scan path
let result = detector.scan(s);
// Invariant: if should_block, there must be matches
if result.should_block {
assert!(!result.matches.is_empty());
}
// Invariant: match locations must be valid
for m in &result.matches {
assert!(m.location.end <= s.len());
}
// Exercise scan_and_clean path
let _ = detector.scan_and_clean(s);
}
});
@@ -1,23 +0,0 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
use ironclaw::safety::Sanitizer;
fuzz_target!(|data: &[u8]| {
if let Ok(s) = std::str::from_utf8(data) {
let sanitizer = Sanitizer::new();
// Exercise the main sanitization path
let result = sanitizer.sanitize(s);
// Verify invariant: warnings should have valid ranges
for w in &result.warnings {
assert!(w.location.end <= s.len());
}
// Verify invariant: critical severity triggers modification
let has_critical = result.warnings.iter().any(|w| {
w.severity == ironclaw::safety::Severity::Critical
});
if has_critical {
assert!(result.was_modified);
}
}
});
@@ -1,21 +0,0 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
use ironclaw::safety::Validator;
fuzz_target!(|data: &[u8]| {
if let Ok(s) = std::str::from_utf8(data) {
let validator = Validator::new();
// Exercise input validation
let result = validator.validate(s);
// Invariant: empty input is always invalid
if s.is_empty() {
assert!(!result.is_valid);
}
// Exercise tool parameter validation with arbitrary JSON
if let Ok(value) = serde_json::from_str::<serde_json::Value>(s) {
let _ = validator.validate_tool_params(&value);
}
}
});
-22
View File
@@ -1,22 +0,0 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
use ironclaw::safety::Validator;
use ironclaw::tools::validate_tool_schema;
fuzz_target!(|data: &[u8]| {
if let Ok(s) = std::str::from_utf8(data) {
// Try parsing as JSON and validating as tool parameters
if let Ok(value) = serde_json::from_str::<serde_json::Value>(s) {
// Exercise Validator::validate_tool_params with arbitrary JSON
let validator = Validator::new();
let result = validator.validate_tool_params(&value);
// Invariant: result should always be well-formed
if !result.is_valid {
assert!(!result.errors.is_empty());
}
// Exercise validate_tool_schema with arbitrary JSON as a schema
let _ = validate_tool_schema(&value, "fuzz");
}
}
});
-7
View File
@@ -1,7 +0,0 @@
-- Add token budget tracking columns to agent_jobs.
--
-- Tracks max_tokens (configured limit per job) and total_tokens_used (running total)
-- to enforce job-level token budgets and prevent budget bypass via user-supplied metadata.
ALTER TABLE agent_jobs ADD COLUMN max_tokens BIGINT NOT NULL DEFAULT 0;
ALTER TABLE agent_jobs ADD COLUMN total_tokens_used BIGINT NOT NULL DEFAULT 0;
+1 -3
View File
@@ -9,9 +9,8 @@
"api_key_required": true,
"base_url_env": "OPENAI_BASE_URL",
"model_env": "OPENAI_MODEL",
"default_model": "gpt-5-mini",
"default_model": "gpt-4o",
"description": "OpenAI GPT models (direct API)",
"unsupported_params": ["temperature"],
"setup": {
"kind": "api_key",
"secret_name": "llm_openai_api_key",
@@ -87,7 +86,6 @@
"model_env": "TINFOIL_MODEL",
"default_model": "kimi-k2-5",
"description": "Tinfoil private inference (hardware-attested TEE)",
"unsupported_params": ["temperature"],
"setup": {
"kind": "api_key",
"secret_name": "llm_tinfoil_api_key",
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/discord-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "030707431717bca3411a48f311c6ab5f92a45c747de26cafe4f6e3e23a8b3b2d"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "6ed36077b67ac70a041f06f760f93ba79b33269885413c3c3f2c8c87ee60807e"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "98c86895a9c4b0a1e19fe8a47f1ccbfe7e972e112b05e584bc897130dc32283a"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/whatsapp-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "bd35cad18d87292ea8d2f52db9b514ed9f814a414de910f59073d475c26c4c14"
}
},
"auth_summary": {
+1 -1
View File
@@ -20,7 +20,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/github-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "6fcd32719a4ff15641a4b50fff8984686550f0c491dce60518f4126857d0c544"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/gmail-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "023da7000b17568bf0e64b2e5013c8a042b2f323c85f1632339231c73d500e39"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-calendar-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "fc42277b65881d6e9bcc5403dc54c7f5b3ddeaaaf04617fce2c5da05d76325f0"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-docs-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "385c04abd1e6b8011ccc330e1f4bd7ce58577e488959b51594aa04eb26cbe7cc"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-drive-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "1b107d575a5d52cc8c76d9a681802190f4373fb485f7f54f445533f097fa37c0"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-sheets-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "c4f6b1e8c5126ac2c8a4b98e4283a3afa32223d2488fc3c3a609758c0c9beb90"
}
},
"auth_summary": {
+1 -1
View File
@@ -18,7 +18,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-slides-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "7110b8565340c888e51f99e9c013bf4de8f8a7f7b33bace00eb8fc47831ff20b"
}
},
"auth_summary": {
+1 -1
View File
@@ -18,7 +18,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-tool-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "6ed36077b67ac70a041f06f760f93ba79b33269885413c3c3f2c8c87ee60807e"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-mtproto-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "98c86895a9c4b0a1e19fe8a47f1ccbfe7e972e112b05e584bc897130dc32283a"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/web-search-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "66cb2b9b00652385e9f30f17c74902b9222c17c53e9d3bd1ef42f5cab705bcf6"
}
},
"auth_summary": {
-50
View File
@@ -209,56 +209,6 @@ else
fi
echo
# --------------------------------------------------------------------------
# Check 6: LLM module isolation — no imports from other crate modules
# --------------------------------------------------------------------------
# src/llm/ should only import from:
# - crate::llm (self-references)
# - external crates (no crate:: prefix)
# It must NOT import from crate::agent, crate::tools, crate::channels,
# crate::safety, crate::config, crate::bootstrap, crate::cli, crate::db,
# crate::workspace, crate::worker, crate::orchestrator, crate::skills,
# crate::hooks, crate::setup, crate::context, etc.
#
# Test-only imports (crate::testing) are excluded since they don't affect
# the runtime dependency graph and won't exist in the extracted crate.
# --------------------------------------------------------------------------
echo "--- Check 6: LLM module isolation ---"
# Match any `crate::` reference (use-imports AND inline paths) that isn't
# crate::llm or crate::testing. Filter out comments.
# We strip inline comments (everything after //) with sed before checking,
# so a line like `real_code(crate::foo); // crate::llm` is still caught.
results=$(grep -rn 'crate::' src/llm/ \
--include='*.rs' \
| grep -v '^\s*//' \
| sed 's|//.*||' \
| grep 'crate::' \
| grep -v 'crate::llm' \
| grep -v 'crate::testing' \
|| true)
if [ -n "$results" ]; then
count=$(echo "$results" | wc -l | tr -d ' ')
echo "WARNING: src/llm/ has $count reference(s) to modules outside crate::llm:"
echo "$results"
echo
echo "(These are pre-existing; fix them before extracting the crate.)"
echo "(New 'use crate::' imports are hard violations — see below.)"
echo
# Hard-fail only on new `use crate::` imports (easy to avoid in new code).
use_imports=$(echo "$results" | grep '^[^:]*:.*use crate::' || true)
if [ -n "$use_imports" ]; then
echo "HARD VIOLATION: new 'use crate::' imports in src/llm/:"
echo "$use_imports"
violations=$((violations + 1))
fi
else
echo "OK"
fi
echo
# --------------------------------------------------------------------------
# Summary
# --------------------------------------------------------------------------
@@ -1,80 +0,0 @@
---
name: ironclaw-workflow-orchestrator
description: "Install and operate a full GitHub issue-to-merge workflow in IronClaw using event-driven and cron routines. Use when setting up or tuning autonomous project orchestration: issue intake, planning, maintainer feedback handling, branch/PR execution, CI/comment follow-up, batched staging review every 8 hours, and memory updates from merge outcomes."
---
# IronClaw Workflow Orchestrator
## Overview
Use this skill to install and maintain a complete project workflow as routines, not core code changes. It maps GitHub webhook events plus scheduled checks into plan/update/implement/review/merge loops with explicit staging-batch analysis.
## Workflow
1. Gather workflow parameters.
2. Verify runtime prerequisites.
3. Install or update routine set from templates.
4. Run a dry test with `event_emit`.
5. Monitor outcomes and tune prompts/filters.
## Parameters
Collect these values before creating routines:
- `repository`: `owner/repo` (required)
- `maintainers`: GitHub handles allowed to trigger implement/replan actions
- `staging_branch`: default `staging`
- `main_branch`: default `main`
- `batch_interval_hours`: default `8`
- `implementation_label`: default `autonomous-impl`
## Prerequisites
Before installing routines, verify:
- Routines system enabled.
- GitHub tool authenticated (for issue/PR/comment/status operations).
- Events are emitted via `event_emit` tool calls (a future HTTP webhook ingestion endpoint is planned but not yet available).
## Install Procedure
1. Open [`workflow-routines.md`](references/workflow-routines.md).
2. For each template block:
- replace placeholders (`{{repository}}`, `{{maintainers}}`, branch names)
- call `routine_create`
3. If a routine already exists:
- use `routine_update` instead of creating duplicates
- keep names stable so long-lived metrics/history stay intact
4. Confirm install with `routine_list` and `routine_history`.
## Routine Set
Install these routines:
- `wf-issue-plan`: on `issue.opened` or `issue.reopened`, generate implementation plan comment/checklist.
- `wf-maintainer-comment-gate`: on maintainer comments, decide update-plan vs start implementation.
- `wf-pr-monitor-loop`: on PR open/sync/review-comment/review, address feedback and refresh branch.
- `wf-ci-fix-loop`: on CI status/check failures, apply fixes and push updates.
- `wf-staging-batch-review`: every 8h, review ready PRs, merge into staging, run deep batch correctness analysis, fix findings, then merge staging -> main.
- `wf-learning-memory`: on merged PRs, extract mistakes/lessons and write to shared memory.
## Event Filters
Prefer top-level filters for stability:
- `repository` (string)
- `sender` (string)
- `issue_number` / `pr_number`
- `ci_status`, `ci_conclusion`
- `review_state`, `comment_author`
Use narrow filters to avoid accidental triggers across repos.
## Operating Rules
- All implementation work must occur on non-main branches.
- PR loop must resolve both human and AI review comments.
- On conflicts with `origin/main`, refresh branch before continuing.
- Staging-batch routine is the only path for bulk correctness verification before mainline merge.
- Memory update routine runs only after successful merge.
## Validation
After install, run:
1. `event_emit` with a synthetic `issue.opened` payload for the target repo.
2. Confirm at least one routine fired.
3. Check corresponding `routine_history` entries.
4. Confirm no unrelated routines fired.
## When To Update Templates
Update this skill when:
- GitHub event names/payload fields change.
- Team review policy changes (e.g., staging cadence, maintainer gates).
- New CI policy requires different failure routing.
@@ -1,4 +0,0 @@
interface:
display_name: "IronClaw Workflow Orchestrator"
short_description: "Install and run event-driven GitHub workflow routines"
default_prompt: "Set up the full issue-to-merge workflow using routines and event triggers."
@@ -1,128 +0,0 @@
# Workflow Routine Templates
Replace `{{...}}` placeholders before use.
## 1) Issue -> Plan
```json
{
"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": "{{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
}
```
## 2) Maintainer Comment Gate (Update Plan vs Implement)
Trigger per-maintainer by creating one routine per handle, or maintain a shared author convention.
```json
{
"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": "{{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
}
```
## 3) PR Monitor Loop
```json
{
"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": "{{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
}
```
## 4) CI Failure Fix Loop
```json
{
"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": "{{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
}
```
## 5) Staging Batch Review (Every 8h)
```json
{
"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
}
```
## 6) Post-Merge Learning -> Common Memory
```json
{
"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": "{{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
}
```
## Optional: Synthetic Event Test
```json
{
"source": "github",
"event_type": "issue.opened",
"payload": {
"repository": "{{repository}}",
"issue_number": 99999,
"sender": "test-bot"
}
}
```
Use with `event_emit` after routine install.
+17 -20
View File
@@ -14,15 +14,14 @@ Core agent logic. This is the most complex subsystem — read this before workin
| `session_manager.rs` | Lifecycle: create/lookup sessions, map external thread IDs to internal UUIDs, prune stale sessions, manage undo managers. |
| `router.rs` | Routes explicit `/commands` to `MessageIntent`. Natural language bypasses the router entirely. |
| `scheduler.rs` | Parallel job scheduling. Maintains `jobs` map (full LLM-driven) and `subtasks` map (tool-exec/background). |
| *(moved to `src/worker/job.rs`)* | Per-job execution now lives in `src/worker/job.rs` as `JobDelegate`, using the shared `run_agentic_loop()` engine. |
| `agentic_loop.rs` | Shared agentic loop engine: `run_agentic_loop()`, `LoopDelegate` trait, `LoopOutcome`, `LoopSignal`, `TextAction`. All three execution paths (chat, job, container) delegate to this. |
| `worker.rs` | Per-job execution for background scheduler jobs: calls LLM, runs tools, handles the reasoning loop. Distinct from `dispatcher.rs`. |
| `compaction.rs` | Context window management: summarize old turns, write to workspace daily log, trim context. Three strategies. |
| `context_monitor.rs` | Detects memory pressure. Suggests `CompactionStrategy` based on usage level. |
| `self_repair.rs` | Detects stuck jobs and broken tools, attempts recovery. |
| `heartbeat.rs` | Proactive periodic execution. Reads `HEARTBEAT.md`, notifies via channel if findings. |
| `submission.rs` | Parses all user submissions into typed variants before routing. |
| `undo.rs` | Turn-based undo/redo with checkpoints. Checkpoints store message lists (max 20 by default). |
| `routine.rs` | `Routine` types: `Trigger` (cron/event/system_event/manual) + `RoutineAction` (lightweight/full_job) + `RoutineGuardrails`. |
| `routine.rs` | `Routine` types: `Trigger` (cron/event/webhook/manual) + `RoutineAction` (lightweight/full_job) + `RoutineGuardrails`. |
| `routine_engine.rs` | Cron ticker and event matcher. Fires routines when triggers match. Lightweight runs inline; full_job dispatches to `Scheduler`. |
| `task.rs` | Task types for the scheduler: `Job`, `ToolExec`, `Background`. Used by `spawn_subtask` and `spawn_batch`. |
| `cost_guard.rs` | LLM spend and action-rate enforcement. Tracks daily budget (cents) and hourly call rate. Lives in `AgentDeps`. |
@@ -50,28 +49,26 @@ Session (per user)
## Agentic Loop (dispatcher.rs)
All three execution paths (chat, job, container) now use the shared `run_agentic_loop()` engine in `agentic_loop.rs`, each providing their own `LoopDelegate` implementation:
- **`ChatDelegate`** (`dispatcher.rs`) — conversational turns, tool approval, skill context injection
- **`JobDelegate`** (`src/worker/job.rs`) — background scheduler jobs, planning support, completion detection
- **`ContainerDelegate`** (`src/worker/container.rs`) — Docker container worker, sequential tool exec, HTTP event streaming
The `dispatcher.rs` module handles **direct conversational turns** (user messages processed inline by the main agent). Background scheduler jobs use `worker.rs` instead — these are two separate execution paths.
```
run_agentic_loop(delegate, reasoning, reason_ctx, config)
1. Check signals (stop/cancel) via delegate.check_signals()
2. Pre-LLM hook via delegate.before_llm_call()
3. LLM call via delegate.call_llm()
4. If text response → delegate.handle_text_response() → Continue or Return
5. If tool callsdelegate.execute_tool_calls() → Continue or Return
6. Post-iteration hook via delegate.after_iteration()
7. Repeat until LoopOutcome returned or max_iterations reached
run_agentic_loop() [dispatcher.rs — conversational turns]
1. Load workspace system prompt (identity files: AGENTS.md, SOUL.md, etc.)
2. Detect group chat from metadata; exclude MEMORY.md if group chat
3. Select active skills (keyword/pattern scoring against message content)
4. Build skill context block (injected before user message)
5. LLM call → text response OR tool calls
6. If tool calls:
a. Check tool approval (session auto-approvals, pending approval queue)
b. Execute tools (parallel via JoinSet)
c. Sanitize results through SafetyLayer
d. Feed results back → goto 5
7. Return AgenticLoopResult::Response or NeedApproval
```
**Tool approval:** Tools flagged `requires_approval` pause the loop `ChatDelegate` returns `LoopOutcome::NeedApproval(pending)`. The web gateway stores the `PendingApproval` in session state and sends an `approval_needed` SSE event. The user's approval/deny resumes the loop.
**Tool approval:** Tools flagged `requires_approval` pause the loop and return `NeedApproval`. The web gateway stores the `PendingApproval` in session state and sends an `approval_needed` SSE event. The user's approval/deny resumes the loop.
**Shared tool execution:** `tools/execute.rs` provides `execute_tool_with_safety()` (validate → timeout → execute → serialize) and `process_tool_result()` (sanitize → wrap → ChatMessage), used by all three delegates.
**ChatDelegate vs JobDelegate:** `ChatDelegate` runs for user-initiated conversational turns (holds session lock, tracks turns). `JobDelegate` is spawned by the `Scheduler` for background jobs created via `CreateJob` / `/job` — it runs independently of the session and has planning support (`use_planning` flag).
**worker.rs vs dispatcher.rs:** `dispatcher.rs` runs the agentic loop for user-initiated conversational turns (holds session lock, tracks turns). `worker.rs` is spawned by the `Scheduler` for background jobs created via `CreateJob` / `/job` — it runs independently of the session and has its own LLM reasoning loop with planning support (`use_planning` flag).
## Command Routing (router.rs)
+8 -36
View File
@@ -446,8 +446,6 @@ impl Agent {
Arc::clone(workspace),
notify_tx,
Some(self.scheduler.clone()),
self.tools().clone(),
self.safety().clone(),
));
// Register routine tools
@@ -516,7 +514,7 @@ impl Agent {
*slot.write().await = Some(Arc::clone(&engine));
}
tracing::debug!(
tracing::info!(
"Routines enabled: cron ticker every {}s, max {} concurrent",
rt_config.cron_check_interval_secs,
rt_config.max_concurrent_routines
@@ -538,20 +536,20 @@ impl Agent {
let routine_engine_for_loop = routine_handle.as_ref().map(|(_, e)| Arc::clone(e));
// Main message loop
tracing::debug!("Agent {} ready and listening", self.config.name);
tracing::info!("Agent {} ready and listening", self.config.name);
loop {
let message = tokio::select! {
biased;
_ = tokio::signal::ctrl_c() => {
tracing::debug!("Ctrl+C received, shutting down...");
tracing::info!("Ctrl+C received, shutting down...");
break;
}
msg = message_stream.next() => {
match msg {
Some(m) => m,
None => {
tracing::debug!("All channel streams ended, shutting down...");
tracing::info!("All channel streams ended, shutting down...");
break;
}
}
@@ -626,7 +624,7 @@ impl Agent {
}
Ok(None) => {
// Shutdown signal received (/quit, /exit, /shutdown)
tracing::debug!("Shutdown command received, exiting...");
tracing::info!("Shutdown command received, exiting...");
break;
}
Err(e) => {
@@ -655,7 +653,7 @@ impl Agent {
}
// Cleanup
tracing::debug!("Agent shutting down...");
tracing::info!("Agent shutting down...");
repair_handle.abort();
pruning_handle.abort();
if let Some(handle) = heartbeat_handle {
@@ -738,18 +736,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,
user_id = %message.user_id,
channel = %message.channel,
thread_id = ?message.thread_id,
"Message details"
);
// Set message tool context for this turn (current channel and target)
// For Signal, use signal_target from metadata (group:ID or phone number),
// otherwise fall back to user_id
@@ -765,7 +751,7 @@ impl Agent {
// Parse submission type first
let mut submission = SubmissionParser::parse(&message.content);
tracing::trace!(
tracing::debug!(
"[agent_loop] Parsed submission: {:?}",
std::any::type_name_of_val(&submission)
);
@@ -798,19 +784,10 @@ impl Agent {
// Hydrate thread from DB if it's a historical thread not in memory
if let Some(ref external_thread_id) = message.thread_id {
tracing::trace!(
message_id = %message.id,
thread_id = %external_thread_id,
"Hydrating thread from DB"
);
self.maybe_hydrate_thread(message, external_thread_id).await;
}
// Resolve session and thread
tracing::debug!(
message_id = %message.id,
"Resolving session and thread"
);
let (session, thread_id) = self
.session_manager
.resolve_thread(
@@ -819,11 +796,6 @@ impl Agent {
message.thread_id.as_deref(),
)
.await;
tracing::debug!(
message_id = %message.id,
thread_id = %thread_id,
"Resolved session and thread"
);
// Auth mode interception: if the thread is awaiting a token, route
// the message directly to the credential store. Nothing touches
@@ -853,7 +825,7 @@ impl Agent {
}
}
tracing::trace!(
tracing::debug!(
"Received message from {} on {} ({} chars)",
message.user_id,
message.channel,
-587
View File
@@ -1,587 +0,0 @@
//! Unified agentic loop engine.
//!
//! Provides a single implementation of the core LLM call → tool execution →
//! result processing → context update → repeat cycle. Three consumers
//! (chat dispatcher, job worker, container runtime) customize behavior
//! via the `LoopDelegate` trait.
use async_trait::async_trait;
use crate::agent::session::PendingApproval;
use crate::error::Error;
use crate::llm::{ChatMessage, Reasoning, ReasoningContext, RespondResult};
/// Signal from the delegate indicating how the loop should proceed.
pub enum LoopSignal {
/// Continue normally.
Continue,
/// Stop the loop gracefully.
Stop,
/// Inject a user message into context and continue.
InjectMessage(String),
}
/// Outcome of a text response from the LLM.
pub enum TextAction {
/// Return this as the final loop result.
Return(LoopOutcome),
/// Continue the loop (text was handled but loop should proceed).
Continue,
}
/// Final outcome of the agentic loop.
pub enum LoopOutcome {
/// Completed with a text response.
Response(String),
/// Loop was stopped by a signal.
Stopped,
/// Max iterations exceeded.
MaxIterations,
/// A tool requires user approval before continuing (chat delegate only).
NeedApproval(Box<PendingApproval>),
}
/// Configuration for the agentic loop.
pub struct AgenticLoopConfig {
pub max_iterations: usize,
pub enable_tool_intent_nudge: bool,
pub max_tool_intent_nudges: u32,
}
impl Default for AgenticLoopConfig {
fn default() -> Self {
Self {
max_iterations: 50,
enable_tool_intent_nudge: true,
max_tool_intent_nudges: 2,
}
}
}
/// Strategy trait — each consumer implements this to customize I/O and lifecycle.
///
/// The shared loop calls these methods at well-defined points. Consumers
/// implement only the behavior that differs between chat, job, and container
/// contexts. The loop itself handles the common logic: tool intent nudge,
/// iteration counting, tool definition refresh, and the respond → execute → process cycle.
///
/// # `Send + Sync` requirement
///
/// This trait requires `Send + Sync` because the loop accepts `&dyn LoopDelegate`.
/// Delegates using borrowed references (e.g. `ChatDelegate<'a>`) must ensure all
/// borrowed fields are `Send + Sync`. This is a load-bearing constraint: if a
/// delegate needs to be spawned into a detached task, it must use `Arc`-based
/// ownership instead of borrows (as `JobDelegate` and `ContainerDelegate` do).
#[async_trait]
pub trait LoopDelegate: Send + Sync {
/// Called at the start of each iteration. Check for external signals
/// (cancellation, user messages, stop requests).
async fn check_signals(&self) -> LoopSignal;
/// Called before the LLM call. Allows the delegate to refresh tool
/// definitions, enforce cost guards, or inject messages.
/// Return `Some(outcome)` to break the loop early.
async fn before_llm_call(
&self,
reason_ctx: &mut ReasoningContext,
iteration: usize,
) -> Option<LoopOutcome>;
/// Call the LLM and return the result. Delegates own the LLM call
/// to handle consumer-specific concerns (rate limiting, auto-compaction,
/// cost tracking, force_text mode).
async fn call_llm(
&self,
reasoning: &Reasoning,
reason_ctx: &mut ReasoningContext,
iteration: usize,
) -> Result<crate::llm::RespondOutput, Error>;
/// Handle a text-only response from the LLM.
/// Return `TextAction::Return` to exit the loop, `TextAction::Continue` to proceed.
async fn handle_text_response(
&self,
text: &str,
reason_ctx: &mut ReasoningContext,
) -> TextAction;
/// Execute tool calls and add results to context.
/// Return `Some(outcome)` to break the loop (e.g. approval needed).
async fn execute_tool_calls(
&self,
tool_calls: Vec<crate::llm::ToolCall>,
content: Option<String>,
reason_ctx: &mut ReasoningContext,
) -> Result<Option<LoopOutcome>, Error>;
/// Called when the LLM expresses tool intent without actually calling a tool.
/// Delegates can use this to emit events or log the nudge for observability.
async fn on_tool_intent_nudge(&self, _text: &str, _reason_ctx: &mut ReasoningContext) {}
/// Called after each successful iteration (no error, no early return).
async fn after_iteration(&self, _iteration: usize) {}
}
/// Run the unified agentic loop.
///
/// This is the single implementation used by all three consumers (chat, job, container).
/// The `delegate` provides consumer-specific behavior via the `LoopDelegate` trait.
pub async fn run_agentic_loop(
delegate: &dyn LoopDelegate,
reasoning: &Reasoning,
reason_ctx: &mut ReasoningContext,
config: &AgenticLoopConfig,
) -> Result<LoopOutcome, Error> {
let mut consecutive_tool_intent_nudges: u32 = 0;
for iteration in 1..=config.max_iterations {
// Check for external signals (stop, cancellation, user messages)
match delegate.check_signals().await {
LoopSignal::Continue => {}
LoopSignal::Stop => return Ok(LoopOutcome::Stopped),
LoopSignal::InjectMessage(msg) => {
reason_ctx.messages.push(ChatMessage::user(&msg));
}
}
// Pre-LLM call hook (cost guard, tool refresh, iteration limit nudge)
if let Some(outcome) = delegate.before_llm_call(reason_ctx, iteration).await {
return Ok(outcome);
}
// Call LLM
let output = delegate.call_llm(reasoning, reason_ctx, iteration).await?;
match output.result {
RespondResult::Text(text) => {
// Tool intent nudge: if the LLM says "let me search..." without
// actually calling a tool, inject a nudge message.
if config.enable_tool_intent_nudge
&& !reason_ctx.available_tools.is_empty()
&& !reason_ctx.force_text
&& consecutive_tool_intent_nudges < config.max_tool_intent_nudges
&& crate::llm::llm_signals_tool_intent(&text)
{
consecutive_tool_intent_nudges += 1;
tracing::info!(
iteration,
"LLM expressed tool intent without calling a tool, nudging"
);
delegate.on_tool_intent_nudge(&text, reason_ctx).await;
reason_ctx.messages.push(ChatMessage::assistant(&text));
reason_ctx
.messages
.push(ChatMessage::user(crate::llm::TOOL_INTENT_NUDGE));
delegate.after_iteration(iteration).await;
continue;
}
// Reset nudge counter since we got a non-intent text response
if !crate::llm::llm_signals_tool_intent(&text) {
consecutive_tool_intent_nudges = 0;
}
match delegate.handle_text_response(&text, reason_ctx).await {
TextAction::Return(outcome) => return Ok(outcome),
TextAction::Continue => {}
}
}
RespondResult::ToolCalls {
tool_calls,
content,
} => {
consecutive_tool_intent_nudges = 0;
if let Some(outcome) = delegate
.execute_tool_calls(tool_calls, content, reason_ctx)
.await?
{
return Ok(outcome);
}
}
}
delegate.after_iteration(iteration).await;
}
Ok(LoopOutcome::MaxIterations)
}
/// Truncate a string for log/status previews.
///
/// `max` is a byte budget. The result is truncated at the last valid char
/// boundary at or before `max` bytes, so it is always valid UTF-8.
pub fn truncate_for_preview(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
let end = crate::util::floor_char_boundary(s, max);
format!("{}...", &s[..end])
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::{RespondOutput, TokenUsage, ToolCall};
use crate::testing::StubLlm;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::sync::Mutex;
fn stub_reasoning() -> Reasoning {
Reasoning::new(Arc::new(StubLlm::default()))
}
fn zero_usage() -> TokenUsage {
TokenUsage {
input_tokens: 0,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
}
}
fn text_output(text: &str) -> RespondOutput {
RespondOutput {
result: RespondResult::Text(text.to_string()),
usage: zero_usage(),
}
}
fn tool_calls_output(calls: Vec<ToolCall>) -> RespondOutput {
RespondOutput {
result: RespondResult::ToolCalls {
tool_calls: calls,
content: None,
},
usage: zero_usage(),
}
}
/// Configurable mock delegate for testing run_agentic_loop.
struct MockDelegate {
signal: Mutex<LoopSignal>,
llm_responses: Mutex<Vec<RespondOutput>>,
tool_exec_count: AtomicUsize,
tool_exec_outcome: Mutex<Option<LoopOutcome>>,
iterations_seen: Mutex<Vec<usize>>,
early_exit: Mutex<Option<(usize, LoopOutcome)>>,
nudge_count: AtomicUsize,
}
impl MockDelegate {
fn new(responses: Vec<RespondOutput>) -> Self {
Self {
signal: Mutex::new(LoopSignal::Continue),
llm_responses: Mutex::new(responses),
tool_exec_count: AtomicUsize::new(0),
tool_exec_outcome: Mutex::new(None),
iterations_seen: Mutex::new(Vec::new()),
early_exit: Mutex::new(None),
nudge_count: AtomicUsize::new(0),
}
}
fn with_signal(mut self, signal: LoopSignal) -> Self {
self.signal = Mutex::new(signal);
self
}
fn with_early_exit(mut self, iteration: usize, outcome: LoopOutcome) -> Self {
self.early_exit = Mutex::new(Some((iteration, outcome)));
self
}
}
#[async_trait]
impl LoopDelegate for MockDelegate {
async fn check_signals(&self) -> LoopSignal {
let mut sig = self.signal.lock().await;
std::mem::replace(&mut *sig, LoopSignal::Continue)
}
async fn before_llm_call(
&self,
_reason_ctx: &mut ReasoningContext,
iteration: usize,
) -> Option<LoopOutcome> {
let mut guard = self.early_exit.lock().await;
let should_take = guard
.as_ref()
.is_some_and(|(target, _)| *target == iteration);
if should_take {
guard.take().map(|(_, o)| o)
} else {
None
}
}
async fn call_llm(
&self,
_reasoning: &Reasoning,
_reason_ctx: &mut ReasoningContext,
_iteration: usize,
) -> Result<crate::llm::RespondOutput, crate::error::Error> {
let mut responses = self.llm_responses.lock().await;
if responses.is_empty() {
panic!("MockDelegate: no more LLM responses queued");
}
Ok(responses.remove(0))
}
async fn handle_text_response(
&self,
text: &str,
_reason_ctx: &mut ReasoningContext,
) -> TextAction {
TextAction::Return(LoopOutcome::Response(text.to_string()))
}
async fn execute_tool_calls(
&self,
_tool_calls: Vec<ToolCall>,
_content: Option<String>,
reason_ctx: &mut ReasoningContext,
) -> Result<Option<LoopOutcome>, crate::error::Error> {
self.tool_exec_count.fetch_add(1, Ordering::SeqCst);
reason_ctx
.messages
.push(ChatMessage::user("tool result stub"));
let outcome = self.tool_exec_outcome.lock().await.take();
Ok(outcome)
}
async fn on_tool_intent_nudge(&self, _text: &str, _reason_ctx: &mut ReasoningContext) {
self.nudge_count.fetch_add(1, Ordering::SeqCst);
}
async fn after_iteration(&self, iteration: usize) {
self.iterations_seen.lock().await.push(iteration);
}
}
// --- Tests ---
#[tokio::test]
async fn test_text_response_returns_immediately() {
let delegate = MockDelegate::new(vec![text_output("Hello, world!")]);
let reasoning = stub_reasoning();
let mut ctx = ReasoningContext::new();
let config = AgenticLoopConfig::default();
let outcome = run_agentic_loop(&delegate, &reasoning, &mut ctx, &config)
.await
.unwrap();
match outcome {
LoopOutcome::Response(text) => assert_eq!(text, "Hello, world!"),
_ => panic!("Expected LoopOutcome::Response"),
}
// after_iteration is NOT called when handle_text_response returns Return
// (the loop exits before reaching after_iteration).
assert!(delegate.iterations_seen.lock().await.is_empty());
}
#[tokio::test]
async fn test_tool_call_then_text_response() {
let tool_call = ToolCall {
id: "call_1".to_string(),
name: "echo".to_string(),
arguments: serde_json::json!({}),
};
let delegate = MockDelegate::new(vec![
tool_calls_output(vec![tool_call]),
text_output("Done!"),
]);
let reasoning = stub_reasoning();
let mut ctx = ReasoningContext::new();
let config = AgenticLoopConfig::default();
let outcome = run_agentic_loop(&delegate, &reasoning, &mut ctx, &config)
.await
.unwrap();
match outcome {
LoopOutcome::Response(text) => assert_eq!(text, "Done!"),
_ => panic!("Expected LoopOutcome::Response"),
}
assert_eq!(delegate.tool_exec_count.load(Ordering::SeqCst), 1);
// after_iteration called for iteration 1 (tool call), but not 2
// (text response exits before after_iteration).
assert_eq!(*delegate.iterations_seen.lock().await, vec![1]);
}
#[tokio::test]
async fn test_stop_signal_exits_immediately() {
let delegate =
MockDelegate::new(vec![text_output("unreachable")]).with_signal(LoopSignal::Stop);
let reasoning = stub_reasoning();
let mut ctx = ReasoningContext::new();
let config = AgenticLoopConfig::default();
let outcome = run_agentic_loop(&delegate, &reasoning, &mut ctx, &config)
.await
.unwrap();
assert!(matches!(outcome, LoopOutcome::Stopped));
assert!(delegate.iterations_seen.lock().await.is_empty());
}
#[tokio::test]
async fn test_inject_message_adds_user_message() {
let delegate = MockDelegate::new(vec![text_output("Got it")])
.with_signal(LoopSignal::InjectMessage("injected prompt".to_string()));
let reasoning = stub_reasoning();
let mut ctx = ReasoningContext::new();
let config = AgenticLoopConfig::default();
let outcome = run_agentic_loop(&delegate, &reasoning, &mut ctx, &config)
.await
.unwrap();
assert!(matches!(outcome, LoopOutcome::Response(_)));
assert!(
ctx.messages
.iter()
.any(|m| m.role == crate::llm::Role::User && m.content.contains("injected prompt")),
"Injected message should appear in context"
);
}
#[tokio::test]
async fn test_max_iterations_reached() {
struct ContinueDelegate;
#[async_trait]
impl LoopDelegate for ContinueDelegate {
async fn check_signals(&self) -> LoopSignal {
LoopSignal::Continue
}
async fn before_llm_call(
&self,
_: &mut ReasoningContext,
_: usize,
) -> Option<LoopOutcome> {
None
}
async fn call_llm(
&self,
_: &Reasoning,
_: &mut ReasoningContext,
_: usize,
) -> Result<crate::llm::RespondOutput, crate::error::Error> {
Ok(text_output("still working"))
}
async fn handle_text_response(
&self,
_: &str,
ctx: &mut ReasoningContext,
) -> TextAction {
ctx.messages.push(ChatMessage::assistant("still working"));
TextAction::Continue
}
async fn execute_tool_calls(
&self,
_: Vec<ToolCall>,
_: Option<String>,
_: &mut ReasoningContext,
) -> Result<Option<LoopOutcome>, crate::error::Error> {
Ok(None)
}
}
let delegate = ContinueDelegate;
let reasoning = stub_reasoning();
let mut ctx = ReasoningContext::new();
let config = AgenticLoopConfig {
max_iterations: 3,
..Default::default()
};
let outcome = run_agentic_loop(&delegate, &reasoning, &mut ctx, &config)
.await
.unwrap();
assert!(matches!(outcome, LoopOutcome::MaxIterations));
let assistant_count = ctx
.messages
.iter()
.filter(|m| m.role == crate::llm::Role::Assistant)
.count();
assert_eq!(assistant_count, 3);
}
#[tokio::test]
async fn test_tool_intent_nudge_fires_and_caps() {
let delegate = MockDelegate::new(vec![
text_output("Let me search for that file"),
text_output("Let me search for that file"),
text_output("Let me search for that file"),
]);
let reasoning = stub_reasoning();
let mut ctx = ReasoningContext::new();
ctx.available_tools.push(crate::llm::ToolDefinition {
name: "search".to_string(),
description: "Search files".to_string(),
parameters: serde_json::json!({"type": "object"}),
});
let config = AgenticLoopConfig {
max_iterations: 10,
enable_tool_intent_nudge: true,
max_tool_intent_nudges: 2,
};
let outcome = run_agentic_loop(&delegate, &reasoning, &mut ctx, &config)
.await
.unwrap();
assert!(matches!(outcome, LoopOutcome::Response(_)));
assert_eq!(delegate.nudge_count.load(Ordering::SeqCst), 2);
let nudge_messages = ctx
.messages
.iter()
.filter(|m| {
m.role == crate::llm::Role::User
&& m.content.contains("you did not include any tool calls")
})
.count();
assert_eq!(
nudge_messages, 2,
"Should have exactly 2 nudge messages in context"
);
}
#[tokio::test]
async fn test_before_llm_call_early_exit() {
let delegate = MockDelegate::new(vec![text_output("unreachable")])
.with_early_exit(1, LoopOutcome::Stopped);
let reasoning = stub_reasoning();
let mut ctx = ReasoningContext::new();
let config = AgenticLoopConfig::default();
let outcome = run_agentic_loop(&delegate, &reasoning, &mut ctx, &config)
.await
.unwrap();
assert!(matches!(outcome, LoopOutcome::Stopped));
assert!(delegate.iterations_seen.lock().await.is_empty());
}
#[test]
fn test_truncate_short_string_unchanged() {
assert_eq!(truncate_for_preview("hello", 10), "hello");
}
#[test]
fn test_truncate_long_string_adds_ellipsis() {
let result = truncate_for_preview("hello world", 5);
assert_eq!(result, "hello...");
}
#[test]
fn test_truncate_multibyte_safe() {
let result = truncate_for_preview("café", 4);
assert_eq!(result, "caf...");
}
}
+2 -4
View File
@@ -405,8 +405,7 @@ impl Agent {
.with_max_tokens(512)
.with_temperature(0.3);
let reasoning =
Reasoning::new(self.llm().clone()).with_model_name(self.llm().active_model_name());
let reasoning = Reasoning::new(self.llm().clone());
match reasoning.complete(request).await {
Ok((text, _usage)) => Ok(SubmissionResult::response(format!(
"Thread Summary:\n\n{}",
@@ -454,8 +453,7 @@ impl Agent {
.with_max_tokens(512)
.with_temperature(0.5);
let reasoning =
Reasoning::new(self.llm().clone()).with_model_name(self.llm().active_model_name());
let reasoning = Reasoning::new(self.llm().clone());
match reasoning.complete(request).await {
Ok((text, _usage)) => Ok(SubmissionResult::response(format!(
"Suggested Next Steps:\n\n{}",
+1 -2
View File
@@ -227,8 +227,7 @@ Be brief but capture all important details. Use bullet points."#,
.with_max_tokens(1024)
.with_temperature(0.3);
let reasoning =
Reasoning::new(self.llm.clone()).with_model_name(self.llm.active_model_name());
let reasoning = Reasoning::new(self.llm.clone());
let (text, _) = reasoning.complete(request).await?;
Ok(text)
}
+782 -804
View File
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -189,7 +189,7 @@ impl HeartbeatRunner {
// Skip during quiet hours
if self.config.is_quiet_hours() {
tracing::trace!("Heartbeat skipped: quiet hours");
tracing::debug!("Heartbeat skipped: quiet hours");
continue;
}
@@ -212,7 +212,7 @@ impl HeartbeatRunner {
match self.check_heartbeat().await {
HeartbeatResult::Ok => {
tracing::trace!("Heartbeat OK");
tracing::debug!("Heartbeat OK");
self.consecutive_failures = 0;
}
HeartbeatResult::NeedsAttention(message) => {
@@ -221,7 +221,7 @@ impl HeartbeatRunner {
self.send_notification(&message).await;
}
HeartbeatResult::Skipped => {
tracing::trace!("Heartbeat skipped");
tracing::debug!("Heartbeat skipped");
}
HeartbeatResult::Failed(error) => {
tracing::error!("Heartbeat failed: {}", error);
@@ -303,8 +303,7 @@ impl HeartbeatRunner {
.with_max_tokens(max_tokens)
.with_temperature(0.3);
let reasoning =
Reasoning::new(self.llm.clone()).with_model_name(self.llm.active_model_name());
let reasoning = Reasoning::new(self.llm.clone());
let (content, _usage) = match reasoning.complete(request).await {
Ok(r) => r,
Err(e) => return HeartbeatResult::Failed(format!("LLM call failed: {}", e)),
+3 -3
View File
@@ -11,7 +11,6 @@
//! - Context compaction for long conversations
mod agent_loop;
pub mod agentic_loop;
mod attachments;
mod commands;
pub mod compaction;
@@ -23,7 +22,7 @@ pub mod job_monitor;
mod router;
pub mod routine;
pub mod routine_engine;
pub(crate) mod scheduler;
mod scheduler;
mod self_repair;
pub mod session;
mod session_manager;
@@ -31,8 +30,8 @@ pub mod submission;
pub mod task;
mod thread_ops;
pub mod undo;
pub mod worker;
pub use crate::worker::{Worker, WorkerDeps};
pub(crate) use agent_loop::truncate_for_preview;
pub use agent_loop::{Agent, AgentDeps};
pub use compaction::{CompactionResult, ContextCompactor};
@@ -48,3 +47,4 @@ pub use session_manager::SessionManager;
pub use submission::{Submission, SubmissionParser, SubmissionResult};
pub use task::{Task, TaskContext, TaskHandler, TaskOutput};
pub use undo::{Checkpoint, UndoManager};
pub use worker::{Worker, WorkerDeps};
+23 -86
View File
@@ -8,7 +8,7 @@
//! ┌──────────┐ ┌─────────┐ ┌──────────────────┐
//! │ Trigger │────▶│ Engine │────▶│ Execution Mode │
//! │ cron/event│ │guardrail│ │lightweight│full_job│
//! │ system │ │ check │ └──────────────────┘
//! │ webhook │ │ check │ └──────────────────┘
//! │ manual │ └─────────┘ │
//! └──────────┘ ▼
//! ┌──────────────┐
@@ -69,15 +69,12 @@ pub enum Trigger {
/// Regex pattern to match against message content.
pattern: String,
},
/// Fire when a structured system event is emitted.
SystemEvent {
/// Event source namespace (e.g. "github", "workflow", "tool").
source: String,
/// Event type within the source (e.g. "issue.opened").
event_type: String,
/// Optional exact-match filters against payload top-level fields.
#[serde(default)]
filters: std::collections::HashMap<String, String>,
/// Fire on incoming webhook POST to /hooks/routine/{id}.
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,
@@ -89,7 +86,7 @@ impl Trigger {
match self {
Trigger::Cron { .. } => "cron",
Trigger::Event { .. } => "event",
Trigger::SystemEvent { .. } => "system_event",
Trigger::Webhook { .. } => "webhook",
Trigger::Manual => "manual",
}
}
@@ -137,39 +134,16 @@ impl Trigger {
.map(String::from);
Ok(Trigger::Event { channel, pattern })
}
"system_event" => {
let source = config
.get("source")
"webhook" => {
let path = config
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| RoutineError::MissingField {
context: "system_event trigger".into(),
field: "source".into(),
})?
.to_string();
let event_type = config
.get("event_type")
.map(String::from);
let secret = config
.get("secret")
.and_then(|v| v.as_str())
.ok_or_else(|| RoutineError::MissingField {
context: "system_event trigger".into(),
field: "event_type".into(),
})?
.to_string();
let filters = config
.get("filters")
.and_then(|v| v.as_object())
.map(|m| {
m.iter()
.filter_map(|(k, v)| {
json_value_as_filter_string(v).map(|s| (k.clone(), s))
})
.collect()
})
.unwrap_or_default();
Ok(Trigger::SystemEvent {
source,
event_type,
filters,
})
.map(String::from);
Ok(Trigger::Webhook { path, secret })
}
"manual" => Ok(Trigger::Manual),
other => Err(RoutineError::UnknownTriggerType {
@@ -189,14 +163,9 @@ impl Trigger {
"pattern": pattern,
"channel": channel,
}),
Trigger::SystemEvent {
source,
event_type,
filters,
} => serde_json::json!({
"source": source,
"event_type": event_type,
"filters": filters,
Trigger::Webhook { path, secret } => serde_json::json!({
"path": path,
"secret": secret,
}),
Trigger::Manual => serde_json::json!({}),
}
@@ -459,19 +428,6 @@ pub struct RoutineRun {
pub created_at: DateTime<Utc>,
}
/// Convert a JSON value to a string for filter storage.
///
/// Handles strings, numbers, and booleans — consistent with the matching
/// logic in `routine_engine::json_value_as_string`.
pub fn json_value_as_filter_string(v: &serde_json::Value) -> Option<String> {
match v {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
serde_json::Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
/// Compute a content hash for event dedup.
pub fn content_hash(content: &str) -> u64 {
let mut hasher = DefaultHasher::new();
@@ -530,24 +486,6 @@ mod tests {
if channel == Some("telegram".to_string()) && pattern == r"deploy\s+\w+"));
}
#[test]
fn test_system_event_trigger_roundtrip() {
let mut filters = std::collections::HashMap::new();
filters.insert("repo".to_string(), "nearai/ironclaw".to_string());
filters.insert("action".to_string(), "opened".to_string());
let trigger = Trigger::SystemEvent {
source: "github".to_string(),
event_type: "issue".to_string(),
filters: filters.clone(),
};
let json = trigger.to_config_json();
let parsed = Trigger::from_db("system_event", json).expect("parse system_event");
assert!(
matches!(parsed, Trigger::SystemEvent { source, event_type, filters: f }
if source == "github" && event_type == "issue" && f == filters)
);
}
#[test]
fn test_action_lightweight_roundtrip() {
let action = RoutineAction::Lightweight {
@@ -685,13 +623,12 @@ mod tests {
"event"
);
assert_eq!(
Trigger::SystemEvent {
source: String::new(),
event_type: String::new(),
filters: std::collections::HashMap::new(),
Trigger::Webhook {
path: None,
secret: None
}
.type_tag(),
"system_event"
"webhook"
);
assert_eq!(Trigger::Manual.type_tag(), "manual");
}
+23 -565
View File
@@ -25,21 +25,12 @@ use crate::agent::routine::{
};
use crate::channels::{IncomingMessage, OutgoingResponse};
use crate::config::RoutineConfig;
use crate::context::JobContext;
use crate::db::Database;
use crate::error::RoutineError;
use crate::llm::{
ChatMessage, CompletionRequest, FinishReason, LlmProvider, ToolCall, ToolCompletionRequest,
};
use crate::safety::SafetyLayer;
use crate::tools::{ApprovalContext, ApprovalRequirement, ToolError, ToolRegistry};
use crate::llm::{ChatMessage, CompletionRequest, FinishReason, LlmProvider};
use crate::tools::ApprovalContext;
use crate::workspace::Workspace;
enum EventMatcher {
Message { routine: Routine, regex: Regex },
System { routine: Routine },
}
/// The routine execution engine.
pub struct RoutineEngine {
config: RoutineConfig,
@@ -50,18 +41,13 @@ pub struct RoutineEngine {
notify_tx: mpsc::Sender<OutgoingResponse>,
/// Currently running routine count (across all routines).
running_count: Arc<AtomicUsize>,
/// Cached matchers for all event-driven routines.
event_cache: Arc<RwLock<Vec<EventMatcher>>>,
/// Compiled event regex cache: routine_id -> compiled regex.
event_cache: Arc<RwLock<Vec<(Uuid, Routine, Regex)>>>,
/// Scheduler for dispatching jobs (FullJob mode).
scheduler: Option<Arc<Scheduler>>,
/// Tool registry for lightweight routine tool execution.
tools: Arc<ToolRegistry>,
/// Safety layer for tool output sanitization.
safety: Arc<SafetyLayer>,
}
impl RoutineEngine {
#[allow(clippy::too_many_arguments)]
pub fn new(
config: RoutineConfig,
store: Arc<dyn Database>,
@@ -69,8 +55,6 @@ impl RoutineEngine {
workspace: Arc<Workspace>,
notify_tx: mpsc::Sender<OutgoingResponse>,
scheduler: Option<Arc<Scheduler>>,
tools: Arc<ToolRegistry>,
safety: Arc<SafetyLayer>,
) -> Self {
Self {
config,
@@ -81,8 +65,6 @@ impl RoutineEngine {
running_count: Arc::new(AtomicUsize::new(0)),
event_cache: Arc::new(RwLock::new(Vec::new())),
scheduler,
tools,
safety,
}
}
@@ -92,12 +74,9 @@ impl RoutineEngine {
Ok(routines) => {
let mut cache = Vec::new();
for routine in routines {
match &routine.trigger {
Trigger::Event { pattern, .. } => match Regex::new(pattern) {
Ok(re) => cache.push(EventMatcher::Message {
routine: routine.clone(),
regex: re,
}),
if let Trigger::Event { ref pattern, .. } = routine.trigger {
match Regex::new(pattern) {
Ok(re) => cache.push((routine.id, routine.clone(), re)),
Err(e) => {
tracing::warn!(
routine = %routine.name,
@@ -105,18 +84,12 @@ impl RoutineEngine {
pattern, e
);
}
},
Trigger::SystemEvent { .. } => {
cache.push(EventMatcher::System {
routine: routine.clone(),
});
}
_ => {}
}
}
let count = cache.len();
*self.event_cache.write().await = cache;
tracing::trace!("Refreshed event cache: {} routines", count);
tracing::debug!("Refreshed event cache: {} routines", count);
}
Err(e) => {
tracing::error!("Failed to refresh event cache: {}", e);
@@ -132,11 +105,7 @@ impl RoutineEngine {
let cache = self.event_cache.read().await;
let mut fired = 0;
for matcher in cache.iter() {
let (routine, re) = match matcher {
EventMatcher::Message { routine, regex } => (routine, regex),
EventMatcher::System { .. } => continue,
};
for (_, routine, re) in cache.iter() {
// Channel filter
if let Trigger::Event {
channel: Some(ch), ..
@@ -153,13 +122,13 @@ impl RoutineEngine {
// Cooldown check
if !self.check_cooldown(routine) {
tracing::trace!(routine = %routine.name, "Skipped: cooldown active");
tracing::debug!(routine = %routine.name, "Skipped: cooldown active");
continue;
}
// Concurrent run check
if !self.check_concurrent(routine).await {
tracing::trace!(routine = %routine.name, "Skipped: max concurrent reached");
tracing::debug!(routine = %routine.name, "Skipped: max concurrent reached");
continue;
}
@@ -177,88 +146,6 @@ impl RoutineEngine {
fired
}
/// Emit a structured event to system-event routines.
///
/// Returns the number of routines that were fired.
pub async fn emit_system_event(
&self,
source: &str,
event_type: &str,
payload: &serde_json::Value,
user_id: Option<&str>,
) -> usize {
let cache = self.event_cache.read().await;
let mut fired = 0;
for matcher in cache.iter() {
let routine = match matcher {
EventMatcher::System { routine } => routine,
EventMatcher::Message { .. } => continue,
};
let Trigger::SystemEvent {
source: expected_source,
event_type: expected_event,
filters,
} = &routine.trigger
else {
continue;
};
if !expected_source.eq_ignore_ascii_case(source)
|| !expected_event.eq_ignore_ascii_case(event_type)
{
continue;
}
if let Some(uid) = user_id
&& routine.user_id != uid
{
continue;
}
let mut matched = true;
for (key, expected) in filters {
let Some(actual) = payload
.get(key)
.and_then(crate::agent::routine::json_value_as_filter_string)
else {
tracing::debug!(routine = %routine.name, filter_key = %key, "Filter key not found in payload");
matched = false;
break;
};
if !actual.eq_ignore_ascii_case(expected) {
matched = false;
break;
}
}
if !matched {
continue;
}
if !self.check_cooldown(routine) {
tracing::debug!(routine = %routine.name, "Skipped: cooldown active");
continue;
}
if !self.check_concurrent(routine).await {
tracing::debug!(routine = %routine.name, "Skipped: max concurrent reached");
continue;
}
if self.running_count.load(Ordering::Relaxed) >= self.config.max_concurrent_routines {
tracing::warn!(routine = %routine.name, "Skipped: global max concurrent reached");
continue;
}
let detail = truncate(&format!("{source}:{event_type}"), 200);
self.spawn_fire(routine.clone(), "system_event", Some(detail));
fired += 1;
}
fired
}
/// Check all due cron routines and fire them. Called by the cron ticker.
pub async fn check_cron_triggers(&self) {
let routines = match self.store.list_due_cron_routines().await {
@@ -353,15 +240,12 @@ impl RoutineEngine {
// Execute inline for manual triggers (caller wants to wait)
let engine = EngineContext {
config: self.config.clone(),
store: self.store.clone(),
llm: self.llm.clone(),
workspace: self.workspace.clone(),
notify_tx: self.notify_tx.clone(),
running_count: self.running_count.clone(),
scheduler: self.scheduler.clone(),
tools: self.tools.clone(),
safety: self.safety.clone(),
};
tokio::spawn(async move {
@@ -388,15 +272,12 @@ impl RoutineEngine {
};
let engine = EngineContext {
config: self.config.clone(),
store: self.store.clone(),
llm: self.llm.clone(),
workspace: self.workspace.clone(),
notify_tx: self.notify_tx.clone(),
running_count: self.running_count.clone(),
scheduler: self.scheduler.clone(),
tools: self.tools.clone(),
safety: self.safety.clone(),
};
// Record the run in DB, then spawn execution
@@ -438,15 +319,12 @@ impl RoutineEngine {
/// Shared context passed to the execution function.
struct EngineContext {
config: RoutineConfig,
store: Arc<dyn Database>,
llm: Arc<dyn LlmProvider>,
workspace: Arc<Workspace>,
notify_tx: mpsc::Sender<OutgoingResponse>,
running_count: Arc<AtomicUsize>,
scheduler: Option<Arc<Scheduler>>,
tools: Arc<ToolRegistry>,
safety: Arc<SafetyLayer>,
}
/// Execute a routine run. Handles both lightweight and full_job modes.
@@ -660,10 +538,7 @@ async fn execute_full_job(
Ok((RunStatus::Ok, Some(summary), None))
}
/// Execute a lightweight routine with optional tool support.
///
/// If tools are enabled, this runs a simplified agentic loop (max 3-5 iterations).
/// If tools are disabled, this does a single LLM call (original behavior).
/// Execute a lightweight routine (single LLM call).
async fn execute_lightweight(
ctx: &EngineContext,
routine: &Routine,
@@ -695,7 +570,7 @@ async fn execute_lightweight(
Err(_) => None,
};
// Build the user-facing prompt
// Build the prompt
let mut full_prompt = String::new();
full_prompt.push_str(prompt);
@@ -723,6 +598,15 @@ async fn execute_lightweight(
}
};
let messages = if system_prompt.is_empty() {
vec![ChatMessage::user(&full_prompt)]
} else {
vec![
ChatMessage::system(&system_prompt),
ChatMessage::user(&full_prompt),
]
};
// Determine max_tokens from model metadata with fallback
let effective_max_tokens = match ctx.llm.model_metadata().await {
Ok(meta) => {
@@ -732,45 +616,6 @@ async fn execute_lightweight(
Err(_) => max_tokens,
};
// If tools are enabled, use the tool execution loop; otherwise, single LLM call
if ctx.config.lightweight_tools_enabled {
execute_lightweight_with_tools(
ctx,
routine,
&system_prompt,
&full_prompt,
effective_max_tokens,
)
.await
} else {
execute_lightweight_no_tools(
ctx,
routine,
&system_prompt,
&full_prompt,
effective_max_tokens,
)
.await
}
}
/// Execute a lightweight routine without tool support (original single-call behavior).
async fn execute_lightweight_no_tools(
ctx: &EngineContext,
_routine: &Routine,
system_prompt: &str,
full_prompt: &str,
effective_max_tokens: u32,
) -> Result<(RunStatus, Option<String>, Option<i32>), RoutineError> {
let messages = if system_prompt.is_empty() {
vec![ChatMessage::user(full_prompt)]
} else {
vec![
ChatMessage::system(system_prompt),
ChatMessage::user(full_prompt),
]
};
let request = CompletionRequest::new(messages)
.with_max_tokens(effective_max_tokens)
.with_temperature(0.3);
@@ -786,7 +631,7 @@ async fn execute_lightweight_no_tools(
let content = response.content.trim();
let tokens_used = Some((response.input_tokens + response.output_tokens) as i32);
// Empty content guard
// Empty content guard (same as heartbeat)
if content.is_empty() {
return if response.finish_reason == FinishReason::Length {
Err(RoutineError::TruncatedResponse)
@@ -803,266 +648,6 @@ async fn execute_lightweight_no_tools(
Ok((RunStatus::Attention, Some(content.to_string()), tokens_used))
}
/// Handle a text-only LLM response in lightweight routine execution.
///
/// Checks for the ROUTINE_OK sentinel, validates content, and returns appropriate status.
fn handle_text_response(
content: &str,
finish_reason: FinishReason,
total_input_tokens: u32,
total_output_tokens: u32,
) -> Result<(RunStatus, Option<String>, Option<i32>), RoutineError> {
let content = content.trim();
// Empty content guard
if content.is_empty() {
return if finish_reason == FinishReason::Length {
Err(RoutineError::TruncatedResponse)
} else {
Err(RoutineError::EmptyResponse)
};
}
// Check for the "nothing to do" sentinel
if content == "ROUTINE_OK" || content.contains("ROUTINE_OK") {
let total_tokens = Some((total_input_tokens + total_output_tokens) as i32);
return Ok((RunStatus::Ok, None, total_tokens));
}
let total_tokens = Some((total_input_tokens + total_output_tokens) as i32);
Ok((
RunStatus::Attention,
Some(content.to_string()),
total_tokens,
))
}
/// Execute a lightweight routine with tool execution support (agentic loop).
///
/// This is a simplified version of the full dispatcher loop:
/// - Max 3-5 iterations (configurable)
/// - Sequential tool execution (not parallel)
/// - Auto-approval of non-Always tools
/// - No hooks or approval dialogs
async fn execute_lightweight_with_tools(
ctx: &EngineContext,
routine: &Routine,
system_prompt: &str,
full_prompt: &str,
effective_max_tokens: u32,
) -> Result<(RunStatus, Option<String>, Option<i32>), RoutineError> {
let mut messages = if system_prompt.is_empty() {
vec![ChatMessage::user(full_prompt)]
} else {
vec![
ChatMessage::system(system_prompt),
ChatMessage::user(full_prompt),
]
};
let max_iterations = ctx.config.lightweight_max_iterations.min(5);
let mut iteration = 0;
let mut total_input_tokens = 0;
let mut total_output_tokens = 0;
// Create a minimal job context for tool execution with unique run ID
let run_id = Uuid::new_v4();
let job_ctx = JobContext {
job_id: run_id,
user_id: routine.user_id.clone(),
title: "Lightweight Routine".to_string(),
description: routine.name.clone(),
..Default::default()
};
loop {
iteration += 1;
// Force text-only response at iteration limit
let force_text = iteration >= max_iterations;
if force_text {
// Final iteration: no tools, just get text response
let request = CompletionRequest::new(messages)
.with_max_tokens(effective_max_tokens)
.with_temperature(0.3);
let response =
ctx.llm
.complete(request)
.await
.map_err(|e| RoutineError::LlmFailed {
reason: e.to_string(),
})?;
total_input_tokens += response.input_tokens;
total_output_tokens += response.output_tokens;
return handle_text_response(
&response.content,
response.finish_reason,
total_input_tokens,
total_output_tokens,
);
} else {
// Tool-enabled iteration
let tool_defs = ctx.tools.tool_definitions().await;
let request = ToolCompletionRequest::new(messages.clone(), tool_defs)
.with_max_tokens(effective_max_tokens)
.with_temperature(0.3);
let response = ctx.llm.complete_with_tools(request).await.map_err(|e| {
RoutineError::LlmFailed {
reason: e.to_string(),
}
})?;
total_input_tokens += response.input_tokens;
total_output_tokens += response.output_tokens;
// Check if LLM returned text (no tool calls)
if response.tool_calls.is_empty() {
let content = response.content.unwrap_or_default();
return handle_text_response(
&content,
response.finish_reason,
total_input_tokens,
total_output_tokens,
);
}
// LLM returned tool calls: add assistant message and execute tools
messages.push(ChatMessage::assistant_with_tool_calls(
response.content.clone(),
response.tool_calls.clone(),
));
// Execute tools sequentially
for tc in response.tool_calls {
let result = execute_routine_tool(ctx, &job_ctx, &tc).await;
// Sanitize and wrap result (including errors)
let result_content = match result {
Ok(output) => {
let sanitized = ctx.safety.sanitize_tool_output(&tc.name, &output);
ctx.safety.wrap_for_llm(
&tc.name,
&sanitized.content,
sanitized.was_modified,
)
}
Err(e) => {
let error_msg = format!("Tool '{}' failed: {}", tc.name, e);
let sanitized = ctx.safety.sanitize_tool_output(&tc.name, &error_msg);
ctx.safety.wrap_for_llm(
&tc.name,
&sanitized.content,
sanitized.was_modified,
)
}
};
// Add tool result to context
messages.push(ChatMessage::tool_result(&tc.id, &tc.name, &result_content));
}
// Continue loop to next LLM call
}
}
}
/// Execute a single tool for a lightweight routine.
async fn execute_routine_tool(
ctx: &EngineContext,
job_ctx: &JobContext,
tc: &ToolCall,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
// Check if tool exists
let tool = ctx
.tools
.get(&tc.name)
.await
.ok_or_else(|| format!("Tool '{}' not found", tc.name))?;
// Check approval requirement: only allow Never tools in lightweight routines.
// UnlessAutoApproved and Always tools are blocked to prevent prompt injection attacks.
// Lightweight routines can be triggered by external events and may process untrusted data,
// making them vulnerable to prompt injection that could trick the LLM into calling
// sensitive tools. Blocking these tools entirely is the safest approach.
match tool.requires_approval(&tc.arguments) {
ApprovalRequirement::Never => {}
ApprovalRequirement::UnlessAutoApproved | ApprovalRequirement::Always => {
return Err(format!(
"Tool '{}' requires manual approval and cannot be used in lightweight routines",
tc.name
)
.into());
}
}
// Validate tool parameters
let validation = ctx.safety.validator().validate_tool_params(&tc.arguments);
if !validation.is_valid {
let details = validation
.errors
.iter()
.map(|e| format!("{}: {}", e.field, e.message))
.collect::<Vec<_>>()
.join("; ");
return Err(format!("Invalid tool parameters: {}", details).into());
}
// Execute with per-tool timeout
let timeout = tool.execution_timeout();
let start = std::time::Instant::now();
let result = tokio::time::timeout(timeout, async {
tool.execute(tc.arguments.clone(), job_ctx).await
})
.await;
let elapsed = start.elapsed();
// Log tool execution result (single consolidated log)
match &result {
Ok(Ok(_)) => {
tracing::debug!(
tool = %tc.name,
elapsed_ms = elapsed.as_millis() as u64,
status = "succeeded",
"Lightweight routine tool execution completed"
);
}
Ok(Err(e)) => {
tracing::debug!(
tool = %tc.name,
elapsed_ms = elapsed.as_millis() as u64,
error = %e,
status = "failed",
"Lightweight routine tool execution completed"
);
}
Err(_) => {
tracing::debug!(
tool = %tc.name,
elapsed_ms = elapsed.as_millis() as u64,
timeout_secs = timeout.as_secs(),
status = "timeout",
"Lightweight routine tool execution completed"
);
}
}
let result = result
.map_err(|_| ToolError::Timeout(timeout))
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
// Serialize result to JSON string
let result_str =
serde_json::to_string(&result.result).unwrap_or_else(|_| "<serialize error>".to_string());
Ok(result_str)
}
/// Send a notification based on the routine's notify config and run status.
async fn send_notification(
tx: &mpsc::Sender<OutgoingResponse>,
@@ -1142,7 +727,6 @@ fn truncate(s: &str, max: usize) -> String {
#[cfg(test)]
mod tests {
use crate::agent::routine::{NotifyConfig, RunStatus};
use crate::config::RoutineConfig;
#[test]
fn test_notification_gating() {
@@ -1171,130 +755,4 @@ mod tests {
let _ = status.to_string();
}
}
#[test]
fn test_routine_config_lightweight_tools_enabled_default() {
let config = RoutineConfig::default();
assert!(
config.lightweight_tools_enabled,
"Tools should be enabled by default"
);
}
#[test]
fn test_routine_config_lightweight_max_iterations_default() {
let config = RoutineConfig::default();
assert_eq!(
config.lightweight_max_iterations, 3,
"Default should be 3 iterations"
);
}
#[test]
fn test_routine_config_can_hold_uncapped_max_iterations() {
// The `RoutineConfig` struct can hold a value greater than the safety cap.
let config = RoutineConfig {
lightweight_max_iterations: 10, // Set a value higher than the cap.
..RoutineConfig::default()
};
// The actual capping to a maximum of 5 is handled at runtime in
// `execute_lightweight_with_tools` and during config resolution from env vars.
assert_eq!(
config.lightweight_max_iterations, 10,
"Config struct should store the provided value"
);
}
#[test]
fn test_sanitize_routine_name_replaces_special_chars() {
let test_cases = vec![
("valid-routine", "valid-routine"),
("routine_with_underscore", "routine_with_underscore"),
("Routine With Spaces", "Routine_With_Spaces"),
("routine/with/slashes", "routine_with_slashes"),
("routine@with#symbols", "routine_with_symbols"),
];
for (input, expected) in test_cases {
let result = super::sanitize_routine_name(input);
assert_eq!(
result, expected,
"sanitize_routine_name({}) should be {}",
input, expected
);
}
}
#[test]
fn test_sanitize_routine_name_preserves_alphanumeric_dash_underscore() {
let names = vec!["routine123", "routine-name", "routine_name", "ROUTINE"];
for name in names {
let result = super::sanitize_routine_name(name);
assert_eq!(result, name, "Should preserve {}", name);
}
}
#[test]
fn test_routine_sentinel_detection_exact_match() {
// The execute_lightweight_no_tools checks: content == "ROUTINE_OK" || content.contains("ROUTINE_OK")
// After trim(), whitespace is removed
let test_cases = vec![
("ROUTINE_OK", true),
(" ROUTINE_OK ", true), // After trim, whitespace is removed so matches
("something ROUTINE_OK something", true),
("ROUTINE_OK is done", true),
("done ROUTINE_OK", true),
("no sentinel here", false),
];
for (content, should_match) in test_cases {
let trimmed = content.trim();
let matches = trimmed == "ROUTINE_OK" || trimmed.contains("ROUTINE_OK");
assert_eq!(
matches, should_match,
"Content '{}' sentinel detection should be {}, got {}",
content, should_match, matches
);
}
}
#[test]
fn test_approval_requirement_pattern_matching() {
// Test the approval requirement logic (Never, UnlessAutoApproved, Always)
use crate::tools::ApprovalRequirement;
let requirements = vec![
(ApprovalRequirement::Never, "auto-approved"),
(ApprovalRequirement::UnlessAutoApproved, "auto-approved"),
(ApprovalRequirement::Always, "blocks"),
];
for (req, expected) in requirements {
let can_auto_approve = matches!(
req,
ApprovalRequirement::Never | ApprovalRequirement::UnlessAutoApproved
);
let label = if can_auto_approve {
"auto-approved"
} else {
"blocks"
};
assert_eq!(label, expected, "Approval pattern should match");
}
}
#[test]
fn test_empty_response_handling() {
// Simulate the empty content guard logic
let empty_content = "";
let finish_reason_length = crate::llm::FinishReason::Length;
let finish_reason_stop = crate::llm::FinishReason::Stop;
assert!(
empty_content.trim().is_empty(),
"Should detect empty content"
);
assert_eq!(finish_reason_length, crate::llm::FinishReason::Length);
assert_eq!(finish_reason_stop, crate::llm::FinishReason::Stop);
}
}
+36 -182
View File
@@ -9,6 +9,7 @@ use tokio::task::JoinHandle;
use uuid::Uuid;
use crate::agent::task::{Task, TaskContext, TaskOutput};
use crate::agent::worker::{Worker, WorkerDeps};
use crate::channels::web::types::SseEvent;
use crate::config::AgentConfig;
use crate::context::{ContextManager, JobContext, JobState};
@@ -18,7 +19,6 @@ use crate::hooks::HookRegistry;
use crate::llm::LlmProvider;
use crate::safety::SafetyLayer;
use crate::tools::{ApprovalContext, ToolRegistry};
use crate::worker::job::{Worker, WorkerDeps};
/// Message to send to a worker.
#[derive(Debug)]
@@ -160,39 +160,11 @@ impl Scheduler {
.create_job_for_user(user_id, title, description)
.await?;
// Apply metadata and token budget in a single atomic update.
// This prevents concurrent workers from observing partial state.
// Cap user-supplied max_tokens at the configured limit (Issue #815).
let user_max_tokens = metadata
.as_ref()
.and_then(|m| m.get("max_tokens"))
.and_then(|v| v.as_u64());
let max_tokens = user_max_tokens
.map(|user_val| {
if self.config.max_tokens_per_job == 0 {
// Config is "unlimited": use the user-supplied value directly.
user_val
} else {
std::cmp::min(user_val, self.config.max_tokens_per_job)
}
})
.unwrap_or(self.config.max_tokens_per_job);
// Apply both metadata and token budget in one closure (Issue #813: atomic update)
// Apply metadata if provided
if let Some(meta) = metadata {
self.context_manager
.update_context(job_id, |ctx| {
ctx.metadata = meta;
if max_tokens > 0 {
ctx.max_tokens = max_tokens;
}
})
.await?;
} else if max_tokens > 0 {
self.context_manager
.update_context(job_id, |ctx| {
ctx.max_tokens = max_tokens;
})
.await?;
}
@@ -474,9 +446,6 @@ impl Scheduler {
}
/// Execute a single tool as a subtask.
///
/// Performs scheduler-specific checks (approval, cancellation) then
/// delegates to the shared `execute_tool_with_safety` pipeline.
async fn execute_tool_task(
tools: Arc<ToolRegistry>,
context_manager: Arc<ContextManager>,
@@ -488,7 +457,7 @@ impl Scheduler {
) -> Result<TaskOutput, Error> {
let start = std::time::Instant::now();
// Get the tool for approval check
// Get the tool
let tool = tools.get(tool_name).await.ok_or_else(|| {
Error::Tool(crate::error::ToolError::NotFound {
name: tool_name.to_string(),
@@ -505,7 +474,6 @@ impl Scheduler {
.into());
}
// Scheduler-specific approval check
let requirement = tool.requires_approval(&params);
let blocked =
ApprovalContext::is_blocked_or_default(&approval_context, tool_name, requirement);
@@ -516,23 +484,41 @@ impl Scheduler {
.into());
}
// Delegate to shared tool execution pipeline
let output_str = crate::tools::execute::execute_tool_with_safety(
&tools, &safety, tool_name, &params, &job_ctx,
)
.await?;
// Parse back to Value for TaskOutput; this should be infallible given
// `execute_tool_with_safety` uses `serde_json::to_string_pretty`, but if it
// ever fails we surface a clear error instead of silently changing types.
let result_value: serde_json::Value = serde_json::from_str(&output_str).map_err(|e| {
Error::Tool(crate::error::ToolError::ExecutionFailed {
// Validate tool parameters
let validation = safety.validator().validate_tool_params(&params);
if !validation.is_valid {
let details = validation
.errors
.iter()
.map(|e| format!("{}: {}", e.field, e.message))
.collect::<Vec<_>>()
.join("; ");
return Err(crate::error::ToolError::InvalidParameters {
name: tool_name.to_string(),
reason: format!("Failed to parse tool output as JSON: {}", e),
})
})?;
reason: format!("Invalid tool parameters: {}", details),
}
.into());
}
Ok(TaskOutput::new(result_value, start.elapsed()))
// Execute with per-tool timeout
let tool_timeout = tool.execution_timeout();
let result =
tokio::time::timeout(tool_timeout, async { tool.execute(params, &job_ctx).await })
.await
.map_err(|_| {
Error::Tool(crate::error::ToolError::Timeout {
name: tool_name.to_string(),
timeout: tool_timeout,
})
})?
.map_err(|e| {
Error::Tool(crate::error::ToolError::ExecutionFailed {
name: tool_name.to_string(),
reason: e.to_string(),
})
})?;
Ok(TaskOutput::new(result.result, start.elapsed()))
}
/// Stop a running job.
@@ -697,140 +683,8 @@ impl Scheduler {
mod tests {
use super::*;
use crate::config::SafetyConfig;
use crate::llm::{
CompletionRequest, CompletionResponse, LlmError, LlmProvider, ToolCompletionRequest,
ToolCompletionResponse,
};
use crate::safety::SafetyLayer;
use crate::tools::{ApprovalRequirement, Tool, ToolError, ToolOutput};
use rust_decimal_macros::dec;
/// Minimal LLM provider stub for scheduler tests that don't exercise LLM calls.
struct StubLlm;
#[async_trait::async_trait]
impl LlmProvider for StubLlm {
fn model_name(&self) -> &str {
"stub"
}
fn cost_per_token(&self) -> (rust_decimal::Decimal, rust_decimal::Decimal) {
(dec!(0), dec!(0))
}
async fn complete(&self, _req: CompletionRequest) -> Result<CompletionResponse, LlmError> {
Err(LlmError::RequestFailed {
provider: "stub".into(),
reason: "not implemented".into(),
})
}
async fn complete_with_tools(
&self,
_req: ToolCompletionRequest,
) -> Result<ToolCompletionResponse, LlmError> {
Err(LlmError::RequestFailed {
provider: "stub".into(),
reason: "not implemented".into(),
})
}
}
/// Create a Scheduler for token-budget tests. The LLM stub will fail if a
/// worker actually tries to call it, but `dispatch_job` sets the token
/// budget *before* spawning the worker so we can inspect the context
/// immediately after dispatch.
fn make_test_scheduler(max_tokens_per_job: u64) -> Scheduler {
let config = AgentConfig {
name: "test".to_string(),
max_parallel_jobs: 5,
job_timeout: std::time::Duration::from_secs(30),
stuck_threshold: std::time::Duration::from_secs(300),
repair_check_interval: std::time::Duration::from_secs(3600),
max_repair_attempts: 0,
use_planning: false,
session_idle_timeout: std::time::Duration::from_secs(3600),
allow_local_tools: true,
max_cost_per_day_cents: None,
max_actions_per_hour: None,
max_tool_iterations: 10,
auto_approve_tools: true,
default_timezone: "UTC".to_string(),
max_tokens_per_job,
};
let cm = Arc::new(ContextManager::new(5));
let llm: Arc<dyn LlmProvider> = Arc::new(StubLlm);
let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
max_output_length: 100_000,
injection_check_enabled: false,
}));
let tools = Arc::new(ToolRegistry::new());
let hooks = Arc::new(HookRegistry::default());
Scheduler::new(config, cm, llm, safety, tools, None, hooks)
}
#[tokio::test]
async fn test_dispatch_job_caps_user_max_tokens() {
let sched = make_test_scheduler(1000);
let meta = serde_json::json!({ "max_tokens": 5000 });
let job_id = sched
.dispatch_job("user1", "test", "desc", Some(meta))
.await
.unwrap();
let ctx = sched.context_manager.get_context(job_id).await.unwrap();
assert_eq!(ctx.max_tokens, 1000, "should cap at configured limit");
}
#[tokio::test]
async fn test_dispatch_job_unlimited_config_preserves_user_tokens() {
let sched = make_test_scheduler(0); // 0 = unlimited
let meta = serde_json::json!({ "max_tokens": 5000 });
let job_id = sched
.dispatch_job("user1", "test", "desc", Some(meta))
.await
.unwrap();
let ctx = sched.context_manager.get_context(job_id).await.unwrap();
assert_eq!(
ctx.max_tokens, 5000,
"unlimited config should preserve user value"
);
}
#[tokio::test]
async fn test_dispatch_job_no_user_tokens_uses_config() {
let sched = make_test_scheduler(2000);
let job_id = sched
.dispatch_job("user1", "test", "desc", None)
.await
.unwrap();
let ctx = sched.context_manager.get_context(job_id).await.unwrap();
assert_eq!(
ctx.max_tokens, 2000,
"should use config default when no user value"
);
}
#[tokio::test]
async fn test_dispatch_job_atomic_metadata_and_tokens() {
let sched = make_test_scheduler(10_000);
let meta = serde_json::json!({
"max_tokens": 3000,
"custom_key": "custom_value"
});
let job_id = sched
.dispatch_job("user1", "test", "desc", Some(meta))
.await
.unwrap();
let ctx = sched.context_manager.get_context(job_id).await.unwrap();
assert_eq!(ctx.max_tokens, 3000, "should use user value within limit");
assert_eq!(
ctx.metadata.get("custom_key").and_then(|v| v.as_str()),
Some("custom_value"),
"metadata should be set atomically with token budget"
);
}
#[test]
fn test_scheduler_creation() {
+9 -7
View File
@@ -334,21 +334,22 @@ impl RepairTask {
// Check for stuck jobs
let stuck_jobs = self.repair.detect_stuck_jobs().await;
for job in stuck_jobs {
tracing::info!("Attempting to repair stuck job {}", job.job_id);
match self.repair.repair_stuck_job(&job).await {
Ok(RepairResult::Success { message }) => {
tracing::info!(job = %job.job_id, status = "success", "Stuck job repair completed: {}", message);
tracing::info!("Repair succeeded: {}", message);
}
Ok(RepairResult::Retry { message }) => {
tracing::debug!(job = %job.job_id, status = "retry", "Stuck job repair needs retry: {}", message);
tracing::warn!("Repair needs retry: {}", message);
}
Ok(RepairResult::Failed { message }) => {
tracing::error!(job = %job.job_id, status = "failed", "Stuck job repair failed: {}", message);
tracing::error!("Repair failed: {}", message);
}
Ok(RepairResult::ManualRequired { message }) => {
tracing::warn!(job = %job.job_id, status = "manual", "Stuck job repair requires manual intervention: {}", message);
tracing::warn!("Manual intervention needed: {}", message);
}
Err(e) => {
tracing::error!(job = %job.job_id, "Stuck job repair error: {}", e);
tracing::error!("Repair error: {}", e);
}
}
}
@@ -356,12 +357,13 @@ impl RepairTask {
// Check for broken tools
let broken_tools = self.repair.detect_broken_tools().await;
for tool in broken_tools {
tracing::info!("Attempting to repair broken tool: {}", tool.name);
match self.repair.repair_broken_tool(&tool).await {
Ok(result) => {
tracing::debug!(tool = %tool.name, status = "completed", "Tool repair completed: {:?}", result);
tracing::info!("Tool repair result: {:?}", result);
}
Err(e) => {
tracing::error!(tool = %tool.name, "Tool repair error: {}", e);
tracing::error!("Tool repair error: {}", e);
}
}
}
+24 -52
View File
@@ -113,13 +113,6 @@ impl Agent {
thread_id: Uuid,
content: &str,
) -> Result<SubmissionResult, Error> {
tracing::debug!(
message_id = %message.id,
thread_id = %thread_id,
content_len = content.len(),
"Processing user input"
);
// First check thread state without holding lock during I/O
let thread_state = {
let sess = session.lock().await;
@@ -130,41 +123,19 @@ impl Agent {
thread.state
};
tracing::debug!(
message_id = %message.id,
thread_id = %thread_id,
thread_state = ?thread_state,
"Checked thread state"
);
// Check thread state
match thread_state {
ThreadState::Processing => {
tracing::warn!(
message_id = %message.id,
thread_id = %thread_id,
"Thread is processing, rejecting new input"
);
return Ok(SubmissionResult::error(
"Turn in progress. Use /interrupt to cancel.",
));
}
ThreadState::AwaitingApproval => {
tracing::warn!(
message_id = %message.id,
thread_id = %thread_id,
"Thread awaiting approval, rejecting new input"
);
return Ok(SubmissionResult::error(
"Waiting for approval. Use /interrupt to cancel.",
));
}
ThreadState::Completed => {
tracing::warn!(
message_id = %message.id,
thread_id = %thread_id,
"Thread completed, rejecting new input"
);
return Ok(SubmissionResult::error(
"Thread completed. Use /thread new.",
));
@@ -298,20 +269,9 @@ impl Agent {
};
// Persist user message to DB immediately so it survives crashes
tracing::debug!(
message_id = %message.id,
thread_id = %thread_id,
"Persisting user message to DB"
);
self.persist_user_message(thread_id, &message.user_id, effective_content)
.await;
tracing::debug!(
message_id = %message.id,
thread_id = %thread_id,
"User message persisted, starting agentic loop"
);
// Send thinking status
let _ = self
.channels
@@ -852,12 +812,19 @@ impl Agent {
// Sanitize tool result, then record the cleaned version in the
// thread. Must happen before auth intercept check which may return early.
let is_tool_error = tool_result.is_err();
let (result_content, _) = crate::tools::execute::process_tool_result(
self.safety(),
&pending.tool_name,
&pending.tool_call_id,
&tool_result,
);
let result_content = match &tool_result {
Ok(output) => {
let sanitized = self
.safety()
.sanitize_tool_output(&pending.tool_name, output);
self.safety().wrap_for_llm(
&pending.tool_name,
&sanitized.content,
sanitized.was_modified,
)
}
Err(e) => format!("Error: {}", e),
};
// Record sanitized result in thread
{
@@ -1097,12 +1064,17 @@ impl Agent {
// Sanitize first, then record the cleaned version in thread.
// Must happen before auth detection which may set deferred_auth.
let is_deferred_error = deferred_result.is_err();
let (deferred_content, _) = crate::tools::execute::process_tool_result(
self.safety(),
&tc.name,
&tc.id,
&deferred_result,
);
let deferred_content = match &deferred_result {
Ok(output) => {
let sanitized = self.safety().sanitize_tool_output(&tc.name, output);
self.safety().wrap_for_llm(
&tc.name,
&sanitized.content,
sanitized.was_modified,
)
}
Err(e) => format!("Error: {}", e),
};
// Record sanitized result in thread
{
File diff suppressed because it is too large Load Diff
+204 -55
View File
@@ -77,7 +77,10 @@ pub struct AppBuilder {
llm_override: Option<Arc<dyn LlmProvider>>,
// Backend-specific handles needed by secrets store
handles: Option<crate::db::DatabaseHandles>,
#[cfg(feature = "postgres")]
pg_pool: Option<deadpool_postgres::Pool>,
#[cfg(feature = "libsql")]
libsql_db: Option<Arc<libsql::Database>>,
}
impl AppBuilder {
@@ -102,7 +105,10 @@ impl AppBuilder {
db: None,
secrets_store: None,
llm_override: None,
handles: None,
#[cfg(feature = "postgres")]
pg_pool: None,
#[cfg(feature = "libsql")]
libsql_db: None,
}
}
@@ -131,10 +137,71 @@ impl AppBuilder {
return Ok(());
}
let (db, handles) = crate::db::connect_with_handles(&self.config.database)
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
self.handles = Some(handles);
let db: Arc<dyn Database> = match self.config.database.backend {
#[cfg(feature = "libsql")]
crate::config::DatabaseBackend::LibSql => {
use crate::db::Database as _;
use crate::db::libsql::LibSqlBackend;
use secrecy::ExposeSecret as _;
let default_path = crate::config::default_libsql_path();
let db_path = self
.config
.database
.libsql_path
.as_deref()
.unwrap_or(&default_path);
let backend = if let Some(ref url) = self.config.database.libsql_url {
let token =
self.config
.database
.libsql_auth_token
.as_ref()
.ok_or_else(|| {
anyhow::anyhow!(
"LIBSQL_AUTH_TOKEN is required when LIBSQL_URL is set"
)
})?;
LibSqlBackend::new_remote_replica(db_path, url, token.expose_secret()).await?
} else {
LibSqlBackend::new_local(db_path).await?
};
backend.run_migrations().await?;
tracing::info!("libSQL database connected and migrations applied");
#[cfg(feature = "libsql")]
{
self.libsql_db = Some(backend.shared_db());
}
Arc::new(backend) as Arc<dyn Database>
}
#[cfg(feature = "postgres")]
_ => {
use crate::db::Database as _;
let pg = crate::db::postgres::PgBackend::new(&self.config.database)
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
pg.run_migrations()
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
tracing::info!("PostgreSQL database connected and migrations applied");
#[cfg(feature = "postgres")]
{
self.pg_pool = Some(pg.pool());
}
Arc::new(pg) as Arc<dyn Database>
}
#[cfg(not(feature = "postgres"))]
_ => {
anyhow::bail!(
"No database backend available. Enable 'postgres' or 'libsql' feature."
);
}
};
// Post-init: migrate disk config, reload config from DB, attach session, cleanup
if let Err(e) = crate::bootstrap::migrate_disk_to_db(db.as_ref(), "default").await {
@@ -145,7 +212,7 @@ impl AppBuilder {
match Config::from_db_with_toml(db.as_ref(), "default", toml_path).await {
Ok(db_config) => {
self.config = db_config;
tracing::debug!("Configuration reloaded from database");
tracing::info!("Configuration reloaded from database");
}
Err(e) => {
tracing::warn!(
@@ -184,7 +251,10 @@ impl AppBuilder {
crate::config::inject_os_credentials();
// Consume unused handles
self.handles.take();
#[cfg(feature = "libsql")]
{
self.libsql_db.take();
}
// Re-resolve only the LLM config with OS credentials.
let store: Option<&(dyn crate::db::SettingsStore + Sync)> =
@@ -208,16 +278,35 @@ impl AppBuilder {
Ok(c) => Arc::new(c),
Err(e) => {
tracing::warn!("Failed to initialize secrets crypto: {}", e);
self.handles.take();
#[cfg(feature = "libsql")]
{
self.libsql_db.take();
}
return Ok(());
}
};
// Fallback covers the no-database path where `init_database` returned
// early before populating `self.handles`.
let empty_handles = crate::db::DatabaseHandles::default();
let handles = self.handles.as_ref().unwrap_or(&empty_handles);
let store = crate::secrets::create_secrets_store(crypto, handles);
let store: Option<Arc<dyn SecretsStore + Send + Sync>> = None;
#[cfg(feature = "libsql")]
let store = store.or_else(|| {
self.libsql_db.take().map(|db| {
Arc::new(crate::secrets::LibSqlSecretsStore::new(
db,
Arc::clone(&crypto),
)) as Arc<dyn SecretsStore + Send + Sync>
})
});
#[cfg(feature = "postgres")]
let store = store.or_else(|| {
self.pg_pool.as_ref().map(|pool| {
Arc::new(crate::secrets::PostgresSecretsStore::new(
pool.clone(),
Arc::clone(&crypto),
)) as Arc<dyn SecretsStore + Send + Sync>
})
});
if let Some(ref secrets) = store {
// Inject LLM API keys from encrypted storage
@@ -274,7 +363,7 @@ impl AppBuilder {
anyhow::Error,
> {
let safety = Arc::new(SafetyLayer::new(&self.config.safety));
tracing::debug!("Safety layer initialized");
tracing::info!("Safety layer initialized");
// Initialize tool registry with credential injection support
let credential_registry = Arc::new(SharedCredentialRegistry::new());
@@ -361,7 +450,7 @@ impl AppBuilder {
tools
.register_builder_tool(llm.clone(), Some(self.config.builder.to_builder_config()))
.await;
tracing::debug!("Builder mode enabled");
tracing::info!("Builder mode enabled");
}
Ok((safety, tools, embeddings, workspace))
@@ -383,7 +472,9 @@ impl AppBuilder {
),
anyhow::Error,
> {
use crate::tools::mcp::config::load_mcp_servers_from_db;
use crate::tools::mcp::{
McpClient, McpTransport, config::load_mcp_servers_from_db, is_authenticated,
};
use crate::tools::wasm::{WasmToolLoader, load_dev_tools};
let mcp_session_manager = Arc::new(McpSessionManager::new());
@@ -419,7 +510,7 @@ impl AppBuilder {
match loader.load_from_dir(&wasm_config.tools_dir).await {
Ok(results) => {
if !results.loaded.is_empty() {
tracing::debug!(
tracing::info!(
"Loaded {} WASM tools from {}",
results.loaded.len(),
wasm_config.tools_dir.display()
@@ -442,7 +533,7 @@ impl AppBuilder {
Ok(results) => {
dev_loaded_tool_names.extend(results.loaded.iter().cloned());
if !dev_loaded_tool_names.is_empty() {
tracing::debug!(
tracing::info!(
"Loaded {} dev WASM tools from build artifacts",
dev_loaded_tool_names.len()
);
@@ -474,10 +565,7 @@ impl AppBuilder {
Ok(servers) => {
let enabled: Vec<_> = servers.enabled_servers().cloned().collect();
if !enabled.is_empty() {
tracing::debug!(
"Loading {} configured MCP server(s)...",
enabled.len()
);
tracing::info!("Loading {} configured MCP server(s)...", enabled.len());
}
let mut join_set = tokio::task::JoinSet::new();
@@ -490,24 +578,95 @@ impl AppBuilder {
join_set.spawn(async move {
let server_name = server.name.clone();
let client = match crate::tools::mcp::create_client_from_config(
server,
&mcp_sm,
&pm,
secrets,
"default",
)
.await
{
Ok(c) => c,
Err(e) => {
let client: McpClient = match server.effective_transport() {
crate::tools::mcp::config::EffectiveTransport::Stdio {
command,
args,
env,
} => {
match pm
.spawn_stdio(
&server_name,
command,
args.to_vec(),
env.clone(),
)
.await
{
Ok(transport) => McpClient::new_with_transport(
&server_name,
transport as Arc<dyn McpTransport>,
None,
secrets,
"default",
Some(server),
),
Err(e) => {
tracing::warn!(
"Failed to spawn stdio MCP server '{}': {}",
server_name,
e
);
return;
}
}
}
#[cfg(unix)]
crate::tools::mcp::config::EffectiveTransport::Unix {
socket_path,
} => {
match crate::tools::mcp::unix_transport::UnixMcpTransport::connect(
&server_name,
socket_path,
)
.await
{
Ok(transport) => McpClient::new_with_transport(
&server_name,
Arc::new(transport) as Arc<dyn McpTransport>,
None,
secrets,
"default",
Some(server),
),
Err(e) => {
tracing::warn!(
"Failed to connect to Unix MCP server '{}': {}",
server_name,
e
);
return;
}
}
}
#[cfg(not(unix))]
crate::tools::mcp::config::EffectiveTransport::Unix { .. } => {
tracing::warn!(
"Failed to create MCP client for '{}': {}",
server_name,
e
"Unix socket transport is not supported on this platform (server '{}')",
server_name
);
return;
}
crate::tools::mcp::config::EffectiveTransport::Http => {
if let Some(ref secrets) = secrets {
let has_tokens =
is_authenticated(&server, secrets, "default")
.await;
if has_tokens || server.requires_auth() {
McpClient::new_authenticated(
server,
Arc::clone(&mcp_sm),
Arc::clone(secrets),
"default",
)
} else {
McpClient::new_with_config(server)
}
} else {
McpClient::new_with_config(server)
}
}
};
match client.list_tools().await {
@@ -518,7 +677,7 @@ impl AppBuilder {
for tool in tool_impls {
tools.register(tool).await;
}
tracing::debug!(
tracing::info!(
"Loaded {} tools from MCP server '{}'",
tool_count,
server_name
@@ -572,14 +731,14 @@ impl AppBuilder {
let (dev_loaded_tool_names, _) = 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() {
let catalog_entries = match crate::registry::RegistryCatalog::load_or_embedded() {
Ok(catalog) => {
let entries: Vec<_> = catalog
.all()
.iter()
.map(|m| m.to_registry_entry())
.collect();
tracing::debug!(
tracing::info!(
count = entries.len(),
"Loaded registry catalog entries for extension discovery"
);
@@ -591,15 +750,6 @@ impl AppBuilder {
}
};
// Append builtin entries (e.g. channel-relay integrations) so they appear
// in the web UI's available extensions list.
let builtin = crate::extensions::registry::builtin_entries();
for entry in builtin {
if !catalog_entries.iter().any(|e| e.name == entry.name) {
catalog_entries.push(entry);
}
}
// Create extension manager. Use ephemeral in-memory secrets if no
// persistent store is configured (listing/install/activate still work).
let ext_secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync> = if let Some(ref s) =
@@ -617,7 +767,6 @@ impl AppBuilder {
let extension_manager = {
let manager = Arc::new(ExtensionManager::new(
Arc::clone(&mcp_session_manager),
Arc::clone(&mcp_process_manager),
ext_secrets,
Arc::clone(tools),
Some(Arc::clone(hooks)),
@@ -630,7 +779,7 @@ impl AppBuilder {
catalog_entries.clone(),
));
tools.register_extension_tools(Arc::clone(&manager));
tracing::debug!("Extension manager initialized with in-chat discovery tools");
tracing::info!("Extension manager initialized with in-chat discovery tools");
Some(manager)
};
@@ -701,7 +850,7 @@ impl AppBuilder {
let import_path = std::path::Path::new(&import_dir);
match ws.import_from_directory(import_path).await {
Ok(count) if count > 0 => {
tracing::debug!("Imported {} workspace file(s) from {}", count, import_dir);
tracing::info!("Imported {} workspace file(s) from {}", count, import_dir);
}
Ok(_) => {}
Err(e) => {
@@ -726,7 +875,7 @@ impl AppBuilder {
tokio::spawn(async move {
match ws_bg.backfill_embeddings().await {
Ok(count) if count > 0 => {
tracing::debug!("Backfilled embeddings for {} chunks", count);
tracing::info!("Backfilled embeddings for {} chunks", count);
}
Ok(_) => {}
Err(e) => {
@@ -743,7 +892,7 @@ impl AppBuilder {
.with_installed_dir(self.config.skills.installed_dir.clone());
let loaded = registry.discover_all().await;
if !loaded.is_empty() {
tracing::debug!("Loaded {} skill(s): {}", loaded.len(), loaded.join(", "));
tracing::info!("Loaded {} skill(s): {}", loaded.len(), loaded.join(", "));
}
let registry = Arc::new(std::sync::RwLock::new(registry));
let catalog = crate::skills::catalog::shared_catalog();
@@ -761,7 +910,7 @@ impl AppBuilder {
},
));
tracing::debug!(
tracing::info!(
"Tool registry initialized with {} total tools",
tools.count()
);
-156
View File
@@ -198,58 +198,6 @@ pub fn save_bootstrap_env_to(path: &std::path::Path, vars: &[(&str, &str)]) -> s
Ok(())
}
/// Update or add multiple variables in `~/.ironclaw/.env`, preserving existing content.
///
/// Like `upsert_bootstrap_var` but batched — replaces lines for any key in `vars`
/// and preserves all other existing lines. Use this instead of `save_bootstrap_env`
/// when you want to update specific keys without destroying user-added variables.
pub fn upsert_bootstrap_vars(vars: &[(&str, &str)]) -> std::io::Result<()> {
upsert_bootstrap_vars_to(&ironclaw_env_path(), vars)
}
/// Update or add multiple variables at an arbitrary path (testable variant).
pub fn upsert_bootstrap_vars_to(
path: &std::path::Path,
vars: &[(&str, &str)],
) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let keys_being_written: std::collections::HashSet<&str> =
vars.iter().map(|(k, _)| *k).collect();
let existing = match std::fs::read_to_string(path) {
Ok(contents) => contents,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e),
};
let mut result = String::new();
for line in existing.lines() {
// Extract key from lines matching `KEY=...`
let is_overwritten = line
.split_once('=')
.map(|(k, _)| keys_being_written.contains(k.trim()))
.unwrap_or(false);
if !is_overwritten {
result.push_str(line);
result.push('\n');
}
}
// Append all new key=value pairs
for (key, value) in vars {
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
result.push_str(&format!("{}=\"{}\"\n", key, escaped));
}
std::fs::write(path, &result)?;
restrict_file_permissions(path)?;
Ok(())
}
/// Update or add a single variable in `~/.ironclaw/.env`, preserving existing content.
///
/// Unlike `save_bootstrap_env` (which overwrites the entire file), this
@@ -1289,108 +1237,4 @@ INJECTED="pwned"#;
let lock = PidLock::acquire_at(pid_path).unwrap();
drop(lock);
}
#[test]
fn upsert_bootstrap_vars_preserves_unknown_keys() {
let dir = tempdir().unwrap();
let env_path = dir.path().join(".env");
// Simulate a user-edited .env with custom vars
let initial =
"HTTP_HOST=\"0.0.0.0\"\nDATABASE_BACKEND=\"postgres\"\nCUSTOM_VAR=\"keep_me\"\n";
std::fs::write(&env_path, initial).unwrap();
// Upsert wizard vars — should preserve HTTP_HOST and CUSTOM_VAR
let vars = [("DATABASE_BACKEND", "libsql"), ("LLM_BACKEND", "openai")];
upsert_bootstrap_vars_to(&env_path, &vars).unwrap();
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert_eq!(
parsed.len(),
4,
"should have 4 vars (2 preserved + 2 upserted)"
);
// User-added vars must be preserved
assert!(
parsed
.iter()
.any(|(k, v)| k == "HTTP_HOST" && v == "0.0.0.0"),
"HTTP_HOST must be preserved"
);
assert!(
parsed
.iter()
.any(|(k, v)| k == "CUSTOM_VAR" && v == "keep_me"),
"CUSTOM_VAR must be preserved"
);
// Wizard vars must be updated/added
assert!(
parsed
.iter()
.any(|(k, v)| k == "DATABASE_BACKEND" && v == "libsql"),
"DATABASE_BACKEND must be updated to libsql"
);
assert!(
parsed
.iter()
.any(|(k, v)| k == "LLM_BACKEND" && v == "openai"),
"LLM_BACKEND must be added"
);
// Now update LLM_BACKEND and verify HTTP_HOST still preserved
let vars2 = [("LLM_BACKEND", "anthropic")];
upsert_bootstrap_vars_to(&env_path, &vars2).unwrap();
let parsed2: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert_eq!(
parsed2.len(),
4,
"should still have 4 vars after second upsert"
);
assert!(
parsed2
.iter()
.any(|(k, v)| k == "HTTP_HOST" && v == "0.0.0.0"),
"HTTP_HOST must still be preserved after second upsert"
);
assert!(
parsed2
.iter()
.any(|(k, v)| k == "LLM_BACKEND" && v == "anthropic"),
"LLM_BACKEND must be updated to anthropic"
);
}
#[test]
fn upsert_bootstrap_vars_creates_file_if_missing() {
let dir = tempdir().unwrap();
let env_path = dir.path().join("subdir").join(".env");
// File doesn't exist yet
assert!(!env_path.exists());
let vars = [("DATABASE_BACKEND", "libsql")];
upsert_bootstrap_vars_to(&env_path, &vars).unwrap();
assert!(env_path.exists());
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert_eq!(parsed.len(), 1);
assert_eq!(
parsed[0],
("DATABASE_BACKEND".to_string(), "libsql".to_string())
);
}
}
+2 -21
View File
@@ -344,28 +344,9 @@ pub trait Channel: Send + Sync {
}
}
/// Trait for channels that support hot-secret-swapping during SIGHUP reload.
///
/// This allows channels to update authentication credentials without restarting,
/// enabling zero-downtime configuration reloads. Channels that don't support
/// secret updates can simply not implement this trait.
#[async_trait]
pub trait ChannelSecretUpdater: Send + Sync {
/// Update the secret for this channel.
///
/// Called during SIGHUP configuration reload. Implementation should:
/// - Apply the new secret atomically
/// - Not fail the entire reload if secret update fails
/// - Log appropriate errors/info messages
///
/// The secret is optional (may be None if secret is no longer configured).
async fn update_secret(&self, new_secret: Option<secrecy::SecretString>);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::credentials::TEST_REDACT_SECRET_123;
/// Stub tool that marks `"value"` as sensitive.
struct SecretTool;
@@ -395,7 +376,7 @@ mod tests {
#[test]
fn tool_completed_redacts_sensitive_params_on_failure() {
let params = serde_json::json!({"name": "api_key", "value": TEST_REDACT_SECRET_123});
let params = serde_json::json!({"name": "api_key", "value": "sk-secret-123"});
let err: Result<String, crate::error::Error> =
Err(crate::error::ToolError::ExecutionFailed {
name: "secret_save".into(),
@@ -430,7 +411,7 @@ mod tests {
param_str
);
assert!(
!param_str.contains(TEST_REDACT_SECRET_123),
!param_str.contains("sk-secret-123"),
"raw secret should not appear: {}",
param_str
);
+13 -202
View File
@@ -10,7 +10,7 @@ use axum::{
response::IntoResponse,
routing::{get, post},
};
use secrecy::{ExposeSecret, SecretString};
use secrecy::ExposeSecret;
use serde::{Deserialize, Serialize};
use subtle::ConstantTimeEq;
use tokio::sync::{RwLock, mpsc, oneshot};
@@ -18,8 +18,7 @@ use tokio_stream::wrappers::ReceiverStream;
use uuid::Uuid;
use crate::channels::{
AttachmentKind, Channel, ChannelSecretUpdater, IncomingAttachment, IncomingMessage,
MessageStream, OutgoingResponse,
AttachmentKind, Channel, IncomingAttachment, IncomingMessage, MessageStream, OutgoingResponse,
};
use crate::config::HttpConfig;
use crate::error::ChannelError;
@@ -30,16 +29,13 @@ pub struct HttpChannel {
state: Arc<HttpChannelState>,
}
pub struct HttpChannelState {
struct HttpChannelState {
/// Sender for incoming messages.
tx: RwLock<Option<mpsc::Sender<IncomingMessage>>>,
/// Pending responses keyed by message ID.
pending_responses: RwLock<std::collections::HashMap<Uuid, oneshot::Sender<String>>>,
/// Expected webhook secret for authentication (if configured).
/// Stored in a separate Arc<RwLock<>> to avoid contending with other state operations.
/// Rarely changes (only on SIGHUP), so isolated from hot-path state accesses.
/// Uses SecretString to prevent accidental logging and memory dump exposure.
webhook_secret: Arc<RwLock<Option<SecretString>>>,
webhook_secret: Option<String>,
/// Fixed user ID for this HTTP channel.
user_id: String,
/// Rate limiting state.
@@ -52,14 +48,6 @@ struct RateLimitState {
request_count: u32,
}
impl HttpChannelState {
/// Update the webhook secret in-place without restarting the listener.
/// Called during SIGHUP to hot-swap credentials.
pub async fn update_secret(&self, new_secret: Option<SecretString>) {
*self.webhook_secret.write().await = new_secret;
}
}
/// Maximum JSON body size for webhook requests (15 MB, to support base64 image attachments
/// with ~33% overhead from base64 encoding).
const MAX_BODY_BYTES: usize = 15 * 1024 * 1024;
@@ -79,7 +67,7 @@ impl HttpChannel {
let webhook_secret = config
.webhook_secret
.as_ref()
.map(|s| SecretString::from(s.expose_secret().to_string()));
.map(|s| s.expose_secret().to_string());
let user_id = config.user_id.clone();
Self {
@@ -87,7 +75,7 @@ impl HttpChannel {
state: Arc::new(HttpChannelState {
tx: RwLock::new(None),
pending_responses: RwLock::new(std::collections::HashMap::new()),
webhook_secret: Arc::new(RwLock::new(webhook_secret)),
webhook_secret,
user_id,
rate_limit: tokio::sync::Mutex::new(RateLimitState {
window_start: std::time::Instant::now(),
@@ -114,16 +102,6 @@ impl HttpChannel {
pub fn addr(&self) -> (&str, u16) {
(&self.config.host, self.config.port)
}
/// Return a shared handle to the channel state for out-of-band updates.
pub fn shared_state(&self) -> Arc<HttpChannelState> {
Arc::clone(&self.state)
}
/// Update the webhook secret in-place without restarting the listener.
pub async fn update_secret(&self, new_secret: Option<SecretString>) {
self.state.update_secret(new_secret).await;
}
}
#[derive(Debug, Deserialize)]
@@ -223,10 +201,9 @@ async fn webhook_handler(
});
// Validate secret if configured
if let Some(ref expected_secret) = *state.webhook_secret.read().await {
let expected_bytes = expected_secret.expose_secret().as_bytes();
if let Some(ref expected_secret) = state.webhook_secret {
match &req.secret {
Some(provided) if bool::from(provided.as_bytes().ct_eq(expected_bytes)) => {
Some(provided) if bool::from(provided.as_bytes().ct_eq(expected_secret.as_bytes())) => {
// Secret matches, continue
}
Some(_) => {
@@ -395,14 +372,9 @@ async fn process_message(
None
};
// Clone sender while holding read lock, then release lock before async send.
// This prevents blocking other webhook handlers during the async I/O.
let tx = {
let guard = state.tx.read().await;
guard.as_ref().cloned()
};
if let Some(tx) = tx {
// Send message to the channel
let tx_guard = state.tx.read().await;
if let Some(tx) = tx_guard.as_ref() {
if tx.send(msg).await.is_err() {
return (
StatusCode::INTERNAL_SERVER_ERROR,
@@ -423,6 +395,7 @@ async fn process_message(
}),
);
}
drop(tx_guard);
// Wait for response if requested
let response = if let Some(rx) = response_rx {
@@ -455,7 +428,7 @@ impl Channel for HttpChannel {
}
async fn start(&self) -> Result<MessageStream, ChannelError> {
if self.state.webhook_secret.read().await.is_none() {
if self.state.webhook_secret.is_none() {
return Err(ChannelError::StartupFailed {
name: "http".to_string(),
reason: "HTTP webhook secret is required (set HTTP_WEBHOOK_SECRET)".to_string(),
@@ -502,16 +475,6 @@ impl Channel for HttpChannel {
}
}
/// Implement secret update for HTTP channel state.
/// This allows SIGHUP handler to update secrets generically via the trait.
#[async_trait]
impl ChannelSecretUpdater for HttpChannelState {
async fn update_secret(&self, new_secret: Option<SecretString>) {
*self.webhook_secret.write().await = new_secret;
tracing::info!("HTTP webhook secret updated");
}
}
#[cfg(test)]
mod tests {
use axum::body::Body;
@@ -599,156 +562,4 @@ mod tests {
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_update_secret_hot_swap() {
let channel = test_channel(Some("old-secret"));
let _stream = channel.start().await.unwrap();
let app1 = channel.routes();
// Request with old-secret should succeed
let body_old = serde_json::json!({
"content": "hello",
"secret": "old-secret"
});
let req1 = Request::builder()
.method("POST")
.uri("/webhook")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body_old).unwrap()))
.unwrap();
let resp1 = app1.oneshot(req1).await.unwrap();
assert_eq!(
resp1.status(),
StatusCode::OK,
"old secret should work initially"
);
// Update secret to new-secret
channel
.update_secret(Some(SecretString::from("new-secret".to_string())))
.await;
let app2 = channel.routes();
// Request with old-secret should fail
let req2 = Request::builder()
.method("POST")
.uri("/webhook")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body_old).unwrap()))
.unwrap();
let resp2 = app2.oneshot(req2).await.unwrap();
assert_eq!(
resp2.status(),
StatusCode::UNAUTHORIZED,
"old secret should fail after update"
);
let app3 = channel.routes();
// Request with new-secret should succeed
let body_new = serde_json::json!({
"content": "hello",
"secret": "new-secret"
});
let req3 = Request::builder()
.method("POST")
.uri("/webhook")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body_new).unwrap()))
.unwrap();
let resp3 = app3.oneshot(req3).await.unwrap();
assert_eq!(
resp3.status(),
StatusCode::OK,
"new secret should work after update"
);
}
#[tokio::test]
async fn test_concurrent_requests_during_secret_update() {
use std::sync::Arc as StdArc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
let channel = test_channel(Some("initial-secret"));
let _stream = channel.start().await.unwrap();
let app = channel.routes();
// Counters for request outcomes
let success_count = StdArc::new(AtomicUsize::new(0));
let mut handles = vec![];
// Spawn 5 concurrent tasks that keep making requests with the initial secret
for i in 0..5 {
let app = app.clone();
let success = StdArc::clone(&success_count);
let handle = tokio::spawn(async move {
let body = serde_json::json!({
"content": format!("test-{}", i),
"secret": "initial-secret"
});
let req = Request::builder()
.method("POST")
.uri("/webhook")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
if resp.status() == StatusCode::OK {
success.fetch_add(1, Ordering::SeqCst);
}
});
handles.push(handle);
}
// Update secret mid-flight (tests that RwLock allows readers while writer holds lock)
tokio::time::sleep(Duration::from_millis(5)).await;
channel
.update_secret(Some(SecretString::from("updated-secret".to_string())))
.await;
// Spawn 5 more tasks that use the new secret
for i in 5..10 {
let app = app.clone();
let success = StdArc::clone(&success_count);
let handle = tokio::spawn(async move {
let body = serde_json::json!({
"content": format!("test-{}", i),
"secret": "updated-secret"
});
let req = Request::builder()
.method("POST")
.uri("/webhook")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
if resp.status() == StatusCode::OK {
success.fetch_add(1, Ordering::SeqCst);
}
});
handles.push(handle);
}
// Wait for all tasks to complete
for handle in handles {
let _ = handle.await;
}
// Verify all requests succeeded with their respective secrets
assert_eq!(
success_count.load(Ordering::SeqCst),
10,
"All concurrent requests should succeed with correct secrets after update"
);
}
}
+2 -39
View File
@@ -56,17 +56,6 @@ impl ChannelManager {
/// the agent loop.
pub async fn hot_add(&self, channel: Box<dyn Channel>) -> Result<(), ChannelError> {
let name = channel.name().to_string();
// Shut down any existing channel with the same name to avoid parallel consumers.
// The old forwarding task will stop when the channel's stream ends after shutdown.
{
let channels = self.channels.read().await;
if let Some(existing) = channels.get(&name) {
tracing::debug!(channel = %name, "Shutting down existing channel before hot-add replacement");
let _ = existing.shutdown().await;
}
}
let stream = channel.start().await?;
// Register for respond/broadcast/send_status
@@ -86,7 +75,7 @@ impl ChannelManager {
break;
}
}
tracing::debug!(channel = %name, "Hot-added channel stream ended");
tracing::info!(channel = %name, "Hot-added channel stream ended");
});
Ok(())
@@ -103,7 +92,7 @@ impl ChannelManager {
for (name, channel) in channels.iter() {
match channel.start().await {
Ok(stream) => {
tracing::debug!("Started channel: {}", name);
tracing::info!("Started channel: {}", name);
streams.push(stream);
}
Err(e) => {
@@ -348,30 +337,4 @@ mod tests {
let msg = stream.next().await.expect("stream ended");
assert_eq!(msg.content, "background alert");
}
#[tokio::test]
async fn test_hot_add_replaces_existing_channel() {
// Regression: hot_add must shut down the existing channel before replacing it,
// to prevent duplicate SSE consumers from running in parallel.
let manager = ChannelManager::new();
let (stub1, _tx1) = StubChannel::new("relay");
manager.add(Box::new(stub1)).await;
let mut stream = manager.start_all().await.expect("start_all");
// Hot-add a replacement channel with the same name
let (stub2, tx2) = StubChannel::new("relay");
manager.hot_add(Box::new(stub2)).await.expect("hot_add");
// Send through the new channel — should arrive in the merged stream
tx2.send(IncomingMessage::new("relay", "u1", "from new"))
.await
.expect("send");
let msg = stream.next().await.expect("stream");
assert_eq!(msg.content, "from new");
// Verify only one channel entry exists
let channels = manager.channels.read().await;
assert_eq!(channels.len(), 1);
assert!(channels.contains_key("relay"));
}
}
+3 -4
View File
@@ -30,7 +30,6 @@
mod channel;
mod http;
mod manager;
pub mod relay;
mod repl;
mod signal;
pub mod wasm;
@@ -38,10 +37,10 @@ pub mod web;
mod webhook_server;
pub use channel::{
AttachmentKind, Channel, ChannelSecretUpdater, IncomingAttachment, IncomingMessage,
MessageStream, OutgoingResponse, StatusUpdate,
AttachmentKind, Channel, IncomingAttachment, IncomingMessage, MessageStream, OutgoingResponse,
StatusUpdate,
};
pub use http::{HttpChannel, HttpChannelState};
pub use http::HttpChannel;
pub use manager::ChannelManager;
pub use repl::ReplChannel;
pub use signal::SignalChannel;
-642
View File
@@ -1,642 +0,0 @@
//! Channel trait implementation for channel-relay SSE streams.
//!
//! `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).
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use tokio::sync::{RwLock, mpsc};
use crate::channels::relay::client::{RelayClient, RelayError};
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
use crate::error::ChannelError;
/// Default channel name for the Slack relay integration.
pub const DEFAULT_RELAY_NAME: &str = "slack-relay";
/// The messaging provider backing a relay channel.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RelayProvider {
Slack,
}
impl RelayProvider {
/// Provider string used in proxy API routes and metadata.
pub fn as_str(&self) -> &'static str {
match self {
Self::Slack => "slack",
}
}
/// The default channel name for this provider.
pub fn channel_name(&self) -> &'static str {
match self {
Self::Slack => DEFAULT_RELAY_NAME,
}
}
}
/// Channel implementation that connects to a channel-relay SSE stream.
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,
}
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,
) -> Self {
Self::new_with_provider(
client,
RelayProvider::Slack,
stream_token,
team_id,
instance_id,
user_id,
)
}
/// Create a new relay channel with a specific provider.
pub fn new_with_provider(
client: RelayClient,
provider: RelayProvider,
stream_token: String,
team_id: String,
instance_id: String,
user_id: String,
) -> 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,
}
}
/// 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
}
/// Build a provider-appropriate proxy body for sending a message.
fn build_send_body(
&self,
channel_id: &str,
text: &str,
thread_id: Option<&str>,
) -> (String, serde_json::Value) {
match self.provider {
RelayProvider::Slack => {
let mut body = serde_json::json!({
"channel": channel_id,
"text": text,
});
if let Some(tid) = thread_id {
body["thread_ts"] = serde_json::Value::String(tid.to_string());
}
("chat.postMessage".to_string(), body)
}
}
}
/// Send a message via the provider proxy.
async fn proxy_send(
&self,
team_id: &str,
method: &str,
body: serde_json::Value,
) -> Result<serde_json::Value, RelayError> {
self.client
.proxy_provider(
self.provider.as_str(),
team_id,
method,
body,
Some(&self.instance_id),
)
.await
}
}
#[async_trait]
impl Channel for RelayChannel {
fn name(&self) -> &str {
self.provider.channel_name()
}
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);
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!(
event_type = %event.event_type,
sender = %event.sender_id,
channel = %event.channel_id,
provider = %provider_str,
"Relay: received message from {}", provider_str
);
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;
}
}
// 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
);
break;
}
tracing::warn!(
backoff_ms = backoff_ms,
failures = consecutive_failures,
"Relay SSE stream ended, reconnecting..."
);
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");
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"
);
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");
}
}
// 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"
);
}
}
}
}
});
*self.reconnect_handle.write().await = Some(handle);
let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
Ok(Box::pin(stream))
}
async fn respond(
&self,
msg: &IncomingMessage,
response: OutgoingResponse,
) -> Result<(), ChannelError> {
let channel_name = self.name().to_string();
let metadata = &msg.metadata;
let team_id = metadata
.get("team_id")
.and_then(|v| v.as_str())
.unwrap_or(&self.team_id);
let channel_id = metadata
.get("channel_id")
.and_then(|v| v.as_str())
.ok_or_else(|| ChannelError::SendFailed {
name: channel_name.clone(),
reason: "Missing channel_id in message metadata".to_string(),
})?;
// Determine thread_id from response or metadata
let thread_id = response
.thread_id
.as_deref()
.or_else(|| metadata.get("thread_id").and_then(|v| v.as_str()));
let (method, body) = self.build_send_body(channel_id, &response.content, thread_id);
self.proxy_send(team_id, &method, body)
.await
.map_err(|e| ChannelError::SendFailed {
name: channel_name,
reason: e.to_string(),
})?;
Ok(())
}
/// Status updates are not forwarded to messaging providers to avoid noise.
async fn send_status(
&self,
_status: StatusUpdate,
_metadata: &serde_json::Value,
) -> Result<(), ChannelError> {
Ok(())
}
async fn broadcast(
&self,
target: &str,
response: OutgoingResponse,
) -> Result<(), ChannelError> {
let channel_name = self.name().to_string();
// Determine thread_id from response or metadata
let thread_id = response
.thread_id
.as_deref()
.or_else(|| response.metadata.get("thread_ts").and_then(|v| v.as_str()));
let (method, body) = self.build_send_body(target, &response.content, thread_id);
self.proxy_send(&self.team_id, &method, body)
.await
.map_err(|e| ChannelError::SendFailed {
name: channel_name,
reason: e.to_string(),
})?;
Ok(())
}
async fn health_check(&self) -> Result<(), ChannelError> {
self.client
.list_connections(&self.instance_id)
.await
.map_err(|_| ChannelError::HealthCheckFailed {
name: self.name().to_string(),
})?;
Ok(())
}
fn conversation_context(&self, metadata: &serde_json::Value) -> HashMap<String, String> {
let mut ctx = HashMap::new();
if let Some(sender) = metadata.get("sender_name").and_then(|v| v.as_str()) {
ctx.insert("sender".to_string(), sender.to_string());
}
if let Some(sender_id) = metadata.get("sender_id").and_then(|v| v.as_str()) {
ctx.insert("sender_uuid".to_string(), sender_id.to_string());
}
if let Some(channel_id) = metadata.get("channel_id").and_then(|v| v.as_str()) {
ctx.insert("group".to_string(), channel_id.to_string());
}
ctx.insert("platform".to_string(), self.provider.as_str().to_string());
ctx
}
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();
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_client() -> RelayClient {
RelayClient::new(
"http://localhost:3001".into(),
secrecy::SecretString::from("key".to_string()),
30,
)
.expect("client")
}
#[test]
fn relay_channel_name() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
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 metadata = serde_json::json!({
"sender_name": "bob",
"sender_id": "U123",
"channel_id": "C456",
});
let ctx = channel.conversation_context(&metadata);
assert_eq!(ctx.get("sender"), Some(&"bob".to_string()));
assert_eq!(ctx.get("sender_uuid"), Some(&"U123".to_string()));
assert_eq!(ctx.get("platform"), Some(&"slack".to_string()));
}
#[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",
"sender_id": "U789",
"sender_name": "alice",
"event_type": "direct_message",
"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 (method, body) = channel.build_send_body("C456", "hello", Some("1234567.890"));
assert_eq!(method, "chat.postMessage");
assert_eq!(body["channel"], "C456");
assert_eq!(body["text"], "hello");
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));
}
#[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);
assert_eq!(channel.max_consecutive_failures, 10);
}
#[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);
}
#[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.
}
}
-549
View File
@@ -1,549 +0,0 @@
//! 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.
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 {
pub const MESSAGE: &str = "message";
pub const DIRECT_MESSAGE: &str = "direct_message";
pub const MENTION: &str = "mention";
}
/// A parsed SSE event from the channel-relay stream.
///
/// Field names match the channel-relay `ChannelEvent` struct exactly.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelEvent {
/// Unique event ID.
#[serde(default)]
pub id: String,
/// Event type enum from channel-relay (e.g., "direct_message", "message", "mention").
pub event_type: String,
/// Provider (e.g., "slack").
#[serde(default)]
pub provider: String,
/// Team/workspace ID (called `provider_scope` in channel-relay).
#[serde(alias = "team_id", default)]
pub provider_scope: String,
/// Channel or DM conversation ID.
#[serde(default)]
pub channel_id: String,
/// Sender user ID.
#[serde(default)]
pub sender_id: String,
/// Sender display name.
#[serde(default)]
pub sender_name: Option<String>,
/// Message text content (called `content` in channel-relay).
#[serde(alias = "text", default)]
pub content: Option<String>,
/// Thread ID (for threaded replies, called `thread_id` in channel-relay).
#[serde(alias = "thread_ts", default)]
pub thread_id: Option<String>,
/// Full raw event data.
#[serde(default)]
pub raw: serde_json::Value,
/// Event timestamp (ISO 8601 from channel-relay).
#[serde(default)]
pub timestamp: Option<String>,
}
impl ChannelEvent {
/// Get the team_id (provider_scope).
pub fn team_id(&self) -> &str {
&self.provider_scope
}
/// Get the message text content.
pub fn text(&self) -> &str {
self.content.as_deref().unwrap_or("")
}
/// Get the sender name or fallback to sender_id.
pub fn display_name(&self) -> &str {
self.sender_name.as_deref().unwrap_or(&self.sender_id)
}
/// Check if this is a message-like event that should be forwarded to the agent.
pub fn is_message(&self) -> bool {
matches!(
self.event_type.as_str(),
event_types::MESSAGE | event_types::DIRECT_MESSAGE | event_types::MENTION
)
}
}
/// Connection info returned by list_connections.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Connection {
pub provider: String,
pub team_id: String,
pub team_name: Option<String>,
pub connected: bool,
}
/// HTTP client for the channel-relay service.
#[derive(Clone)]
pub struct RelayClient {
http: reqwest::Client,
base_url: String,
api_key: SecretString,
}
impl RelayClient {
/// Create a new relay client.
pub fn new(
base_url: String,
api_key: SecretString,
request_timeout_secs: u64,
) -> Result<Self, RelayError> {
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(request_timeout_secs))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|e| RelayError::Network(format!("Failed to build HTTP client: {e}")))?;
Ok(Self {
http,
base_url: base_url.trim_end_matches('/').to_string(),
api_key,
})
}
/// Initiate Slack OAuth flow via channel-relay.
///
/// 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> {
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),
])
.send()
.await
.map_err(|e| RelayError::Network(e.to_string()))?;
let status = resp.status();
if status.is_redirection() {
let location = resp
.headers()
.get(reqwest::header::LOCATION)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.ok_or_else(|| {
RelayError::Protocol("Redirect response missing Location header".to_string())
})?;
Ok(location)
} else if status.is_success() {
// Some relay implementations return the URL in JSON body instead
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| RelayError::Protocol(e.to_string()))?;
body.get("auth_url")
.or_else(|| body.get("url"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| RelayError::Protocol("Response missing auth_url field".to_string()))
} else {
let body = resp.text().await.unwrap_or_default();
Err(RelayError::Api {
status: status.as_u16(),
message: body,
})
}
}
/// Connect to the SSE event stream.
///
/// 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(
&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,
) -> Result<String, RelayError> {
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,
}))
.send()
.await
.map_err(|e| RelayError::Network(e.to_string()))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(RelayError::Api {
status: status.as_u16(),
message: body,
});
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| RelayError::Protocol(e.to_string()))?;
body.get("stream_token")
.or_else(|| body.get("token"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| RelayError::Protocol("Response missing stream_token field".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 resp = self
.http
.post(format!("{}/proxy/{}/{}", self.base_url, provider, method))
.header("X-API-Key", self.api_key.expose_secret())
.query(&query)
.json(&body)
.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,
});
}
resp.json()
.await
.map_err(|e| RelayError::Protocol(e.to_string()))
}
/// 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())
.query(&[("instance_id", instance_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,
});
}
resp.json()
.await
.map_err(|e| RelayError::Protocol(e.to_string()))
}
}
/// 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 {
#[error("Network error: {0}")]
Network(String),
#[error("API error (HTTP {status}): {message}")]
Api { status: u16, message: String },
#[error("Protocol error: {0}")]
Protocol(String),
#[error("Stream token expired")]
TokenExpired,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn channel_event_deserialize_minimal() {
let json = r#"{"event_type": "message", "content": "hello"}"#;
let event: ChannelEvent = serde_json::from_str(json).expect("parse failed");
assert_eq!(event.event_type, "message");
assert_eq!(event.text(), "hello");
assert!(event.provider_scope.is_empty());
}
#[test]
fn channel_event_deserialize_relay_format() {
// Matches the actual channel-relay ChannelEvent serialization format.
let json = r#"{
"id": "evt_123",
"event_type": "direct_message",
"provider": "slack",
"provider_scope": "T123",
"channel_id": "D456",
"sender_id": "U789",
"sender_name": "bob",
"content": "hi there",
"thread_id": "1234567890.123456",
"raw": {},
"timestamp": "2026-03-09T21:00:00Z"
}"#;
let event: ChannelEvent = serde_json::from_str(json).expect("parse failed");
assert_eq!(event.provider, "slack");
assert_eq!(event.team_id(), "T123");
assert_eq!(event.display_name(), "bob");
assert_eq!(event.thread_id, Some("1234567890.123456".to_string()));
assert!(event.is_message());
}
#[test]
fn channel_event_is_message() {
let make = |et: &str| ChannelEvent {
id: String::new(),
event_type: et.to_string(),
provider: String::new(),
provider_scope: String::new(),
channel_id: String::new(),
sender_id: String::new(),
sender_name: None,
content: None,
thread_id: None,
raw: serde_json::Value::Null,
timestamp: None,
};
assert!(make("message").is_message());
assert!(make("direct_message").is_message());
assert!(make("mention").is_message());
assert!(!make("reaction").is_message());
}
#[test]
fn connection_deserialize() {
let json = r#"{"provider": "slack", "team_id": "T123", "team_name": "My Team", "connected": true}"#;
let conn: Connection = serde_json::from_str(json).expect("parse failed");
assert_eq!(conn.provider, "slack");
assert!(conn.connected);
}
#[test]
fn relay_error_display() {
let err = RelayError::Network("timeout".into());
assert_eq!(err.to_string(), "Network error: timeout");
let err = RelayError::Api {
status: 401,
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]
fn event_type_constants_match_is_message() {
let make = |et: &str| ChannelEvent {
id: String::new(),
event_type: et.to_string(),
provider: String::new(),
provider_scope: String::new(),
channel_id: String::new(),
sender_id: String::new(),
sender_name: None,
content: None,
thread_id: None,
raw: serde_json::Value::Null,
timestamp: None,
};
assert!(make(event_types::MESSAGE).is_message());
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");
}
}
-12
View File
@@ -1,12 +0,0 @@
//! 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.
pub mod channel;
pub mod client;
pub use channel::{DEFAULT_RELAY_NAME, RelayChannel};
pub use client::RelayClient;
+6 -37
View File
@@ -184,32 +184,18 @@ impl WasmChannelLoader {
/// └── telegram.capabilities.json
/// ```
pub async fn load_from_dir(&self, dir: &Path) -> Result<LoadResults, WasmChannelError> {
match fs::metadata(dir).await {
Ok(meta) if meta.is_dir() => {}
Ok(_) => {
return Err(WasmChannelError::Io(std::io::Error::new(
std::io::ErrorKind::NotADirectory,
format!("{} is not a directory", dir.display()),
)));
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(LoadResults::default());
}
Err(e) => return Err(WasmChannelError::Io(e)),
if !dir.is_dir() {
return Err(WasmChannelError::Io(std::io::Error::new(
std::io::ErrorKind::NotADirectory,
format!("{} is not a directory", dir.display()),
)));
}
let mut results = LoadResults::default();
// Collect all .wasm entries first, then load in parallel
let mut channel_entries = Vec::new();
// Handle TOCTOU: if read_dir fails with NotFound, treat as empty
let mut entries = match fs::read_dir(dir).await {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(LoadResults::default());
}
Err(e) => return Err(WasmChannelError::Io(e)),
};
let mut entries = fs::read_dir(dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
@@ -500,21 +486,4 @@ mod tests {
let result = loader.load_from_files("", &wasm_path, None).await;
assert!(result.is_err());
}
#[tokio::test]
async fn load_from_dir_returns_empty_when_dir_missing() {
let config = WasmChannelRuntimeConfig::for_testing();
let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap());
let loader = WasmChannelLoader::new(runtime, Arc::new(PairingStore::new()), None);
let dir = TempDir::new().unwrap();
let missing = dir.path().join("nonexistent_channels_dir");
let results = loader.load_from_dir(&missing).await;
// Must succeed with empty results, not error
let results = results.expect("missing dir should return Ok, not Err");
assert!(results.loaded.is_empty());
assert!(results.errors.is_empty());
}
}
-2
View File
@@ -86,7 +86,6 @@ mod loader;
mod router;
mod runtime;
mod schema;
pub mod setup;
pub(crate) mod signature;
#[allow(dead_code)]
pub(crate) mod storage;
@@ -106,5 +105,4 @@ pub use runtime::{PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeC
pub use schema::{
ChannelCapabilitiesFile, ChannelConfig, SecretSetupSchema, SetupSchema, WebhookSchema,
};
pub use setup::{WasmChannelSetup, inject_channel_credentials, setup_wasm_channels};
pub use wrapper::{HttpResponse, SharedWasmChannel, WasmChannel};
-350
View File
@@ -1,350 +0,0 @@
//! WASM channel setup and credential injection.
//!
//! Encapsulates the logic for loading WASM channels, registering their
//! webhook routes, and injecting credentials from the secrets store.
use std::collections::HashSet;
use std::sync::Arc;
use crate::channels::wasm::{
LoadedChannel, RegisteredEndpoint, SharedWasmChannel, WasmChannel, WasmChannelLoader,
WasmChannelRouter, WasmChannelRuntime, WasmChannelRuntimeConfig, create_wasm_channel_router,
};
use crate::config::Config;
use crate::db::Database;
use crate::extensions::ExtensionManager;
use crate::pairing::PairingStore;
use crate::secrets::SecretsStore;
/// Result of WASM channel setup.
pub struct WasmChannelSetup {
pub channels: Vec<(String, Box<dyn crate::channels::Channel>)>,
pub channel_names: Vec<String>,
pub webhook_routes: Option<axum::Router>,
/// Runtime objects needed for hot-activation via ExtensionManager.
pub wasm_channel_runtime: Arc<WasmChannelRuntime>,
pub pairing_store: Arc<PairingStore>,
pub wasm_channel_router: Arc<WasmChannelRouter>,
}
/// Load WASM channels and register their webhook routes.
pub async fn setup_wasm_channels(
config: &Config,
secrets_store: &Option<Arc<dyn SecretsStore + Send + Sync>>,
extension_manager: Option<&Arc<ExtensionManager>>,
database: Option<&Arc<dyn Database>>,
) -> Option<WasmChannelSetup> {
let runtime = match WasmChannelRuntime::new(WasmChannelRuntimeConfig::default()) {
Ok(r) => Arc::new(r),
Err(e) => {
tracing::warn!("Failed to initialize WASM channel runtime: {}", e);
return None;
}
};
let pairing_store = Arc::new(PairingStore::new());
let settings_store: Option<Arc<dyn crate::db::SettingsStore>> =
database.map(|db| Arc::clone(db) as Arc<dyn crate::db::SettingsStore>);
let mut loader = WasmChannelLoader::new(
Arc::clone(&runtime),
Arc::clone(&pairing_store),
settings_store,
);
if let Some(secrets) = secrets_store {
loader = loader.with_secrets_store(Arc::clone(secrets));
}
let results = match loader
.load_from_dir(&config.channels.wasm_channels_dir)
.await
{
Ok(r) => r,
Err(e) => {
tracing::warn!("Failed to scan WASM channels directory: {}", e);
return None;
}
};
let wasm_router = Arc::new(WasmChannelRouter::new());
let mut channels: Vec<(String, Box<dyn crate::channels::Channel>)> = Vec::new();
let mut channel_names: Vec<String> = Vec::new();
for loaded in results.loaded {
let (name, channel) = register_channel(loaded, config, secrets_store, &wasm_router).await;
channel_names.push(name.clone());
channels.push((name, channel));
}
for (path, err) in &results.errors {
tracing::warn!("Failed to load WASM channel {}: {}", path.display(), err);
}
// Always create webhook routes (even with no channels loaded) so that
// channels hot-added at runtime can receive webhooks without a restart.
let webhook_routes = {
Some(create_wasm_channel_router(
Arc::clone(&wasm_router),
extension_manager.map(Arc::clone),
))
};
Some(WasmChannelSetup {
channels,
channel_names,
webhook_routes,
wasm_channel_runtime: runtime,
pairing_store,
wasm_channel_router: wasm_router,
})
}
/// Process a single loaded WASM channel: retrieve secrets, inject config,
/// register with the router, and set up signing keys and credentials.
async fn register_channel(
loaded: LoadedChannel,
config: &Config,
secrets_store: &Option<Arc<dyn SecretsStore + Send + Sync>>,
wasm_router: &Arc<WasmChannelRouter>,
) -> (String, Box<dyn crate::channels::Channel>) {
let channel_name = loaded.name().to_string();
tracing::info!("Loaded WASM channel: {}", channel_name);
let secret_name = loaded.webhook_secret_name();
let sig_key_secret_name = loaded.signature_key_secret_name();
let hmac_secret_name = loaded.hmac_secret_name();
let webhook_secret = if let Some(secrets) = secrets_store {
secrets
.get_decrypted("default", &secret_name)
.await
.ok()
.map(|s| s.expose().to_string())
} else {
None
};
let secret_header = loaded.webhook_secret_header().map(|s| s.to_string());
let webhook_path = format!("/webhook/{}", channel_name);
let endpoints = vec![RegisteredEndpoint {
channel_name: channel_name.clone(),
path: webhook_path,
methods: vec!["POST".to_string()],
require_secret: webhook_secret.is_some(),
}];
let channel_arc = Arc::new(loaded.channel);
// Inject runtime config (tunnel URL, webhook secret, owner_id).
{
let mut config_updates = std::collections::HashMap::new();
if let Some(ref tunnel_url) = config.tunnel.public_url {
config_updates.insert(
"tunnel_url".to_string(),
serde_json::Value::String(tunnel_url.clone()),
);
}
if let Some(ref secret) = webhook_secret {
config_updates.insert(
"webhook_secret".to_string(),
serde_json::Value::String(secret.clone()),
);
}
if let Some(&owner_id) = config
.channels
.wasm_channel_owner_ids
.get(channel_name.as_str())
{
config_updates.insert("owner_id".to_string(), serde_json::json!(owner_id));
}
if !config_updates.is_empty() {
channel_arc.update_config(config_updates).await;
tracing::info!(
channel = %channel_name,
has_tunnel = config.tunnel.public_url.is_some(),
has_webhook_secret = webhook_secret.is_some(),
"Injected runtime config into channel"
);
}
}
tracing::info!(
channel = %channel_name,
has_webhook_secret = webhook_secret.is_some(),
secret_header = ?secret_header,
"Registering channel with router"
);
wasm_router
.register(
Arc::clone(&channel_arc),
endpoints,
webhook_secret.clone(),
secret_header,
)
.await;
// Register Ed25519 signature key if declared in capabilities.
if let Some(ref sig_key_name) = sig_key_secret_name
&& let Some(secrets) = secrets_store
&& let Ok(key_secret) = secrets.get_decrypted("default", sig_key_name).await
{
match wasm_router
.register_signature_key(&channel_name, key_secret.expose())
.await
{
Ok(()) => {
tracing::info!(channel = %channel_name, "Registered Ed25519 signature key")
}
Err(e) => {
tracing::error!(channel = %channel_name, error = %e, "Invalid signature key in secrets store")
}
}
}
// Register HMAC signing secret if declared in capabilities.
if let Some(ref hmac_secret_name) = hmac_secret_name
&& let Some(secrets) = secrets_store
&& let Ok(secret) = secrets.get_decrypted("default", hmac_secret_name).await
{
wasm_router
.register_hmac_secret(&channel_name, secret.expose())
.await;
tracing::info!(channel = %channel_name, "Registered HMAC signing secret");
}
// Inject credentials from secrets store / environment.
match inject_channel_credentials(
&channel_arc,
secrets_store
.as_ref()
.map(|s| s.as_ref() as &dyn SecretsStore),
&channel_name,
)
.await
{
Ok(count) => {
if count > 0 {
tracing::info!(
channel = %channel_name,
credentials_injected = count,
"Channel credentials injected"
);
}
}
Err(e) => {
tracing::error!(
channel = %channel_name,
error = %e,
"Failed to inject channel credentials"
);
}
}
(channel_name, Box::new(SharedWasmChannel::new(channel_arc)))
}
/// Inject credentials for a channel based on naming convention.
///
/// Looks for secrets matching the pattern `{channel_name}_*` and injects them
/// as credential placeholders (e.g., `telegram_bot_token` -> `{TELEGRAM_BOT_TOKEN}`).
///
/// Falls back to environment variables starting with the uppercase channel name
/// prefix (e.g., `TELEGRAM_` for channel `telegram`) for missing credentials.
///
/// Returns the number of credentials injected.
pub async fn inject_channel_credentials(
channel: &Arc<WasmChannel>,
secrets: Option<&dyn SecretsStore>,
channel_name: &str,
) -> anyhow::Result<usize> {
if channel_name.trim().is_empty() {
return Ok(0);
}
let mut count = 0;
let mut injected_placeholders = HashSet::new();
// 1. Try injecting from persistent secrets store if available
if let Some(secrets) = secrets {
let all_secrets = secrets
.list("default")
.await
.map_err(|e| anyhow::anyhow!("Failed to list secrets: {}", e))?;
let prefix = format!("{}_", channel_name.to_ascii_lowercase());
for secret_meta in all_secrets {
if !secret_meta.name.to_ascii_lowercase().starts_with(&prefix) {
continue;
}
let decrypted = match secrets.get_decrypted("default", &secret_meta.name).await {
Ok(d) => d,
Err(e) => {
tracing::warn!(
secret = %secret_meta.name,
error = %e,
"Failed to decrypt secret for channel credential injection"
);
continue;
}
};
let placeholder = secret_meta.name.to_uppercase();
tracing::debug!(
channel = %channel_name,
secret = %secret_meta.name,
placeholder = %placeholder,
"Injecting credential"
);
channel
.set_credential(&placeholder, decrypted.expose().to_string())
.await;
injected_placeholders.insert(placeholder);
count += 1;
}
}
// 2. Fall back to environment variables for credentials not in the secrets store.
// Only env vars starting with the channel's uppercase prefix are allowed
// (e.g., TELEGRAM_ for channel "telegram") to prevent reading unrelated host
// credentials like AWS_SECRET_ACCESS_KEY.
let prefix = format!("{}_", channel_name.to_ascii_uppercase());
let caps = channel.capabilities();
if let Some(ref http_cap) = caps.tool_capabilities.http {
for cred_mapping in http_cap.credentials.values() {
let placeholder = cred_mapping.secret_name.to_uppercase();
if injected_placeholders.contains(&placeholder) {
continue;
}
if !placeholder.starts_with(&prefix) {
tracing::warn!(
channel = %channel_name,
placeholder = %placeholder,
"Ignoring non-prefixed credential placeholder in environment fallback"
);
continue;
}
if let Ok(env_value) = std::env::var(&placeholder)
&& !env_value.is_empty()
{
tracing::debug!(
channel = %channel_name,
placeholder = %placeholder,
"Injecting credential from environment variable"
);
channel.set_credential(&placeholder, env_value).await;
count += 1;
}
}
}
Ok(count)
}
+5 -8
View File
@@ -3059,7 +3059,6 @@ mod tests {
};
use crate::channels::wasm::wrapper::{HttpResponse, WasmChannel};
use crate::pairing::PairingStore;
use crate::testing::credentials::TEST_TELEGRAM_BOT_TOKEN;
use crate::tools::wasm::ResourceLimits;
fn create_test_channel() -> WasmChannel {
@@ -4010,7 +4009,7 @@ mod tests {
let mut creds = std::collections::HashMap::new();
creds.insert(
"TELEGRAM_BOT_TOKEN".to_string(),
TEST_TELEGRAM_BOT_TOKEN.to_string(),
"8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis".to_string(),
);
creds.insert("OTHER_SECRET".to_string(), "s3cret".to_string());
@@ -4023,15 +4022,13 @@ mod tests {
Arc::new(PairingStore::new()),
);
let error = format!(
"HTTP request failed: error sending request for url \
(https://api.telegram.org/bot{TEST_TELEGRAM_BOT_TOKEN}/getUpdates)"
);
let error = "HTTP request failed: error sending request for url \
(https://api.telegram.org/bot8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis/getUpdates)";
let redacted = store.redact_credentials(&error);
let redacted = store.redact_credentials(error);
assert!(
!redacted.contains(TEST_TELEGRAM_BOT_TOKEN),
!redacted.contains("8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis"),
"credential value should be redacted"
);
assert!(
+26 -27
View File
@@ -83,15 +83,14 @@ pub async fn auth_middleware(
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::credentials::{TEST_AUTH_SECRET_TOKEN, TEST_BEARER_TOKEN};
#[test]
fn test_auth_state_clone() {
let state = AuthState {
token: TEST_BEARER_TOKEN.to_string(),
token: "test-token".to_string(),
};
let cloned = state.clone();
assert_eq!(cloned.token, TEST_BEARER_TOKEN);
assert_eq!(cloned.token, "test-token");
}
use axum::Router;
@@ -121,10 +120,10 @@ mod tests {
#[tokio::test]
async fn test_valid_bearer_token_passes() {
let app = test_app(TEST_AUTH_SECRET_TOKEN);
let app = test_app("secret-token");
let req = Request::builder()
.uri("/api/chat/events")
.header("Authorization", format!("Bearer {TEST_AUTH_SECRET_TOKEN}"))
.header("Authorization", "Bearer secret-token")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
@@ -133,7 +132,7 @@ mod tests {
#[tokio::test]
async fn test_invalid_bearer_token_rejected() {
let app = test_app(TEST_AUTH_SECRET_TOKEN);
let app = test_app("secret-token");
let req = Request::builder()
.uri("/api/chat/events")
.header("Authorization", "Bearer wrong-token")
@@ -145,9 +144,9 @@ mod tests {
#[tokio::test]
async fn test_query_token_allowed_for_chat_events() {
let app = test_app(TEST_AUTH_SECRET_TOKEN);
let app = test_app("secret-token");
let req = Request::builder()
.uri(format!("/api/chat/events?token={TEST_AUTH_SECRET_TOKEN}"))
.uri("/api/chat/events?token=secret-token")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
@@ -156,9 +155,9 @@ mod tests {
#[tokio::test]
async fn test_query_token_allowed_for_logs_events() {
let app = test_app(TEST_AUTH_SECRET_TOKEN);
let app = test_app("secret-token");
let req = Request::builder()
.uri(format!("/api/logs/events?token={TEST_AUTH_SECRET_TOKEN}"))
.uri("/api/logs/events?token=secret-token")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
@@ -167,9 +166,9 @@ mod tests {
#[tokio::test]
async fn test_query_token_allowed_for_ws_upgrade() {
let app = test_app(TEST_AUTH_SECRET_TOKEN);
let app = test_app("secret-token");
let req = Request::builder()
.uri(format!("/api/chat/ws?token={TEST_AUTH_SECRET_TOKEN}"))
.uri("/api/chat/ws?token=secret-token")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
@@ -203,9 +202,9 @@ mod tests {
#[tokio::test]
async fn test_query_token_rejected_for_non_sse_get() {
let app = test_app(TEST_AUTH_SECRET_TOKEN);
let app = test_app("secret-token");
let req = Request::builder()
.uri(format!("/api/chat/history?token={TEST_AUTH_SECRET_TOKEN}"))
.uri("/api/chat/history?token=secret-token")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
@@ -214,10 +213,10 @@ mod tests {
#[tokio::test]
async fn test_query_token_rejected_for_post() {
let app = test_app(TEST_AUTH_SECRET_TOKEN);
let app = test_app("secret-token");
let req = Request::builder()
.method(Method::POST)
.uri(format!("/api/chat/send?token={TEST_AUTH_SECRET_TOKEN}"))
.uri("/api/chat/send?token=secret-token")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
@@ -226,7 +225,7 @@ mod tests {
#[tokio::test]
async fn test_query_token_invalid_rejected() {
let app = test_app(TEST_AUTH_SECRET_TOKEN);
let app = test_app("secret-token");
let req = Request::builder()
.uri("/api/chat/events?token=wrong-token")
.body(Body::empty())
@@ -237,7 +236,7 @@ mod tests {
#[tokio::test]
async fn test_no_auth_at_all_rejected() {
let app = test_app(TEST_AUTH_SECRET_TOKEN);
let app = test_app("secret-token");
let req = Request::builder()
.uri("/api/chat/events")
.body(Body::empty())
@@ -248,11 +247,11 @@ mod tests {
#[tokio::test]
async fn test_bearer_header_works_for_post() {
let app = test_app(TEST_AUTH_SECRET_TOKEN);
let app = test_app("secret-token");
let req = Request::builder()
.method(Method::POST)
.uri("/api/chat/send")
.header("Authorization", format!("Bearer {TEST_AUTH_SECRET_TOKEN}"))
.header("Authorization", "Bearer secret-token")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
@@ -261,10 +260,10 @@ mod tests {
#[tokio::test]
async fn test_bearer_prefix_case_insensitive() {
let app = test_app(TEST_AUTH_SECRET_TOKEN);
let app = test_app("secret-token");
let req = Request::builder()
.uri("/api/chat/events")
.header("Authorization", format!("bearer {TEST_AUTH_SECRET_TOKEN}"))
.header("Authorization", "bearer secret-token")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
@@ -273,10 +272,10 @@ mod tests {
#[tokio::test]
async fn test_bearer_prefix_mixed_case() {
let app = test_app(TEST_AUTH_SECRET_TOKEN);
let app = test_app("secret-token");
let req = Request::builder()
.uri("/api/chat/events")
.header("Authorization", format!("BEARER {TEST_AUTH_SECRET_TOKEN}"))
.header("Authorization", "BEARER secret-token")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
@@ -285,7 +284,7 @@ mod tests {
#[tokio::test]
async fn test_empty_bearer_token_rejected() {
let app = test_app(TEST_AUTH_SECRET_TOKEN);
let app = test_app("secret-token");
let req = Request::builder()
.uri("/api/chat/events")
.header("Authorization", "Bearer ")
@@ -297,10 +296,10 @@ mod tests {
#[tokio::test]
async fn test_token_with_whitespace_rejected() {
let app = test_app(TEST_AUTH_SECRET_TOKEN);
let app = test_app("secret-token");
let req = Request::builder()
.uri("/api/chat/events")
.header("Authorization", format!("Bearer {TEST_AUTH_SECRET_TOKEN}"))
.header("Authorization", "Bearer secret-token")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
+54 -78
View File
@@ -35,7 +35,6 @@ pub async fn chat_send_handler(
}
let msg_id = msg.id;
let thread_id = msg.thread_id.clone();
let tx_guard = state.msg_tx.read().await;
let tx = tx_guard.as_ref().ok_or((
@@ -50,13 +49,6 @@ pub async fn chat_send_handler(
)
})?;
tracing::debug!(
message_id = %msg_id,
thread_id = ?thread_id,
content_len = req.content.len(),
"Message queued to agent loop"
);
Ok((
StatusCode::ACCEPTED,
Json(SendMessageResponse {
@@ -271,6 +263,7 @@ pub async fn chat_history_handler(
))?;
let session = session_manager.get_or_create_session(&state.user_id).await;
let sess = session.lock().await;
let limit = query.limit.unwrap_or(50);
let before_cursor = query
@@ -288,12 +281,11 @@ pub async fn chat_history_handler(
})
.transpose()?;
// Find the thread (lock only briefly to get active_thread if needed)
// Find the thread
let thread_id = if let Some(ref tid) = query.thread_id {
Uuid::parse_str(tid)
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid thread_id".to_string()))?
} else {
let sess = session.lock().await;
sess.active_thread
.ok_or((StatusCode::NOT_FOUND, "No active thread".to_string()))?
};
@@ -306,11 +298,8 @@ pub async fn chat_history_handler(
.conversation_belongs_to_user(thread_id, &state.user_id)
.await
.unwrap_or(false);
if !owned {
let sess = session.lock().await;
if !sess.threads.contains_key(&thread_id) {
return Err((StatusCode::NOT_FOUND, "Thread not found".to_string()));
}
if !owned && !sess.threads.contains_key(&thread_id) {
return Err((StatusCode::NOT_FOUND, "Thread not found".to_string()));
}
}
@@ -335,60 +324,56 @@ pub async fn chat_history_handler(
}
// Try in-memory first (freshest data for active threads)
// Lock only when checking in-memory state
if let Some(thread) = sess.threads.get(&thread_id)
&& (!thread.turns.is_empty() || thread.pending_approval.is_some())
{
let sess = session.lock().await;
if let Some(thread) = sess.threads.get(&thread_id)
&& (!thread.turns.is_empty() || thread.pending_approval.is_some())
{
let turns: Vec<TurnInfo> = thread
.turns
.iter()
.map(|t| TurnInfo {
turn_number: t.turn_number,
user_input: t.user_input.clone(),
response: t.response.clone(),
state: format!("{:?}", t.state),
started_at: t.started_at.to_rfc3339(),
completed_at: t.completed_at.map(|dt| dt.to_rfc3339()),
tool_calls: t
.tool_calls
.iter()
.map(|tc| ToolCallInfo {
name: tc.name.clone(),
has_result: tc.result.is_some(),
has_error: tc.error.is_some(),
result_preview: tc.result.as_ref().map(|r| {
let s = match r {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
truncate_preview(&s, 500)
}),
error: tc.error.clone(),
})
.collect(),
})
.collect();
let turns: Vec<TurnInfo> = thread
.turns
.iter()
.map(|t| TurnInfo {
turn_number: t.turn_number,
user_input: t.user_input.clone(),
response: t.response.clone(),
state: format!("{:?}", t.state),
started_at: t.started_at.to_rfc3339(),
completed_at: t.completed_at.map(|dt| dt.to_rfc3339()),
tool_calls: t
.tool_calls
.iter()
.map(|tc| ToolCallInfo {
name: tc.name.clone(),
has_result: tc.result.is_some(),
has_error: tc.error.is_some(),
result_preview: tc.result.as_ref().map(|r| {
let s = match r {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
truncate_preview(&s, 500)
}),
error: tc.error.clone(),
})
.collect(),
})
.collect();
let pending_approval = thread
.pending_approval
.as_ref()
.map(|pa| PendingApprovalInfo {
request_id: pa.request_id.to_string(),
tool_name: pa.tool_name.clone(),
description: pa.description.clone(),
parameters: serde_json::to_string_pretty(&pa.parameters).unwrap_or_default(),
});
let pending_approval = thread
.pending_approval
.as_ref()
.map(|pa| PendingApprovalInfo {
request_id: pa.request_id.to_string(),
tool_name: pa.tool_name.clone(),
description: pa.description.clone(),
parameters: serde_json::to_string_pretty(&pa.parameters).unwrap_or_default(),
});
return Ok(Json(HistoryResponse {
thread_id,
turns,
has_more: false,
oldest_timestamp: None,
pending_approval,
}));
}
return Ok(Json(HistoryResponse {
thread_id,
turns,
has_more: false,
oldest_timestamp: None,
pending_approval,
}));
}
// Fall back to DB for historical threads not in memory (paginated)
@@ -430,6 +415,7 @@ pub async fn chat_threads_handler(
))?;
let session = session_manager.get_or_create_session(&state.user_id).await;
let sess = session.lock().await;
// Try DB first for persistent thread list
if let Some(ref store) = state.store {
@@ -479,22 +465,15 @@ pub async fn chat_threads_handler(
});
}
// Read active thread while holding minimal lock (just before return)
let active_thread = {
let sess = session.lock().await;
sess.active_thread
};
return Ok(Json(ThreadListResponse {
assistant_thread,
threads,
active_thread,
active_thread: sess.active_thread,
}));
}
}
// Fallback: in-memory only (no assistant thread without DB)
let sess = session.lock().await;
let mut sorted_threads: Vec<_> = sess.threads.values().collect();
sorted_threads.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
let threads: Vec<ThreadInfo> = sorted_threads
@@ -511,13 +490,10 @@ pub async fn chat_threads_handler(
})
.collect();
let active_thread = sess.active_thread;
drop(sess); // Explicit drop to release lock
Ok(Json(ThreadListResponse {
assistant_thread: None,
threads,
active_thread,
active_thread: sess.active_thread,
}))
}
+56 -9
View File
@@ -46,14 +46,6 @@ pub async fn extensions_list_handler(
} else {
"configured".to_string()
})
} else if ext.kind == crate::extensions::ExtensionKind::ChannelRelay {
Some(if ext.active {
"active".to_string()
} else if ext.authenticated {
"configured".to_string()
} else {
"installed".to_string()
})
} else {
None
};
@@ -111,7 +103,6 @@ pub async fn extensions_install_handler(
"mcp_server" => Some(crate::extensions::ExtensionKind::McpServer),
"wasm_tool" => Some(crate::extensions::ExtensionKind::WasmTool),
"wasm_channel" => Some(crate::extensions::ExtensionKind::WasmChannel),
"channel_relay" => Some(crate::extensions::ExtensionKind::ChannelRelay),
_ => None,
});
@@ -124,6 +115,62 @@ pub async fn extensions_install_handler(
}
}
pub async fn extensions_activate_handler(
State(state): State<Arc<GatewayState>>,
Path(name): Path<String>,
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
let ext_mgr = state.extension_manager.as_ref().ok_or((
StatusCode::NOT_IMPLEMENTED,
"Extension manager not available (secrets store required)".to_string(),
))?;
match ext_mgr.activate(&name).await {
Ok(result) => {
// Activation just loads the WASM module. Auth (OAuth/manual) is
// triggered separately via save_setup_secrets or the auth endpoint.
Ok(Json(ActionResponse::ok(result.message)))
}
Err(activate_err) => {
let err_str = activate_err.to_string();
let needs_auth = err_str.contains("authentication")
|| err_str.contains("401")
|| err_str.contains("Unauthorized");
if !needs_auth {
return Ok(Json(ActionResponse::fail(err_str)));
}
// Activation failed due to auth; try authenticating first.
match ext_mgr.auth(&name, None).await {
Ok(auth_result) if auth_result.is_authenticated() => {
// Auth succeeded, retry activation.
match ext_mgr.activate(&name).await {
Ok(result) => Ok(Json(ActionResponse::ok(result.message))),
Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))),
}
}
Ok(auth_result) => {
// Auth in progress (OAuth URL or awaiting manual token).
let mut resp = ActionResponse::fail(
auth_result
.instructions()
.map(String::from)
.unwrap_or_else(|| format!("'{}' requires authentication.", name)),
);
resp.auth_url = auth_result.auth_url().map(String::from);
resp.awaiting_token = Some(auth_result.is_awaiting_token());
resp.instructions = auth_result.instructions().map(String::from);
Ok(Json(resp))
}
Err(auth_err) => Ok(Json(ActionResponse::fail(format!(
"Authentication failed: {}",
auth_err
)))),
}
}
}
}
pub async fn extensions_remove_handler(
State(state): State<Arc<GatewayState>>,
Path(name): Path<String>,
+1 -15
View File
@@ -276,25 +276,11 @@ pub async fn jobs_cancel_handler(
})));
}
// Fall back to agent job cancellation: stop the worker via the scheduler
// (which updates the in-memory ContextManager AND aborts the task handle),
// then persist the status to the DB as a fallback.
// Fall back to agent job cancellation via DB status update.
if let Some(ref store) = state.store
&& let Ok(Some(job)) = store.get_job(job_id).await
{
if job.state.is_active() {
// Try to stop via scheduler (aborts the worker task + updates
// in-memory ContextManager). This is best-effort — the job may
// not be in the scheduler map if it already finished.
if let Some(ref slot) = state.scheduler
&& let Some(ref scheduler) = *slot.read().await
{
let _ = scheduler.stop(job_id).await;
}
// Always persist cancellation to the DB so the state is
// consistent even if the scheduler wasn't available or the
// job wasn't in its in-memory map.
store
.update_job_status(
job_id,
+49 -3
View File
@@ -27,7 +27,7 @@ pub async fn routines_list_handler(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let items: Vec<RoutineInfo> = routines.iter().map(RoutineInfo::from_routine).collect();
let items: Vec<RoutineInfo> = routines.iter().map(routine_to_info).collect();
Ok(Json(RoutineListResponse { routines: items }))
}
@@ -108,7 +108,6 @@ pub async fn routines_detail_handler(
status: format!("{:?}", run.status),
result_summary: run.result_summary.clone(),
tokens_used: run.tokens_used,
job_id: run.job_id,
})
.collect();
@@ -253,7 +252,6 @@ pub async fn routines_runs_handler(
status: format!("{:?}", run.status),
result_summary: run.result_summary.clone(),
tokens_used: run.tokens_used,
job_id: run.job_id,
})
.collect();
@@ -263,6 +261,54 @@ pub async fn routines_runs_handler(
})))
}
/// Convert a Routine to the trimmed RoutineInfo for list display.
fn routine_to_info(r: &crate::agent::routine::Routine) -> RoutineInfo {
let (trigger_type, trigger_summary) = match &r.trigger {
crate::agent::routine::Trigger::Cron { schedule, .. } => {
("cron".to_string(), format!("cron: {}", schedule))
}
crate::agent::routine::Trigger::Event {
pattern, channel, ..
} => {
let ch = channel.as_deref().unwrap_or("any");
("event".to_string(), format!("on {} /{}/", ch, pattern))
}
crate::agent::routine::Trigger::Webhook { path, .. } => {
let p = path.as_deref().unwrap_or("/");
("webhook".to_string(), format!("webhook: {}", p))
}
crate::agent::routine::Trigger::Manual => ("manual".to_string(), "manual only".to_string()),
};
let action_type = match &r.action {
crate::agent::routine::RoutineAction::Lightweight { .. } => "lightweight",
crate::agent::routine::RoutineAction::FullJob { .. } => "full_job",
};
let status = if !r.enabled {
"disabled"
} else if r.consecutive_failures > 0 {
"failing"
} else {
"active"
};
RoutineInfo {
id: r.id,
name: r.name.clone(),
description: r.description.clone(),
enabled: r.enabled,
trigger_type,
trigger_summary,
action_type: action_type.to_string(),
last_run_at: r.last_run_at.map(|dt| dt.to_rfc3339()),
next_fire_at: r.next_fire_at.map(|dt| dt.to_rfc3339()),
run_count: r.run_count,
consecutive_failures: r.consecutive_failures,
status: status.to_string(),
}
}
/// Map `RoutineError` variants to appropriate HTTP status codes.
fn routine_error_status(err: &RoutineError) -> StatusCode {
match err {
-2
View File
@@ -97,7 +97,6 @@ impl GatewayChannel {
skill_registry: None,
skill_catalog: None,
chat_rate_limiter: server::RateLimiter::new(30, 60),
oauth_rate_limiter: server::RateLimiter::new(10, 60),
registry_entries: Vec::new(),
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
@@ -134,7 +133,6 @@ impl GatewayChannel {
skill_registry: self.state.skill_registry.clone(),
skill_catalog: self.state.skill_catalog.clone(),
chat_rate_limiter: server::RateLimiter::new(30, 60),
oauth_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),
+65 -406
View File
@@ -28,7 +28,6 @@ use uuid::Uuid;
use crate::agent::SessionManager;
use crate::bootstrap::ironclaw_base_dir;
use crate::channels::IncomingMessage;
use crate::channels::relay::DEFAULT_RELAY_NAME;
use crate::channels::web::auth::{AuthState, auth_middleware};
use crate::channels::web::handlers::jobs::{
job_files_list_handler, job_files_read_handler, jobs_cancel_handler, jobs_detail_handler,
@@ -165,8 +164,6 @@ pub struct GatewayState {
pub scheduler: Option<crate::tools::builtin::SchedulerSlot>,
/// Rate limiter for chat endpoints (30 messages per 60 seconds).
pub chat_rate_limiter: RateLimiter,
/// Rate limiter for OAuth callback endpoints (10 requests per 60 seconds).
pub oauth_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>,
@@ -203,11 +200,7 @@ pub async fn start_server(
// Public routes (no auth)
let public = Router::new()
.route("/api/health", get(health_handler))
.route("/oauth/callback", get(oauth_callback_handler))
.route(
"/oauth/slack/callback",
get(slack_relay_oauth_callback_handler),
);
.route("/oauth/callback", get(oauth_callback_handler));
// Protected routes (require auth)
let auth_state = AuthState { token: auth_token };
@@ -377,7 +370,7 @@ pub async fn start_server(
if let Err(e) = axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = shutdown_rx.await;
tracing::debug!("Web gateway shutting down");
tracing::info!("Web gateway shutting down");
})
.await
{
@@ -613,208 +606,6 @@ async fn oauth_callback_handler(
axum::response::Html(html).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`.
async fn slack_relay_oauth_callback_handler(
State(state): State<Arc<GatewayState>>,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> impl IntoResponse {
// Rate limit
if !state.oauth_rate_limiter.check() {
return axum::response::Html(
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
<h2>Too Many Requests</h2>\
<p>Please try again later.</p>\
</body></html>"
.to_string(),
)
.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() {
let valid_team_id = team_id.len() <= 21
&& team_id.starts_with('T')
&& team_id[1..].chars().all(|c| c.is_ascii_alphanumeric());
if !valid_team_id {
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 provider: must be "slack" (only supported provider)
let provider = params
.get("provider")
.cloned()
.unwrap_or_else(|| "slack".into());
if provider != "slack" {
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();
}
let ext_mgr = match state.extension_manager.as_ref() {
Some(mgr) => mgr,
None => {
return axum::response::Html(
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
<h2>Error</h2><p>Extension manager not available.</p></body></html>"
.to_string(),
)
.into_response();
}
};
// Validate CSRF state parameter
let state_param = match params.get("state") {
Some(s) if !s.is_empty() && s.len() <= 128 => s.clone(),
_ => {
return axum::response::Html(
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
<h2>Error</h2><p>Invalid or expired authorization.</p></body></html>"
.to_string(),
)
.into_response();
}
};
let state_key = format!("relay:{}:oauth_state", DEFAULT_RELAY_NAME);
let stored_state = match ext_mgr
.secrets()
.get_decrypted(&state.user_id, &state_key)
.await
{
Ok(secret) => secret.expose().to_string(),
Err(_) => {
return axum::response::Html(
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
<h2>Error</h2><p>Invalid or expired authorization.</p></body></html>"
.to_string(),
)
.into_response();
}
};
if state_param != stored_state {
return axum::response::Html(
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
<h2>Error</h2><p>Invalid or expired authorization.</p></body></html>"
.to_string(),
)
.into_response();
}
// Delete the nonce (one-time use)
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))?;
// 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;
}
// Activate the relay channel
ext_mgr
.activate_stored_relay(DEFAULT_RELAY_NAME)
.await
.map_err(|e| format!("Failed to activate relay channel: {}", e))?;
Ok(())
}
.await;
let (success, message) = match &result {
Ok(()) => (true, "Slack connected successfully!".to_string()),
Err(e) => {
tracing::error!(error = %e, "Slack relay OAuth callback failed");
(
false,
"Connection failed. Check server logs for details.".to_string(),
)
}
};
// Broadcast SSE event to notify the web UI
state.sse.broadcast(SseEvent::AuthCompleted {
extension_name: DEFAULT_RELAY_NAME.to_string(),
success,
message: message.clone(),
});
if success {
axum::response::Html(
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
<h2>Slack Connected!</h2>\
<p>You can close this tab and return to IronClaw.</p>\
<script>window.close()</script>\
</body></html>"
.to_string(),
)
.into_response()
} else {
axum::response::Html(format!(
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
<h2>Connection Failed</h2>\
<p>{}</p>\
</body></html>",
message
))
.into_response()
}
}
// --- Chat handlers ---
/// Convert web gateway `ImageData` to `IncomingAttachment` objects.
@@ -872,9 +663,9 @@ async fn chat_send_handler(
headers: axum::http::HeaderMap,
Json(req): Json<SendMessageRequest>,
) -> Result<(StatusCode, Json<SendMessageResponse>), (StatusCode, String)> {
tracing::trace!(
"[chat_send_handler] Received message: content_len={}, thread_id={:?}",
req.content.len(),
tracing::debug!(
"[chat_send_handler] Received message: content={:?}, thread_id={:?}",
req.content,
req.thread_id
);
@@ -907,10 +698,10 @@ async fn chat_send_handler(
}
let msg_id = msg.id;
tracing::trace!(
"[chat_send_handler] Created message id={}, content_len={}, images={}",
tracing::debug!(
"[chat_send_handler] Created message id={}, content={:?}, images={}",
msg_id,
req.content.len(),
req.content,
req.images.len()
);
@@ -1848,13 +1639,13 @@ async fn extensions_activate_handler(
Ok(Json(resp))
}
Err(activate_err) => {
let needs_auth = matches!(
&activate_err,
crate::extensions::ExtensionError::AuthRequired
);
let err_str = activate_err.to_string();
let needs_auth = err_str.contains("authentication")
|| err_str.contains("401")
|| err_str.contains("Unauthorized");
if !needs_auth {
return Ok(Json(ActionResponse::fail(activate_err.to_string())));
return Ok(Json(ActionResponse::fail(err_str)));
}
// Activation failed due to auth; try authenticating first.
@@ -2145,7 +1936,7 @@ async fn routines_list_handler(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let items: Vec<RoutineInfo> = routines.iter().map(RoutineInfo::from_routine).collect();
let items: Vec<RoutineInfo> = routines.iter().map(routine_to_info).collect();
Ok(Json(RoutineListResponse { routines: items }))
}
@@ -2226,7 +2017,6 @@ async fn routines_detail_handler(
status: format!("{:?}", run.status),
result_summary: run.result_summary.clone(),
tokens_used: run.tokens_used,
job_id: run.job_id,
})
.collect();
@@ -2379,7 +2169,6 @@ async fn routines_runs_handler(
status: format!("{:?}", run.status),
result_summary: run.result_summary.clone(),
tokens_used: run.tokens_used,
job_id: run.job_id,
})
.collect();
@@ -2389,6 +2178,54 @@ async fn routines_runs_handler(
})))
}
/// Convert a Routine to the trimmed RoutineInfo for list display.
fn routine_to_info(r: &crate::agent::routine::Routine) -> RoutineInfo {
let (trigger_type, trigger_summary) = match &r.trigger {
crate::agent::routine::Trigger::Cron { schedule, .. } => {
("cron".to_string(), format!("cron: {}", schedule))
}
crate::agent::routine::Trigger::Event {
pattern, channel, ..
} => {
let ch = channel.as_deref().unwrap_or("any");
("event".to_string(), format!("on {} /{}/", ch, pattern))
}
crate::agent::routine::Trigger::Webhook { path, .. } => {
let p = path.as_deref().unwrap_or("/");
("webhook".to_string(), format!("webhook: {}", p))
}
crate::agent::routine::Trigger::Manual => ("manual".to_string(), "manual only".to_string()),
};
let action_type = match &r.action {
crate::agent::routine::RoutineAction::Lightweight { .. } => "lightweight",
crate::agent::routine::RoutineAction::FullJob { .. } => "full_job",
};
let status = if !r.enabled {
"disabled"
} else if r.consecutive_failures > 0 {
"failing"
} else {
"active"
};
RoutineInfo {
id: r.id,
name: r.name.clone(),
description: r.description.clone(),
enabled: r.enabled,
trigger_type,
trigger_summary,
action_type: action_type.to_string(),
last_run_at: r.last_run_at.map(|dt| dt.to_rfc3339()),
next_fire_at: r.next_fire_at.map(|dt| dt.to_rfc3339()),
run_count: r.run_count,
consecutive_failures: r.consecutive_failures,
status: status.to_string(),
}
}
// --- Settings handlers ---
async fn settings_list_handler(
@@ -2588,7 +2425,6 @@ struct GatewayStatusResponse {
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::credentials::TEST_GATEWAY_CRYPTO_KEY;
#[test]
fn test_build_turns_from_db_messages_complete() {
@@ -2690,7 +2526,6 @@ mod tests {
skill_catalog: None,
scheduler: None,
chat_rate_limiter: RateLimiter::new(30, 60),
oauth_rate_limiter: RateLimiter::new(10, 60),
registry_entries: vec![],
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
@@ -2763,7 +2598,7 @@ mod tests {
// Build an ExtensionManager so the handler can look up flows
let secrets = Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
TEST_GATEWAY_CRYPTO_KEY.to_string(),
"test-key-at-least-32-chars-long!!".to_string(),
))
.expect("crypto"),
)));
@@ -2772,7 +2607,6 @@ mod tests {
let ext_mgr = Arc::new(ExtensionManager::new(
mcp_sm,
Arc::new(crate::tools::mcp::process::McpProcessManager::new()),
secrets,
tool_registry,
None,
@@ -2813,7 +2647,7 @@ mod tests {
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(),
"test-key-at-least-32-chars-long!!".to_string(),
))
.expect("crypto"),
)));
@@ -2822,7 +2656,6 @@ mod tests {
let ext_mgr = Arc::new(ExtensionManager::new(
mcp_sm,
Arc::new(crate::tools::mcp::process::McpProcessManager::new()),
secrets.clone(),
tool_registry,
None,
@@ -2919,7 +2752,7 @@ mod tests {
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(),
"test-key-at-least-32-chars-long!!".to_string(),
))
.expect("crypto"),
)));
@@ -2928,7 +2761,6 @@ mod tests {
let ext_mgr = Arc::new(ExtensionManager::new(
mcp_sm,
Arc::new(crate::tools::mcp::process::McpProcessManager::new()),
secrets.clone(),
tool_registry,
None,
@@ -3013,177 +2845,4 @@ mod tests {
.is_none()
);
}
// --- Slack relay OAuth CSRF tests ---
fn test_relay_oauth_router(state: Arc<GatewayState>) -> Router {
Router::new()
.route(
"/oauth/slack/callback",
get(slack_relay_oauth_callback_handler),
)
.with_state(state)
}
fn test_secrets_store() -> Arc<dyn crate::secrets::SecretsStore + Send + Sync> {
Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
"test-key-at-least-32-chars-long!!".to_string(),
))
.expect("crypto"),
)))
}
fn test_ext_mgr(
secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync>,
) -> Arc<ExtensionManager> {
let tool_registry = Arc::new(ToolRegistry::new());
let mcp_sm = Arc::new(crate::tools::mcp::session::McpSessionManager::new());
let mcp_pm = Arc::new(crate::tools::mcp::process::McpProcessManager::new());
Arc::new(ExtensionManager::new(
mcp_sm,
mcp_pm,
secrets,
tool_registry,
None,
None,
std::path::PathBuf::from("/tmp/wasm_tools"),
std::path::PathBuf::from("/tmp/wasm_channels"),
None,
"test".to_string(),
None,
vec![],
))
}
#[tokio::test]
async fn test_relay_oauth_callback_missing_state_param() {
use axum::body::Body;
use tower::ServiceExt;
let secrets = test_secrets_store();
let ext_mgr = test_ext_mgr(secrets);
let state = test_gateway_state(Some(ext_mgr));
let app = test_relay_oauth_router(state);
// 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")
.body(Body::empty())
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
.await
.expect("body");
let html = String::from_utf8_lossy(&body);
assert!(
html.contains("Invalid or expired authorization"),
"Expected CSRF error, got: {}",
&html[..html.len().min(300)]
);
}
#[tokio::test]
async fn test_relay_oauth_callback_wrong_state_param() {
use axum::body::Body;
use tower::ServiceExt;
let secrets = test_secrets_store();
// Store a valid nonce
secrets
.create(
"test",
crate::secrets::CreateSecretParams::new(
format!("relay:{}:oauth_state", DEFAULT_RELAY_NAME),
"correct-nonce-value",
),
)
.await
.expect("store nonce");
let ext_mgr = test_ext_mgr(secrets);
let state = test_gateway_state(Some(ext_mgr));
let app = test_relay_oauth_router(state);
// 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")
.body(Body::empty())
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
.await
.expect("body");
let html = String::from_utf8_lossy(&body);
assert!(
html.contains("Invalid or expired authorization"),
"Expected CSRF error for wrong nonce, got: {}",
&html[..html.len().min(300)]
);
}
#[tokio::test]
async fn test_relay_oauth_callback_correct_state_proceeds() {
use axum::body::Body;
use tower::ServiceExt;
let secrets = test_secrets_store();
let nonce = "valid-test-nonce-12345";
// Store the correct nonce
secrets
.create(
"test",
crate::secrets::CreateSecretParams::new(
format!("relay:{}:oauth_state", DEFAULT_RELAY_NAME),
nonce,
),
)
.await
.expect("store nonce");
let ext_mgr = test_ext_mgr(secrets.clone());
let state = test_gateway_state(Some(ext_mgr));
let app = test_relay_oauth_router(state);
// Callback with correct state param — will pass CSRF check
// but may fail downstream (no real relay service) — that's OK,
// 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={}",
nonce
))
.body(Body::empty())
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
.await
.expect("body");
let html = String::from_utf8_lossy(&body);
// Should NOT contain the CSRF error message
assert!(
!html.contains("Invalid or expired authorization"),
"Should have passed CSRF check, got: {}",
&html[..html.len().min(300)]
);
// Verify the nonce was consumed (deleted)
let state_key = format!("relay:{}:oauth_state", DEFAULT_RELAY_NAME);
let exists = secrets.exists("test", &state_key).await.unwrap_or(true);
assert!(!exists, "CSRF nonce should be deleted after use");
}
}
+2 -2
View File
@@ -2350,8 +2350,8 @@ function renderExtensionCard(ext) {
activeLabel.textContent = ext.active ? 'Active' : 'Installed';
actions.appendChild(activeLabel);
// MCP servers and channel-relay extensions may be installed but inactive — show Activate button
if ((ext.kind === 'mcp_server' || ext.kind === 'channel_relay') && !ext.active) {
// MCP servers may be installed but inactive — show Activate button
if (ext.kind === 'mcp_server' && !ext.active) {
const activateBtn = document.createElement('button');
activateBtn.className = 'btn-ext activate';
activateBtn.textContent = 'Activate';
-17
View File
@@ -1277,8 +1277,6 @@ body {
gap: 8px;
background: var(--bg-secondary);
border-top: 1px solid var(--border);
flex-shrink: 0;
min-height: 56px;
}
.chat-input textarea {
@@ -3722,21 +3720,6 @@ mark {
.ext-install-form input {
width: 100%;
}
/* Chat input: ensure visibility on mobile */
.chat-input {
min-height: 52px;
}
.chat-input textarea {
min-height: 36px;
max-height: 100px;
}
.chat-input button {
padding: 6px 16px;
font-size: 14px;
}
}
/* Slash command autocomplete dropdown */
-1
View File
@@ -82,7 +82,6 @@ impl TestGatewayBuilder {
skill_catalog: None,
scheduler: None,
chat_rate_limiter: RateLimiter::new(30, 60),
oauth_rate_limiter: RateLimiter::new(10, 60),
registry_entries: Vec::new(),
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
-55
View File
@@ -735,60 +735,6 @@ pub struct RoutineInfo {
pub status: String,
}
impl RoutineInfo {
/// Convert a `Routine` to the trimmed `RoutineInfo` for list display.
pub fn from_routine(r: &crate::agent::routine::Routine) -> Self {
let (trigger_type, trigger_summary) = match &r.trigger {
crate::agent::routine::Trigger::Cron { schedule, .. } => {
("cron".to_string(), format!("cron: {}", schedule))
}
crate::agent::routine::Trigger::Event {
pattern, channel, ..
} => {
let ch = channel.as_deref().unwrap_or("any");
("event".to_string(), format!("on {} /{}/", ch, pattern))
}
crate::agent::routine::Trigger::SystemEvent {
source, event_type, ..
} => (
"system_event".to_string(),
format!("event: {}.{}", source, event_type),
),
crate::agent::routine::Trigger::Manual => {
("manual".to_string(), "manual only".to_string())
}
};
let action_type = match &r.action {
crate::agent::routine::RoutineAction::Lightweight { .. } => "lightweight",
crate::agent::routine::RoutineAction::FullJob { .. } => "full_job",
};
let status = if !r.enabled {
"disabled"
} else if r.consecutive_failures > 0 {
"failing"
} else {
"active"
};
RoutineInfo {
id: r.id,
name: r.name.clone(),
description: r.description.clone(),
enabled: r.enabled,
trigger_type,
trigger_summary,
action_type: action_type.to_string(),
last_run_at: r.last_run_at.map(|dt| dt.to_rfc3339()),
next_fire_at: r.next_fire_at.map(|dt| dt.to_rfc3339()),
run_count: r.run_count,
consecutive_failures: r.consecutive_failures,
status: status.to_string(),
}
}
}
#[derive(Debug, Serialize)]
pub struct RoutineListResponse {
pub routines: Vec<RoutineInfo>,
@@ -830,7 +776,6 @@ pub struct RoutineRunInfo {
pub status: String,
pub result_summary: Option<String>,
pub tokens_used: Option<i32>,
pub job_id: Option<Uuid>,
}
// --- Settings ---
-1
View File
@@ -509,7 +509,6 @@ mod tests {
skill_registry: None,
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),
registry_entries: Vec::new(),
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
+1 -237
View File
@@ -24,8 +24,6 @@ pub struct WebhookServerConfig {
pub struct WebhookServer {
config: WebhookServerConfig,
routes: Vec<Router>,
/// Merged router saved after start() for restart_with_addr().
merged_router: Option<Router>,
shutdown_tx: Option<oneshot::Sender<()>>,
handle: Option<JoinHandle<()>>,
}
@@ -36,7 +34,6 @@ impl WebhookServer {
Self {
config,
routes: Vec::new(),
merged_router: None,
shutdown_tx: None,
handle: None,
}
@@ -54,13 +51,7 @@ impl WebhookServer {
for fragment in self.routes.drain(..) {
app = app.merge(fragment);
}
self.merged_router = Some(app.clone());
self.bind_and_spawn(app).await
}
/// Bind a listener to the configured address and spawn the server task.
/// Private helper used by both start() and restart_with_addr().
async fn bind_and_spawn(&mut self, app: Router) -> Result<(), ChannelError> {
let listener = tokio::net::TcpListener::bind(self.config.addr)
.await
.map_err(|e| ChannelError::StartupFailed {
@@ -77,7 +68,7 @@ impl WebhookServer {
if let Err(e) = axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = shutdown_rx.await;
tracing::debug!("Webhook server shutting down");
tracing::info!("Webhook server shutting down");
})
.await
{
@@ -89,54 +80,6 @@ impl WebhookServer {
Ok(())
}
/// Gracefully shut down the current listener and rebind to a new address.
/// The merged router from the original `start()` call is reused.
///
/// If binding to the new address fails, the old listener remains active and
/// state is restored. This prevents a denial-of-service if the new address
/// is invalid or already in use.
pub async fn restart_with_addr(&mut self, new_addr: SocketAddr) -> Result<(), ChannelError> {
let app = self
.merged_router
.clone()
.ok_or_else(|| ChannelError::StartupFailed {
name: "webhook_server".to_string(),
reason: "restart_with_addr called before start()".to_string(),
})?;
// Save old state for rollback if new bind fails
let old_addr = self.config.addr;
let old_shutdown_tx = self.shutdown_tx.take();
let old_handle = self.handle.take();
// Update config to new address and try to bind
self.config.addr = new_addr;
match self.bind_and_spawn(app).await {
Ok(()) => {
// New listener is running, gracefully shut down the old one
if let Some(tx) = old_shutdown_tx {
let _ = tx.send(());
}
if let Some(handle) = old_handle {
let _ = handle.await;
}
Ok(())
}
Err(e) => {
// Restore old state; old listener remains active
self.config.addr = old_addr;
self.shutdown_tx = old_shutdown_tx;
self.handle = old_handle;
Err(e)
}
}
}
/// Return the current bind address.
pub fn current_addr(&self) -> SocketAddr {
self.config.addr
}
/// Signal graceful shutdown and wait for the server task to finish.
pub async fn shutdown(&mut self) {
if let Some(tx) = self.shutdown_tx.take() {
@@ -147,182 +90,3 @@ impl WebhookServer {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::Json;
use serde_json::json;
#[tokio::test]
async fn test_restart_with_addr_rebinds_listener() {
use std::net::TcpListener as StdTcpListener;
// Find two available ports by binding and immediately closing
let port1 = {
let listener =
StdTcpListener::bind("127.0.0.1:0").expect("Failed to find available port 1");
listener
.local_addr()
.expect("Failed to get local addr")
.port()
};
let port2 = {
let listener =
StdTcpListener::bind("127.0.0.1:0").expect("Failed to find available port 2");
listener
.local_addr()
.expect("Failed to get local addr")
.port()
};
assert_ne!(port1, port2, "Should have different ports");
assert_ne!(port1, 0, "Port 1 should be non-zero");
assert_ne!(port2, 0, "Port 2 should be non-zero");
// Start server on first port
let addr1 = format!("127.0.0.1:{}", port1).parse().unwrap();
let mut server = WebhookServer::new(WebhookServerConfig { addr: addr1 });
// Create a test router that responds to health checks
let test_router = axum::Router::new().route(
"/health",
axum::routing::get(|| async { Json(json!({"status": "ok"})) }),
);
server.add_routes(test_router);
// Start the server on first port
server.start().await.expect("Failed to start server");
assert_eq!(
server.current_addr(),
addr1,
"Server should be bound to initial address"
);
// Verify the first server is actually listening
let client = reqwest::Client::new();
let response = client
.get(format!("http://{}/health", addr1))
.send()
.await
.expect("Failed to send request to first server");
assert_eq!(
response.status(),
200,
"First server should respond to health check"
);
// Restart on second port
let addr2 = format!("127.0.0.1:{}", port2).parse().unwrap();
server
.restart_with_addr(addr2)
.await
.expect("Failed to restart with new addr");
// Assert the address changed
assert_eq!(
server.current_addr(),
addr2,
"Server address should be updated after restart"
);
assert_ne!(
addr1, addr2,
"Address should change after restart_with_addr"
);
// Verify the new server is actually listening on the new address
let response = client
.get(format!("http://{}/health", addr2))
.send()
.await
.expect("Failed to send request to restarted server");
assert_eq!(
response.status(),
200,
"Restarted server should respond to health check on new address"
);
// Verify the old address is no longer responding
let old_result = tokio::time::timeout(
std::time::Duration::from_millis(200),
client.get(format!("http://{}/health", addr1)).send(),
)
.await;
assert!(
old_result.is_err() || old_result.as_ref().unwrap().is_err(),
"Old address should not respond after server restarts"
);
// Clean up
server.shutdown().await;
}
#[tokio::test]
async fn test_restart_with_addr_rollback_on_bind_failure() {
use std::net::TcpListener as StdTcpListener;
// Find an available port
let port1 = {
let listener =
StdTcpListener::bind("127.0.0.1:0").expect("Failed to find available port");
listener
.local_addr()
.expect("Failed to get local addr")
.port()
};
// Start server on first port
let addr1 = format!("127.0.0.1:{}", port1).parse().unwrap();
let mut server = WebhookServer::new(WebhookServerConfig { addr: addr1 });
// Create a test router
let test_router = axum::Router::new().route(
"/health",
axum::routing::get(|| async { Json(json!({"status": "ok"})) }),
);
server.add_routes(test_router);
// Start the server on first port
server.start().await.expect("Failed to start server");
// Verify the server is listening
let client = reqwest::Client::new();
let response = client
.get(format!("http://{}/health", addr1))
.send()
.await
.expect("Failed to send request");
assert_eq!(response.status(), 200, "Server should be listening");
// Try to restart on an invalid address (port 0 is reserved, won't bind)
// Use port 1 which typically requires elevated privileges
let invalid_addr: SocketAddr = "127.0.0.1:1".parse().unwrap();
// Attempt restart (should fail)
let result = server.restart_with_addr(invalid_addr).await;
assert!(result.is_err(), "Restart with invalid address should fail");
// Verify the old address is still responding (rollback succeeded)
let response = client
.get(format!("http://{}/health", addr1))
.send()
.await
.expect("Failed to send request to old address");
assert_eq!(
response.status(),
200,
"Old listener should still be running after failed restart"
);
// Verify the server address is unchanged
assert_eq!(
server.current_addr(),
addr1,
"Server address should be restored after failed restart"
);
// Clean up
server.shutdown().await;
}
}
+6 -542
View File
@@ -7,7 +7,6 @@
use std::path::PathBuf;
use crate::bootstrap::ironclaw_base_dir;
use crate::settings::Settings;
/// Run all diagnostic checks and print results.
pub async fn run_doctor_command() -> anyhow::Result<()> {
@@ -16,35 +15,14 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
let mut passed = 0u32;
let mut failed = 0u32;
let mut skipped = 0u32;
// Load settings once for checks that need them.
let settings = Settings::load();
// ── Settings & core config ─────────────────────────────────
check(
"Settings file",
check_settings_file(),
&mut passed,
&mut failed,
&mut skipped,
);
// ── Configuration checks ──────────────────────────────────
check(
"NEAR AI session",
check_nearai_session().await,
&mut passed,
&mut failed,
&mut skipped,
);
check(
"LLM configuration",
check_llm_config(&settings),
&mut passed,
&mut failed,
&mut skipped,
);
check(
@@ -52,7 +30,6 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
check_database().await,
&mut passed,
&mut failed,
&mut skipped,
);
check(
@@ -60,75 +37,15 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
check_workspace_dir(),
&mut passed,
&mut failed,
&mut skipped,
);
// ── Subsystem configuration checks ─────────────────────────
check(
"Embeddings",
check_embeddings(&settings),
&mut passed,
&mut failed,
&mut skipped,
);
check(
"Routines config",
check_routines_config(),
&mut passed,
&mut failed,
&mut skipped,
);
check(
"Gateway config",
check_gateway_config(&settings),
&mut passed,
&mut failed,
&mut skipped,
);
check(
"MCP servers",
check_mcp_config().await,
&mut passed,
&mut failed,
&mut skipped,
);
check(
"Skills",
check_skills().await,
&mut passed,
&mut failed,
&mut skipped,
);
check(
"Secrets",
check_secrets(&settings),
&mut passed,
&mut failed,
&mut skipped,
);
check(
"Service",
check_service_installed(),
&mut passed,
&mut failed,
&mut skipped,
);
// ── External binary checks ────────────────────────────────
check(
"Docker daemon",
check_docker_daemon().await,
"Docker",
check_binary("docker", &["--version"]),
&mut passed,
&mut failed,
&mut skipped,
);
check(
@@ -136,7 +53,6 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
check_binary("cloudflared", &["--version"]),
&mut passed,
&mut failed,
&mut skipped,
);
check(
@@ -144,7 +60,6 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
check_binary("ngrok", &["version"]),
&mut passed,
&mut failed,
&mut skipped,
);
check(
@@ -152,13 +67,12 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
check_binary("tailscale", &["version"]),
&mut passed,
&mut failed,
&mut skipped,
);
// ── Summary ───────────────────────────────────────────────
println!();
println!(" {passed} passed, {failed} failed, {skipped} skipped");
println!(" {passed} passed, {failed} failed");
if failed > 0 {
println!("\n Some checks failed. This is normal if you don't use those features.");
@@ -169,7 +83,7 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
// ── Individual checks ───────────────────────────────────────
fn check(name: &str, result: CheckResult, passed: &mut u32, failed: &mut u32, skipped: &mut u32) {
fn check(name: &str, result: CheckResult, passed: &mut u32, failed: &mut u32) {
match result {
CheckResult::Pass(detail) => {
*passed += 1;
@@ -180,7 +94,6 @@ fn check(name: &str, result: CheckResult, passed: &mut u32, failed: &mut u32, sk
println!(" [FAIL] {name}: {detail}");
}
CheckResult::Skip(reason) => {
*skipped += 1;
println!(" [skip] {name}: {reason}");
}
}
@@ -192,32 +105,9 @@ enum CheckResult {
Skip(String),
}
// ── Settings file ───────────────────────────────────────────
fn check_settings_file() -> CheckResult {
let path = Settings::default_path();
if !path.exists() {
return CheckResult::Pass("no settings file (defaults will be used)".into());
}
match std::fs::read_to_string(&path) {
Ok(data) => match serde_json::from_str::<serde_json::Value>(&data) {
Ok(_) => CheckResult::Pass(format!("valid ({})", path.display())),
Err(e) => CheckResult::Fail(format!(
"settings.json is malformed: {}. Fix or delete {}",
e,
path.display()
)),
},
Err(e) => CheckResult::Fail(format!("cannot read {}: {}", path.display(), e)),
}
}
// ── NEAR AI session ─────────────────────────────────────────
async fn check_nearai_session() -> CheckResult {
// Check if session file exists
let session_path = crate::config::llm::default_session_path();
let session_path = crate::llm::session::default_session_path();
if !session_path.exists() {
// Check for API key mode
if std::env::var("NEARAI_API_KEY").is_ok() {
@@ -239,27 +129,6 @@ async fn check_nearai_session() -> CheckResult {
}
}
// ── LLM configuration ──────────────────────────────────────
fn check_llm_config(settings: &Settings) -> CheckResult {
match crate::llm::LlmConfig::resolve(settings) {
Ok(config) => {
// Show the model for the active backend, not always nearai.model.
let model = if let Some(ref bedrock) = config.bedrock {
&bedrock.model
} else if let Some(ref provider) = config.provider {
&provider.model
} else {
&config.nearai.model
};
CheckResult::Pass(format!("backend={}, model={}", config.backend, model))
}
Err(e) => CheckResult::Fail(format!("LLM config error: {e}")),
}
}
// ── Database ────────────────────────────────────────────────
async fn check_database() -> CheckResult {
let backend = std::env::var("DATABASE_BACKEND")
.ok()
@@ -323,8 +192,6 @@ async fn try_pg_connect() -> Result<(), String> {
Err("postgres feature not compiled in".into())
}
// ── Workspace directory ─────────────────────────────────────
fn check_workspace_dir() -> CheckResult {
let dir = ironclaw_base_dir();
@@ -339,222 +206,6 @@ fn check_workspace_dir() -> CheckResult {
}
}
// ── Embeddings ──────────────────────────────────────────────
fn check_embeddings(settings: &Settings) -> CheckResult {
match crate::config::EmbeddingsConfig::resolve(settings) {
Ok(config) => {
if !config.enabled {
return CheckResult::Skip("disabled (set EMBEDDING_ENABLED=true)".into());
}
let has_creds = match config.provider.as_str() {
"openai" => config.openai_api_key().is_some(),
"nearai" => {
// NearAiEmbeddings uses SessionManager::get_token() which
// only returns session tokens, NOT NEARAI_API_KEY
// (src/workspace/embeddings.rs:309, src/llm/session.rs:132).
let session_path = crate::config::llm::default_session_path();
session_path.exists()
&& std::fs::read_to_string(&session_path)
.map(|s| !s.trim().is_empty())
.unwrap_or(false)
}
"ollama" => true, // local, no creds needed
_ => config.openai_api_key().is_some(),
};
if has_creds {
CheckResult::Pass(format!(
"provider={}, model={}",
config.provider, config.model
))
} else {
let hint = match config.provider.as_str() {
"nearai" => "run `ironclaw onboard` to create a session",
_ => "set OPENAI_API_KEY",
};
CheckResult::Fail(format!(
"provider={} but credentials missing ({})",
config.provider, hint
))
}
}
Err(e) => CheckResult::Fail(format!("config error: {e}")),
}
}
// ── Routines config ─────────────────────────────────────────
fn check_routines_config() -> CheckResult {
match crate::config::RoutineConfig::resolve() {
Ok(config) => {
if config.enabled {
CheckResult::Pass(format!(
"enabled (interval={}s, max_concurrent={})",
config.cron_check_interval_secs, config.max_concurrent_routines
))
} else {
CheckResult::Skip("disabled".into())
}
}
Err(e) => CheckResult::Fail(format!("config error: {e}")),
}
}
// ── Gateway config ──────────────────────────────────────────
fn check_gateway_config(settings: &Settings) -> CheckResult {
// Use the same resolve() path as runtime so invalid env values
// (e.g. GATEWAY_PORT=abc) are caught here too.
match crate::config::ChannelsConfig::resolve(settings) {
Ok(channels) => match channels.gateway {
Some(gw) => {
if gw.auth_token.is_some() {
CheckResult::Pass(format!(
"enabled at {}:{} (auth token set)",
gw.host, gw.port
))
} else {
CheckResult::Pass(format!(
"enabled at {}:{} (no auth token — random token will be generated)",
gw.host, gw.port
))
}
}
None => CheckResult::Skip("disabled (GATEWAY_ENABLED=false)".into()),
},
Err(e) => CheckResult::Fail(format!("config error: {e}")),
}
}
// ── MCP servers ─────────────────────────────────────────────
async fn check_mcp_config() -> CheckResult {
match crate::tools::mcp::config::load_mcp_servers().await {
Ok(file) => {
let servers: Vec<_> = file.enabled_servers().collect();
if servers.is_empty() {
return CheckResult::Skip("no MCP servers configured".into());
}
let mut invalid = Vec::new();
for server in &servers {
if let Err(e) = server.validate() {
invalid.push(format!("{}: {}", server.name, e));
}
}
if invalid.is_empty() {
CheckResult::Pass(format!("{} server(s) configured, all valid", servers.len()))
} else {
CheckResult::Fail(format!(
"{} server(s), {} invalid: {}",
servers.len(),
invalid.len(),
invalid.join("; ")
))
}
}
Err(e) => {
// Distinguish no config from corrupted config
let msg = e.to_string();
if msg.contains("not found") || msg.contains("No such file") {
CheckResult::Skip("no MCP config file".into())
} else {
CheckResult::Fail(format!("config error: {e}"))
}
}
}
}
// ── Skills ──────────────────────────────────────────────────
async fn check_skills() -> CheckResult {
let user_dir = ironclaw_base_dir().join("skills");
let installed_dir = ironclaw_base_dir().join("installed_skills");
let mut registry = crate::skills::SkillRegistry::new(user_dir.clone());
registry = registry.with_installed_dir(installed_dir);
// discover_all() returns loaded skill names (not warnings).
let _loaded_names = registry.discover_all().await;
let count = registry.count();
if count == 0 {
return CheckResult::Skip("no skills discovered".into());
}
CheckResult::Pass(format!("{count} skill(s) loaded"))
}
// ── Secrets ─────────────────────────────────────────────────
fn check_secrets(settings: &Settings) -> CheckResult {
match settings.secrets_master_key_source {
crate::settings::KeySource::Keychain => {
CheckResult::Pass("master key source: OS keychain".into())
}
crate::settings::KeySource::Env => {
if std::env::var("SECRETS_MASTER_KEY").is_ok() {
CheckResult::Pass("master key source: env var (set)".into())
} else {
CheckResult::Fail(
"master key source: env var but SECRETS_MASTER_KEY not set".into(),
)
}
}
crate::settings::KeySource::None => {
CheckResult::Skip("secrets not configured (run `ironclaw onboard`)".into())
}
}
}
// ── Service ─────────────────────────────────────────────────
fn check_service_installed() -> CheckResult {
if cfg!(target_os = "macos") {
let plist =
dirs::home_dir().map(|h| h.join("Library/LaunchAgents/com.ironclaw.daemon.plist"));
match plist {
Some(path) if path.exists() => {
CheckResult::Pass(format!("launchd plist installed ({})", path.display()))
}
Some(_) => CheckResult::Skip("not installed (run `ironclaw service install`)".into()),
None => CheckResult::Skip("cannot determine home directory".into()),
}
} else if cfg!(target_os = "linux") {
let unit = dirs::home_dir().map(|h| h.join(".config/systemd/user/ironclaw.service"));
match unit {
Some(path) if path.exists() => {
CheckResult::Pass(format!("systemd unit installed ({})", path.display()))
}
Some(_) => CheckResult::Skip("not installed (run `ironclaw service install`)".into()),
None => CheckResult::Skip("cannot determine home directory".into()),
}
} else {
CheckResult::Skip("service management not supported on this platform".into())
}
}
// ── Docker daemon ───────────────────────────────────────────
async fn check_docker_daemon() -> CheckResult {
let detection = crate::sandbox::check_docker().await;
match detection.status {
crate::sandbox::DockerStatus::Available => CheckResult::Pass("running".into()),
crate::sandbox::DockerStatus::NotInstalled => CheckResult::Skip(format!(
"not installed. {}",
detection.platform.install_hint()
)),
crate::sandbox::DockerStatus::NotRunning => CheckResult::Fail(format!(
"installed but not running. {}",
detection.platform.start_hint()
)),
crate::sandbox::DockerStatus::Disabled => CheckResult::Skip("sandbox disabled".into()),
}
}
// ── External binary ─────────────────────────────────────────
fn check_binary(name: &str, args: &[&str]) -> CheckResult {
match std::process::Command::new(name)
.args(args)
@@ -622,193 +273,6 @@ mod tests {
}
}
#[test]
fn check_settings_file_handles_missing() {
// Settings::default_path() might or might not exist, but must not panic
let result = check_settings_file();
match result {
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
}
}
#[test]
fn check_llm_config_does_not_panic() {
let settings = Settings::default();
let result = check_llm_config(&settings);
match result {
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
}
}
#[test]
fn check_routines_config_does_not_panic() {
let result = check_routines_config();
match result {
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
}
}
#[test]
fn check_gateway_config_does_not_panic() {
let settings = Settings::default();
let result = check_gateway_config(&settings);
match result {
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
}
}
#[test]
fn check_embeddings_does_not_panic() {
let settings = Settings::default();
let result = check_embeddings(&settings);
match result {
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
}
}
#[test]
fn check_secrets_none_returns_skip() {
let settings = Settings::default();
match check_secrets(&settings) {
CheckResult::Skip(msg) => {
assert!(
msg.contains("not configured"),
"expected 'not configured' in skip message, got: {msg}"
);
}
other => panic!(
"expected Skip for default settings, got: {}",
format_result(&other)
),
}
}
#[test]
fn check_service_installed_does_not_panic() {
let result = check_service_installed();
match result {
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
}
}
#[tokio::test]
async fn check_docker_daemon_does_not_panic() {
let result = check_docker_daemon().await;
match result {
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
}
}
#[tokio::test]
async fn check_mcp_config_does_not_panic() {
let result = check_mcp_config().await;
match result {
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
}
}
#[tokio::test]
async fn check_skills_does_not_panic() {
let result = check_skills().await;
match result {
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
}
}
#[test]
fn check_llm_config_shows_nearai_model_for_nearai_backend() {
let _guard = crate::config::helpers::ENV_MUTEX.lock().expect("env mutex");
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
std::env::remove_var("LLM_BACKEND");
}
let settings = Settings::default();
match check_llm_config(&settings) {
CheckResult::Pass(msg) => {
assert!(
msg.contains("backend=nearai"),
"expected nearai backend, got: {msg}"
);
// Must NOT show a bedrock or registry model when backend is nearai
assert!(
!msg.contains("anthropic.claude"),
"should not show bedrock model for nearai backend: {msg}"
);
}
other => panic!(
"expected Pass for default LLM config, got: {}",
format_result(&other)
),
}
}
#[test]
fn check_embeddings_disabled_by_default_returns_skip() {
let _guard = crate::config::helpers::ENV_MUTEX.lock().expect("env mutex");
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::remove_var("EMBEDDING_ENABLED");
}
let settings = Settings::default();
match check_embeddings(&settings) {
CheckResult::Skip(msg) => {
assert!(
msg.contains("disabled"),
"expected 'disabled' in skip message, got: {msg}"
);
}
other => panic!(
"expected Skip for disabled embeddings, got: {}",
format_result(&other)
),
}
}
#[test]
fn check_routines_enabled_by_default() {
let _guard = crate::config::helpers::ENV_MUTEX.lock().expect("env mutex");
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::remove_var("ROUTINES_ENABLED");
}
match check_routines_config() {
CheckResult::Pass(msg) => {
assert!(
msg.contains("enabled"),
"routines should be enabled by default, got: {msg}"
);
}
other => panic!(
"expected Pass for default routines, got: {}",
format_result(&other)
),
}
}
#[test]
fn check_secrets_env_without_var_returns_fail() {
let settings = Settings {
secrets_master_key_source: crate::settings::KeySource::Env,
..Default::default()
};
match check_secrets(&settings) {
CheckResult::Fail(msg) => {
assert!(
msg.contains("SECRETS_MASTER_KEY not set"),
"expected mention of missing env var, got: {msg}"
);
}
CheckResult::Pass(_) => {
// If SECRETS_MASTER_KEY happens to be set in the environment,
// Pass is correct — don't fail the test.
}
other => panic!(
"expected Fail or Pass for env key source, got: {}",
format_result(&other)
),
}
}
fn format_result(r: &CheckResult) -> String {
match r {
CheckResult::Pass(s) => format!("Pass({s})"),
+12 -2
View File
@@ -10,7 +10,7 @@ use clap::{Args, Subcommand};
use crate::config::Config;
use crate::db::Database;
use crate::secrets::SecretsStore;
use crate::secrets::{SecretsCrypto, SecretsStore};
use crate::tools::mcp::{
McpClient, McpServerConfig, McpSessionManager, OAuthConfig,
auth::{authorize_mcp_server, is_authenticated},
@@ -628,7 +628,17 @@ async fn save_servers(
/// Initialize and return the secrets store.
async fn get_secrets_store() -> anyhow::Result<Arc<dyn SecretsStore + Send + Sync>> {
crate::cli::init_secrets_store().await
let config = Config::from_env().await?;
let master_key = config.secrets.master_key().ok_or_else(|| {
anyhow::anyhow!(
"SECRETS_MASTER_KEY not set. Run 'ironclaw onboard' first or set it in .env"
)
})?;
let crypto = Arc::new(SecretsCrypto::new(master_key.clone())?);
Ok(crate::db::create_secrets_store(&config.database, crypto).await?)
}
#[cfg(test)]
+4 -45
View File
@@ -28,6 +28,8 @@ pub use config::{ConfigCommand, run_config_command};
pub use doctor::run_doctor_command;
pub use mcp::{McpCommand, run_mcp_command};
pub use memory::MemoryCommand;
#[cfg(feature = "postgres")]
pub use memory::run_memory_command;
pub use memory::run_memory_command_with_db;
pub use pairing::{PairingCommand, run_pairing_command, run_pairing_command_with_store};
pub use registry::{RegistryCommand, run_registry_command};
@@ -35,8 +37,6 @@ pub use service::{ServiceCommand, run_service_command};
pub use status::run_status_command;
pub use tool::{ToolCommand, run_tool_command};
use std::sync::Arc;
use clap::{ColorChoice, Parser, Subcommand};
#[derive(Parser, Debug)]
@@ -94,16 +94,12 @@ pub enum Command {
skip_auth: bool,
/// Reconfigure channels only
#[arg(long, conflicts_with_all = ["provider_only", "quick"])]
#[arg(long, conflicts_with = "provider_only")]
channels_only: bool,
/// Reconfigure LLM provider and model only
#[arg(long, conflicts_with_all = ["channels_only", "quick"])]
#[arg(long, conflicts_with = "channels_only")]
provider_only: bool,
/// Quick setup: auto-defaults everything except LLM provider and model
#[arg(long, conflicts_with_all = ["channels_only", "provider_only"])]
quick: bool,
},
/// Manage configuration settings
@@ -229,43 +225,6 @@ impl Cli {
}
}
/// Initialize a secrets store from environment config.
///
/// Shared helper for CLI subcommands (`mcp auth`, `tool auth`, etc.) that need
/// access to encrypted secrets without spinning up the full AppBuilder.
pub async fn init_secrets_store()
-> anyhow::Result<Arc<dyn crate::secrets::SecretsStore + Send + Sync>> {
let config = crate::config::Config::from_env().await?;
let master_key = config.secrets.master_key().ok_or_else(|| {
anyhow::anyhow!(
"SECRETS_MASTER_KEY not set. Run 'ironclaw onboard' first or set it in .env"
)
})?;
let crypto = Arc::new(crate::secrets::SecretsCrypto::new(master_key.clone())?);
Ok(crate::db::create_secrets_store(&config.database, crypto).await?)
}
/// Run the Memory CLI subcommand.
pub async fn run_memory_command(mem_cmd: &MemoryCommand) -> anyhow::Result<()> {
let config = crate::config::Config::from_env()
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
let session = crate::llm::create_session_manager(config.llm.session.clone()).await;
let embeddings = config
.embeddings
.create_provider(&config.llm.nearai.base_url, session);
let db: Arc<dyn crate::db::Database> = crate::db::connect_from_config(&config.database)
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
run_memory_command_with_db(mem_cmd.clone(), db, embeddings).await
}
#[cfg(test)]
mod tests {
use super::*;
+342 -6
View File
@@ -24,6 +24,8 @@ use std::time::Duration;
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use rand::RngCore;
use sha2::{Digest, Sha256};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
use tokio::sync::RwLock;
use crate::secrets::{CreateSecretParams, SecretsStore};
@@ -62,12 +64,259 @@ pub fn builtin_credentials(secret_name: &str) -> Option<OAuthCredentials> {
// ── Shared callback server ──────────────────────────────────────────────
// Core OAuth callback infrastructure is defined in `crate::llm::oauth_helpers`
// and re-exported here for backward compatibility.
pub use crate::llm::oauth_helpers::{
OAUTH_CALLBACK_PORT, OAuthCallbackError, bind_callback_listener, callback_host, callback_url,
is_loopback_host, landing_html, wait_for_callback,
};
/// Fixed port for all OAuth callbacks.
///
/// Every redirect URI registered with providers must use this port:
/// `http://localhost:9876/callback` (or `/auth/callback` for NEAR AI).
pub const OAUTH_CALLBACK_PORT: u16 = 9876;
/// Returns the OAuth callback base URL.
///
/// Checks `IRONCLAW_OAUTH_CALLBACK_URL` env var first (useful for remote/VPS
/// deployments where `127.0.0.1` is unreachable from the user's browser),
/// then falls back to `http://{callback_host()}:{OAUTH_CALLBACK_PORT}`.
pub fn callback_url() -> String {
std::env::var("IRONCLAW_OAUTH_CALLBACK_URL")
.ok()
.filter(|v| !v.is_empty())
.unwrap_or_else(|| format!("http://{}:{}", callback_host(), OAUTH_CALLBACK_PORT))
}
/// Returns the hostname used in OAuth callback URLs.
///
/// Reads `OAUTH_CALLBACK_HOST` from the environment (default: `127.0.0.1`).
///
/// **Remote server usage:** set `OAUTH_CALLBACK_HOST` to the network interface
/// address you want to listen on (e.g. the server's LAN IP or `0.0.0.0`).
/// The callback listener will bind to that specific address instead of the
/// loopback interface, so the OAuth redirect can reach an external browser.
/// Note: this transmits the session token over plain HTTP — prefer SSH port
/// forwarding (`ssh -L 9876:127.0.0.1:9876 user@host`) when possible.
///
/// # Example
///
/// ```bash
/// export OAUTH_CALLBACK_HOST=203.0.113.10
/// ironclaw login
/// # Opens: http://203.0.113.10:9876/auth/callback
/// ```
pub fn callback_host() -> String {
std::env::var("OAUTH_CALLBACK_HOST").unwrap_or_else(|_| "127.0.0.1".to_string())
}
/// Returns `true` if `host` is a loopback address that only accepts local connections.
///
/// Covers `localhost` (case-insensitive), the full `127.0.0.0/8` IPv4 loopback
/// range, and `::1` for IPv6.
pub fn is_loopback_host(host: &str) -> bool {
if host.eq_ignore_ascii_case("localhost") {
return true;
}
host.parse::<std::net::IpAddr>()
.map(|ip| ip.is_loopback())
.unwrap_or(false)
}
/// Error from the OAuth callback listener.
#[derive(Debug, thiserror::Error)]
pub enum OAuthCallbackError {
#[error("Port {0} is in use (another auth flow running?): {1}")]
PortInUse(u16, String),
#[error("Authorization denied by user")]
Denied,
#[error("Timed out waiting for authorization")]
Timeout,
#[error("CSRF state mismatch: expected {expected}, got {actual}")]
StateMismatch { expected: String, actual: String },
#[error("IO error: {0}")]
Io(String),
}
/// Map a `std::io::Error` from a bind attempt to an `OAuthCallbackError`.
fn bind_error(e: std::io::Error) -> OAuthCallbackError {
if e.kind() == std::io::ErrorKind::AddrInUse {
OAuthCallbackError::PortInUse(OAUTH_CALLBACK_PORT, e.to_string())
} else {
OAuthCallbackError::Io(e.to_string())
}
}
/// Bind the OAuth callback listener on the fixed port.
///
/// When `OAUTH_CALLBACK_HOST` is a loopback address (the default `127.0.0.1`),
/// binds to `127.0.0.1` first and falls back to `[::1]` so local-only auth
/// flows remain restricted to the local machine.
///
/// When `OAUTH_CALLBACK_HOST` is set to a remote address, binds to that
/// specific address so only connections directed to it are accepted.
pub async fn bind_callback_listener() -> Result<TcpListener, OAuthCallbackError> {
let host = callback_host();
if is_loopback_host(&host) {
// Local mode: prefer IPv4 loopback, fall back to IPv6.
let ipv4_addr = format!("127.0.0.1:{}", OAUTH_CALLBACK_PORT);
match TcpListener::bind(&ipv4_addr).await {
Ok(listener) => return Ok(listener),
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {
return Err(OAuthCallbackError::PortInUse(
OAUTH_CALLBACK_PORT,
e.to_string(),
));
}
Err(_) => {
// IPv4 not available, fall back to IPv6
}
}
TcpListener::bind(format!("[::1]:{}", OAUTH_CALLBACK_PORT))
.await
.map_err(bind_error)
} else {
// Remote mode: bind to the specific configured host address only,
// not 0.0.0.0, to limit exposure to the intended interface.
let addr = format!("{}:{}", host, OAUTH_CALLBACK_PORT);
TcpListener::bind(&addr).await.map_err(bind_error)
}
}
/// Wait for an OAuth callback and extract a query parameter value.
///
/// Listens for a GET request matching `path_prefix` (e.g., "/callback" or "/auth/callback"),
/// extracts the value of `param_name` (e.g., "code" or "token"), and shows a branded
/// landing page using `display_name` (e.g., "Google", "Notion", "NEAR AI").
///
/// When `expected_state` is `Some`, the callback's `state` query parameter is validated
/// against it to prevent CSRF attacks. If the state doesn't match, the callback is
/// rejected with an error page.
///
/// Times out after 5 minutes.
pub async fn wait_for_callback(
listener: TcpListener,
path_prefix: &str,
param_name: &str,
display_name: &str,
expected_state: Option<&str>,
) -> Result<String, OAuthCallbackError> {
let path_prefix = path_prefix.to_string();
let param_name = param_name.to_string();
let display_name = display_name.to_string();
let expected_state = expected_state.map(String::from);
tokio::time::timeout(Duration::from_secs(300), async move {
loop {
let (mut socket, _) = listener
.accept()
.await
.map_err(|e| OAuthCallbackError::Io(e.to_string()))?;
let mut reader = BufReader::new(&mut socket);
let mut request_line = String::new();
reader
.read_line(&mut request_line)
.await
.map_err(|e| OAuthCallbackError::Io(e.to_string()))?;
if let Some(path) = request_line.split_whitespace().nth(1)
&& path.starts_with(&path_prefix)
&& let Some(query) = path.split('?').nth(1)
{
// Check for error first
if query.contains("error=") {
let html = landing_html(&display_name, false);
let response = format!(
"HTTP/1.1 400 Bad Request\r\n\
Content-Type: text/html; charset=utf-8\r\n\
Connection: close\r\n\
\r\n\
{}",
html
);
let _ = socket.write_all(response.as_bytes()).await;
return Err(OAuthCallbackError::Denied);
}
// Parse all query params into a map for validation
let params: HashMap<&str, String> = query
.split('&')
.filter_map(|p| {
let mut parts = p.splitn(2, '=');
let key = parts.next()?;
let val = parts.next().unwrap_or("");
Some((
key,
urlencoding::decode(val)
.unwrap_or_else(|_| val.into())
.into_owned(),
))
})
.collect();
// Validate CSRF state parameter
if let Some(ref expected) = expected_state {
let actual = params.get("state").cloned().unwrap_or_default();
if actual != *expected {
let html = landing_html(&display_name, false);
let response = format!(
"HTTP/1.1 403 Forbidden\r\n\
Content-Type: text/html; charset=utf-8\r\n\
Connection: close\r\n\
\r\n\
{}",
html
);
let _ = socket.write_all(response.as_bytes()).await;
return Err(OAuthCallbackError::StateMismatch {
expected: expected.clone(),
actual,
});
}
}
// Look for the target parameter
if let Some(value) = params.get(param_name.as_str()) {
let html = landing_html(&display_name, true);
let response = format!(
"HTTP/1.1 200 OK\r\n\
Content-Type: text/html; charset=utf-8\r\n\
Connection: close\r\n\
\r\n\
{}",
html
);
let _ = socket.write_all(response.as_bytes()).await;
let _ = socket.shutdown().await;
return Ok(value.clone());
}
}
// Not the callback we're looking for
let response = "HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n";
let _ = socket.write_all(response.as_bytes()).await;
}
})
.await
.map_err(|_| OAuthCallbackError::Timeout)?
}
/// Escape a string for safe interpolation into HTML content.
fn html_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&#x27;"),
_ => out.push(c),
}
}
out
}
// ── Shared OAuth flow steps ─────────────────────────────────────────
@@ -349,6 +598,93 @@ pub async fn validate_oauth_token(
}
}
// ── Landing pages ───────────────────────────────────────────────────
pub fn landing_html(provider_name: &str, success: bool) -> String {
let safe_name = html_escape(provider_name);
let (icon, heading, subtitle, accent) = if success {
(
r##"<div style="width:64px;height:64px;border-radius:50%;background:#22c55e;display:flex;align-items:center;justify-content:center;margin:0 auto 24px">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</div>"##,
format!("{} Connected", safe_name),
"You can close this window and return to your terminal.",
"#22c55e",
)
} else {
(
r##"<div style="width:64px;height:64px;border-radius:50%;background:#ef4444;display:flex;align-items:center;justify-content:center;margin:0 auto 24px">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</div>"##,
"Authorization Failed".to_string(),
"The request was denied. You can close this window and try again.",
"#ef4444",
)
};
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>IronClaw - {heading}</title>
<style>
* {{ margin:0; padding:0; box-sizing:border-box }}
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: #0a0a0a;
color: #e5e5e5;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}}
.card {{
text-align: center;
padding: 48px 40px;
max-width: 420px;
border: 1px solid #262626;
border-radius: 16px;
background: #141414;
}}
h1 {{
font-size: 22px;
font-weight: 600;
margin-bottom: 8px;
color: #fafafa;
}}
p {{
font-size: 14px;
color: #a3a3a3;
line-height: 1.5;
}}
.accent {{ color: {accent}; }}
.brand {{
margin-top: 32px;
font-size: 12px;
color: #525252;
letter-spacing: 0.5px;
text-transform: uppercase;
}}
</style>
</head>
<body>
<div class="card">
{icon}
<h1>{heading}</h1>
<p>{subtitle}</p>
<div class="brand">IronClaw</div>
</div>
</body>
</html>"#,
heading = heading,
icon = icon,
subtitle = subtitle,
accent = accent,
)
}
// ── Gateway callback support ─────────────────────────────────────────
/// State for an in-progress OAuth flow, keyed by CSRF `state` parameter.
+1 -1
View File
@@ -83,7 +83,7 @@ pub async fn run_status_command() -> anyhow::Result<()> {
// Session / Auth
print!(" Session: ");
let session_path = crate::config::llm::default_session_path();
let session_path = crate::llm::session::default_session_path();
if session_path.exists() {
println!("found ({})", session_path.display());
} else {

Some files were not shown because too many files have changed in this diff Show More