mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 07:30:11 +00:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34ad608a9a | ||
|
|
0d8b26a00f | ||
|
|
3d4ccd884e | ||
|
|
a268790b88 | ||
|
|
3a2989d009 | ||
|
|
94d101924e | ||
|
|
a868b14221 | ||
|
|
a95f5ebb05 | ||
|
|
83950d11a4 | ||
|
|
764be8547f | ||
|
|
7de639e782 | ||
|
|
a5f88b32fd | ||
|
|
7d8576a464 | ||
|
|
f4b7309523 | ||
|
|
577e26eff4 | ||
|
|
bcbdc273a5 | ||
|
|
c541220ea4 | ||
|
|
14aadd3063 | ||
|
|
45923ef360 |
@@ -0,0 +1,303 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
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
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
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
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
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
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
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
|
||||
}
|
||||
```
|
||||
@@ -115,6 +115,8 @@ 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
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, labeled]
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -28,6 +28,7 @@ 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:
|
||||
|
||||
@@ -44,6 +44,7 @@ jobs:
|
||||
|
||||
clippy-windows:
|
||||
name: Clippy Windows (${{ matrix.name }})
|
||||
if: github.base_ref == 'main'
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -76,7 +77,12 @@ jobs:
|
||||
needs: [format, clippy, clippy-windows]
|
||||
steps:
|
||||
- run: |
|
||||
if [[ "${{ needs.format.result }}" != "success" || "${{ needs.clippy.result }}" != "success" || "${{ needs.clippy-windows.result }}" != "success" ]]; then
|
||||
if [[ "${{ needs.format.result }}" != "success" || "${{ needs.clippy.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
|
||||
|
||||
@@ -115,7 +115,6 @@ 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 }}
|
||||
@@ -230,7 +229,6 @@ 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,6 +2,8 @@ name: Run Tests
|
||||
on:
|
||||
workflow_call:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
@@ -25,3 +25,6 @@ bench-results/
|
||||
|
||||
# Traces
|
||||
trace_*.json
|
||||
|
||||
# Local Claude Code settings (machine-specific, should not be committed)
|
||||
.claude/settings.local.json
|
||||
|
||||
@@ -1,73 +1,37 @@
|
||||
# IronClaw Development Guide
|
||||
|
||||
## 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
|
||||
**IronClaw** is a secure personal AI assistant — user-first security, self-expanding tools, defense in depth, multi-channel access with proactive background execution.
|
||||
|
||||
## Build & Test
|
||||
|
||||
```bash
|
||||
# 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
|
||||
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
|
||||
```
|
||||
|
||||
### Test Tiers
|
||||
E2E tests: see `tests/e2e/CLAUDE.md`.
|
||||
|
||||
| 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 |
|
||||
## Code Style
|
||||
|
||||
Run `bash scripts/check-boundaries.sh` to verify test tier gating and other architecture rules.
|
||||
- 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.
|
||||
|
||||
## Project Structure
|
||||
|
||||
@@ -95,12 +59,6 @@ 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)
|
||||
@@ -111,62 +69,38 @@ 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 # 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
|
||||
│ └── config.rs, tool.rs, registry.rs, mcp.rs, memory.rs, pairing.rs, service.rs, doctor.rs, status.rs, completion.rs
|
||||
│
|
||||
├── 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
|
||||
│ ├── artifacts.rs # Artifact download and caching
|
||||
│ └── embedded.rs # Catalog compiled into binary at build time (via build.rs)
|
||||
│ └── installer.rs # RegistryInstaller: download, verify, install WASM artifacts
|
||||
│
|
||||
├── 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
|
||||
├── hooks/ # Lifecycle hooks (6 points: BeforeInbound, BeforeToolCall, BeforeOutbound, OnSessionStart, OnSessionEnd, TransformResponse)
|
||||
│
|
||||
├── tunnel/ # Tunnel abstraction for public internet exposure
|
||||
│ ├── mod.rs # Tunnel trait, TunnelProviderConfig, create_tunnel() factory
|
||||
│ ├── mod.rs # Tunnel trait, TunnelProviderConfig, create_tunnel(), start_managed_tunnel()
|
||||
│ ├── 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
|
||||
│ ├── 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)
|
||||
├── observability/ # Pluggable event/metric recording (noop, log, multi)
|
||||
│
|
||||
├── 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
|
||||
│ ├── 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
|
||||
@@ -174,30 +108,15 @@ 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 (headers, URL params)
|
||||
│ └── credential_detect.rs # HTTP request credential detection
|
||||
│
|
||||
├── 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
|
||||
│ ├── 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)
|
||||
│ ├── 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)
|
||||
│ ├── builder/ # Dynamic tool building
|
||||
│ │ ├── core.rs # BuildRequirement, SoftwareType, Language
|
||||
│ │ ├── templates.rs # Project scaffolding
|
||||
@@ -205,6 +124,7 @@ 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)
|
||||
@@ -221,132 +141,55 @@ src/
|
||||
│
|
||||
├── db/ # Dual-backend persistence (PostgreSQL + libSQL) — see src/db/CLAUDE.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
|
||||
├── workspace/ # Persistent memory system — see src/workspace/README.md
|
||||
│
|
||||
├── 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
|
||||
├── context/ # Job context isolation (JobState, JobContext, ContextManager)
|
||||
├── estimation/ # Cost/time/value estimation with EMA learning
|
||||
├── evaluation/ # Success evaluation (rule-based, LLM-based)
|
||||
│
|
||||
├── sandbox/ # Docker execution sandbox
|
||||
│ ├── mod.rs # Public API, default allowlist
|
||||
│ ├── config.rs # SandboxConfig, SandboxPolicy enum
|
||||
│ ├── config.rs # SandboxConfig, SandboxPolicy enum (ReadOnly/WorkspaceWrite/FullAccess)
|
||||
│ ├── manager.rs # SandboxManager orchestration
|
||||
│ ├── container.rs # ContainerRunner, Docker lifecycle
|
||||
│ ├── 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
|
||||
│ └── proxy/ # Network proxy: domain allowlist, credential injection, CONNECT tunnel
|
||||
│
|
||||
├── 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
|
||||
├── secrets/ # Secrets management (AES-256-GCM, OS keychain for master key)
|
||||
│
|
||||
├── 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)
|
||||
├── setup/ # 7-step onboarding wizard — see src/setup/README.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
|
||||
├── skills/ # SKILL.md prompt extension system — see .claude/rules/skills.md
|
||||
│
|
||||
└── history/ # Persistence
|
||||
├── store.rs # PostgreSQL repositories
|
||||
└── analytics.rs # Aggregation queries (JobStats, ToolStats)
|
||||
└── history/ # Persistence (PostgreSQL repositories, analytics)
|
||||
|
||||
tests/
|
||||
├── *.rs # Integration tests (workspace, heartbeat, WS gateway, pairing, etc.)
|
||||
├── test-pages/ # HTML→Markdown conversion fixtures (CNN, Medium, Yahoo)
|
||||
├── test-pages/ # HTML→Markdown conversion fixtures
|
||||
└── e2e/ # Python/Playwright E2E scenarios (see tests/e2e/CLAUDE.md)
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
## Database
|
||||
|
||||
### Architecture
|
||||
Dual-backend: PostgreSQL + libSQL/Turso. **All new persistence features must support both backends.** See `src/db/CLAUDE.md` and `.claude/rules/database.md`.
|
||||
|
||||
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.
|
||||
## Module Specs
|
||||
|
||||
### 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
|
||||
When modifying a module with a spec, read the spec first. Code follows spec; spec is the tiebreaker.
|
||||
|
||||
### Async
|
||||
- All I/O is async with tokio
|
||||
- Use `Arc<T>` for shared state across tasks
|
||||
- Use `RwLock` for concurrent read/write access
|
||||
**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.
|
||||
|
||||
### 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
|
||||
| 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` |
|
||||
|
||||
### 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"]
|
||||
})
|
||||
}
|
||||
## Job State Machine
|
||||
|
||||
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
|
||||
@@ -354,315 +197,17 @@ Pending -> InProgress -> Completed -> Submitted -> Accepted
|
||||
\-> Failed
|
||||
```
|
||||
|
||||
### Code Style
|
||||
## Skills System
|
||||
|
||||
- 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
|
||||
SKILL.md files extend the agent's prompt with domain-specific instructions. See `.claude/rules/skills.md` for full details.
|
||||
|
||||
### 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
|
||||
- **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`
|
||||
|
||||
## Configuration
|
||||
|
||||
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.
|
||||
See `.env.example` for all environment variables. LLM backends (`nearai`, `openai`, `anthropic`, `ollama`, `openai_compatible`, `tinfoil`, `bedrock`) documented in `src/llm/CLAUDE.md`.
|
||||
|
||||
## Adding a New Channel
|
||||
|
||||
@@ -671,48 +216,24 @@ See `src/tools/README.md` for full tool architecture, adding new tools (built-in
|
||||
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
|
||||
# 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
|
||||
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
|
||||
```
|
||||
|
||||
## Module Specifications
|
||||
## Current Limitations
|
||||
|
||||
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.
|
||||
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)
|
||||
|
||||
+51
-44
@@ -10,6 +10,8 @@ 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
|
||||
@@ -39,11 +41,11 @@ 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) | ✅ | ✅ | `fs4` flock-based, acquired in `main.rs` before agent startup |
|
||||
| Gateway lock (PID-based) | ✅ | ❌ | |
|
||||
| launchd/systemd integration | ✅ | ❌ | |
|
||||
| Bonjour/mDNS discovery | ✅ | ❌ | |
|
||||
| Tailscale integration | ✅ | ❌ | |
|
||||
| Health check endpoints | ✅ | ✅ | /api/health + /api/gateway/status |
|
||||
| Health check endpoints | ✅ | ✅ | /api/health + /api/gateway/status + /healthz + /readyz, with channel-backed readiness probes |
|
||||
| `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 |
|
||||
@@ -66,17 +68,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 |
|
||||
| Telegram | ✅ | ✅ | - | WASM channel(MTProto), DM pairing, caption, /start, bot_username, DM topics |
|
||||
| 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 |
|
||||
| Feishu/Lark | ✅ | ❌ | P3 | Bitable create app/field tools, Docx table/image/file actions, rich-text media extraction |
|
||||
| LINE | ✅ | ❌ | P3 | |
|
||||
| WebChat | ✅ | ✅ | - | Web gateway chat |
|
||||
| Matrix | ✅ | ❌ | P3 | E2EE support |
|
||||
| Mattermost | ✅ | ❌ | P3 | Emoji reactions |
|
||||
| Mattermost | ✅ | ❌ | P3 | Emoji reactions, interactive buttons, model picker |
|
||||
| Google Chat | ✅ | ❌ | P3 | |
|
||||
| MS Teams | ✅ | ❌ | P3 | |
|
||||
| Twitch | ✅ | ❌ | P3 | |
|
||||
@@ -92,6 +94,8 @@ 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)
|
||||
|
||||
@@ -107,21 +111,36 @@ 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 |
|
||||
| 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 |
|
||||
|
||||
### Channel Features
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| DM pairing codes | ✅ | ✅ | `ironclaw pairing list/approve`, host APIs |
|
||||
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
|
||||
| Allowlist/blocklist | ✅ | 🚧 | `allow_from` + pairing store + hardened command/group allowlists |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| Group session priming | ✅ | ❌ | Member roster injected for context |
|
||||
| Sender_id in trusted metadata | ✅ | ❌ | Exposed in system metadata |
|
||||
|
||||
@@ -138,7 +157,8 @@ 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 |
|
||||
| `config` | ✅ | ✅ | - | Read/write config plus validate/path helpers |
|
||||
| `backup` | ✅ | ❌ | P3 | Create/verify local backup archives |
|
||||
| `channels` | ✅ | ❌ | P2 | Channel management |
|
||||
| `models` | ✅ | 🚧 | - | Model selector in TUI |
|
||||
| `status` | ✅ | ✅ | - | System status (enriched session details) |
|
||||
@@ -177,14 +197,15 @@ 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 (low/med/high) | ✅ | ❌ | Configurable reasoning depth |
|
||||
| Per-model thinkingDefault override | ✅ | ❌ | Override thinking level per model |
|
||||
| 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 |
|
||||
| Block-level streaming | ✅ | ❌ | |
|
||||
| Tool-level streaming | ✅ | ❌ | |
|
||||
| Z.AI tool_stream | ✅ | ❌ | Real-time tool call streaming |
|
||||
@@ -213,15 +234,11 @@ 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 |
|
||||
| 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` |
|
||||
| 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 |
|
||||
| OpenRouter | ✅ | ✅ | - | Via OpenAI-compatible provider (RigAdapter) |
|
||||
| Tinfoil | ❌ | ✅ | - | Private inference provider (IronClaw-only) |
|
||||
| OpenAI-compatible | ❌ | ✅ | - | Generic OpenAI-compatible endpoint (RigAdapter) |
|
||||
@@ -242,7 +259,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 beta header | ✅ | ❌ | Anthropic extended context support |
|
||||
| 1M context support | ✅ | ❌ | Anthropic extended context beta + OpenAI Codex GPT-5.4 1M context |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -252,32 +269,20 @@ 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 parsing | ✅ | ❌ | P2 | pdfjs-dist |
|
||||
| MIME detection | ✅ | ✅ | P2 | MIME allowlist in host validates attachment types |
|
||||
| PDF analysis tool | ✅ | ❌ | P2 | Native Anthropic/Gemini path with text/image extraction fallback |
|
||||
| PDF parsing | ✅ | ❌ | P2 | `pdfjs-dist` fallback path |
|
||||
| MIME detection | ✅ | ❌ | P2 | |
|
||||
| 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 emitted as image/webp attachments |
|
||||
| Sticker-to-image | ✅ | ❌ | P3 | Telegram stickers |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -293,7 +298,8 @@ 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 |
|
||||
| Memory plugins | ✅ | ❌ | Custom backends + selectable memory slot |
|
||||
| Context-engine plugins | ✅ | ❌ | Custom context management + subagent/context hooks |
|
||||
| Tool plugins | ✅ | ✅ | WASM tools |
|
||||
| Hook plugins | ✅ | ✅ | Declarative hooks from extension capabilities |
|
||||
| Provider plugins | ✅ | ❌ | |
|
||||
@@ -315,7 +321,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 |
|
||||
| Config validation/schema | ✅ | ✅ | Type-safe Config struct + `openclaw config validate` |
|
||||
| Hot-reload | ✅ | ❌ | |
|
||||
| Legacy migration | ✅ | ➖ | |
|
||||
| State directory | ✅ `~/.openclaw-state/` | ✅ `~/.ironclaw/` | |
|
||||
@@ -422,6 +428,7 @@ 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 |
|
||||
@@ -475,10 +482,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) |
|
||||
| Path traversal prevention | ✅ | ✅ | Including config includes (OC-06) + workspace-only tool mounts |
|
||||
| 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 | ✅ | ❌ | Prevent arbitrary write targets |
|
||||
| Skill download path restriction | ✅ | ❌ | Validated download roots prevent arbitrary write targets |
|
||||
| Webhook signature verification | ✅ | ✅ | |
|
||||
| Media URL validation | ✅ | ❌ | |
|
||||
| Prompt injection defense | ✅ | ✅ | Pattern detection, sanitization |
|
||||
|
||||
@@ -209,6 +209,56 @@ 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
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -446,6 +446,8 @@ impl Agent {
|
||||
Arc::clone(workspace),
|
||||
notify_tx,
|
||||
Some(self.scheduler.clone()),
|
||||
self.tools().clone(),
|
||||
self.safety().clone(),
|
||||
));
|
||||
|
||||
// Register routine tools
|
||||
@@ -514,7 +516,7 @@ impl Agent {
|
||||
*slot.write().await = Some(Arc::clone(&engine));
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
"Routines enabled: cron ticker every {}s, max {} concurrent",
|
||||
rt_config.cron_check_interval_secs,
|
||||
rt_config.max_concurrent_routines
|
||||
@@ -536,20 +538,20 @@ impl Agent {
|
||||
let routine_engine_for_loop = routine_handle.as_ref().map(|(_, e)| Arc::clone(e));
|
||||
|
||||
// Main message loop
|
||||
tracing::info!("Agent {} ready and listening", self.config.name);
|
||||
tracing::debug!("Agent {} ready and listening", self.config.name);
|
||||
|
||||
loop {
|
||||
let message = tokio::select! {
|
||||
biased;
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
tracing::info!("Ctrl+C received, shutting down...");
|
||||
tracing::debug!("Ctrl+C received, shutting down...");
|
||||
break;
|
||||
}
|
||||
msg = message_stream.next() => {
|
||||
match msg {
|
||||
Some(m) => m,
|
||||
None => {
|
||||
tracing::info!("All channel streams ended, shutting down...");
|
||||
tracing::debug!("All channel streams ended, shutting down...");
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -624,7 +626,7 @@ impl Agent {
|
||||
}
|
||||
Ok(None) => {
|
||||
// Shutdown signal received (/quit, /exit, /shutdown)
|
||||
tracing::info!("Shutdown command received, exiting...");
|
||||
tracing::debug!("Shutdown command received, exiting...");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -653,7 +655,7 @@ impl Agent {
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
tracing::info!("Agent shutting down...");
|
||||
tracing::debug!("Agent shutting down...");
|
||||
repair_handle.abort();
|
||||
pruning_handle.abort();
|
||||
if let Some(handle) = heartbeat_handle {
|
||||
|
||||
@@ -1205,6 +1205,7 @@ mod tests {
|
||||
max_tool_iterations: 50,
|
||||
auto_approve_tools: false,
|
||||
default_timezone: "UTC".to_string(),
|
||||
max_tokens_per_job: 0,
|
||||
},
|
||||
deps,
|
||||
Arc::new(ChannelManager::new()),
|
||||
@@ -1263,6 +1264,96 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_always_approval_requirement_bypasses_session_auto_approve() {
|
||||
// Regression test: even if tool is auto-approved in session,
|
||||
// ApprovalRequirement::Always must still trigger approval.
|
||||
use crate::tools::ApprovalRequirement;
|
||||
|
||||
let mut session = Session::new("user-1");
|
||||
let tool_name = "tool_remove";
|
||||
|
||||
// Manually auto-approve tool_remove in this session
|
||||
session.auto_approve_tool(tool_name);
|
||||
assert!(
|
||||
session.is_tool_auto_approved(tool_name),
|
||||
"tool should be auto-approved"
|
||||
);
|
||||
|
||||
// However, ApprovalRequirement::Always should always require approval
|
||||
// This is verified by the dispatcher logic: Always => true (ignores session state)
|
||||
let always_req = ApprovalRequirement::Always;
|
||||
let requires_approval = match always_req {
|
||||
ApprovalRequirement::Never => false,
|
||||
ApprovalRequirement::UnlessAutoApproved => !session.is_tool_auto_approved(tool_name),
|
||||
ApprovalRequirement::Always => true,
|
||||
};
|
||||
|
||||
assert!(
|
||||
requires_approval,
|
||||
"ApprovalRequirement::Always must require approval even when tool is auto-approved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_always_approval_requirement_vs_unless_auto_approved() {
|
||||
// Verify the two requirements behave differently
|
||||
use crate::tools::ApprovalRequirement;
|
||||
|
||||
let mut session = Session::new("user-2");
|
||||
let tool_name = "http";
|
||||
|
||||
// Scenario 1: Tool is auto-approved
|
||||
session.auto_approve_tool(tool_name);
|
||||
|
||||
// UnlessAutoApproved → doesn't require approval if auto-approved
|
||||
let unless_req = ApprovalRequirement::UnlessAutoApproved;
|
||||
let unless_needs = match unless_req {
|
||||
ApprovalRequirement::Never => false,
|
||||
ApprovalRequirement::UnlessAutoApproved => !session.is_tool_auto_approved(tool_name),
|
||||
ApprovalRequirement::Always => true,
|
||||
};
|
||||
assert!(
|
||||
!unless_needs,
|
||||
"UnlessAutoApproved should not need approval when auto-approved"
|
||||
);
|
||||
|
||||
// Always → always requires approval
|
||||
let always_req = ApprovalRequirement::Always;
|
||||
let always_needs = match always_req {
|
||||
ApprovalRequirement::Never => false,
|
||||
ApprovalRequirement::UnlessAutoApproved => !session.is_tool_auto_approved(tool_name),
|
||||
ApprovalRequirement::Always => true,
|
||||
};
|
||||
assert!(
|
||||
always_needs,
|
||||
"Always must always require approval, even when auto-approved"
|
||||
);
|
||||
|
||||
// Scenario 2: Tool is NOT auto-approved
|
||||
let new_tool = "new_tool";
|
||||
assert!(!session.is_tool_auto_approved(new_tool));
|
||||
|
||||
// UnlessAutoApproved → requires approval
|
||||
let unless_needs = match unless_req {
|
||||
ApprovalRequirement::Never => false,
|
||||
ApprovalRequirement::UnlessAutoApproved => !session.is_tool_auto_approved(new_tool),
|
||||
ApprovalRequirement::Always => true,
|
||||
};
|
||||
assert!(
|
||||
unless_needs,
|
||||
"UnlessAutoApproved should need approval when not auto-approved"
|
||||
);
|
||||
|
||||
// Always → always requires approval
|
||||
let always_needs = match always_req {
|
||||
ApprovalRequirement::Never => false,
|
||||
ApprovalRequirement::UnlessAutoApproved => !session.is_tool_auto_approved(new_tool),
|
||||
ApprovalRequirement::Always => true,
|
||||
};
|
||||
assert!(always_needs, "Always must always require approval");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pending_approval_serialization_backcompat_without_deferred_calls() {
|
||||
// PendingApproval from before the deferred_tool_calls field was added
|
||||
@@ -1953,6 +2044,7 @@ mod tests {
|
||||
max_tool_iterations,
|
||||
auto_approve_tools: true,
|
||||
default_timezone: "UTC".to_string(),
|
||||
max_tokens_per_job: 0,
|
||||
},
|
||||
deps,
|
||||
Arc::new(ChannelManager::new()),
|
||||
@@ -2069,6 +2161,7 @@ mod tests {
|
||||
max_tool_iterations: max_iter,
|
||||
auto_approve_tools: true,
|
||||
default_timezone: "UTC".to_string(),
|
||||
max_tokens_per_job: 0,
|
||||
},
|
||||
deps,
|
||||
Arc::new(ChannelManager::new()),
|
||||
|
||||
+459
-14
@@ -25,10 +25,14 @@ 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};
|
||||
use crate::tools::ApprovalContext;
|
||||
use crate::llm::{
|
||||
ChatMessage, CompletionRequest, FinishReason, LlmProvider, ToolCall, ToolCompletionRequest,
|
||||
};
|
||||
use crate::safety::SafetyLayer;
|
||||
use crate::tools::{ApprovalContext, ApprovalRequirement, ToolError, ToolRegistry, redact_params};
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
/// The routine execution engine.
|
||||
@@ -45,9 +49,14 @@ pub struct RoutineEngine {
|
||||
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>,
|
||||
@@ -55,6 +64,8 @@ impl RoutineEngine {
|
||||
workspace: Arc<Workspace>,
|
||||
notify_tx: mpsc::Sender<OutgoingResponse>,
|
||||
scheduler: Option<Arc<Scheduler>>,
|
||||
tools: Arc<ToolRegistry>,
|
||||
safety: Arc<SafetyLayer>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
@@ -65,6 +76,8 @@ impl RoutineEngine {
|
||||
running_count: Arc::new(AtomicUsize::new(0)),
|
||||
event_cache: Arc::new(RwLock::new(Vec::new())),
|
||||
scheduler,
|
||||
tools,
|
||||
safety,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,12 +253,15 @@ 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 {
|
||||
@@ -272,12 +288,15 @@ 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
|
||||
@@ -319,12 +338,15 @@ 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.
|
||||
@@ -538,7 +560,10 @@ async fn execute_full_job(
|
||||
Ok((RunStatus::Ok, Some(summary), None))
|
||||
}
|
||||
|
||||
/// Execute a lightweight routine (single LLM call).
|
||||
/// 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).
|
||||
async fn execute_lightweight(
|
||||
ctx: &EngineContext,
|
||||
routine: &Routine,
|
||||
@@ -570,7 +595,7 @@ async fn execute_lightweight(
|
||||
Err(_) => None,
|
||||
};
|
||||
|
||||
// Build the prompt
|
||||
// Build the user-facing prompt
|
||||
let mut full_prompt = String::new();
|
||||
full_prompt.push_str(prompt);
|
||||
|
||||
@@ -598,15 +623,6 @@ 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) => {
|
||||
@@ -616,6 +632,45 @@ 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);
|
||||
@@ -631,7 +686,7 @@ async fn execute_lightweight(
|
||||
let content = response.content.trim();
|
||||
let tokens_used = Some((response.input_tokens + response.output_tokens) as i32);
|
||||
|
||||
// Empty content guard (same as heartbeat)
|
||||
// Empty content guard
|
||||
if content.is_empty() {
|
||||
return if response.finish_reason == FinishReason::Length {
|
||||
Err(RoutineError::TruncatedResponse)
|
||||
@@ -648,6 +703,269 @@ async fn execute_lightweight(
|
||||
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());
|
||||
}
|
||||
|
||||
let safe_params = redact_params(&tc.arguments, tool.sensitive_params());
|
||||
tracing::debug!(
|
||||
tool = %tc.name,
|
||||
params = %safe_params,
|
||||
"Lightweight routine tool call started"
|
||||
);
|
||||
|
||||
// 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();
|
||||
|
||||
match &result {
|
||||
Ok(Ok(_)) => {
|
||||
tracing::debug!(
|
||||
tool = %tc.name,
|
||||
elapsed_ms = elapsed.as_millis() as u64,
|
||||
"Lightweight routine tool call succeeded"
|
||||
);
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::debug!(
|
||||
tool = %tc.name,
|
||||
elapsed_ms = elapsed.as_millis() as u64,
|
||||
error = %e,
|
||||
"Lightweight routine tool call failed"
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::debug!(
|
||||
tool = %tc.name,
|
||||
elapsed_ms = elapsed.as_millis() as u64,
|
||||
timeout_secs = timeout.as_secs(),
|
||||
"Lightweight routine tool call timed out"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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>,
|
||||
@@ -727,6 +1045,7 @@ 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() {
|
||||
@@ -755,4 +1074,130 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,6 +160,13 @@ impl Scheduler {
|
||||
.create_job_for_user(user_id, title, description)
|
||||
.await?;
|
||||
|
||||
// Apply token budget from config, allowing per-job metadata override.
|
||||
let max_tokens = metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("max_tokens"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(self.config.max_tokens_per_job);
|
||||
|
||||
// Apply metadata if provided
|
||||
if let Some(meta) = metadata {
|
||||
self.context_manager
|
||||
@@ -169,6 +176,15 @@ impl Scheduler {
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Set token budget (separate update to avoid overwriting metadata)
|
||||
if max_tokens > 0 {
|
||||
self.context_manager
|
||||
.update_context(job_id, |ctx| {
|
||||
ctx.max_tokens = max_tokens;
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Persist to DB before scheduling so the worker's FK references are valid
|
||||
if let Some(ref store) = self.store {
|
||||
let ctx = self.context_manager.get_context(job_id).await?;
|
||||
|
||||
+100
-3
@@ -417,7 +417,8 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
|
||||
iteration += 1;
|
||||
if iteration > max_iterations {
|
||||
self.mark_stuck("Maximum iterations exceeded").await?;
|
||||
self.mark_failed("Maximum iterations exceeded: job hit the iteration cap")
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -437,7 +438,8 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
"LLM rate limited during tool selection, backing off"
|
||||
);
|
||||
if consecutive_rate_limits >= MAX_CONSECUTIVE_RATE_LIMITS {
|
||||
self.mark_stuck("Persistent rate limiting").await?;
|
||||
self.mark_failed("Persistent rate limiting: exceeded retry limit")
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
self.log_event(
|
||||
@@ -467,7 +469,8 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
"LLM rate limited during respond_with_tools, backing off"
|
||||
);
|
||||
if consecutive_rate_limits >= MAX_CONSECUTIVE_RATE_LIMITS {
|
||||
self.mark_stuck("Persistent rate limiting").await?;
|
||||
self.mark_failed("Persistent rate limiting: exceeded retry limit")
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
self.log_event(
|
||||
@@ -483,6 +486,20 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
// Track token usage from LLM call against the job budget.
|
||||
// NOTE: select_tools() also makes LLM calls but doesn't expose
|
||||
// TokenUsage; only respond_with_tools() usage is tracked here.
|
||||
let total_tokens = respond_output.usage.total() as u64;
|
||||
if total_tokens > 0
|
||||
&& let Err(msg) = self
|
||||
.context_manager()
|
||||
.update_context(self.job_id, |ctx| ctx.add_tokens(total_tokens))
|
||||
.await?
|
||||
{
|
||||
self.mark_failed(&msg).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match respond_output.result {
|
||||
RespondResult::Text(response) => {
|
||||
// Check for explicit completion phrases. Use word-boundary
|
||||
@@ -1762,4 +1779,84 @@ mod tests {
|
||||
"Always tool should be allowed with permission"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_token_budget_exceeded_fails_job() {
|
||||
let worker = make_worker(vec![]).await;
|
||||
|
||||
// Transition to InProgress (required for mark_failed)
|
||||
worker
|
||||
.context_manager()
|
||||
.update_context(worker.job_id, |ctx| {
|
||||
ctx.transition_to(JobState::InProgress, None)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
// Set a token budget
|
||||
worker
|
||||
.context_manager()
|
||||
.update_context(worker.job_id, |ctx| {
|
||||
ctx.max_tokens = 100;
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Simulate adding tokens that exceed the budget
|
||||
let budget_result = worker
|
||||
.context_manager()
|
||||
.update_context(worker.job_id, |ctx| ctx.add_tokens(200))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
budget_result.is_err(),
|
||||
"Should return error when token budget exceeded"
|
||||
);
|
||||
|
||||
// Verify that mark_failed transitions job to Failed
|
||||
worker
|
||||
.mark_failed(&budget_result.unwrap_err())
|
||||
.await
|
||||
.unwrap();
|
||||
let ctx = worker
|
||||
.context_manager()
|
||||
.get_context(worker.job_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ctx.state, JobState::Failed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_iteration_cap_marks_failed_not_stuck() {
|
||||
let worker = make_worker(vec![]).await;
|
||||
|
||||
// Transition to InProgress (required for mark_failed)
|
||||
worker
|
||||
.context_manager()
|
||||
.update_context(worker.job_id, |ctx| {
|
||||
ctx.transition_to(JobState::InProgress, None)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
// Simulate what the execution loop does when max_iterations is exceeded
|
||||
worker
|
||||
.mark_failed("Maximum iterations exceeded: job hit the iteration cap")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let ctx = worker
|
||||
.context_manager()
|
||||
.get_context(worker.job_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ctx.state,
|
||||
JobState::Failed,
|
||||
"Iteration cap should transition to Failed, not Stuck"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+45
-203
@@ -77,10 +77,7 @@ pub struct AppBuilder {
|
||||
llm_override: Option<Arc<dyn LlmProvider>>,
|
||||
|
||||
// Backend-specific handles needed by secrets store
|
||||
#[cfg(feature = "postgres")]
|
||||
pg_pool: Option<deadpool_postgres::Pool>,
|
||||
#[cfg(feature = "libsql")]
|
||||
libsql_db: Option<Arc<libsql::Database>>,
|
||||
handles: Option<crate::db::DatabaseHandles>,
|
||||
}
|
||||
|
||||
impl AppBuilder {
|
||||
@@ -105,10 +102,7 @@ impl AppBuilder {
|
||||
db: None,
|
||||
secrets_store: None,
|
||||
llm_override: None,
|
||||
#[cfg(feature = "postgres")]
|
||||
pg_pool: None,
|
||||
#[cfg(feature = "libsql")]
|
||||
libsql_db: None,
|
||||
handles: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,71 +131,10 @@ impl AppBuilder {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
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."
|
||||
);
|
||||
}
|
||||
};
|
||||
let (db, handles) = crate::db::connect_with_handles(&self.config.database)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
self.handles = Some(handles);
|
||||
|
||||
// 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 {
|
||||
@@ -212,7 +145,7 @@ impl AppBuilder {
|
||||
match Config::from_db_with_toml(db.as_ref(), "default", toml_path).await {
|
||||
Ok(db_config) => {
|
||||
self.config = db_config;
|
||||
tracing::info!("Configuration reloaded from database");
|
||||
tracing::debug!("Configuration reloaded from database");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
@@ -251,10 +184,7 @@ impl AppBuilder {
|
||||
crate::config::inject_os_credentials();
|
||||
|
||||
// Consume unused handles
|
||||
#[cfg(feature = "libsql")]
|
||||
{
|
||||
self.libsql_db.take();
|
||||
}
|
||||
self.handles.take();
|
||||
|
||||
// Re-resolve only the LLM config with OS credentials.
|
||||
let store: Option<&(dyn crate::db::SettingsStore + Sync)> =
|
||||
@@ -278,35 +208,16 @@ impl AppBuilder {
|
||||
Ok(c) => Arc::new(c),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to initialize secrets crypto: {}", e);
|
||||
#[cfg(feature = "libsql")]
|
||||
{
|
||||
self.libsql_db.take();
|
||||
}
|
||||
self.handles.take();
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
})
|
||||
});
|
||||
// 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);
|
||||
|
||||
if let Some(ref secrets) = store {
|
||||
// Inject LLM API keys from encrypted storage
|
||||
@@ -363,7 +274,7 @@ impl AppBuilder {
|
||||
anyhow::Error,
|
||||
> {
|
||||
let safety = Arc::new(SafetyLayer::new(&self.config.safety));
|
||||
tracing::info!("Safety layer initialized");
|
||||
tracing::debug!("Safety layer initialized");
|
||||
|
||||
// Initialize tool registry with credential injection support
|
||||
let credential_registry = Arc::new(SharedCredentialRegistry::new());
|
||||
@@ -450,7 +361,7 @@ impl AppBuilder {
|
||||
tools
|
||||
.register_builder_tool(llm.clone(), Some(self.config.builder.to_builder_config()))
|
||||
.await;
|
||||
tracing::info!("Builder mode enabled");
|
||||
tracing::debug!("Builder mode enabled");
|
||||
}
|
||||
|
||||
Ok((safety, tools, embeddings, workspace))
|
||||
@@ -472,9 +383,7 @@ impl AppBuilder {
|
||||
),
|
||||
anyhow::Error,
|
||||
> {
|
||||
use crate::tools::mcp::{
|
||||
McpClient, McpTransport, config::load_mcp_servers_from_db, is_authenticated,
|
||||
};
|
||||
use crate::tools::mcp::config::load_mcp_servers_from_db;
|
||||
use crate::tools::wasm::{WasmToolLoader, load_dev_tools};
|
||||
|
||||
let mcp_session_manager = Arc::new(McpSessionManager::new());
|
||||
@@ -510,7 +419,7 @@ impl AppBuilder {
|
||||
match loader.load_from_dir(&wasm_config.tools_dir).await {
|
||||
Ok(results) => {
|
||||
if !results.loaded.is_empty() {
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
"Loaded {} WASM tools from {}",
|
||||
results.loaded.len(),
|
||||
wasm_config.tools_dir.display()
|
||||
@@ -533,7 +442,7 @@ impl AppBuilder {
|
||||
Ok(results) => {
|
||||
dev_loaded_tool_names.extend(results.loaded.iter().cloned());
|
||||
if !dev_loaded_tool_names.is_empty() {
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
"Loaded {} dev WASM tools from build artifacts",
|
||||
dev_loaded_tool_names.len()
|
||||
);
|
||||
@@ -565,7 +474,10 @@ impl AppBuilder {
|
||||
Ok(servers) => {
|
||||
let enabled: Vec<_> = servers.enabled_servers().cloned().collect();
|
||||
if !enabled.is_empty() {
|
||||
tracing::info!("Loading {} configured MCP server(s)...", enabled.len());
|
||||
tracing::debug!(
|
||||
"Loading {} configured MCP server(s)...",
|
||||
enabled.len()
|
||||
);
|
||||
}
|
||||
|
||||
let mut join_set = tokio::task::JoinSet::new();
|
||||
@@ -578,95 +490,24 @@ impl AppBuilder {
|
||||
join_set.spawn(async move {
|
||||
let server_name = server.name.clone();
|
||||
|
||||
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 { .. } => {
|
||||
let client = match crate::tools::mcp::create_client_from_config(
|
||||
server,
|
||||
&mcp_sm,
|
||||
&pm,
|
||||
secrets,
|
||||
"default",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Unix socket transport is not supported on this platform (server '{}')",
|
||||
server_name
|
||||
"Failed to create MCP client for '{}': {}",
|
||||
server_name,
|
||||
e
|
||||
);
|
||||
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 {
|
||||
@@ -677,7 +518,7 @@ impl AppBuilder {
|
||||
for tool in tool_impls {
|
||||
tools.register(tool).await;
|
||||
}
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
"Loaded {} tools from MCP server '{}'",
|
||||
tool_count,
|
||||
server_name
|
||||
@@ -738,7 +579,7 @@ impl AppBuilder {
|
||||
.iter()
|
||||
.map(|m| m.to_registry_entry())
|
||||
.collect();
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
count = entries.len(),
|
||||
"Loaded registry catalog entries for extension discovery"
|
||||
);
|
||||
@@ -767,6 +608,7 @@ 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)),
|
||||
@@ -779,7 +621,7 @@ impl AppBuilder {
|
||||
catalog_entries.clone(),
|
||||
));
|
||||
tools.register_extension_tools(Arc::clone(&manager));
|
||||
tracing::info!("Extension manager initialized with in-chat discovery tools");
|
||||
tracing::debug!("Extension manager initialized with in-chat discovery tools");
|
||||
Some(manager)
|
||||
};
|
||||
|
||||
@@ -850,7 +692,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::info!("Imported {} workspace file(s) from {}", count, import_dir);
|
||||
tracing::debug!("Imported {} workspace file(s) from {}", count, import_dir);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
@@ -875,7 +717,7 @@ impl AppBuilder {
|
||||
tokio::spawn(async move {
|
||||
match ws_bg.backfill_embeddings().await {
|
||||
Ok(count) if count > 0 => {
|
||||
tracing::info!("Backfilled embeddings for {} chunks", count);
|
||||
tracing::debug!("Backfilled embeddings for {} chunks", count);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
@@ -892,7 +734,7 @@ impl AppBuilder {
|
||||
.with_installed_dir(self.config.skills.installed_dir.clone());
|
||||
let loaded = registry.discover_all().await;
|
||||
if !loaded.is_empty() {
|
||||
tracing::info!("Loaded {} skill(s): {}", loaded.len(), loaded.join(", "));
|
||||
tracing::debug!("Loaded {} skill(s): {}", loaded.len(), loaded.join(", "));
|
||||
}
|
||||
let registry = Arc::new(std::sync::RwLock::new(registry));
|
||||
let catalog = crate::skills::catalog::shared_catalog();
|
||||
@@ -910,7 +752,7 @@ impl AppBuilder {
|
||||
},
|
||||
));
|
||||
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
"Tool registry initialized with {} total tools",
|
||||
tools.count()
|
||||
);
|
||||
|
||||
@@ -198,6 +198,58 @@ 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
|
||||
@@ -1237,4 +1289,108 @@ 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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,6 +347,7 @@ pub trait Channel: Send + Sync {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testing::credentials::TEST_REDACT_SECRET_123;
|
||||
|
||||
/// Stub tool that marks `"value"` as sensitive.
|
||||
struct SecretTool;
|
||||
@@ -376,7 +377,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tool_completed_redacts_sensitive_params_on_failure() {
|
||||
let params = serde_json::json!({"name": "api_key", "value": "sk-secret-123"});
|
||||
let params = serde_json::json!({"name": "api_key", "value": TEST_REDACT_SECRET_123});
|
||||
let err: Result<String, crate::error::Error> =
|
||||
Err(crate::error::ToolError::ExecutionFailed {
|
||||
name: "secret_save".into(),
|
||||
@@ -411,7 +412,7 @@ mod tests {
|
||||
param_str
|
||||
);
|
||||
assert!(
|
||||
!param_str.contains("sk-secret-123"),
|
||||
!param_str.contains(TEST_REDACT_SECRET_123),
|
||||
"raw secret should not appear: {}",
|
||||
param_str
|
||||
);
|
||||
|
||||
@@ -75,7 +75,7 @@ impl ChannelManager {
|
||||
break;
|
||||
}
|
||||
}
|
||||
tracing::info!(channel = %name, "Hot-added channel stream ended");
|
||||
tracing::debug!(channel = %name, "Hot-added channel stream ended");
|
||||
});
|
||||
|
||||
Ok(())
|
||||
@@ -92,7 +92,7 @@ impl ChannelManager {
|
||||
for (name, channel) in channels.iter() {
|
||||
match channel.start().await {
|
||||
Ok(stream) => {
|
||||
tracing::info!("Started channel: {}", name);
|
||||
tracing::debug!("Started channel: {}", name);
|
||||
streams.push(stream);
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -184,18 +184,32 @@ impl WasmChannelLoader {
|
||||
/// └── telegram.capabilities.json
|
||||
/// ```
|
||||
pub async fn load_from_dir(&self, dir: &Path) -> Result<LoadResults, WasmChannelError> {
|
||||
if !dir.is_dir() {
|
||||
return Err(WasmChannelError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::NotADirectory,
|
||||
format!("{} is not a directory", dir.display()),
|
||||
)));
|
||||
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)),
|
||||
}
|
||||
|
||||
let mut results = LoadResults::default();
|
||||
|
||||
// Collect all .wasm entries first, then load in parallel
|
||||
let mut channel_entries = Vec::new();
|
||||
let mut entries = fs::read_dir(dir).await?;
|
||||
// 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)),
|
||||
};
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
@@ -486,4 +500,21 @@ 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ mod loader;
|
||||
mod router;
|
||||
mod runtime;
|
||||
mod schema;
|
||||
pub mod setup;
|
||||
pub(crate) mod signature;
|
||||
#[allow(dead_code)]
|
||||
pub(crate) mod storage;
|
||||
@@ -105,4 +106,5 @@ 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};
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
//! 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.
|
||||
if let Some(secrets) = secrets_store {
|
||||
match inject_channel_credentials(&channel_arc, secrets.as_ref(), &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 with the uppercase name if not found
|
||||
/// in the secrets store (e.g., `TELEGRAM_BOT_TOKEN`).
|
||||
pub async fn inject_channel_credentials(
|
||||
channel: &Arc<WasmChannel>,
|
||||
secrets: &dyn SecretsStore,
|
||||
channel_name: &str,
|
||||
) -> anyhow::Result<usize> {
|
||||
let all_secrets = secrets
|
||||
.list("default")
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to list secrets: {}", e))?;
|
||||
|
||||
let prefix = format!("{}_", channel_name);
|
||||
let mut count = 0;
|
||||
let mut injected_placeholders = HashSet::new();
|
||||
|
||||
for secret_meta in all_secrets {
|
||||
if !secret_meta.name.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;
|
||||
}
|
||||
|
||||
// Fall back to environment variables for required secrets not found in the store.
|
||||
// This allows channels to work when configured via env vars (e.g., TELEGRAM_BOT_TOKEN)
|
||||
// without requiring the setup wizard to have run.
|
||||
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 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)
|
||||
}
|
||||
@@ -3059,6 +3059,7 @@ 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 {
|
||||
@@ -4009,7 +4010,7 @@ mod tests {
|
||||
let mut creds = std::collections::HashMap::new();
|
||||
creds.insert(
|
||||
"TELEGRAM_BOT_TOKEN".to_string(),
|
||||
"8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis".to_string(),
|
||||
TEST_TELEGRAM_BOT_TOKEN.to_string(),
|
||||
);
|
||||
creds.insert("OTHER_SECRET".to_string(), "s3cret".to_string());
|
||||
|
||||
@@ -4022,13 +4023,15 @@ mod tests {
|
||||
Arc::new(PairingStore::new()),
|
||||
);
|
||||
|
||||
let error = "HTTP request failed: error sending request for url \
|
||||
(https://api.telegram.org/bot8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis/getUpdates)";
|
||||
let error = format!(
|
||||
"HTTP request failed: error sending request for url \
|
||||
(https://api.telegram.org/bot{TEST_TELEGRAM_BOT_TOKEN}/getUpdates)"
|
||||
);
|
||||
|
||||
let redacted = store.redact_credentials(error);
|
||||
let redacted = store.redact_credentials(&error);
|
||||
|
||||
assert!(
|
||||
!redacted.contains("8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis"),
|
||||
!redacted.contains(TEST_TELEGRAM_BOT_TOKEN),
|
||||
"credential value should be redacted"
|
||||
);
|
||||
assert!(
|
||||
|
||||
+27
-26
@@ -83,14 +83,15 @@ 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-token".to_string(),
|
||||
token: TEST_BEARER_TOKEN.to_string(),
|
||||
};
|
||||
let cloned = state.clone();
|
||||
assert_eq!(cloned.token, "test-token");
|
||||
assert_eq!(cloned.token, TEST_BEARER_TOKEN);
|
||||
}
|
||||
|
||||
use axum::Router;
|
||||
@@ -120,10 +121,10 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_valid_bearer_token_passes() {
|
||||
let app = test_app("secret-token");
|
||||
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
||||
let req = Request::builder()
|
||||
.uri("/api/chat/events")
|
||||
.header("Authorization", "Bearer secret-token")
|
||||
.header("Authorization", format!("Bearer {TEST_AUTH_SECRET_TOKEN}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
@@ -132,7 +133,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_invalid_bearer_token_rejected() {
|
||||
let app = test_app("secret-token");
|
||||
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
||||
let req = Request::builder()
|
||||
.uri("/api/chat/events")
|
||||
.header("Authorization", "Bearer wrong-token")
|
||||
@@ -144,9 +145,9 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_query_token_allowed_for_chat_events() {
|
||||
let app = test_app("secret-token");
|
||||
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
||||
let req = Request::builder()
|
||||
.uri("/api/chat/events?token=secret-token")
|
||||
.uri(format!("/api/chat/events?token={TEST_AUTH_SECRET_TOKEN}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
@@ -155,9 +156,9 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_query_token_allowed_for_logs_events() {
|
||||
let app = test_app("secret-token");
|
||||
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
||||
let req = Request::builder()
|
||||
.uri("/api/logs/events?token=secret-token")
|
||||
.uri(format!("/api/logs/events?token={TEST_AUTH_SECRET_TOKEN}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
@@ -166,9 +167,9 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_query_token_allowed_for_ws_upgrade() {
|
||||
let app = test_app("secret-token");
|
||||
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
||||
let req = Request::builder()
|
||||
.uri("/api/chat/ws?token=secret-token")
|
||||
.uri(format!("/api/chat/ws?token={TEST_AUTH_SECRET_TOKEN}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
@@ -202,9 +203,9 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_query_token_rejected_for_non_sse_get() {
|
||||
let app = test_app("secret-token");
|
||||
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
||||
let req = Request::builder()
|
||||
.uri("/api/chat/history?token=secret-token")
|
||||
.uri(format!("/api/chat/history?token={TEST_AUTH_SECRET_TOKEN}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
@@ -213,10 +214,10 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_query_token_rejected_for_post() {
|
||||
let app = test_app("secret-token");
|
||||
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
||||
let req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/api/chat/send?token=secret-token")
|
||||
.uri(format!("/api/chat/send?token={TEST_AUTH_SECRET_TOKEN}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
@@ -225,7 +226,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_query_token_invalid_rejected() {
|
||||
let app = test_app("secret-token");
|
||||
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
||||
let req = Request::builder()
|
||||
.uri("/api/chat/events?token=wrong-token")
|
||||
.body(Body::empty())
|
||||
@@ -236,7 +237,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_no_auth_at_all_rejected() {
|
||||
let app = test_app("secret-token");
|
||||
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
||||
let req = Request::builder()
|
||||
.uri("/api/chat/events")
|
||||
.body(Body::empty())
|
||||
@@ -247,11 +248,11 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_bearer_header_works_for_post() {
|
||||
let app = test_app("secret-token");
|
||||
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
||||
let req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/api/chat/send")
|
||||
.header("Authorization", "Bearer secret-token")
|
||||
.header("Authorization", format!("Bearer {TEST_AUTH_SECRET_TOKEN}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
@@ -260,10 +261,10 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_bearer_prefix_case_insensitive() {
|
||||
let app = test_app("secret-token");
|
||||
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
||||
let req = Request::builder()
|
||||
.uri("/api/chat/events")
|
||||
.header("Authorization", "bearer secret-token")
|
||||
.header("Authorization", format!("bearer {TEST_AUTH_SECRET_TOKEN}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
@@ -272,10 +273,10 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_bearer_prefix_mixed_case() {
|
||||
let app = test_app("secret-token");
|
||||
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
||||
let req = Request::builder()
|
||||
.uri("/api/chat/events")
|
||||
.header("Authorization", "BEARER secret-token")
|
||||
.header("Authorization", format!("BEARER {TEST_AUTH_SECRET_TOKEN}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
@@ -284,7 +285,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_empty_bearer_token_rejected() {
|
||||
let app = test_app("secret-token");
|
||||
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
||||
let req = Request::builder()
|
||||
.uri("/api/chat/events")
|
||||
.header("Authorization", "Bearer ")
|
||||
@@ -296,10 +297,10 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_token_with_whitespace_rejected() {
|
||||
let app = test_app("secret-token");
|
||||
let app = test_app(TEST_AUTH_SECRET_TOKEN);
|
||||
let req = Request::builder()
|
||||
.uri("/api/chat/events")
|
||||
.header("Authorization", "Bearer secret-token")
|
||||
.header("Authorization", format!("Bearer {TEST_AUTH_SECRET_TOKEN}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
|
||||
@@ -276,11 +276,25 @@ pub async fn jobs_cancel_handler(
|
||||
})));
|
||||
}
|
||||
|
||||
// Fall back to agent job cancellation via DB status update.
|
||||
// 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.
|
||||
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,
|
||||
|
||||
@@ -108,6 +108,7 @@ 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();
|
||||
|
||||
@@ -252,6 +253,7 @@ 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();
|
||||
|
||||
|
||||
@@ -370,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::info!("Web gateway shutting down");
|
||||
tracing::debug!("Web gateway shutting down");
|
||||
})
|
||||
.await
|
||||
{
|
||||
@@ -2017,6 +2017,7 @@ 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();
|
||||
|
||||
@@ -2169,6 +2170,7 @@ 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();
|
||||
|
||||
@@ -2425,6 +2427,7 @@ 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() {
|
||||
@@ -2598,7 +2601,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-key-at-least-32-chars-long!!".to_string(),
|
||||
TEST_GATEWAY_CRYPTO_KEY.to_string(),
|
||||
))
|
||||
.expect("crypto"),
|
||||
)));
|
||||
@@ -2607,6 +2610,7 @@ mod tests {
|
||||
|
||||
let ext_mgr = Arc::new(ExtensionManager::new(
|
||||
mcp_sm,
|
||||
Arc::new(crate::tools::mcp::process::McpProcessManager::new()),
|
||||
secrets,
|
||||
tool_registry,
|
||||
None,
|
||||
@@ -2647,7 +2651,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-key-at-least-32-chars-long!!".to_string(),
|
||||
TEST_GATEWAY_CRYPTO_KEY.to_string(),
|
||||
))
|
||||
.expect("crypto"),
|
||||
)));
|
||||
@@ -2656,6 +2660,7 @@ mod tests {
|
||||
|
||||
let ext_mgr = Arc::new(ExtensionManager::new(
|
||||
mcp_sm,
|
||||
Arc::new(crate::tools::mcp::process::McpProcessManager::new()),
|
||||
secrets.clone(),
|
||||
tool_registry,
|
||||
None,
|
||||
@@ -2752,7 +2757,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-key-at-least-32-chars-long!!".to_string(),
|
||||
TEST_GATEWAY_CRYPTO_KEY.to_string(),
|
||||
))
|
||||
.expect("crypto"),
|
||||
)));
|
||||
@@ -2761,6 +2766,7 @@ mod tests {
|
||||
|
||||
let ext_mgr = Arc::new(ExtensionManager::new(
|
||||
mcp_sm,
|
||||
Arc::new(crate::tools::mcp::process::McpProcessManager::new()),
|
||||
secrets.clone(),
|
||||
tool_registry,
|
||||
None,
|
||||
|
||||
@@ -776,6 +776,7 @@ pub struct RoutineRunInfo {
|
||||
pub status: String,
|
||||
pub result_summary: Option<String>,
|
||||
pub tokens_used: Option<i32>,
|
||||
pub job_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
// --- Settings ---
|
||||
|
||||
@@ -68,7 +68,7 @@ impl WebhookServer {
|
||||
if let Err(e) = axum::serve(listener, app)
|
||||
.with_graceful_shutdown(async {
|
||||
let _ = shutdown_rx.await;
|
||||
tracing::info!("Webhook server shutting down");
|
||||
tracing::debug!("Webhook server shutting down");
|
||||
})
|
||||
.await
|
||||
{
|
||||
|
||||
+1
-1
@@ -107,7 +107,7 @@ enum CheckResult {
|
||||
|
||||
async fn check_nearai_session() -> CheckResult {
|
||||
// Check if session file exists
|
||||
let session_path = crate::llm::session::default_session_path();
|
||||
let session_path = crate::config::llm::default_session_path();
|
||||
if !session_path.exists() {
|
||||
// Check for API key mode
|
||||
if std::env::var("NEARAI_API_KEY").is_ok() {
|
||||
|
||||
+2
-12
@@ -10,7 +10,7 @@ use clap::{Args, Subcommand};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::db::Database;
|
||||
use crate::secrets::{SecretsCrypto, SecretsStore};
|
||||
use crate::secrets::SecretsStore;
|
||||
use crate::tools::mcp::{
|
||||
McpClient, McpServerConfig, McpSessionManager, OAuthConfig,
|
||||
auth::{authorize_mcp_server, is_authenticated},
|
||||
@@ -628,17 +628,7 @@ async fn save_servers(
|
||||
|
||||
/// Initialize and return the secrets store.
|
||||
async fn get_secrets_store() -> anyhow::Result<Arc<dyn SecretsStore + Send + Sync>> {
|
||||
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?)
|
||||
crate::cli::init_secrets_store().await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+45
-4
@@ -28,8 +28,6 @@ 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};
|
||||
@@ -37,6 +35,8 @@ 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,12 +94,16 @@ pub enum Command {
|
||||
skip_auth: bool,
|
||||
|
||||
/// Reconfigure channels only
|
||||
#[arg(long, conflicts_with = "provider_only")]
|
||||
#[arg(long, conflicts_with_all = ["provider_only", "quick"])]
|
||||
channels_only: bool,
|
||||
|
||||
/// Reconfigure LLM provider and model only
|
||||
#[arg(long, conflicts_with = "channels_only")]
|
||||
#[arg(long, conflicts_with_all = ["channels_only", "quick"])]
|
||||
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
|
||||
@@ -225,6 +229,43 @@ 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::*;
|
||||
|
||||
+6
-342
@@ -24,8 +24,6 @@ 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};
|
||||
@@ -64,259 +62,12 @@ pub fn builtin_credentials(secret_name: &str) -> Option<OAuthCredentials> {
|
||||
|
||||
// ── Shared callback server ──────────────────────────────────────────────
|
||||
|
||||
/// 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("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
// 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,
|
||||
};
|
||||
|
||||
// ── Shared OAuth flow steps ─────────────────────────────────────────
|
||||
|
||||
@@ -598,93 +349,6 @@ 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
@@ -83,7 +83,7 @@ pub async fn run_status_command() -> anyhow::Result<()> {
|
||||
|
||||
// Session / Auth
|
||||
print!(" Session: ");
|
||||
let session_path = crate::llm::session::default_session_path();
|
||||
let session_path = crate::config::llm::default_session_path();
|
||||
if session_path.exists() {
|
||||
println!("found ({})", session_path.display());
|
||||
} else {
|
||||
|
||||
+2
-12
@@ -10,8 +10,7 @@ use clap::Subcommand;
|
||||
use tokio::fs;
|
||||
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
use crate::config::Config;
|
||||
use crate::secrets::{CreateSecretParams, SecretsCrypto, SecretsStore};
|
||||
use crate::secrets::{CreateSecretParams, SecretsStore};
|
||||
use crate::tools::wasm::{CapabilitiesFile, compute_binary_hash};
|
||||
|
||||
/// Default tools directory.
|
||||
@@ -552,16 +551,7 @@ fn validate_tool_name(name: &str) -> anyhow::Result<()> {
|
||||
|
||||
/// Initialize the secrets store from environment config.
|
||||
async fn init_secrets_store() -> anyhow::Result<Arc<dyn SecretsStore + Send + Sync>> {
|
||||
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?)
|
||||
crate::cli::init_secrets_store().await
|
||||
}
|
||||
|
||||
/// Configure authentication for a tool.
|
||||
|
||||
@@ -29,6 +29,8 @@ pub struct AgentConfig {
|
||||
pub auto_approve_tools: bool,
|
||||
/// Default timezone for new sessions (IANA name, e.g. "America/New_York").
|
||||
pub default_timezone: String,
|
||||
/// Maximum tokens per job (0 = unlimited).
|
||||
pub max_tokens_per_job: u64,
|
||||
}
|
||||
|
||||
impl AgentConfig {
|
||||
@@ -50,6 +52,7 @@ impl AgentConfig {
|
||||
max_tool_iterations: 10,
|
||||
auto_approve_tools: true,
|
||||
default_timezone: "UTC".to_string(),
|
||||
max_tokens_per_job: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +108,10 @@ impl AgentConfig {
|
||||
}
|
||||
tz
|
||||
},
|
||||
max_tokens_per_job: parse_optional_env(
|
||||
"AGENT_MAX_TOKENS_PER_JOB",
|
||||
settings.agent.max_tokens_per_job,
|
||||
)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,13 +100,13 @@ impl EmbeddingsConfig {
|
||||
session: Arc<SessionManager>,
|
||||
) -> Option<Arc<dyn EmbeddingProvider>> {
|
||||
if !self.enabled {
|
||||
tracing::info!("Embeddings disabled (set EMBEDDING_ENABLED=true to enable)");
|
||||
tracing::debug!("Embeddings disabled (set EMBEDDING_ENABLED=true to enable)");
|
||||
return None;
|
||||
}
|
||||
|
||||
match self.provider.as_str() {
|
||||
"nearai" => {
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
"Embeddings enabled via NEAR AI (model: {}, dim: {})",
|
||||
self.model,
|
||||
self.dimension,
|
||||
@@ -117,7 +117,7 @@ impl EmbeddingsConfig {
|
||||
))
|
||||
}
|
||||
"ollama" => {
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
"Embeddings enabled via Ollama (model: {}, url: {}, dim: {})",
|
||||
self.model,
|
||||
self.ollama_base_url,
|
||||
@@ -130,7 +130,7 @@ impl EmbeddingsConfig {
|
||||
}
|
||||
_ => {
|
||||
if let Some(api_key) = self.openai_api_key() {
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
"Embeddings enabled via OpenAI (model: {}, dim: {})",
|
||||
self.model,
|
||||
self.dimension,
|
||||
@@ -154,6 +154,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::config::helpers::ENV_MUTEX;
|
||||
use crate::settings::{EmbeddingsSettings, Settings};
|
||||
use crate::testing::credentials::*;
|
||||
|
||||
/// Clear all embedding-related env vars.
|
||||
fn clear_embedding_env() {
|
||||
@@ -173,7 +174,7 @@ mod tests {
|
||||
clear_embedding_env();
|
||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||
unsafe {
|
||||
std::env::set_var("OPENAI_API_KEY", "sk-test-key-for-issue-129");
|
||||
std::env::set_var("OPENAI_API_KEY", TEST_OPENAI_API_KEY_ISSUE_129);
|
||||
}
|
||||
|
||||
let settings = Settings {
|
||||
|
||||
+28
-156
@@ -5,158 +5,11 @@ use secrecy::SecretString;
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
use crate::config::helpers::{optional_env, parse_optional_env};
|
||||
use crate::error::ConfigError;
|
||||
use crate::llm::config::*;
|
||||
use crate::llm::registry::{ProviderProtocol, ProviderRegistry};
|
||||
use crate::llm::session::SessionConfig;
|
||||
use crate::settings::Settings;
|
||||
|
||||
/// Sentinel value used as `api_key` when only an OAuth token is present.
|
||||
///
|
||||
/// When we only have an OAuth token the provider factory in `llm/mod.rs`
|
||||
/// checks for this value and routes to `AnthropicOAuthProvider`, so this
|
||||
/// placeholder is never sent over the wire.
|
||||
pub const OAUTH_PLACEHOLDER: &str = "oauth-placeholder";
|
||||
|
||||
/// Prompt cache retention policy for Anthropic.
|
||||
///
|
||||
/// Controls Anthropic's automatic prompt caching via a top-level
|
||||
/// `cache_control` field injected through rig-core's `additional_params`.
|
||||
/// - `None` — caching disabled, no `cache_control` injected.
|
||||
/// - `Short` — 5-minute TTL (default), `{"type": "ephemeral"}`, 1.25× write surcharge.
|
||||
/// - `Long` — 1-hour TTL, `{"type": "ephemeral", "ttl": "1h"}`, 2× write surcharge.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum CacheRetention {
|
||||
/// No prompt caching.
|
||||
None,
|
||||
/// 5-minute TTL (default). Write cost: 1.25× base input.
|
||||
#[default]
|
||||
Short,
|
||||
/// 1-hour TTL. Write cost: 2× base input.
|
||||
Long,
|
||||
}
|
||||
|
||||
impl std::str::FromStr for CacheRetention {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"none" | "off" | "disabled" => Ok(Self::None),
|
||||
"short" | "5m" | "ephemeral" => Ok(Self::Short),
|
||||
"long" | "1h" => Ok(Self::Long),
|
||||
_ => Err(format!(
|
||||
"invalid cache retention '{}', expected one of: none, short, long",
|
||||
s
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CacheRetention {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::None => write!(f, "none"),
|
||||
Self::Short => write!(f, "short"),
|
||||
Self::Long => write!(f, "long"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved configuration for a registry-based provider.
|
||||
///
|
||||
/// This single struct replaces what used to be five separate config types
|
||||
/// (`OpenAiDirectConfig`, `AnthropicDirectConfig`, `OllamaConfig`,
|
||||
/// `OpenAiCompatibleConfig`, `TinfoilConfig`). The `protocol` field
|
||||
/// determines which rig-core client constructor to use.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RegistryProviderConfig {
|
||||
/// Which API protocol to use (determines the rig-core client).
|
||||
pub protocol: ProviderProtocol,
|
||||
/// Provider identifier (e.g., "groq", "openai", "tinfoil").
|
||||
pub provider_id: String,
|
||||
/// API key (optional for some providers like Ollama).
|
||||
/// For Anthropic OAuth, this is set to `OAUTH_PLACEHOLDER`.
|
||||
pub api_key: Option<SecretString>,
|
||||
/// Base URL for the API endpoint.
|
||||
pub base_url: String,
|
||||
/// Model identifier.
|
||||
pub model: String,
|
||||
/// Extra HTTP headers injected into every request.
|
||||
pub extra_headers: Vec<(String, String)>,
|
||||
/// OAuth token for providers that support Bearer auth (e.g. Anthropic via `claude login`).
|
||||
/// When set, the provider factory routes to the OAuth-specific provider implementation.
|
||||
pub oauth_token: Option<SecretString>,
|
||||
}
|
||||
|
||||
/// Configuration for AWS Bedrock (native Converse API).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BedrockConfig {
|
||||
/// AWS region (e.g. "us-east-1").
|
||||
pub region: String,
|
||||
/// Bedrock model ID (e.g. "anthropic.claude-opus-4-6-v1").
|
||||
pub model: String,
|
||||
/// Cross-region inference prefix: "us", "eu", "apac", "global", or None.
|
||||
pub cross_region: Option<String>,
|
||||
/// AWS named profile (for SSO / assume-role workflows).
|
||||
pub profile: Option<String>,
|
||||
}
|
||||
|
||||
/// LLM provider configuration.
|
||||
///
|
||||
/// NearAI remains the default backend with its own config struct (session auth).
|
||||
/// All other providers are resolved through the provider registry, producing
|
||||
/// a generic `RegistryProviderConfig`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LlmConfig {
|
||||
/// Backend identifier (e.g., "nearai", "openai", "groq", "tinfoil").
|
||||
pub backend: String,
|
||||
/// Session manager configuration (auth URL, token persistence path).
|
||||
/// Used by the NearAI provider for OAuth/session-token auth.
|
||||
pub session: SessionConfig,
|
||||
/// NEAR AI config (always populated, also used for embeddings).
|
||||
pub nearai: NearAiConfig,
|
||||
/// Resolved provider config for registry-based providers.
|
||||
/// `None` when backend is "nearai" or "bedrock".
|
||||
pub provider: Option<RegistryProviderConfig>,
|
||||
/// AWS Bedrock config (populated when backend=bedrock, requires --features bedrock).
|
||||
pub bedrock: Option<BedrockConfig>,
|
||||
/// HTTP request timeout in seconds for LLM API calls.
|
||||
/// Default: 120. Increase for local LLMs (Ollama, vLLM, LM Studio) that
|
||||
/// need more time for prompt evaluation on consumer hardware.
|
||||
pub request_timeout_secs: u64,
|
||||
}
|
||||
|
||||
/// NEAR AI configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NearAiConfig {
|
||||
/// Model to use (e.g., "claude-3-5-sonnet-20241022", "gpt-4o")
|
||||
pub model: String,
|
||||
/// Cheap/fast model for lightweight tasks (heartbeat, routing, evaluation).
|
||||
pub cheap_model: Option<String>,
|
||||
/// Base URL for the NEAR AI API.
|
||||
pub base_url: String,
|
||||
/// API key for NEAR AI Cloud.
|
||||
pub api_key: Option<SecretString>,
|
||||
/// Optional fallback model for failover.
|
||||
pub fallback_model: Option<String>,
|
||||
/// Maximum number of retries for transient errors (default: 3).
|
||||
pub max_retries: u32,
|
||||
/// Consecutive failures before circuit breaker opens. None = disabled.
|
||||
pub circuit_breaker_threshold: Option<u32>,
|
||||
/// Seconds the circuit stays open before probing (default: 30).
|
||||
pub circuit_breaker_recovery_secs: u64,
|
||||
/// Enable in-memory response caching. Default: false.
|
||||
pub response_cache_enabled: bool,
|
||||
/// TTL in seconds for cached responses (default: 3600).
|
||||
pub response_cache_ttl_secs: u64,
|
||||
/// Max cached responses before LRU eviction (default: 1000).
|
||||
pub response_cache_max_entries: usize,
|
||||
/// Cooldown duration in seconds for failover (default: 300).
|
||||
pub failover_cooldown_secs: u64,
|
||||
/// Consecutive failures before failover cooldown (default: 3).
|
||||
pub failover_cooldown_threshold: u32,
|
||||
/// Enable cascade mode for smart routing. Default: true.
|
||||
pub smart_routing_cascade: bool,
|
||||
}
|
||||
|
||||
impl LlmConfig {
|
||||
/// Create a test-friendly config without reading env vars.
|
||||
#[cfg(feature = "libsql")]
|
||||
@@ -459,6 +312,23 @@ impl LlmConfig {
|
||||
api_key
|
||||
};
|
||||
|
||||
// Resolve Anthropic prompt cache retention from env (default: Short).
|
||||
let cache_retention: CacheRetention = if canonical_id == "anthropic" {
|
||||
optional_env("ANTHROPIC_CACHE_RETENTION")?
|
||||
.and_then(|val| match val.parse::<CacheRetention>() {
|
||||
Ok(r) => Some(r),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Invalid ANTHROPIC_CACHE_RETENTION: {e}; defaulting to short"
|
||||
);
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
CacheRetention::default()
|
||||
};
|
||||
|
||||
Ok(RegistryProviderConfig {
|
||||
protocol,
|
||||
provider_id: canonical_id.to_string(),
|
||||
@@ -467,6 +337,7 @@ impl LlmConfig {
|
||||
model,
|
||||
extra_headers,
|
||||
oauth_token,
|
||||
cache_retention,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -505,7 +376,7 @@ fn parse_extra_headers(val: &str) -> Result<Vec<(String, String)>, ConfigError>
|
||||
}
|
||||
|
||||
/// Get the default session file path (~/.ironclaw/session.json).
|
||||
fn default_session_path() -> PathBuf {
|
||||
pub fn default_session_path() -> PathBuf {
|
||||
ironclaw_base_dir().join("session.json")
|
||||
}
|
||||
|
||||
@@ -514,6 +385,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::config::helpers::ENV_MUTEX;
|
||||
use crate::settings::Settings;
|
||||
use crate::testing::credentials::*;
|
||||
|
||||
/// Clear all openai-compatible-related env vars.
|
||||
fn clear_openai_compatible_env() {
|
||||
@@ -776,7 +648,7 @@ mod tests {
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::set_var("LLM_BACKEND", "open_ai");
|
||||
std::env::set_var("OPENAI_API_KEY", "test-key");
|
||||
std::env::set_var("OPENAI_API_KEY", TEST_API_KEY);
|
||||
}
|
||||
|
||||
let settings = Settings::default();
|
||||
@@ -910,7 +782,7 @@ mod tests {
|
||||
clear_anthropic_env();
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::set_var("ANTHROPIC_OAUTH_TOKEN", "sk-ant-oat01-test-token");
|
||||
std::env::set_var("ANTHROPIC_OAUTH_TOKEN", TEST_ANTHROPIC_OAUTH_TOKEN);
|
||||
}
|
||||
|
||||
let settings = Settings {
|
||||
@@ -934,7 +806,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
provider.oauth_token.as_ref().unwrap().expose_secret(),
|
||||
"sk-ant-oat01-test-token"
|
||||
TEST_ANTHROPIC_OAUTH_TOKEN
|
||||
);
|
||||
|
||||
clear_anthropic_env();
|
||||
@@ -948,8 +820,8 @@ mod tests {
|
||||
clear_anthropic_env();
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::set_var("ANTHROPIC_API_KEY", "sk-ant-real-key");
|
||||
std::env::set_var("ANTHROPIC_OAUTH_TOKEN", "sk-ant-oat01-test-token");
|
||||
std::env::set_var("ANTHROPIC_API_KEY", TEST_ANTHROPIC_API_KEY);
|
||||
std::env::set_var("ANTHROPIC_OAUTH_TOKEN", TEST_ANTHROPIC_OAUTH_TOKEN);
|
||||
}
|
||||
|
||||
let settings = Settings {
|
||||
@@ -964,7 +836,7 @@ mod tests {
|
||||
.api_key
|
||||
.as_ref()
|
||||
.map(|k| k.expose_secret().to_string()),
|
||||
Some("sk-ant-real-key".to_string()),
|
||||
Some(TEST_ANTHROPIC_API_KEY.to_string()),
|
||||
"real API key should take priority over OAuth placeholder"
|
||||
);
|
||||
assert!(
|
||||
@@ -981,7 +853,7 @@ mod tests {
|
||||
clear_anthropic_env();
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::set_var("ANTHROPIC_OAUTH_TOKEN", "sk-ant-oat01-test-token");
|
||||
std::env::set_var("ANTHROPIC_OAUTH_TOKEN", TEST_ANTHROPIC_OAUTH_TOKEN);
|
||||
}
|
||||
|
||||
let settings = Settings {
|
||||
|
||||
+5
-3
@@ -37,9 +37,7 @@ pub use self::database::{DatabaseBackend, DatabaseConfig, SslMode, default_libsq
|
||||
pub use self::embeddings::EmbeddingsConfig;
|
||||
pub use self::heartbeat::HeartbeatConfig;
|
||||
pub use self::hygiene::HygieneConfig;
|
||||
pub use self::llm::{
|
||||
BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, RegistryProviderConfig,
|
||||
};
|
||||
pub use self::llm::default_session_path;
|
||||
pub use self::routines::RoutineConfig;
|
||||
pub use self::safety::SafetyConfig;
|
||||
pub use self::sandbox::{ClaudeCodeConfig, SandboxModeConfig};
|
||||
@@ -48,6 +46,10 @@ pub use self::skills::SkillsConfig;
|
||||
pub use self::transcription::TranscriptionConfig;
|
||||
pub use self::tunnel::TunnelConfig;
|
||||
pub use self::wasm::WasmConfig;
|
||||
pub use crate::llm::config::{
|
||||
BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER,
|
||||
RegistryProviderConfig,
|
||||
};
|
||||
pub use crate::llm::session::SessionConfig;
|
||||
|
||||
/// Thread-safe overlay for injected env vars (secrets loaded from DB).
|
||||
|
||||
@@ -14,6 +14,10 @@ pub struct RoutineConfig {
|
||||
pub default_cooldown_secs: u64,
|
||||
/// Max output tokens for lightweight routine LLM calls.
|
||||
pub max_lightweight_tokens: u32,
|
||||
/// Enable tool execution in lightweight routines (default: true).
|
||||
pub lightweight_tools_enabled: bool,
|
||||
/// Max tool iterations for lightweight routines (default: 3, max: 5).
|
||||
pub lightweight_max_iterations: u32,
|
||||
}
|
||||
|
||||
impl Default for RoutineConfig {
|
||||
@@ -24,18 +28,23 @@ impl Default for RoutineConfig {
|
||||
max_concurrent_routines: 10,
|
||||
default_cooldown_secs: 300,
|
||||
max_lightweight_tokens: 4096,
|
||||
lightweight_tools_enabled: true,
|
||||
lightweight_max_iterations: 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RoutineConfig {
|
||||
pub(crate) fn resolve() -> Result<Self, ConfigError> {
|
||||
let max_iterations: u32 = parse_optional_env("ROUTINES_LIGHTWEIGHT_MAX_ITERATIONS", 3)?;
|
||||
Ok(Self {
|
||||
enabled: parse_bool_env("ROUTINES_ENABLED", true)?,
|
||||
cron_check_interval_secs: parse_optional_env("ROUTINES_CRON_INTERVAL", 15)?,
|
||||
max_concurrent_routines: parse_optional_env("ROUTINES_MAX_CONCURRENT", 10)?,
|
||||
default_cooldown_secs: parse_optional_env("ROUTINES_DEFAULT_COOLDOWN", 300)?,
|
||||
max_lightweight_tokens: parse_optional_env("ROUTINES_MAX_TOKENS", 4096)?,
|
||||
lightweight_tools_enabled: parse_bool_env("ROUTINES_LIGHTWEIGHT_TOOLS", true)?,
|
||||
lightweight_max_iterations: max_iterations.min(5), // cap at 5
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+47
-10
@@ -20,6 +20,10 @@ pub struct SandboxModeConfig {
|
||||
pub auto_pull_image: bool,
|
||||
/// Additional domains to allow through the network proxy.
|
||||
pub extra_allowed_domains: Vec<String>,
|
||||
/// How often the reaper scans for orphaned containers (seconds). Default: 300 (5 min).
|
||||
pub reaper_interval_secs: u64,
|
||||
/// Containers older than this with no active job are reaped (seconds). Default: 600 (10 min).
|
||||
pub orphan_threshold_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for SandboxModeConfig {
|
||||
@@ -33,6 +37,8 @@ impl Default for SandboxModeConfig {
|
||||
image: "ironclaw-worker:latest".to_string(),
|
||||
auto_pull_image: true,
|
||||
extra_allowed_domains: Vec::new(),
|
||||
reaper_interval_secs: 300,
|
||||
orphan_threshold_secs: 600,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +49,24 @@ impl SandboxModeConfig {
|
||||
.map(|s| s.split(',').map(|d| d.trim().to_string()).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let reaper_interval_secs: u64 = parse_optional_env("SANDBOX_REAPER_INTERVAL_SECS", 300)?;
|
||||
let orphan_threshold_secs: u64 = parse_optional_env("SANDBOX_ORPHAN_THRESHOLD_SECS", 600)?;
|
||||
|
||||
// Validate that reaper timings are non-zero to prevent tokio::time::interval panics
|
||||
if reaper_interval_secs == 0 {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: "SANDBOX_REAPER_INTERVAL_SECS".to_string(),
|
||||
message: "must be greater than 0".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if orphan_threshold_secs == 0 {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: "SANDBOX_ORPHAN_THRESHOLD_SECS".to_string(),
|
||||
message: "must be greater than 0".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
enabled: parse_bool_env("SANDBOX_ENABLED", true)?,
|
||||
policy: parse_string_env("SANDBOX_POLICY", "readonly")?,
|
||||
@@ -52,6 +76,8 @@ impl SandboxModeConfig {
|
||||
image: parse_string_env("SANDBOX_IMAGE", "ironclaw-worker:latest")?,
|
||||
auto_pull_image: parse_bool_env("SANDBOX_AUTO_PULL", true)?,
|
||||
extra_allowed_domains: extra_domains,
|
||||
reaper_interval_secs,
|
||||
orphan_threshold_secs,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -246,6 +272,7 @@ fn parse_oauth_access_token(json: &str) -> Option<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::config::sandbox::*;
|
||||
use crate::testing::credentials::*;
|
||||
|
||||
// ── SandboxModeConfig defaults ──────────────────────────────────
|
||||
|
||||
@@ -273,6 +300,8 @@ mod tests {
|
||||
image: "custom-worker:v2".to_string(),
|
||||
auto_pull_image: false,
|
||||
extra_allowed_domains: vec!["example.com".to_string()],
|
||||
reaper_interval_secs: 300,
|
||||
orphan_threshold_secs: 600,
|
||||
};
|
||||
assert!(!cfg.enabled);
|
||||
assert_eq!(cfg.policy, "full_access");
|
||||
@@ -295,6 +324,8 @@ mod tests {
|
||||
image: "test:latest".to_string(),
|
||||
auto_pull_image: false,
|
||||
extra_allowed_domains: vec!["custom.example.com".to_string()],
|
||||
reaper_interval_secs: 300,
|
||||
orphan_threshold_secs: 600,
|
||||
};
|
||||
let sc = mode.to_sandbox_config();
|
||||
assert!(sc.enabled);
|
||||
@@ -375,9 +406,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_oauth_token_valid() {
|
||||
let json = r#"{"claudeAiOauth": {"accessToken": "sk-ant-oat01-fake"}}"#;
|
||||
let token = parse_oauth_access_token(json);
|
||||
assert_eq!(token, Some("sk-ant-oat01-fake".to_string()));
|
||||
let json = format!(
|
||||
r#"{{"claudeAiOauth": {{"accessToken": "{}"}}}}"#,
|
||||
TEST_ANTHROPIC_OAUTH_FAKE
|
||||
);
|
||||
let token = parse_oauth_access_token(&json);
|
||||
assert_eq!(token, Some(TEST_ANTHROPIC_OAUTH_FAKE.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -404,16 +438,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_oauth_token_nested_extra_fields() {
|
||||
let json = r#"{
|
||||
"claudeAiOauth": {
|
||||
"accessToken": "sk-ant-oat01-real-token",
|
||||
let json = format!(
|
||||
r#"{{
|
||||
"claudeAiOauth": {{
|
||||
"accessToken": "{}",
|
||||
"refreshToken": "rt-abc",
|
||||
"expiresAt": 1700000000
|
||||
}
|
||||
}"#;
|
||||
}}
|
||||
}}"#,
|
||||
TEST_ANTHROPIC_OAUTH_REAL
|
||||
);
|
||||
assert_eq!(
|
||||
parse_oauth_access_token(json),
|
||||
Some("sk-ant-oat01-real-token".to_string())
|
||||
parse_oauth_access_token(&json),
|
||||
Some(TEST_ANTHROPIC_OAUTH_REAL.to_string())
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,14 +28,16 @@ impl JobStore for LibSqlBackend {
|
||||
r#"
|
||||
INSERT INTO agent_jobs (
|
||||
id, conversation_id, title, description, category, status, source,
|
||||
user_id,
|
||||
budget_amount, budget_token, bid_amount, estimated_cost, estimated_time_secs,
|
||||
actual_cost, repair_attempts, created_at, started_at, completed_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
description = excluded.description,
|
||||
category = excluded.category,
|
||||
status = excluded.status,
|
||||
user_id = excluded.user_id,
|
||||
estimated_cost = excluded.estimated_cost,
|
||||
estimated_time_secs = excluded.estimated_time_secs,
|
||||
actual_cost = excluded.actual_cost,
|
||||
@@ -51,6 +53,7 @@ impl JobStore for LibSqlBackend {
|
||||
opt_text(ctx.category.as_deref()),
|
||||
status,
|
||||
"direct",
|
||||
ctx.user_id.as_str(),
|
||||
opt_text_owned(ctx.budget.map(|d| d.to_string())),
|
||||
opt_text(ctx.budget_token.as_deref()),
|
||||
opt_text_owned(ctx.bid_amount.map(|d| d.to_string())),
|
||||
|
||||
@@ -482,6 +482,24 @@ mod tests {
|
||||
assert_eq!(timeout, 5000);
|
||||
}
|
||||
|
||||
/// Regression test: save_job must persist user_id and get_job must return it.
|
||||
#[tokio::test]
|
||||
async fn test_save_job_persists_user_id() {
|
||||
use crate::context::JobContext;
|
||||
use crate::db::JobStore;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db_path = dir.path().join("test_user_id.db");
|
||||
let backend = LibSqlBackend::new_local(&db_path).await.unwrap();
|
||||
backend.run_migrations().await.unwrap();
|
||||
|
||||
let ctx = JobContext::with_user("test-user-42", "Test Job", "A test job");
|
||||
backend.save_job(&ctx).await.unwrap();
|
||||
|
||||
let loaded = backend.get_job(ctx.job_id).await.unwrap().unwrap();
|
||||
assert_eq!(loaded.user_id, "test-user-42");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_concurrent_writes_succeed() {
|
||||
// Use a temp file so connections share state (in-memory DBs are connection-local)
|
||||
|
||||
+33
-2
@@ -51,6 +51,29 @@ use crate::workspace::{SearchConfig, SearchResult};
|
||||
pub async fn connect_from_config(
|
||||
config: &crate::config::DatabaseConfig,
|
||||
) -> Result<Arc<dyn Database>, DatabaseError> {
|
||||
let (db, _handles) = connect_with_handles(config).await?;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
/// Backend-specific handles retained after database connection.
|
||||
///
|
||||
/// These are needed by satellite stores (e.g., `SecretsStore`) that require
|
||||
/// a backend-specific handle rather than the generic `Arc<dyn Database>`.
|
||||
#[derive(Default)]
|
||||
pub struct DatabaseHandles {
|
||||
#[cfg(feature = "postgres")]
|
||||
pub pg_pool: Option<deadpool_postgres::Pool>,
|
||||
#[cfg(feature = "libsql")]
|
||||
pub libsql_db: Option<Arc<::libsql::Database>>,
|
||||
}
|
||||
|
||||
/// Connect to the database, run migrations, and return both the generic
|
||||
/// `Database` trait object and the backend-specific handles.
|
||||
pub async fn connect_with_handles(
|
||||
config: &crate::config::DatabaseConfig,
|
||||
) -> Result<(Arc<dyn Database>, DatabaseHandles), DatabaseError> {
|
||||
let mut handles = DatabaseHandles::default();
|
||||
|
||||
match config.backend {
|
||||
#[cfg(feature = "libsql")]
|
||||
crate::config::DatabaseBackend::LibSql => {
|
||||
@@ -74,7 +97,11 @@ pub async fn connect_from_config(
|
||||
.map_err(|e| DatabaseError::Pool(e.to_string()))?
|
||||
};
|
||||
backend.run_migrations().await?;
|
||||
Ok(Arc::new(backend))
|
||||
tracing::info!("libSQL database connected and migrations applied");
|
||||
|
||||
handles.libsql_db = Some(backend.shared_db());
|
||||
|
||||
Ok((Arc::new(backend) as Arc<dyn Database>, handles))
|
||||
}
|
||||
#[cfg(feature = "postgres")]
|
||||
_ => {
|
||||
@@ -82,7 +109,11 @@ pub async fn connect_from_config(
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Pool(e.to_string()))?;
|
||||
pg.run_migrations().await?;
|
||||
Ok(Arc::new(pg))
|
||||
tracing::info!("PostgreSQL database connected and migrations applied");
|
||||
|
||||
handles.pg_pool = Some(pg.pool());
|
||||
|
||||
Ok((Arc::new(pg) as Arc<dyn Database>, handles))
|
||||
}
|
||||
#[cfg(not(feature = "postgres"))]
|
||||
_ => Err(DatabaseError::Pool(
|
||||
|
||||
+2
-57
@@ -138,45 +138,8 @@ pub enum ChannelError {
|
||||
HealthCheckFailed { name: String },
|
||||
}
|
||||
|
||||
/// LLM provider errors.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum LlmError {
|
||||
#[error("Provider {provider} request failed: {reason}")]
|
||||
RequestFailed { provider: String, reason: String },
|
||||
|
||||
#[error("Provider {provider} rate limited, retry after {retry_after:?}")]
|
||||
RateLimited {
|
||||
provider: String,
|
||||
retry_after: Option<Duration>,
|
||||
},
|
||||
|
||||
#[error("Invalid response from {provider}: {reason}")]
|
||||
InvalidResponse { provider: String, reason: String },
|
||||
|
||||
#[error("Context length exceeded: {used} tokens used, {limit} allowed")]
|
||||
ContextLengthExceeded { used: usize, limit: usize },
|
||||
|
||||
#[error("Model {model} not available on provider {provider}")]
|
||||
ModelNotAvailable { provider: String, model: String },
|
||||
|
||||
#[error("Authentication failed for provider {provider}")]
|
||||
AuthFailed { provider: String },
|
||||
|
||||
#[error("Session expired for provider {provider}")]
|
||||
SessionExpired { provider: String },
|
||||
|
||||
#[error("Session renewal failed for provider {provider}: {reason}")]
|
||||
SessionRenewalFailed { provider: String, reason: String },
|
||||
|
||||
#[error("HTTP error: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
|
||||
#[error("JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
// LlmError lives in src/llm/error.rs; re-exported here for backward compatibility.
|
||||
pub use crate::llm::error::LlmError;
|
||||
|
||||
/// Tool execution errors.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -486,24 +449,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_error_display() {
|
||||
let err = LlmError::ContextLengthExceeded {
|
||||
used: 100_000,
|
||||
limit: 50_000,
|
||||
};
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("100000"), "Should mention used tokens: {msg}");
|
||||
assert!(msg.contains("50000"), "Should mention limit: {msg}");
|
||||
|
||||
let err = LlmError::RateLimited {
|
||||
provider: "openai".to_string(),
|
||||
retry_after: Some(Duration::from_secs(30)),
|
||||
};
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("openai"), "Should mention provider: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_error_display() {
|
||||
let err = JobError::MaxJobsExceeded { max: 5 };
|
||||
|
||||
+18
-14
@@ -73,6 +73,7 @@ pub struct ExtensionManager {
|
||||
|
||||
// MCP infrastructure
|
||||
mcp_session_manager: Arc<McpSessionManager>,
|
||||
mcp_process_manager: Arc<crate::tools::mcp::process::McpProcessManager>,
|
||||
/// Active MCP clients keyed by server name.
|
||||
mcp_clients: RwLock<HashMap<String, Arc<McpClient>>>,
|
||||
|
||||
@@ -116,6 +117,7 @@ impl ExtensionManager {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
mcp_session_manager: Arc<McpSessionManager>,
|
||||
mcp_process_manager: Arc<crate::tools::mcp::process::McpProcessManager>,
|
||||
secrets: Arc<dyn SecretsStore + Send + Sync>,
|
||||
tool_registry: Arc<ToolRegistry>,
|
||||
hooks: Option<Arc<HookRegistry>>,
|
||||
@@ -136,6 +138,7 @@ impl ExtensionManager {
|
||||
registry,
|
||||
discovery: OnlineDiscovery::new(),
|
||||
mcp_session_manager,
|
||||
mcp_process_manager,
|
||||
mcp_clients: RwLock::new(HashMap::new()),
|
||||
wasm_tool_runtime,
|
||||
wasm_tools_dir,
|
||||
@@ -2467,18 +2470,15 @@ impl ExtensionManager {
|
||||
.await
|
||||
.map_err(|e| ExtensionError::NotInstalled(e.to_string()))?;
|
||||
|
||||
let has_tokens = is_authenticated(&server, &self.secrets, &self.user_id).await;
|
||||
|
||||
let client = if has_tokens || server.requires_auth() {
|
||||
McpClient::new_authenticated(
|
||||
server.clone(),
|
||||
Arc::clone(&self.mcp_session_manager),
|
||||
Arc::clone(&self.secrets),
|
||||
&self.user_id,
|
||||
)
|
||||
} else {
|
||||
McpClient::new_with_config(server.clone())
|
||||
};
|
||||
let client = crate::tools::mcp::create_client_from_config(
|
||||
server.clone(),
|
||||
&self.mcp_session_manager,
|
||||
&self.mcp_process_manager,
|
||||
Some(Arc::clone(&self.secrets)),
|
||||
&self.user_id,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?;
|
||||
|
||||
// Try to list and create tools
|
||||
let mcp_tools = client
|
||||
@@ -3736,6 +3736,7 @@ mod tests {
|
||||
tools_dir: std::path::PathBuf,
|
||||
) -> crate::extensions::manager::ExtensionManager {
|
||||
use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
|
||||
use crate::tools::mcp::process::McpProcessManager;
|
||||
use crate::tools::mcp::session::McpSessionManager;
|
||||
|
||||
let key = secrecy::SecretString::from(crate::secrets::keychain::generate_master_key_hex());
|
||||
@@ -3747,6 +3748,7 @@ mod tests {
|
||||
|
||||
crate::extensions::manager::ExtensionManager::new(
|
||||
mcp,
|
||||
Arc::new(McpProcessManager::new()),
|
||||
secrets,
|
||||
tools,
|
||||
None, // hooks
|
||||
@@ -3905,18 +3907,20 @@ mod tests {
|
||||
channels_dir: std::path::PathBuf,
|
||||
) -> ExtensionManager {
|
||||
use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
|
||||
use crate::testing::credentials::TEST_CRYPTO_KEY;
|
||||
use crate::tools::ToolRegistry;
|
||||
use crate::tools::mcp::process::McpProcessManager;
|
||||
use crate::tools::mcp::session::McpSessionManager;
|
||||
|
||||
std::fs::create_dir_all(&tools_dir).ok();
|
||||
std::fs::create_dir_all(&channels_dir).ok();
|
||||
|
||||
let master_key =
|
||||
secrecy::SecretString::from("0123456789abcdef0123456789abcdef".to_string());
|
||||
let master_key = secrecy::SecretString::from(TEST_CRYPTO_KEY.to_string());
|
||||
let crypto = Arc::new(SecretsCrypto::new(master_key).unwrap());
|
||||
|
||||
ExtensionManager::new(
|
||||
Arc::new(McpSessionManager::new()),
|
||||
Arc::new(McpProcessManager::new()),
|
||||
Arc::new(InMemorySecretsStore::new(crypto)),
|
||||
Arc::new(ToolRegistry::new()),
|
||||
None,
|
||||
|
||||
+36
-1
@@ -149,14 +149,16 @@ impl Store {
|
||||
r#"
|
||||
INSERT INTO agent_jobs (
|
||||
id, conversation_id, title, description, category, status, source,
|
||||
user_id,
|
||||
budget_amount, budget_token, bid_amount, estimated_cost, estimated_time_secs,
|
||||
actual_cost, repair_attempts, created_at, started_at, completed_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
description = EXCLUDED.description,
|
||||
category = EXCLUDED.category,
|
||||
status = EXCLUDED.status,
|
||||
user_id = EXCLUDED.user_id,
|
||||
estimated_cost = EXCLUDED.estimated_cost,
|
||||
estimated_time_secs = EXCLUDED.estimated_time_secs,
|
||||
actual_cost = EXCLUDED.actual_cost,
|
||||
@@ -172,6 +174,7 @@ impl Store {
|
||||
&ctx.category,
|
||||
&status,
|
||||
&"direct", // source
|
||||
&ctx.user_id,
|
||||
&ctx.budget,
|
||||
&ctx.budget_token,
|
||||
&ctx.bid_amount,
|
||||
@@ -2133,4 +2136,36 @@ mod tests {
|
||||
assert_eq!(summary.channel, ch);
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression test: save_job must persist user_id and get_job must return it.
|
||||
/// Requires a running PostgreSQL instance (integration tier).
|
||||
#[cfg(feature = "postgres")]
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_save_job_persists_user_id() {
|
||||
use crate::config::Config;
|
||||
use crate::context::JobContext;
|
||||
|
||||
let _ = dotenvy::dotenv();
|
||||
let config = Config::from_env().await.expect("Failed to load config");
|
||||
let store = Store::new(&config.database)
|
||||
.await
|
||||
.expect("Failed to connect to database");
|
||||
store
|
||||
.run_migrations()
|
||||
.await
|
||||
.expect("Failed to run migrations");
|
||||
|
||||
let ctx = JobContext::with_user("test-user-42", "PG user_id test", "regression test");
|
||||
store.save_job(&ctx).await.unwrap();
|
||||
|
||||
let loaded = store.get_job(ctx.job_id).await.unwrap().unwrap();
|
||||
assert_eq!(loaded.user_id, "test-user-42");
|
||||
|
||||
// Clean up
|
||||
let conn = store.conn().await.unwrap();
|
||||
conn.execute("DELETE FROM agent_jobs WHERE id = $1", &[&ctx.job_id])
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ Multi-provider LLM integration with circuit breaker, retry, failover, and respon
|
||||
| `rig_adapter.rs` | Adapter bridging rig-core `CompletionModel` → `LlmProvider`; used by OpenAI, Anthropic, Ollama, Tinfoil |
|
||||
| `smart_routing.rs` | `SmartRoutingProvider` — 13-dimension complexity scorer routes cheap vs primary model |
|
||||
| `recording.rs` | `RecordingLlm` — trace capture for E2E replay testing (`IRONCLAW_RECORD_TRACE`) |
|
||||
| `bedrock.rs` | AWS Bedrock provider via native Converse API (feature-gated: `--features bedrock`) |
|
||||
|
||||
## Provider Selection
|
||||
|
||||
@@ -32,6 +33,18 @@ Set via `LLM_BACKEND` env var:
|
||||
| `ollama` | Ollama local | `OLLAMA_BASE_URL` |
|
||||
| `openai_compatible` | Any OpenAI-compatible endpoint | `LLM_BASE_URL`, `LLM_API_KEY`, `LLM_MODEL` |
|
||||
| `tinfoil` | Tinfoil TEE inference | `TINFOIL_API_KEY`, `TINFOIL_MODEL` |
|
||||
| `bedrock` | AWS Bedrock (requires `--features bedrock`) | `BEDROCK_REGION`, `BEDROCK_MODEL`, `AWS_PROFILE` |
|
||||
|
||||
## AWS Bedrock Provider
|
||||
|
||||
Uses the native Converse API via `aws-sdk-bedrockruntime` (`bedrock.rs`). Requires `--features bedrock` at build time — not in default features due to heavy AWS SDK dependencies.
|
||||
|
||||
**Auth:** Standard AWS credential chain — IAM credentials (`AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`), SSO profiles (`AWS_PROFILE`), or instance roles. The SDK resolves auth automatically from the environment.
|
||||
|
||||
**Config:**
|
||||
- `BEDROCK_REGION` — AWS region (default: `us-east-1`)
|
||||
- `BEDROCK_MODEL` — Required model ID (e.g., `anthropic.claude-opus-4-6-v1`)
|
||||
- `BEDROCK_CROSS_REGION` — Optional cross-region inference prefix (`us`, `eu`, `apac`, `global`)
|
||||
|
||||
## NEAR AI Provider Gotchas
|
||||
|
||||
|
||||
@@ -12,9 +12,9 @@ use rust_decimal::Decimal;
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::RegistryProviderConfig;
|
||||
use crate::error::LlmError;
|
||||
use crate::llm::config::RegistryProviderConfig;
|
||||
use crate::llm::costs;
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::provider::{
|
||||
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, Role, ToolCall,
|
||||
ToolCompletionRequest, ToolCompletionResponse,
|
||||
|
||||
+2
-2
@@ -20,8 +20,8 @@ use aws_sdk_bedrockruntime::types::{
|
||||
use aws_smithy_types::Document;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
use crate::config::BedrockConfig;
|
||||
use crate::error::LlmError;
|
||||
use crate::llm::config::BedrockConfig;
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::provider::{
|
||||
CompletionRequest, CompletionResponse, FinishReason, LlmProvider, ModelMetadata, ToolCall,
|
||||
ToolCompletionRequest, ToolCompletionResponse, ToolDefinition,
|
||||
|
||||
@@ -19,7 +19,7 @@ use async_trait::async_trait;
|
||||
use rust_decimal::Decimal;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::error::LlmError;
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::provider::{
|
||||
CompletionRequest, CompletionResponse, LlmProvider, ModelMetadata, ToolCompletionRequest,
|
||||
ToolCompletionResponse,
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
//! LLM configuration types.
|
||||
//!
|
||||
//! These types define the configuration for LLM providers. They are defined
|
||||
//! here (in the `llm` module) so that the module is self-contained and can be
|
||||
//! extracted into a standalone crate. Resolution logic (reading env vars,
|
||||
//! settings) lives in `crate::config::llm`.
|
||||
|
||||
use secrecy::SecretString;
|
||||
|
||||
use crate::llm::registry::ProviderProtocol;
|
||||
use crate::llm::session::SessionConfig;
|
||||
|
||||
/// Sentinel value used as `api_key` when only an OAuth token is present.
|
||||
///
|
||||
/// When we only have an OAuth token the provider factory in `llm/mod.rs`
|
||||
/// checks for this value and routes to `AnthropicOAuthProvider`, so this
|
||||
/// placeholder is never sent over the wire.
|
||||
pub const OAUTH_PLACEHOLDER: &str = "oauth-placeholder";
|
||||
|
||||
/// Prompt cache retention policy for Anthropic.
|
||||
///
|
||||
/// Controls Anthropic's automatic prompt caching via a top-level
|
||||
/// `cache_control` field injected through rig-core's `additional_params`.
|
||||
/// - `None` — caching disabled, no `cache_control` injected.
|
||||
/// - `Short` — 5-minute TTL (default), `{"type": "ephemeral"}`, 1.25× write surcharge.
|
||||
/// - `Long` — 1-hour TTL, `{"type": "ephemeral", "ttl": "1h"}`, 2× write surcharge.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum CacheRetention {
|
||||
/// No prompt caching.
|
||||
None,
|
||||
/// 5-minute TTL (default). Write cost: 1.25× base input.
|
||||
#[default]
|
||||
Short,
|
||||
/// 1-hour TTL. Write cost: 2× base input.
|
||||
Long,
|
||||
}
|
||||
|
||||
impl std::str::FromStr for CacheRetention {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"none" | "off" | "disabled" => Ok(Self::None),
|
||||
"short" | "5m" | "ephemeral" => Ok(Self::Short),
|
||||
"long" | "1h" => Ok(Self::Long),
|
||||
_ => Err(format!(
|
||||
"invalid cache retention '{}', expected one of: none, short, long",
|
||||
s
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CacheRetention {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::None => write!(f, "none"),
|
||||
Self::Short => write!(f, "short"),
|
||||
Self::Long => write!(f, "long"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved configuration for a registry-based provider.
|
||||
///
|
||||
/// This single struct replaces what used to be five separate config types
|
||||
/// (`OpenAiDirectConfig`, `AnthropicDirectConfig`, `OllamaConfig`,
|
||||
/// `OpenAiCompatibleConfig`, `TinfoilConfig`). The `protocol` field
|
||||
/// determines which rig-core client constructor to use.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RegistryProviderConfig {
|
||||
/// Which API protocol to use (determines the rig-core client).
|
||||
pub protocol: ProviderProtocol,
|
||||
/// Provider identifier (e.g., "groq", "openai", "tinfoil").
|
||||
pub provider_id: String,
|
||||
/// API key (optional for some providers like Ollama).
|
||||
/// For Anthropic OAuth, this is set to `OAUTH_PLACEHOLDER`.
|
||||
pub api_key: Option<SecretString>,
|
||||
/// Base URL for the API endpoint.
|
||||
pub base_url: String,
|
||||
/// Model identifier.
|
||||
pub model: String,
|
||||
/// Extra HTTP headers injected into every request.
|
||||
pub extra_headers: Vec<(String, String)>,
|
||||
/// OAuth token for providers that support Bearer auth (e.g. Anthropic via `claude login`).
|
||||
/// When set, the provider factory routes to the OAuth-specific provider implementation.
|
||||
pub oauth_token: Option<SecretString>,
|
||||
/// Prompt cache retention (Anthropic-specific).
|
||||
pub cache_retention: CacheRetention,
|
||||
}
|
||||
|
||||
/// Configuration for AWS Bedrock (native Converse API).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BedrockConfig {
|
||||
/// AWS region (e.g. "us-east-1").
|
||||
pub region: String,
|
||||
/// Bedrock model ID (e.g. "anthropic.claude-opus-4-6-v1").
|
||||
pub model: String,
|
||||
/// Cross-region inference prefix: "us", "eu", "apac", "global", or None.
|
||||
pub cross_region: Option<String>,
|
||||
/// AWS named profile (for SSO / assume-role workflows).
|
||||
pub profile: Option<String>,
|
||||
}
|
||||
|
||||
/// LLM provider configuration.
|
||||
///
|
||||
/// NearAI remains the default backend with its own config struct (session auth).
|
||||
/// All other providers are resolved through the provider registry, producing
|
||||
/// a generic `RegistryProviderConfig`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LlmConfig {
|
||||
/// Backend identifier (e.g., "nearai", "openai", "groq", "tinfoil").
|
||||
pub backend: String,
|
||||
/// Session manager configuration (auth URL, token persistence path).
|
||||
/// Used by the NearAI provider for OAuth/session-token auth.
|
||||
pub session: SessionConfig,
|
||||
/// NEAR AI config (always populated, also used for embeddings).
|
||||
pub nearai: NearAiConfig,
|
||||
/// Resolved provider config for registry-based providers.
|
||||
/// `None` when backend is "nearai" or "bedrock".
|
||||
pub provider: Option<RegistryProviderConfig>,
|
||||
/// AWS Bedrock config (populated when backend=bedrock, requires --features bedrock).
|
||||
pub bedrock: Option<BedrockConfig>,
|
||||
/// HTTP request timeout in seconds for LLM API calls.
|
||||
/// Default: 120. Increase for local LLMs (Ollama, vLLM, LM Studio) that
|
||||
/// need more time for prompt evaluation on consumer hardware.
|
||||
pub request_timeout_secs: u64,
|
||||
}
|
||||
|
||||
/// NEAR AI configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NearAiConfig {
|
||||
/// Model to use (e.g., "claude-3-5-sonnet-20241022", "gpt-4o")
|
||||
pub model: String,
|
||||
/// Cheap/fast model for lightweight tasks (heartbeat, routing, evaluation).
|
||||
pub cheap_model: Option<String>,
|
||||
/// Base URL for the NEAR AI API.
|
||||
pub base_url: String,
|
||||
/// API key for NEAR AI Cloud.
|
||||
pub api_key: Option<SecretString>,
|
||||
/// Optional fallback model for failover.
|
||||
pub fallback_model: Option<String>,
|
||||
/// Maximum number of retries for transient errors (default: 3).
|
||||
pub max_retries: u32,
|
||||
/// Consecutive failures before circuit breaker opens. None = disabled.
|
||||
pub circuit_breaker_threshold: Option<u32>,
|
||||
/// Seconds the circuit stays open before probing (default: 30).
|
||||
pub circuit_breaker_recovery_secs: u64,
|
||||
/// Enable in-memory response caching. Default: false.
|
||||
pub response_cache_enabled: bool,
|
||||
/// TTL in seconds for cached responses (default: 3600).
|
||||
pub response_cache_ttl_secs: u64,
|
||||
/// Max cached responses before LRU eviction (default: 1000).
|
||||
pub response_cache_max_entries: usize,
|
||||
/// Cooldown duration in seconds for failover (default: 300).
|
||||
pub failover_cooldown_secs: u64,
|
||||
/// Consecutive failures before failover cooldown (default: 3).
|
||||
pub failover_cooldown_threshold: u32,
|
||||
/// Enable cascade mode for smart routing. Default: true.
|
||||
pub smart_routing_cascade: bool,
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//! LLM provider error types.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// LLM provider errors.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum LlmError {
|
||||
#[error("Provider {provider} request failed: {reason}")]
|
||||
RequestFailed { provider: String, reason: String },
|
||||
|
||||
#[error("Provider {provider} rate limited, retry after {retry_after:?}")]
|
||||
RateLimited {
|
||||
provider: String,
|
||||
retry_after: Option<Duration>,
|
||||
},
|
||||
|
||||
#[error("Invalid response from {provider}: {reason}")]
|
||||
InvalidResponse { provider: String, reason: String },
|
||||
|
||||
#[error("Context length exceeded: {used} tokens used, {limit} allowed")]
|
||||
ContextLengthExceeded { used: usize, limit: usize },
|
||||
|
||||
#[error("Model {model} not available on provider {provider}")]
|
||||
ModelNotAvailable { provider: String, model: String },
|
||||
|
||||
#[error("Authentication failed for provider {provider}")]
|
||||
AuthFailed { provider: String },
|
||||
|
||||
#[error("Session expired for provider {provider}")]
|
||||
SessionExpired { provider: String },
|
||||
|
||||
#[error("Session renewal failed for provider {provider}: {reason}")]
|
||||
SessionRenewalFailed { provider: String, reason: String },
|
||||
|
||||
#[error("HTTP error: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
|
||||
#[error("JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
+1
-1
@@ -17,7 +17,7 @@ use std::time::{Duration, Instant};
|
||||
use async_trait::async_trait;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
use crate::error::LlmError;
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::provider::{
|
||||
CompletionRequest, CompletionResponse, LlmProvider, ModelMetadata, ToolCompletionRequest,
|
||||
ToolCompletionResponse,
|
||||
|
||||
+28
-35
@@ -12,9 +12,12 @@ mod anthropic_oauth;
|
||||
#[cfg(feature = "bedrock")]
|
||||
mod bedrock;
|
||||
pub mod circuit_breaker;
|
||||
pub mod config;
|
||||
pub mod costs;
|
||||
pub mod error;
|
||||
pub mod failover;
|
||||
mod nearai_chat;
|
||||
pub mod oauth_helpers;
|
||||
mod provider;
|
||||
mod reasoning;
|
||||
pub mod recording;
|
||||
@@ -29,6 +32,11 @@ pub mod image_models;
|
||||
pub mod vision_models;
|
||||
|
||||
pub use circuit_breaker::{CircuitBreakerConfig, CircuitBreakerProvider};
|
||||
pub use config::{
|
||||
BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER,
|
||||
RegistryProviderConfig,
|
||||
};
|
||||
pub use error::LlmError;
|
||||
pub use failover::{CooldownConfig, FailoverProvider};
|
||||
pub use nearai_chat::{ModelInfo, NearAiChatProvider};
|
||||
pub use provider::{
|
||||
@@ -53,8 +61,8 @@ use std::sync::Arc;
|
||||
use rig::client::CompletionClient;
|
||||
use secrecy::ExposeSecret;
|
||||
|
||||
use crate::config::{LlmConfig, NearAiConfig, RegistryProviderConfig};
|
||||
use crate::error::LlmError;
|
||||
// LlmConfig, NearAiConfig, RegistryProviderConfig, and LlmError are
|
||||
// re-exported via `pub use` above from config and error submodules.
|
||||
|
||||
/// Create an LLM provider based on configuration.
|
||||
///
|
||||
@@ -109,7 +117,7 @@ pub fn create_llm_provider_with_config(
|
||||
} else {
|
||||
"session token"
|
||||
};
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
model = %config.model,
|
||||
base_url = %config.base_url,
|
||||
auth = auth_mode,
|
||||
@@ -148,7 +156,7 @@ async fn create_bedrock_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvid
|
||||
})?;
|
||||
|
||||
let provider = bedrock::BedrockProvider::new(br).await?;
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
"Using AWS Bedrock (Converse API, region: {}, model: {})",
|
||||
br.region,
|
||||
provider.active_model_name(),
|
||||
@@ -213,7 +221,7 @@ fn create_openai_compat_from_registry(
|
||||
let client = client.completions_api();
|
||||
let model = client.completion_model(&config.model);
|
||||
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
provider = %config.provider_id,
|
||||
model = %config.model,
|
||||
base_url = %config.base_url,
|
||||
@@ -232,9 +240,9 @@ fn create_anthropic_from_registry(
|
||||
let api_key_is_placeholder = config
|
||||
.api_key
|
||||
.as_ref()
|
||||
.is_some_and(|k| k.expose_secret() == crate::config::llm::OAUTH_PLACEHOLDER);
|
||||
.is_some_and(|k| k.expose_secret() == crate::llm::config::OAUTH_PLACEHOLDER);
|
||||
if config.oauth_token.is_some() && (config.api_key.is_none() || api_key_is_placeholder) {
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
provider = %config.provider_id,
|
||||
model = %config.model,
|
||||
base_url = if config.base_url.is_empty() { "default" } else { &config.base_url },
|
||||
@@ -244,8 +252,7 @@ fn create_anthropic_from_registry(
|
||||
return Ok(Arc::new(provider));
|
||||
}
|
||||
|
||||
use crate::config::CacheRetention;
|
||||
use crate::config::helpers::optional_env;
|
||||
use crate::llm::config::CacheRetention;
|
||||
use rig::providers::anthropic;
|
||||
|
||||
let api_key = config
|
||||
@@ -269,33 +276,19 @@ fn create_anthropic_from_registry(
|
||||
reason: format!("Failed to create Anthropic client: {e}"),
|
||||
})?;
|
||||
|
||||
// Resolve prompt cache retention from env (default: Short).
|
||||
// Injects top-level cache_control via additional_params for Anthropic
|
||||
// automatic caching (the API auto-places the breakpoint at the last
|
||||
// cacheable block).
|
||||
let cache_retention: CacheRetention = optional_env("ANTHROPIC_CACHE_RETENTION")
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|val| match val.parse::<CacheRetention>() {
|
||||
Ok(r) => Some(r),
|
||||
Err(e) => {
|
||||
tracing::warn!("Invalid ANTHROPIC_CACHE_RETENTION: {e}; defaulting to short");
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let cache_retention = config.cache_retention;
|
||||
|
||||
let model = client.completion_model(&config.model);
|
||||
|
||||
if cache_retention != CacheRetention::None {
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
model = %config.model,
|
||||
retention = %cache_retention,
|
||||
"Anthropic automatic prompt caching enabled"
|
||||
);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
provider = %config.provider_id,
|
||||
model = %config.model,
|
||||
base_url = if config.base_url.is_empty() { "default" } else { &config.base_url },
|
||||
@@ -324,7 +317,7 @@ fn create_ollama_from_registry(
|
||||
|
||||
let model = client.completion_model(&config.model);
|
||||
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
provider = %config.provider_id,
|
||||
model = %config.model,
|
||||
base_url = %config.base_url,
|
||||
@@ -392,14 +385,14 @@ pub async fn build_provider_chain(
|
||||
LlmError,
|
||||
> {
|
||||
let llm = create_llm_provider(config, session.clone()).await?;
|
||||
tracing::info!("LLM provider initialized: {}", llm.model_name());
|
||||
tracing::debug!("LLM provider initialized: {}", llm.model_name());
|
||||
|
||||
// 1. Retry
|
||||
let retry_config = RetryConfig {
|
||||
max_retries: config.nearai.max_retries,
|
||||
};
|
||||
let llm: Arc<dyn LlmProvider> = if retry_config.max_retries > 0 {
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
max_retries = retry_config.max_retries,
|
||||
"LLM retry wrapper enabled"
|
||||
);
|
||||
@@ -422,7 +415,7 @@ pub async fn build_provider_chain(
|
||||
} else {
|
||||
cheap
|
||||
};
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
primary = %llm.model_name(),
|
||||
cheap = %cheap.model_name(),
|
||||
"Smart routing enabled"
|
||||
@@ -453,7 +446,7 @@ pub async fn build_provider_chain(
|
||||
session.clone(),
|
||||
config.request_timeout_secs,
|
||||
)?;
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
primary = %llm.model_name(),
|
||||
fallback = %fallback.model_name(),
|
||||
"LLM failover enabled"
|
||||
@@ -485,7 +478,7 @@ pub async fn build_provider_chain(
|
||||
),
|
||||
..CircuitBreakerConfig::default()
|
||||
};
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
threshold,
|
||||
recovery_secs = config.nearai.circuit_breaker_recovery_secs,
|
||||
"LLM circuit breaker enabled"
|
||||
@@ -501,7 +494,7 @@ pub async fn build_provider_chain(
|
||||
ttl: std::time::Duration::from_secs(config.nearai.response_cache_ttl_secs),
|
||||
max_entries: config.nearai.response_cache_max_entries,
|
||||
};
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
ttl_secs = config.nearai.response_cache_ttl_secs,
|
||||
max_entries = config.nearai.response_cache_max_entries,
|
||||
"LLM response cache enabled"
|
||||
@@ -522,7 +515,7 @@ pub async fn build_provider_chain(
|
||||
// Standalone cheap LLM for heartbeat/evaluation (not part of the chain)
|
||||
let cheap_llm = create_cheap_llm_provider(config, session)?;
|
||||
if let Some(ref cheap) = cheap_llm {
|
||||
tracing::info!("Cheap LLM provider initialized: {}", cheap.model_name());
|
||||
tracing::debug!("Cheap LLM provider initialized: {}", cheap.model_name());
|
||||
}
|
||||
|
||||
Ok((llm, cheap_llm, recording_handle))
|
||||
@@ -531,7 +524,7 @@ pub async fn build_provider_chain(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::NearAiConfig;
|
||||
use crate::llm::config::NearAiConfig;
|
||||
|
||||
fn test_nearai_config() -> NearAiConfig {
|
||||
NearAiConfig {
|
||||
|
||||
@@ -16,8 +16,8 @@ use rust_decimal::prelude::MathematicalOps;
|
||||
use secrecy::ExposeSecret;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::NearAiConfig;
|
||||
use crate::error::LlmError;
|
||||
use crate::llm::config::NearAiConfig;
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::provider::{
|
||||
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, Role, ToolCall,
|
||||
ToolCompletionRequest, ToolCompletionResponse,
|
||||
@@ -110,7 +110,7 @@ impl NearAiChatProvider {
|
||||
handle.spawn(async move {
|
||||
match fetch_pricing(&client, &base_url, api_key.as_ref(), &session).await {
|
||||
Ok(map) if !map.is_empty() => {
|
||||
tracing::info!("Loaded NEAR AI pricing for {} model(s)", map.len());
|
||||
tracing::debug!("Loaded NEAR AI pricing for {} model(s)", map.len());
|
||||
match pricing.write() {
|
||||
Ok(mut guard) => *guard = map,
|
||||
Err(poisoned) => *poisoned.into_inner() = map,
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
//! OAuth callback infrastructure used by the NEAR AI session login flow.
|
||||
//!
|
||||
//! These utilities (callback server, landing pages, hostname detection) were
|
||||
//! originally in `cli/oauth_defaults.rs` and are moved here so the `llm`
|
||||
//! module is self-contained. `cli/oauth_defaults` re-exports everything for
|
||||
//! backward compatibility.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
/// Fixed port for the OAuth callback listener.
|
||||
pub const OAUTH_CALLBACK_PORT: u16 = 9876;
|
||||
|
||||
/// 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),
|
||||
}
|
||||
|
||||
/// 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 specific network
|
||||
/// interface address you want to listen on (e.g. the server's LAN IP).
|
||||
/// Wildcard addresses (`0.0.0.0`, `::`) are rejected — use a specific interface
|
||||
/// IP to limit exposure. The callback listener will bind to that address 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.
|
||||
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)
|
||||
}
|
||||
|
||||
/// Returns `true` if `host` is a wildcard/unspecified address (`0.0.0.0` or `::`).
|
||||
///
|
||||
/// Wildcard binds accept connections on all interfaces, which is a security risk
|
||||
/// for OAuth callbacks that carry session tokens over plain HTTP.
|
||||
fn is_wildcard_host(host: &str) -> bool {
|
||||
host.parse::<std::net::IpAddr>()
|
||||
.map(|ip| ip.is_unspecified())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 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_wildcard_host(&host) {
|
||||
return Err(OAuthCallbackError::Io(format!(
|
||||
"OAUTH_CALLBACK_HOST={host} is a wildcard address — this would accept \
|
||||
connections on all interfaces, exposing the session token. \
|
||||
Use a specific interface IP (e.g. 192.168.1.x) or SSH port forwarding instead."
|
||||
)));
|
||||
}
|
||||
|
||||
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("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Generate a branded HTML landing page for the OAuth callback result.
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn loopback_detection() {
|
||||
assert!(is_loopback_host("127.0.0.1"));
|
||||
assert!(is_loopback_host("127.0.0.2")); // full 127.0.0.0/8 range
|
||||
assert!(is_loopback_host("::1"));
|
||||
assert!(is_loopback_host("localhost"));
|
||||
assert!(is_loopback_host("LOCALHOST"));
|
||||
assert!(!is_loopback_host("0.0.0.0"));
|
||||
assert!(!is_loopback_host("192.168.1.1"));
|
||||
assert!(!is_loopback_host("::"));
|
||||
assert!(!is_loopback_host("example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wildcard_detection() {
|
||||
assert!(is_wildcard_host("0.0.0.0"));
|
||||
assert!(is_wildcard_host("::"));
|
||||
assert!(!is_wildcard_host("127.0.0.1"));
|
||||
assert!(!is_wildcard_host("192.168.1.1"));
|
||||
assert!(!is_wildcard_host("::1"));
|
||||
assert!(!is_wildcard_host("localhost"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bind_rejects_wildcard_ipv4() {
|
||||
// SAFETY: test is single-threaded; env var is restored immediately after.
|
||||
unsafe { std::env::set_var("OAUTH_CALLBACK_HOST", "0.0.0.0") };
|
||||
let result = bind_callback_listener().await;
|
||||
unsafe { std::env::remove_var("OAUTH_CALLBACK_HOST") };
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("wildcard"),
|
||||
"error should mention wildcard: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bind_rejects_wildcard_ipv6() {
|
||||
// SAFETY: test is single-threaded; env var is restored immediately after.
|
||||
unsafe { std::env::set_var("OAUTH_CALLBACK_HOST", "::") };
|
||||
let result = bind_callback_listener().await;
|
||||
unsafe { std::env::remove_var("OAUTH_CALLBACK_HOST") };
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("wildcard"),
|
||||
"error should mention wildcard: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -4,7 +4,7 @@ use async_trait::async_trait;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::LlmError;
|
||||
use crate::llm::error::LlmError;
|
||||
|
||||
/// Role in a conversation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::sync::{Arc, LazyLock};
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::LlmError;
|
||||
use crate::llm::error::LlmError;
|
||||
|
||||
use crate::llm::{
|
||||
ChatMessage, CompletionRequest, LlmProvider, Role, ToolCall, ToolCompletionRequest,
|
||||
|
||||
+38
-3
@@ -21,7 +21,7 @@ use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::error::LlmError;
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::provider::{
|
||||
ChatMessage, CompletionRequest, CompletionResponse, LlmProvider, ModelMetadata, Role,
|
||||
ToolCompletionRequest, ToolCompletionResponse,
|
||||
@@ -437,7 +437,11 @@ impl RecordingLlm {
|
||||
.find(|m| m.role == Role::User)
|
||||
.map(|msg| {
|
||||
let hint_text = if msg.content.len() > 80 {
|
||||
msg.content[..80].to_string()
|
||||
let mut end = 80;
|
||||
while end > 0 && !msg.content.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
msg.content[..end].to_string()
|
||||
} else {
|
||||
msg.content.clone()
|
||||
};
|
||||
@@ -546,6 +550,10 @@ impl LlmProvider for RecordingLlm {
|
||||
fn set_model(&self, model: &str) -> Result<(), LlmError> {
|
||||
self.inner.set_model(model)
|
||||
}
|
||||
|
||||
fn calculate_cost(&self, input_tokens: u32, output_tokens: u32) -> Decimal {
|
||||
self.inner.calculate_cost(input_tokens, output_tokens)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -554,9 +562,10 @@ mod tests {
|
||||
use crate::testing::StubLlm;
|
||||
|
||||
fn make_recorder(stub: Arc<StubLlm>) -> RecordingLlm {
|
||||
let dir = tempfile::tempdir().expect("failed to create temp dir");
|
||||
RecordingLlm::new(
|
||||
stub,
|
||||
PathBuf::from("/tmp/test_recording.json"),
|
||||
dir.path().join("test_recording.json"),
|
||||
"test-recording".to_string(),
|
||||
)
|
||||
}
|
||||
@@ -900,6 +909,32 @@ mod tests {
|
||||
assert_eq!(parsed.steps[2].expected_tool_results.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_hint_handles_multibyte_utf8() {
|
||||
let stub = Arc::new(StubLlm::new("response"));
|
||||
let recorder = make_recorder(stub);
|
||||
|
||||
// Create a string where byte index 80 falls inside a multi-byte char.
|
||||
// Each CJK character is 3 bytes; 26 chars × 3 bytes = 78, then "ab" = 80 bytes,
|
||||
// but let's use 27 CJK chars (81 bytes) so truncation must respect the boundary.
|
||||
let long_cjk = "你".repeat(27); // 81 bytes, > 80
|
||||
assert!(long_cjk.len() > 80);
|
||||
|
||||
let request = CompletionRequest::new(vec![
|
||||
ChatMessage::system("sys"),
|
||||
ChatMessage::user(&long_cjk),
|
||||
]);
|
||||
recorder.complete(request).await.unwrap();
|
||||
|
||||
let steps = recorder.steps.lock().await;
|
||||
let text_step = &steps[1];
|
||||
let hint = text_step.request_hint.as_ref().unwrap();
|
||||
let hint_text = hint.last_user_message_contains.as_deref().unwrap();
|
||||
// Must be valid UTF-8 and not longer than 80 bytes
|
||||
assert!(hint_text.len() <= 80);
|
||||
assert!(hint_text.is_ascii() || hint_text.chars().count() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backward_compatible_with_old_format() {
|
||||
// Old format without memory_snapshot, http_exchanges, expected_tool_results
|
||||
|
||||
@@ -25,7 +25,7 @@ use async_trait::async_trait;
|
||||
use rust_decimal::Decimal;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::error::LlmError;
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::provider::{
|
||||
CompletionRequest, CompletionResponse, LlmProvider, ModelMetadata, ToolCompletionRequest,
|
||||
ToolCompletionResponse,
|
||||
@@ -298,6 +298,10 @@ impl LlmProvider for CachedProvider {
|
||||
// hit again rather than wasted. Natural TTL / LRU eviction cleans them up.
|
||||
self.inner.set_model(model)
|
||||
}
|
||||
|
||||
fn calculate_cost(&self, input_tokens: u32, output_tokens: u32) -> Decimal {
|
||||
self.inner.calculate_cost(input_tokens, output_tokens)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -307,7 +311,7 @@ mod tests {
|
||||
use rust_decimal::Decimal;
|
||||
use tracing_test::traced_test;
|
||||
|
||||
use crate::error::LlmError;
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::provider::{
|
||||
ChatMessage, CompletionResponse, FinishReason, ToolCompletionRequest,
|
||||
ToolCompletionResponse,
|
||||
|
||||
+68
-77
@@ -5,6 +5,7 @@
|
||||
//! - `retry_backoff_delay()` — exponential backoff with jitter
|
||||
//! - `RetryProvider` — decorator that wraps any `LlmProvider` with automatic retries
|
||||
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -12,7 +13,7 @@ use async_trait::async_trait;
|
||||
use rand::Rng;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
use crate::error::LlmError;
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::provider::{
|
||||
CompletionRequest, CompletionResponse, LlmProvider, ModelMetadata, ToolCompletionRequest,
|
||||
ToolCompletionResponse,
|
||||
@@ -97,6 +98,50 @@ impl RetryProvider {
|
||||
pub fn new(inner: Arc<dyn LlmProvider>, config: RetryConfig) -> Self {
|
||||
Self { inner, config }
|
||||
}
|
||||
|
||||
async fn retry_loop<T, F, Fut>(&self, mut op: F, label: &str) -> Result<T, LlmError>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: Future<Output = Result<T, LlmError>>,
|
||||
{
|
||||
let mut last_error: Option<LlmError> = None;
|
||||
|
||||
for attempt in 0..=self.config.max_retries {
|
||||
match op().await {
|
||||
Ok(resp) => return Ok(resp),
|
||||
Err(err) => {
|
||||
if !is_retryable(&err) || attempt == self.config.max_retries {
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let delay = match &err {
|
||||
LlmError::RateLimited {
|
||||
retry_after: Some(duration),
|
||||
..
|
||||
} => *duration,
|
||||
_ => retry_backoff_delay(attempt),
|
||||
};
|
||||
|
||||
tracing::warn!(
|
||||
provider = %self.inner.model_name(),
|
||||
attempt = attempt + 1,
|
||||
max_retries = self.config.max_retries,
|
||||
delay_ms = delay.as_millis() as u64,
|
||||
error = %err,
|
||||
"Retrying after transient error{label}"
|
||||
);
|
||||
|
||||
last_error = Some(err);
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_error.unwrap_or_else(|| LlmError::RequestFailed {
|
||||
provider: self.inner.model_name().to_string(),
|
||||
reason: "retry loop exited unexpectedly".to_string(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -118,88 +163,30 @@ impl LlmProvider for RetryProvider {
|
||||
}
|
||||
|
||||
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
||||
let mut last_error: Option<LlmError> = None;
|
||||
|
||||
for attempt in 0..=self.config.max_retries {
|
||||
let req = request.clone();
|
||||
match self.inner.complete(req).await {
|
||||
Ok(resp) => return Ok(resp),
|
||||
Err(err) => {
|
||||
if !is_retryable(&err) || attempt == self.config.max_retries {
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let delay = match &err {
|
||||
LlmError::RateLimited {
|
||||
retry_after: Some(duration),
|
||||
..
|
||||
} => *duration,
|
||||
_ => retry_backoff_delay(attempt),
|
||||
};
|
||||
|
||||
tracing::warn!(
|
||||
provider = %self.inner.model_name(),
|
||||
attempt = attempt + 1,
|
||||
max_retries = self.config.max_retries,
|
||||
delay_ms = delay.as_millis() as u64,
|
||||
error = %err,
|
||||
"Retrying after transient error"
|
||||
);
|
||||
|
||||
last_error = Some(err);
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_error.unwrap_or_else(|| LlmError::RequestFailed {
|
||||
provider: self.inner.model_name().to_string(),
|
||||
reason: "retry loop exited unexpectedly".to_string(),
|
||||
}))
|
||||
let inner = &self.inner;
|
||||
self.retry_loop(
|
||||
|| {
|
||||
let req = request.clone();
|
||||
async move { inner.complete(req).await }
|
||||
},
|
||||
"",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn complete_with_tools(
|
||||
&self,
|
||||
request: ToolCompletionRequest,
|
||||
) -> Result<ToolCompletionResponse, LlmError> {
|
||||
let mut last_error: Option<LlmError> = None;
|
||||
|
||||
for attempt in 0..=self.config.max_retries {
|
||||
let req = request.clone();
|
||||
match self.inner.complete_with_tools(req).await {
|
||||
Ok(resp) => return Ok(resp),
|
||||
Err(err) => {
|
||||
if !is_retryable(&err) || attempt == self.config.max_retries {
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let delay = match &err {
|
||||
LlmError::RateLimited {
|
||||
retry_after: Some(duration),
|
||||
..
|
||||
} => *duration,
|
||||
_ => retry_backoff_delay(attempt),
|
||||
};
|
||||
|
||||
tracing::warn!(
|
||||
provider = %self.inner.model_name(),
|
||||
attempt = attempt + 1,
|
||||
max_retries = self.config.max_retries,
|
||||
delay_ms = delay.as_millis() as u64,
|
||||
error = %err,
|
||||
"Retrying after transient error (tools)"
|
||||
);
|
||||
|
||||
last_error = Some(err);
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_error.unwrap_or_else(|| LlmError::RequestFailed {
|
||||
provider: self.inner.model_name().to_string(),
|
||||
reason: "retry loop exited unexpectedly".to_string(),
|
||||
}))
|
||||
let inner = &self.inner;
|
||||
self.retry_loop(
|
||||
|| {
|
||||
let req = request.clone();
|
||||
async move { inner.complete_with_tools(req).await }
|
||||
},
|
||||
" (tools)",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_models(&self) -> Result<Vec<String>, LlmError> {
|
||||
@@ -210,6 +197,10 @@ impl LlmProvider for RetryProvider {
|
||||
self.inner.model_metadata().await
|
||||
}
|
||||
|
||||
fn effective_model_name(&self, requested_model: Option<&str>) -> String {
|
||||
self.inner.effective_model_name(requested_model)
|
||||
}
|
||||
|
||||
fn active_model_name(&self) -> String {
|
||||
self.inner.active_model_name()
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! This lets us use any rig-core provider (OpenAI, Anthropic, Ollama, etc.) as an
|
||||
//! `Arc<dyn LlmProvider>` without changing any of the agent, reasoning, or tool code.
|
||||
|
||||
use crate::config::CacheRetention;
|
||||
use crate::llm::config::CacheRetention;
|
||||
use async_trait::async_trait;
|
||||
use rig::OneOrMany;
|
||||
use rig::completion::{
|
||||
@@ -23,8 +23,8 @@ use serde_json::Value as JsonValue;
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::error::LlmError;
|
||||
use crate::llm::costs;
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::provider::{
|
||||
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider,
|
||||
ToolCall as IronToolCall, ToolCompletionRequest, ToolCompletionResponse,
|
||||
|
||||
+24
-33
@@ -7,8 +7,7 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
use crate::cli::oauth_defaults::OAUTH_CALLBACK_PORT;
|
||||
use crate::llm::oauth_helpers::OAUTH_CALLBACK_PORT;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use reqwest::Client;
|
||||
@@ -16,7 +15,7 @@ use secrecy::SecretString;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
|
||||
use crate::error::LlmError;
|
||||
use crate::llm::error::LlmError;
|
||||
|
||||
/// Session data persisted to disk.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -40,16 +39,13 @@ impl Default for SessionConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
auth_base_url: "https://private.near.ai".to_string(),
|
||||
session_path: default_session_path(),
|
||||
// Real path is set by LlmConfig::resolve() via config/llm.rs.
|
||||
// This default is only used in tests.
|
||||
session_path: PathBuf::from("session.json"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the default session file path (~/.ironclaw/session.json).
|
||||
pub fn default_session_path() -> PathBuf {
|
||||
ironclaw_base_dir().join("session.json")
|
||||
}
|
||||
|
||||
/// Manages NEAR AI session tokens with persistence and automatic renewal.
|
||||
pub struct SessionManager {
|
||||
config: SessionConfig,
|
||||
@@ -236,10 +232,10 @@ impl SessionManager {
|
||||
/// 2. Set NEARAI_API_KEY env var and save to bootstrap .env
|
||||
/// 3. No session token saved (different auth model)
|
||||
async fn initiate_login(&self) -> Result<(), LlmError> {
|
||||
use crate::cli::oauth_defaults;
|
||||
use crate::llm::oauth_helpers;
|
||||
|
||||
let cb_url = oauth_defaults::callback_url();
|
||||
let host = oauth_defaults::callback_host();
|
||||
let cb_url = oauth_helpers::callback_url();
|
||||
let host = oauth_helpers::callback_host();
|
||||
|
||||
// Show auth provider menu BEFORE binding the listener
|
||||
println!();
|
||||
@@ -292,7 +288,7 @@ impl SessionManager {
|
||||
|
||||
// Warn about plain-HTTP token transmission only for OAuth paths (1, 2)
|
||||
// where the callback URL actually carries the session token.
|
||||
if !oauth_defaults::is_loopback_host(&host) {
|
||||
if !oauth_helpers::is_loopback_host(&host) {
|
||||
println!();
|
||||
println!("Warning: OAuth callback is using plain HTTP to a remote host ({host}).");
|
||||
println!(" The session token will be transmitted unencrypted.");
|
||||
@@ -303,12 +299,12 @@ impl SessionManager {
|
||||
}
|
||||
|
||||
// OAuth paths: bind the callback listener now
|
||||
let listener = oauth_defaults::bind_callback_listener()
|
||||
.await
|
||||
.map_err(|e| LlmError::SessionRenewalFailed {
|
||||
let listener = oauth_helpers::bind_callback_listener().await.map_err(|e| {
|
||||
LlmError::SessionRenewalFailed {
|
||||
provider: "nearai".to_string(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
}
|
||||
})?;
|
||||
|
||||
let (auth_provider, auth_url) = match choice.trim() {
|
||||
"2" => {
|
||||
@@ -348,7 +344,7 @@ impl SessionManager {
|
||||
|
||||
// The NEAR AI API redirects to: {frontend_callback}/auth/callback?token=X&...
|
||||
let session_token =
|
||||
oauth_defaults::wait_for_callback(listener, "/auth/callback", "token", "NEAR AI", None)
|
||||
oauth_helpers::wait_for_callback(listener, "/auth/callback", "token", "NEAR AI", None)
|
||||
.await
|
||||
.map_err(|e| LlmError::SessionRenewalFailed {
|
||||
provider: "nearai".to_string(),
|
||||
@@ -631,6 +627,9 @@ pub async fn create_session_manager(config: SessionConfig) -> Arc<SessionManager
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testing::credentials::{
|
||||
TEST_SESSION_NEARAI_ABC, TEST_SESSION_NEARAI_XYZ, TEST_SESSION_TOKEN,
|
||||
};
|
||||
use secrecy::ExposeSecret;
|
||||
use tempfile::tempdir;
|
||||
|
||||
@@ -651,28 +650,28 @@ mod tests {
|
||||
|
||||
// Save a token
|
||||
manager
|
||||
.save_session("test_token_123", Some("near"))
|
||||
.save_session(TEST_SESSION_TOKEN, Some("near"))
|
||||
.await
|
||||
.unwrap();
|
||||
manager
|
||||
.set_token(SecretString::from("test_token_123"))
|
||||
.set_token(SecretString::from(TEST_SESSION_TOKEN))
|
||||
.await;
|
||||
|
||||
// Verify it's set
|
||||
assert!(manager.has_token().await);
|
||||
let token = manager.get_token().await.unwrap();
|
||||
assert_eq!(token.expose_secret(), "test_token_123");
|
||||
assert_eq!(token.expose_secret(), TEST_SESSION_TOKEN);
|
||||
|
||||
// Create new manager and verify it loads the token
|
||||
let manager2 = SessionManager::new_async(config).await;
|
||||
assert!(manager2.has_token().await);
|
||||
let token2 = manager2.get_token().await.unwrap();
|
||||
assert_eq!(token2.expose_secret(), "test_token_123");
|
||||
assert_eq!(token2.expose_secret(), TEST_SESSION_TOKEN);
|
||||
|
||||
// Verify file contents
|
||||
let data: SessionData =
|
||||
serde_json::from_str(&std::fs::read_to_string(&session_path).unwrap()).unwrap();
|
||||
assert_eq!(data.session_token, "test_token_123");
|
||||
assert_eq!(data.session_token, TEST_SESSION_TOKEN);
|
||||
assert_eq!(data.auth_provider, Some("near".to_string()));
|
||||
}
|
||||
|
||||
@@ -690,17 +689,10 @@ mod tests {
|
||||
assert!(matches!(result, Err(LlmError::AuthFailed { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_session_path() {
|
||||
let path = default_session_path();
|
||||
assert!(path.ends_with("session.json"));
|
||||
assert!(path.to_string_lossy().contains(".ironclaw"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_data_serde_roundtrip_with_auth_provider() {
|
||||
let original = SessionData {
|
||||
session_token: "sess_abc123".to_string(),
|
||||
session_token: TEST_SESSION_NEARAI_ABC.to_string(),
|
||||
created_at: Utc::now(),
|
||||
auth_provider: Some("github".to_string()),
|
||||
};
|
||||
@@ -714,7 +706,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_session_data_serde_roundtrip_without_auth_provider() {
|
||||
let original = SessionData {
|
||||
session_token: "sess_xyz789".to_string(),
|
||||
session_token: TEST_SESSION_NEARAI_XYZ.to_string(),
|
||||
created_at: Utc::now(),
|
||||
auth_provider: None,
|
||||
};
|
||||
@@ -737,7 +729,6 @@ mod tests {
|
||||
let config = SessionConfig::default();
|
||||
assert_eq!(config.auth_base_url, "https://private.near.ai");
|
||||
assert!(config.session_path.ends_with("session.json"));
|
||||
assert!(config.session_path.to_string_lossy().contains(".ironclaw"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -24,7 +24,7 @@ use async_trait::async_trait;
|
||||
use regex::Regex;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
use crate::error::LlmError;
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::provider::{
|
||||
CompletionRequest, CompletionResponse, LlmProvider, ModelMetadata, Role, ToolCompletionRequest,
|
||||
ToolCompletionResponse,
|
||||
@@ -946,6 +946,10 @@ impl LlmProvider for SmartRoutingProvider {
|
||||
self.primary.model_metadata().await
|
||||
}
|
||||
|
||||
fn effective_model_name(&self, requested_model: Option<&str>) -> String {
|
||||
self.primary.effective_model_name(requested_model)
|
||||
}
|
||||
|
||||
fn active_model_name(&self) -> String {
|
||||
self.primary.active_model_name()
|
||||
}
|
||||
|
||||
+67
-597
@@ -1,9 +1,9 @@
|
||||
//! IronClaw - Main entry point.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use clap::Parser;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use ironclaw::{
|
||||
agent::{Agent, AgentDeps},
|
||||
@@ -11,10 +11,7 @@ use ironclaw::{
|
||||
channels::{
|
||||
ChannelManager, GatewayChannel, HttpChannel, ReplChannel, SignalChannel, WebhookServer,
|
||||
WebhookServerConfig,
|
||||
wasm::{
|
||||
RegisteredEndpoint, SharedWasmChannel, WasmChannelLoader, WasmChannelRouter,
|
||||
WasmChannelRuntime, WasmChannelRuntimeConfig, create_wasm_channel_router,
|
||||
},
|
||||
wasm::{WasmChannelRouter, WasmChannelRuntime},
|
||||
web::log_layer::LogBroadcaster,
|
||||
},
|
||||
cli::{
|
||||
@@ -24,26 +21,14 @@ use ironclaw::{
|
||||
config::Config,
|
||||
hooks::bootstrap_hooks,
|
||||
llm::create_session_manager,
|
||||
orchestrator::{
|
||||
ContainerJobConfig, ContainerJobManager, OrchestratorApi, TokenStore,
|
||||
api::OrchestratorState,
|
||||
},
|
||||
orchestrator::{ReaperConfig, SandboxReaper},
|
||||
pairing::PairingStore,
|
||||
secrets::SecretsStore,
|
||||
tracing_fmt::{init_cli_tracing, init_worker_tracing},
|
||||
};
|
||||
|
||||
#[cfg(any(feature = "postgres", feature = "libsql"))]
|
||||
use ironclaw::setup::{SetupConfig, SetupWizard};
|
||||
|
||||
/// Initialize tracing for simple CLI commands (warn level, no fancy layers).
|
||||
fn init_cli_tracing() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")),
|
||||
)
|
||||
.init();
|
||||
}
|
||||
|
||||
/// Synchronous entry point. Loads `.env` files before the Tokio runtime
|
||||
/// starts so that `std::env::set_var` is safe (no worker threads yet).
|
||||
fn main() -> anyhow::Result<()> {
|
||||
@@ -79,7 +64,7 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
}
|
||||
Some(Command::Memory(mem_cmd)) => {
|
||||
init_cli_tracing();
|
||||
return run_memory_command(mem_cmd).await;
|
||||
return ironclaw::cli::run_memory_command(mem_cmd).await;
|
||||
}
|
||||
Some(Command::Pairing(pairing_cmd)) => {
|
||||
init_cli_tracing();
|
||||
@@ -107,7 +92,7 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
max_iterations,
|
||||
}) => {
|
||||
init_worker_tracing();
|
||||
return run_worker(*job_id, orchestrator_url, *max_iterations).await;
|
||||
return ironclaw::worker::run_worker(*job_id, orchestrator_url, *max_iterations).await;
|
||||
}
|
||||
Some(Command::ClaudeBridge {
|
||||
job_id,
|
||||
@@ -116,12 +101,19 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
model,
|
||||
}) => {
|
||||
init_worker_tracing();
|
||||
return run_claude_bridge(*job_id, orchestrator_url, *max_turns, model).await;
|
||||
return ironclaw::worker::run_claude_bridge(
|
||||
*job_id,
|
||||
orchestrator_url,
|
||||
*max_turns,
|
||||
model,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Some(Command::Onboard {
|
||||
skip_auth,
|
||||
channels_only,
|
||||
provider_only,
|
||||
quick,
|
||||
}) => {
|
||||
#[cfg(any(feature = "postgres", feature = "libsql"))]
|
||||
{
|
||||
@@ -129,13 +121,14 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
skip_auth: *skip_auth,
|
||||
channels_only: *channels_only,
|
||||
provider_only: *provider_only,
|
||||
quick: *quick,
|
||||
};
|
||||
let mut wizard = SetupWizard::with_config(config);
|
||||
wizard.run().await?;
|
||||
}
|
||||
#[cfg(not(any(feature = "postgres", feature = "libsql")))]
|
||||
{
|
||||
let _ = (skip_auth, channels_only, provider_only);
|
||||
let _ = (skip_auth, channels_only, provider_only, quick);
|
||||
eprintln!("Onboarding wizard requires the 'postgres' or 'libsql' feature.");
|
||||
}
|
||||
return Ok(());
|
||||
@@ -168,11 +161,14 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
// Enhanced first-run detection
|
||||
#[cfg(any(feature = "postgres", feature = "libsql"))]
|
||||
if !cli.no_onboard
|
||||
&& let Some(reason) = check_onboard_needed()
|
||||
&& let Some(reason) = ironclaw::setup::check_onboard_needed()
|
||||
{
|
||||
println!("Onboarding needed: {}", reason);
|
||||
println!();
|
||||
let mut wizard = SetupWizard::new();
|
||||
let mut wizard = SetupWizard::with_config(SetupConfig {
|
||||
quick: true,
|
||||
..Default::default()
|
||||
});
|
||||
wizard.run().await?;
|
||||
}
|
||||
|
||||
@@ -205,9 +201,9 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
let log_level_handle =
|
||||
ironclaw::channels::web::log_layer::init_tracing(Arc::clone(&log_broadcaster));
|
||||
|
||||
tracing::info!("Starting IronClaw...");
|
||||
tracing::info!("Loaded configuration for agent: {}", config.agent.name);
|
||||
tracing::info!("LLM backend: {}", config.llm.backend);
|
||||
tracing::debug!("Starting IronClaw...");
|
||||
tracing::debug!("Loaded configuration for agent: {}", config.agent.name);
|
||||
tracing::debug!("LLM backend: {}", config.llm.backend);
|
||||
|
||||
// ── Phase 1-5: Build all core components via AppBuilder ────────────
|
||||
|
||||
@@ -226,95 +222,21 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
|
||||
// ── Tunnel setup ───────────────────────────────────────────────────
|
||||
|
||||
let (config, active_tunnel) = start_tunnel(config).await;
|
||||
let (config, active_tunnel) = ironclaw::tunnel::start_managed_tunnel(config).await;
|
||||
|
||||
// ── Orchestrator / container job manager ────────────────────────────
|
||||
|
||||
// Proactive Docker detection
|
||||
let docker_status = if config.sandbox.enabled {
|
||||
let detection = ironclaw::sandbox::check_docker().await;
|
||||
match detection.status {
|
||||
ironclaw::sandbox::DockerStatus::Available => {
|
||||
tracing::info!("Docker is available");
|
||||
}
|
||||
ironclaw::sandbox::DockerStatus::NotInstalled => {
|
||||
tracing::warn!(
|
||||
"Docker is not installed -- sandbox disabled for this session. {}",
|
||||
detection.platform.install_hint()
|
||||
);
|
||||
}
|
||||
ironclaw::sandbox::DockerStatus::NotRunning => {
|
||||
tracing::warn!(
|
||||
"Docker is installed but not running -- sandbox disabled for this session. {}",
|
||||
detection.platform.start_hint()
|
||||
);
|
||||
}
|
||||
ironclaw::sandbox::DockerStatus::Disabled => {}
|
||||
}
|
||||
detection.status
|
||||
} else {
|
||||
ironclaw::sandbox::DockerStatus::Disabled
|
||||
};
|
||||
|
||||
let job_event_tx: Option<
|
||||
tokio::sync::broadcast::Sender<(uuid::Uuid, ironclaw::channels::web::types::SseEvent)>,
|
||||
> = if config.sandbox.enabled && docker_status.is_ok() {
|
||||
let (tx, _) = tokio::sync::broadcast::channel(256);
|
||||
Some(tx)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let prompt_queue = Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::<
|
||||
uuid::Uuid,
|
||||
std::collections::VecDeque<ironclaw::orchestrator::api::PendingPrompt>,
|
||||
>::new()));
|
||||
|
||||
let container_job_manager: Option<Arc<ContainerJobManager>> =
|
||||
if config.sandbox.enabled && docker_status.is_ok() {
|
||||
let token_store = TokenStore::new();
|
||||
let job_config = ContainerJobConfig {
|
||||
image: config.sandbox.image.clone(),
|
||||
memory_limit_mb: config.sandbox.memory_limit_mb,
|
||||
cpu_shares: config.sandbox.cpu_shares,
|
||||
orchestrator_port: 50051,
|
||||
claude_code_api_key: std::env::var("ANTHROPIC_API_KEY").ok(),
|
||||
claude_code_oauth_token: ironclaw::config::ClaudeCodeConfig::extract_oauth_token(),
|
||||
claude_code_model: config.claude_code.model.clone(),
|
||||
claude_code_max_turns: config.claude_code.max_turns,
|
||||
claude_code_memory_limit_mb: config.claude_code.memory_limit_mb,
|
||||
claude_code_allowed_tools: config.claude_code.allowed_tools.clone(),
|
||||
};
|
||||
let jm = Arc::new(ContainerJobManager::new(job_config, token_store.clone()));
|
||||
|
||||
// Start the orchestrator internal API in the background
|
||||
let orchestrator_state = OrchestratorState {
|
||||
llm: components.llm.clone(),
|
||||
job_manager: Arc::clone(&jm),
|
||||
token_store,
|
||||
job_event_tx: job_event_tx.clone(),
|
||||
prompt_queue: Arc::clone(&prompt_queue),
|
||||
store: components.db.clone(),
|
||||
secrets_store: components.secrets_store.clone(),
|
||||
user_id: "default".to_string(),
|
||||
};
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = OrchestratorApi::start(orchestrator_state, 50051).await {
|
||||
tracing::error!("Orchestrator API failed: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
if config.claude_code.enabled {
|
||||
tracing::info!(
|
||||
"Claude Code sandbox mode available (model: {}, max_turns: {})",
|
||||
config.claude_code.model,
|
||||
config.claude_code.max_turns
|
||||
);
|
||||
}
|
||||
Some(jm)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let orch = ironclaw::orchestrator::setup_orchestrator(
|
||||
&config,
|
||||
&components.llm,
|
||||
components.db.as_ref(),
|
||||
components.secrets_store.as_ref(),
|
||||
)
|
||||
.await;
|
||||
let container_job_manager = orch.container_job_manager;
|
||||
let job_event_tx = orch.job_event_tx;
|
||||
let prompt_queue = orch.prompt_queue;
|
||||
let docker_status = orch.docker_status;
|
||||
|
||||
// ── Channel setup ──────────────────────────────────────────────────
|
||||
|
||||
@@ -342,10 +264,10 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
if let Some(repl) = repl_channel {
|
||||
channels.add(Box::new(repl)).await;
|
||||
if cli.message.is_some() {
|
||||
tracing::info!("Single message mode");
|
||||
tracing::debug!("Single message mode");
|
||||
} else {
|
||||
channel_names.push("repl".to_string());
|
||||
tracing::info!("REPL mode enabled");
|
||||
tracing::debug!("REPL mode enabled");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,7 +276,7 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
|
||||
// Load WASM channels and register their webhook routes.
|
||||
if config.channels.wasm_channels_enabled && config.channels.wasm_channels_dir.exists() {
|
||||
let wasm_result = setup_wasm_channels(
|
||||
let wasm_result = ironclaw::channels::wasm::setup_wasm_channels(
|
||||
&config,
|
||||
&components.secrets_store,
|
||||
components.extension_manager.as_ref(),
|
||||
@@ -387,7 +309,7 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
channel_names.push("signal".to_string());
|
||||
channels.add(Box::new(signal_channel)).await;
|
||||
let safe_url = SignalChannel::redact_url(&signal_config.http_url);
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
url = %safe_url,
|
||||
"Signal channel enabled"
|
||||
);
|
||||
@@ -413,7 +335,7 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
);
|
||||
channel_names.push("http".to_string());
|
||||
channels.add(Box::new(http_channel)).await;
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
"HTTP channel enabled on {}:{}",
|
||||
http_config.host,
|
||||
http_config.port
|
||||
@@ -454,7 +376,7 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
&components.dev_loaded_tool_names,
|
||||
)
|
||||
.await;
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
bundled = hook_bootstrap.bundled_hooks,
|
||||
plugin = hook_bootstrap.plugin_hooks,
|
||||
workspace = hook_bootstrap.workspace_hooks,
|
||||
@@ -547,7 +469,7 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
gw.auth_token()
|
||||
));
|
||||
|
||||
tracing::info!("Web UI: http://{}:{}/", gw_config.host, gw_config.port);
|
||||
tracing::debug!("Web UI: http://{}:{}/", gw_config.host, gw_config.port);
|
||||
|
||||
// Capture SSE sender and routine engine slot before moving gw into channels.
|
||||
// IMPORTANT: This must come after all `with_*` calls since `rebuild_state`
|
||||
@@ -632,7 +554,7 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
config.channels.wasm_channel_owner_ids.clone(),
|
||||
)
|
||||
.await;
|
||||
tracing::info!("Channel runtime wired into extension manager for hot-activation");
|
||||
tracing::debug!("Channel runtime wired into extension manager for hot-activation");
|
||||
|
||||
// Auto-activate channels that were active in a previous session.
|
||||
let persisted = ext_mgr.load_persisted_active_channels().await;
|
||||
@@ -640,7 +562,7 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
if !active_at_startup.contains(name) {
|
||||
match ext_mgr.activate(name).await {
|
||||
Ok(result) => {
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
channel = %name,
|
||||
message = %result.message,
|
||||
"Auto-activated persisted channel"
|
||||
@@ -676,6 +598,9 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
.recording_handle
|
||||
.as_ref()
|
||||
.map(|r| r.http_interceptor());
|
||||
// Clone context_manager for the reaper before it's moved into Agent::new()
|
||||
let reaper_context_manager = Arc::clone(&components.context_manager);
|
||||
|
||||
let deps = AgentDeps {
|
||||
store: components.db,
|
||||
llm: components.llm,
|
||||
@@ -714,6 +639,23 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
// Fill the scheduler slot now that Agent (and its Scheduler) exist.
|
||||
*scheduler_slot.write().await = Some(agent.scheduler());
|
||||
|
||||
// Spawn sandbox reaper for orphaned container cleanup
|
||||
if let Some(ref jm) = container_job_manager {
|
||||
let reaper_jm = Arc::clone(jm);
|
||||
let reaper_config = ReaperConfig {
|
||||
scan_interval: Duration::from_secs(config.sandbox.reaper_interval_secs),
|
||||
orphan_threshold: Duration::from_secs(config.sandbox.orphan_threshold_secs),
|
||||
..ReaperConfig::default()
|
||||
};
|
||||
let reaper_ctx = Arc::clone(&reaper_context_manager);
|
||||
tokio::spawn(async move {
|
||||
match SandboxReaper::new(reaper_jm, reaper_ctx, reaper_config).await {
|
||||
Ok(reaper) => reaper.run().await,
|
||||
Err(e) => tracing::error!("Sandbox reaper failed to initialize: {}", e),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Give the agent the routine engine slot so it can expose the engine to the gateway.
|
||||
if let Some(slot) = routine_engine_slot {
|
||||
agent.set_routine_engine_slot(slot);
|
||||
@@ -738,485 +680,13 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
if let Some(tunnel) = active_tunnel {
|
||||
tracing::info!("Stopping {} tunnel...", tunnel.name());
|
||||
tracing::debug!("Stopping {} tunnel...", tunnel.name());
|
||||
if let Err(e) = tunnel.stop().await {
|
||||
tracing::warn!("Failed to stop tunnel cleanly: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("Agent shutdown complete");
|
||||
tracing::debug!("Agent shutdown complete");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Helper functions ────────────────────────────────────────────────────
|
||||
|
||||
/// Initialize tracing for worker/bridge processes (info level).
|
||||
fn init_worker_tracing() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("ironclaw=info")),
|
||||
)
|
||||
.init();
|
||||
}
|
||||
|
||||
/// Run the Memory CLI subcommand.
|
||||
async fn run_memory_command(mem_cmd: &ironclaw::cli::MemoryCommand) -> anyhow::Result<()> {
|
||||
let config = Config::from_env()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
let session = create_session_manager(config.llm.session.clone()).await;
|
||||
|
||||
let embeddings = config
|
||||
.embeddings
|
||||
.create_provider(&config.llm.nearai.base_url, session);
|
||||
|
||||
let db: Arc<dyn ironclaw::db::Database> = ironclaw::db::connect_from_config(&config.database)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
ironclaw::cli::run_memory_command_with_db(mem_cmd.clone(), db, embeddings).await
|
||||
}
|
||||
|
||||
/// Run the Worker subcommand (inside Docker containers).
|
||||
async fn run_worker(
|
||||
job_id: uuid::Uuid,
|
||||
orchestrator_url: &str,
|
||||
max_iterations: u32,
|
||||
) -> anyhow::Result<()> {
|
||||
tracing::info!(
|
||||
"Starting worker for job {} (orchestrator: {})",
|
||||
job_id,
|
||||
orchestrator_url
|
||||
);
|
||||
|
||||
let config = ironclaw::worker::runtime::WorkerConfig {
|
||||
job_id,
|
||||
orchestrator_url: orchestrator_url.to_string(),
|
||||
max_iterations,
|
||||
timeout: std::time::Duration::from_secs(600),
|
||||
};
|
||||
|
||||
let runtime = ironclaw::worker::WorkerRuntime::new(config)
|
||||
.map_err(|e| anyhow::anyhow!("Worker init failed: {}", e))?;
|
||||
|
||||
runtime
|
||||
.run()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Worker failed: {}", e))
|
||||
}
|
||||
|
||||
/// Run the Claude Code bridge subcommand (inside Docker containers).
|
||||
async fn run_claude_bridge(
|
||||
job_id: uuid::Uuid,
|
||||
orchestrator_url: &str,
|
||||
max_turns: u32,
|
||||
model: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
tracing::info!(
|
||||
"Starting Claude Code bridge for job {} (orchestrator: {}, model: {})",
|
||||
job_id,
|
||||
orchestrator_url,
|
||||
model
|
||||
);
|
||||
|
||||
let config = ironclaw::worker::claude_bridge::ClaudeBridgeConfig {
|
||||
job_id,
|
||||
orchestrator_url: orchestrator_url.to_string(),
|
||||
max_turns,
|
||||
model: model.to_string(),
|
||||
timeout: std::time::Duration::from_secs(1800),
|
||||
allowed_tools: ironclaw::config::ClaudeCodeConfig::from_env().allowed_tools,
|
||||
};
|
||||
|
||||
let runtime = ironclaw::worker::ClaudeBridgeRuntime::new(config)
|
||||
.map_err(|e| anyhow::anyhow!("Claude bridge init failed: {}", e))?;
|
||||
|
||||
runtime
|
||||
.run()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Claude bridge failed: {}", e))
|
||||
}
|
||||
|
||||
/// Start managed tunnel if configured and no static URL is already set.
|
||||
async fn start_tunnel(
|
||||
mut config: ironclaw::config::Config,
|
||||
) -> (
|
||||
ironclaw::config::Config,
|
||||
Option<Box<dyn ironclaw::tunnel::Tunnel>>,
|
||||
) {
|
||||
if config.tunnel.public_url.is_some() {
|
||||
tracing::info!(
|
||||
"Static tunnel URL in use: {}",
|
||||
config.tunnel.public_url.as_deref().unwrap_or("?")
|
||||
);
|
||||
return (config, None);
|
||||
}
|
||||
|
||||
let Some(ref provider_config) = config.tunnel.provider else {
|
||||
return (config, None);
|
||||
};
|
||||
|
||||
let gateway_port = config
|
||||
.channels
|
||||
.gateway
|
||||
.as_ref()
|
||||
.map(|g| g.port)
|
||||
.unwrap_or(3000);
|
||||
let gateway_host = config
|
||||
.channels
|
||||
.gateway
|
||||
.as_ref()
|
||||
.map(|g| g.host.as_str())
|
||||
.unwrap_or("127.0.0.1");
|
||||
|
||||
match ironclaw::tunnel::create_tunnel(provider_config) {
|
||||
Ok(Some(tunnel)) => {
|
||||
tracing::info!(
|
||||
"Starting {} tunnel on {}:{}...",
|
||||
tunnel.name(),
|
||||
gateway_host,
|
||||
gateway_port
|
||||
);
|
||||
match tunnel.start(gateway_host, gateway_port).await {
|
||||
Ok(url) => {
|
||||
tracing::info!("Tunnel started: {}", url);
|
||||
config.tunnel.public_url = Some(url);
|
||||
(config, Some(tunnel))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to start tunnel: {}", e);
|
||||
(config, None)
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => (config, None),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to create tunnel: {}", e);
|
||||
(config, None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of WASM channel setup.
|
||||
struct WasmChannelSetup {
|
||||
channels: Vec<(String, Box<dyn ironclaw::channels::Channel>)>,
|
||||
channel_names: Vec<String>,
|
||||
webhook_routes: Option<axum::Router>,
|
||||
/// Runtime objects needed for hot-activation via ExtensionManager.
|
||||
wasm_channel_runtime: Arc<WasmChannelRuntime>,
|
||||
pairing_store: Arc<PairingStore>,
|
||||
wasm_channel_router: Arc<WasmChannelRouter>,
|
||||
}
|
||||
|
||||
/// Load WASM channels and register their webhook routes.
|
||||
async fn setup_wasm_channels(
|
||||
config: &ironclaw::config::Config,
|
||||
secrets_store: &Option<Arc<dyn SecretsStore + Send + Sync>>,
|
||||
extension_manager: Option<&Arc<ironclaw::extensions::ExtensionManager>>,
|
||||
database: Option<&Arc<dyn ironclaw::db::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 ironclaw::db::SettingsStore>> =
|
||||
database.map(|db| Arc::clone(db) as Arc<dyn ironclaw::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 ironclaw::channels::Channel>)> = Vec::new();
|
||||
let mut channel_names: Vec<String> = Vec::new();
|
||||
|
||||
for loaded in results.loaded {
|
||||
let channel_name = loaded.name().to_string();
|
||||
channel_names.push(channel_name.clone());
|
||||
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);
|
||||
|
||||
{
|
||||
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()),
|
||||
);
|
||||
}
|
||||
|
||||
// Inject owner_id if configured for this channel.
|
||||
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");
|
||||
}
|
||||
|
||||
if let Some(secrets) = secrets_store {
|
||||
match inject_channel_credentials(&channel_arc, secrets.as_ref(), &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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
channels.push((channel_name, Box::new(SharedWasmChannel::new(channel_arc))));
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if onboarding is needed and return the reason.
|
||||
#[cfg(any(feature = "postgres", feature = "libsql"))]
|
||||
fn check_onboard_needed() -> Option<&'static str> {
|
||||
let has_db = std::env::var("DATABASE_URL").is_ok()
|
||||
|| std::env::var("LIBSQL_PATH").is_ok()
|
||||
|| ironclaw::config::default_libsql_path().exists();
|
||||
|
||||
if !has_db {
|
||||
return Some("Database not configured");
|
||||
}
|
||||
|
||||
if std::env::var("ONBOARD_COMPLETED")
|
||||
.map(|v| v == "true")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
if std::env::var("NEARAI_API_KEY").is_err() {
|
||||
let session_path = ironclaw::llm::session::default_session_path();
|
||||
if !session_path.exists() {
|
||||
return Some("First run");
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// 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 with the uppercase name if not found
|
||||
/// in the secrets store (e.g., `TELEGRAM_BOT_TOKEN`).
|
||||
async fn inject_channel_credentials(
|
||||
channel: &Arc<ironclaw::channels::wasm::WasmChannel>,
|
||||
secrets: &dyn SecretsStore,
|
||||
channel_name: &str,
|
||||
) -> anyhow::Result<usize> {
|
||||
let all_secrets = secrets
|
||||
.list("default")
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to list secrets: {}", e))?;
|
||||
|
||||
let prefix = format!("{}_", channel_name);
|
||||
let mut count = 0;
|
||||
let mut injected_placeholders = std::collections::HashSet::new();
|
||||
|
||||
for secret_meta in all_secrets {
|
||||
if !secret_meta.name.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;
|
||||
}
|
||||
|
||||
// Fall back to environment variables for required secrets not found in the store.
|
||||
// This allows channels to work when configured via env vars (e.g., TELEGRAM_BOT_TOKEN)
|
||||
// without requiring the setup wizard to have run.
|
||||
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 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)
|
||||
}
|
||||
|
||||
@@ -458,6 +458,7 @@ mod tests {
|
||||
use crate::orchestrator::auth::TokenStore;
|
||||
use crate::orchestrator::job_manager::{ContainerJobConfig, ContainerJobManager};
|
||||
use crate::testing::StubLlm;
|
||||
use crate::testing::credentials::test_secrets_store;
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -662,11 +663,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn credentials_returns_secrets_when_store_configured() {
|
||||
use secrecy::SecretString;
|
||||
let key = "0123456789abcdef0123456789abcdef";
|
||||
let crypto = Arc::new(
|
||||
crate::secrets::SecretsCrypto::new(SecretString::from(key.to_string())).unwrap(),
|
||||
);
|
||||
let secrets_store = Arc::new(crate::secrets::InMemorySecretsStore::new(crypto));
|
||||
let secrets_store = Arc::new(test_secrets_store());
|
||||
|
||||
// Create a secret
|
||||
secrets_store
|
||||
|
||||
@@ -400,6 +400,14 @@ impl ContainerJobManager {
|
||||
],
|
||||
};
|
||||
|
||||
// Add Docker labels for reaper identification and orphan detection
|
||||
let mut labels = std::collections::HashMap::new();
|
||||
labels.insert("ironclaw.job_id".to_string(), job_id.to_string());
|
||||
labels.insert(
|
||||
"ironclaw.created_at".to_string(),
|
||||
chrono::Utc::now().to_rfc3339(),
|
||||
);
|
||||
|
||||
let container_config = Config {
|
||||
image: Some(self.config.image.clone()),
|
||||
cmd: Some(cmd),
|
||||
@@ -407,6 +415,7 @@ impl ContainerJobManager {
|
||||
host_config: Some(host_config),
|
||||
user: Some("1000:1000".to_string()),
|
||||
working_dir: Some("/workspace".to_string()),
|
||||
labels: Some(labels),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
||||
@@ -31,9 +31,123 @@
|
||||
pub mod api;
|
||||
pub mod auth;
|
||||
pub mod job_manager;
|
||||
pub mod reaper;
|
||||
|
||||
pub use api::OrchestratorApi;
|
||||
pub use auth::{CredentialGrant, TokenStore};
|
||||
pub use job_manager::{
|
||||
CompletionResult, ContainerHandle, ContainerJobConfig, ContainerJobManager, JobMode,
|
||||
};
|
||||
pub use reaper::{ReaperConfig, SandboxReaper};
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::{Mutex, broadcast};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::channels::web::types::SseEvent;
|
||||
use crate::db::Database;
|
||||
use crate::llm::LlmProvider;
|
||||
use crate::secrets::SecretsStore;
|
||||
|
||||
/// Result of orchestrator setup, containing all handles needed by the agent.
|
||||
pub struct OrchestratorSetup {
|
||||
pub container_job_manager: Option<Arc<ContainerJobManager>>,
|
||||
pub job_event_tx: Option<broadcast::Sender<(Uuid, SseEvent)>>,
|
||||
pub prompt_queue: Arc<Mutex<HashMap<Uuid, VecDeque<api::PendingPrompt>>>>,
|
||||
pub docker_status: crate::sandbox::DockerStatus,
|
||||
}
|
||||
|
||||
/// Detect Docker availability, create the container job manager, and start
|
||||
/// the orchestrator internal API in the background.
|
||||
pub async fn setup_orchestrator(
|
||||
config: &crate::config::Config,
|
||||
llm: &Arc<dyn LlmProvider>,
|
||||
db: Option<&Arc<dyn Database>>,
|
||||
secrets_store: Option<&Arc<dyn SecretsStore + Send + Sync>>,
|
||||
) -> OrchestratorSetup {
|
||||
let prompt_queue = Arc::new(Mutex::new(
|
||||
HashMap::<Uuid, VecDeque<api::PendingPrompt>>::new(),
|
||||
));
|
||||
|
||||
let docker_status = if config.sandbox.enabled {
|
||||
let detection = crate::sandbox::check_docker().await;
|
||||
match detection.status {
|
||||
crate::sandbox::DockerStatus::Available => {
|
||||
tracing::info!("Docker is available");
|
||||
}
|
||||
crate::sandbox::DockerStatus::NotInstalled => {
|
||||
tracing::warn!(
|
||||
"Docker is not installed -- sandbox disabled for this session. {}",
|
||||
detection.platform.install_hint()
|
||||
);
|
||||
}
|
||||
crate::sandbox::DockerStatus::NotRunning => {
|
||||
tracing::warn!(
|
||||
"Docker is installed but not running -- sandbox disabled for this session. {}",
|
||||
detection.platform.start_hint()
|
||||
);
|
||||
}
|
||||
crate::sandbox::DockerStatus::Disabled => {}
|
||||
}
|
||||
detection.status
|
||||
} else {
|
||||
crate::sandbox::DockerStatus::Disabled
|
||||
};
|
||||
|
||||
let (job_event_tx, container_job_manager) = if config.sandbox.enabled && docker_status.is_ok() {
|
||||
let (tx, _) = broadcast::channel(256);
|
||||
let job_event_tx = Some(tx);
|
||||
|
||||
let token_store = TokenStore::new();
|
||||
let job_config = ContainerJobConfig {
|
||||
image: config.sandbox.image.clone(),
|
||||
memory_limit_mb: config.sandbox.memory_limit_mb,
|
||||
cpu_shares: config.sandbox.cpu_shares,
|
||||
orchestrator_port: 50051,
|
||||
claude_code_api_key: std::env::var("ANTHROPIC_API_KEY").ok(),
|
||||
claude_code_oauth_token: crate::config::ClaudeCodeConfig::extract_oauth_token(),
|
||||
claude_code_model: config.claude_code.model.clone(),
|
||||
claude_code_max_turns: config.claude_code.max_turns,
|
||||
claude_code_memory_limit_mb: config.claude_code.memory_limit_mb,
|
||||
claude_code_allowed_tools: config.claude_code.allowed_tools.clone(),
|
||||
};
|
||||
let jm = Arc::new(ContainerJobManager::new(job_config, token_store.clone()));
|
||||
|
||||
let orchestrator_state = api::OrchestratorState {
|
||||
llm: Arc::clone(llm),
|
||||
job_manager: Arc::clone(&jm),
|
||||
token_store,
|
||||
job_event_tx: job_event_tx.clone(),
|
||||
prompt_queue: Arc::clone(&prompt_queue),
|
||||
store: db.cloned(),
|
||||
secrets_store: secrets_store.cloned(),
|
||||
user_id: "default".to_string(),
|
||||
};
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = OrchestratorApi::start(orchestrator_state, 50051).await {
|
||||
tracing::error!("Orchestrator API failed: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
if config.claude_code.enabled {
|
||||
tracing::info!(
|
||||
"Claude Code sandbox mode available (model: {}, max_turns: {})",
|
||||
config.claude_code.model,
|
||||
config.claude_code.max_turns
|
||||
);
|
||||
}
|
||||
(job_event_tx, Some(jm))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
OrchestratorSetup {
|
||||
container_job_manager,
|
||||
job_event_tx,
|
||||
prompt_queue,
|
||||
docker_status,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,969 @@
|
||||
//! Orphaned Docker container cleanup.
|
||||
//!
|
||||
//! The SandboxReaper periodically scans Docker for IronClaw-labeled containers
|
||||
//! and cleans up those whose corresponding jobs are not active.
|
||||
//!
|
||||
//! **Problem:** If the agent process crashes between container creation and cleanup,
|
||||
//! containers are orphaned indefinitely.
|
||||
//!
|
||||
//! **Solution:** Background reaper task that:
|
||||
//! 1. Scans Docker for containers with the `ironclaw.job_id` label
|
||||
//! 2. Checks if each job is active in the ContextManager
|
||||
//! 3. Cleans up containers with inactive/missing jobs
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::context::ContextManager;
|
||||
use crate::orchestrator::job_manager::ContainerJobManager;
|
||||
use crate::sandbox::connect_docker;
|
||||
|
||||
/// Configuration for the sandbox reaper.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReaperConfig {
|
||||
/// How often to scan for orphaned containers.
|
||||
pub scan_interval: Duration,
|
||||
/// Containers older than this with no active job are reaped.
|
||||
pub orphan_threshold: Duration,
|
||||
/// Label key for looking up job IDs in Docker metadata.
|
||||
pub container_label: String,
|
||||
}
|
||||
|
||||
impl Default for ReaperConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
scan_interval: Duration::from_secs(300),
|
||||
orphan_threshold: Duration::from_secs(600),
|
||||
container_label: "ironclaw.job_id".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Background task that periodically cleans up orphaned Docker containers.
|
||||
pub struct SandboxReaper {
|
||||
docker: bollard::Docker,
|
||||
job_manager: Arc<ContainerJobManager>,
|
||||
context_manager: Arc<ContextManager>,
|
||||
config: ReaperConfig,
|
||||
}
|
||||
|
||||
impl SandboxReaper {
|
||||
/// Create a new reaper. Connects to Docker eagerly — returns error if Docker unavailable.
|
||||
pub async fn new(
|
||||
job_manager: Arc<ContainerJobManager>,
|
||||
context_manager: Arc<ContextManager>,
|
||||
config: ReaperConfig,
|
||||
) -> Result<Self, crate::sandbox::SandboxError> {
|
||||
let docker = connect_docker().await?;
|
||||
Ok(Self {
|
||||
docker,
|
||||
job_manager,
|
||||
context_manager,
|
||||
config,
|
||||
})
|
||||
}
|
||||
|
||||
/// Run the reaper loop forever. Should be spawned with `tokio::spawn`.
|
||||
pub async fn run(self) {
|
||||
// Validate scan_interval is non-zero to prevent tokio::time::interval panic
|
||||
if self.config.scan_interval.as_secs() == 0 {
|
||||
tracing::error!(
|
||||
"Reaper: scan_interval must be > 0, got {:?}. Reaper will not start.",
|
||||
self.config.scan_interval
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut interval = tokio::time::interval(self.config.scan_interval);
|
||||
// Skip any missed ticks if scan takes longer than the interval
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
loop {
|
||||
interval.tick().await;
|
||||
self.scan_and_reap().await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn scan_and_reap(&self) {
|
||||
let containers = match self.list_ironclaw_containers().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Reaper: failed to list Docker containers");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let now = Utc::now();
|
||||
// Compute threshold once outside the loop
|
||||
let threshold = match chrono::Duration::from_std(self.config.orphan_threshold) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
"Reaper: failed to convert orphan_threshold to chrono::Duration, using default of 10 minutes"
|
||||
);
|
||||
chrono::Duration::minutes(10)
|
||||
}
|
||||
};
|
||||
|
||||
for (container_id, job_id, created_at) in containers {
|
||||
let age = now.signed_duration_since(created_at);
|
||||
|
||||
if age < threshold {
|
||||
continue; // Too young — skip
|
||||
}
|
||||
|
||||
// Check if job is still active (any non-terminal state prevents reaping).
|
||||
// Terminal states: Failed, Cancelled, Accepted
|
||||
// Active states: Pending, InProgress, Completed, Submitted, Stuck
|
||||
// If job doesn't exist or is in a terminal state, it's eligible for reaping.
|
||||
let is_active = match self.context_manager.get_context(job_id).await {
|
||||
Ok(ctx) => ctx.state.is_active(),
|
||||
Err(_) => false, // Not found — treat as orphaned
|
||||
};
|
||||
|
||||
if is_active {
|
||||
tracing::debug!(
|
||||
job_id = %job_id,
|
||||
container_id = %&container_id[..12.min(container_id.len())],
|
||||
"Reaper: container has active job, skipping"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
job_id = %job_id,
|
||||
container_id = %&container_id[..12.min(container_id.len())],
|
||||
age_secs = age.num_seconds(),
|
||||
"Reaper: orphaned container detected, cleaning up"
|
||||
);
|
||||
|
||||
self.reap_container(&container_id, job_id).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// List all IronClaw-managed containers from Docker.
|
||||
///
|
||||
/// Returns tuples of (container_id, job_id, created_at).
|
||||
async fn list_ironclaw_containers(
|
||||
&self,
|
||||
) -> Result<Vec<(String, Uuid, DateTime<Utc>)>, bollard::errors::Error> {
|
||||
use bollard::container::ListContainersOptions;
|
||||
|
||||
let mut filters = HashMap::new();
|
||||
filters.insert("label", vec![self.config.container_label.as_str()]);
|
||||
|
||||
let options = ListContainersOptions {
|
||||
all: true, // include stopped containers
|
||||
filters,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let summaries = self.docker.list_containers(Some(options)).await?;
|
||||
let mut result = Vec::new();
|
||||
|
||||
for summary in summaries {
|
||||
let container_id = match summary.id {
|
||||
Some(id) => id,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let labels = summary.labels.unwrap_or_default();
|
||||
|
||||
// Parse job_id from label (using configured label key for consistency)
|
||||
let job_id = match labels
|
||||
.get(&self.config.container_label)
|
||||
.and_then(|s| s.parse::<Uuid>().ok())
|
||||
{
|
||||
Some(id) => id,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
container_id = %&container_id[..12.min(container_id.len())],
|
||||
label_key = %&self.config.container_label,
|
||||
"Reaper: ironclaw container missing valid job_id label"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Parse created_at from label (set by us at creation time); fall back to Docker timestamp
|
||||
let created_at = match labels
|
||||
.get("ironclaw.created_at")
|
||||
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.or_else(|| {
|
||||
summary
|
||||
.created
|
||||
.and_then(|ts| DateTime::from_timestamp(ts, 0))
|
||||
}) {
|
||||
Some(ts) => ts,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
container_id = %&container_id[..12.min(container_id.len())],
|
||||
"Reaper: could not determine creation time for container, skipping"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
result.push((container_id, job_id, created_at));
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Stop and remove a single orphaned container.
|
||||
///
|
||||
/// First tries `job_manager.stop_job()` (which also revokes the auth token).
|
||||
/// Falls back to direct Docker API if the handle is no longer in the in-memory map
|
||||
/// (e.g., after a process restart).
|
||||
async fn reap_container(&self, container_id: &str, job_id: Uuid) {
|
||||
// Try the high-level stop first (handles token revocation)
|
||||
match self.job_manager.stop_job(job_id).await {
|
||||
Ok(()) => {
|
||||
tracing::info!(
|
||||
job_id = %job_id,
|
||||
"Reaper: cleaned up orphaned container via job_manager"
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
job_id = %job_id,
|
||||
error = %e,
|
||||
"Reaper: job_manager.stop_job failed (likely no handle after restart), falling back to direct Docker cleanup"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back: direct Docker stop + force remove
|
||||
if let Err(e) = self
|
||||
.docker
|
||||
.stop_container(
|
||||
container_id,
|
||||
Some(bollard::container::StopContainerOptions { t: 10 }),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::debug!(
|
||||
job_id = %job_id,
|
||||
container_id = %&container_id[..12.min(container_id.len())],
|
||||
error = %e,
|
||||
"Reaper: stop_container failed (may already be stopped)"
|
||||
);
|
||||
}
|
||||
|
||||
if let Err(e) = self
|
||||
.docker
|
||||
.remove_container(
|
||||
container_id,
|
||||
Some(bollard::container::RemoveContainerOptions {
|
||||
force: true,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
job_id = %job_id,
|
||||
container_id = %&container_id[..12.min(container_id.len())],
|
||||
error = %e,
|
||||
"Reaper: failed to remove orphaned container"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
job_id = %job_id,
|
||||
container_id = %&container_id[..12.min(container_id.len())],
|
||||
"Reaper: removed orphaned container via direct Docker API"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
|
||||
// Test: age threshold filtering
|
||||
#[test]
|
||||
fn orphan_threshold_filters_young_containers() {
|
||||
let threshold = chrono::Duration::minutes(10);
|
||||
let young_age = chrono::Duration::minutes(2);
|
||||
assert!(young_age < threshold, "Young container should be skipped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orphan_threshold_allows_old_containers() {
|
||||
let threshold = chrono::Duration::minutes(10);
|
||||
let old_age = chrono::Duration::minutes(15);
|
||||
assert!(old_age >= threshold, "Old container should be reaped");
|
||||
}
|
||||
|
||||
// Test: active job detection
|
||||
#[tokio::test]
|
||||
async fn active_job_is_not_orphaned() {
|
||||
let ctx_mgr = Arc::new(ContextManager::new(5));
|
||||
|
||||
// Create job and get its ID
|
||||
let job_id = ctx_mgr
|
||||
.create_job_for_user("default", "test", "test description")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let ctx = ctx_mgr.get_context(job_id).await.unwrap();
|
||||
assert!(ctx.state.is_active(), "Pending job should be active");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_job_is_treated_as_orphaned() {
|
||||
let ctx_mgr = Arc::new(ContextManager::new(5));
|
||||
let job_id = Uuid::new_v4(); // Not created
|
||||
let is_active = match ctx_mgr.get_context(job_id).await {
|
||||
Ok(ctx) => ctx.state.is_active(),
|
||||
Err(_) => false,
|
||||
};
|
||||
assert!(!is_active, "Missing job should be treated as orphaned");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_job_is_treated_as_orphaned() {
|
||||
use crate::context::JobState;
|
||||
|
||||
let ctx_mgr = Arc::new(ContextManager::new(5));
|
||||
let job_id = ctx_mgr
|
||||
.create_job_for_user("default", "test", "test description")
|
||||
.await
|
||||
.unwrap();
|
||||
ctx_mgr
|
||||
.update_context(job_id, |ctx| {
|
||||
ctx.state = JobState::Failed;
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let ctx = ctx_mgr.get_context(job_id).await.unwrap();
|
||||
assert!(
|
||||
!ctx.state.is_active(),
|
||||
"Failed job should be treated as orphaned"
|
||||
);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Integration tests with mocks
|
||||
// ================================================================
|
||||
|
||||
/// Mock implementation of Docker API for testing.
|
||||
/// (Currently unused but kept for future mock-based integration tests)
|
||||
#[allow(dead_code)]
|
||||
struct MockDocker {
|
||||
containers: Arc<std::sync::Mutex<Vec<ContainerSummary>>>,
|
||||
stop_called: Arc<AtomicU32>,
|
||||
remove_called: Arc<AtomicU32>,
|
||||
stop_error: Arc<AtomicBool>,
|
||||
remove_error: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug)]
|
||||
struct ContainerSummary {
|
||||
id: String,
|
||||
labels: HashMap<String, String>,
|
||||
created: Option<i64>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl MockDocker {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
containers: Arc::new(std::sync::Mutex::new(Vec::new())),
|
||||
stop_called: Arc::new(AtomicU32::new(0)),
|
||||
remove_called: Arc::new(AtomicU32::new(0)),
|
||||
stop_error: Arc::new(AtomicBool::new(false)),
|
||||
remove_error: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_container(&self, id: String, labels: HashMap<String, String>, created: Option<i64>) {
|
||||
let mut cs = self.containers.lock().unwrap();
|
||||
cs.push(ContainerSummary {
|
||||
id,
|
||||
labels,
|
||||
created,
|
||||
});
|
||||
}
|
||||
|
||||
fn set_stop_error(&self, error: bool) {
|
||||
self.stop_error.store(error, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
fn set_remove_error(&self, error: bool) {
|
||||
self.remove_error.store(error, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
fn stop_call_count(&self) -> u32 {
|
||||
self.stop_called.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
fn remove_call_count(&self) -> u32 {
|
||||
self.remove_called.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
// Test: container labeling is parsed correctly
|
||||
#[test]
|
||||
fn parse_container_labels_extracts_job_id_and_timestamp() {
|
||||
let mut labels = HashMap::new();
|
||||
let job_id = Uuid::new_v4();
|
||||
labels.insert("ironclaw.job_id".to_string(), job_id.to_string());
|
||||
labels.insert(
|
||||
"ironclaw.created_at".to_string(),
|
||||
"2024-01-15T10:30:45+00:00".to_string(),
|
||||
);
|
||||
|
||||
// Verify parsing works
|
||||
let parsed_id: Option<Uuid> = labels
|
||||
.get("ironclaw.job_id")
|
||||
.and_then(|s| s.parse::<Uuid>().ok());
|
||||
assert_eq!(parsed_id, Some(job_id));
|
||||
|
||||
let parsed_time = labels
|
||||
.get("ironclaw.created_at")
|
||||
.and_then(|s| DateTime::parse_from_rfc3339(s).ok());
|
||||
assert!(parsed_time.is_some());
|
||||
}
|
||||
|
||||
// Test: missing job_id label is handled gracefully
|
||||
#[test]
|
||||
fn missing_job_id_label_is_skipped() {
|
||||
let labels: HashMap<String, String> = HashMap::new();
|
||||
let job_id: Option<Uuid> = labels
|
||||
.get("ironclaw.job_id")
|
||||
.and_then(|s| s.parse::<Uuid>().ok());
|
||||
assert_eq!(job_id, None);
|
||||
}
|
||||
|
||||
// Test: malformed timestamp falls back to Docker's created timestamp
|
||||
#[test]
|
||||
fn malformed_timestamp_fallback_works() {
|
||||
let mut labels: HashMap<String, String> = HashMap::new();
|
||||
labels.insert(
|
||||
"ironclaw.created_at".to_string(),
|
||||
"invalid-date".to_string(),
|
||||
);
|
||||
|
||||
let parsed_time = labels
|
||||
.get("ironclaw.created_at")
|
||||
.and_then(|s| DateTime::parse_from_rfc3339(s).ok());
|
||||
assert!(
|
||||
parsed_time.is_none(),
|
||||
"Malformed timestamp should fail to parse"
|
||||
);
|
||||
|
||||
// In actual code, Docker's summary.created timestamp is used as fallback.
|
||||
// If both our label and Docker's timestamp are missing/invalid, the container is skipped.
|
||||
// Verify that a valid Docker timestamp would be used as fallback:
|
||||
let docker_timestamp: Option<i64> = Some(1705324245); // Some valid Unix timestamp
|
||||
let fallback = docker_timestamp.and_then(|ts| DateTime::from_timestamp(ts, 0));
|
||||
assert!(
|
||||
fallback.is_some(),
|
||||
"Docker timestamp fallback should parse successfully"
|
||||
);
|
||||
}
|
||||
|
||||
// Test: age calculation distinguishes young from old containers
|
||||
#[tokio::test]
|
||||
async fn age_calculation_correctly_filters_containers() {
|
||||
let now = Utc::now();
|
||||
let young_container = now - chrono::Duration::minutes(2);
|
||||
let old_container = now - chrono::Duration::minutes(20);
|
||||
|
||||
let threshold = chrono::Duration::minutes(10);
|
||||
|
||||
let young_age = now.signed_duration_since(young_container);
|
||||
let old_age = now.signed_duration_since(old_container);
|
||||
|
||||
assert!(
|
||||
young_age < threshold,
|
||||
"Young container should not be cleaned"
|
||||
);
|
||||
assert!(old_age >= threshold, "Old container should be cleaned");
|
||||
}
|
||||
|
||||
// Test: active job prevents cleanup even if container is old
|
||||
#[tokio::test]
|
||||
async fn active_job_prevents_cleanup_of_old_container() {
|
||||
let ctx_mgr = Arc::new(ContextManager::new(5));
|
||||
|
||||
// Create an active job
|
||||
let job_id = ctx_mgr
|
||||
.create_job_for_user("default", "test", "test job")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify job is active
|
||||
let ctx = ctx_mgr.get_context(job_id).await.unwrap();
|
||||
assert!(ctx.state.is_active());
|
||||
|
||||
// Even if container is "old", active job means don't cleanup
|
||||
let is_active = match ctx_mgr.get_context(job_id).await {
|
||||
Ok(ctx) => ctx.state.is_active(),
|
||||
Err(_) => false,
|
||||
};
|
||||
assert!(is_active, "Active job should prevent cleanup");
|
||||
}
|
||||
|
||||
// Test: failed job allows cleanup (terminal state)
|
||||
#[tokio::test]
|
||||
async fn failed_job_allows_cleanup() {
|
||||
use crate::context::JobState;
|
||||
|
||||
let ctx_mgr = Arc::new(ContextManager::new(5));
|
||||
let job_id = ctx_mgr
|
||||
.create_job_for_user("default", "test", "test")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Mark job as failed (terminal state)
|
||||
ctx_mgr
|
||||
.update_context(job_id, |ctx| {
|
||||
ctx.state = JobState::Failed;
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let ctx = ctx_mgr.get_context(job_id).await.unwrap();
|
||||
assert!(
|
||||
!ctx.state.is_active(),
|
||||
"Failed job (terminal state) should allow cleanup"
|
||||
);
|
||||
}
|
||||
|
||||
// Test: config validation
|
||||
#[test]
|
||||
fn reaper_config_defaults_are_reasonable() {
|
||||
let cfg = ReaperConfig::default();
|
||||
assert_eq!(
|
||||
cfg.scan_interval,
|
||||
Duration::from_secs(300),
|
||||
"Scan interval should be 5 min"
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.orphan_threshold,
|
||||
Duration::from_secs(600),
|
||||
"Orphan threshold should be 10 min"
|
||||
);
|
||||
assert_eq!(cfg.container_label, "ironclaw.job_id");
|
||||
}
|
||||
|
||||
// Test: reaper config is customizable
|
||||
#[test]
|
||||
fn reaper_config_can_be_customized() {
|
||||
let cfg = ReaperConfig {
|
||||
scan_interval: Duration::from_secs(60),
|
||||
orphan_threshold: Duration::from_secs(300),
|
||||
container_label: "custom.label".to_string(),
|
||||
};
|
||||
assert_eq!(cfg.scan_interval, Duration::from_secs(60));
|
||||
assert_eq!(cfg.orphan_threshold, Duration::from_secs(300));
|
||||
assert_eq!(cfg.container_label, "custom.label");
|
||||
}
|
||||
|
||||
// Test: reaper correctly identifies which containers to cleanup
|
||||
#[tokio::test]
|
||||
async fn reaper_cleanup_decision_matrix() {
|
||||
use crate::context::JobState;
|
||||
|
||||
let ctx_mgr = Arc::new(ContextManager::new(5));
|
||||
|
||||
// Case 1: Pending job (active) -> should NOT cleanup even if old
|
||||
let job1 = ctx_mgr
|
||||
.create_job_for_user("default", "test", "test1")
|
||||
.await
|
||||
.unwrap();
|
||||
let ctx1 = ctx_mgr.get_context(job1).await.unwrap();
|
||||
assert!(ctx1.state.is_active(), "Pending job is active");
|
||||
assert!(ctx1.state.is_active(), "Should NOT cleanup active jobs");
|
||||
|
||||
// Case 2: In-progress job (active) -> should NOT cleanup even if old
|
||||
let job2 = ctx_mgr
|
||||
.create_job_for_user("default", "test", "test2")
|
||||
.await
|
||||
.unwrap();
|
||||
ctx_mgr
|
||||
.update_context(job2, |ctx| {
|
||||
ctx.state = JobState::InProgress;
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let ctx2 = ctx_mgr.get_context(job2).await.unwrap();
|
||||
assert!(ctx2.state.is_active(), "InProgress job is active");
|
||||
assert!(ctx2.state.is_active(), "Should NOT cleanup active jobs");
|
||||
|
||||
// Case 3: Completed job (active) -> still active, should NOT cleanup
|
||||
let job3 = ctx_mgr
|
||||
.create_job_for_user("default", "test", "test3")
|
||||
.await
|
||||
.unwrap();
|
||||
ctx_mgr
|
||||
.update_context(job3, |ctx| {
|
||||
ctx.state = JobState::Completed;
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let ctx3 = ctx_mgr.get_context(job3).await.unwrap();
|
||||
// Completed is NOT terminal, still active
|
||||
assert!(ctx3.state.is_active(), "Completed is still active");
|
||||
|
||||
// Case 4: Failed job (terminal) -> should cleanup if old enough
|
||||
let job4 = ctx_mgr
|
||||
.create_job_for_user("default", "test", "test4")
|
||||
.await
|
||||
.unwrap();
|
||||
ctx_mgr
|
||||
.update_context(job4, |ctx| {
|
||||
ctx.state = JobState::Failed;
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let ctx4 = ctx_mgr.get_context(job4).await.unwrap();
|
||||
assert!(
|
||||
!ctx4.state.is_active(),
|
||||
"Failed job is terminal (should cleanup if old)"
|
||||
);
|
||||
|
||||
// Case 5: Cancelled job (terminal) -> should cleanup if old enough
|
||||
let job5 = ctx_mgr
|
||||
.create_job_for_user("default", "test", "test5")
|
||||
.await
|
||||
.unwrap();
|
||||
ctx_mgr
|
||||
.update_context(job5, |ctx| {
|
||||
ctx.state = JobState::Cancelled;
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let ctx5 = ctx_mgr.get_context(job5).await.unwrap();
|
||||
assert!(!ctx5.state.is_active(), "Cancelled job is terminal");
|
||||
|
||||
// Case 6: Missing job -> should cleanup if old enough
|
||||
let missing_job = Uuid::new_v4();
|
||||
let is_active = match ctx_mgr.get_context(missing_job).await {
|
||||
Ok(ctx) => ctx.state.is_active(),
|
||||
Err(_) => false,
|
||||
};
|
||||
assert!(!is_active, "Missing job should be treated as inactive");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// End-to-end tests with real Docker containers
|
||||
// ================================================================
|
||||
//
|
||||
// These tests verify the reaper works with actual Docker containers.
|
||||
// They require Docker to be running and the IRONCLAW_E2E_DOCKER_TESTS
|
||||
// environment variable to be set (to avoid running them in CI by default).
|
||||
//
|
||||
// Run with: IRONCLAW_E2E_DOCKER_TESTS=1 cargo test orchestrator::reaper::e2e_tests --lib -- --nocapture
|
||||
|
||||
#[cfg(all(test, not(target_env = "msvc")))]
|
||||
mod e2e_tests {
|
||||
use super::*;
|
||||
|
||||
fn should_run_e2e() -> bool {
|
||||
std::env::var("IRONCLAW_E2E_DOCKER_TESTS").is_ok()
|
||||
}
|
||||
|
||||
/// Test that reaper can list containers with IronClaw labels
|
||||
#[tokio::test]
|
||||
async fn e2e_reaper_lists_ironclaw_containers() {
|
||||
if !should_run_e2e() {
|
||||
eprintln!("Skipping e2e test (set IRONCLAW_E2E_DOCKER_TESTS=1 to run)");
|
||||
return;
|
||||
}
|
||||
|
||||
// Connect to Docker
|
||||
let docker = match crate::sandbox::connect_docker().await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
eprintln!("Skipping e2e test: Docker unavailable: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Create a test container with IronClaw labels
|
||||
let job_id = Uuid::new_v4();
|
||||
let test_name = format!("ironclaw-reaper-test-{}", &job_id.to_string()[..8]);
|
||||
|
||||
let job_id_str = job_id.to_string();
|
||||
let created_at_str = (Utc::now() - chrono::Duration::hours(1)).to_rfc3339();
|
||||
|
||||
let mut labels_str: std::collections::HashMap<&str, &str> =
|
||||
std::collections::HashMap::new();
|
||||
labels_str.insert("ironclaw.job_id", &job_id_str);
|
||||
labels_str.insert("ironclaw.created_at", &created_at_str);
|
||||
|
||||
let config = bollard::container::CreateContainerOptions {
|
||||
name: test_name.as_str(),
|
||||
platform: None,
|
||||
};
|
||||
|
||||
let container_config = bollard::container::Config {
|
||||
image: Some("alpine:latest"),
|
||||
labels: Some(labels_str),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let response = match docker
|
||||
.create_container(Some(config), container_config)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
eprintln!("Skipping e2e test: Could not create test container: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let container_id = &response.id;
|
||||
tracing::info!(
|
||||
container_id = %&container_id[..12.min(container_id.len())],
|
||||
job_id = %job_id,
|
||||
"e2e test: created test container"
|
||||
);
|
||||
|
||||
// Verify container has correct labels
|
||||
let inspect = match docker.inspect_container(container_id, None).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let _ = docker.remove_container(container_id, None).await;
|
||||
eprintln!("Failed to inspect container: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let labels = inspect.config.and_then(|c| c.labels).unwrap_or_default();
|
||||
assert!(
|
||||
labels.contains_key("ironclaw.job_id"),
|
||||
"Container should have ironclaw.job_id label"
|
||||
);
|
||||
assert_eq!(
|
||||
labels.get("ironclaw.job_id").map(|s| s.as_str()),
|
||||
Some(job_id.to_string().as_str()),
|
||||
"job_id label should match"
|
||||
);
|
||||
|
||||
tracing::info!("e2e test: verified container labels");
|
||||
|
||||
// Clean up
|
||||
let _ = docker.remove_container(container_id, None).await;
|
||||
tracing::info!("e2e test: cleaned up test container");
|
||||
}
|
||||
|
||||
/// Test that reaper correctly identifies and removes orphaned containers
|
||||
#[tokio::test]
|
||||
async fn e2e_reaper_removes_orphaned_containers() {
|
||||
if !should_run_e2e() {
|
||||
eprintln!("Skipping e2e test (set IRONCLAW_E2E_DOCKER_TESTS=1 to run)");
|
||||
return;
|
||||
}
|
||||
|
||||
// Connect to Docker and create job manager / context manager
|
||||
let docker = match crate::sandbox::connect_docker().await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
eprintln!("Skipping e2e test: Docker unavailable: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Create a fake job ID that won't exist in context manager
|
||||
let orphaned_job_id = Uuid::new_v4();
|
||||
let test_name = format!("ironclaw-orphan-test-{}", &orphaned_job_id.to_string()[..8]);
|
||||
|
||||
let job_id_str = orphaned_job_id.to_string();
|
||||
let created_at_str = (Utc::now() - chrono::Duration::hours(2)).to_rfc3339();
|
||||
let mut labels: std::collections::HashMap<&str, &str> =
|
||||
std::collections::HashMap::new();
|
||||
labels.insert("ironclaw.job_id", &job_id_str);
|
||||
labels.insert("ironclaw.created_at", &created_at_str);
|
||||
|
||||
let config = bollard::container::CreateContainerOptions {
|
||||
name: test_name.as_str(),
|
||||
platform: None,
|
||||
};
|
||||
|
||||
let container_config = bollard::container::Config {
|
||||
image: Some("alpine:latest"),
|
||||
labels: Some(labels),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let response = match docker
|
||||
.create_container(Some(config), container_config)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
eprintln!("Skipping e2e test: Could not create test container: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let container_id = response.id.clone();
|
||||
tracing::info!(
|
||||
container_id = %&container_id[..12.min(container_id.len())],
|
||||
job_id = %orphaned_job_id,
|
||||
"e2e test: created orphaned test container"
|
||||
);
|
||||
|
||||
// Verify container exists before cleanup
|
||||
let exists_before = docker.inspect_container(&container_id, None).await.is_ok();
|
||||
assert!(exists_before, "Container should exist before cleanup");
|
||||
|
||||
// Simulate reaper cleanup: try to stop and remove it
|
||||
let _ = docker
|
||||
.stop_container(
|
||||
&container_id,
|
||||
Some(bollard::container::StopContainerOptions { t: 10 }),
|
||||
)
|
||||
.await;
|
||||
|
||||
let removal_result = docker
|
||||
.remove_container(
|
||||
&container_id,
|
||||
Some(bollard::container::RemoveContainerOptions {
|
||||
force: true,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
match removal_result {
|
||||
Ok(()) => {
|
||||
tracing::info!(
|
||||
container_id = %&container_id[..12.min(container_id.len())],
|
||||
"e2e test: successfully removed orphaned container"
|
||||
);
|
||||
// Verify it's gone
|
||||
let exists_after = docker.inspect_container(&container_id, None).await.is_ok();
|
||||
assert!(!exists_after, "Container should not exist after removal");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Warning: failed to remove test container: {e}");
|
||||
// Attempt cleanup anyway
|
||||
let _ = docker.remove_container(&container_id, None).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Test that reaper respects age threshold
|
||||
#[tokio::test]
|
||||
async fn e2e_reaper_respects_age_threshold() {
|
||||
if !should_run_e2e() {
|
||||
eprintln!("Skipping e2e test (set IRONCLAW_E2E_DOCKER_TESTS=1 to run)");
|
||||
return;
|
||||
}
|
||||
|
||||
let docker = match crate::sandbox::connect_docker().await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
eprintln!("Skipping e2e test: Docker unavailable: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Create two containers: one old, one new
|
||||
let old_job_id = Uuid::new_v4();
|
||||
let new_job_id = Uuid::new_v4();
|
||||
|
||||
// Old container (created 2 hours ago, beyond typical 10min threshold)
|
||||
let old_id_str = old_job_id.to_string();
|
||||
let old_time_str = (Utc::now() - chrono::Duration::hours(2)).to_rfc3339();
|
||||
let mut old_labels: std::collections::HashMap<&str, &str> =
|
||||
std::collections::HashMap::new();
|
||||
old_labels.insert("ironclaw.job_id", &old_id_str);
|
||||
old_labels.insert("ironclaw.created_at", &old_time_str);
|
||||
|
||||
// New container (created 1 minute ago, within threshold)
|
||||
let new_id_str = new_job_id.to_string();
|
||||
let new_time_str = (Utc::now() - chrono::Duration::minutes(1)).to_rfc3339();
|
||||
let mut new_labels: std::collections::HashMap<&str, &str> =
|
||||
std::collections::HashMap::new();
|
||||
new_labels.insert("ironclaw.job_id", &new_id_str);
|
||||
new_labels.insert("ironclaw.created_at", &new_time_str);
|
||||
|
||||
let mut containers_to_cleanup = Vec::new();
|
||||
|
||||
// Create old container
|
||||
let old_name = format!("ironclaw-age-old-{}", &old_job_id.to_string()[..8]);
|
||||
if let Ok(r) = docker
|
||||
.create_container(
|
||||
Some(bollard::container::CreateContainerOptions {
|
||||
name: old_name.as_str(),
|
||||
platform: None,
|
||||
}),
|
||||
bollard::container::Config {
|
||||
image: Some("alpine:latest"),
|
||||
labels: Some(old_labels),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
containers_to_cleanup.push(r.id.clone());
|
||||
tracing::info!("e2e test: created old orphaned container for age threshold test");
|
||||
}
|
||||
|
||||
// Create new container
|
||||
let new_name = format!("ironclaw-age-new-{}", &new_job_id.to_string()[..8]);
|
||||
if let Ok(r) = docker
|
||||
.create_container(
|
||||
Some(bollard::container::CreateContainerOptions {
|
||||
name: new_name.as_str(),
|
||||
platform: None,
|
||||
}),
|
||||
bollard::container::Config {
|
||||
image: Some("alpine:latest"),
|
||||
labels: Some(new_labels),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
containers_to_cleanup.push(r.id.clone());
|
||||
tracing::info!("e2e test: created new orphaned container for age threshold test");
|
||||
}
|
||||
|
||||
// Verify both exist
|
||||
assert_eq!(
|
||||
containers_to_cleanup.len(),
|
||||
2,
|
||||
"Should have created 2 test containers"
|
||||
);
|
||||
|
||||
// Clean up
|
||||
for container_id in containers_to_cleanup {
|
||||
let _ = docker
|
||||
.stop_container(
|
||||
&container_id,
|
||||
Some(bollard::container::StopContainerOptions { t: 10 }),
|
||||
)
|
||||
.await;
|
||||
let _ = docker
|
||||
.remove_container(
|
||||
&container_id,
|
||||
Some(bollard::container::RemoveContainerOptions {
|
||||
force: true,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
tracing::info!("e2e test: age threshold test completed and cleaned up");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,7 +185,7 @@ impl SandboxManager {
|
||||
self.initialized
|
||||
.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
|
||||
tracing::info!("Sandbox shut down");
|
||||
tracing::debug!("Sandbox shut down");
|
||||
}
|
||||
|
||||
/// Execute a command in the sandbox.
|
||||
|
||||
@@ -154,7 +154,7 @@ impl HttpProxy {
|
||||
}
|
||||
}
|
||||
_ = &mut shutdown_rx => {
|
||||
tracing::info!("Sandbox proxy shutting down");
|
||||
tracing::debug!("Sandbox proxy shutting down");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,11 +153,11 @@ mod tests {
|
||||
use secrecy::SecretString;
|
||||
|
||||
use crate::secrets::crypto::SecretsCrypto;
|
||||
use crate::testing::credentials::TEST_CRYPTO_KEY;
|
||||
|
||||
fn test_crypto() -> SecretsCrypto {
|
||||
// 32-byte test key
|
||||
let key = "0123456789abcdef0123456789abcdef";
|
||||
SecretsCrypto::new(SecretString::from(key.to_string())).unwrap()
|
||||
SecretsCrypto::new(SecretString::from(TEST_CRYPTO_KEY.to_string())).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -75,3 +75,37 @@ pub use types::{
|
||||
};
|
||||
|
||||
pub use store::in_memory::InMemorySecretsStore;
|
||||
|
||||
/// Create a secrets store from a master key and database handles.
|
||||
///
|
||||
/// Returns `None` if no matching backend handle is available (e.g. when
|
||||
/// running without a database). This is a normal condition in no-db mode,
|
||||
/// not an error — callers should treat `None` as "secrets unavailable".
|
||||
pub fn create_secrets_store(
|
||||
crypto: std::sync::Arc<SecretsCrypto>,
|
||||
handles: &crate::db::DatabaseHandles,
|
||||
) -> Option<std::sync::Arc<dyn SecretsStore + Send + Sync>> {
|
||||
let store: Option<std::sync::Arc<dyn SecretsStore + Send + Sync>> = None;
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
let store = store.or_else(|| {
|
||||
handles.libsql_db.as_ref().map(|db| {
|
||||
std::sync::Arc::new(LibSqlSecretsStore::new(
|
||||
std::sync::Arc::clone(db),
|
||||
std::sync::Arc::clone(&crypto),
|
||||
)) as std::sync::Arc<dyn SecretsStore + Send + Sync>
|
||||
})
|
||||
});
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
let store = store.or_else(|| {
|
||||
handles.pg_pool.as_ref().map(|pool| {
|
||||
std::sync::Arc::new(PostgresSecretsStore::new(
|
||||
pool.clone(),
|
||||
std::sync::Arc::clone(&crypto),
|
||||
)) as std::sync::Arc<dyn SecretsStore + Send + Sync>
|
||||
})
|
||||
});
|
||||
|
||||
store
|
||||
}
|
||||
|
||||
+15
-14
@@ -802,30 +802,25 @@ pub mod in_memory {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use secrecy::SecretString;
|
||||
|
||||
use crate::secrets::crypto::SecretsCrypto;
|
||||
use crate::secrets::store::SecretsStore;
|
||||
use crate::secrets::store::in_memory::InMemorySecretsStore;
|
||||
use crate::secrets::types::CreateSecretParams;
|
||||
use crate::testing::credentials::{
|
||||
TEST_OPENAI_API_KEY_SHORT, TEST_SECRET_VALUE, TEST_STRIPE_KEY, test_secrets_store,
|
||||
};
|
||||
|
||||
fn test_store() -> InMemorySecretsStore {
|
||||
let key = "0123456789abcdef0123456789abcdef";
|
||||
let crypto = Arc::new(SecretsCrypto::new(SecretString::from(key.to_string())).unwrap());
|
||||
InMemorySecretsStore::new(crypto)
|
||||
fn test_store() -> crate::secrets::store::in_memory::InMemorySecretsStore {
|
||||
test_secrets_store()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_and_get() {
|
||||
let store = test_store();
|
||||
let params = CreateSecretParams::new("api_key", "sk-test-12345");
|
||||
let params = CreateSecretParams::new("api_key", TEST_SECRET_VALUE);
|
||||
|
||||
store.create("user1", params).await.unwrap();
|
||||
|
||||
let decrypted = store.get_decrypted("user1", "api_key").await.unwrap();
|
||||
assert_eq!(decrypted.expose(), "sk-test-12345");
|
||||
assert_eq!(decrypted.expose(), TEST_SECRET_VALUE);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -878,11 +873,17 @@ mod tests {
|
||||
async fn test_is_accessible() {
|
||||
let store = test_store();
|
||||
store
|
||||
.create("user1", CreateSecretParams::new("openai_key", "sk-test"))
|
||||
.create(
|
||||
"user1",
|
||||
CreateSecretParams::new("openai_key", TEST_OPENAI_API_KEY_SHORT),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.create("user1", CreateSecretParams::new("stripe_key", "sk-live"))
|
||||
.create(
|
||||
"user1",
|
||||
CreateSecretParams::new("stripe_key", TEST_STRIPE_KEY),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
||||
@@ -386,6 +386,10 @@ pub struct AgentSettings {
|
||||
/// Default timezone for new sessions (IANA name, e.g. "America/New_York").
|
||||
#[serde(default = "default_timezone")]
|
||||
pub default_timezone: String,
|
||||
|
||||
/// Maximum tokens per job (0 = unlimited).
|
||||
#[serde(default)]
|
||||
pub max_tokens_per_job: u64,
|
||||
}
|
||||
|
||||
fn default_agent_name() -> String {
|
||||
@@ -442,6 +446,7 @@ impl Default for AgentSettings {
|
||||
max_tool_iterations: default_max_tool_iterations(),
|
||||
auto_approve_tools: false,
|
||||
default_timezone: default_timezone(),
|
||||
max_tokens_per_job: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+40
-3
@@ -10,7 +10,7 @@ file first, then adjust the code to match.
|
||||
## Entry Points
|
||||
|
||||
```
|
||||
ironclaw onboard [--skip-auth] [--channels-only]
|
||||
ironclaw onboard [--skip-auth] [--channels-only] [--provider-only] [--quick]
|
||||
```
|
||||
|
||||
Explicit invocation. Loads `.env` files, runs the wizard, exits.
|
||||
@@ -26,6 +26,8 @@ the wizard). Otherwise triggers when no database is configured:
|
||||
- `LIBSQL_PATH` env var is set
|
||||
- `~/.ironclaw/ironclaw.db` exists on disk
|
||||
|
||||
Auto-triggered onboarding uses **quick mode** by default.
|
||||
|
||||
The `--no-onboard` CLI flag suppresses auto-detection.
|
||||
|
||||
---
|
||||
@@ -50,7 +52,41 @@ The `--no-onboard` CLI flag suppresses auto-detection.
|
||||
|
||||
---
|
||||
|
||||
## The 8-Step Wizard
|
||||
## Quick Mode
|
||||
|
||||
Quick mode (`--quick` flag, or auto-triggered on first run) provides a
|
||||
near-instant onboarding experience by auto-defaulting everything except
|
||||
the LLM provider and model selection.
|
||||
|
||||
```
|
||||
auto_setup_database() → libsql at ~/.ironclaw/ironclaw.db (zero prompts)
|
||||
auto_setup_security() → keychain or env var (zero prompts)
|
||||
Step 1/2: Inference Provider ← only interactive step
|
||||
Step 2/2: Model Selection ← only interactive step
|
||||
↓
|
||||
save_and_summarize() → includes tip to run `ironclaw onboard`
|
||||
```
|
||||
|
||||
**`auto_setup_database()`:** Uses existing env vars if set (`DATABASE_URL`
|
||||
for postgres, `LIBSQL_PATH` for libsql) without prompting. Otherwise
|
||||
defaults to libsql at `~/.ironclaw/ironclaw.db`, creates the database,
|
||||
and runs migrations silently. Falls back to interactive mode only when
|
||||
just the postgres feature is compiled and no `DATABASE_URL` is set.
|
||||
|
||||
**`auto_setup_security()`:** Checks for existing `SECRETS_MASTER_KEY`
|
||||
env var or OS keychain key. If neither exists, generates a new key and
|
||||
stores it in the keychain (macOS) or env var (Linux/other). Zero prompts
|
||||
except unavoidable macOS keychain dialogs.
|
||||
|
||||
**`.env` preservation (fix for #751):** `write_bootstrap_env()` now uses
|
||||
`upsert_bootstrap_vars()` instead of `save_bootstrap_env()`, preserving
|
||||
user-added variables like `HTTP_HOST` across re-onboarding.
|
||||
|
||||
The full 9-step wizard remains available via `ironclaw onboard`.
|
||||
|
||||
---
|
||||
|
||||
## The 9-Step Wizard
|
||||
|
||||
### Overview
|
||||
|
||||
@@ -62,7 +98,8 @@ Step 4: Model Selection
|
||||
Step 5: Embeddings
|
||||
Step 6: Channel Configuration
|
||||
Step 7: Extensions (tools)
|
||||
Step 8: Background Tasks (heartbeat)
|
||||
Step 8: Docker Sandbox
|
||||
Step 9: Background Tasks (heartbeat)
|
||||
↓
|
||||
save_and_summarize()
|
||||
```
|
||||
|
||||
@@ -31,3 +31,35 @@ pub use prompts::{
|
||||
};
|
||||
#[cfg(any(feature = "postgres", feature = "libsql"))]
|
||||
pub use wizard::{SetupConfig, SetupWizard};
|
||||
|
||||
/// Check if onboarding is needed and return the reason.
|
||||
///
|
||||
/// Reads environment variables (`DATABASE_URL`, `LIBSQL_PATH`,
|
||||
/// `ONBOARD_COMPLETED`, `NEARAI_API_KEY`) and checks for the default
|
||||
/// session file on disk. Not safe to call concurrently with `env::set_var`.
|
||||
#[cfg(any(feature = "postgres", feature = "libsql"))]
|
||||
pub fn check_onboard_needed() -> Option<&'static str> {
|
||||
let has_db = std::env::var("DATABASE_URL").is_ok()
|
||||
|| std::env::var("LIBSQL_PATH").is_ok()
|
||||
|| crate::config::default_libsql_path().exists();
|
||||
|
||||
if !has_db {
|
||||
return Some("Database not configured");
|
||||
}
|
||||
|
||||
if std::env::var("ONBOARD_COMPLETED")
|
||||
.map(|v| v == "true")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
if std::env::var("NEARAI_API_KEY").is_err() {
|
||||
let session_path = crate::config::default_session_path();
|
||||
if !session_path.exists() {
|
||||
return Some("First run");
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
+313
-41
@@ -22,7 +22,7 @@ use crate::bootstrap::ironclaw_base_dir;
|
||||
use crate::channels::wasm::{
|
||||
ChannelCapabilitiesFile, available_channel_names, install_bundled_channel,
|
||||
};
|
||||
use crate::config::llm::OAUTH_PLACEHOLDER;
|
||||
use crate::config::OAUTH_PLACEHOLDER;
|
||||
use crate::llm::{SessionConfig, SessionManager};
|
||||
use crate::secrets::{SecretsCrypto, SecretsStore};
|
||||
use crate::settings::{KeySource, Settings};
|
||||
@@ -76,6 +76,8 @@ pub struct SetupConfig {
|
||||
pub channels_only: bool,
|
||||
/// Only reconfigure LLM provider and model selection.
|
||||
pub provider_only: bool,
|
||||
/// Quick setup: auto-defaults everything except LLM provider and model.
|
||||
pub quick: bool,
|
||||
}
|
||||
|
||||
/// Interactive setup wizard for IronClaw.
|
||||
@@ -154,6 +156,26 @@ impl SetupWizard {
|
||||
print_step(1, 2, "Inference Provider");
|
||||
self.step_inference_provider().await?;
|
||||
self.persist_after_step().await;
|
||||
print_step(2, 2, "Model Selection");
|
||||
self.step_model_selection().await?;
|
||||
self.persist_after_step().await;
|
||||
} else if self.config.quick {
|
||||
// Quick mode: auto-default database + security, only ask for
|
||||
// LLM provider + model. Designed for first-run experience.
|
||||
self.auto_setup_database().await?;
|
||||
|
||||
// Load existing settings from DB (if any prior partial run)
|
||||
let step1_settings = self.settings.clone();
|
||||
self.try_load_existing_settings().await;
|
||||
self.settings.merge_from(&step1_settings);
|
||||
|
||||
self.auto_setup_security().await?;
|
||||
self.persist_after_step().await;
|
||||
|
||||
print_step(1, 2, "Inference Provider");
|
||||
self.step_inference_provider().await?;
|
||||
self.persist_after_step().await;
|
||||
|
||||
print_step(2, 2, "Model Selection");
|
||||
self.step_model_selection().await?;
|
||||
self.persist_after_step().await;
|
||||
@@ -659,7 +681,10 @@ impl SetupWizard {
|
||||
use refinery::embed_migrations;
|
||||
embed_migrations!("migrations");
|
||||
|
||||
print_info("Running migrations...");
|
||||
if !self.config.quick {
|
||||
print_info("Running migrations...");
|
||||
}
|
||||
tracing::debug!("Running PostgreSQL migrations...");
|
||||
|
||||
let mut client = pool
|
||||
.get()
|
||||
@@ -671,7 +696,10 @@ impl SetupWizard {
|
||||
.await
|
||||
.map_err(|e| SetupError::Database(format!("Migration failed: {}", e)))?;
|
||||
|
||||
print_success("Migrations applied");
|
||||
if !self.config.quick {
|
||||
print_success("Migrations applied");
|
||||
}
|
||||
tracing::debug!("PostgreSQL migrations applied");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -682,14 +710,20 @@ impl SetupWizard {
|
||||
if let Some(ref backend) = self.db_backend {
|
||||
use crate::db::Database;
|
||||
|
||||
print_info("Running migrations...");
|
||||
if !self.config.quick {
|
||||
print_info("Running migrations...");
|
||||
}
|
||||
tracing::debug!("Running libSQL migrations...");
|
||||
|
||||
backend
|
||||
.run_migrations()
|
||||
.await
|
||||
.map_err(|e| SetupError::Database(format!("Migration failed: {}", e)))?;
|
||||
|
||||
print_success("Migrations applied");
|
||||
if !self.config.quick {
|
||||
print_success("Migrations applied");
|
||||
}
|
||||
tracing::debug!("libSQL migrations applied");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -804,6 +838,140 @@ impl SetupWizard {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Auto-setup database with zero prompts (quick mode).
|
||||
///
|
||||
/// Uses existing env vars if present, otherwise defaults to libsql at the
|
||||
/// standard path. Falls back to the interactive `step_database()` only when
|
||||
/// just the postgres feature is compiled (can't auto-default postgres).
|
||||
async fn auto_setup_database(&mut self) -> Result<(), SetupError> {
|
||||
// If DATABASE_URL or LIBSQL_PATH already set, respect existing config
|
||||
#[cfg(feature = "postgres")]
|
||||
let env_backend = std::env::var("DATABASE_BACKEND").ok();
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
if let Some(ref backend) = env_backend
|
||||
&& (backend == "postgres" || backend == "postgresql")
|
||||
{
|
||||
if let Ok(url) = std::env::var("DATABASE_URL") {
|
||||
print_info("Using existing PostgreSQL configuration");
|
||||
self.settings.database_backend = Some("postgres".to_string());
|
||||
self.settings.database_url = Some(url);
|
||||
return Ok(());
|
||||
}
|
||||
// Postgres configured but no URL — fall through to interactive
|
||||
return self.step_database().await;
|
||||
}
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
if let Ok(url) = std::env::var("DATABASE_URL") {
|
||||
print_info("Using existing PostgreSQL configuration");
|
||||
self.settings.database_backend = Some("postgres".to_string());
|
||||
self.settings.database_url = Some(url);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Auto-default to libsql if the feature is compiled
|
||||
#[cfg(feature = "libsql")]
|
||||
{
|
||||
self.settings.database_backend = Some("libsql".to_string());
|
||||
|
||||
let existing_path = std::env::var("LIBSQL_PATH")
|
||||
.ok()
|
||||
.or_else(|| self.settings.libsql_path.clone());
|
||||
|
||||
let db_path = existing_path.unwrap_or_else(|| {
|
||||
crate::config::default_libsql_path()
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
});
|
||||
|
||||
let turso_url = std::env::var("LIBSQL_URL").ok();
|
||||
let turso_token = std::env::var("LIBSQL_AUTH_TOKEN").ok();
|
||||
|
||||
self.test_database_connection_libsql(
|
||||
&db_path,
|
||||
turso_url.as_deref(),
|
||||
turso_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.run_migrations_libsql().await?;
|
||||
|
||||
self.settings.libsql_path = Some(db_path.clone());
|
||||
if let Some(url) = turso_url {
|
||||
self.settings.libsql_url = Some(url);
|
||||
}
|
||||
|
||||
print_success(&format!("Using embedded database at {}", db_path));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Only postgres feature compiled — can't auto-default, use interactive
|
||||
#[allow(unreachable_code)]
|
||||
{
|
||||
self.step_database().await
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-setup security with zero prompts (quick mode).
|
||||
///
|
||||
/// Silently configures the master key: uses existing env var or keychain
|
||||
/// key if available, otherwise generates and stores one automatically
|
||||
/// (keychain on macOS, env var fallback).
|
||||
async fn auto_setup_security(&mut self) -> Result<(), SetupError> {
|
||||
// Check env var first
|
||||
if std::env::var("SECRETS_MASTER_KEY").is_ok() {
|
||||
self.settings.secrets_master_key_source = KeySource::Env;
|
||||
print_success("Security configured (env var)");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Try existing keychain key (no prompts — get_master_key may show
|
||||
// OS dialogs on macOS, but that's unavoidable for keychain access)
|
||||
if let Ok(keychain_key_bytes) = crate::secrets::keychain::get_master_key().await {
|
||||
let key_hex: String = keychain_key_bytes
|
||||
.iter()
|
||||
.map(|b| format!("{:02x}", b))
|
||||
.collect();
|
||||
self.secrets_crypto = Some(Arc::new(
|
||||
SecretsCrypto::new(SecretString::from(key_hex))
|
||||
.map_err(|e| SetupError::Config(e.to_string()))?,
|
||||
));
|
||||
self.settings.secrets_master_key_source = KeySource::Keychain;
|
||||
print_success("Security configured (keychain)");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// No existing key — generate one
|
||||
// Try keychain first (preferred on macOS)
|
||||
let key = crate::secrets::keychain::generate_master_key();
|
||||
if crate::secrets::keychain::store_master_key(&key)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
let key_hex: String = key.iter().map(|b| format!("{:02x}", b)).collect();
|
||||
self.secrets_crypto = Some(Arc::new(
|
||||
SecretsCrypto::new(SecretString::from(key_hex))
|
||||
.map_err(|e| SetupError::Config(e.to_string()))?,
|
||||
));
|
||||
self.settings.secrets_master_key_source = KeySource::Keychain;
|
||||
print_success("Master key stored in OS keychain");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Keychain unavailable — fall back to env var mode
|
||||
let key_hex = crate::secrets::keychain::generate_master_key_hex();
|
||||
self.secrets_crypto = Some(Arc::new(
|
||||
SecretsCrypto::new(SecretString::from(key_hex.clone()))
|
||||
.map_err(|e| SetupError::Config(e.to_string()))?,
|
||||
));
|
||||
crate::config::inject_single_var("SECRETS_MASTER_KEY", &key_hex);
|
||||
self.settings.secrets_master_key_hex = Some(key_hex);
|
||||
self.settings.secrets_master_key_source = KeySource::Env;
|
||||
print_success("Master key stored in ~/.ironclaw/.env");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Step 3: Inference provider selection.
|
||||
///
|
||||
/// Uses the provider registry to dynamically build the selection menu.
|
||||
@@ -992,7 +1160,10 @@ impl SetupWizard {
|
||||
let session = if let Some(ref s) = self.session_manager {
|
||||
Arc::clone(s)
|
||||
} else {
|
||||
let config = SessionConfig::default();
|
||||
let config = SessionConfig {
|
||||
session_path: crate::config::llm::default_session_path(),
|
||||
..SessionConfig::default()
|
||||
};
|
||||
Arc::new(SessionManager::new(config))
|
||||
};
|
||||
|
||||
@@ -1570,46 +1741,18 @@ impl SetupWizard {
|
||||
}
|
||||
|
||||
/// Fetch available models from the NEAR AI API.
|
||||
///
|
||||
/// Uses [`build_nearai_model_fetch_config`] to construct the provider config,
|
||||
/// which reads `NEARAI_API_KEY` from the environment when present.
|
||||
async fn fetch_nearai_models(&self) -> Vec<String> {
|
||||
let session = match self.session_manager {
|
||||
Some(ref s) => Arc::clone(s),
|
||||
None => return vec![],
|
||||
};
|
||||
|
||||
use crate::config::LlmConfig;
|
||||
use crate::llm::create_llm_provider;
|
||||
|
||||
let base_url = std::env::var("NEARAI_BASE_URL")
|
||||
.unwrap_or_else(|_| "https://private.near.ai".to_string());
|
||||
let auth_base_url = std::env::var("NEARAI_AUTH_URL")
|
||||
.unwrap_or_else(|_| "https://private.near.ai".to_string());
|
||||
|
||||
let config = LlmConfig {
|
||||
backend: "nearai".to_string(),
|
||||
session: crate::llm::session::SessionConfig {
|
||||
auth_base_url,
|
||||
session_path: crate::llm::session::default_session_path(),
|
||||
},
|
||||
nearai: crate::config::NearAiConfig {
|
||||
model: "dummy".to_string(),
|
||||
cheap_model: None,
|
||||
base_url,
|
||||
api_key: None,
|
||||
fallback_model: None,
|
||||
max_retries: 3,
|
||||
circuit_breaker_threshold: None,
|
||||
circuit_breaker_recovery_secs: 30,
|
||||
response_cache_enabled: false,
|
||||
response_cache_ttl_secs: 3600,
|
||||
response_cache_max_entries: 1000,
|
||||
failover_cooldown_secs: 300,
|
||||
failover_cooldown_threshold: 3,
|
||||
smart_routing_cascade: true,
|
||||
},
|
||||
provider: None,
|
||||
bedrock: None,
|
||||
request_timeout_secs: 120,
|
||||
};
|
||||
let config = build_nearai_model_fetch_config();
|
||||
|
||||
match create_llm_provider(&config, session).await {
|
||||
Ok(provider) => match provider.list_models().await {
|
||||
@@ -2531,7 +2674,7 @@ impl SetupWizard {
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), v.as_str()))
|
||||
.collect();
|
||||
crate::bootstrap::save_bootstrap_env(&pairs).map_err(|e| {
|
||||
crate::bootstrap::upsert_bootstrap_vars(&pairs).map_err(|e| {
|
||||
SetupError::Io(std::io::Error::other(format!(
|
||||
"Failed to save bootstrap env to .env: {}",
|
||||
e
|
||||
@@ -2552,7 +2695,7 @@ impl SetupWizard {
|
||||
/// Best-effort: silently ignores errors (no DB connection yet, no
|
||||
/// session file, etc.).
|
||||
async fn persist_session_to_db(&self) {
|
||||
let session_path = crate::llm::session::default_session_path();
|
||||
let session_path = crate::config::llm::default_session_path();
|
||||
let data = match std::fs::read_to_string(&session_path) {
|
||||
Ok(d) if !d.trim().is_empty() => d,
|
||||
_ => return,
|
||||
@@ -2803,6 +2946,13 @@ impl SetupWizard {
|
||||
println!(" ironclaw onboard");
|
||||
println!();
|
||||
|
||||
if self.config.quick {
|
||||
print_info(
|
||||
"Tip: Run `ironclaw onboard` to configure channels, extensions, embeddings, and more.",
|
||||
);
|
||||
println!();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -2861,7 +3011,7 @@ async fn fetch_anthropic_models(cached_key: Option<&str>) -> Vec<(String, String
|
||||
let api_key = cached_key
|
||||
.map(String::from)
|
||||
.or_else(|| std::env::var("ANTHROPIC_API_KEY").ok())
|
||||
.filter(|k| !k.is_empty() && k != crate::config::llm::OAUTH_PLACEHOLDER);
|
||||
.filter(|k| !k.is_empty() && k != crate::config::OAUTH_PLACEHOLDER);
|
||||
|
||||
// Fall back to OAuth token if no API key
|
||||
let oauth_token = if api_key.is_none() {
|
||||
@@ -3237,6 +3387,58 @@ async fn discover_wasm_channels(dir: &std::path::Path) -> Vec<(String, ChannelCa
|
||||
/// Mask an API key for display: show first 6 + last 4 chars.
|
||||
///
|
||||
/// Uses char-based indexing to avoid panicking on multi-byte UTF-8.
|
||||
/// Build the `LlmConfig` used by `fetch_nearai_models` to list available models.
|
||||
///
|
||||
/// Reads `NEARAI_API_KEY` from the environment so that users who authenticated
|
||||
/// via Cloud API key (option 4) don't get re-prompted during model selection.
|
||||
fn build_nearai_model_fetch_config() -> crate::config::LlmConfig {
|
||||
// If the user authenticated via API key (option 4), the key is stored
|
||||
// as an env var. Pass it through so `resolve_bearer_token()` doesn't
|
||||
// re-trigger the interactive auth prompt.
|
||||
let api_key = std::env::var("NEARAI_API_KEY")
|
||||
.ok()
|
||||
.filter(|k| !k.is_empty())
|
||||
.map(secrecy::SecretString::from);
|
||||
|
||||
// Match the same base_url logic as LlmConfig::resolve(): use cloud-api
|
||||
// when an API key is present, private.near.ai for session-token auth.
|
||||
let default_base = if api_key.is_some() {
|
||||
"https://cloud-api.near.ai"
|
||||
} else {
|
||||
"https://private.near.ai"
|
||||
};
|
||||
let base_url = std::env::var("NEARAI_BASE_URL").unwrap_or_else(|_| default_base.to_string());
|
||||
let auth_base_url =
|
||||
std::env::var("NEARAI_AUTH_URL").unwrap_or_else(|_| "https://private.near.ai".to_string());
|
||||
|
||||
crate::config::LlmConfig {
|
||||
backend: "nearai".to_string(),
|
||||
session: crate::llm::session::SessionConfig {
|
||||
auth_base_url,
|
||||
session_path: crate::config::llm::default_session_path(),
|
||||
},
|
||||
nearai: crate::config::NearAiConfig {
|
||||
model: "dummy".to_string(),
|
||||
cheap_model: None,
|
||||
base_url,
|
||||
api_key,
|
||||
fallback_model: None,
|
||||
max_retries: 3,
|
||||
circuit_breaker_threshold: None,
|
||||
circuit_breaker_recovery_secs: 30,
|
||||
response_cache_enabled: false,
|
||||
response_cache_ttl_secs: 3600,
|
||||
response_cache_max_entries: 1000,
|
||||
failover_cooldown_secs: 300,
|
||||
failover_cooldown_threshold: 3,
|
||||
smart_routing_cascade: true,
|
||||
},
|
||||
provider: None,
|
||||
bedrock: None,
|
||||
request_timeout_secs: 120,
|
||||
}
|
||||
}
|
||||
|
||||
fn mask_api_key(key: &str) -> String {
|
||||
let chars: Vec<char> = key.chars().collect();
|
||||
if chars.len() < 12 {
|
||||
@@ -3445,6 +3647,7 @@ mod tests {
|
||||
use tempfile::tempdir;
|
||||
|
||||
use super::*;
|
||||
use crate::config::helpers::ENV_MUTEX;
|
||||
|
||||
#[test]
|
||||
fn test_wizard_creation() {
|
||||
@@ -3459,6 +3662,7 @@ mod tests {
|
||||
skip_auth: true,
|
||||
channels_only: false,
|
||||
provider_only: false,
|
||||
quick: false,
|
||||
};
|
||||
let wizard = SetupWizard::with_config(config);
|
||||
assert!(wizard.config.skip_auth);
|
||||
@@ -3637,6 +3841,14 @@ mod tests {
|
||||
}
|
||||
|
||||
impl EnvGuard {
|
||||
fn set(key: &'static str, value: &str) -> Self {
|
||||
let original = std::env::var(key).ok();
|
||||
unsafe {
|
||||
std::env::set_var(key, value);
|
||||
}
|
||||
Self { key, original }
|
||||
}
|
||||
|
||||
fn clear(key: &'static str) -> Self {
|
||||
let original = std::env::var(key).ok();
|
||||
unsafe {
|
||||
@@ -3823,4 +4035,64 @@ mod tests {
|
||||
};
|
||||
assert!(settings.secrets_master_key_hex.is_some());
|
||||
}
|
||||
|
||||
/// Regression test for #799: `fetch_nearai_models` hardcoded `api_key: None`,
|
||||
/// causing the auth prompt to re-appear during model selection when the user
|
||||
/// had authenticated via NEAR AI Cloud API key (option 4).
|
||||
#[test]
|
||||
fn test_build_nearai_model_fetch_config_picks_up_api_key_env() {
|
||||
use secrecy::ExposeSecret;
|
||||
|
||||
let _lock = ENV_MUTEX.lock().unwrap();
|
||||
let _guard = EnvGuard::set("NEARAI_API_KEY", "test-cloud-api-key-12345");
|
||||
let _guard2 = EnvGuard::clear("NEARAI_BASE_URL");
|
||||
|
||||
let config = build_nearai_model_fetch_config();
|
||||
assert!(
|
||||
config.nearai.api_key.is_some(),
|
||||
"config should include NEARAI_API_KEY from env"
|
||||
);
|
||||
assert_eq!(
|
||||
config.nearai.api_key.as_ref().unwrap().expose_secret(),
|
||||
"test-cloud-api-key-12345"
|
||||
);
|
||||
// With API key, base_url must point to cloud-api (not private.near.ai)
|
||||
assert_eq!(
|
||||
config.nearai.base_url, "https://cloud-api.near.ai",
|
||||
"API key auth must use cloud-api base URL for model fetching"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression test for #799: when NEARAI_API_KEY is absent or empty,
|
||||
/// the config should have `api_key: None` (session token path).
|
||||
#[test]
|
||||
fn test_build_nearai_model_fetch_config_none_when_no_api_key() {
|
||||
let _lock = ENV_MUTEX.lock().unwrap();
|
||||
let _guard = EnvGuard::clear("NEARAI_API_KEY");
|
||||
let _guard2 = EnvGuard::clear("NEARAI_BASE_URL");
|
||||
|
||||
let config = build_nearai_model_fetch_config();
|
||||
assert!(
|
||||
config.nearai.api_key.is_none(),
|
||||
"config should have no api_key when env var is absent"
|
||||
);
|
||||
// Without API key, base_url must point to private.near.ai (session token)
|
||||
assert_eq!(
|
||||
config.nearai.base_url, "https://private.near.ai",
|
||||
"session-token auth must use private.near.ai base URL"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression test for #799: empty NEARAI_API_KEY should be treated as absent.
|
||||
#[test]
|
||||
fn test_build_nearai_model_fetch_config_none_when_empty_api_key() {
|
||||
let _lock = ENV_MUTEX.lock().unwrap();
|
||||
let _guard = EnvGuard::set("NEARAI_API_KEY", "");
|
||||
|
||||
let config = build_nearai_model_fetch_config();
|
||||
assert!(
|
||||
config.nearai.api_key.is_none(),
|
||||
"config should have no api_key when env var is empty"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Centralized fake credential constants for tests.
|
||||
//!
|
||||
//! All values here are intentionally fake. Centralizing them makes security
|
||||
//! audits trivial (one file to verify) and eliminates duplication across
|
||||
//! the test suite.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use secrecy::SecretString;
|
||||
|
||||
use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
|
||||
|
||||
// ── Encryption keys ──────────────────────────────────────────────────────
|
||||
|
||||
/// 32-byte hex key for `SecretsCrypto::new()` in tests.
|
||||
pub const TEST_CRYPTO_KEY: &str = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
/// 32+ char key for web gateway `SecretsCrypto` in tests.
|
||||
pub const TEST_GATEWAY_CRYPTO_KEY: &str = "test-key-at-least-32-chars-long!!";
|
||||
|
||||
// ── OpenAI-style API keys ────────────────────────────────────────────────
|
||||
|
||||
/// Generic OpenAI-style test API key.
|
||||
pub const TEST_OPENAI_API_KEY: &str = "sk-test123";
|
||||
|
||||
/// OpenAI API key with longer format (config round-trip tests).
|
||||
pub const TEST_OPENAI_API_KEY_LONG: &str = "sk-test-key-1234567890";
|
||||
|
||||
/// Short OpenAI-style key for secrets store accessibility tests.
|
||||
pub const TEST_OPENAI_API_KEY_SHORT: &str = "sk-test";
|
||||
|
||||
/// OpenAI API key used in embeddings config issue-129 test.
|
||||
pub const TEST_OPENAI_API_KEY_ISSUE_129: &str = "sk-test-key-for-issue-129";
|
||||
|
||||
// ── Anthropic keys ───────────────────────────────────────────────────────
|
||||
|
||||
/// Anthropic OAuth token for config tests.
|
||||
pub const TEST_ANTHROPIC_OAUTH_TOKEN: &str = "sk-ant-oat01-test-token";
|
||||
|
||||
/// Anthropic API key for priority tests.
|
||||
pub const TEST_ANTHROPIC_API_KEY: &str = "sk-ant-real-key";
|
||||
|
||||
/// Anthropic OAuth token for sandbox config parse tests.
|
||||
pub const TEST_ANTHROPIC_OAUTH_FAKE: &str = "sk-ant-oat01-fake";
|
||||
|
||||
/// Anthropic OAuth token in nested JSON parse test.
|
||||
pub const TEST_ANTHROPIC_OAUTH_REAL: &str = "sk-ant-oat01-real-token";
|
||||
|
||||
// ── Google OAuth ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Google OAuth access token (standard test).
|
||||
pub const TEST_GOOGLE_OAUTH_TOKEN: &str = "ya29.test-token";
|
||||
|
||||
/// Google OAuth access token (fresh/non-expired variant).
|
||||
pub const TEST_GOOGLE_OAUTH_FRESH: &str = "ya29.fresh-token";
|
||||
|
||||
/// Google OAuth access token (legacy/no-expiry variant).
|
||||
pub const TEST_GOOGLE_OAUTH_LEGACY: &str = "ya29.legacy-token";
|
||||
|
||||
// ── GitHub ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// GitHub personal access token (test).
|
||||
pub const TEST_GITHUB_TOKEN: &str = "ghp_test123";
|
||||
|
||||
// ── Telegram ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Telegram bot token for credential redaction tests.
|
||||
pub const TEST_TELEGRAM_BOT_TOKEN: &str = "0000000000:AAFakeTestTokenForTestingPurposesOnly";
|
||||
|
||||
// ── OAuth client credentials ────────────────────────────────────────────
|
||||
|
||||
/// OAuth client ID for token refresh tests.
|
||||
pub const TEST_OAUTH_CLIENT_ID: &str = "test-client-id";
|
||||
|
||||
/// OAuth client secret for token refresh tests.
|
||||
pub const TEST_OAUTH_CLIENT_SECRET: &str = "test-client-secret";
|
||||
|
||||
// ── Bearer/auth tokens ──────────────────────────────────────────────────
|
||||
|
||||
/// Generic test bearer token.
|
||||
pub const TEST_BEARER_TOKEN: &str = "test-token";
|
||||
|
||||
/// Bearer token with suffix (wasm wrapper credential injection).
|
||||
pub const TEST_BEARER_TOKEN_123: &str = "test-token-123";
|
||||
|
||||
/// Auth token used by web gateway middleware tests.
|
||||
pub const TEST_AUTH_SECRET_TOKEN: &str = "secret-token";
|
||||
|
||||
// ── Stripe ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Stripe-style test key.
|
||||
pub const TEST_STRIPE_KEY: &str = "sk-live";
|
||||
|
||||
// ── Redaction test values ───────────────────────────────────────────────
|
||||
|
||||
/// Secret-prefixed key for redaction/sanitization tests.
|
||||
pub const TEST_REDACT_SECRET: &str = "sk-secret";
|
||||
|
||||
/// Secret-prefixed key with suffix for redaction tests.
|
||||
pub const TEST_REDACT_SECRET_123: &str = "sk-secret-123";
|
||||
|
||||
// ── Session tokens ──────────────────────────────────────────────────────
|
||||
|
||||
/// Generic session token for persistence tests.
|
||||
pub const TEST_SESSION_TOKEN: &str = "test_token_123";
|
||||
|
||||
/// NEAR AI session token variant A.
|
||||
pub const TEST_SESSION_NEARAI_ABC: &str = "sess_abc123";
|
||||
|
||||
/// NEAR AI session token variant B.
|
||||
pub const TEST_SESSION_NEARAI_XYZ: &str = "sess_xyz789";
|
||||
|
||||
// ── Generic ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Generic test API key for LLM config, embedding config, nearai tests.
|
||||
pub const TEST_API_KEY: &str = "test-key";
|
||||
|
||||
/// Stored secret value for create-and-get tests.
|
||||
pub const TEST_SECRET_VALUE: &str = "sk-test-12345";
|
||||
|
||||
/// HTTP webhook secret for channel tests.
|
||||
pub const TEST_HTTP_SECRET: &str = "test-secret-123";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Create an `InMemorySecretsStore` backed by [`TEST_CRYPTO_KEY`].
|
||||
///
|
||||
/// Replaces the duplicated `test_store()` pattern found across multiple
|
||||
/// test modules.
|
||||
pub fn test_secrets_store() -> InMemorySecretsStore {
|
||||
let crypto =
|
||||
Arc::new(SecretsCrypto::new(SecretString::from(TEST_CRYPTO_KEY.to_string())).unwrap());
|
||||
InMemorySecretsStore::new(crypto)
|
||||
}
|
||||
@@ -18,6 +18,8 @@
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
pub mod credentials;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
@@ -451,8 +451,8 @@ impl Tool for ToolRemoveTool {
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Remove an installed extension (channel, tool, or MCP server). \
|
||||
Unregisters tools and deletes configuration."
|
||||
"Permanently remove an installed extension (channel, tool, or MCP server) from disk. \
|
||||
This action cannot be undone — the WASM binary and configuration files will be deleted."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
@@ -492,7 +492,7 @@ impl Tool for ToolRemoveTool {
|
||||
}
|
||||
|
||||
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
|
||||
ApprovalRequirement::UnlessAutoApproved
|
||||
ApprovalRequirement::Always
|
||||
}
|
||||
}
|
||||
|
||||
@@ -701,10 +701,38 @@ mod tests {
|
||||
assert_eq!(tool.name(), "tool_remove");
|
||||
assert_eq!(
|
||||
tool.requires_approval(&serde_json::json!({})),
|
||||
ApprovalRequirement::UnlessAutoApproved
|
||||
ApprovalRequirement::Always
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_remove_always_requires_approval_regardless_of_params() {
|
||||
use crate::tools::tool::ApprovalRequirement;
|
||||
let tool = ToolRemoveTool {
|
||||
manager: test_manager_stub(),
|
||||
};
|
||||
|
||||
let test_cases = vec![
|
||||
("no params", serde_json::json!({})),
|
||||
("empty name", serde_json::json!({"name": ""})),
|
||||
("slack", serde_json::json!({"name": "slack"})),
|
||||
("github-cli", serde_json::json!({"name": "github-cli"})),
|
||||
(
|
||||
"with extra fields",
|
||||
serde_json::json!({"name": "tool", "extra": "field"}),
|
||||
),
|
||||
];
|
||||
|
||||
for (case_name, params) in test_cases {
|
||||
assert_eq!(
|
||||
tool.requires_approval(¶ms),
|
||||
ApprovalRequirement::Always,
|
||||
"tool_remove must always require approval for case: {}",
|
||||
case_name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_upgrade_schema() {
|
||||
use crate::tools::tool::ApprovalRequirement;
|
||||
@@ -740,15 +768,16 @@ mod tests {
|
||||
/// Create a stub manager for schema tests (these don't call execute).
|
||||
fn test_manager_stub() -> Arc<ExtensionManager> {
|
||||
use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
|
||||
use crate::testing::credentials::TEST_CRYPTO_KEY;
|
||||
use crate::tools::ToolRegistry;
|
||||
use crate::tools::mcp::session::McpSessionManager;
|
||||
|
||||
let master_key =
|
||||
secrecy::SecretString::from("0123456789abcdef0123456789abcdef".to_string());
|
||||
let master_key = secrecy::SecretString::from(TEST_CRYPTO_KEY.to_string());
|
||||
let crypto = Arc::new(SecretsCrypto::new(master_key).unwrap());
|
||||
|
||||
Arc::new(ExtensionManager::new(
|
||||
Arc::new(McpSessionManager::new()),
|
||||
Arc::new(crate::tools::mcp::process::McpProcessManager::new()),
|
||||
Arc::new(InMemorySecretsStore::new(crypto)),
|
||||
Arc::new(ToolRegistry::new()),
|
||||
None,
|
||||
|
||||
@@ -609,6 +609,7 @@ impl Tool for HttpTool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testing::credentials::{TEST_CRYPTO_KEY, TEST_OPENAI_API_KEY};
|
||||
|
||||
#[test]
|
||||
fn test_http_tool_schema_headers_is_array() {
|
||||
@@ -870,7 +871,7 @@ mod tests {
|
||||
// secrets_store is not used in requires_approval, just needs to be present
|
||||
Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
|
||||
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
|
||||
"0123456789abcdef0123456789abcdef".to_string(),
|
||||
TEST_CRYPTO_KEY.to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
))),
|
||||
@@ -894,7 +895,7 @@ mod tests {
|
||||
registry,
|
||||
Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
|
||||
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
|
||||
"0123456789abcdef0123456789abcdef".to_string(),
|
||||
TEST_CRYPTO_KEY.to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
))),
|
||||
@@ -926,7 +927,7 @@ mod tests {
|
||||
let params = serde_json::json!({
|
||||
"method": "GET",
|
||||
"url": "https://example.com",
|
||||
"headers": {"X-Custom": "Bearer sk-test123"}
|
||||
"headers": {"X-Custom": format!("Bearer {TEST_OPENAI_API_KEY}")}
|
||||
});
|
||||
assert_eq!(tool.requires_approval(¶ms), ApprovalRequirement::Always);
|
||||
}
|
||||
@@ -961,7 +962,7 @@ mod tests {
|
||||
registry,
|
||||
Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
|
||||
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
|
||||
"0123456789abcdef0123456789abcdef".to_string(),
|
||||
TEST_CRYPTO_KEY.to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
))),
|
||||
|
||||
@@ -1748,14 +1748,10 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_credentials_missing_secret() {
|
||||
use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
|
||||
use secrecy::SecretString;
|
||||
use crate::testing::credentials::test_secrets_store;
|
||||
|
||||
let manager = Arc::new(ContextManager::new(5));
|
||||
let key = "0123456789abcdef0123456789abcdef";
|
||||
let crypto = Arc::new(SecretsCrypto::new(SecretString::from(key.to_string())).unwrap());
|
||||
let secrets: Arc<dyn SecretsStore + Send + Sync> =
|
||||
Arc::new(InMemorySecretsStore::new(crypto));
|
||||
let secrets: Arc<dyn SecretsStore + Send + Sync> = Arc::new(test_secrets_store());
|
||||
|
||||
let tool = CreateJobTool::new(manager).with_secrets(Arc::clone(&secrets));
|
||||
|
||||
@@ -1772,20 +1768,17 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_credentials_valid() {
|
||||
use crate::secrets::{CreateSecretParams, InMemorySecretsStore, SecretsCrypto};
|
||||
use secrecy::SecretString;
|
||||
use crate::secrets::CreateSecretParams;
|
||||
use crate::testing::credentials::{TEST_GITHUB_TOKEN, test_secrets_store};
|
||||
|
||||
let manager = Arc::new(ContextManager::new(5));
|
||||
let key = "0123456789abcdef0123456789abcdef";
|
||||
let crypto = Arc::new(SecretsCrypto::new(SecretString::from(key.to_string())).unwrap());
|
||||
let secrets: Arc<dyn SecretsStore + Send + Sync> =
|
||||
Arc::new(InMemorySecretsStore::new(Arc::clone(&crypto)));
|
||||
let secrets: Arc<dyn SecretsStore + Send + Sync> = Arc::new(test_secrets_store());
|
||||
|
||||
// Store a secret
|
||||
secrets
|
||||
.create(
|
||||
"user1",
|
||||
CreateSecretParams::new("github_token", "ghp_test123"),
|
||||
CreateSecretParams::new("github_token", TEST_GITHUB_TOKEN),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -158,16 +158,13 @@ impl Tool for SecretDeleteTool {
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use secrecy::SecretString;
|
||||
|
||||
use super::*;
|
||||
use crate::context::JobContext;
|
||||
use crate::secrets::{CreateSecretParams, InMemorySecretsStore, SecretsCrypto};
|
||||
use crate::secrets::CreateSecretParams;
|
||||
use crate::testing::credentials::{TEST_OPENAI_API_KEY_SHORT, test_secrets_store};
|
||||
|
||||
fn test_store() -> Arc<InMemorySecretsStore> {
|
||||
let key = "0123456789abcdef0123456789abcdef";
|
||||
let crypto = Arc::new(SecretsCrypto::new(SecretString::from(key.to_string())).unwrap());
|
||||
Arc::new(InMemorySecretsStore::new(crypto))
|
||||
fn test_store() -> Arc<crate::secrets::InMemorySecretsStore> {
|
||||
Arc::new(test_secrets_store())
|
||||
}
|
||||
|
||||
fn test_ctx() -> JobContext {
|
||||
@@ -183,7 +180,7 @@ mod tests {
|
||||
store
|
||||
.create(
|
||||
&ctx.user_id,
|
||||
CreateSecretParams::new("openai_key", "sk-test"),
|
||||
CreateSecretParams::new("openai_key", TEST_OPENAI_API_KEY_SHORT),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -709,7 +709,8 @@ impl Tool for SkillRemoveTool {
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Remove an installed skill by name. Only user-installed skills can be removed."
|
||||
"Permanently remove an installed skill from disk. This action cannot be undone — \
|
||||
the skill files will be deleted."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
@@ -770,7 +771,7 @@ impl Tool for SkillRemoveTool {
|
||||
}
|
||||
|
||||
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
|
||||
ApprovalRequirement::UnlessAutoApproved
|
||||
ApprovalRequirement::Always
|
||||
}
|
||||
}
|
||||
|
||||
@@ -837,12 +838,41 @@ mod tests {
|
||||
assert_eq!(tool.name(), "skill_remove");
|
||||
assert_eq!(
|
||||
tool.requires_approval(&serde_json::json!({})),
|
||||
ApprovalRequirement::UnlessAutoApproved
|
||||
ApprovalRequirement::Always
|
||||
);
|
||||
let schema = tool.parameters_schema();
|
||||
assert!(schema["properties"].get("name").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_remove_always_requires_approval_regardless_of_params() {
|
||||
use crate::tools::tool::ApprovalRequirement;
|
||||
let tool = SkillRemoveTool::new(test_registry());
|
||||
|
||||
let test_cases = vec![
|
||||
("no params", serde_json::json!({})),
|
||||
("empty name", serde_json::json!({"name": ""})),
|
||||
(
|
||||
"deployment skill",
|
||||
serde_json::json!({"name": "deployment"}),
|
||||
),
|
||||
("custom skill", serde_json::json!({"name": "custom-skill"})),
|
||||
(
|
||||
"with extra fields",
|
||||
serde_json::json!({"name": "skill", "extra": "field"}),
|
||||
),
|
||||
];
|
||||
|
||||
for (case_name, params) in test_cases {
|
||||
assert_eq!(
|
||||
tool.requires_approval(¶ms),
|
||||
ApprovalRequirement::Always,
|
||||
"skill_remove must always require approval for case: {}",
|
||||
case_name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_fetch_url_allows_https() {
|
||||
assert!(super::validate_fetch_url("https://clawhub.ai/api/v1/download?slug=foo").is_ok());
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Factory for creating MCP clients from server configuration.
|
||||
//!
|
||||
//! Encapsulates the transport dispatch logic (stdio, Unix socket, HTTP)
|
||||
//! so that callers don't need to match on `EffectiveTransport` themselves.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::secrets::SecretsStore;
|
||||
use crate::tools::mcp::config::{EffectiveTransport, McpServerConfig};
|
||||
use crate::tools::mcp::{McpClient, McpProcessManager, McpSessionManager, McpTransport};
|
||||
|
||||
/// Error returned when MCP client creation fails.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum McpFactoryError {
|
||||
#[error("Failed to spawn stdio MCP server '{name}': {reason}")]
|
||||
StdioSpawn { name: String, reason: String },
|
||||
#[error("Failed to connect to Unix MCP server '{name}': {reason}")]
|
||||
UnixConnect { name: String, reason: String },
|
||||
#[error("Unix socket transport is not supported on this platform (server '{name}')")]
|
||||
UnixNotSupported { name: String },
|
||||
}
|
||||
|
||||
/// Create an `McpClient` from a server configuration, dispatching on the
|
||||
/// effective transport type.
|
||||
pub async fn create_client_from_config(
|
||||
server: McpServerConfig,
|
||||
session_manager: &Arc<McpSessionManager>,
|
||||
process_manager: &Arc<McpProcessManager>,
|
||||
secrets: Option<Arc<dyn SecretsStore + Send + Sync>>,
|
||||
user_id: &str,
|
||||
) -> Result<McpClient, McpFactoryError> {
|
||||
let server_name = server.name.clone();
|
||||
|
||||
match server.effective_transport() {
|
||||
EffectiveTransport::Stdio { command, args, env } => {
|
||||
let transport = process_manager
|
||||
.spawn_stdio(&server_name, command, args.to_vec(), env.clone())
|
||||
.await
|
||||
.map_err(|e| McpFactoryError::StdioSpawn {
|
||||
name: server_name.clone(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
Ok(McpClient::new_with_transport(
|
||||
&server_name,
|
||||
transport as Arc<dyn McpTransport>,
|
||||
None,
|
||||
secrets,
|
||||
user_id,
|
||||
Some(server),
|
||||
))
|
||||
}
|
||||
#[cfg(unix)]
|
||||
EffectiveTransport::Unix { socket_path } => {
|
||||
let transport = crate::tools::mcp::unix_transport::UnixMcpTransport::connect(
|
||||
&server_name,
|
||||
socket_path,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| McpFactoryError::UnixConnect {
|
||||
name: server_name.clone(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
Ok(McpClient::new_with_transport(
|
||||
&server_name,
|
||||
Arc::new(transport) as Arc<dyn McpTransport>,
|
||||
None,
|
||||
secrets,
|
||||
user_id,
|
||||
Some(server),
|
||||
))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
EffectiveTransport::Unix { .. } => {
|
||||
Err(McpFactoryError::UnixNotSupported { name: server_name })
|
||||
}
|
||||
EffectiveTransport::Http => {
|
||||
if let Some(ref secrets) = secrets {
|
||||
let has_tokens =
|
||||
crate::tools::mcp::is_authenticated(&server, secrets, user_id).await;
|
||||
|
||||
if has_tokens || server.requires_auth() {
|
||||
Ok(McpClient::new_authenticated(
|
||||
server,
|
||||
Arc::clone(session_manager),
|
||||
Arc::clone(secrets),
|
||||
user_id,
|
||||
))
|
||||
} else {
|
||||
Ok(McpClient::new_with_config(server))
|
||||
}
|
||||
} else {
|
||||
Ok(McpClient::new_with_config(server))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@
|
||||
pub mod auth;
|
||||
mod client;
|
||||
pub mod config;
|
||||
pub mod factory;
|
||||
pub(crate) mod http_transport;
|
||||
pub(crate) mod process;
|
||||
mod protocol;
|
||||
@@ -43,6 +44,7 @@ pub(crate) mod unix_transport;
|
||||
pub use auth::{is_authenticated, refresh_access_token};
|
||||
pub use client::McpClient;
|
||||
pub use config::{McpServerConfig, McpServersFile, OAuthConfig};
|
||||
pub use factory::{McpFactoryError, create_client_from_config};
|
||||
pub use process::McpProcessManager;
|
||||
pub use protocol::{InitializeResult, McpRequest, McpResponse, McpTool};
|
||||
pub use session::McpSessionManager;
|
||||
|
||||
+14
-14
@@ -241,7 +241,7 @@ impl ToolRegistry {
|
||||
}
|
||||
self.register_sync(Arc::new(http));
|
||||
|
||||
tracing::info!("Registered {} built-in tools", self.count());
|
||||
tracing::debug!("Registered {} built-in tools", self.count());
|
||||
}
|
||||
|
||||
/// Register only orchestrator-domain tools (safe for the main process).
|
||||
@@ -289,7 +289,7 @@ impl ToolRegistry {
|
||||
self.register_sync(Arc::new(ListDirTool::new()));
|
||||
self.register_sync(Arc::new(ApplyPatchTool::new()));
|
||||
|
||||
tracing::info!("Registered 5 development tools");
|
||||
tracing::debug!("Registered 5 development tools");
|
||||
}
|
||||
|
||||
/// Register memory tools with a workspace.
|
||||
@@ -302,7 +302,7 @@ impl ToolRegistry {
|
||||
self.register_sync(Arc::new(MemoryReadTool::new(Arc::clone(&workspace))));
|
||||
self.register_sync(Arc::new(MemoryTreeTool::new(workspace)));
|
||||
|
||||
tracing::info!("Registered 4 memory tools");
|
||||
tracing::debug!("Registered 4 memory tools");
|
||||
}
|
||||
|
||||
/// Register job management tools.
|
||||
@@ -364,7 +364,7 @@ impl ToolRegistry {
|
||||
job_tool_count += 1;
|
||||
}
|
||||
|
||||
tracing::info!("Registered {} job management tools", job_tool_count);
|
||||
tracing::debug!("Registered {} job management tools", job_tool_count);
|
||||
}
|
||||
|
||||
/// Register secret management tools (list, delete).
|
||||
@@ -378,7 +378,7 @@ impl ToolRegistry {
|
||||
use crate::tools::builtin::{SecretDeleteTool, SecretListTool};
|
||||
self.register_sync(Arc::new(SecretListTool::new(Arc::clone(&store))));
|
||||
self.register_sync(Arc::new(SecretDeleteTool::new(store)));
|
||||
tracing::info!("Registered 2 secret management tools (list, delete)");
|
||||
tracing::debug!("Registered 2 secret management tools (list, delete)");
|
||||
}
|
||||
|
||||
/// Register extension management tools (search, install, auth, activate, list, remove).
|
||||
@@ -393,7 +393,7 @@ impl ToolRegistry {
|
||||
self.register_sync(Arc::new(ToolRemoveTool::new(Arc::clone(&manager))));
|
||||
self.register_sync(Arc::new(ToolUpgradeTool::new(Arc::clone(&manager))));
|
||||
self.register_sync(Arc::new(ExtensionInfoTool::new(manager)));
|
||||
tracing::info!("Registered 8 extension management tools");
|
||||
tracing::debug!("Registered 8 extension management tools");
|
||||
}
|
||||
|
||||
/// Register skill management tools (list, search, install, remove).
|
||||
@@ -414,7 +414,7 @@ impl ToolRegistry {
|
||||
Arc::clone(&catalog),
|
||||
)));
|
||||
self.register_sync(Arc::new(SkillRemoveTool::new(registry)));
|
||||
tracing::info!("Registered 4 skill management tools");
|
||||
tracing::debug!("Registered 4 skill management tools");
|
||||
}
|
||||
|
||||
/// Register routine management tools.
|
||||
@@ -448,7 +448,7 @@ impl ToolRegistry {
|
||||
Arc::clone(&engine),
|
||||
)));
|
||||
self.register_sync(Arc::new(RoutineHistoryTool::new(store)));
|
||||
tracing::info!("Registered 6 routine management tools");
|
||||
tracing::debug!("Registered 6 routine management tools");
|
||||
}
|
||||
|
||||
/// Register message tool for sending messages to channels.
|
||||
@@ -467,7 +467,7 @@ impl ToolRegistry {
|
||||
.write()
|
||||
.await
|
||||
.insert("message".to_string());
|
||||
tracing::info!("Registered message tool");
|
||||
tracing::debug!("Registered message tool");
|
||||
}
|
||||
|
||||
/// Set the default channel and target for the message tool.
|
||||
@@ -501,7 +501,7 @@ impl ToolRegistry {
|
||||
gen_model,
|
||||
base_dir,
|
||||
)));
|
||||
tracing::info!("Registered 2 image tools (generate, edit)");
|
||||
tracing::debug!("Registered 2 image tools (generate, edit)");
|
||||
}
|
||||
|
||||
/// Register vision/image analysis tools.
|
||||
@@ -521,7 +521,7 @@ impl ToolRegistry {
|
||||
vision_model,
|
||||
base_dir,
|
||||
)));
|
||||
tracing::info!("Registered 1 vision tool (analyze)");
|
||||
tracing::debug!("Registered 1 vision tool (analyze)");
|
||||
}
|
||||
|
||||
/// Register the software builder tool.
|
||||
@@ -549,7 +549,7 @@ impl ToolRegistry {
|
||||
self.register(Arc::new(BuildSoftwareTool::new(builder)))
|
||||
.await;
|
||||
|
||||
tracing::info!("Registered software builder tool");
|
||||
tracing::debug!("Registered software builder tool");
|
||||
}
|
||||
|
||||
/// Register a WASM tool from bytes.
|
||||
@@ -619,7 +619,7 @@ impl ToolRegistry {
|
||||
);
|
||||
}
|
||||
|
||||
tracing::info!(name = reg.name, "Registered WASM tool");
|
||||
tracing::debug!(name = reg.name, "Registered WASM tool");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -676,7 +676,7 @@ impl ToolRegistry {
|
||||
.await
|
||||
.map_err(WasmRegistrationError::Wasm)?;
|
||||
|
||||
tracing::info!(
|
||||
tracing::debug!(
|
||||
name = tool_with_binary.tool.name,
|
||||
user_id = user_id,
|
||||
trust_level = %tool_with_binary.tool.trust_level,
|
||||
|
||||
+3
-2
@@ -480,6 +480,7 @@ pub fn validate_tool_schema(schema: &serde_json::Value, path: &str) -> Vec<Strin
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testing::credentials::TEST_REDACT_SECRET;
|
||||
|
||||
/// A simple no-op tool for testing.
|
||||
#[derive(Debug)]
|
||||
@@ -602,12 +603,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_redact_params_replaces_sensitive_key() {
|
||||
let params = serde_json::json!({"name": "openai_key", "value": "sk-secret"});
|
||||
let params = serde_json::json!({"name": "openai_key", "value": TEST_REDACT_SECRET});
|
||||
let redacted = redact_params(¶ms, &["value"]);
|
||||
assert_eq!(redacted["name"], "openai_key");
|
||||
assert_eq!(redacted["value"], "[REDACTED]");
|
||||
// Original unchanged
|
||||
assert_eq!(params["value"], "sk-secret");
|
||||
assert_eq!(params["value"], TEST_REDACT_SECRET);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -365,22 +365,18 @@ fn base64_encode(input: &[u8]) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use secrecy::SecretString;
|
||||
|
||||
use crate::secrets::{
|
||||
CreateSecretParams, CredentialLocation, CredentialMapping, InMemorySecretsStore,
|
||||
SecretsCrypto, SecretsStore,
|
||||
SecretsStore,
|
||||
};
|
||||
use crate::testing::credentials::{TEST_OPENAI_API_KEY, test_secrets_store};
|
||||
use crate::tools::wasm::credential_injector::{
|
||||
CredentialInjector, base64_encode, host_matches_pattern,
|
||||
};
|
||||
|
||||
fn test_store() -> InMemorySecretsStore {
|
||||
let key = "0123456789abcdef0123456789abcdef";
|
||||
let crypto = Arc::new(SecretsCrypto::new(SecretString::from(key.to_string())).unwrap());
|
||||
InMemorySecretsStore::new(crypto)
|
||||
test_secrets_store()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -406,7 +402,10 @@ mod tests {
|
||||
async fn test_inject_bearer() {
|
||||
let store = test_store();
|
||||
store
|
||||
.create("user1", CreateSecretParams::new("openai_key", "sk-test123"))
|
||||
.create(
|
||||
"user1",
|
||||
CreateSecretParams::new("openai_key", TEST_OPENAI_API_KEY),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -428,7 +427,7 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
result.headers.get("Authorization"),
|
||||
Some(&"Bearer sk-test123".to_string())
|
||||
Some(&format!("Bearer {TEST_OPENAI_API_KEY}"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+44
-12
@@ -193,18 +193,31 @@ impl WasmToolLoader {
|
||||
///
|
||||
/// Tools without a capabilities file get no permissions (default deny).
|
||||
pub async fn load_from_dir(&self, dir: &Path) -> Result<LoadResults, WasmLoadError> {
|
||||
if !dir.is_dir() {
|
||||
return Err(WasmLoadError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::NotADirectory,
|
||||
format!("{} is not a directory", dir.display()),
|
||||
)));
|
||||
match fs::metadata(dir).await {
|
||||
Ok(meta) if meta.is_dir() => {}
|
||||
Ok(_) => {
|
||||
return Err(WasmLoadError::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(WasmLoadError::Io(e)),
|
||||
}
|
||||
|
||||
let mut results = LoadResults::default();
|
||||
// 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(WasmLoadError::Io(e)),
|
||||
};
|
||||
|
||||
// Collect all .wasm entries first, then load in parallel
|
||||
let mut results = LoadResults::default();
|
||||
let mut tool_entries = Vec::new();
|
||||
let mut entries = fs::read_dir(dir).await?;
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
@@ -681,6 +694,7 @@ mod tests {
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::testing::credentials::{TEST_OAUTH_CLIENT_ID, TEST_OAUTH_CLIENT_SECRET};
|
||||
use crate::tools::wasm::loader::{WasmLoadError, check_wit_version_compat, discover_tools};
|
||||
|
||||
#[test]
|
||||
@@ -821,8 +835,8 @@ mod tests {
|
||||
oauth: Some(OAuthConfigSchema {
|
||||
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
|
||||
token_url: "https://oauth2.googleapis.com/token".to_string(),
|
||||
client_id: Some("test-client-id".to_string()),
|
||||
client_secret: Some("test-client-secret".to_string()),
|
||||
client_id: Some(TEST_OAUTH_CLIENT_ID.to_string()),
|
||||
client_secret: Some(TEST_OAUTH_CLIENT_SECRET.to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
@@ -835,8 +849,11 @@ mod tests {
|
||||
|
||||
let config = config.unwrap();
|
||||
assert_eq!(config.token_url, "https://oauth2.googleapis.com/token");
|
||||
assert_eq!(config.client_id, "test-client-id");
|
||||
assert_eq!(config.client_secret, Some("test-client-secret".to_string()));
|
||||
assert_eq!(config.client_id, TEST_OAUTH_CLIENT_ID);
|
||||
assert_eq!(
|
||||
config.client_secret,
|
||||
Some(TEST_OAUTH_CLIENT_SECRET.to_string())
|
||||
);
|
||||
assert_eq!(config.secret_name, "google_oauth_token");
|
||||
assert_eq!(config.provider, Some("google".to_string()));
|
||||
}
|
||||
@@ -1077,4 +1094,19 @@ mod tests {
|
||||
"nested.wasm inside subdir should NOT be discovered"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_from_dir_returns_empty_when_dir_missing() {
|
||||
let loader = make_loader();
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let missing = dir.path().join("nonexistent_tools_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());
|
||||
}
|
||||
}
|
||||
|
||||
+29
-49
@@ -1212,6 +1212,11 @@ fn coerce_params_to_schema(
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::testing::credentials::{
|
||||
TEST_BEARER_TOKEN_123, TEST_GOOGLE_OAUTH_FRESH, TEST_GOOGLE_OAUTH_LEGACY,
|
||||
TEST_GOOGLE_OAUTH_TOKEN, TEST_OAUTH_CLIENT_ID, TEST_OAUTH_CLIENT_SECRET,
|
||||
test_secrets_store,
|
||||
};
|
||||
use crate::tools::wasm::capabilities::Capabilities;
|
||||
use crate::tools::wasm::runtime::{WasmRuntimeConfig, WasmToolRuntime};
|
||||
|
||||
@@ -1279,12 +1284,12 @@ mod tests {
|
||||
let mut h = HashMap::new();
|
||||
h.insert(
|
||||
"Authorization".to_string(),
|
||||
"Bearer test-token-123".to_string(),
|
||||
format!("Bearer {TEST_BEARER_TOKEN_123}"),
|
||||
);
|
||||
h
|
||||
},
|
||||
query_params: HashMap::new(),
|
||||
secret_value: "test-token-123".to_string(),
|
||||
secret_value: TEST_BEARER_TOKEN_123.to_string(),
|
||||
}];
|
||||
|
||||
let store_data = StoreData::new(
|
||||
@@ -1300,7 +1305,7 @@ mod tests {
|
||||
store_data.inject_host_credentials("www.googleapis.com", &mut headers, &mut url);
|
||||
assert_eq!(
|
||||
headers.get("Authorization"),
|
||||
Some(&"Bearer test-token-123".to_string())
|
||||
Some(&format!("Bearer {TEST_BEARER_TOKEN_123}"))
|
||||
);
|
||||
|
||||
// Should not inject for non-matching host
|
||||
@@ -1376,13 +1381,9 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_host_credentials_no_http_cap() {
|
||||
use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
|
||||
use crate::tools::wasm::wrapper::resolve_host_credentials;
|
||||
use secrecy::SecretString;
|
||||
|
||||
let key = "0123456789abcdef0123456789abcdef";
|
||||
let crypto = Arc::new(SecretsCrypto::new(SecretString::from(key.to_string())).unwrap());
|
||||
let store = InMemorySecretsStore::new(crypto);
|
||||
let store = test_secrets_store();
|
||||
|
||||
let caps = Capabilities::default();
|
||||
let result = resolve_host_credentials(&caps, Some(&store), "user1", None).await;
|
||||
@@ -1394,21 +1395,17 @@ mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::secrets::{
|
||||
CreateSecretParams, CredentialLocation, CredentialMapping, InMemorySecretsStore,
|
||||
SecretsCrypto, SecretsStore,
|
||||
CreateSecretParams, CredentialLocation, CredentialMapping, SecretsStore,
|
||||
};
|
||||
use crate::tools::wasm::capabilities::HttpCapability;
|
||||
use crate::tools::wasm::wrapper::resolve_host_credentials;
|
||||
use secrecy::SecretString;
|
||||
|
||||
let key = "0123456789abcdef0123456789abcdef";
|
||||
let crypto = Arc::new(SecretsCrypto::new(SecretString::from(key.to_string())).unwrap());
|
||||
let store = InMemorySecretsStore::new(crypto);
|
||||
let store = test_secrets_store();
|
||||
|
||||
store
|
||||
.create(
|
||||
"user1",
|
||||
CreateSecretParams::new("google_oauth_token", "ya29.test-token"),
|
||||
CreateSecretParams::new("google_oauth_token", TEST_GOOGLE_OAUTH_TOKEN),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1436,7 +1433,7 @@ mod tests {
|
||||
assert_eq!(result[0].host_patterns, vec!["www.googleapis.com"]);
|
||||
assert_eq!(
|
||||
result[0].headers.get("Authorization"),
|
||||
Some(&"Bearer ya29.test-token".to_string())
|
||||
Some(&format!("Bearer {TEST_GOOGLE_OAUTH_TOKEN}"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1444,16 +1441,11 @@ mod tests {
|
||||
async fn test_resolve_host_credentials_missing_secret() {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::secrets::{
|
||||
CredentialLocation, CredentialMapping, InMemorySecretsStore, SecretsCrypto,
|
||||
};
|
||||
use crate::secrets::{CredentialLocation, CredentialMapping};
|
||||
use crate::tools::wasm::capabilities::HttpCapability;
|
||||
use crate::tools::wasm::wrapper::resolve_host_credentials;
|
||||
use secrecy::SecretString;
|
||||
|
||||
let key = "0123456789abcdef0123456789abcdef";
|
||||
let crypto = Arc::new(SecretsCrypto::new(SecretString::from(key.to_string())).unwrap());
|
||||
let store = InMemorySecretsStore::new(crypto);
|
||||
let store = test_secrets_store();
|
||||
|
||||
// No secret stored, should silently skip
|
||||
let mut credentials = HashMap::new();
|
||||
@@ -1483,23 +1475,19 @@ mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::secrets::{
|
||||
CreateSecretParams, CredentialLocation, CredentialMapping, InMemorySecretsStore,
|
||||
SecretsCrypto, SecretsStore,
|
||||
CreateSecretParams, CredentialLocation, CredentialMapping, SecretsStore,
|
||||
};
|
||||
use crate::tools::wasm::capabilities::HttpCapability;
|
||||
use crate::tools::wasm::wrapper::{OAuthRefreshConfig, resolve_host_credentials};
|
||||
use secrecy::SecretString;
|
||||
|
||||
let key = "0123456789abcdef0123456789abcdef";
|
||||
let crypto = Arc::new(SecretsCrypto::new(SecretString::from(key.to_string())).unwrap());
|
||||
let store = InMemorySecretsStore::new(crypto);
|
||||
let store = test_secrets_store();
|
||||
|
||||
// Store a token that expires 2 hours from now (well within buffer)
|
||||
let expires_at = chrono::Utc::now() + chrono::Duration::hours(2);
|
||||
store
|
||||
.create(
|
||||
"user1",
|
||||
CreateSecretParams::new("google_oauth_token", "ya29.fresh-token")
|
||||
CreateSecretParams::new("google_oauth_token", TEST_GOOGLE_OAUTH_FRESH)
|
||||
.with_expiry(expires_at),
|
||||
)
|
||||
.await
|
||||
@@ -1525,8 +1513,8 @@ mod tests {
|
||||
|
||||
let oauth_config = OAuthRefreshConfig {
|
||||
token_url: "https://oauth2.googleapis.com/token".to_string(),
|
||||
client_id: "test-client-id".to_string(),
|
||||
client_secret: Some("test-client-secret".to_string()),
|
||||
client_id: TEST_OAUTH_CLIENT_ID.to_string(),
|
||||
client_secret: Some(TEST_OAUTH_CLIENT_SECRET.to_string()),
|
||||
secret_name: "google_oauth_token".to_string(),
|
||||
provider: Some("google".to_string()),
|
||||
};
|
||||
@@ -1537,7 +1525,7 @@ mod tests {
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(
|
||||
result[0].headers.get("Authorization"),
|
||||
Some(&"Bearer ya29.fresh-token".to_string())
|
||||
Some(&format!("Bearer {TEST_GOOGLE_OAUTH_FRESH}"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1546,16 +1534,12 @@ mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::secrets::{
|
||||
CreateSecretParams, CredentialLocation, CredentialMapping, InMemorySecretsStore,
|
||||
SecretsCrypto, SecretsStore,
|
||||
CreateSecretParams, CredentialLocation, CredentialMapping, SecretsStore,
|
||||
};
|
||||
use crate::tools::wasm::capabilities::HttpCapability;
|
||||
use crate::tools::wasm::wrapper::resolve_host_credentials;
|
||||
use secrecy::SecretString;
|
||||
|
||||
let key = "0123456789abcdef0123456789abcdef";
|
||||
let crypto = Arc::new(SecretsCrypto::new(SecretString::from(key.to_string())).unwrap());
|
||||
let store = InMemorySecretsStore::new(crypto);
|
||||
let store = test_secrets_store();
|
||||
|
||||
// Store an expired token
|
||||
let expires_at = chrono::Utc::now() - chrono::Duration::hours(1);
|
||||
@@ -1595,22 +1579,18 @@ mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::secrets::{
|
||||
CreateSecretParams, CredentialLocation, CredentialMapping, InMemorySecretsStore,
|
||||
SecretsCrypto, SecretsStore,
|
||||
CreateSecretParams, CredentialLocation, CredentialMapping, SecretsStore,
|
||||
};
|
||||
use crate::tools::wasm::capabilities::HttpCapability;
|
||||
use crate::tools::wasm::wrapper::{OAuthRefreshConfig, resolve_host_credentials};
|
||||
use secrecy::SecretString;
|
||||
|
||||
let key = "0123456789abcdef0123456789abcdef";
|
||||
let crypto = Arc::new(SecretsCrypto::new(SecretString::from(key.to_string())).unwrap());
|
||||
let store = InMemorySecretsStore::new(crypto);
|
||||
let store = test_secrets_store();
|
||||
|
||||
// Legacy token: no expires_at set
|
||||
store
|
||||
.create(
|
||||
"user1",
|
||||
CreateSecretParams::new("google_oauth_token", "ya29.legacy-token"),
|
||||
CreateSecretParams::new("google_oauth_token", TEST_GOOGLE_OAUTH_LEGACY),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1635,8 +1615,8 @@ mod tests {
|
||||
|
||||
let oauth_config = OAuthRefreshConfig {
|
||||
token_url: "https://oauth2.googleapis.com/token".to_string(),
|
||||
client_id: "test-client-id".to_string(),
|
||||
client_secret: Some("test-client-secret".to_string()),
|
||||
client_id: TEST_OAUTH_CLIENT_ID.to_string(),
|
||||
client_secret: Some(TEST_OAUTH_CLIENT_SECRET.to_string()),
|
||||
secret_name: "google_oauth_token".to_string(),
|
||||
provider: Some("google".to_string()),
|
||||
};
|
||||
@@ -1647,7 +1627,7 @@ mod tests {
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(
|
||||
result[0].headers.get("Authorization"),
|
||||
Some(&"Bearer ya29.legacy-token".to_string())
|
||||
Some(&format!("Bearer {TEST_GOOGLE_OAUTH_LEGACY}"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,8 +21,27 @@
|
||||
|
||||
use std::io::{self, Write};
|
||||
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use tracing_subscriber::fmt::MakeWriter;
|
||||
|
||||
/// Initialize tracing for simple CLI commands (warn level, no fancy layers).
|
||||
pub fn init_cli_tracing() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")),
|
||||
)
|
||||
.init();
|
||||
}
|
||||
|
||||
/// Initialize tracing for worker/bridge processes (info level).
|
||||
pub fn init_worker_tracing() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("ironclaw=info")),
|
||||
)
|
||||
.init();
|
||||
}
|
||||
|
||||
/// Maximum bytes per tracing event written to the terminal.
|
||||
const TERMINAL_MAX_EVENT_BYTES: usize = 500;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user