mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be57a7684d | ||
|
|
2016693b0c | ||
|
|
bcef04b821 | ||
|
|
6e12ce6f2d | ||
|
|
b53986f00b | ||
|
|
1440ec7422 | ||
|
|
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
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,100 @@
|
||||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: claude-review-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
review:
|
||||
name: Claude Code Review
|
||||
if: contains(github.event.pull_request.labels.*.name, 'staging-promotion')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run Claude Code review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
allowed_bots: "ironclaw-ci[bot]"
|
||||
claude_args: "--max-turns 50 --model claude-haiku-4-5-20251001 --allowedTools 'Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh search:*),Bash(git blame:*),Bash(git log:*),Bash(git diff:*)'"
|
||||
prompt: |
|
||||
Code review this pull request. Follow these steps precisely:
|
||||
|
||||
1. Use a Haiku agent to find relevant CLAUDE.md files: the root CLAUDE.md
|
||||
and any CLAUDE.md files in directories whose files this PR modifies.
|
||||
|
||||
2. Use a Haiku agent to summarize the PR change (use `gh pr diff`).
|
||||
|
||||
3. Launch 4 parallel agents to review the change independently. Each agent should
|
||||
read the PR diff with `gh pr diff` and the full source files for changed
|
||||
code, then return a list of issues found:
|
||||
|
||||
Agent 1 — Security & Safety
|
||||
Check for: command injection, path traversal, SSRF, XSS, auth bypass,
|
||||
secrets in logs, .unwrap()/.expect() in production code (not tests),
|
||||
race conditions, TOCTOU, unsafe blocks, panics in async, unbounded allocations.
|
||||
|
||||
Agent 2 — Architecture & Patterns
|
||||
Check for: extensible design (traits/enums over nested conditionals),
|
||||
clean abstractions, proper error types (thiserror), CLAUDE.md compliance,
|
||||
type-driven design over stringly-typed code, DRY violations.
|
||||
|
||||
Agent 3 — Bug Scan
|
||||
Shallow diff-only scan for obvious bugs: logic errors, off-by-one,
|
||||
missing error handling, division by zero, incorrect return values.
|
||||
Ignore nitpicks and likely false positives. Do NOT read extra context
|
||||
beyond the diff — focus only on the changes.
|
||||
|
||||
Agent 4 — Performance & Production
|
||||
Check for: blocking in async, N+1 queries, unbounded loops, missing
|
||||
timeouts, resource leaks (file handles, connections), large allocations
|
||||
in hot paths.
|
||||
|
||||
4. For each issue found, launch a parallel Haiku agent to:
|
||||
a. Assign a severity:
|
||||
- CRITICAL: security vulns, panics in prod (.unwrap/.expect), data exfiltration, race conditions
|
||||
- HIGH: logic bugs, missing error handling, breaking API/schema changes
|
||||
- MEDIUM: missing tests, unnecessary complexity, performance issues
|
||||
- LOW: documentation gaps, naming suggestions
|
||||
b. Score confidence 0-100 (give this rubric verbatim):
|
||||
0: False positive, doesn't stand up to scrutiny, or pre-existing issue.
|
||||
25: Might be real, but may be false positive. Stylistic issues not in CLAUDE.md.
|
||||
50: Real issue but nitpick or rare in practice. Not very important.
|
||||
75: Verified real issue, will be hit in practice. Directly impacts functionality
|
||||
or explicitly mentioned in CLAUDE.md.
|
||||
100: Certain, confirmed, will happen frequently. Evidence directly confirms.
|
||||
|
||||
5. Post a single comment on the PR using `gh pr comment` with this format.
|
||||
If no issues were found, post "No issues found." instead:
|
||||
|
||||
### Code review
|
||||
|
||||
Found N issues:
|
||||
|
||||
1. [SEVERITY:CONFIDENCE] <brief description>
|
||||
|
||||
<permalink to file:line using full SHA, eg https://github.com/owner/repo/blob/abc123def/src/file.rs#L10-L15>
|
||||
|
||||
Example: [CRITICAL:92] `.unwrap()` can panic in production when config is missing
|
||||
|
||||
You MUST use the full git SHA in links (not HEAD or branch name).
|
||||
Provide 1 line of context before and after each linked range.
|
||||
|
||||
Notes:
|
||||
- Use `gh` for all GitHub interactions, not web fetch
|
||||
- Do NOT check build signal or attempt to build/test the code
|
||||
- Ignore pre-existing issues not introduced by this PR
|
||||
- Ignore issues a linter/compiler would catch (formatting, imports, types)
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
name: E2E Tests
|
||||
on:
|
||||
workflow_call:
|
||||
schedule:
|
||||
- cron: "0 6 * * 1" # Weekly Monday 6 AM UTC
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
name: Staging CI (Batched)
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 * * * *" # Every 60 minutes
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
description: "Force run even if no new commits"
|
||||
type: boolean
|
||||
default: false
|
||||
skip_claude_gate:
|
||||
description: "Skip Claude review gate (bypass blocking findings)"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
checks: read
|
||||
|
||||
concurrency:
|
||||
group: staging-ci
|
||||
cancel-in-progress: false # Let running suites finish
|
||||
|
||||
jobs:
|
||||
# ── Check for new commits ──────────────────────────────────────
|
||||
check-changes:
|
||||
name: Check for new commits
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
has_changes: ${{ steps.check.outputs.has_changes }}
|
||||
current_head: ${{ steps.check.outputs.current_head }}
|
||||
diff_range: ${{ steps.check.outputs.diff_range }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: staging
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Check for changes since last tested
|
||||
id: check
|
||||
env:
|
||||
FORCE_RUN: ${{ inputs.force }}
|
||||
run: |
|
||||
CURRENT_HEAD=$(git rev-parse HEAD)
|
||||
echo "current_head=${CURRENT_HEAD}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if git rev-parse staging-tested >/dev/null 2>&1; then
|
||||
LAST_TESTED=$(git rev-parse staging-tested)
|
||||
else
|
||||
LAST_TESTED=""
|
||||
fi
|
||||
|
||||
DIFF_RANGE=""
|
||||
if [ -n "$LAST_TESTED" ] && [ "$LAST_TESTED" = "$CURRENT_HEAD" ]; then
|
||||
echo "No new commits since last tested (${CURRENT_HEAD})"
|
||||
HAS_CHANGES=false
|
||||
else
|
||||
HAS_CHANGES=true
|
||||
if [ -n "$LAST_TESTED" ]; then
|
||||
COMMIT_COUNT=$(git rev-list --count "${LAST_TESTED}..HEAD")
|
||||
echo "Found ${COMMIT_COUNT} new commit(s) since last tested"
|
||||
DIFF_RANGE="${LAST_TESTED}..${CURRENT_HEAD}"
|
||||
else
|
||||
git fetch origin main
|
||||
MERGE_BASE=$(git merge-base origin/main HEAD)
|
||||
echo "First run -- reviewing from merge-base ${MERGE_BASE}"
|
||||
DIFF_RANGE="${MERGE_BASE}..${CURRENT_HEAD}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Force override from workflow_dispatch
|
||||
if [ "$FORCE_RUN" = "true" ]; then
|
||||
echo "Force run requested"
|
||||
HAS_CHANGES=true
|
||||
if [ -z "$DIFF_RANGE" ]; then
|
||||
DIFF_RANGE="${CURRENT_HEAD}..${CURRENT_HEAD}"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "has_changes=${HAS_CHANGES}" >> "$GITHUB_OUTPUT"
|
||||
echo "diff_range=${DIFF_RANGE}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ── Run full test suite ──────────────────────────────────────────
|
||||
tests:
|
||||
name: Test Suite
|
||||
needs: check-changes
|
||||
if: needs.check-changes.outputs.has_changes == 'true'
|
||||
uses: ./.github/workflows/test.yml
|
||||
|
||||
# ── Run E2E browser tests ────────────────────────────────────────
|
||||
e2e:
|
||||
name: E2E Browser Tests
|
||||
needs: check-changes
|
||||
if: needs.check-changes.outputs.has_changes == 'true'
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
|
||||
# ── Create promotion PR (triggers claude-review.yml on the PR) ──
|
||||
create-promotion-pr:
|
||||
name: Create Promotion PR
|
||||
needs: check-changes
|
||||
if: needs.check-changes.outputs.has_changes == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
pr_number: ${{ steps.create-pr.outputs.pr_number }}
|
||||
promotion_branch: ${{ steps.branch.outputs.branch }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: staging
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
|
||||
private-key: ${{ secrets.GH_RELEASES_MANAGER_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set token
|
||||
id: token
|
||||
run: |
|
||||
if [ -n "${{ steps.app-token.outputs.token }}" ]; then
|
||||
echo "token=${{ steps.app-token.outputs.token }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "token=${{ github.token }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Check if staging is ahead of main
|
||||
id: ahead-check
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.token.outputs.token }}
|
||||
run: |
|
||||
git fetch origin main
|
||||
AHEAD=$(git rev-list --count origin/main..origin/staging)
|
||||
echo "commits_ahead=${AHEAD}" >> "$GITHUB_OUTPUT"
|
||||
if [ "$AHEAD" -eq 0 ]; then
|
||||
echo "Staging is not ahead of main. Nothing to promote."
|
||||
else
|
||||
echo "Staging is ${AHEAD} commits ahead of main."
|
||||
fi
|
||||
|
||||
- name: Create promotion branch
|
||||
id: branch
|
||||
if: steps.ahead-check.outputs.commits_ahead != '0'
|
||||
run: |
|
||||
SHORT_SHA=$(echo "${{ needs.check-changes.outputs.current_head }}" | cut -c1-8)
|
||||
BRANCH="staging-promote/${SHORT_SHA}-${{ github.run_id }}"
|
||||
git checkout -b "$BRANCH"
|
||||
git push origin "$BRANCH"
|
||||
echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT"
|
||||
echo "Created promotion branch: ${BRANCH}"
|
||||
|
||||
- name: Find base branch
|
||||
id: find-base
|
||||
if: steps.ahead-check.outputs.commits_ahead != '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.token.outputs.token }}
|
||||
run: |
|
||||
# Find the newest open promotion PR with a staging-promote/* head branch
|
||||
LATEST=$(gh pr list --label staging-promotion --state open \
|
||||
--json headRefName,createdAt \
|
||||
--jq '[.[] | select(.headRefName | startswith("staging-promote/"))] | sort_by(.createdAt) | last | .headRefName // empty')
|
||||
if [ -n "$LATEST" ]; then
|
||||
echo "base=${LATEST}" >> "$GITHUB_OUTPUT"
|
||||
echo "Chaining onto existing promotion branch: ${LATEST}"
|
||||
else
|
||||
echo "base=main" >> "$GITHUB_OUTPUT"
|
||||
echo "No existing promotion PR — targeting main"
|
||||
fi
|
||||
|
||||
- name: Create promotion PR
|
||||
id: create-pr
|
||||
if: steps.ahead-check.outputs.commits_ahead != '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.token.outputs.token }}
|
||||
run: |
|
||||
RANGE="${{ needs.check-changes.outputs.diff_range }}"
|
||||
TIMESTAMP=$(date -u +"%Y-%m-%d %H:%M UTC")
|
||||
BRANCH="${{ steps.branch.outputs.branch }}"
|
||||
BASE="${{ steps.find-base.outputs.base }}"
|
||||
|
||||
PR_URL=$(gh pr create \
|
||||
--base "$BASE" \
|
||||
--head "$BRANCH" \
|
||||
--title "chore: promote staging to main (${TIMESTAMP})" \
|
||||
--body "## Auto-promotion from staging CI
|
||||
|
||||
**Batch range:** \`${RANGE}\`
|
||||
**Promotion branch:** \`${BRANCH}\`
|
||||
**Base:** \`${BASE}\`
|
||||
**Triggered by:** Staging CI batch at ${TIMESTAMP}
|
||||
|
||||
Waiting for gates:
|
||||
- Tests: pending
|
||||
- E2E: pending
|
||||
- Claude Code review: pending (will post comments on this PR)
|
||||
|
||||
---
|
||||
*Auto-created by staging-ci workflow*" \
|
||||
--label "staging-promotion")
|
||||
|
||||
PR_NUM=$(echo "$PR_URL" | grep -oE '[0-9]+$')
|
||||
echo "pr_number=${PR_NUM}" >> "$GITHUB_OUTPUT"
|
||||
echo "Created promotion PR #${PR_NUM}"
|
||||
|
||||
# ── Gate: wait for review, process findings, merge or block ─────
|
||||
gate:
|
||||
name: Staging Gate
|
||||
needs: [check-changes, tests, e2e, create-promotion-pr]
|
||||
if: >
|
||||
always() &&
|
||||
needs.check-changes.outputs.has_changes == 'true' &&
|
||||
needs.tests.result == 'success' &&
|
||||
needs.e2e.result == 'success' &&
|
||||
needs.create-promotion-pr.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
outputs:
|
||||
gate_passed: ${{ steps.evaluate.outputs.passed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: staging
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
|
||||
private-key: ${{ secrets.GH_RELEASES_MANAGER_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set token
|
||||
id: token
|
||||
run: |
|
||||
if [ -n "${{ steps.app-token.outputs.token }}" ]; then
|
||||
echo "token=${{ steps.app-token.outputs.token }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "token=${{ github.token }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Wait for Claude review job
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.token.outputs.token }}
|
||||
PR_NUMBER: ${{ needs.create-promotion-pr.outputs.pr_number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ -z "$PR_NUMBER" ]; then
|
||||
echo "No PR number — skipping wait"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PR_SHA=$(gh pr view "$PR_NUMBER" --json headRefOid --jq '.headRefOid' || echo "")
|
||||
if [ -z "$PR_SHA" ]; then
|
||||
echo "::warning::Could not get PR head SHA"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Polling for Claude Code Review job on PR #${PR_NUMBER} (SHA: ${PR_SHA})..."
|
||||
TIMEOUT=1200 # 20 minutes
|
||||
ELAPSED=0
|
||||
INTERVAL=30
|
||||
|
||||
while [ "$ELAPSED" -lt "$TIMEOUT" ]; do
|
||||
STATUS=$(gh api "repos/${REPO}/commits/${PR_SHA}/check-runs" \
|
||||
--jq '[.check_runs[] | select(.name == "Claude Code Review") | .conclusion // .status] | first // "pending"' 2>/dev/null || echo "pending")
|
||||
|
||||
if [ "$STATUS" = "success" ] || [ "$STATUS" = "failure" ] || [ "$STATUS" = "cancelled" ]; then
|
||||
echo "Claude review job completed with status: ${STATUS} (${ELAPSED}s)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Claude review status: ${STATUS} (${ELAPSED}s elapsed)"
|
||||
sleep "$INTERVAL"
|
||||
ELAPSED=$((ELAPSED + INTERVAL))
|
||||
done
|
||||
|
||||
echo "::warning::Claude review job not completed after ${TIMEOUT}s"
|
||||
|
||||
- name: Process Claude review comments and create issues
|
||||
id: process-findings
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.token.outputs.token }}
|
||||
PR_NUMBER: ${{ needs.create-promotion-pr.outputs.pr_number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
HAS_BLOCKING=false
|
||||
ISSUES_CREATED=0
|
||||
|
||||
if [ -z "$PR_NUMBER" ]; then
|
||||
echo "No PR — skipping finding processing"
|
||||
echo "has_blocking=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check for "No issues found" first (clean pass)
|
||||
NO_ISSUES=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \
|
||||
--jq '[.[] | select(.user.login == "claude[bot]") | select(.body | test("No issues found"))] | length' 2>/dev/null || echo "0")
|
||||
if [ "$NO_ISSUES" -gt 0 ]; then
|
||||
echo "Claude review found no issues — gate passes"
|
||||
echo "has_blocking=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Get the last Claude comment that contains findings
|
||||
JQ_FILTER='[.[] | select(.user.login == "claude[bot]") | select(.body | test("Found [0-9]+ issue"))] | last'
|
||||
BODY=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \
|
||||
--jq "${JQ_FILTER} | .body // empty" 2>/dev/null || echo "")
|
||||
COMMENT_URL=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \
|
||||
--jq "${JQ_FILTER} | .html_url // empty" 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$BODY" ]; then
|
||||
echo "::warning::No Claude review comment found for PR #${PR_NUMBER} — treating as blocking"
|
||||
echo "has_blocking=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Parse [SEVERITY:CONFIDENCE] tags from each numbered finding
|
||||
# Matrix: CRITICAL always→issue, ≥80→block. HIGH ≥50→issue. MEDIUM ≥80→issue. LOW ≥80→issue.
|
||||
# Use process substitution so variables propagate to parent shell
|
||||
while read -r line; do
|
||||
TAG=$(echo "$line" | grep -oE '^\[(CRITICAL|HIGH|MEDIUM|LOW):[0-9]+\]')
|
||||
SEVERITY=$(echo "$TAG" | sed 's/\[\(.*\):\(.*\)\]/\1/')
|
||||
CONFIDENCE=$(echo "$TAG" | sed 's/\[\(.*\):\(.*\)\]/\2/')
|
||||
DESC=$(echo "$line" | sed "s/\[${SEVERITY}:${CONFIDENCE}\] *//" | head -1)
|
||||
|
||||
echo "Found: [${SEVERITY}:${CONFIDENCE}] ${DESC}"
|
||||
|
||||
# Check if blocking (CRITICAL ≥80)
|
||||
if [ "$SEVERITY" = "CRITICAL" ] && [ "$CONFIDENCE" -ge 80 ]; then
|
||||
HAS_BLOCKING=true
|
||||
fi
|
||||
|
||||
# Determine if this should create an issue
|
||||
CREATE_ISSUE=false
|
||||
case "$SEVERITY" in
|
||||
CRITICAL) CREATE_ISSUE=true ;;
|
||||
HIGH) [ "$CONFIDENCE" -ge 50 ] && CREATE_ISSUE=true ;;
|
||||
MEDIUM) [ "$CONFIDENCE" -ge 80 ] && CREATE_ISSUE=true ;;
|
||||
LOW) [ "$CONFIDENCE" -ge 80 ] && CREATE_ISSUE=true ;;
|
||||
esac
|
||||
|
||||
if [ "$CREATE_ISSUE" = "true" ]; then
|
||||
case "$SEVERITY" in
|
||||
CRITICAL) LABELS="bug,risk: high,staging-ci-review" ;;
|
||||
HIGH) LABELS="bug,risk: medium,staging-ci-review" ;;
|
||||
MEDIUM) LABELS="risk: medium,staging-ci-review" ;;
|
||||
LOW) LABELS="risk: low,staging-ci-review" ;;
|
||||
esac
|
||||
|
||||
TITLE=$(echo "$DESC" | cut -c1-80)
|
||||
{
|
||||
echo "## [${SEVERITY}:${CONFIDENCE}] Issue Found by Staging CI Review"
|
||||
echo ""
|
||||
echo "**Severity:** ${SEVERITY}"
|
||||
echo "**Confidence:** ${CONFIDENCE}/100"
|
||||
echo "**PR comment:** ${COMMENT_URL}"
|
||||
echo ""
|
||||
echo "### Description"
|
||||
echo "$DESC"
|
||||
echo ""
|
||||
echo "---"
|
||||
echo "*Auto-created by staging-ci Claude Code review*"
|
||||
} > /tmp/issue-body.md
|
||||
|
||||
if gh issue create \
|
||||
--title "[${SEVERITY}] ${TITLE}" \
|
||||
--body-file /tmp/issue-body.md \
|
||||
--label "${LABELS}"; then
|
||||
ISSUES_CREATED=$((ISSUES_CREATED + 1))
|
||||
else
|
||||
echo "::warning::Failed to create issue for ${SEVERITY} finding"
|
||||
fi
|
||||
fi
|
||||
done < <(echo "$BODY" | grep -oE '\[(CRITICAL|HIGH|MEDIUM|LOW):[0-9]+\].*')
|
||||
|
||||
echo "Created ${ISSUES_CREATED} issues"
|
||||
echo "has_blocking=${HAS_BLOCKING}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Evaluate gate
|
||||
id: evaluate
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.create-promotion-pr.outputs.pr_number }}
|
||||
SKIP_GATE: ${{ inputs.skip_claude_gate }}
|
||||
HAS_BLOCKING: ${{ steps.process-findings.outputs.has_blocking }}
|
||||
run: |
|
||||
SKIP_INPUT="$SKIP_GATE"
|
||||
|
||||
if [ "$HAS_BLOCKING" = "true" ]; then
|
||||
echo "::warning::Claude review found blocking issues (CRITICAL ≥80 confidence)"
|
||||
if [ "$SKIP_INPUT" = "true" ]; then
|
||||
echo "::warning::Gate overridden by skip_claude_gate workflow input"
|
||||
echo "passed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::error::Blocking promotion due to CRITICAL findings (≥80 confidence)"
|
||||
echo "::error::PR #${PR_NUMBER} left open with review comments"
|
||||
echo "passed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "No blocking findings. Gate passed."
|
||||
echo "passed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Merge promotion PR
|
||||
id: merge
|
||||
if: steps.evaluate.outputs.passed == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.token.outputs.token }}
|
||||
PR_NUMBER: ${{ needs.create-promotion-pr.outputs.pr_number }}
|
||||
run: |
|
||||
if [ -n "$PR_NUMBER" ]; then
|
||||
echo "Merging promotion PR #${PR_NUMBER}"
|
||||
# Do NOT use --delete-branch: deleting a promotion branch closes
|
||||
# any chained PRs that use it as their base (verified in ironclaw-ci-test).
|
||||
# Stale promotion branches are cleaned up separately.
|
||||
gh pr merge "$PR_NUMBER" --merge
|
||||
echo "merged=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# ── Update tested tag (always, so next batch covers only new commits) ──
|
||||
update-tag:
|
||||
name: Update staging-tested tag
|
||||
needs: [check-changes, tests, e2e, create-promotion-pr, gate]
|
||||
if: >
|
||||
always() &&
|
||||
needs.check-changes.outputs.has_changes == 'true' &&
|
||||
needs.tests.result == 'success' &&
|
||||
needs.e2e.result == 'success' &&
|
||||
needs.create-promotion-pr.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: staging
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Update staging-tested tag
|
||||
run: |
|
||||
git tag -f staging-tested "${{ needs.check-changes.outputs.current_head }}"
|
||||
git push origin staging-tested --force
|
||||
echo "Updated staging-tested tag to ${{ needs.check-changes.outputs.current_head }}"
|
||||
|
||||
# ── Report ───────────────────────────────────────────────────────
|
||||
report:
|
||||
name: Staging CI Summary
|
||||
needs: [check-changes, tests, e2e, create-promotion-pr, gate, update-tag]
|
||||
if: always() && needs.check-changes.outputs.has_changes == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "## Staging CI Batch Results" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Check | Result |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "|-------|--------|" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Tests | ${{ needs.tests.result }} |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| E2E | ${{ needs.e2e.result }} |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Promotion PR | ${{ needs.create-promotion-pr.result }} |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Gate | ${{ needs.gate.result }} |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Tag Updated | ${{ needs.update-tag.result }} |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Range: ${{ needs.check-changes.outputs.diff_range }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
PR_NUM="${{ needs.create-promotion-pr.outputs.pr_number }}"
|
||||
if [ -n "$PR_NUM" ]; then
|
||||
echo "Promotion PR: #${PR_NUM}" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
@@ -1,6 +1,9 @@
|
||||
name: Run Tests
|
||||
on:
|
||||
workflow_call:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -38,6 +41,9 @@ jobs:
|
||||
|
||||
telegram-tests:
|
||||
name: Telegram Channel Tests
|
||||
if: >
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'pull_request' && github.base_ref != 'staging')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -50,6 +56,9 @@ jobs:
|
||||
|
||||
windows-build:
|
||||
name: Windows Build (${{ matrix.name }})
|
||||
if: >
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'pull_request' && github.base_ref != 'staging')
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -74,6 +83,9 @@ jobs:
|
||||
|
||||
wasm-wit-compat:
|
||||
name: WASM WIT Compatibility
|
||||
if: >
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'pull_request' && github.base_ref != 'staging')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -94,6 +106,9 @@ jobs:
|
||||
|
||||
docker-build:
|
||||
name: Docker Build
|
||||
if: >
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'pull_request' && github.base_ref != 'staging')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -123,12 +138,22 @@ jobs:
|
||||
needs: [tests, telegram-tests, wasm-wit-compat, docker-build, windows-build, version-check]
|
||||
steps:
|
||||
- run: |
|
||||
if [[ "${{ needs.tests.result }}" != "success" || "${{ needs.telegram-tests.result }}" != "success" || "${{ needs.wasm-wit-compat.result }}" != "success" || "${{ needs.docker-build.result }}" != "success" || "${{ needs.windows-build.result }}" != "success" ]]; then
|
||||
echo "One or more jobs failed"
|
||||
exit 1
|
||||
fi
|
||||
# version-check only runs on PRs, so skip/success are both acceptable
|
||||
if [[ "${{ needs.version-check.result }}" == "failure" ]]; then
|
||||
echo "Version bump check failed"
|
||||
# Unit tests must always pass
|
||||
if [[ "${{ needs.tests.result }}" != "success" ]]; then
|
||||
echo "Unit tests failed"
|
||||
exit 1
|
||||
fi
|
||||
# Gated jobs: must pass on promotion PRs / push, skipped on developer PRs
|
||||
for job in telegram-tests wasm-wit-compat docker-build windows-build version-check; do
|
||||
case "$job" in
|
||||
telegram-tests) result="${{ needs.telegram-tests.result }}" ;;
|
||||
wasm-wit-compat) result="${{ needs.wasm-wit-compat.result }}" ;;
|
||||
docker-build) result="${{ needs.docker-build.result }}" ;;
|
||||
windows-build) result="${{ needs.windows-build.result }}" ;;
|
||||
version-check) result="${{ needs.version-check.result }}" ;;
|
||||
esac
|
||||
if [[ "$result" == "failure" || "$result" == "cancelled" ]]; then
|
||||
echo "$job failed"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -25,3 +25,6 @@ bench-results/
|
||||
|
||||
# Traces
|
||||
trace_*.json
|
||||
|
||||
# Local Claude Code settings (machine-specific, should not be committed)
|
||||
.claude/settings.local.json
|
||||
|
||||
@@ -7,6 +7,81 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.17.0](https://github.com/nearai/ironclaw/compare/v0.16.1...v0.17.0) - 2026-03-10
|
||||
|
||||
### Added
|
||||
|
||||
- *(llm)* per-provider unsupported parameter filtering (#749, #728) ([#809](https://github.com/nearai/ironclaw/pull/809))
|
||||
- persist user_id in save_job and expose job_id on routine runs ([#709](https://github.com/nearai/ironclaw/pull/709))
|
||||
- *(ci)* chained promotion PRs with multi-agent Claude review ([#776](https://github.com/nearai/ironclaw/pull/776))
|
||||
- add background sandbox reaper for orphaned Docker containers ([#634](https://github.com/nearai/ironclaw/pull/634))
|
||||
- *(wasm)* lazy schema injection on WASM tool errors ([#638](https://github.com/nearai/ironclaw/pull/638))
|
||||
- add AWS Bedrock LLM provider via native Converse API ([#713](https://github.com/nearai/ironclaw/pull/713))
|
||||
- full image support across all channels ([#725](https://github.com/nearai/ironclaw/pull/725))
|
||||
- *(skills)* exclude_keywords veto in skill activation scoring ([#688](https://github.com/nearai/ironclaw/pull/688))
|
||||
- *(mcp)* transport abstraction, stdio/UDS transports, and OAuth fixes ([#721](https://github.com/nearai/ironclaw/pull/721))
|
||||
- add PID-based gateway lock to prevent multiple instances ([#717](https://github.com/nearai/ironclaw/pull/717))
|
||||
- configurable LLM request timeout via LLM_REQUEST_TIMEOUT_SECS ([#615](https://github.com/nearai/ironclaw/pull/615)) ([#630](https://github.com/nearai/ironclaw/pull/630))
|
||||
- *(timezone)* add timezone-aware session context ([#671](https://github.com/nearai/ironclaw/pull/671))
|
||||
- *(setup)* Anthropic OAuth onboarding with setup-token support ([#384](https://github.com/nearai/ironclaw/pull/384))
|
||||
- *(llm)* add Google Gemini, AWS Bedrock, io.net, Mistral, Yandex, and Cloudflare WS AI providers ([#676](https://github.com/nearai/ironclaw/pull/676))
|
||||
- unified thread model for web gateway ([#607](https://github.com/nearai/ironclaw/pull/607))
|
||||
- WASM channel attachments with LLM pipeline integration ([#596](https://github.com/nearai/ironclaw/pull/596))
|
||||
- enable Anthropic prompt caching via automatic cache_control injection ([#660](https://github.com/nearai/ironclaw/pull/660))
|
||||
- *(routines)* approval context for autonomous job execution ([#577](https://github.com/nearai/ironclaw/pull/577))
|
||||
- *(llm)* declarative provider registry ([#618](https://github.com/nearai/ironclaw/pull/618))
|
||||
- *(gateway)* show IronClaw version in status popover [skip-regression-check] ([#636](https://github.com/nearai/ironclaw/pull/636))
|
||||
- Wire memory hygiene retention policy into heartbeat loop ([#629](https://github.com/nearai/ironclaw/pull/629))
|
||||
|
||||
### Fixed
|
||||
|
||||
- *(ci)* run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] ([#802](https://github.com/nearai/ironclaw/pull/802))
|
||||
- *(ci)* clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] ([#794](https://github.com/nearai/ironclaw/pull/794))
|
||||
- *(ci)* secrets can't be used in step if conditions [skip-regression-check] ([#787](https://github.com/nearai/ironclaw/pull/787))
|
||||
- prevent irreversible context loss when compaction archive write fails ([#754](https://github.com/nearai/ironclaw/pull/754))
|
||||
- button styles ([#637](https://github.com/nearai/ironclaw/pull/637))
|
||||
- *(mcp)* JSON-RPC spec compliance — flexible id, correct notification format ([#685](https://github.com/nearai/ironclaw/pull/685))
|
||||
- preserve tool-call history across thread hydration ([#568](https://github.com/nearai/ironclaw/pull/568)) ([#670](https://github.com/nearai/ironclaw/pull/670))
|
||||
- CLI commands ignore runtime DATABASE_BACKEND when both features compiled ([#740](https://github.com/nearai/ironclaw/pull/740))
|
||||
- *(web)* prevent fetch error when hostname is an IP address in TEE check ([#672](https://github.com/nearai/ironclaw/pull/672))
|
||||
- add timezone conversion support to time tool ([#687](https://github.com/nearai/ironclaw/pull/687))
|
||||
- standardize libSQL timestamps as RFC 3339 UTC ([#683](https://github.com/nearai/ironclaw/pull/683))
|
||||
- *(docker)* bind postgres to localhost only ([#686](https://github.com/nearai/ironclaw/pull/686))
|
||||
- *(repl)* skip /quit on EOF when stdin is not a TTY ([#724](https://github.com/nearai/ironclaw/pull/724))
|
||||
- *(web)* prevent Enter key from sending message during IME composition ([#715](https://github.com/nearai/ironclaw/pull/715))
|
||||
- *(config)* init_secrets no longer overwrites entire config ([#726](https://github.com/nearai/ironclaw/pull/726))
|
||||
- *(cli)* status command ignores config.toml and settings.json ([#354](https://github.com/nearai/ironclaw/pull/354)) ([#734](https://github.com/nearai/ironclaw/pull/734))
|
||||
- *(setup)* preserve model name when re-running onboarding with same provider ([#600](https://github.com/nearai/ironclaw/pull/600)) ([#694](https://github.com/nearai/ironclaw/pull/694))
|
||||
- *(setup)* initialize secrets crypto for env-var security option ([#666](https://github.com/nearai/ironclaw/pull/666)) ([#706](https://github.com/nearai/ironclaw/pull/706))
|
||||
- persist /model selection across restarts ([#707](https://github.com/nearai/ironclaw/pull/707))
|
||||
- *(routines)* resolve message tool channel/target from per-job metadata ([#708](https://github.com/nearai/ironclaw/pull/708))
|
||||
- sanitize HTML error bodies from MCP servers to prevent web UI white screen ([#263](https://github.com/nearai/ironclaw/pull/263)) ([#656](https://github.com/nearai/ironclaw/pull/656))
|
||||
- prevent Instant duration overflow on Windows ([#657](https://github.com/nearai/ironclaw/pull/657)) ([#664](https://github.com/nearai/ironclaw/pull/664))
|
||||
- enable libsql remote + tls features for Turso cloud sync ([#587](https://github.com/nearai/ironclaw/pull/587))
|
||||
- *(tests)* replace hardcoded /tmp paths with tempdir + add 300 unit tests ([#659](https://github.com/nearai/ironclaw/pull/659))
|
||||
- *(llm)* nudge LLM when it expresses tool intent without calling tools ([#653](https://github.com/nearai/ironclaw/pull/653))
|
||||
- *(llm)* report zero cost for OpenRouter free-tier models ([#463](https://github.com/nearai/ironclaw/pull/463)) ([#613](https://github.com/nearai/ironclaw/pull/613))
|
||||
- reliable network tests and improved tool error messages ([#626](https://github.com/nearai/ironclaw/pull/626))
|
||||
- *(wasm)* use per-engine cache dirs on Windows to avoid file lock error ([#624](https://github.com/nearai/ironclaw/pull/624))
|
||||
- *(libsql)* support flexible embedding dimensions ([#534](https://github.com/nearai/ironclaw/pull/534))
|
||||
|
||||
### Other
|
||||
|
||||
- Restructure CLAUDE.md into modular rules + add pr-shepherd command ([#750](https://github.com/nearai/ironclaw/pull/750))
|
||||
- make src/llm/ self-contained for crate extraction ([#767](https://github.com/nearai/ironclaw/pull/767))
|
||||
- add simplified Chinese (zh-CN) README translation ([#488](https://github.com/nearai/ironclaw/pull/488))
|
||||
- *(job)* cover job tool validation and state transitions ([#681](https://github.com/nearai/ironclaw/pull/681))
|
||||
- *(agent)* wire TestRig job tools through the scheduler ([#716](https://github.com/nearai/ironclaw/pull/716))
|
||||
- Fix single-message mode to exit after one turn when background channels are enabled ([#719](https://github.com/nearai/ironclaw/pull/719))
|
||||
- remove dead code ([#648](https://github.com/nearai/ironclaw/pull/648)) ([#703](https://github.com/nearai/ironclaw/pull/703))
|
||||
- add reviewer-feedback guardrails (CLAUDE.md, pre-commit hook, skill) ([#665](https://github.com/nearai/ironclaw/pull/665))
|
||||
- update WASM artifact SHA256 checksums [skip ci] ([#631](https://github.com/nearai/ironclaw/pull/631))
|
||||
- add explanatory comments to coverage workflow ([#610](https://github.com/nearai/ironclaw/pull/610))
|
||||
- build system prompt once per turn, skip tools on force-text ([#583](https://github.com/nearai/ironclaw/pull/583))
|
||||
- add comprehensive subdirectory CLAUDE.md files and update root ([#589](https://github.com/nearai/ironclaw/pull/589))
|
||||
- Improve test infrastructure: StubChannel, gateway helpers, security tests, search edge cases ([#623](https://github.com/nearai/ironclaw/pull/623))
|
||||
- *(workspace)* regression test for document_path in search results ([#509](https://github.com/nearai/ironclaw/pull/509))
|
||||
|
||||
### Added
|
||||
|
||||
- AWS Bedrock LLM provider via native Converse API with IAM and SSO auth support (feature-gated: `--features bedrock`)
|
||||
|
||||
@@ -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,78 +59,35 @@ 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)
|
||||
│ ├── web/ # Web gateway (browser UI) — see src/channels/web/CLAUDE.md
|
||||
│ └── wasm/ # WASM channel runtime
|
||||
│ ├── mod.rs
|
||||
│ ├── bundled.rs # Bundled channel discovery
|
||||
│ ├── capabilities.rs # Channel-specific capabilities (HTTP endpoint, emit rate)
|
||||
│ ├── error.rs # WASM channel error types
|
||||
│ ├── runtime.rs # WASM channel execution runtime
|
||||
│ └── 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
|
||||
│ ├── 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)
|
||||
├── tunnel/ # Tunnel abstraction (cloudflare, ngrok, tailscale, custom, none)
|
||||
│
|
||||
├── 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,179 +95,68 @@ 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
|
||||
│ │ ├── testing.rs # Test harness integration
|
||||
│ │ └── validation.rs # WASM validation
|
||||
│ ├── mcp/ # Model Context Protocol
|
||||
│ │ ├── client.rs # MCP client over HTTP
|
||||
│ │ ├── protocol.rs # JSON-RPC types
|
||||
│ │ └── session.rs # MCP session management (Mcp-Session-Id header, per-server state)
|
||||
│ └── wasm/ # Full WASM sandbox (wasmtime)
|
||||
│ ├── runtime.rs # Module compilation and caching
|
||||
│ ├── wrapper.rs # Tool trait wrapper for WASM modules
|
||||
│ ├── host.rs # Host functions (logging, time, workspace)
|
||||
│ ├── limits.rs # Fuel metering and memory limiting
|
||||
│ ├── allowlist.rs # Network endpoint allowlisting
|
||||
│ ├── credential_injector.rs # Safe credential injection
|
||||
│ ├── loader.rs # WASM tool discovery from filesystem
|
||||
│ ├── rate_limiter.rs # Per-tool rate limiting
|
||||
│ ├── error.rs # WASM-specific error types
|
||||
│ └── storage.rs # Linear memory persistence
|
||||
│ ├── mcp/ # Model Context Protocol client
|
||||
│ └── wasm/ # Full WASM sandbox (wasmtime) — runtime, host functions, fuel metering, allowlist, credential injection
|
||||
│
|
||||
├── 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 | Spec |
|
||||
|--------|------|
|
||||
| `src/agent/` | `src/agent/CLAUDE.md` |
|
||||
| `src/channels/web/` | `src/channels/web/CLAUDE.md` |
|
||||
| `src/db/` | `src/db/CLAUDE.md` |
|
||||
| `src/llm/` | `src/llm/CLAUDE.md` |
|
||||
| `src/setup/` | `src/setup/README.md` |
|
||||
| `src/tools/` | `src/tools/README.md` |
|
||||
| `src/workspace/` | `src/workspace/README.md` |
|
||||
| `tests/e2e/` | `tests/e2e/CLAUDE.md` |
|
||||
|
||||
### Traits for Extensibility
|
||||
- `Database` - Add new database backends (must implement all ~78 methods)
|
||||
- `Channel` - Add new input sources
|
||||
- `Tool` - Add new capabilities
|
||||
- `LlmProvider` - Add new LLM backends
|
||||
- `SuccessEvaluator` - Custom evaluation logic
|
||||
- `EmbeddingProvider` - Add embedding backends (workspace search)
|
||||
- `NetworkPolicyDecider` - Custom network access policies for sandbox containers
|
||||
- `Hook` - Lifecycle hook at 6 interception points (BeforeInbound, BeforeToolCall, BeforeOutbound, OnSessionStart, OnSessionEnd, TransformResponse)
|
||||
- `Observer` - Observability backend (noop/log/multi; future: OpenTelemetry, Prometheus)
|
||||
- `Tunnel` - Tunnel provider for public internet exposure
|
||||
## Job State Machine
|
||||
|
||||
### Tool Implementation
|
||||
```rust
|
||||
#[async_trait]
|
||||
impl Tool for MyTool {
|
||||
fn name(&self) -> &str { "my_tool" }
|
||||
fn description(&self) -> &str { "Does something useful" }
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"param": { "type": "string", "description": "A parameter" }
|
||||
},
|
||||
"required": ["param"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, params: serde_json::Value, ctx: &JobContext)
|
||||
-> Result<ToolOutput, ToolError>
|
||||
{
|
||||
let start = std::time::Instant::now();
|
||||
// ... do work ...
|
||||
Ok(ToolOutput::text("result", start.elapsed()))
|
||||
}
|
||||
|
||||
fn requires_sanitization(&self) -> bool { true } // External data
|
||||
}
|
||||
```
|
||||
|
||||
### State Transitions
|
||||
Job states follow a defined state machine in `context/state.rs`:
|
||||
```
|
||||
Pending -> InProgress -> Completed -> Submitted -> Accepted
|
||||
\-> Failed
|
||||
@@ -354,315 +164,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 +183,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)
|
||||
|
||||
Generated
+1
-1
@@ -3350,7 +3350,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ironclaw"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"aho-corasick",
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ exclude = [
|
||||
|
||||
[package]
|
||||
name = "ironclaw"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.92"
|
||||
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
||||
|
||||
+3
-1
@@ -9,8 +9,9 @@
|
||||
"api_key_required": true,
|
||||
"base_url_env": "OPENAI_BASE_URL",
|
||||
"model_env": "OPENAI_MODEL",
|
||||
"default_model": "gpt-4o",
|
||||
"default_model": "gpt-5-mini",
|
||||
"description": "OpenAI GPT models (direct API)",
|
||||
"unsupported_params": ["temperature"],
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_openai_api_key",
|
||||
@@ -86,6 +87,7 @@
|
||||
"model_env": "TINFOIL_MODEL",
|
||||
"default_model": "kimi-k2-5",
|
||||
"description": "Tinfoil private inference (hardware-attested TEE)",
|
||||
"unsupported_params": ["temperature"],
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_tinfoil_api_key",
|
||||
|
||||
@@ -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
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
+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() {
|
||||
|
||||
+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 {
|
||||
|
||||
+30
-149
@@ -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")]
|
||||
@@ -356,6 +209,7 @@ impl LlmConfig {
|
||||
extra_headers_env,
|
||||
api_key_required,
|
||||
base_url_required,
|
||||
unsupported_params,
|
||||
) = if let Some(def) = def {
|
||||
(
|
||||
def.id.as_str(),
|
||||
@@ -368,6 +222,7 @@ impl LlmConfig {
|
||||
def.extra_headers_env.as_deref(),
|
||||
def.api_key_required,
|
||||
def.base_url_required,
|
||||
def.unsupported_params.clone(),
|
||||
)
|
||||
} else {
|
||||
// Absolute fallback: treat as generic openai_completions
|
||||
@@ -382,6 +237,7 @@ impl LlmConfig {
|
||||
Some("LLM_EXTRA_HEADERS"),
|
||||
false,
|
||||
true,
|
||||
Vec::new(),
|
||||
)
|
||||
};
|
||||
|
||||
@@ -459,6 +315,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 +340,8 @@ impl LlmConfig {
|
||||
model,
|
||||
extra_headers,
|
||||
oauth_token,
|
||||
cache_retention,
|
||||
unsupported_params,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -505,7 +380,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")
|
||||
}
|
||||
|
||||
@@ -753,6 +628,12 @@ mod tests {
|
||||
let provider = cfg.provider.expect("provider config should be present");
|
||||
assert_eq!(provider.base_url, "https://inference.tinfoil.sh/v1");
|
||||
assert_eq!(provider.model, "kimi-k2-5");
|
||||
assert!(
|
||||
provider
|
||||
.unsupported_params
|
||||
.contains(&"temperature".to_string()),
|
||||
"tinfoil should propagate unsupported_params from registry"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+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).
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -273,6 +299,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 +323,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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
+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 };
|
||||
|
||||
+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
|
||||
|
||||
|
||||
@@ -6,15 +6,17 @@
|
||||
//!
|
||||
//! Pattern follows `nearai_chat.rs`: direct HTTP calls via `reqwest::Client`.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
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,
|
||||
@@ -35,6 +37,8 @@ pub struct AnthropicOAuthProvider {
|
||||
model: String,
|
||||
base_url: Option<String>,
|
||||
active_model: std::sync::RwLock<String>,
|
||||
/// Parameter names that this provider does not support.
|
||||
unsupported_params: HashSet<String>,
|
||||
}
|
||||
|
||||
impl AnthropicOAuthProvider {
|
||||
@@ -61,15 +65,45 @@ impl AnthropicOAuthProvider {
|
||||
Some(config.base_url.clone())
|
||||
};
|
||||
|
||||
let unsupported_params: HashSet<String> =
|
||||
config.unsupported_params.iter().cloned().collect();
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
token,
|
||||
model: config.model.clone(),
|
||||
base_url,
|
||||
active_model,
|
||||
unsupported_params,
|
||||
})
|
||||
}
|
||||
|
||||
/// Strip unsupported fields from a `CompletionRequest` in place.
|
||||
fn strip_unsupported_completion_params(&self, req: &mut CompletionRequest) {
|
||||
if self.unsupported_params.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.unsupported_params.contains("temperature") {
|
||||
req.temperature = None;
|
||||
}
|
||||
if self.unsupported_params.contains("max_tokens") {
|
||||
req.max_tokens = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip unsupported fields from a `ToolCompletionRequest` in place.
|
||||
fn strip_unsupported_tool_params(&self, req: &mut ToolCompletionRequest) {
|
||||
if self.unsupported_params.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.unsupported_params.contains("temperature") {
|
||||
req.temperature = None;
|
||||
}
|
||||
if self.unsupported_params.contains("max_tokens") {
|
||||
req.max_tokens = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn api_url(&self) -> String {
|
||||
if let Some(ref base) = self.base_url {
|
||||
let base = base.trim_end_matches('/');
|
||||
@@ -197,8 +231,9 @@ impl AnthropicOAuthProvider {
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for AnthropicOAuthProvider {
|
||||
async fn complete(&self, req: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
||||
let model = req.model.unwrap_or_else(|| self.active_model_name());
|
||||
async fn complete(&self, mut req: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
||||
let model = req.model.take().unwrap_or_else(|| self.active_model_name());
|
||||
self.strip_unsupported_completion_params(&mut req);
|
||||
let (system, messages) = convert_messages(req.messages);
|
||||
|
||||
let request = AnthropicRequest {
|
||||
@@ -233,9 +268,10 @@ impl LlmProvider for AnthropicOAuthProvider {
|
||||
|
||||
async fn complete_with_tools(
|
||||
&self,
|
||||
req: ToolCompletionRequest,
|
||||
mut req: ToolCompletionRequest,
|
||||
) -> Result<ToolCompletionResponse, LlmError> {
|
||||
let model = req.model.unwrap_or_else(|| self.active_model_name());
|
||||
let model = req.model.take().unwrap_or_else(|| self.active_model_name());
|
||||
self.strip_unsupported_tool_params(&mut req);
|
||||
let (system, messages) = convert_messages(req.messages);
|
||||
|
||||
let tools: Vec<AnthropicTool> = req
|
||||
|
||||
+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,165 @@
|
||||
//! 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,
|
||||
/// Parameter names that this provider does not support (e.g., `["temperature"]`).
|
||||
/// Supported keys: `"temperature"`, `"max_tokens"`, `"stop_sequences"`.
|
||||
/// Listed parameters are stripped from requests before sending to avoid 400 errors.
|
||||
pub unsupported_params: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
|
||||
+23
-24
@@ -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.
|
||||
///
|
||||
@@ -220,7 +228,9 @@ fn create_openai_compat_from_registry(
|
||||
"Using OpenAI-compatible provider"
|
||||
);
|
||||
|
||||
Ok(Arc::new(RigAdapter::new(model, &config.model)))
|
||||
let adapter = RigAdapter::new(model, &config.model)
|
||||
.with_unsupported_params(config.unsupported_params.clone());
|
||||
Ok(Arc::new(adapter))
|
||||
}
|
||||
|
||||
fn create_anthropic_from_registry(
|
||||
@@ -232,7 +242,7 @@ 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!(
|
||||
provider = %config.provider_id,
|
||||
@@ -244,8 +254,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,21 +278,7 @@ 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);
|
||||
|
||||
@@ -303,7 +298,9 @@ fn create_anthropic_from_registry(
|
||||
);
|
||||
|
||||
Ok(Arc::new(
|
||||
RigAdapter::new(model, &config.model).with_cache_retention(cache_retention),
|
||||
RigAdapter::new(model, &config.model)
|
||||
.with_cache_retention(cache_retention)
|
||||
.with_unsupported_params(config.unsupported_params.clone()),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -331,7 +328,9 @@ fn create_ollama_from_registry(
|
||||
"Using Ollama provider"
|
||||
);
|
||||
|
||||
Ok(Arc::new(RigAdapter::new(model, &config.model)))
|
||||
let adapter = RigAdapter::new(model, &config.model)
|
||||
.with_unsupported_params(config.unsupported_params.clone());
|
||||
Ok(Arc::new(adapter))
|
||||
}
|
||||
|
||||
/// Create a cheap/fast LLM provider for lightweight tasks (heartbeat, routing, evaluation).
|
||||
@@ -531,7 +530,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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -152,6 +152,11 @@ pub struct ProviderDefinition {
|
||||
/// Setup wizard hints.
|
||||
#[serde(default)]
|
||||
pub setup: Option<SetupHint>,
|
||||
/// Parameter names that this provider does not support (e.g., `["temperature"]`).
|
||||
/// Supported keys: `"temperature"`, `"max_tokens"`, `"stop_sequences"`.
|
||||
/// Listed parameters are stripped from requests before sending to avoid 400 errors.
|
||||
#[serde(default)]
|
||||
pub unsupported_params: Vec<String>,
|
||||
}
|
||||
|
||||
/// Registry of known LLM providers.
|
||||
@@ -378,6 +383,7 @@ mod tests {
|
||||
description: "Custom tinfoil".to_string(),
|
||||
extra_headers_env: None,
|
||||
setup: None,
|
||||
unsupported_params: vec![],
|
||||
});
|
||||
let registry = ProviderRegistry::new(all);
|
||||
let tf = registry.find("tinfoil").expect("tinfoil should exist");
|
||||
@@ -517,6 +523,7 @@ mod tests {
|
||||
description: "No setup".to_string(),
|
||||
extra_headers_env: None,
|
||||
setup: None, // no setup hint
|
||||
unsupported_params: vec![],
|
||||
}];
|
||||
|
||||
let registry = ProviderRegistry::new(providers.clone());
|
||||
@@ -546,6 +553,7 @@ mod tests {
|
||||
can_list_models: false,
|
||||
models_filter: None,
|
||||
}),
|
||||
unsupported_params: vec![],
|
||||
});
|
||||
|
||||
let registry = ProviderRegistry::new(providers);
|
||||
@@ -587,6 +595,7 @@ mod tests {
|
||||
can_list_models: false,
|
||||
models_filter: None,
|
||||
}),
|
||||
unsupported_params: vec![],
|
||||
},
|
||||
// User override removes setup
|
||||
ProviderDefinition {
|
||||
@@ -603,6 +612,7 @@ mod tests {
|
||||
description: "No setup now".to_string(),
|
||||
extra_headers_env: None,
|
||||
setup: None,
|
||||
unsupported_params: vec![],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -640,6 +650,7 @@ mod tests {
|
||||
display_name: "A".to_string(),
|
||||
can_list_models: false,
|
||||
}),
|
||||
unsupported_params: vec![],
|
||||
},
|
||||
ProviderDefinition {
|
||||
id: "bbb".to_string(),
|
||||
@@ -658,6 +669,7 @@ mod tests {
|
||||
display_name: "B".to_string(),
|
||||
can_list_models: false,
|
||||
}),
|
||||
unsupported_params: vec![],
|
||||
},
|
||||
ProviderDefinition {
|
||||
id: "ccc".to_string(),
|
||||
@@ -676,6 +688,7 @@ mod tests {
|
||||
display_name: "C".to_string(),
|
||||
can_list_models: false,
|
||||
}),
|
||||
unsupported_params: vec![],
|
||||
},
|
||||
// User override for B
|
||||
ProviderDefinition {
|
||||
@@ -695,6 +708,7 @@ mod tests {
|
||||
display_name: "B".to_string(),
|
||||
can_list_models: false,
|
||||
}),
|
||||
unsupported_params: vec![],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -708,6 +722,48 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unsupported_params_deserialized() {
|
||||
let providers: Vec<ProviderDefinition> =
|
||||
serde_json::from_str(include_str!("../../providers.json")).unwrap();
|
||||
|
||||
// Tinfoil should have temperature in unsupported_params
|
||||
let tinfoil = providers.iter().find(|p| p.id == "tinfoil").unwrap();
|
||||
assert!(
|
||||
tinfoil
|
||||
.unsupported_params
|
||||
.contains(&"temperature".to_string()),
|
||||
"tinfoil should have 'temperature' in unsupported_params"
|
||||
);
|
||||
|
||||
// OpenAI should also have temperature in unsupported_params
|
||||
let openai = providers.iter().find(|p| p.id == "openai").unwrap();
|
||||
assert!(
|
||||
openai
|
||||
.unsupported_params
|
||||
.contains(&"temperature".to_string()),
|
||||
"openai should have 'temperature' in unsupported_params"
|
||||
);
|
||||
|
||||
// Providers without the field in JSON should deserialize to empty vec
|
||||
let groq = providers.iter().find(|p| p.id == "groq").unwrap();
|
||||
assert!(
|
||||
groq.unsupported_params.is_empty(),
|
||||
"groq should have empty unsupported_params (field absent in JSON)"
|
||||
);
|
||||
|
||||
// Every non-empty entry should contain valid param names
|
||||
for def in &providers {
|
||||
for param in &def.unsupported_params {
|
||||
assert!(
|
||||
!param.is_empty(),
|
||||
"{}: unsupported_params contains empty string",
|
||||
def.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_builtin_api_key_providers_have_api_key_env() {
|
||||
// Every built-in provider with SetupHint::ApiKey must have api_key_env
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+146
-4
@@ -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,
|
||||
@@ -42,6 +42,9 @@ pub struct RigAdapter<M: CompletionModel> {
|
||||
/// via `additional_params` for Anthropic automatic caching. Also controls
|
||||
/// the cost multiplier for cache-creation tokens.
|
||||
cache_retention: CacheRetention,
|
||||
/// Parameter names that this provider does not support (e.g., `"temperature"`).
|
||||
/// These are stripped from requests before sending to avoid 400 errors.
|
||||
unsupported_params: HashSet<String>,
|
||||
}
|
||||
|
||||
impl<M: CompletionModel> RigAdapter<M> {
|
||||
@@ -56,6 +59,7 @@ impl<M: CompletionModel> RigAdapter<M> {
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_retention: CacheRetention::None,
|
||||
unsupported_params: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +88,44 @@ impl<M: CompletionModel> RigAdapter<M> {
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the list of unsupported parameter names for this provider.
|
||||
///
|
||||
/// Parameters in this set are stripped from requests before sending.
|
||||
/// Supported parameter names: `"temperature"`, `"max_tokens"`, `"stop_sequences"`.
|
||||
pub fn with_unsupported_params(mut self, params: Vec<String>) -> Self {
|
||||
self.unsupported_params = params.into_iter().collect();
|
||||
self
|
||||
}
|
||||
|
||||
/// Strip unsupported fields from a `CompletionRequest` in place.
|
||||
fn strip_unsupported_completion_params(&self, req: &mut CompletionRequest) {
|
||||
if self.unsupported_params.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.unsupported_params.contains("temperature") {
|
||||
req.temperature = None;
|
||||
}
|
||||
if self.unsupported_params.contains("max_tokens") {
|
||||
req.max_tokens = None;
|
||||
}
|
||||
if self.unsupported_params.contains("stop_sequences") {
|
||||
req.stop_sequences = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip unsupported fields from a `ToolCompletionRequest` in place.
|
||||
fn strip_unsupported_tool_params(&self, req: &mut ToolCompletionRequest) {
|
||||
if self.unsupported_params.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.unsupported_params.contains("temperature") {
|
||||
req.temperature = None;
|
||||
}
|
||||
if self.unsupported_params.contains("max_tokens") {
|
||||
req.max_tokens = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Type conversion helpers --
|
||||
@@ -539,7 +581,10 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
||||
async fn complete(
|
||||
&self,
|
||||
mut request: CompletionRequest,
|
||||
) -> Result<CompletionResponse, LlmError> {
|
||||
if let Some(requested_model) = request.model.as_deref()
|
||||
&& requested_model != self.model_name.as_str()
|
||||
{
|
||||
@@ -550,6 +595,8 @@ where
|
||||
);
|
||||
}
|
||||
|
||||
self.strip_unsupported_completion_params(&mut request);
|
||||
|
||||
let mut messages = request.messages;
|
||||
crate::llm::provider::sanitize_tool_messages(&mut messages);
|
||||
let (preamble, history) = convert_messages(&messages);
|
||||
@@ -599,7 +646,7 @@ where
|
||||
|
||||
async fn complete_with_tools(
|
||||
&self,
|
||||
request: ToolCompletionRequest,
|
||||
mut request: ToolCompletionRequest,
|
||||
) -> Result<ToolCompletionResponse, LlmError> {
|
||||
if let Some(requested_model) = request.model.as_deref()
|
||||
&& requested_model != self.model_name.as_str()
|
||||
@@ -611,6 +658,8 @@ where
|
||||
);
|
||||
}
|
||||
|
||||
self.strip_unsupported_tool_params(&mut request);
|
||||
|
||||
let known_tool_names: HashSet<String> =
|
||||
request.tools.iter().map(|t| t.name.clone()).collect();
|
||||
|
||||
@@ -1156,4 +1205,97 @@ mod tests {
|
||||
assert!(!supports_prompt_cache("gpt-4o"));
|
||||
assert!(!supports_prompt_cache("llama3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_unsupported_params_populates_set() {
|
||||
use rig::client::CompletionClient;
|
||||
use rig::providers::openai;
|
||||
|
||||
let client: openai::Client = openai::Client::builder()
|
||||
.api_key("test-key")
|
||||
.base_url("http://localhost:0")
|
||||
.build()
|
||||
.unwrap();
|
||||
let client = client.completions_api();
|
||||
let model = client.completion_model("test-model");
|
||||
let adapter = RigAdapter::new(model, "test-model")
|
||||
.with_unsupported_params(vec!["temperature".to_string()]);
|
||||
|
||||
assert!(adapter.unsupported_params.contains("temperature"));
|
||||
assert!(!adapter.unsupported_params.contains("max_tokens"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_unsupported_completion_params() {
|
||||
use rig::client::CompletionClient;
|
||||
use rig::providers::openai;
|
||||
|
||||
let client: openai::Client = openai::Client::builder()
|
||||
.api_key("test-key")
|
||||
.base_url("http://localhost:0")
|
||||
.build()
|
||||
.unwrap();
|
||||
let client = client.completions_api();
|
||||
let model = client.completion_model("test-model");
|
||||
let adapter = RigAdapter::new(model, "test-model").with_unsupported_params(vec![
|
||||
"temperature".to_string(),
|
||||
"stop_sequences".to_string(),
|
||||
]);
|
||||
|
||||
let mut req = CompletionRequest::new(vec![ChatMessage::user("hi")]);
|
||||
req.temperature = Some(0.7);
|
||||
req.max_tokens = Some(100);
|
||||
req.stop_sequences = Some(vec!["STOP".to_string()]);
|
||||
|
||||
adapter.strip_unsupported_completion_params(&mut req);
|
||||
|
||||
assert!(req.temperature.is_none(), "temperature should be stripped");
|
||||
assert_eq!(req.max_tokens, Some(100), "max_tokens should be preserved");
|
||||
assert!(
|
||||
req.stop_sequences.is_none(),
|
||||
"stop_sequences should be stripped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_unsupported_tool_params() {
|
||||
use rig::client::CompletionClient;
|
||||
use rig::providers::openai;
|
||||
|
||||
let client: openai::Client = openai::Client::builder()
|
||||
.api_key("test-key")
|
||||
.base_url("http://localhost:0")
|
||||
.build()
|
||||
.unwrap();
|
||||
let client = client.completions_api();
|
||||
let model = client.completion_model("test-model");
|
||||
let adapter = RigAdapter::new(model, "test-model")
|
||||
.with_unsupported_params(vec!["temperature".to_string(), "max_tokens".to_string()]);
|
||||
|
||||
let mut req = ToolCompletionRequest::new(vec![ChatMessage::user("hi")], vec![]);
|
||||
req.temperature = Some(0.5);
|
||||
req.max_tokens = Some(200);
|
||||
|
||||
adapter.strip_unsupported_tool_params(&mut req);
|
||||
|
||||
assert!(req.temperature.is_none(), "temperature should be stripped");
|
||||
assert!(req.max_tokens.is_none(), "max_tokens should be stripped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unsupported_params_empty_by_default() {
|
||||
use rig::client::CompletionClient;
|
||||
use rig::providers::openai;
|
||||
|
||||
let client: openai::Client = openai::Client::builder()
|
||||
.api_key("test-key")
|
||||
.base_url("http://localhost:0")
|
||||
.build()
|
||||
.unwrap();
|
||||
let client = client.completions_api();
|
||||
let model = client.completion_model("test-model");
|
||||
let adapter = RigAdapter::new(model, "test-model");
|
||||
|
||||
assert!(adapter.unsupported_params.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
+14
-26
@@ -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(),
|
||||
@@ -690,13 +686,6 @@ 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 {
|
||||
@@ -737,7 +726,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()
|
||||
}
|
||||
|
||||
+24
-3
@@ -1,6 +1,7 @@
|
||||
//! IronClaw - Main entry point.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use clap::Parser;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
@@ -25,8 +26,8 @@ use ironclaw::{
|
||||
hooks::bootstrap_hooks,
|
||||
llm::create_session_manager,
|
||||
orchestrator::{
|
||||
ContainerJobConfig, ContainerJobManager, OrchestratorApi, TokenStore,
|
||||
api::OrchestratorState,
|
||||
ContainerJobConfig, ContainerJobManager, OrchestratorApi, ReaperConfig, SandboxReaper,
|
||||
TokenStore, api::OrchestratorState,
|
||||
},
|
||||
pairing::PairingStore,
|
||||
secrets::SecretsStore,
|
||||
@@ -676,6 +677,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 +718,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);
|
||||
@@ -1131,7 +1152,7 @@ fn check_onboard_needed() -> Option<&'static str> {
|
||||
}
|
||||
|
||||
if std::env::var("NEARAI_API_KEY").is_err() {
|
||||
let session_path = ironclaw::llm::session::default_session_path();
|
||||
let session_path = ironclaw::config::default_session_path();
|
||||
if !session_path.exists() {
|
||||
return Some("First run");
|
||||
}
|
||||
|
||||
@@ -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,11 @@
|
||||
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};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-5
@@ -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};
|
||||
@@ -992,7 +992,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))
|
||||
};
|
||||
|
||||
@@ -1588,7 +1591,7 @@ impl SetupWizard {
|
||||
backend: "nearai".to_string(),
|
||||
session: crate::llm::session::SessionConfig {
|
||||
auth_base_url,
|
||||
session_path: crate::llm::session::default_session_path(),
|
||||
session_path: crate::config::llm::default_session_path(),
|
||||
},
|
||||
nearai: crate::config::NearAiConfig {
|
||||
model: "dummy".to_string(),
|
||||
@@ -2552,7 +2555,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,
|
||||
@@ -2861,7 +2864,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() {
|
||||
@@ -3784,6 +3787,7 @@ mod tests {
|
||||
description: "Custom provider with no setup wizard".to_string(),
|
||||
extra_headers_env: None,
|
||||
setup: None,
|
||||
unsupported_params: vec![],
|
||||
});
|
||||
let registry = crate::llm::ProviderRegistry::new(providers);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user