mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 23:16:26 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e62d71567 |
@@ -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,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,8 +0,0 @@
|
||||
target/
|
||||
.git/
|
||||
.env
|
||||
.env.*
|
||||
*.md
|
||||
!CLAUDE.md
|
||||
node_modules/
|
||||
tools-src/
|
||||
+8
-43
@@ -1,44 +1,15 @@
|
||||
# Database Configuration
|
||||
DATABASE_URL=postgres://localhost/ironclaw
|
||||
DATABASE_URL=postgres://ironclaw:password@localhost:5432/ironclaw
|
||||
DATABASE_POOL_SIZE=10
|
||||
|
||||
# LLM Provider
|
||||
# LLM_BACKEND=nearai # default
|
||||
# Possible values: nearai, ollama, openai_compatible, openai, anthropic, tinfoil
|
||||
|
||||
# === 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 ~/.near-agent/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
|
||||
|
||||
# === OpenRouter (via OpenAI-compatible) ===
|
||||
# LLM_MODEL=anthropic/claude-sonnet-4
|
||||
# LLM_BACKEND=openai_compatible
|
||||
# LLM_BASE_URL=https://openrouter.ai/api/v1
|
||||
# LLM_API_KEY=sk-or-...
|
||||
|
||||
# NEARAI_SESSION_PATH=~/.near-agent/session.json # optional, default shown
|
||||
|
||||
# Channel Configuration
|
||||
# CLI is always enabled
|
||||
@@ -75,12 +46,6 @@ 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_RETENTION_DAYS=30 # delete daily/ 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
|
||||
|
||||
@@ -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,71 +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 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,22 +0,0 @@
|
||||
name: Code Style
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
codestyle:
|
||||
name: Code Style (fmt + clippy)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- 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,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,67 +0,0 @@
|
||||
name: Release-plz
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
|
||||
# Release unpublished packages.
|
||||
release-plz-release:
|
||||
if: ${{ github.repository_owner == 'nearai' }}
|
||||
name: Release-plz release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- &checkout
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- &install-rust
|
||||
name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
# Generating a GitHub token, so that PRs and tags created by
|
||||
# the release-plz-action can trigger actions workflows.
|
||||
- name: Generate GitHub token
|
||||
uses: actions/create-github-app-token@v2
|
||||
id: generate-token
|
||||
with:
|
||||
# GitHub App ID secret name
|
||||
app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
|
||||
# GitHub App private key secret name
|
||||
private-key: ${{ secrets.GH_RELEASES_MANAGER_APP_PRIVATE_KEY }}
|
||||
- name: Run release-plz
|
||||
uses: release-plz/[email protected]
|
||||
with:
|
||||
command: release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }}
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||
|
||||
# Create a PR with the new versions and changelog, preparing the next release.
|
||||
release-plz-pr:
|
||||
if: ${{ github.repository_owner == 'nearai' }}
|
||||
name: Release-plz PR
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
concurrency:
|
||||
group: release-plz-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- *checkout
|
||||
- *install-rust
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Run release-plz
|
||||
uses: release-plz/[email protected]
|
||||
with:
|
||||
command: release-pr
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||
@@ -1,299 +0,0 @@
|
||||
# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist
|
||||
#
|
||||
# Copyright 2022-2024, axodotdev
|
||||
# SPDX-License-Identifier: MIT or Apache-2.0
|
||||
#
|
||||
# CI that:
|
||||
#
|
||||
# * checks for a Git Tag that looks like a release
|
||||
# * builds artifacts with dist (archives, installers, hashes)
|
||||
# * uploads those artifacts to temporary workflow zip
|
||||
# * on success, uploads the artifacts to a GitHub Release
|
||||
#
|
||||
# Note that the GitHub Release will be created with a generated
|
||||
# title/body based on your changelogs.
|
||||
|
||||
name: Release
|
||||
permissions:
|
||||
"contents": "write"
|
||||
|
||||
# This task will run whenever you push a git tag that looks like a version
|
||||
# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc.
|
||||
# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where
|
||||
# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION
|
||||
# must be a Cargo-style SemVer Version (must have at least major.minor.patch).
|
||||
#
|
||||
# If PACKAGE_NAME is specified, then the announcement will be for that
|
||||
# package (erroring out if it doesn't have the given version or isn't dist-able).
|
||||
#
|
||||
# If PACKAGE_NAME isn't specified, then the announcement will be for all
|
||||
# (dist-able) packages in the workspace with that version (this mode is
|
||||
# intended for workspaces with only one dist-able package, or with all dist-able
|
||||
# packages versioned/released in lockstep).
|
||||
#
|
||||
# If you push multiple tags at once, separate instances of this workflow will
|
||||
# spin up, creating an independent announcement for each one. However, GitHub
|
||||
# will hard limit this to 3 tags per commit, as it will assume more tags is a
|
||||
# mistake.
|
||||
#
|
||||
# If there's a prerelease-style suffix to the version, then the release(s)
|
||||
# will be marked as a prerelease.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '**[0-9]+.[0-9]+.[0-9]+*'
|
||||
|
||||
jobs:
|
||||
# Run 'dist plan' (or host) to determine what tasks we need to do
|
||||
plan:
|
||||
runs-on: "ubuntu-22.04"
|
||||
outputs:
|
||||
val: ${{ steps.plan.outputs.manifest }}
|
||||
tag: ${{ !github.event.pull_request && github.ref_name || '' }}
|
||||
tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }}
|
||||
publishing: ${{ !github.event.pull_request }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Install dist
|
||||
# we specify bash to get pipefail; it guards against the `curl` command
|
||||
# failing. otherwise `sh` won't catch that `curl` returned non-0
|
||||
shell: bash
|
||||
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.30.3/cargo-dist-installer.sh | sh"
|
||||
- name: Cache dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cargo-dist-cache
|
||||
path: ~/.cargo/bin/dist
|
||||
# sure would be cool if github gave us proper conditionals...
|
||||
# so here's a doubly-nested ternary-via-truthiness to try to provide the best possible
|
||||
# functionality based on whether this is a pull_request, and whether it's from a fork.
|
||||
# (PRs run on the *source* but secrets are usually on the *target* -- that's *good*
|
||||
# but also really annoying to build CI around when it needs secrets to work right.)
|
||||
- id: plan
|
||||
run: |
|
||||
dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json
|
||||
echo "dist ran successfully"
|
||||
cat plan-dist-manifest.json
|
||||
echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT"
|
||||
- name: "Upload dist-manifest.json"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: artifacts-plan-dist-manifest
|
||||
path: plan-dist-manifest.json
|
||||
|
||||
# Build and packages all the platform-specific things
|
||||
build-local-artifacts:
|
||||
name: build-local-artifacts (${{ join(matrix.targets, ', ') }})
|
||||
# Let the initial task tell us to not run (currently very blunt)
|
||||
needs:
|
||||
- plan
|
||||
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.
|
||||
# Each member of the matrix has the following arguments:
|
||||
#
|
||||
# - runner: the github runner
|
||||
# - dist-args: cli flags to pass to dist
|
||||
# - install-dist: expression to run to install dist on the runner
|
||||
#
|
||||
# Typically there will be:
|
||||
# - 1 "global" task that builds universal installers
|
||||
# - N "local" tasks that build each platform's binaries and platform-specific installers
|
||||
matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
container: ${{ matrix.container && matrix.container.image || null }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json
|
||||
steps:
|
||||
- name: enable windows longpaths
|
||||
run: |
|
||||
git config --global core.longpaths true
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Install Rust non-interactively if not already installed
|
||||
if: ${{ matrix.container }}
|
||||
run: |
|
||||
if ! command -v cargo > /dev/null 2>&1; then
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||
fi
|
||||
- uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
key: ${{ join(matrix.targets, '-') }}
|
||||
cache-provider: ${{ matrix.cache_provider }}
|
||||
- name: Install dist
|
||||
run: ${{ matrix.install_dist.run }}
|
||||
# Get the dist-manifest
|
||||
- name: Fetch local artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: target/distrib/
|
||||
merge-multiple: true
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
${{ matrix.packages_install }}
|
||||
- name: Build artifacts
|
||||
run: |
|
||||
# Actually do builds and make zips and whatnot
|
||||
dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json
|
||||
echo "dist ran successfully"
|
||||
- id: cargo-dist
|
||||
name: Post-build
|
||||
# We force bash here just because github makes it really hard to get values up
|
||||
# to "real" actions without writing to env-vars, and writing to env-vars has
|
||||
# inconsistent syntax between shell and powershell.
|
||||
shell: bash
|
||||
run: |
|
||||
# Parse out what we just built and upload it to scratch storage
|
||||
echo "paths<<EOF" >> "$GITHUB_OUTPUT"
|
||||
dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT"
|
||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
|
||||
- name: "Upload artifacts"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: artifacts-build-local-${{ join(matrix.targets, '_') }}
|
||||
path: |
|
||||
${{ steps.cargo-dist.outputs.paths }}
|
||||
${{ env.BUILD_MANIFEST_NAME }}
|
||||
|
||||
# Build and package all the platform-agnostic(ish) things
|
||||
build-global-artifacts:
|
||||
needs:
|
||||
- plan
|
||||
- build-local-artifacts
|
||||
runs-on: "ubuntu-22.04"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Install cached dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: cargo-dist-cache
|
||||
path: ~/.cargo/bin/
|
||||
- run: chmod +x ~/.cargo/bin/dist
|
||||
# Get all the local artifacts for the global tasks to use (for e.g. checksums)
|
||||
- name: Fetch local artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: target/distrib/
|
||||
merge-multiple: true
|
||||
- id: cargo-dist
|
||||
shell: bash
|
||||
run: |
|
||||
dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json
|
||||
echo "dist ran successfully"
|
||||
|
||||
# Parse out what we just built and upload it to scratch storage
|
||||
echo "paths<<EOF" >> "$GITHUB_OUTPUT"
|
||||
jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT"
|
||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
|
||||
- name: "Upload artifacts"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: artifacts-build-global
|
||||
path: |
|
||||
${{ steps.cargo-dist.outputs.paths }}
|
||||
${{ env.BUILD_MANIFEST_NAME }}
|
||||
# Determines if we should publish/announce
|
||||
host:
|
||||
needs:
|
||||
- plan
|
||||
- build-local-artifacts
|
||||
- build-global-artifacts
|
||||
# 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"
|
||||
outputs:
|
||||
val: ${{ steps.host.outputs.manifest }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Install cached dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: cargo-dist-cache
|
||||
path: ~/.cargo/bin/
|
||||
- run: chmod +x ~/.cargo/bin/dist
|
||||
# Fetch artifacts from scratch-storage
|
||||
- name: Fetch artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: target/distrib/
|
||||
merge-multiple: true
|
||||
- id: host
|
||||
shell: bash
|
||||
run: |
|
||||
dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json
|
||||
echo "artifacts uploaded and released successfully"
|
||||
cat dist-manifest.json
|
||||
echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT"
|
||||
- name: "Upload dist-manifest.json"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
# Overwrite the previous copy
|
||||
name: artifacts-dist-manifest
|
||||
path: dist-manifest.json
|
||||
# Create a GitHub Release while uploading all files to it
|
||||
- name: "Download GitHub Artifacts"
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
- name: Cleanup
|
||||
run: |
|
||||
# Remove the granular manifests
|
||||
rm -f artifacts/*-dist-manifest.json
|
||||
- name: Create GitHub Release
|
||||
env:
|
||||
PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}"
|
||||
ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}"
|
||||
ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}"
|
||||
RELEASE_COMMIT: "${{ github.sha }}"
|
||||
run: |
|
||||
# Write and read notes from a file to avoid quoting breaking things
|
||||
echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt
|
||||
|
||||
gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/*
|
||||
|
||||
announce:
|
||||
needs:
|
||||
- plan
|
||||
- host
|
||||
# use "always() && ..." to allow us to wait for all publish jobs while
|
||||
# still allowing individual publish jobs to skip themselves (for prereleases).
|
||||
# "host" however must run to completion, no skipping allowed!
|
||||
if: ${{ always() && needs.host.result == 'success' }}
|
||||
runs-on: "ubuntu-22.04"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
@@ -1,21 +0,0 @@
|
||||
name: Run Tests
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
name: Run Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- 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
|
||||
-15
@@ -1,21 +1,6 @@
|
||||
|
||||
.env
|
||||
.env.local
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Claude Code worktrees
|
||||
.claude/worktrees/
|
||||
|
||||
# Sidecar tool data
|
||||
.sidecar/
|
||||
.todos/
|
||||
|
||||
target/
|
||||
|
||||
# Benchmark results (local runs, not committed)
|
||||
bench-results/
|
||||
|
||||
# WASM build artifacts (loaded from disk, not bundled)
|
||||
*.wasm
|
||||
|
||||
|
||||
-268
@@ -1,268 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
- Enabled builds caching during CI/CD
|
||||
- Disabled npm publishing as the name is already taken
|
||||
|
||||
## [0.1.2](https://github.com/nearai/ironclaw/compare/v0.1.1...v0.1.2) - 2026-02-12
|
||||
|
||||
### Other
|
||||
|
||||
- Added Installation instructions for the pre-built binaries
|
||||
- Disabled Windows ARM64 builds as auto-updater [provided by cargo-dist] does not support this platform yet and it is not a common platform for us to support
|
||||
|
||||
## [0.1.1](https://github.com/nearai/ironclaw/compare/v0.1.0...v0.1.1) - 2026-02-12
|
||||
|
||||
### Other
|
||||
|
||||
- Renamed the secrets in release-plz.yml to match the configuration
|
||||
- Make sure that the binaries release CD it kicking in after release-plz
|
||||
|
||||
## [0.1.0](https://github.com/nearai/ironclaw/releases/tag/v0.1.0) - 2026-02-12
|
||||
|
||||
### Added
|
||||
|
||||
- Add multi-provider LLM support via rig-core adapter ([#36](https://github.com/nearai/ironclaw/pull/36))
|
||||
- Sandbox jobs ([#4](https://github.com/nearai/ironclaw/pull/4))
|
||||
- Add Google Suite & Telegram WASM tools ([#9](https://github.com/nearai/ironclaw/pull/9))
|
||||
- Improve CLI ([#5](https://github.com/nearai/ironclaw/pull/5))
|
||||
|
||||
### Fixed
|
||||
|
||||
- resolve runtime panic in Linux keychain integration ([#32](https://github.com/nearai/ironclaw/pull/32))
|
||||
|
||||
### Other
|
||||
|
||||
- Skip release-plz on forks
|
||||
- Upgraded release-plz CD pipeline
|
||||
- Added CI/CD and release pipelines ([#45](https://github.com/nearai/ironclaw/pull/45))
|
||||
- DM pairing + Telegram channel improvements ([#17](https://github.com/nearai/ironclaw/pull/17))
|
||||
- Fixes build, adds missing sse event and correct command ([#11](https://github.com/nearai/ironclaw/pull/11))
|
||||
- Codex/feature parity pr hook ([#6](https://github.com/nearai/ironclaw/pull/6))
|
||||
- Add WebSocket gateway and control plane ([#8](https://github.com/nearai/ironclaw/pull/8))
|
||||
- select bundled Telegram channel and auto-install ([#3](https://github.com/nearai/ironclaw/pull/3))
|
||||
- Adding skills for reusable work
|
||||
- Fix MCP tool calls, approval loop, shutdown, and improve web UI
|
||||
- Add auth mode, fix MCP token handling, and parallelize startup loading
|
||||
- Merge remote-tracking branch 'origin/main' into ui
|
||||
- Adding web UI
|
||||
- Rename `setup` CLI command to `onboard` for compatibility
|
||||
- Add in-chat extension discovery, auth, and activation system
|
||||
- Add Telegram typing indicator via WIT on-status callback
|
||||
- Add proactivity features: memory CLI, session pruning, self-repair notifications, slash commands, status diagnostics, context warnings
|
||||
- Add hosted MCP server support with OAuth 2.1 and token refresh
|
||||
- Add interactive setup wizard and persistent settings
|
||||
- Rebrand to IronClaw with security-first mission
|
||||
- Fix build_software tool stuck in planning mode loop
|
||||
- Enable sandbox by default
|
||||
- Fix Telegram Markdown formatting and clarify tool/memory distinctions
|
||||
- Simplify Telegram channel config with host-injected tunnel/webhook settings
|
||||
- Apply Telegram channel learnings to WhatsApp implementation
|
||||
- Merge remote-tracking branch 'origin/main'
|
||||
- Docker file for sandbox
|
||||
- Replace hardcoded intent patterns with job tools
|
||||
- Fix router test to match intentional job creation patterns
|
||||
- Add Docker execution sandbox for secure shell command isolation
|
||||
- Move setup wizard credentials to database storage
|
||||
- Add interactive setup wizard for first-run configuration
|
||||
- Add Telegram Bot API channel as WASM module
|
||||
- Add OpenClaw feature parity tracking matrix
|
||||
- Add Chat Completions API support and expand REPL debugging
|
||||
- Implementing channels to be handled in wasm
|
||||
- Support non interactive mode and model selection
|
||||
- Implement tool approval, fix tool definition refresh, and wire embeddings
|
||||
- Tool use
|
||||
- Wiring more
|
||||
- Add heartbeat integration, planning phase, and auto-repair
|
||||
- Login flow
|
||||
- Extend support for session management
|
||||
- Adding builder capability
|
||||
- Load tools at launch
|
||||
- Fix multiline message rendering in TUI
|
||||
- Parse NEAR AI alternative response format with output field
|
||||
- Handle NEAR AI plain text responses
|
||||
- Disable mouse capture to allow text selection in TUI
|
||||
- Add verbose logging to debug empty NEAR AI responses
|
||||
- Improve NEAR AI response parsing for varying response formats
|
||||
- Show status/thinking messages in chat window, debug empty responses
|
||||
- Add timeout and logging to NEAR AI provider
|
||||
- Add status updates to show agent thinking/processing state
|
||||
- Add CLI subcommands for WASM tool management
|
||||
- Fix TUI shutdown: send /shutdown message and handle in agent loop
|
||||
- Remove SimpleCliChannel, add Ctrl+D twice quit, redirect logs to TUI
|
||||
- Fix TuiChannel integration and enable in main.rs
|
||||
- Integrate Codex patterns: task scheduler, TUI, sessions, compaction
|
||||
- Adding LICENSE
|
||||
- Add README with IronClaw branding
|
||||
- Add WASM sandbox secure API extension
|
||||
- Wire database Store into agent loop
|
||||
- Implementing WASM runtime
|
||||
- Add workspace integration tests
|
||||
- Compact memory_tree output format
|
||||
- Replace memory_list with memory_tree tool
|
||||
- Simplify workspace to path-based storage, remove legacy code
|
||||
- Add NEAR AI chat-api as default LLM provider
|
||||
- Add CLAUDE.md project documentation
|
||||
- Add workspace and memory system (OpenClaw-inspired)
|
||||
- Initial implementation of the agent framework
|
||||
@@ -11,19 +11,11 @@
|
||||
- **Always available** - Multi-channel access with proactive background execution
|
||||
|
||||
### Features
|
||||
- **Multi-channel input**: TUI (Ratatui), HTTP webhooks, WASM channels (Telegram, Slack), web gateway
|
||||
- **Multi-channel input**: TUI (Ratatui), HTTP webhooks, Telegram, WhatsApp, Slack (WASM channels)
|
||||
- **Parallel job execution** with state machine and self-repair for stuck jobs
|
||||
- **Sandbox execution**: Docker container isolation with network proxy and credential injection
|
||||
- **Claude Code mode**: Delegate jobs to Claude CLI inside containers
|
||||
- **Skills system**: SKILL.md prompt extensions with trust model, tool attenuation, and ClawHub registry
|
||||
- **Routines**: Scheduled (cron) and reactive (event, webhook) task execution
|
||||
- **Web gateway**: Browser UI with SSE/WebSocket real-time streaming
|
||||
- **Extension management**: Install, auth, activate MCP/WASM extensions
|
||||
- **Extensible tools**: Built-in tools, WASM sandbox, MCP client, dynamic builder
|
||||
- **Persistent memory**: Workspace with hybrid search (FTS + vector via RRF)
|
||||
- **Prompt injection defense**: Sanitizer, validator, policy rules, leak detection, shell env scrubbing
|
||||
- **Multi-provider LLM**: NEAR AI, OpenAI, Anthropic, Ollama, OpenAI-compatible, Tinfoil private inference
|
||||
- **Setup wizard**: 7-step interactive onboarding for first-run configuration
|
||||
- **Prompt injection defense**: Sanitizer, validator, policy rules, leak detection
|
||||
- **Heartbeat system**: Proactive periodic execution with checklist
|
||||
|
||||
## Build & Test
|
||||
@@ -67,10 +59,7 @@ src/
|
||||
│ ├── context_monitor.rs # Memory pressure detection
|
||||
│ ├── undo.rs # Turn-based undo/redo with checkpoints
|
||||
│ ├── submission.rs # Submission parsing (undo, redo, compact, clear, etc.)
|
||||
│ ├── dispatcher.rs # Skill-aware job dispatching
|
||||
│ ├── task.rs # Sub-task execution framework
|
||||
│ ├── routine.rs # Routine types (Trigger, Action, Guardrails)
|
||||
│ └── routine_engine.rs # Routine execution (cron ticker, event matcher)
|
||||
│ └── task.rs # Sub-task execution framework
|
||||
│
|
||||
├── channels/ # Multi-channel input
|
||||
│ ├── channel.rs # Channel trait, IncomingMessage, OutgoingResponse
|
||||
@@ -83,33 +72,8 @@ src/
|
||||
│ │ ├── overlay.rs # Approval overlays
|
||||
│ │ └── composer.rs # Message composition
|
||||
│ ├── http.rs # HTTP webhook (axum) with secret validation
|
||||
│ ├── repl.rs # Simple REPL (for testing)
|
||||
│ ├── 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
|
||||
│ └── wrapper.rs # Channel trait wrapper for WASM modules
|
||||
│
|
||||
├── orchestrator/ # Internal HTTP API for sandbox containers
|
||||
│ ├── mod.rs
|
||||
│ ├── api.rs # Axum endpoints (LLM proxy, events, prompts)
|
||||
│ ├── auth.rs # Per-job bearer token store
|
||||
│ └── job_manager.rs # Container lifecycle (create, stop, cleanup)
|
||||
│
|
||||
├── worker/ # Runs inside Docker containers
|
||||
│ ├── mod.rs
|
||||
│ ├── runtime.rs # Worker execution loop (tool calls, LLM)
|
||||
│ ├── claude_bridge.rs # Claude Code bridge (spawns claude CLI)
|
||||
│ ├── api.rs # HTTP client to orchestrator
|
||||
│ └── proxy_llm.rs # LlmProvider that proxies through orchestrator
|
||||
│ ├── slack.rs # Stub
|
||||
│ └── telegram.rs # Stub
|
||||
│
|
||||
├── safety/ # Prompt injection defense
|
||||
│ ├── sanitizer.rs # Pattern detection, content escaping
|
||||
@@ -117,18 +81,11 @@ src/
|
||||
│ ├── policy.rs # PolicyRule system with severity/actions
|
||||
│ └── leak_detector.rs # Secret detection (API keys, tokens, etc.)
|
||||
│
|
||||
├── llm/ # LLM integration (multi-provider)
|
||||
│ ├── mod.rs # Provider factory, LlmBackend enum
|
||||
├── llm/ # LLM integration (NEAR AI only)
|
||||
│ ├── provider.rs # LlmProvider trait, message types
|
||||
│ ├── nearai_chat.rs # NEAR AI Chat Completions provider (session token + API key auth)
|
||||
│ ├── nearai.rs # NEAR AI chat-api implementation
|
||||
│ ├── reasoning.rs # Planning, tool selection, evaluation
|
||||
│ ├── session.rs # Session token management with auto-renewal
|
||||
│ ├── circuit_breaker.rs # Circuit breaker for provider failures
|
||||
│ ├── retry.rs # Retry with exponential backoff
|
||||
│ ├── failover.rs # Multi-provider failover chain
|
||||
│ ├── response_cache.rs # LLM response caching
|
||||
│ ├── costs.rs # Token cost tracking
|
||||
│ └── rig_adapter.rs # Rig framework adapter
|
||||
│ └── session.rs # Session token management with auto-renewal
|
||||
│
|
||||
├── tools/ # Extensible tool system
|
||||
│ ├── tool.rs # Tool trait, ToolOutput, ToolError
|
||||
@@ -139,10 +96,6 @@ src/
|
||||
│ │ ├── 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
|
||||
│ │ ├── skill_tools.rs # skill_list/search/install/remove tools
|
||||
│ │ └── marketplace.rs, ecommerce.rs, taskrabbit.rs, restaurant.rs (stubs)
|
||||
│ ├── builder/ # Dynamic tool building
|
||||
│ │ ├── core.rs # BuildRequirement, SoftwareType, Language
|
||||
@@ -163,12 +116,6 @@ src/
|
||||
│ ├── rate_limiter.rs # Per-tool rate limiting
|
||||
│ └── storage.rs # Linear memory persistence
|
||||
│
|
||||
├── 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 (OpenClaw-inspired)
|
||||
│ ├── mod.rs # Workspace struct, memory operations
|
||||
│ ├── document.rs # MemoryDocument, MemoryChunk, WorkspaceEntry
|
||||
@@ -192,38 +139,11 @@ src/
|
||||
│ ├── success.rs # SuccessEvaluator trait, RuleBasedEvaluator, LlmEvaluator
|
||||
│ └── metrics.rs # MetricsCollector, QualityMetrics
|
||||
│
|
||||
├── sandbox/ # Docker execution sandbox
|
||||
│ ├── mod.rs # Public API, default allowlist
|
||||
│ ├── config.rs # SandboxConfig, SandboxPolicy enum
|
||||
│ ├── manager.rs # SandboxManager orchestration
|
||||
│ ├── container.rs # ContainerRunner, Docker lifecycle
|
||||
│ ├── error.rs # SandboxError types
|
||||
│ └── proxy/ # Network proxy for containers
|
||||
│ ├── mod.rs # NetworkProxyBuilder
|
||||
│ ├── http.rs # HttpProxy, CredentialResolver trait
|
||||
│ ├── policy.rs # NetworkPolicyDecider trait
|
||||
│ └── allowlist.rs # DomainAllowlist validation
|
||||
│
|
||||
├── secrets/ # Secrets management
|
||||
│ ├── crypto.rs # AES-256-GCM encryption
|
||||
│ ├── store.rs # Secret storage
|
||||
│ └── types.rs # Credential types
|
||||
│
|
||||
├── setup/ # Onboarding wizard (spec: src/setup/README.md)
|
||||
│ ├── mod.rs # Entry point, check_onboard_needed()
|
||||
│ ├── wizard.rs # 7-step interactive wizard
|
||||
│ ├── channels.rs # Channel setup helpers
|
||||
│ └── prompts.rs # Terminal prompts (select, confirm, secret)
|
||||
│
|
||||
├── skills/ # SKILL.md prompt extension system
|
||||
│ ├── mod.rs # Core types (SkillTrust, LoadedSkill)
|
||||
│ ├── registry.rs # SkillRegistry: discover, install, remove
|
||||
│ ├── selector.rs # Deterministic scoring prefilter
|
||||
│ ├── attenuation.rs # Trust-based tool ceiling
|
||||
│ ├── gating.rs # Requirement checks (bins, env, config)
|
||||
│ ├── parser.rs # SKILL.md frontmatter + markdown parser
|
||||
│ └── catalog.rs # ClawHub registry client
|
||||
│
|
||||
└── history/ # Persistence
|
||||
├── store.rs # PostgreSQL repositories
|
||||
└── analytics.rs # Aggregation queries (JobStats, ToolStats)
|
||||
@@ -237,9 +157,8 @@ When designing new features or systems, always prefer generic/extensible archite
|
||||
|
||||
### Error Handling
|
||||
- Use `thiserror` for error types in `error.rs`
|
||||
- Never use `.unwrap()` or `.expect()` in production code (tests are fine)
|
||||
- Never use `.unwrap()` 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
|
||||
|
||||
### Async
|
||||
- All I/O is async with tokio
|
||||
@@ -247,13 +166,11 @@ When designing new features or systems, always prefer generic/extensible archite
|
||||
- Use `RwLock` for concurrent read/write access
|
||||
|
||||
### 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)
|
||||
- `NetworkPolicyDecider` - Custom network access policies for sandbox containers
|
||||
|
||||
### Tool Implementation
|
||||
```rust
|
||||
@@ -292,59 +209,16 @@ Pending -> InProgress -> Completed -> Submitted -> Accepted
|
||||
\-> Failed
|
||||
```
|
||||
|
||||
### Code Style
|
||||
|
||||
- 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/`
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables (see `.env.example`):
|
||||
```bash
|
||||
# Database backend (default: postgres)
|
||||
DATABASE_BACKEND=postgres # or "libsql" / "turso"
|
||||
DATABASE_URL=postgres://user:pass@localhost/ironclaw
|
||||
LIBSQL_PATH=~/.ironclaw/ironclaw.db # libSQL local path (default)
|
||||
# LIBSQL_URL=libsql://xxx.turso.io # Turso cloud (optional)
|
||||
# LIBSQL_AUTH_TOKEN=xxx # Required with LIBSQL_URL
|
||||
|
||||
# NEAR AI (when LLM_BACKEND=nearai, the default)
|
||||
# Two auth modes: session token (default) or API key
|
||||
# Session token auth (default): uses browser OAuth on first run
|
||||
NEARAI_SESSION_TOKEN=sess_... # hosting providers: set this
|
||||
NEARAI_BASE_URL=https://private.near.ai
|
||||
# API key auth: set NEARAI_API_KEY, base URL defaults to cloud-api.near.ai
|
||||
# NEARAI_API_KEY=... # API key from cloud.near.ai
|
||||
# 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
|
||||
@@ -362,101 +236,20 @@ HEARTBEAT_ENABLED=true
|
||||
HEARTBEAT_INTERVAL_SECS=1800 # 30 minutes
|
||||
HEARTBEAT_NOTIFY_CHANNEL=tui
|
||||
HEARTBEAT_NOTIFY_USER=default
|
||||
|
||||
# Web gateway
|
||||
GATEWAY_ENABLED=true
|
||||
GATEWAY_HOST=127.0.0.1
|
||||
GATEWAY_PORT=3001
|
||||
GATEWAY_AUTH_TOKEN=changeme # Required for API access
|
||||
GATEWAY_USER_ID=default
|
||||
|
||||
# Docker sandbox
|
||||
SANDBOX_ENABLED=true
|
||||
SANDBOX_IMAGE=ironclaw-worker:latest
|
||||
SANDBOX_MEMORY_LIMIT_MB=512
|
||||
SANDBOX_TIMEOUT_SECS=1800
|
||||
SANDBOX_CPU_LIMIT=1.0 # CPU cores per container
|
||||
SANDBOX_NETWORK_PROXY=true # Enable network proxy for containers
|
||||
SANDBOX_PROXY_PORT=8080 # Proxy listener port
|
||||
SANDBOX_DEFAULT_POLICY=workspace_write # ReadOnly, WorkspaceWrite, FullAccess
|
||||
|
||||
# Claude Code mode (runs inside sandbox containers)
|
||||
CLAUDE_CODE_ENABLED=false
|
||||
CLAUDE_CODE_MODEL=claude-sonnet-4-20250514
|
||||
CLAUDE_CODE_MAX_TURNS=50
|
||||
CLAUDE_CODE_CONFIG_DIR=/home/worker/.claude
|
||||
|
||||
# Routines (scheduled/reactive execution)
|
||||
ROUTINES_ENABLED=true
|
||||
ROUTINES_CRON_INTERVAL=60 # Tick interval in seconds
|
||||
ROUTINES_MAX_CONCURRENT=3
|
||||
|
||||
# Skills system
|
||||
SKILLS_ENABLED=true
|
||||
SKILLS_MAX_TOKENS=4000 # Max prompt budget per turn
|
||||
SKILLS_CATALOG_URL=https://clawhub.dev # ClawHub registry URL
|
||||
SKILLS_AUTO_DISCOVER=true # Scan skill directories on startup
|
||||
|
||||
# Tinfoil private inference
|
||||
TINFOIL_API_KEY=... # Required when LLM_BACKEND=tinfoil
|
||||
TINFOIL_MODEL=kimi-k2-5 # Default model
|
||||
```
|
||||
|
||||
### LLM Providers
|
||||
### NEAR AI Provider
|
||||
|
||||
IronClaw supports multiple LLM backends via the `LLM_BACKEND` env var: `nearai` (default), `openai`, `anthropic`, `ollama`, `openai_compatible`, and `tinfoil`.
|
||||
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
|
||||
|
||||
**NEAR AI** -- Uses the Chat Completions API with dual auth support. Session token auth (default): authenticates with session tokens (`sess_xxx`) obtained via browser OAuth (GitHub/Google), base URL defaults to `https://private.near.ai`. API key auth: set `NEARAI_API_KEY` (from `cloud.near.ai`), base URL defaults to `https://cloud-api.near.ai`. Both modes use the same Chat Completions endpoint. Tool messages are flattened to plain text for compatibility. Set `NEARAI_SESSION_TOKEN` env var for hosting providers that inject tokens via environment.
|
||||
|
||||
**Tinfoil** -- Private inference via `https://inference.tinfoil.sh/v1`. Runs models inside hardware-attested TEEs so neither Tinfoil nor the cloud provider can see prompts or responses. Uses the OpenAI-compatible Chat Completions API. Configure with `TINFOIL_API_KEY` and `TINFOIL_MODEL` (default: `kimi-k2-5`).
|
||||
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):**
|
||||
Single migration in `migrations/V1__initial.sql`. Tables:
|
||||
|
||||
**Core:**
|
||||
- `conversations` - Multi-channel conversation tracking
|
||||
@@ -468,26 +261,12 @@ Both backends implement this trait. PostgreSQL delegates to the existing `Store`
|
||||
|
||||
**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
|
||||
- `memory_chunks` - Chunked content with FTS (tsvector) and vector (pgvector) 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
|
||||
Requires pgvector extension: `CREATE EXTENSION IF NOT EXISTS vector;`
|
||||
|
||||
Database configuration: see Configuration section above.
|
||||
|
||||
### 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.
|
||||
Run migrations: `refinery migrate -c refinery.toml`
|
||||
|
||||
## Safety Layer
|
||||
|
||||
@@ -495,7 +274,6 @@ All external tool output passes through `SafetyLayer`:
|
||||
1. **Sanitizer** - Detects injection patterns, escapes dangerous content
|
||||
2. **Validator** - Checks length, encoding, forbidden patterns
|
||||
3. **Policy** - Rules with severity (Critical/High/Medium/Low) and actions (Block/Warn/Review/Sanitize)
|
||||
4. **Leak Detector** - Scans for 15+ secret patterns (API keys, tokens, private keys, connection strings) at two points: tool output before it reaches the LLM, and LLM responses before they reach the user. Actions per pattern: Block (reject entirely), Redact (mask the secret), or Warn (flag but allow)
|
||||
|
||||
Tool outputs are wrapped before reaching LLM:
|
||||
```xml
|
||||
@@ -504,95 +282,6 @@ Tool outputs are wrapped before reaching LLM:
|
||||
</tool_output>
|
||||
```
|
||||
|
||||
### Shell Environment Scrubbing
|
||||
|
||||
The shell tool (`src/tools/builtin/shell.rs`) scrubs sensitive environment variables before executing commands, preventing secrets from leaking through `env`, `printenv`, or `$VAR` expansion. The sanitizer (`src/safety/sanitizer.rs`) also detects command injection patterns (chained commands, subshells, path traversal) and blocks or escapes them based on policy rules.
|
||||
|
||||
## Skills System
|
||||
|
||||
Skills are SKILL.md files that extend the agent's prompt with domain-specific instructions. Each skill is a YAML frontmatter block (metadata, activation criteria, required tools) followed by a markdown body that gets injected into the LLM context when the skill activates.
|
||||
|
||||
### Trust Model
|
||||
|
||||
| Trust Level | Source | Tool Access |
|
||||
|-------------|--------|-------------|
|
||||
| **Trusted** | User-placed in `~/.ironclaw/skills/` or workspace `skills/` | All tools available to the agent |
|
||||
| **Installed** | Downloaded from ClawHub registry | Read-only tools only (no shell, file write, HTTP) |
|
||||
|
||||
### SKILL.md Format
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: my-skill
|
||||
version: 0.1.0
|
||||
description: Does something useful
|
||||
activation:
|
||||
patterns:
|
||||
- "deploy to.*production"
|
||||
keywords:
|
||||
- "deployment"
|
||||
max_context_tokens: 2000
|
||||
metadata:
|
||||
openclaw:
|
||||
requires:
|
||||
bins: [docker, kubectl]
|
||||
env: [KUBECONFIG]
|
||||
---
|
||||
|
||||
# Deployment Skill
|
||||
|
||||
Instructions for the agent when this skill activates...
|
||||
```
|
||||
|
||||
### Selection Pipeline
|
||||
|
||||
1. **Gating** -- Check binary/env/config requirements; skip skills whose prerequisites are missing
|
||||
2. **Scoring** -- Deterministic scoring against message content using keywords, tags, and regex patterns
|
||||
3. **Budget** -- Select top-scoring skills that fit within `SKILLS_MAX_TOKENS` prompt budget
|
||||
4. **Attenuation** -- Apply trust-based tool ceiling; installed skills lose access to dangerous tools
|
||||
|
||||
### Skill Tools
|
||||
|
||||
Four built-in tools for managing skills at runtime:
|
||||
- **`skill_list`** -- List all discovered skills with trust level and status
|
||||
- **`skill_search`** -- Search ClawHub registry for available skills
|
||||
- **`skill_install`** -- Download and install a skill from ClawHub
|
||||
- **`skill_remove`** -- Remove an installed skill
|
||||
|
||||
### Skill Directories
|
||||
|
||||
- `~/.ironclaw/skills/` -- User's global skills (trusted)
|
||||
- `<workspace>/skills/` -- Per-workspace skills (trusted)
|
||||
- `~/.ironclaw/installed_skills/` -- Registry-installed skills (installed trust)
|
||||
|
||||
Skills configuration: see Configuration section above.
|
||||
|
||||
## Docker Sandbox
|
||||
|
||||
The `src/sandbox/` module provides Docker-based isolation for job execution with a network proxy that controls outbound access and injects credentials.
|
||||
|
||||
### Sandbox Policies
|
||||
|
||||
| Policy | Filesystem | Network | Use Case |
|
||||
|--------|-----------|---------|----------|
|
||||
| **ReadOnly** | Read-only workspace mount | Allowlisted domains only | Analysis, code review |
|
||||
| **WorkspaceWrite** | Read-write workspace mount | Allowlisted domains only | Code generation, file edits |
|
||||
| **FullAccess** | Full filesystem | Unrestricted | Trusted admin tasks |
|
||||
|
||||
### Network Proxy
|
||||
|
||||
Containers route all HTTP/HTTPS traffic through a host-side proxy (`src/sandbox/proxy/`):
|
||||
- **Domain allowlist** -- Only allowlisted domains are reachable (default: package registries, docs sites, GitHub, common APIs)
|
||||
- **Credential injection** -- The `CredentialResolver` trait injects auth headers into proxied requests so secrets never enter the container environment
|
||||
- **CONNECT tunnel** -- HTTPS traffic uses CONNECT method; the proxy validates the target domain against the allowlist before establishing the tunnel
|
||||
- **Policy decisions** -- The `NetworkPolicyDecider` trait allows custom logic for allow/deny/inject decisions per request
|
||||
|
||||
### Zero-Exposure Credential Model
|
||||
|
||||
Secrets (API keys, tokens) are stored encrypted on the host and injected into HTTP requests by the proxy at transit time. Container processes never have access to raw credential values, preventing exfiltration even if container code is compromised.
|
||||
|
||||
Sandbox configuration: see Configuration section above.
|
||||
|
||||
## Testing
|
||||
|
||||
Tests are in `mod tests {}` blocks at the bottom of each file. Run specific module tests:
|
||||
@@ -608,22 +297,164 @@ Key test patterns:
|
||||
|
||||
## 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
|
||||
1. **Slack/Telegram channels** - Stubs only, need implementation
|
||||
2. **Domain-specific tools** - `marketplace.rs`, `restaurant.rs`, `taskrabbit.rs`, `ecommerce.rs` return placeholder responses; need real API integrations
|
||||
3. **Integration tests** - Need testcontainers setup for PostgreSQL
|
||||
4. **MCP stdio transport** - Only HTTP transport implemented
|
||||
5. **WIT bindgen integration** - Auto-extract tool description/schema from WASM modules (stubbed)
|
||||
6. **Capability granting after tool build** - Built tools get empty capabilities; need UX for granting HTTP/secrets access
|
||||
7. **Tool versioning workflow** - No version tracking or rollback for dynamically built tools
|
||||
|
||||
## Tool Architecture
|
||||
### Completed
|
||||
|
||||
**Keep tool-specific logic out of the main agent codebase.** The main agent provides generic infrastructure; tools are self-contained units that declare their requirements through `capabilities.json` files (API endpoints, credentials, rate limits, auth setup). Service-specific auth flows, CLI commands, and configuration do not belong in the main agent.
|
||||
- ✅ **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
|
||||
|
||||
Tools can be built as **WASM** (sandboxed, credential-injected, single binary) or **MCP servers** (ecosystem of pre-built servers, any language, but no sandbox). Both are first-class via `ironclaw tool install`. Auth is declared in capabilities files with OAuth and manual token entry support.
|
||||
## Adding a New Tool
|
||||
|
||||
See `src/tools/README.md` for full tool architecture, adding new tools (built-in Rust and WASM), auth JSON examples, and WASM vs MCP decision guide.
|
||||
### 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
|
||||
|
||||
@@ -645,30 +476,118 @@ RUST_LOG=ironclaw::agent=debug cargo run
|
||||
RUST_LOG=ironclaw=debug,tower_http=debug cargo run
|
||||
```
|
||||
|
||||
## Module Specifications
|
||||
## Code Style
|
||||
|
||||
Some modules have a `README.md` that serves as the authoritative specification
|
||||
for that module's behavior. When modifying code in a module that has a spec:
|
||||
|
||||
1. **Read the spec first** before making changes
|
||||
2. **Code follows spec**: if the spec says X, the code must do X
|
||||
3. **Update both sides**: if you change behavior, update the spec to match;
|
||||
if you're implementing a spec change, update the code to match
|
||||
4. **Spec is the tiebreaker**: when code and spec disagree, the spec is correct
|
||||
(unless the spec is clearly outdated, in which case fix the spec first)
|
||||
|
||||
| Module | Spec File |
|
||||
|--------|-----------|
|
||||
| `src/setup/` | `src/setup/README.md` |
|
||||
| `src/workspace/` | `src/workspace/README.md` |
|
||||
| `src/tools/` | `src/tools/README.md` |
|
||||
- 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
|
||||
|
||||
## Workspace & Memory System
|
||||
|
||||
OpenClaw-inspired persistent memory with a flexible filesystem-like structure. Principle: "Memory is database, not RAM" -- if you want to remember something, write it explicitly. Uses hybrid search combining FTS (keyword) + vector (semantic) via Reciprocal Rank Fusion.
|
||||
Inspired by [OpenClaw](https://github.com/openclaw/openclaw), the workspace provides persistent memory for agents with a flexible filesystem-like structure.
|
||||
|
||||
Four memory tools for LLM use: `memory_search` (hybrid search -- call before answering questions about prior work), `memory_write`, `memory_read`, `memory_tree`. Identity files (AGENTS.md, SOUL.md, USER.md, IDENTITY.md) are injected into the LLM system prompt.
|
||||
### Key Principles
|
||||
|
||||
The heartbeat system runs proactive periodic execution (default: 30 minutes), reading `HEARTBEAT.md` and notifying via channel if findings are detected.
|
||||
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
|
||||
|
||||
See `src/workspace/README.md` for full API documentation, filesystem structure, hybrid search details, chunking strategy, and heartbeat system.
|
||||
### 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 (PostgreSQL `ts_rank_cd`) and vector similarity (pgvector cosine) 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.
|
||||
|
||||
### 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)
|
||||
|
||||
Generated
+233
-1046
File diff suppressed because it is too large
Load Diff
+19
-109
@@ -1,38 +1,10 @@
|
||||
[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/okta",
|
||||
"tools-src/slack",
|
||||
"tools-src/telegram",
|
||||
]
|
||||
|
||||
[package]
|
||||
name = "ironclaw"
|
||||
version = "0.9.0"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.92"
|
||||
rust-version = "1.85"
|
||||
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
||||
authors = ["NEAR AI <[email protected]>"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
homepage = "https://github.com/nearai/ironclaw"
|
||||
repository = "https://github.com/nearai/ironclaw"
|
||||
|
||||
[package.metadata.wix]
|
||||
upgrade-guid = "D0156E61-BA37-451E-8AB9-1A2ECCCFA48F"
|
||||
path-guid = "F90B6EA6-87F7-499B-BB19-CF55DE1EB339"
|
||||
license = false
|
||||
eula = false
|
||||
|
||||
[dependencies]
|
||||
# Async runtime
|
||||
@@ -41,20 +13,17 @@ tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
futures = "0.3"
|
||||
|
||||
# HTTP client
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots", "stream"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# Database - PostgreSQL (default, feature-gated)
|
||||
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 }
|
||||
|
||||
# Database - libSQL/Turso (optional embedded database)
|
||||
libsql = { version = "0.6", optional = true, default-features = false, features = ["core", "replication"] }
|
||||
# Database
|
||||
deadpool-postgres = "0.14"
|
||||
tokio-postgres = { version = "0.7", features = ["with-uuid-1", "with-chrono-0_4", "with-serde_json-1"] }
|
||||
postgres-types = { version = "0.2", features = ["with-serde_json-1"] }
|
||||
refinery = { version = "0.8", features = ["tokio-postgres"] }
|
||||
|
||||
# Error handling
|
||||
thiserror = "2"
|
||||
@@ -66,12 +35,11 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
|
||||
# Configuration
|
||||
dotenvy = "0.15"
|
||||
toml = "0.8"
|
||||
|
||||
# Core types
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
rust_decimal = { version = "1", features = ["serde", "serde-with-str", "maths"] }
|
||||
rust_decimal = { version = "1", features = ["serde", "serde-with-str", "db-tokio-postgres", "maths"] }
|
||||
rust_decimal_macros = "1"
|
||||
|
||||
# Async traits
|
||||
@@ -88,27 +56,19 @@ termimad = "0.34"
|
||||
# Channel integrations
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["trace", "cors", "set-header"] }
|
||||
|
||||
# Cron scheduling for routines
|
||||
cron = "0.13"
|
||||
tower-http = { version = "0.6", features = ["trace", "cors"] }
|
||||
|
||||
# Safety/sanitization
|
||||
regex = "1"
|
||||
aho-corasick = "1"
|
||||
|
||||
# YAML parsing for SKILL.md frontmatter
|
||||
serde_yml = "0.0.12"
|
||||
|
||||
# Filesystem paths
|
||||
dirs = "6"
|
||||
fs4 = "0.6"
|
||||
|
||||
# Secrecy for sensitive values
|
||||
secrecy = { version = "0.10", features = ["serde"] }
|
||||
|
||||
# URL parsing and encoding
|
||||
url = "2"
|
||||
# URL encoding for OAuth flow
|
||||
urlencoding = "2"
|
||||
|
||||
# Open URLs in browser
|
||||
@@ -116,7 +76,7 @@ open = "5"
|
||||
|
||||
# Vector embeddings for semantic search
|
||||
# The postgres feature provides ToSql/FromSql for postgres-types (shared by tokio-postgres)
|
||||
pgvector = { version = "0.4", features = ["postgres"], optional = true }
|
||||
pgvector = { version = "0.4", features = ["postgres"] }
|
||||
|
||||
# WASM sandbox for untrusted tool execution
|
||||
wasmtime = { version = "28", features = ["component-model"] }
|
||||
@@ -129,10 +89,13 @@ hkdf = "0.12"
|
||||
sha2 = "0.10"
|
||||
blake3 = "1"
|
||||
rand = "0.8"
|
||||
subtle = "2" # Constant-time comparisons for token validation
|
||||
|
||||
# Multi-provider LLM support
|
||||
rig-core = "0.30"
|
||||
# NEAR key management (ed25519 signing, borsh serialization, base58 encoding)
|
||||
ed25519-dalek = { version = "2", features = ["rand_core", "zeroize"] }
|
||||
borsh = { version = "1", features = ["derive"] }
|
||||
bs58 = "0.5"
|
||||
argon2 = "0.5"
|
||||
zeroize = { version = "1", features = ["derive"] }
|
||||
|
||||
# Docker sandbox
|
||||
bollard = "0.18"
|
||||
@@ -143,7 +106,6 @@ hyper-util = { version = "0.1", features = ["server", "tokio", "http1", "http2"]
|
||||
http-body-util = "0.1"
|
||||
bytes = "1"
|
||||
base64 = "0.22.1"
|
||||
mime_guess = "2.0.5"
|
||||
|
||||
# macOS keychain
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
@@ -162,57 +124,5 @@ pretty_assertions = "1"
|
||||
tempfile = "3"
|
||||
|
||||
[features]
|
||||
default = ["postgres", "libsql"]
|
||||
postgres = [
|
||||
"dep:deadpool-postgres",
|
||||
"dep:tokio-postgres",
|
||||
"dep:postgres-types",
|
||||
"dep:refinery",
|
||||
"dep:pgvector",
|
||||
"rust_decimal/db-tokio-postgres",
|
||||
]
|
||||
libsql = ["dep:libsql"]
|
||||
default = []
|
||||
integration = []
|
||||
|
||||
# The profile that 'cargo dist' will build with
|
||||
[profile.dist]
|
||||
inherits = "release"
|
||||
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"
|
||||
# CI backends to support
|
||||
ci = "github"
|
||||
# The installers to generate for each app
|
||||
installers = ["shell", "powershell", "npm", "msi"]
|
||||
# Publish jobs to run in CI
|
||||
publish-jobs = []
|
||||
# Target platforms to build apps for (Rust target-triple syntax)
|
||||
targets = [
|
||||
"aarch64-apple-darwin",
|
||||
"aarch64-unknown-linux-gnu",
|
||||
"x86_64-apple-darwin",
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"x86_64-pc-windows-msvc",
|
||||
]
|
||||
# The archive format to use for windows builds (defaults .zip)
|
||||
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"
|
||||
# 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"
|
||||
x86_64-unknown-linux-gnu = "ubuntu-22.04"
|
||||
x86_64-pc-windows-msvc = "windows-2022"
|
||||
x86_64-apple-darwin = "macos-15-intel"
|
||||
aarch64-apple-darwin = "macos-14"
|
||||
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
# Multi-stage Dockerfile for the IronClaw agent (cloud deployment).
|
||||
#
|
||||
# Build:
|
||||
# docker build --platform linux/amd64 -t ironclaw:latest .
|
||||
#
|
||||
# Run:
|
||||
# docker run --env-file .env -p 3000:3000 ironclaw:latest
|
||||
|
||||
# Stage 1: Build
|
||||
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/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy manifests first for layer caching
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
|
||||
# Copy source and build artifacts
|
||||
COPY src/ src/
|
||||
COPY migrations/ migrations/
|
||||
COPY wit/ wit/
|
||||
|
||||
RUN cargo build --release --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
|
||||
COPY --from=builder /app/migrations /app/migrations
|
||||
|
||||
# Non-root user
|
||||
RUN useradd -m -u 1000 -s /bin/bash ironclaw
|
||||
USER ironclaw
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV RUST_LOG=ironclaw=info
|
||||
|
||||
ENTRYPOINT ["ironclaw"]
|
||||
@@ -1,69 +0,0 @@
|
||||
# Multi-stage Dockerfile for the IronClaw worker container.
|
||||
#
|
||||
# This image runs the ironclaw binary in worker mode inside Docker containers.
|
||||
# The orchestrator creates instances of this image for sandboxed job execution.
|
||||
#
|
||||
# Build:
|
||||
# docker build -f Dockerfile.worker -t ironclaw-worker .
|
||||
#
|
||||
# The image includes common development tools so workers can build software,
|
||||
# run tests, and execute shell commands.
|
||||
|
||||
FROM rust:1.92-bookworm AS builder
|
||||
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
|
||||
# Build only the ironclaw binary (release mode)
|
||||
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 \
|
||||
git \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
nodejs \
|
||||
npm \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
gh \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Rust toolchain for the sandbox user
|
||||
ENV RUSTUP_HOME=/usr/local/rustup \
|
||||
CARGO_HOME=/usr/local/cargo \
|
||||
PATH=/usr/local/cargo/bin:$PATH
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.92.0 \
|
||||
&& chmod -R a+r /usr/local/rustup /usr/local/cargo
|
||||
|
||||
# Install Claude Code CLI (for claude-bridge mode)
|
||||
RUN npm install -g @anthropic-ai/claude-code@latest
|
||||
|
||||
# Copy the binary
|
||||
COPY --from=builder /build/target/release/ironclaw /usr/local/bin/ironclaw
|
||||
|
||||
# Create non-root user (UID 1000 matches the orchestrator's container config)
|
||||
RUN useradd -m -u 1000 -s /bin/bash sandbox \
|
||||
&& mkdir -p /workspace \
|
||||
&& chown sandbox:sandbox /workspace \
|
||||
&& mkdir -p /home/sandbox/.claude \
|
||||
&& chown sandbox:sandbox /home/sandbox/.claude
|
||||
|
||||
USER sandbox
|
||||
WORKDIR /workspace
|
||||
|
||||
# The orchestrator passes the full command via Docker cmd.
|
||||
ENTRYPOINT ["ironclaw"]
|
||||
+83
-206
@@ -16,8 +16,8 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Hub-and-spoke architecture | ✅ | ✅ | Web gateway as central hub |
|
||||
| WebSocket control plane | ✅ | ✅ | Gateway with WebSocket + SSE |
|
||||
| Hub-and-spoke architecture | ✅ | 🚧 | IronClaw has channels but no central gateway |
|
||||
| WebSocket control plane | ✅ | ❌ | Gateway with ws://127.0.0.1:18789 |
|
||||
| Single-user system | ✅ | ✅ | |
|
||||
| Multi-agent routing | ✅ | ❌ | Workspace isolation per-agent |
|
||||
| Session-based messaging | ✅ | ✅ | Per-sender sessions |
|
||||
@@ -31,27 +31,20 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Gateway control plane | ✅ | ✅ | Web gateway with 40+ API endpoints |
|
||||
| HTTP endpoints for Control UI | ✅ | ✅ | Web dashboard with chat, memory, jobs, logs, extensions |
|
||||
| Channel connection lifecycle | ✅ | ✅ | ChannelManager + WebSocket tracker |
|
||||
| Gateway control plane | ✅ | ❌ | Central WebSocket server |
|
||||
| HTTP endpoints for Control UI | ✅ | ❌ | Web dashboard |
|
||||
| Channel connection lifecycle | ✅ | 🚧 | ChannelManager handles streams |
|
||||
| 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 |
|
||||
| Health check endpoints | ✅ | ❌ | |
|
||||
| `doctor` diagnostics | ✅ | ❌ | |
|
||||
| Agent event broadcast | ✅ | 🚧 | SSE broadcast manager exists (SseManager) but tool/job-state events not fully wired |
|
||||
| Channel health monitor | ✅ | ❌ | Auto-restart with configurable interval |
|
||||
| Presence system | ✅ | ❌ | Beacons on connect, system presence for agents |
|
||||
| Trusted-proxy auth mode | ✅ | ❌ | Header-based auth for reverse proxies |
|
||||
| APNs push pipeline | ✅ | ❌ | Wake disconnected iOS nodes via push |
|
||||
| Oversized payload guard | ✅ | 🚧 | HTTP webhook has 64KB body limit + Content-Length check; no chat.history cap |
|
||||
| Pre-prompt context diagnostics | ✅ | ❌ | Context size logging before prompt |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -65,65 +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 |
|
||||
| Discord | ✅ | ❌ | P2 | discord.js, thread parent binding inheritance |
|
||||
| WhatsApp | ✅ | ❌ | P1 | Baileys (Web) |
|
||||
| Telegram | ✅ | ❌ | P1 | grammY (Bot API) |
|
||||
| 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 |
|
||||
| Slack | ✅ | 🚧 | P1 | Stub exists, needs implementation |
|
||||
| iMessage | ✅ | ❌ | P3 | BlueBubbles recommended |
|
||||
| Feishu/Lark | ✅ | ❌ | P3 | |
|
||||
| LINE | ✅ | ❌ | P3 | |
|
||||
| WebChat | ✅ | ✅ | - | Web gateway chat |
|
||||
| WebChat | ✅ | ❌ | P2 | Browser-based chat |
|
||||
| Matrix | ✅ | ❌ | P3 | E2EE support |
|
||||
| Mattermost | ✅ | ❌ | P3 | Emoji reactions |
|
||||
| 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 |
|
||||
|
||||
### 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 |
|
||||
|
||||
### Channel Features
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| DM pairing codes | ✅ | ✅ | `ironclaw pairing list/approve`, host APIs |
|
||||
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
|
||||
| DM pairing codes | ✅ | ❌ | Verification for unknown senders |
|
||||
| Allowlist/blocklist | ✅ | ❌ | Per-channel access control |
|
||||
| Self-message bypass | ✅ | ❌ | Own messages skip pairing |
|
||||
| Mention-based activation | ✅ | ✅ | bot_username + respond_to_all_group_messages |
|
||||
| Mention-based activation | ✅ | ❌ | Configurable patterns |
|
||||
| Per-group tool policies | ✅ | ❌ | Allow/deny specific tools |
|
||||
| Thread isolation | ✅ | ✅ | Separate sessions per thread |
|
||||
| Per-channel media limits | ✅ | 🚧 | Caption support for media; no size limits |
|
||||
| Per-channel media limits | ✅ | ❌ | |
|
||||
| Typing indicators | ✅ | 🚧 | TUI shows status |
|
||||
| Per-channel ackReaction config | ✅ | ❌ | Customizable acknowledgement reactions |
|
||||
| Group session priming | ✅ | ❌ | Member roster injected for context |
|
||||
| Sender_id in trusted metadata | ✅ | ❌ | Exposed in system metadata |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -136,21 +99,21 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| `run` (agent) | ✅ | ✅ | - | Default command |
|
||||
| `tool install/list/remove` | ✅ | ✅ | - | WASM tools |
|
||||
| `gateway start/stop` | ✅ | ❌ | P2 | |
|
||||
| `onboard` (wizard) | ✅ | ✅ | - | Interactive setup |
|
||||
| `onboard` (wizard) | ✅ | ❌ | P2 | Interactive setup |
|
||||
| `tui` | ✅ | ✅ | - | Ratatui TUI |
|
||||
| `config` | ✅ | ✅ | - | Read/write config |
|
||||
| `config` | ✅ | ❌ | P2 | Read/write config |
|
||||
| `channels` | ✅ | ❌ | P2 | Channel management |
|
||||
| `models` | ✅ | 🚧 | - | Model selector in TUI |
|
||||
| `status` | ✅ | ✅ | - | System status (enriched session details) |
|
||||
| `status` | ✅ | ❌ | P2 | System status |
|
||||
| `agents` | ✅ | ❌ | P3 | Multi-agent management |
|
||||
| `sessions` | ✅ | ❌ | P3 | Session listing (shows subagent models) |
|
||||
| `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 |
|
||||
| `sessions` | ✅ | ❌ | P3 | Session listing |
|
||||
| `memory` | ✅ | ❌ | P2 | Memory search CLI |
|
||||
| `skills` | ✅ | ❌ | P3 | Agent skills |
|
||||
| `pairing` | ✅ | ❌ | P3 | Node 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 |
|
||||
@@ -159,8 +122,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| `logs` | ✅ | ❌ | P3 | Query logs |
|
||||
| `update` | ✅ | ❌ | P3 | Self-update |
|
||||
| `completion` | ✅ | ❌ | P3 | Shell completion |
|
||||
| `/subagents spawn` | ✅ | ❌ | P3 | Spawn subagents from chat |
|
||||
| `/export-session` | ✅ | ❌ | P3 | Export current session transcript |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -171,38 +132,23 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Pi agent runtime | ✅ | ➖ | IronClaw uses custom runtime |
|
||||
| RPC-based execution | ✅ | ✅ | Orchestrator/worker pattern |
|
||||
| Multi-provider failover | ✅ | ✅ | `FailoverProvider` tries providers sequentially on retryable errors |
|
||||
| RPC-based execution | ✅ | 🚧 | Worker isolation |
|
||||
| Multi-provider failover | ✅ | ❌ | Provider fallback chains |
|
||||
| Per-sender sessions | ✅ | ✅ | |
|
||||
| Global sessions | ✅ | ❌ | Optional shared context |
|
||||
| Session pruning | ✅ | ❌ | Auto cleanup old sessions |
|
||||
| Context compaction | ✅ | ✅ | Auto summarization |
|
||||
| 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 |
|
||||
| Custom system prompts | ✅ | ✅ | Template variables |
|
||||
| Skills (modular capabilities) | ✅ | ❌ | Capability bundles |
|
||||
| Thinking modes (low/med/high) | ✅ | ❌ | Configurable reasoning depth |
|
||||
| Per-model thinkingDefault override | ✅ | ❌ | Override thinking level per model |
|
||||
| Block-level streaming | ✅ | ❌ | |
|
||||
| Tool-level streaming | ✅ | ❌ | |
|
||||
| Z.AI tool_stream | ✅ | ❌ | Real-time tool call streaming |
|
||||
| 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_
|
||||
|
||||
@@ -213,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 |
|
||||
| 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 |
|
||||
|
||||
@@ -233,12 +173,10 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Auto-discovery | ✅ | ❌ | |
|
||||
| Failover chains | ✅ | ✅ | `FailoverProvider` with configurable `fallback_model` |
|
||||
| Cooldown management | ✅ | ✅ | Lock-free per-provider cooldown in `FailoverProvider` |
|
||||
| Failover chains | ✅ | ❌ | Provider fallback |
|
||||
| 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 beta header | ✅ | ❌ | Anthropic extended context support |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -249,8 +187,6 @@ 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 parsing | ✅ | ❌ | P2 | pdfjs-dist |
|
||||
@@ -259,7 +195,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| 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_
|
||||
@@ -278,13 +213,10 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Auth plugins | ✅ | ❌ | |
|
||||
| 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_
|
||||
|
||||
@@ -303,7 +235,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| 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_
|
||||
|
||||
@@ -316,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 | ✅ | ✅ | |
|
||||
@@ -344,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)
|
||||
|
||||
@@ -364,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)
|
||||
|
||||
@@ -384,17 +303,14 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Priority | Notes |
|
||||
|---------|----------|----------|----------|-------|
|
||||
| Control UI Dashboard | ✅ | ✅ | - | Web gateway with chat, memory, jobs, logs, extensions |
|
||||
| Channel status view | ✅ | 🚧 | P2 | Gateway status widget, full channel view pending |
|
||||
| Control UI Dashboard | ✅ | ❌ | P2 | Web status/config |
|
||||
| Channel status view | ✅ | ❌ | P2 | |
|
||||
| Agent management | ✅ | ❌ | P3 | |
|
||||
| Model selection | ✅ | ✅ | - | TUI only |
|
||||
| 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 |
|
||||
| Debug/logs viewer | ✅ | ❌ | P3 | |
|
||||
| WebChat interface | ✅ | ❌ | P2 | Browser chat |
|
||||
| Canvas system (A2UI) | ✅ | ❌ | P3 | Agent-driven UI |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -404,27 +320,21 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Priority | Notes |
|
||||
|---------|----------|----------|----------|-------|
|
||||
| Cron jobs | ✅ | ✅ | - | Routines with cron trigger |
|
||||
| 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 |
|
||||
| `onMessage` hook | ✅ | ✅ | - | Routines with event trigger |
|
||||
| `onSessionStart` hook | ✅ | ✅ | P2 | |
|
||||
| `onSessionEnd` hook | ✅ | ✅ | P2 | |
|
||||
| Cron jobs | ✅ | ❌ | P2 | Schedule-based tasks |
|
||||
| Timezone support | ✅ | ❌ | P2 | |
|
||||
| One-shot/recurring jobs | ✅ | ❌ | P2 | |
|
||||
| `beforeInbound` hook | ✅ | ❌ | P2 | |
|
||||
| `beforeOutbound` hook | ✅ | ❌ | P2 | |
|
||||
| `beforeToolCall` hook | ✅ | ❌ | P2 | |
|
||||
| `onMessage` 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 | |
|
||||
|
||||
@@ -436,37 +346,28 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Gateway token auth | ✅ | ✅ | Bearer token auth on web gateway |
|
||||
| Gateway token auth | ✅ | 🚧 | HTTP webhook secret |
|
||||
| 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 |
|
||||
| DM pairing verification | ✅ | ❌ | |
|
||||
| Allowlist/blocklist | ✅ | ❌ | |
|
||||
| Per-group tool policies | ✅ | ❌ | |
|
||||
| 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 |
|
||||
| Docker sandbox | ✅ | ❌ | Uses WASM sandbox |
|
||||
| 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) |
|
||||
| Credential theft via env injection | ✅ | 🚧 | Shell env scrubbing + command injection detection; no full OC-09 defense |
|
||||
| Session file permissions (0o600) | ✅ | ✅ | Session token file set to 0o600 in llm/session.rs |
|
||||
| Skill download path restriction | ✅ | ❌ | Prevent arbitrary write targets |
|
||||
| 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_
|
||||
|
||||
@@ -486,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_
|
||||
|
||||
@@ -499,44 +397,30 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
### P0 - Core (Already Done)
|
||||
- ✅ TUI channel with approval overlays
|
||||
- ✅ 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
|
||||
- ✅ Context compaction
|
||||
- ✅ Model selection
|
||||
- ✅ Gateway control plane + WebSocket
|
||||
- ✅ Web Control UI (chat, memory, jobs, logs, extensions, routines)
|
||||
- ✅ WebChat channel (web gateway)
|
||||
- ✅ Slack channel (WASM tool)
|
||||
- ✅ Telegram channel (WASM tool, MTProto)
|
||||
- ✅ Docker sandbox (orchestrator/worker)
|
||||
- ✅ 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)
|
||||
- ❌ Telegram channel
|
||||
- ❌ WhatsApp channel
|
||||
- ✅ Multi-provider failover (`FailoverProvider` with retryable error classification)
|
||||
- ✅ Hooks system (core lifecycle hooks + bundled/plugin/workspace hooks + outbound webhooks)
|
||||
- ❌ Multi-provider failover
|
||||
- ❌ Gateway control plane + WebSocket
|
||||
- ❌ Hooks system (beforeInbound, beforeToolCall, etc.)
|
||||
|
||||
### P2 - Medium Priority
|
||||
- ❌ Cron job scheduling
|
||||
- ❌ Web Control UI
|
||||
- ❌ WebChat channel
|
||||
- ❌ Media handling (images, PDFs)
|
||||
- ✅ Ollama/local model support (via rig::providers::ollama)
|
||||
- ❌ CLI subcommands (config, status, memory, doctor)
|
||||
- ❌ Ollama/local model support
|
||||
- ❌ Configuration hot-reload
|
||||
- ❌ Webhook trigger endpoint in web gateway
|
||||
- ❌ Channel health monitor with auto-restart
|
||||
- ❌ Partial output preservation on abort
|
||||
|
||||
### P3 - Lower Priority
|
||||
- ❌ Discord channel
|
||||
@@ -545,12 +429,8 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
- ❌ 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
|
||||
|
||||
---
|
||||
|
||||
@@ -575,12 +455,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.
|
||||
|
||||
@@ -43,10 +43,7 @@ IronClaw is the AI assistant you can actually trust with your personal and profe
|
||||
|
||||
### Always Available
|
||||
|
||||
- **Multi-channel** - REPL, HTTP webhooks, WASM channels (Telegram, Slack), and web gateway
|
||||
- **Docker Sandbox** - Isolated container execution with per-job tokens and orchestrator/worker pattern
|
||||
- **Web Gateway** - Browser UI with real-time SSE/WebSocket streaming
|
||||
- **Routines** - Cron schedules, event triggers, webhook handlers for background automation
|
||||
- **Multi-channel** - REPL, HTTP webhooks, and extensible WASM channels (Telegram, Slack, and more)
|
||||
- **Heartbeat System** - Proactive background execution for monitoring and maintenance tasks
|
||||
- **Parallel Jobs** - Handle multiple requests concurrently with isolated contexts
|
||||
- **Self-repair** - Automatic detection and recovery of stuck operations
|
||||
@@ -68,41 +65,10 @@ IronClaw is the AI assistant you can actually trust with your personal and profe
|
||||
### Prerequisites
|
||||
|
||||
- Rust 1.85+
|
||||
- PostgreSQL 15+ with [pgvector](https://github.com/pgvector/pgvector) extension
|
||||
- PostgreSQL 15+ with pgvector extension
|
||||
- NEAR AI account (authentication handled via setup wizard)
|
||||
|
||||
## Download or Build
|
||||
|
||||
Visit [Releases page](https://github.com/nearai/ironclaw/releases/) to see the latest updates.
|
||||
|
||||
<details>
|
||||
<summary>Install via Windows Installer (Windows)</summary>
|
||||
|
||||
Download the [Windows Installer](https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-x86_64-pc-windows-msvc.msi) and run it.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Install via powershell script (Windows)</summary>
|
||||
|
||||
```sh
|
||||
irm https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.ps1 | iex
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Install via shell script (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>Compile the source code (Cargo on Windows, Linux, macOS)</summary>
|
||||
|
||||
Install it with `cargo`, just make sure you have [Rust](https://rustup.rs) installed on your computer.
|
||||
### Build
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
@@ -116,10 +82,6 @@ cargo build --release
|
||||
cargo test
|
||||
```
|
||||
|
||||
For **full release** (after modifying channel sources), run `./scripts/build-all.sh` to rebuild channels first.
|
||||
|
||||
</details>
|
||||
|
||||
### Database Setup
|
||||
|
||||
```bash
|
||||
@@ -135,13 +97,12 @@ psql ironclaw -c "CREATE EXTENSION IF NOT EXISTS vector;"
|
||||
Run the setup wizard to configure IronClaw:
|
||||
|
||||
```bash
|
||||
ironclaw onboard
|
||||
ironclaw setup
|
||||
```
|
||||
|
||||
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.
|
||||
and secrets encryption (using your system keychain). All settings are saved to
|
||||
`~/.ironclaw/settings.toml`.
|
||||
|
||||
## Security
|
||||
|
||||
@@ -182,42 +143,37 @@ External content passes through multiple security layers:
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ Channels │
|
||||
│ ┌──────┐ ┌──────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ REPL │ │ HTTP │ │WASM Channels│ │ Web Gateway │ │
|
||||
│ └──┬───┘ └──┬───┘ └──────┬──────┘ │ (SSE + WS) │ │
|
||||
│ │ │ │ └──────┬──────┘ │
|
||||
│ └─────────┴──────────────┴────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────▼─────────┐ │
|
||||
│ │ Agent Loop │ Intent routing │
|
||||
│ └────┬──────────┬───┘ │
|
||||
│ │ │ │
|
||||
│ ┌──────────▼────┐ ┌──▼───────────────┐ │
|
||||
│ │ Scheduler │ │ Routines Engine │ │
|
||||
│ │(parallel jobs)│ │(cron, event, wh) │ │
|
||||
│ └──────┬────────┘ └────────┬─────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌─────────────┼────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌───▼─────┐ ┌────▼────────────────┐ │
|
||||
│ │ Local │ │ Orchestrator │ │
|
||||
│ │Workers │ │ ┌───────────────┐ │ │
|
||||
│ │(in-proc)│ │ │ Docker Sandbox│ │ │
|
||||
│ └───┬─────┘ │ │ Containers │ │ │
|
||||
│ │ │ │ ┌───────────┐ │ │ │
|
||||
│ │ │ │ │Worker / CC│ │ │ │
|
||||
│ │ │ │ └───────────┘ │ │ │
|
||||
│ │ │ └───────────────┘ │ │
|
||||
│ │ └─────────┬───────────┘ │
|
||||
│ └──────────────────┤ │
|
||||
│ │ │
|
||||
│ ┌───────────▼──────────┐ │
|
||||
│ │ Tool Registry │ │
|
||||
│ │ Built-in, MCP, WASM │ │
|
||||
│ └──────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Channels │
|
||||
│ ┌──────┐ ┌──────┐ ┌──────────────┐ │
|
||||
│ │ REPL │ │ HTTP │ │ WASM Channels│ │
|
||||
│ └──┬───┘ └──┬───┘ └──────┬───────┘ │
|
||||
│ └─────────┴─────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────▼────┐ │
|
||||
│ │ Router │ Intent classification │
|
||||
│ └────┬────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────▼──────────┐ │
|
||||
│ │ Scheduler │ Parallel job management │
|
||||
│ └──────────┬──────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────┼───────────────┐ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ Worker │ │ Worker │ │ Worker │ LLM reasoning │
|
||||
│ └────┬────┘ └────┬────┘ └────┬────┘ │
|
||||
│ └───────────────┼───────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────▼──────────┐ │
|
||||
│ │ Tool Registry │ │
|
||||
│ │ ┌───────────────┐ │ │
|
||||
│ │ │ Built-in │ │ │
|
||||
│ │ │ MCP │ │ │
|
||||
│ │ │ WASM Sandbox │ │ │
|
||||
│ │ └───────────────┘ │ │
|
||||
│ └─────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Core Components
|
||||
@@ -228,9 +184,6 @@ External content passes through multiple security layers:
|
||||
| **Router** | Classifies user intent (command, query, task) |
|
||||
| **Scheduler** | Manages parallel job execution with priorities |
|
||||
| **Worker** | Executes jobs with LLM reasoning and tool calls |
|
||||
| **Orchestrator** | Container lifecycle, LLM proxying, per-job auth |
|
||||
| **Web Gateway** | Browser UI with chat, memory, jobs, logs, extensions, routines |
|
||||
| **Routines Engine** | Scheduled (cron) and reactive (event, webhook) background tasks |
|
||||
| **Workspace** | Persistent memory with hybrid search |
|
||||
| **Safety Layer** | Prompt injection defense and content sanitization |
|
||||
|
||||
@@ -238,7 +191,7 @@ External content passes through multiple security layers:
|
||||
|
||||
```bash
|
||||
# First-time setup (configures database, auth, etc.)
|
||||
ironclaw onboard
|
||||
ironclaw setup
|
||||
|
||||
# Start interactive REPL
|
||||
cargo run
|
||||
@@ -257,16 +210,12 @@ cargo fmt
|
||||
cargo clippy --all --benches --tests --examples --all-features
|
||||
|
||||
# Run tests
|
||||
createdb ironclaw_test
|
||||
cargo test
|
||||
|
||||
# Run specific test
|
||||
cargo test test_name
|
||||
```
|
||||
|
||||
- **Telegram channel**: See [docs/TELEGRAM_SETUP.md](docs/TELEGRAM_SETUP.md) for setup and DM pairing.
|
||||
- **Changing channel sources**: Run `./channels-src/telegram/build.sh` before `cargo build` so the updated WASM is bundled.
|
||||
|
||||
## OpenClaw Heritage
|
||||
|
||||
IronClaw is a Rust reimplementation inspired by [OpenClaw](https://github.com/openclaw/openclaw). See [FEATURE_PARITY.md](FEATURE_PARITY.md) for the complete tracking matrix.
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
//! Build script: compile Telegram channel WASM from source.
|
||||
//!
|
||||
//! Do not commit compiled WASM binaries — they are a supply chain risk.
|
||||
//! This script builds telegram.wasm from channels-src/telegram before the main crate compiles.
|
||||
//!
|
||||
//! Reproducible build:
|
||||
//! cargo build --release
|
||||
//! (build.rs invokes the channel build automatically)
|
||||
//!
|
||||
//! Prerequisites: rustup target add wasm32-wasip2, cargo install wasm-tools
|
||||
|
||||
use std::env;
|
||||
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);
|
||||
let channel_dir = root.join("channels-src/telegram");
|
||||
let wasm_out = channel_dir.join("telegram.wasm");
|
||||
|
||||
// Rerun when channel source or build script changes
|
||||
println!("cargo:rerun-if-changed=channels-src/telegram/src");
|
||||
println!("cargo:rerun-if-changed=channels-src/telegram/Cargo.toml");
|
||||
println!("cargo:rerun-if-changed=wit/channel.wit");
|
||||
|
||||
if !channel_dir.is_dir() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build WASM module
|
||||
let status = match Command::new("cargo")
|
||||
.args([
|
||||
"build",
|
||||
"--release",
|
||||
"--target",
|
||||
"wasm32-wasip2",
|
||||
"--manifest-path",
|
||||
channel_dir.join("Cargo.toml").to_str().unwrap(),
|
||||
])
|
||||
.current_dir(&root)
|
||||
.status()
|
||||
{
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
eprintln!(
|
||||
"cargo:warning=Telegram channel build failed. Run: ./channels-src/telegram/build.sh"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if !status.success() {
|
||||
eprintln!(
|
||||
"cargo:warning=Telegram channel build failed. Run: ./channels-src/telegram/build.sh"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let raw_wasm = channel_dir.join("target/wasm32-wasip2/release/telegram_channel.wasm");
|
||||
if !raw_wasm.exists() {
|
||||
eprintln!(
|
||||
"cargo:warning=Telegram WASM output not found at {:?}",
|
||||
raw_wasm
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to component and strip (wasm-tools)
|
||||
let component_ok = Command::new("wasm-tools")
|
||||
.args([
|
||||
"component",
|
||||
"new",
|
||||
raw_wasm.to_str().unwrap(),
|
||||
"-o",
|
||||
wasm_out.to_str().unwrap(),
|
||||
])
|
||||
.current_dir(&root)
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
|
||||
if !component_ok {
|
||||
// Fallback: copy raw module if wasm-tools unavailable
|
||||
if std::fs::copy(&raw_wasm, &wasm_out).is_err() {
|
||||
eprintln!("cargo:warning=wasm-tools not found. Run: cargo install wasm-tools");
|
||||
}
|
||||
} else {
|
||||
// Strip debug info (use temp file to avoid clobbering)
|
||||
let stripped = wasm_out.with_extension("wasm.stripped");
|
||||
let strip_ok = Command::new("wasm-tools")
|
||||
.args([
|
||||
"strip",
|
||||
wasm_out.to_str().unwrap(),
|
||||
"-o",
|
||||
stripped.to_str().unwrap(),
|
||||
])
|
||||
.current_dir(&root)
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
if strip_ok {
|
||||
let _ = std::fs::rename(&stripped, &wasm_out);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
[package]
|
||||
name = "discord-channel"
|
||||
version = "0.1.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.41.0"
|
||||
|
||||
[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,39 +0,0 @@
|
||||
{
|
||||
"type": "channel",
|
||||
"name": "discord",
|
||||
"description": "Discord Gateway/Webhook channel for handling slash commands, buttons, and messages",
|
||||
"capabilities": {
|
||||
"http": {
|
||||
"allowlist": [
|
||||
{ "host": "discord.com", "path_prefix": "/api/v10" }
|
||||
],
|
||||
"credentials": {
|
||||
"discord_bot_token": {
|
||||
"secret_name": "discord_bot_token",
|
||||
"location": { "type": "header", "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
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"require_signature_verification": true
|
||||
}
|
||||
}
|
||||
@@ -1,476 +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>,
|
||||
}
|
||||
|
||||
struct DiscordChannel;
|
||||
|
||||
impl Guest for DiscordChannel {
|
||||
fn on_start(_config_json: String) -> Result<ChannelConfig, String> {
|
||||
channel_host::log(channel_host::LogLevel::Info, "Discord channel starting");
|
||||
|
||||
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 => {
|
||||
handle_slash_command(&interaction);
|
||||
json_response(
|
||||
200,
|
||||
serde_json::json!({
|
||||
"type": 5,
|
||||
"data": {
|
||||
"content": "🤔 Thinking..."
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// 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_shutdown() {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
"Discord channel shutting down",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_slash_command(interaction: &DiscordInteraction) {
|
||||
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 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),
|
||||
);
|
||||
// Attempt to notify user of internal error
|
||||
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 // Ephemeral
|
||||
});
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
channel_host::emit_message(&EmittedMessage {
|
||||
user_id,
|
||||
user_name: Some(user_name),
|
||||
content,
|
||||
thread_id: None,
|
||||
metadata_json,
|
||||
});
|
||||
}
|
||||
|
||||
fn handle_message_component(interaction: &DiscordInteraction, message: &DiscordMessage) {
|
||||
// Check member first (for server contexts), then user (for DMs)
|
||||
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 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,
|
||||
});
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -27,5 +27,3 @@ opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
|
||||
[workspace]
|
||||
|
||||
@@ -30,7 +30,7 @@ if [ -f "$WASM_PATH" ]; then
|
||||
wasm-tools strip slack.wasm -o slack.wasm
|
||||
|
||||
echo "Built: slack.wasm ($(du -h slack.wasm | cut -f1))"
|
||||
echo "Copy slack.wasm and slack.capabilities.json to ~/.ironclaw/channels/"
|
||||
echo "Copy slack.wasm and slack.capabilities.json to ~/.near-agent/channels/"
|
||||
else
|
||||
echo "Error: WASM output not found at $WASM_PATH"
|
||||
exit 1
|
||||
|
||||
@@ -108,10 +108,7 @@ struct SlackPostMessageResponse {
|
||||
#[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,
|
||||
}
|
||||
|
||||
@@ -178,7 +175,11 @@ impl Guest for SlackChannel {
|
||||
// Actual event callback
|
||||
"event_callback" => {
|
||||
if let Some(event) = event_wrapper.event {
|
||||
handle_slack_event(event, event_wrapper.team_id, event_wrapper.event_id);
|
||||
handle_slack_event(
|
||||
event,
|
||||
event_wrapper.team_id,
|
||||
event_wrapper.event_id,
|
||||
);
|
||||
}
|
||||
// Always respond 200 quickly to Slack (they have a 3s timeout)
|
||||
json_response(200, serde_json::json!({"ok": true}))
|
||||
@@ -229,7 +230,6 @@ impl Guest for SlackChannel {
|
||||
"https://slack.com/api/chat.postMessage",
|
||||
&headers.to_string(),
|
||||
Some(&payload_bytes),
|
||||
None,
|
||||
);
|
||||
|
||||
match result {
|
||||
@@ -243,15 +243,14 @@ impl Guest for SlackChannel {
|
||||
|
||||
// Parse Slack response
|
||||
let slack_response: SlackPostMessageResponse =
|
||||
serde_json::from_slice(&http_response.body)
|
||||
.map_err(|e| format!("Failed to parse Slack response: {}", e))?;
|
||||
serde_json::from_slice(&http_response.body).map_err(|e| {
|
||||
format!("Failed to parse Slack response: {}", e)
|
||||
})?;
|
||||
|
||||
if !slack_response.ok {
|
||||
return Err(format!(
|
||||
"Slack API error: {}",
|
||||
slack_response
|
||||
.error
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
slack_response.error.unwrap_or_else(|| "unknown".to_string())
|
||||
));
|
||||
}
|
||||
|
||||
@@ -278,16 +277,17 @@ impl Guest for SlackChannel {
|
||||
}
|
||||
|
||||
/// Handle a Slack event and emit message if applicable.
|
||||
fn handle_slack_event(event: SlackEvent, team_id: Option<String>, _event_id: Option<String>) {
|
||||
fn handle_slack_event(
|
||||
event: SlackEvent,
|
||||
team_id: Option<String>,
|
||||
_event_id: Option<String>,
|
||||
) {
|
||||
match event.event_type.as_str() {
|
||||
// Direct mention of the bot
|
||||
"app_mention" => {
|
||||
if let (Some(user), Some(channel), Some(text), Some(ts)) = (
|
||||
event.user,
|
||||
event.channel.clone(),
|
||||
event.text,
|
||||
event.ts.clone(),
|
||||
) {
|
||||
if let (Some(user), Some(channel), Some(text), Some(ts)) =
|
||||
(event.user, event.channel.clone(), event.text, event.ts.clone())
|
||||
{
|
||||
emit_message(user, text, channel, event.thread_ts.or(Some(ts)), team_id);
|
||||
}
|
||||
}
|
||||
@@ -299,12 +299,9 @@ fn handle_slack_event(event: SlackEvent, team_id: Option<String>, _event_id: Opt
|
||||
return;
|
||||
}
|
||||
|
||||
if let (Some(user), Some(channel), Some(text), Some(ts)) = (
|
||||
event.user,
|
||||
event.channel.clone(),
|
||||
event.text,
|
||||
event.ts.clone(),
|
||||
) {
|
||||
if let (Some(user), Some(channel), Some(text), Some(ts)) =
|
||||
(event.user, event.channel.clone(), event.text, event.ts.clone())
|
||||
{
|
||||
// Only process DMs (channel IDs starting with D)
|
||||
if channel.starts_with('D') {
|
||||
emit_message(user, text, channel, event.thread_ts.or(Some(ts)), team_id);
|
||||
@@ -338,13 +335,8 @@ fn emit_message(
|
||||
team_id,
|
||||
};
|
||||
|
||||
let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|e| {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Failed to serialize Slack metadata: {}", e),
|
||||
);
|
||||
"{}".to_string()
|
||||
});
|
||||
let metadata_json =
|
||||
serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string());
|
||||
|
||||
// Strip @ mentions of the bot from the text for cleaner messages
|
||||
let cleaned_text = strip_bot_mention(&text);
|
||||
@@ -372,13 +364,7 @@ fn strip_bot_mention(text: &str) -> String {
|
||||
|
||||
/// Create a JSON HTTP response.
|
||||
fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse {
|
||||
let body = serde_json::to_vec(&value).unwrap_or_else(|e| {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Failed to serialize JSON response: {}", e),
|
||||
);
|
||||
Vec::new()
|
||||
});
|
||||
let body = serde_json::to_vec(&value).unwrap_or_default();
|
||||
let headers = serde_json::json!({"Content-Type": "application/json"});
|
||||
|
||||
OutgoingHttpResponse {
|
||||
|
||||
@@ -16,14 +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)
|
||||
[workspace]
|
||||
|
||||
[profile.release]
|
||||
# Optimize for size
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
|
||||
[workspace]
|
||||
|
||||
@@ -32,8 +32,8 @@ if [ -f "$WASM_PATH" ]; then
|
||||
echo "Built: telegram.wasm ($(du -h telegram.wasm | cut -f1))"
|
||||
echo ""
|
||||
echo "To install:"
|
||||
echo " mkdir -p ~/.ironclaw/channels"
|
||||
echo " cp telegram.wasm telegram.capabilities.json ~/.ironclaw/channels/"
|
||||
echo " mkdir -p ~/.near-agent/channels"
|
||||
echo " cp telegram.wasm telegram.capabilities.json ~/.near-agent/channels/"
|
||||
echo ""
|
||||
echo "Then add your bot token to secrets:"
|
||||
echo " # Set TELEGRAM_BOT_TOKEN in your environment or secrets store"
|
||||
|
||||
+100
-472
@@ -72,10 +72,6 @@ struct TelegramMessage {
|
||||
/// Message text.
|
||||
text: Option<String>,
|
||||
|
||||
/// Caption for media (photo, video, document, etc.).
|
||||
#[serde(default)]
|
||||
caption: Option<String>,
|
||||
|
||||
/// Original message if this is a reply.
|
||||
reply_to_message: Option<Box<TelegramMessage>>,
|
||||
|
||||
@@ -164,21 +160,6 @@ const POLLING_STATE_PATH: &str = "state/last_update_id";
|
||||
/// 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 = "telegram";
|
||||
|
||||
/// Workspace path for persisting bot_username for mention detection in groups.
|
||||
const BOT_USERNAME_PATH: &str = "state/bot_username";
|
||||
|
||||
/// Workspace path for persisting respond_to_all_group_messages flag.
|
||||
const RESPOND_TO_ALL_GROUP_PATH: &str = "state/respond_to_all_group_messages";
|
||||
|
||||
// ============================================================================
|
||||
// Channel Metadata
|
||||
// ============================================================================
|
||||
@@ -215,14 +196,6 @@ struct TelegramConfig {
|
||||
#[serde(default)]
|
||||
owner_id: Option<i64>,
|
||||
|
||||
/// DM policy: "pairing" (default), "allowlist", or "open".
|
||||
#[serde(default)]
|
||||
dm_policy: Option<String>,
|
||||
|
||||
/// Allowed sender IDs/usernames from config (merged with pairing-approved store).
|
||||
#[serde(default)]
|
||||
allow_from: Option<Vec<String>>,
|
||||
|
||||
/// Whether to respond to all group messages (not just mentions).
|
||||
#[serde(default)]
|
||||
respond_to_all_group_messages: bool,
|
||||
@@ -284,24 +257,6 @@ impl Guest for TelegramChannel {
|
||||
);
|
||||
}
|
||||
|
||||
// Persist dm_policy and allow_from for DM pairing in handle_message
|
||||
let dm_policy = config.dm_policy.as_deref().unwrap_or("pairing").to_string();
|
||||
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);
|
||||
|
||||
// Persist bot_username and respond_to_all_group_messages for group handling
|
||||
let _ = channel_host::workspace_write(
|
||||
BOT_USERNAME_PATH,
|
||||
&config.bot_username.unwrap_or_default(),
|
||||
);
|
||||
let _ = channel_host::workspace_write(
|
||||
RESPOND_TO_ALL_GROUP_PATH,
|
||||
&config.respond_to_all_group_messages.to_string(),
|
||||
);
|
||||
|
||||
// Mode is determined by whether the host injected a tunnel_url
|
||||
// If tunnel is configured, use webhooks. Otherwise, use polling.
|
||||
let webhook_mode = config.tunnel_url.is_some();
|
||||
@@ -433,9 +388,7 @@ impl Guest for TelegramChannel {
|
||||
|
||||
let headers = serde_json::json!({});
|
||||
|
||||
// 35s HTTP timeout outlives Telegram's 30s server-side long-poll
|
||||
let result =
|
||||
channel_host::http_request("GET", &url, &headers.to_string(), None, Some(35_000));
|
||||
let result = channel_host::http_request("GET", &url, &headers.to_string(), None);
|
||||
|
||||
match result {
|
||||
Ok(response) => {
|
||||
@@ -508,52 +461,72 @@ impl Guest for TelegramChannel {
|
||||
}
|
||||
|
||||
fn on_respond(response: AgentResponse) -> Result<(), String> {
|
||||
// Parse metadata to get chat info
|
||||
let metadata: TelegramMessageMetadata = serde_json::from_str(&response.metadata_json)
|
||||
.map_err(|e| format!("Failed to parse metadata: {}", e))?;
|
||||
|
||||
// Try sending with Markdown first; fall back to plain text if Telegram
|
||||
// can't parse the entities (e.g. model leaked <tool_call> with underscores).
|
||||
let result = send_message(
|
||||
metadata.chat_id,
|
||||
&response.content,
|
||||
metadata.message_id,
|
||||
Some("Markdown"),
|
||||
// Build sendMessage payload
|
||||
let mut payload = serde_json::json!({
|
||||
"chat_id": metadata.chat_id,
|
||||
"text": response.content,
|
||||
"parse_mode": "Markdown",
|
||||
});
|
||||
|
||||
// Reply to the original message for context
|
||||
payload["reply_to_message_id"] = serde_json::Value::Number(metadata.message_id.into());
|
||||
|
||||
let payload_bytes = serde_json::to_vec(&payload)
|
||||
.map_err(|e| format!("Failed to serialize payload: {}", e))?;
|
||||
|
||||
// Make HTTP request to Telegram API
|
||||
// The bot token is injected into the URL by the host
|
||||
let headers = serde_json::json!({
|
||||
"Content-Type": "application/json"
|
||||
});
|
||||
|
||||
let result = channel_host::http_request(
|
||||
"POST",
|
||||
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
|
||||
&headers.to_string(),
|
||||
Some(&payload_bytes),
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(msg_id) => {
|
||||
Ok(http_response) => {
|
||||
if http_response.status != 200 {
|
||||
let body_str = String::from_utf8_lossy(&http_response.body);
|
||||
return Err(format!(
|
||||
"Telegram API returned status {}: {}",
|
||||
http_response.status, body_str
|
||||
));
|
||||
}
|
||||
|
||||
// Parse Telegram response
|
||||
let api_response: TelegramApiResponse<SentMessage> =
|
||||
serde_json::from_slice(&http_response.body)
|
||||
.map_err(|e| format!("Failed to parse Telegram response: {}", e))?;
|
||||
|
||||
if !api_response.ok {
|
||||
return Err(format!(
|
||||
"Telegram API error: {}",
|
||||
api_response
|
||||
.description
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
));
|
||||
}
|
||||
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Debug,
|
||||
&format!(
|
||||
"Sent message to chat {}: message_id={}",
|
||||
metadata.chat_id, msg_id
|
||||
metadata.chat_id,
|
||||
api_response.result.map(|r| r.message_id).unwrap_or(0)
|
||||
),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(SendError::ParseEntities(detail)) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Warn,
|
||||
&format!("Markdown parse failed ({}), retrying as plain text", detail),
|
||||
);
|
||||
let msg_id = send_message(
|
||||
metadata.chat_id,
|
||||
&response.content,
|
||||
metadata.message_id,
|
||||
None,
|
||||
)
|
||||
.map_err(|e| format!("Plain-text retry also failed: {}", e))?;
|
||||
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Debug,
|
||||
&format!(
|
||||
"Sent plain-text message to chat {}: message_id={}",
|
||||
metadata.chat_id, msg_id
|
||||
),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e.to_string()),
|
||||
Err(e) => Err(format!("HTTP request failed: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -595,7 +568,6 @@ impl Guest for TelegramChannel {
|
||||
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendChatAction",
|
||||
&headers.to_string(),
|
||||
Some(&payload_bytes),
|
||||
None,
|
||||
);
|
||||
|
||||
if let Err(e) = result {
|
||||
@@ -614,101 +586,6 @@ impl Guest for TelegramChannel {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Send Message Helper
|
||||
// ============================================================================
|
||||
|
||||
/// Errors from send_message, split so callers can match on parse-entity failures.
|
||||
enum SendError {
|
||||
/// Telegram returned 400 with "can't parse entities" (Markdown issue).
|
||||
ParseEntities(String),
|
||||
/// Any other failure.
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SendError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SendError::ParseEntities(detail) => write!(f, "parse entities error: {}", detail),
|
||||
SendError::Other(msg) => write!(f, "{}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a message via the Telegram Bot API.
|
||||
///
|
||||
/// Returns the sent message_id on success. When `parse_mode` is set and
|
||||
/// Telegram returns a 400 "can't parse entities" error, returns
|
||||
/// `SendError::ParseEntities` so the caller can retry without formatting.
|
||||
fn send_message(
|
||||
chat_id: i64,
|
||||
text: &str,
|
||||
reply_to_message_id: i64,
|
||||
parse_mode: Option<&str>,
|
||||
) -> Result<i64, SendError> {
|
||||
let mut payload = serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"text": text,
|
||||
"reply_to_message_id": reply_to_message_id,
|
||||
});
|
||||
|
||||
if let Some(mode) = parse_mode {
|
||||
payload["parse_mode"] = serde_json::Value::String(mode.to_string());
|
||||
}
|
||||
|
||||
let payload_bytes = serde_json::to_vec(&payload)
|
||||
.map_err(|e| SendError::Other(format!("Failed to serialize payload: {}", e)))?;
|
||||
|
||||
let headers = serde_json::json!({ "Content-Type": "application/json" });
|
||||
|
||||
let result = channel_host::http_request(
|
||||
"POST",
|
||||
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
|
||||
&headers.to_string(),
|
||||
Some(&payload_bytes),
|
||||
None,
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(http_response) => {
|
||||
if http_response.status == 400 {
|
||||
let body_str = String::from_utf8_lossy(&http_response.body);
|
||||
if body_str.contains("can't parse entities") {
|
||||
return Err(SendError::ParseEntities(body_str.to_string()));
|
||||
}
|
||||
return Err(SendError::Other(format!(
|
||||
"Telegram API returned 400: {}",
|
||||
body_str
|
||||
)));
|
||||
}
|
||||
|
||||
if http_response.status != 200 {
|
||||
let body_str = String::from_utf8_lossy(&http_response.body);
|
||||
return Err(SendError::Other(format!(
|
||||
"Telegram API returned status {}: {}",
|
||||
http_response.status, body_str
|
||||
)));
|
||||
}
|
||||
|
||||
let api_response: TelegramApiResponse<SentMessage> =
|
||||
serde_json::from_slice(&http_response.body)
|
||||
.map_err(|e| SendError::Other(format!("Failed to parse response: {}", e)))?;
|
||||
|
||||
if !api_response.ok {
|
||||
return Err(SendError::Other(format!(
|
||||
"Telegram API error: {}",
|
||||
api_response
|
||||
.description
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(api_response.result.map(|r| r.message_id).unwrap_or(0))
|
||||
}
|
||||
Err(e) => Err(SendError::Other(format!("HTTP request failed: {}", e))),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Webhook Management
|
||||
// ============================================================================
|
||||
@@ -727,7 +604,6 @@ fn delete_webhook() -> Result<(), String> {
|
||||
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/deleteWebhook",
|
||||
&headers.to_string(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
match result {
|
||||
@@ -790,7 +666,6 @@ fn register_webhook(tunnel_url: &str, webhook_secret: Option<&str>) -> Result<()
|
||||
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/setWebhook",
|
||||
&headers.to_string(),
|
||||
Some(&body_bytes),
|
||||
None,
|
||||
);
|
||||
|
||||
match result {
|
||||
@@ -825,48 +700,6 @@ fn register_webhook(tunnel_url: &str, webhook_secret: Option<&str>) -> Result<()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Pairing Reply
|
||||
// ============================================================================
|
||||
|
||||
/// Send a pairing code message to a chat. Used when an unknown user DMs the bot.
|
||||
fn send_pairing_reply(chat_id: i64, code: &str) -> Result<(), String> {
|
||||
let payload = serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"text": format!(
|
||||
"To pair with this bot, run: `ironclaw pairing approve telegram {}`",
|
||||
code
|
||||
),
|
||||
"parse_mode": "Markdown",
|
||||
});
|
||||
|
||||
let payload_bytes =
|
||||
serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize payload: {}", e))?;
|
||||
|
||||
let headers = serde_json::json!({
|
||||
"Content-Type": "application/json"
|
||||
});
|
||||
|
||||
let result = channel_host::http_request(
|
||||
"POST",
|
||||
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
|
||||
&headers.to_string(),
|
||||
Some(&payload_bytes),
|
||||
None,
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(response) => {
|
||||
if response.status != 200 {
|
||||
let body_str = String::from_utf8_lossy(&response.body);
|
||||
return Err(format!("HTTP {}: {}", response.status, body_str));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(format!("HTTP request failed: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Update Handling
|
||||
// ============================================================================
|
||||
@@ -886,16 +719,11 @@ fn handle_update(update: TelegramUpdate) {
|
||||
|
||||
/// Process a single message.
|
||||
fn handle_message(message: TelegramMessage) {
|
||||
// Use text or caption (for media messages)
|
||||
let content = message
|
||||
.text
|
||||
.filter(|t| !t.is_empty())
|
||||
.or_else(|| message.caption.filter(|c| !c.is_empty()))
|
||||
.unwrap_or_default();
|
||||
|
||||
if content.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Skip messages without text
|
||||
let text = match message.text {
|
||||
Some(t) if !t.is_empty() => t,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
// Skip messages without a sender (channel posts)
|
||||
let from = match message.from {
|
||||
@@ -908,105 +736,41 @@ fn handle_message(message: TelegramMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
let is_private = message.chat.chat_type == "private";
|
||||
|
||||
// Owner validation: when owner_id is set, only that user can message
|
||||
let owner_id_str = channel_host::workspace_read(OWNER_ID_PATH).filter(|s| !s.is_empty());
|
||||
|
||||
if let Some(ref id_str) = owner_id_str {
|
||||
if let Ok(owner_id) = id_str.parse::<i64>() {
|
||||
if from.id != owner_id {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Debug,
|
||||
&format!(
|
||||
"Dropping message from non-owner user {} (owner: {})",
|
||||
from.id, owner_id
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if is_private {
|
||||
// No owner_id: apply dm_policy for private chats
|
||||
let dm_policy =
|
||||
channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| "pairing".to_string());
|
||||
|
||||
if dm_policy != "open" {
|
||||
// Build effective 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);
|
||||
}
|
||||
|
||||
let id_str = from.id.to_string();
|
||||
let username_opt = from.username.as_deref();
|
||||
let is_allowed = allowed.contains(&"*".to_string())
|
||||
|| allowed.contains(&id_str)
|
||||
|| username_opt.map_or(false, |u| allowed.contains(&u.to_string()));
|
||||
|
||||
if !is_allowed {
|
||||
if dm_policy == "pairing" {
|
||||
// Upsert pairing request and send reply
|
||||
let meta = serde_json::json!({
|
||||
"chat_id": message.chat.id,
|
||||
"user_id": from.id,
|
||||
"username": username_opt,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
match channel_host::pairing_upsert_request(CHANNEL_NAME, &id_str, &meta) {
|
||||
Ok(result) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
&format!(
|
||||
"Pairing request for user {} (chat {}): code {}",
|
||||
from.id, message.chat.id, result.code
|
||||
),
|
||||
);
|
||||
if result.created {
|
||||
let _ = send_pairing_reply(message.chat.id, &result.code);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Pairing upsert failed: {}", e),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Owner validation: silently drop messages from non-owner users
|
||||
if let Some(owner_id_str) = channel_host::workspace_read(OWNER_ID_PATH) {
|
||||
if !owner_id_str.is_empty() {
|
||||
if let Ok(owner_id) = owner_id_str.parse::<i64>() {
|
||||
if from.id != owner_id {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Debug,
|
||||
&format!(
|
||||
"Dropping message from non-owner user {} (owner: {})",
|
||||
from.id, owner_id
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For group chats, only respond if bot was mentioned or respond_to_all is enabled
|
||||
let is_private = message.chat.chat_type == "private";
|
||||
|
||||
// For group chats, check if the bot was mentioned
|
||||
// TODO: Read bot_username from config and check mentions
|
||||
// For now, process all messages in private chats and groups
|
||||
if !is_private {
|
||||
let respond_to_all = channel_host::workspace_read(RESPOND_TO_ALL_GROUP_PATH)
|
||||
.as_deref()
|
||||
.unwrap_or("false")
|
||||
== "true";
|
||||
// In groups, only respond if there's a bot mention or command
|
||||
// This is a simplified check - proper implementation would use entities
|
||||
let has_command = text.starts_with('/');
|
||||
let has_mention = text.contains('@');
|
||||
|
||||
if !respond_to_all {
|
||||
let has_command = content.starts_with('/');
|
||||
let bot_username = channel_host::workspace_read(BOT_USERNAME_PATH).unwrap_or_default();
|
||||
let has_bot_mention = if bot_username.is_empty() {
|
||||
content.contains('@')
|
||||
} else {
|
||||
let mention = format!("@{}", bot_username);
|
||||
content.to_lowercase().contains(&mention.to_lowercase())
|
||||
};
|
||||
|
||||
if !has_command && !has_bot_mention {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Debug,
|
||||
&format!("Ignoring group message without mention: {}", content),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if !has_command && !has_mention {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Debug,
|
||||
&format!("Ignoring group message without mention: {}", text),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1028,39 +792,17 @@ fn handle_message(message: TelegramMessage) {
|
||||
let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string());
|
||||
|
||||
// Clean the message text (strip bot mentions and commands)
|
||||
let bot_username = channel_host::workspace_read(BOT_USERNAME_PATH).unwrap_or_default();
|
||||
let cleaned_text = clean_message_text(
|
||||
&content,
|
||||
if bot_username.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(bot_username.as_str())
|
||||
},
|
||||
);
|
||||
let cleaned_text = clean_message_text(&text);
|
||||
|
||||
// Determine what to emit to the agent.
|
||||
// - `/start` (no args): emit a welcome placeholder so the agent greets the user
|
||||
// - Other bare `/commands` (e.g. /interrupt, /help): pass the raw command through
|
||||
// so Submission::parse() can handle it
|
||||
// - Commands with args (e.g. `/start hello`): cleaned_text already has the args
|
||||
// - Plain text: pass through as-is
|
||||
let trimmed_content = content.trim();
|
||||
let content_to_emit = if trimmed_content.eq_ignore_ascii_case("/start") {
|
||||
"[User started the bot]".to_string()
|
||||
} else if cleaned_text.is_empty() && trimmed_content.starts_with('/') {
|
||||
// Bare control command like /interrupt, /stop, /help — pass through raw
|
||||
trimmed_content.to_string()
|
||||
} else if cleaned_text.is_empty() {
|
||||
if cleaned_text.is_empty() {
|
||||
return;
|
||||
} else {
|
||||
cleaned_text
|
||||
};
|
||||
}
|
||||
|
||||
// Emit the message to the agent
|
||||
channel_host::emit_message(&EmittedMessage {
|
||||
user_id: from.id.to_string(),
|
||||
user_name: Some(user_name),
|
||||
content: content_to_emit,
|
||||
content: cleaned_text,
|
||||
thread_id: None, // Telegram doesn't have threads in the same way
|
||||
metadata_json,
|
||||
});
|
||||
@@ -1075,8 +817,7 @@ fn handle_message(message: TelegramMessage) {
|
||||
}
|
||||
|
||||
/// Clean message text by removing bot commands and @mentions at the start.
|
||||
/// When bot_username is set, only strips that specific mention; otherwise strips any leading @mention.
|
||||
fn clean_message_text(text: &str, bot_username: Option<&str>) -> String {
|
||||
fn clean_message_text(text: &str) -> String {
|
||||
let mut result = text.trim().to_string();
|
||||
|
||||
// Remove leading /command
|
||||
@@ -1091,30 +832,11 @@ fn clean_message_text(text: &str, bot_username: Option<&str>) -> String {
|
||||
|
||||
// Remove leading @mention
|
||||
if result.starts_with('@') {
|
||||
if let Some(bot) = bot_username {
|
||||
let mention = format!("@{}", bot);
|
||||
let mention_lower = mention.to_lowercase();
|
||||
let result_lower = result.to_lowercase();
|
||||
if result_lower.starts_with(&mention_lower) {
|
||||
let rest = result[mention.len()..].trim_start();
|
||||
if rest.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
result = rest.to_string();
|
||||
} else if let Some(space_idx) = result.find(' ') {
|
||||
// Different leading @mention - only strip if it's the bot
|
||||
let first_word = &result[..space_idx];
|
||||
if first_word.eq_ignore_ascii_case(&mention) {
|
||||
result = result[space_idx..].trim_start().to_string();
|
||||
}
|
||||
}
|
||||
if let Some(space_idx) = result.find(' ') {
|
||||
result = result[space_idx..].trim_start().to_string();
|
||||
} else {
|
||||
// No bot_username: strip any leading @mention
|
||||
if let Some(space_idx) = result.find(' ') {
|
||||
result = result[space_idx..].trim_start().to_string();
|
||||
} else {
|
||||
return String::new();
|
||||
}
|
||||
// Just a mention with no text
|
||||
return String::new();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1150,93 +872,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_clean_message_text() {
|
||||
// Without bot_username: strips any leading @mention
|
||||
assert_eq!(clean_message_text("/start hello", None), "hello");
|
||||
assert_eq!(clean_message_text("@bot hello world", None), "hello world");
|
||||
assert_eq!(clean_message_text("/start", None), "");
|
||||
assert_eq!(clean_message_text("@botname", None), "");
|
||||
assert_eq!(clean_message_text("just text", None), "just text");
|
||||
assert_eq!(clean_message_text(" spaced ", None), "spaced");
|
||||
|
||||
// With bot_username: only strips @MyBot, not @alice
|
||||
assert_eq!(clean_message_text("@MyBot hello", Some("MyBot")), "hello");
|
||||
assert_eq!(clean_message_text("@mybot hi", Some("MyBot")), "hi");
|
||||
assert_eq!(
|
||||
clean_message_text("@alice hello", Some("MyBot")),
|
||||
"@alice hello"
|
||||
);
|
||||
assert_eq!(clean_message_text("@MyBot", Some("MyBot")), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_message_text_bare_commands() {
|
||||
// Bare commands return empty (the caller decides what to emit)
|
||||
assert_eq!(clean_message_text("/start", None), "");
|
||||
assert_eq!(clean_message_text("/interrupt", None), "");
|
||||
assert_eq!(clean_message_text("/stop", None), "");
|
||||
assert_eq!(clean_message_text("/help", None), "");
|
||||
assert_eq!(clean_message_text("/undo", None), "");
|
||||
assert_eq!(clean_message_text("/ping", None), "");
|
||||
|
||||
// Commands with args: command prefix stripped, args returned
|
||||
assert_eq!(clean_message_text("/start hello", None), "hello");
|
||||
assert_eq!(clean_message_text("/help me please", None), "me please");
|
||||
assert_eq!(clean_message_text("/model claude-opus-4-6", None), "claude-opus-4-6");
|
||||
}
|
||||
|
||||
/// Tests for the content_to_emit logic in handle_message.
|
||||
/// Since handle_message uses WASM host calls, we test the decision logic inline.
|
||||
#[test]
|
||||
fn test_content_to_emit_logic() {
|
||||
// Simulates the content_to_emit decision for various inputs.
|
||||
// This mirrors the logic in handle_message after clean_message_text.
|
||||
fn resolve_content(content: &str) -> Option<String> {
|
||||
let cleaned_text = clean_message_text(content, None);
|
||||
let trimmed_content = content.trim();
|
||||
if trimmed_content.eq_ignore_ascii_case("/start") {
|
||||
Some("[User started the bot]".to_string())
|
||||
} else if cleaned_text.is_empty() && trimmed_content.starts_with('/') {
|
||||
Some(trimmed_content.to_string())
|
||||
} else if cleaned_text.is_empty() {
|
||||
None // would return/skip in handle_message
|
||||
} else {
|
||||
Some(cleaned_text)
|
||||
}
|
||||
}
|
||||
|
||||
// /start → welcome placeholder
|
||||
assert_eq!(resolve_content("/start"), Some("[User started the bot]".to_string()));
|
||||
assert_eq!(resolve_content("/Start"), Some("[User started the bot]".to_string()));
|
||||
assert_eq!(resolve_content(" /start "), Some("[User started the bot]".to_string()));
|
||||
|
||||
// /start with args → pass args through
|
||||
assert_eq!(resolve_content("/start hello"), Some("hello".to_string()));
|
||||
|
||||
// Control commands → pass through raw so Submission::parse() can match
|
||||
assert_eq!(resolve_content("/interrupt"), Some("/interrupt".to_string()));
|
||||
assert_eq!(resolve_content("/stop"), Some("/stop".to_string()));
|
||||
assert_eq!(resolve_content("/help"), Some("/help".to_string()));
|
||||
assert_eq!(resolve_content("/undo"), Some("/undo".to_string()));
|
||||
assert_eq!(resolve_content("/redo"), Some("/redo".to_string()));
|
||||
assert_eq!(resolve_content("/ping"), Some("/ping".to_string()));
|
||||
assert_eq!(resolve_content("/tools"), Some("/tools".to_string()));
|
||||
assert_eq!(resolve_content("/compact"), Some("/compact".to_string()));
|
||||
assert_eq!(resolve_content("/clear"), Some("/clear".to_string()));
|
||||
assert_eq!(resolve_content("/version"), Some("/version".to_string()));
|
||||
|
||||
// Commands with args → cleaned text (command stripped)
|
||||
assert_eq!(resolve_content("/help me please"), Some("me please".to_string()));
|
||||
|
||||
// Plain text → pass through
|
||||
assert_eq!(resolve_content("hello world"), Some("hello world".to_string()));
|
||||
assert_eq!(resolve_content("just text"), Some("just text".to_string()));
|
||||
|
||||
// Empty / whitespace → skip (None)
|
||||
assert_eq!(resolve_content(""), None);
|
||||
assert_eq!(resolve_content(" "), None);
|
||||
|
||||
// Bare @mention without bot → skip
|
||||
assert_eq!(resolve_content("@botname"), None);
|
||||
assert_eq!(clean_message_text("/start hello"), "hello");
|
||||
assert_eq!(clean_message_text("@bot hello world"), "hello world");
|
||||
assert_eq!(clean_message_text("/start"), "");
|
||||
assert_eq!(clean_message_text("@botname"), "");
|
||||
assert_eq!(clean_message_text("just text"), "just text");
|
||||
assert_eq!(clean_message_text(" spaced "), "spaced");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1304,17 +945,4 @@ mod tests {
|
||||
assert_eq!(from.id, 789);
|
||||
assert_eq!(from.first_name, "John");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_message_with_caption() {
|
||||
let json = r#"{
|
||||
"message_id": 1,
|
||||
"from": {"id": 1, "is_bot": false, "first_name": "A"},
|
||||
"chat": {"id": 1, "type": "private"},
|
||||
"caption": "What's in this image?"
|
||||
}"#;
|
||||
let msg: TelegramMessage = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(msg.text, None);
|
||||
assert_eq!(msg.caption.as_deref(), Some("What's in this image?"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,43 @@
|
||||
{"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":[]}}
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -16,5 +16,3 @@ serde_json = "1"
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
|
||||
[workspace]
|
||||
|
||||
@@ -254,19 +254,10 @@ struct WhatsAppChannel;
|
||||
|
||||
impl Guest for WhatsAppChannel {
|
||||
fn on_start(config_json: String) -> Result<ChannelConfig, String> {
|
||||
let config: WhatsAppConfig = match serde_json::from_str(&config_json) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Warn,
|
||||
&format!("Failed to parse WhatsApp config, using defaults: {}", e),
|
||||
);
|
||||
WhatsAppConfig {
|
||||
api_version: default_api_version(),
|
||||
reply_to_message: default_reply_to_message(),
|
||||
}
|
||||
}
|
||||
};
|
||||
let config: WhatsAppConfig = serde_json::from_str(&config_json).unwrap_or(WhatsAppConfig {
|
||||
api_version: default_api_version(),
|
||||
reply_to_message: default_reply_to_message(),
|
||||
});
|
||||
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
@@ -276,9 +267,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);
|
||||
|
||||
// WhatsApp Cloud API is webhook-only, no polling available
|
||||
Ok(ChannelConfig {
|
||||
display_name: "WhatsApp".to_string(),
|
||||
@@ -339,16 +327,11 @@ impl Guest for WhatsAppChannel {
|
||||
let metadata: WhatsAppMessageMetadata = serde_json::from_str(&response.metadata_json)
|
||||
.map_err(|e| format!("Failed to parse metadata: {}", e))?;
|
||||
|
||||
// Read api_version from workspace (set during on_start), fallback to default
|
||||
let api_version = channel_host::workspace_read("channels/whatsapp/api_version")
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "v18.0".to_string());
|
||||
|
||||
// Build WhatsApp API URL with token placeholder
|
||||
// Host will replace {WHATSAPP_ACCESS_TOKEN} with actual token in Authorization header
|
||||
let api_url = format!(
|
||||
"https://graph.facebook.com/{}/{}/messages",
|
||||
api_version, metadata.phone_number_id
|
||||
"https://graph.facebook.com/v18.0/{}/messages",
|
||||
metadata.phone_number_id
|
||||
);
|
||||
|
||||
// Build sendMessage payload
|
||||
@@ -378,7 +361,6 @@ impl Guest for WhatsAppChannel {
|
||||
&api_url,
|
||||
&headers.to_string(),
|
||||
Some(&payload_bytes),
|
||||
None,
|
||||
);
|
||||
|
||||
match result {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
[Unit]
|
||||
Description=Cloud SQL Auth Proxy
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
DynamicUser=yes
|
||||
ExecStart=/usr/local/bin/cloud-sql-proxy ironclaw-prod:us-central1:ironclaw-db --port=5432
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,30 +0,0 @@
|
||||
# WARNING: Replace all CHANGE_ME values before deploying.
|
||||
# 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
|
||||
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
|
||||
|
||||
# Agent
|
||||
AGENT_NAME=ironclaw
|
||||
CLI_ENABLED=false
|
||||
|
||||
# Web Gateway
|
||||
GATEWAY_ENABLED=true
|
||||
# 0.0.0.0 binds to all interfaces (required for Docker --network=host).
|
||||
# Use 127.0.0.1 if running outside Docker or for local-only access.
|
||||
GATEWAY_HOST=0.0.0.0
|
||||
GATEWAY_PORT=3000
|
||||
GATEWAY_AUTH_TOKEN=CHANGE_ME
|
||||
|
||||
# Disabled for initial deploy
|
||||
SANDBOX_ENABLED=false
|
||||
HEARTBEAT_ENABLED=false
|
||||
EMBEDDING_ENABLED=false
|
||||
@@ -1,20 +0,0 @@
|
||||
[Unit]
|
||||
Description=IronClaw AI Assistant
|
||||
After=cloud-sql-proxy.service docker.service
|
||||
Requires=cloud-sql-proxy.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStartPre=/usr/bin/docker pull us-central1-docker.pkg.dev/ironclaw-prod/ironclaw/agent:latest
|
||||
ExecStart=/usr/bin/docker run --rm \
|
||||
--name ironclaw \
|
||||
--env-file /opt/ironclaw/.env \
|
||||
--network=host \
|
||||
us-central1-docker.pkg.dev/ironclaw-prod/ironclaw/agent:latest \
|
||||
--no-onboard
|
||||
ExecStop=/usr/bin/docker stop ironclaw
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,68 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# VM bootstrap script for IronClaw on GCP Compute Engine.
|
||||
#
|
||||
# Run on a fresh Debian 12 VM after SSH:
|
||||
# sudo bash setup.sh
|
||||
#
|
||||
# Prerequisites:
|
||||
# - VM has the ironclaw-vm service account attached
|
||||
# - Cloud SQL Auth Proxy accessible via IAM
|
||||
# - Artifact Registry image pushed
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Must run as root
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "ERROR: This script must be run as root (sudo bash setup.sh)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Installing Docker"
|
||||
apt-get update
|
||||
apt-get install -y docker.io
|
||||
systemctl enable docker
|
||||
systemctl start docker
|
||||
|
||||
echo "==> Installing Cloud SQL Auth Proxy"
|
||||
curl -fsSL -o /usr/local/bin/cloud-sql-proxy \
|
||||
https://storage.googleapis.com/cloud-sql-connectors/cloud-sql-proxy/v2.14.3/cloud-sql-proxy.linux.amd64
|
||||
chmod +x /usr/local/bin/cloud-sql-proxy
|
||||
|
||||
echo "==> Installing systemd services"
|
||||
cp /tmp/deploy/cloud-sql-proxy.service /etc/systemd/system/
|
||||
cp /tmp/deploy/ironclaw.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
|
||||
echo "==> Starting Cloud SQL Auth Proxy"
|
||||
systemctl enable cloud-sql-proxy
|
||||
systemctl start cloud-sql-proxy
|
||||
|
||||
echo "==> Configuring Docker registry auth"
|
||||
# The VM service account provides Artifact Registry access
|
||||
gcloud auth configure-docker us-central1-docker.pkg.dev --quiet
|
||||
|
||||
echo "==> Creating config directory"
|
||||
# Owned by root, readable only by root. Docker reads --env-file as root
|
||||
# before dropping to uid 1000 (ironclaw) inside the container.
|
||||
mkdir -p /opt/ironclaw
|
||||
chmod 700 /opt/ironclaw
|
||||
|
||||
if [ ! -f /opt/ironclaw/.env ]; then
|
||||
echo "WARNING: /opt/ironclaw/.env does not exist."
|
||||
echo "Create it with your configuration before starting IronClaw."
|
||||
echo "See deploy/env.example for the required variables."
|
||||
echo ""
|
||||
echo "Then run: systemctl enable ironclaw && systemctl start ironclaw"
|
||||
else
|
||||
chmod 600 /opt/ironclaw/.env
|
||||
echo "==> Starting IronClaw"
|
||||
systemctl enable ironclaw
|
||||
systemctl start ironclaw
|
||||
fi
|
||||
|
||||
echo "==> Setup complete"
|
||||
echo ""
|
||||
echo "Verify with:"
|
||||
echo " systemctl status cloud-sql-proxy"
|
||||
echo " systemctl status ironclaw"
|
||||
echo " docker logs ironclaw"
|
||||
@@ -1,20 +0,0 @@
|
||||
# Local development only — do NOT use these credentials in production.
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_DB: ironclaw
|
||||
POSTGRES_USER: ironclaw
|
||||
POSTGRES_PASSWORD: ironclaw # dev-only, change for any non-local deployment
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ironclaw"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -246,46 +246,13 @@ Create `my-channel.capabilities.json`:
|
||||
|
||||
## Building and Deploying
|
||||
|
||||
### Supply Chain Security: No Committed Binaries
|
||||
|
||||
**Do not commit compiled WASM binaries.** They are a supply chain risk — the binary in a PR may not match the source. IronClaw builds channels from source:
|
||||
|
||||
- `cargo build` automatically builds `telegram.wasm` via `build.rs`
|
||||
- The built binary is in `.gitignore` and is not committed
|
||||
- CI should run `cargo build` (or `./scripts/build-all.sh`) to produce releases
|
||||
|
||||
**Reproducible build:**
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Prerequisites: `rustup target add wasm32-wasip2`, `cargo install wasm-tools` (optional; fallback copies raw WASM if unavailable).
|
||||
|
||||
### Telegram Channel (Manual Build)
|
||||
|
||||
```bash
|
||||
# Add WASM target if needed
|
||||
rustup target add wasm32-wasip2
|
||||
|
||||
# Build Telegram channel
|
||||
./channels-src/telegram/build.sh
|
||||
|
||||
# Install (or use ironclaw onboard to install bundled channel)
|
||||
mkdir -p ~/.ironclaw/channels
|
||||
cp channels-src/telegram/telegram.wasm channels-src/telegram/telegram.capabilities.json ~/.ironclaw/channels/
|
||||
```
|
||||
|
||||
**Note**: The main IronClaw binary bundles `telegram.wasm` via `include_bytes!`. When modifying the Telegram channel source, run `./channels-src/telegram/build.sh` **before** building the main crate, so the updated WASM is included.
|
||||
|
||||
### Other Channels
|
||||
|
||||
```bash
|
||||
# Build the WASM component
|
||||
cd channels-src/my-channel
|
||||
cargo build --release --target wasm32-wasip2
|
||||
cd channels/my-channel
|
||||
cargo component build --release
|
||||
|
||||
# Deploy to ~/.ironclaw/channels/
|
||||
cp target/wasm32-wasip2/release/my_channel.wasm ~/.ironclaw/channels/my-channel.wasm
|
||||
cp target/wasm32-wasip1/release/my_channel.wasm ~/.ironclaw/channels/my-channel.wasm
|
||||
cp my-channel.capabilities.json ~/.ironclaw/channels/
|
||||
```
|
||||
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
# Telegram Channel Setup
|
||||
|
||||
This guide covers configuring the Telegram channel for IronClaw, including DM pairing for access control.
|
||||
|
||||
## Overview
|
||||
|
||||
The Telegram channel lets you interact with IronClaw via Telegram DMs and groups. It supports:
|
||||
|
||||
- **Webhook mode** (recommended): Instant delivery via tunnel
|
||||
- **Polling mode**: No tunnel required; ~30s delay
|
||||
- **DM pairing**: Approve unknown users before they can message the agent
|
||||
- **Group mentions**: `@YourBot` or `/command` to trigger in groups
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- IronClaw installed and configured (`ironclaw onboard`)
|
||||
- A Telegram bot token from [@BotFather](https://t.me/BotFather)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Create a Bot
|
||||
|
||||
1. Message [@BotFather](https://t.me/BotFather) on Telegram
|
||||
2. Send `/newbot` and follow the prompts
|
||||
3. Copy the bot token (e.g., `123456789:ABCdefGHIjklMNOpqrsTUVwxyz`)
|
||||
|
||||
### 2. Configure via Setup Wizard
|
||||
|
||||
```bash
|
||||
ironclaw onboard
|
||||
```
|
||||
|
||||
When prompted, enable the Telegram channel and paste your bot token. The wizard will:
|
||||
|
||||
- Validate the token
|
||||
- Optionally configure a webhook secret
|
||||
- Set up tunnel (if you want webhook mode)
|
||||
|
||||
### 3. (Optional) Configure Tunnel for Webhooks
|
||||
|
||||
For instant message delivery, expose your agent via a tunnel:
|
||||
|
||||
```bash
|
||||
# ngrok
|
||||
ngrok http 8080
|
||||
|
||||
# Cloudflare
|
||||
cloudflared tunnel --url http://localhost:8080
|
||||
```
|
||||
|
||||
Set the tunnel URL in settings or via `TUNNEL_URL` env var. Without a tunnel, the channel uses polling (~30s delay).
|
||||
|
||||
## DM Pairing
|
||||
|
||||
When an unknown user DMs your bot, they receive a pairing code. You must approve them before they can message the agent.
|
||||
|
||||
### Flow
|
||||
|
||||
1. Unknown user sends a message to your bot
|
||||
2. Bot replies: `To pair with this bot, run: ironclaw pairing approve telegram ABC12345`
|
||||
3. You run: `ironclaw pairing approve telegram ABC12345`
|
||||
4. User is added to the allow list; future messages are delivered
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
# List pending pairing requests
|
||||
ironclaw pairing list telegram
|
||||
|
||||
# List as JSON
|
||||
ironclaw pairing list telegram --json
|
||||
|
||||
# Approve a user by code
|
||||
ironclaw pairing approve telegram ABC12345
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Edit `~/.ironclaw/channels/telegram.capabilities.json` (or the config injected by the host):
|
||||
|
||||
| Option | Values | Default | Description |
|
||||
|--------|--------|---------|-------------|
|
||||
| `dm_policy` | `open`, `allowlist`, `pairing` | `pairing` | `open` = allow all; `allowlist` = config + approved only; `pairing` = allowlist + send pairing reply to unknown |
|
||||
| `allow_from` | `["user_id", "username", "*"]` | `[]` | Pre-approved IDs/usernames. `*` allows everyone. |
|
||||
| `owner_id` | Telegram user ID | `null` | When set, only this user can message (overrides dm_policy) |
|
||||
| `bot_username` | Bot username (no @) | `null` | Used for mention detection in groups; when set, only strips this mention from messages |
|
||||
| `respond_to_all_group_messages` | `true`/`false` | `false` | When true, respond to all group messages; when false, only @mentions and /commands |
|
||||
|
||||
## Manual Installation
|
||||
|
||||
If the channel isn't installed via the wizard:
|
||||
|
||||
```bash
|
||||
# Build the Telegram channel (requires wasm32-wasip2 target)
|
||||
rustup target add wasm32-wasip2
|
||||
./channels-src/telegram/build.sh
|
||||
|
||||
# Install
|
||||
mkdir -p ~/.ironclaw/channels
|
||||
cp channels-src/telegram/telegram.wasm channels-src/telegram/telegram.capabilities.json ~/.ironclaw/channels/
|
||||
```
|
||||
|
||||
## Secrets
|
||||
|
||||
The channel expects a secret named `telegram_bot_token`. Configure via:
|
||||
|
||||
- **Setup wizard**: Saves to encrypted secrets store
|
||||
- **Environment**: `TELEGRAM_BOT_TOKEN=your_token`
|
||||
- **Secrets store**: `ironclaw` CLI (if available)
|
||||
|
||||
## Webhook Secret (Optional)
|
||||
|
||||
For webhook validation, set `telegram_webhook_secret` in secrets. Telegram will send `X-Telegram-Bot-Api-Secret-Token` with each request; the host validates it before forwarding.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Messages not delivered
|
||||
|
||||
- **Polling mode**: Check logs for `getUpdates` errors. Ensure the bot token is valid.
|
||||
- **Webhook mode**: Verify tunnel is running and `TUNNEL_URL` is correct. Telegram requires HTTPS.
|
||||
|
||||
### Pairing code not received
|
||||
|
||||
- Verify the channel can send messages (HTTP allowlist includes `api.telegram.org`)
|
||||
- Check `dm_policy` is `pairing` (not `allowlist` which blocks without reply)
|
||||
|
||||
### Group mentions not working
|
||||
|
||||
- Set `bot_username` in config to your bot's username (e.g., `MyIronClawBot`)
|
||||
- Ensure the message contains `@YourBot` or starts with `/`
|
||||
|
||||
### "Connection refused" when starting
|
||||
|
||||
- For webhook mode: Start your tunnel before `ironclaw run`
|
||||
- For polling only: No tunnel needed; ignore tunnel-related warnings
|
||||
@@ -1,10 +0,0 @@
|
||||
-- Add project_dir and user_id columns for sandbox job tracking.
|
||||
-- user_id was previously hardcoded to "default" in the Rust layer;
|
||||
-- now it's persisted so we can filter per-user.
|
||||
|
||||
ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS project_dir TEXT;
|
||||
ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS user_id TEXT NOT NULL DEFAULT 'default';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_jobs_source ON agent_jobs(source);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_jobs_user ON agent_jobs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_jobs_created ON agent_jobs(created_at DESC);
|
||||
@@ -1,14 +0,0 @@
|
||||
-- Track which mode a sandbox job uses (worker vs claude_code).
|
||||
ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS job_mode TEXT NOT NULL DEFAULT 'worker';
|
||||
|
||||
-- Persist Claude Code streaming events so they survive restarts and can be
|
||||
-- loaded when the frontend opens a job detail view after the fact.
|
||||
CREATE TABLE IF NOT EXISTS claude_code_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
job_id UUID NOT NULL REFERENCES agent_jobs(id),
|
||||
event_type TEXT NOT NULL,
|
||||
data JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cc_events_job ON claude_code_events(job_id, id);
|
||||
@@ -1,73 +0,0 @@
|
||||
-- Routines: scheduled and reactive job system.
|
||||
--
|
||||
-- A routine is a named, persistent, user-owned task with a trigger and an action.
|
||||
-- Triggers fire independently (cron, event, webhook, manual) so only the
|
||||
-- relevant routine's prompt hits the LLM, not the whole checklist.
|
||||
|
||||
CREATE TABLE routines (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
user_id TEXT NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
-- Trigger definition
|
||||
trigger_type TEXT NOT NULL, -- 'cron', 'event', 'webhook', 'manual'
|
||||
trigger_config JSONB NOT NULL, -- type-specific config (schedule, pattern, etc.)
|
||||
|
||||
-- Action definition
|
||||
action_type TEXT NOT NULL, -- 'lightweight', 'full_job'
|
||||
action_config JSONB NOT NULL, -- prompt, context_paths, max_tokens / title, max_iterations
|
||||
|
||||
-- Guardrails
|
||||
cooldown_secs INTEGER NOT NULL DEFAULT 300,
|
||||
max_concurrent INTEGER NOT NULL DEFAULT 1,
|
||||
dedup_window_secs INTEGER, -- NULL = no dedup
|
||||
|
||||
-- Notification preferences
|
||||
notify_channel TEXT, -- NULL = use default
|
||||
notify_user TEXT NOT NULL DEFAULT 'default',
|
||||
notify_on_success BOOLEAN NOT NULL DEFAULT false,
|
||||
notify_on_failure BOOLEAN NOT NULL DEFAULT true,
|
||||
notify_on_attention BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
-- Runtime state (updated by engine)
|
||||
state JSONB NOT NULL DEFAULT '{}',
|
||||
last_run_at TIMESTAMPTZ,
|
||||
next_fire_at TIMESTAMPTZ, -- pre-computed for cron triggers
|
||||
run_count BIGINT NOT NULL DEFAULT 0,
|
||||
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
UNIQUE (user_id, name)
|
||||
);
|
||||
|
||||
-- Fast lookup: "which cron routines need to fire right now?"
|
||||
CREATE INDEX idx_routines_next_fire
|
||||
ON routines (next_fire_at)
|
||||
WHERE enabled AND next_fire_at IS NOT NULL;
|
||||
|
||||
-- Fast lookup: event triggers for a user
|
||||
CREATE INDEX idx_routines_event_triggers
|
||||
ON routines (user_id)
|
||||
WHERE enabled AND trigger_type = 'event';
|
||||
|
||||
-- Audit log of individual routine executions.
|
||||
CREATE TABLE routine_runs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
routine_id UUID NOT NULL REFERENCES routines(id) ON DELETE CASCADE,
|
||||
trigger_type TEXT NOT NULL,
|
||||
trigger_detail TEXT, -- e.g. matched message preview, cron expression
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
status TEXT NOT NULL DEFAULT 'running', -- running, ok, attention, failed
|
||||
result_summary TEXT,
|
||||
tokens_used INTEGER,
|
||||
job_id UUID REFERENCES agent_jobs(id), -- non-NULL for full_job runs
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_routine_runs_routine ON routine_runs (routine_id);
|
||||
CREATE INDEX idx_routine_runs_status ON routine_runs (status) WHERE status = 'running';
|
||||
@@ -1,3 +0,0 @@
|
||||
-- Rename claude_code_events to job_events (generic for all sandbox job types).
|
||||
ALTER TABLE claude_code_events RENAME TO job_events;
|
||||
ALTER INDEX idx_cc_events_job RENAME TO idx_job_events_job;
|
||||
@@ -1,16 +0,0 @@
|
||||
-- Settings table: key-value store for all user configuration.
|
||||
--
|
||||
-- Replaces ~/.ironclaw/settings.json, session.json, and mcp-servers.json.
|
||||
-- Keys use dotted paths matching the existing Settings.get()/set() convention
|
||||
-- (e.g., "agent.name", "sandbox.enabled", "mcp_servers").
|
||||
-- One row per setting so individual values can be updated atomically.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
user_id TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value JSONB NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (user_id, key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_settings_user ON settings (user_id);
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
"channels/telegram",
|
||||
"channels/slack"
|
||||
],
|
||||
"shared_auth": null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"name": "discord",
|
||||
"display_name": "Discord",
|
||||
"kind": "channel",
|
||||
"version": "0.1.0",
|
||||
"description": "Discord Gateway/Webhook channel for slash commands, buttons, and messages",
|
||||
"keywords": ["messaging", "chat", "discord", "bot"],
|
||||
|
||||
"source": {
|
||||
"dir": "channels-src/discord",
|
||||
"capabilities": "discord.capabilities.json",
|
||||
"crate_name": "discord-channel"
|
||||
},
|
||||
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": null,
|
||||
"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,31 +0,0 @@
|
||||
{
|
||||
"name": "slack",
|
||||
"display_name": "Slack",
|
||||
"kind": "channel",
|
||||
"version": "0.1.0",
|
||||
"description": "Slack Events API channel for receiving and responding to Slack messages",
|
||||
"keywords": ["messaging", "chat", "workspace", "slack"],
|
||||
|
||||
"source": {
|
||||
"dir": "channels-src/slack",
|
||||
"capabilities": "slack.capabilities.json",
|
||||
"crate_name": "slack-channel"
|
||||
},
|
||||
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": null,
|
||||
"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,31 +0,0 @@
|
||||
{
|
||||
"name": "telegram",
|
||||
"display_name": "Telegram",
|
||||
"kind": "channel",
|
||||
"version": "0.1.0",
|
||||
"description": "Telegram Bot API channel for receiving and responding to messages",
|
||||
"keywords": ["messaging", "bot", "chat", "telegram"],
|
||||
|
||||
"source": {
|
||||
"dir": "channels-src/telegram",
|
||||
"capabilities": "telegram.capabilities.json",
|
||||
"crate_name": "telegram-channel"
|
||||
},
|
||||
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": null,
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
|
||||
"auth_summary": {
|
||||
"method": "manual",
|
||||
"provider": "Telegram",
|
||||
"secrets": ["telegram_bot_token"],
|
||||
"shared_auth": null,
|
||||
"setup_url": "https://t.me/BotFather"
|
||||
},
|
||||
|
||||
"tags": ["default", "messaging"]
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"name": "whatsapp",
|
||||
"display_name": "WhatsApp",
|
||||
"kind": "channel",
|
||||
"version": "0.1.0",
|
||||
"description": "WhatsApp Cloud API channel for receiving and responding to messages",
|
||||
"keywords": ["messaging", "chat", "whatsapp", "meta"],
|
||||
|
||||
"source": {
|
||||
"dir": "channels-src/whatsapp",
|
||||
"capabilities": "whatsapp.capabilities.json",
|
||||
"crate_name": "whatsapp-channel"
|
||||
},
|
||||
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": null,
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
|
||||
"auth_summary": {
|
||||
"method": "manual",
|
||||
"provider": "Meta",
|
||||
"secrets": ["whatsapp_access_token", "whatsapp_verify_token"],
|
||||
"shared_auth": null,
|
||||
"setup_url": "https://developers.facebook.com/apps/"
|
||||
},
|
||||
|
||||
"tags": ["messaging"]
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"name": "github",
|
||||
"display_name": "GitHub",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"description": "GitHub integration for issues, PRs, repos, and code search",
|
||||
"keywords": ["git", "code", "issues", "pull-requests", "repositories"],
|
||||
|
||||
"source": {
|
||||
"dir": "tools-src/github",
|
||||
"capabilities": "github-tool.capabilities.json",
|
||||
"crate_name": "github-tool"
|
||||
},
|
||||
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": null,
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
|
||||
"auth_summary": {
|
||||
"method": "manual",
|
||||
"provider": "GitHub",
|
||||
"secrets": ["github_token"],
|
||||
"shared_auth": null,
|
||||
"setup_url": "https://github.com/settings/tokens"
|
||||
},
|
||||
|
||||
"tags": ["default", "development"]
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"name": "gmail",
|
||||
"display_name": "Gmail",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"description": "Read, send, and manage Gmail messages and threads",
|
||||
"keywords": ["email", "google", "mail", "messaging"],
|
||||
|
||||
"source": {
|
||||
"dir": "tools-src/gmail",
|
||||
"capabilities": "gmail-tool.capabilities.json",
|
||||
"crate_name": "gmail-tool"
|
||||
},
|
||||
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": null,
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
|
||||
"auth_summary": {
|
||||
"method": "oauth",
|
||||
"provider": "Google",
|
||||
"secrets": ["google_oauth_token"],
|
||||
"shared_auth": "google_oauth_token",
|
||||
"setup_url": "https://console.cloud.google.com/apis/credentials"
|
||||
},
|
||||
|
||||
"tags": ["default", "google", "messaging"]
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"name": "google-calendar",
|
||||
"display_name": "Google Calendar",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"description": "Create, read, update, and delete Google Calendar events",
|
||||
"keywords": ["calendar", "google", "scheduling", "events"],
|
||||
|
||||
"source": {
|
||||
"dir": "tools-src/google-calendar",
|
||||
"capabilities": "google-calendar-tool.capabilities.json",
|
||||
"crate_name": "google-calendar-tool"
|
||||
},
|
||||
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": null,
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
|
||||
"auth_summary": {
|
||||
"method": "oauth",
|
||||
"provider": "Google",
|
||||
"secrets": ["google_oauth_token"],
|
||||
"shared_auth": "google_oauth_token",
|
||||
"setup_url": "https://console.cloud.google.com/apis/credentials"
|
||||
},
|
||||
|
||||
"tags": ["default", "google", "productivity"]
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"name": "google-docs",
|
||||
"display_name": "Google Docs",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"description": "Create and edit Google Docs documents",
|
||||
"keywords": ["documents", "google", "writing", "docs"],
|
||||
|
||||
"source": {
|
||||
"dir": "tools-src/google-docs",
|
||||
"capabilities": "google-docs-tool.capabilities.json",
|
||||
"crate_name": "google-docs-tool"
|
||||
},
|
||||
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": null,
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
|
||||
"auth_summary": {
|
||||
"method": "oauth",
|
||||
"provider": "Google",
|
||||
"secrets": ["google_oauth_token"],
|
||||
"shared_auth": "google_oauth_token",
|
||||
"setup_url": "https://console.cloud.google.com/apis/credentials"
|
||||
},
|
||||
|
||||
"tags": ["google", "productivity"]
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"name": "google-drive",
|
||||
"display_name": "Google Drive",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"description": "Upload, download, search, and manage Google Drive files and folders",
|
||||
"keywords": ["storage", "google", "files", "drive"],
|
||||
|
||||
"source": {
|
||||
"dir": "tools-src/google-drive",
|
||||
"capabilities": "google-drive-tool.capabilities.json",
|
||||
"crate_name": "google-drive-tool"
|
||||
},
|
||||
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": null,
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
|
||||
"auth_summary": {
|
||||
"method": "oauth",
|
||||
"provider": "Google",
|
||||
"secrets": ["google_oauth_token"],
|
||||
"shared_auth": "google_oauth_token",
|
||||
"setup_url": "https://console.cloud.google.com/apis/credentials"
|
||||
},
|
||||
|
||||
"tags": ["default", "google", "storage"]
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"name": "google-sheets",
|
||||
"display_name": "Google Sheets",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"description": "Read and write Google Sheets spreadsheet data",
|
||||
"keywords": ["spreadsheets", "google", "data", "sheets"],
|
||||
|
||||
"source": {
|
||||
"dir": "tools-src/google-sheets",
|
||||
"capabilities": "google-sheets-tool.capabilities.json",
|
||||
"crate_name": "google-sheets-tool"
|
||||
},
|
||||
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": null,
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
|
||||
"auth_summary": {
|
||||
"method": "oauth",
|
||||
"provider": "Google",
|
||||
"secrets": ["google_oauth_token"],
|
||||
"shared_auth": "google_oauth_token",
|
||||
"setup_url": "https://console.cloud.google.com/apis/credentials"
|
||||
},
|
||||
|
||||
"tags": ["google", "productivity"]
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"name": "google-slides",
|
||||
"display_name": "Google Slides",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"description": "Create and edit Google Slides presentations",
|
||||
"keywords": ["presentations", "google", "slides"],
|
||||
|
||||
"source": {
|
||||
"dir": "tools-src/google-slides",
|
||||
"capabilities": "google-slides-tool.capabilities.json",
|
||||
"crate_name": "google-slides-tool"
|
||||
},
|
||||
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": null,
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
|
||||
"auth_summary": {
|
||||
"method": "oauth",
|
||||
"provider": "Google",
|
||||
"secrets": ["google_oauth_token"],
|
||||
"shared_auth": "google_oauth_token",
|
||||
"setup_url": "https://console.cloud.google.com/apis/credentials"
|
||||
},
|
||||
|
||||
"tags": ["google", "productivity"]
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"name": "okta",
|
||||
"display_name": "Okta",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"description": "Okta SSO for user profile, app catalog, and SSO launch links",
|
||||
"keywords": ["sso", "identity", "authentication", "okta"],
|
||||
|
||||
"source": {
|
||||
"dir": "tools-src/okta",
|
||||
"capabilities": "okta-tool.capabilities.json",
|
||||
"crate_name": "okta-tool"
|
||||
},
|
||||
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": null,
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
|
||||
"auth_summary": {
|
||||
"method": "oauth",
|
||||
"provider": "Okta",
|
||||
"secrets": ["okta_oauth_token"],
|
||||
"shared_auth": null,
|
||||
"setup_url": "https://developer.okta.com/docs/guides/implement-oauth-for-okta/main/"
|
||||
},
|
||||
|
||||
"tags": ["identity"]
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"name": "slack",
|
||||
"display_name": "Slack",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"description": "Post messages, read channels, and manage conversations via Slack API",
|
||||
"keywords": ["messaging", "chat", "workspace"],
|
||||
|
||||
"source": {
|
||||
"dir": "tools-src/slack",
|
||||
"capabilities": "slack-tool.capabilities.json",
|
||||
"crate_name": "slack-tool"
|
||||
},
|
||||
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": null,
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
|
||||
"auth_summary": {
|
||||
"method": "oauth",
|
||||
"provider": "Slack",
|
||||
"secrets": ["slack_bot_token"],
|
||||
"shared_auth": null,
|
||||
"setup_url": "https://api.slack.com/apps"
|
||||
},
|
||||
|
||||
"tags": ["default", "messaging"]
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"name": "telegram",
|
||||
"display_name": "Telegram",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"description": "Telegram user-mode integration via MTProto for messages and contacts",
|
||||
"keywords": ["messaging", "chat", "telegram", "mtproto"],
|
||||
|
||||
"source": {
|
||||
"dir": "tools-src/telegram",
|
||||
"capabilities": "telegram-tool.capabilities.json",
|
||||
"crate_name": "telegram-tool"
|
||||
},
|
||||
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": null,
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
|
||||
"auth_summary": {
|
||||
"method": "manual",
|
||||
"provider": "Telegram",
|
||||
"secrets": ["telegram_api_id", "telegram_api_hash"],
|
||||
"shared_auth": null,
|
||||
"setup_url": "https://my.telegram.org/apps"
|
||||
},
|
||||
|
||||
"tags": ["messaging"]
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
[workspace]
|
||||
git_release_enable = false
|
||||
@@ -1,21 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build IronClaw and all bundled channels.
|
||||
#
|
||||
# Run this before release or when channel sources have changed.
|
||||
# The main binary bundles telegram.wasm via include_bytes!; it must exist.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "Building bundled channels..."
|
||||
if [ -d "channels-src/telegram" ]; then
|
||||
./channels-src/telegram/build.sh
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Building IronClaw..."
|
||||
cargo build --release
|
||||
|
||||
echo ""
|
||||
echo "Done. Binary: target/release/ironclaw"
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Developer setup script for IronClaw.
|
||||
#
|
||||
# Gets a fresh checkout ready for development without requiring
|
||||
# Docker, PostgreSQL, or any external services.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/dev-setup.sh
|
||||
#
|
||||
# After running, you can:
|
||||
# cargo check # default features (postgres + libsql)
|
||||
# cargo test # default test suite (uses libsql temp DB)
|
||||
# cargo test --all-features # full test suite
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "=== IronClaw Developer Setup ==="
|
||||
echo ""
|
||||
|
||||
# 1. Check rustup
|
||||
if ! command -v rustup &>/dev/null; then
|
||||
echo "ERROR: rustup not found. Install from https://rustup.rs"
|
||||
exit 1
|
||||
fi
|
||||
echo "[1/5] rustup found: $(rustup --version 2>/dev/null | head -1)"
|
||||
|
||||
# 2. Add WASM target (required by build.rs for channel compilation)
|
||||
echo "[2/5] Adding wasm32-wasip2 target..."
|
||||
rustup target add wasm32-wasip2
|
||||
|
||||
# 3. Install wasm-tools (required by build.rs for WASM component model)
|
||||
echo "[3/5] Installing wasm-tools..."
|
||||
if command -v wasm-tools &>/dev/null; then
|
||||
echo " wasm-tools already installed: $(wasm-tools --version)"
|
||||
else
|
||||
cargo install wasm-tools --locked
|
||||
fi
|
||||
|
||||
# 4. Verify the project compiles
|
||||
echo "[4/5] Running cargo check..."
|
||||
cargo check
|
||||
|
||||
# 5. Run tests using libsql temp DB (no Docker/external DB needed)
|
||||
echo "[5/5] Running tests (no external DB required)..."
|
||||
cargo test
|
||||
|
||||
echo ""
|
||||
echo "=== Setup complete ==="
|
||||
echo ""
|
||||
echo "Quick start:"
|
||||
echo " cargo run # Run with default features"
|
||||
echo " cargo test # Test suite (libsql temp DB)"
|
||||
echo " cargo test --all-features # Full test suite"
|
||||
echo " cargo clippy --all-features # Lint all code"
|
||||
@@ -1,566 +0,0 @@
|
||||
# IronClaw Network Security Reference
|
||||
|
||||
This document catalogs every network-facing surface in IronClaw, its authentication mechanism, bind address, security controls, and known findings. Use this as the authoritative reference during code reviews that touch network-facing code.
|
||||
|
||||
**Last updated:** 2026-02-18
|
||||
|
||||
---
|
||||
|
||||
## Threat Model
|
||||
|
||||
IronClaw operates across four trust boundaries:
|
||||
|
||||
| Boundary | Trust Level | Examples |
|
||||
|----------|------------|---------|
|
||||
| **Local user** | Fully trusted | TUI, web gateway (loopback), CLI commands |
|
||||
| **Browser client** | Authenticated | Web UI connected via bearer token; subject to CORS, Origin validation, CSRF protections |
|
||||
| **Docker containers** | Untrusted (sandboxed) | Worker containers executing user jobs; isolated via per-job tokens, allowlisted egress, dropped capabilities |
|
||||
| **External services** | Untrusted | Webhook senders (Telegram, Slack); authenticated via shared secret |
|
||||
|
||||
**Key assumptions:**
|
||||
|
||||
- The local machine is single-user. The web gateway and OAuth listener bind to loopback and do not defend against other local users.
|
||||
- Docker containers are adversarial. A compromised container should not be able to access other jobs, exfiltrate secrets, or reach the host network beyond the orchestrator API.
|
||||
- Webhook senders must prove knowledge of the shared secret. The secret is never transmitted in the clear by IronClaw itself.
|
||||
- MCP server URLs are operator-configured and treated as trusted destinations (see [MCP Client](#mcp-client)).
|
||||
|
||||
---
|
||||
|
||||
## Network Surface Inventory
|
||||
|
||||
| Listener | Default Port | Default Bind | Auth Mechanism | Config Env Var | Source |
|
||||
|----------|-------------|-------------|----------------|----------------|--------|
|
||||
| Web Gateway | 3000 | `127.0.0.1` | Bearer token (constant-time) | `GATEWAY_HOST`, `GATEWAY_PORT`, `GATEWAY_AUTH_TOKEN` | `server.rs` — `start_server()` |
|
||||
| HTTP Webhook Server | 8080 | `0.0.0.0` | Shared secret (body field) | `HTTP_HOST`, `HTTP_PORT`, `HTTP_WEBHOOK_SECRET` | `webhook_server.rs` — `start()` |
|
||||
| Orchestrator Internal API | 50051 | `127.0.0.1` (macOS/Win) / `0.0.0.0` (Linux) | Per-job bearer token (constant-time) | `ORCHESTRATOR_PORT` | `api.rs` — `OrchestratorApi::start()` |
|
||||
| OAuth Callback Listener | 9876 | `127.0.0.1` | None (ephemeral, 5-min timeout) | N/A (hardcoded) | `oauth_defaults.rs` — `bind_callback_listener()` |
|
||||
| Sandbox HTTP Proxy | OS-assigned (ephemeral) | `127.0.0.1` | None (loopback only) | N/A (auto-assigned) | `proxy/http.rs` — `SandboxProxy::start()` |
|
||||
|
||||
---
|
||||
|
||||
## 1. Web Gateway
|
||||
|
||||
**Source:** `src/channels/web/server.rs`, `src/channels/web/auth.rs`
|
||||
|
||||
### Bind Address
|
||||
|
||||
Configurable via `GATEWAY_HOST` (default `127.0.0.1`) and `GATEWAY_PORT` (default `3000`). The gateway is designed as a local-first, single-user service.
|
||||
|
||||
**Reference:** `src/config.rs` — `gateway_host` default (`"127.0.0.1"`), `gateway_port` default (`3000`)
|
||||
|
||||
### Authentication
|
||||
|
||||
Bearer token middleware applied to all `/api/*` routes via `route_layer`. Token checked in two locations:
|
||||
|
||||
1. `Authorization: Bearer <token>` header (primary)
|
||||
2. `?token=<token>` query parameter (fallback for SSE `EventSource` which cannot set headers)
|
||||
|
||||
Both paths use **constant-time comparison** via `subtle::ConstantTimeEq` (`ct_eq`).
|
||||
|
||||
**Reference:** `src/channels/web/auth.rs` — `auth_middleware()`, header check and query-param fallback both use `ct_eq`
|
||||
|
||||
If `GATEWAY_AUTH_TOKEN` is not set, a random hex token is generated at startup.
|
||||
|
||||
### Unauthenticated Routes
|
||||
|
||||
| Route | Purpose | Response |
|
||||
|-------|---------|----------|
|
||||
| `/api/health` | Health check endpoint | `{"status":"healthy","channel":"gateway"}` — no version, uptime, or fingerprinting data |
|
||||
| `/` | Static HTML (embedded) | Single-page app shell |
|
||||
| `/style.css` | Static CSS (embedded) | Stylesheet |
|
||||
| `/app.js` | Static JS (embedded) | Client-side app |
|
||||
|
||||
### CORS Policy
|
||||
|
||||
Restricted to a two-origin allowlist (not browser same-origin policy, but a CORS allowlist that achieves equivalent protection):
|
||||
|
||||
- `http://<bind_ip>:<bind_port>`
|
||||
- `http://localhost:<bind_port>`
|
||||
|
||||
Allowed methods: `GET`, `POST`, `PUT`, `DELETE`. Allowed headers: `Content-Type`, `Authorization`. Credentials allowed.
|
||||
|
||||
**Reference:** `src/channels/web/server.rs` — `CorsLayer::new()` block
|
||||
|
||||
### WebSocket Origin Validation
|
||||
|
||||
The `/api/chat/ws` endpoint has two layers of protection:
|
||||
|
||||
1. **Bearer token auth** — the route is inside the `protected` router with `route_layer`, so `auth_middleware` runs before the handler. The token is passed via the `Authorization: Bearer` header on the HTTP upgrade request (not via query parameter).
|
||||
|
||||
2. **Origin header validation** (inside the handler) as a defense-in-depth guard against cross-site WebSocket hijacking (CSWSH):
|
||||
- Origin header is **required** — missing Origin returns 403 (browsers always send it for WS upgrades; absence implies a non-browser client)
|
||||
- Origin host is extracted by stripping scheme and port, then compared **exactly** against `localhost`, `127.0.0.1`, and `[::1]`
|
||||
- Partial matches like `localhost.evil.com` are rejected because the check extracts the host portion before the first `:` or `/`
|
||||
|
||||
**Reference:** `src/channels/web/server.rs` — `chat_ws_handler()` (origin validation block)
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
Chat endpoint (`/api/chat/send`) enforces a sliding-window rate limit: **30 requests per 60 seconds** (global, not per-IP — single-user gateway).
|
||||
|
||||
**Reference:** `src/channels/web/server.rs` — `RateLimiter` struct, `chat_rate_limiter` field
|
||||
|
||||
### Body Limits
|
||||
|
||||
- Global: **1 MB** max request body (`DefaultBodyLimit::max(1024 * 1024)`)
|
||||
- **Reference:** `src/channels/web/server.rs` — `.layer(DefaultBodyLimit::max(...))`
|
||||
|
||||
### Project File Serving
|
||||
|
||||
The `/projects/{project_id}/*` routes serve files from project directories. These are **behind auth middleware** to prevent unauthorized file access.
|
||||
|
||||
**Reference:** `src/channels/web/server.rs` — project file routes in `protected` router
|
||||
|
||||
### Security Headers
|
||||
|
||||
The gateway sets the following security headers on all responses (via `SetResponseHeaderLayer::if_not_present`, so handlers can override):
|
||||
|
||||
- `X-Content-Type-Options: nosniff` — prevents MIME-sniffing
|
||||
- `X-Frame-Options: DENY` — prevents clickjacking via iframes
|
||||
|
||||
**Reference:** `src/channels/web/server.rs` — `SetResponseHeaderLayer` calls
|
||||
|
||||
### Graceful Shutdown
|
||||
|
||||
Shutdown is triggered via a `oneshot::Sender` stored in `GatewayState::shutdown_tx`. The server uses `axum::serve(...).with_graceful_shutdown(...)` to drain in-flight requests before closing the listener.
|
||||
|
||||
**Reference:** `src/channels/web/server.rs` — `shutdown_tx` / `shutdown_rx` setup
|
||||
|
||||
---
|
||||
|
||||
## 2. HTTP Webhook Server
|
||||
|
||||
**Source:** `src/channels/webhook_server.rs`, `src/channels/http.rs`
|
||||
|
||||
### Bind Address
|
||||
|
||||
Configurable via `HTTP_HOST` (default `0.0.0.0`) and `HTTP_PORT` (default `8080`).
|
||||
|
||||
**WARNING:** The default bind address is `0.0.0.0`, meaning the webhook server listens on **all interfaces** by default. This is intentional (webhooks must be reachable from external services like Telegram/Slack), but operators should be aware of the exposure.
|
||||
|
||||
**Reference:** `src/config.rs` — `http_host` default (`"0.0.0.0"`), `http_port` default (`8080`)
|
||||
|
||||
### Authentication
|
||||
|
||||
Webhook secret is passed **in the JSON request body** (`secret` field), not as a header. The secret is compared using **constant-time** `subtle::ConstantTimeEq` (`ct_eq`).
|
||||
|
||||
The secret is required to start the channel — if `HTTP_WEBHOOK_SECRET` is not set, `start()` returns an error.
|
||||
|
||||
**CSRF note:** Because the secret is in the JSON body (not a cookie or header that browsers auto-attach), a cross-origin form POST cannot forge a valid request. Browsers would send `application/x-www-form-urlencoded`, which the `Json<T>` extractor rejects with HTTP 415. Even if `Content-Type` were spoofed via CORS preflight, the attacker would need the secret value, which is never stored in the browser.
|
||||
|
||||
**Reference:** `src/channels/http.rs` — `webhook_handler()` (secret validation with `ct_eq`), `start()` (required-secret check)
|
||||
|
||||
### Content-Type Validation
|
||||
|
||||
The webhook endpoint uses axum's `Json<WebhookRequest>` extractor, which enforces `Content-Type: application/json`. Requests with missing or incorrect Content-Type are rejected with **HTTP 415 Unsupported Media Type** before the handler body executes. Malformed JSON bodies are rejected with **HTTP 422 Unprocessable Entity**.
|
||||
|
||||
**Reference:** `src/channels/http.rs` — `webhook_handler()` function signature (`Json(req): Json<WebhookRequest>`)
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
**60 requests per minute**, enforced via a mutex-protected sliding window.
|
||||
|
||||
**Reference:** `src/channels/http.rs` — `MAX_REQUESTS_PER_MINUTE` constant, rate-limit check in `webhook_handler()`
|
||||
|
||||
### Body Limits
|
||||
|
||||
- JSON body: **64 KB** max (`MAX_BODY_BYTES`)
|
||||
- Message content: **32 KB** max (`MAX_CONTENT_BYTES`)
|
||||
- Pending synchronous responses: **100 max** (`MAX_PENDING_RESPONSES`)
|
||||
- Synchronous response timeout: **60 seconds**
|
||||
|
||||
**Reference:** `src/channels/http.rs` — constants block (`MAX_BODY_BYTES`, `MAX_CONTENT_BYTES`, `MAX_PENDING_RESPONSES`, `MAX_REQUESTS_PER_MINUTE`)
|
||||
|
||||
### Routes
|
||||
|
||||
| Route | Auth | Purpose | Response |
|
||||
|-------|------|---------|----------|
|
||||
| `/health` | None | Health check | `{"status":"healthy","channel":"http"}` — no fingerprinting data |
|
||||
| `/webhook` | Webhook secret | Receive messages | Webhook response |
|
||||
|
||||
### Graceful Shutdown
|
||||
|
||||
Shutdown is triggered via a `oneshot::Sender` stored on the `WebhookServer` struct. The server uses `axum::serve(...).with_graceful_shutdown(...)`. The public `shutdown()` method sends the signal and awaits the task join handle, ensuring a clean drain-and-wait.
|
||||
|
||||
**Reference:** `src/channels/webhook_server.rs` — `shutdown()` method
|
||||
|
||||
---
|
||||
|
||||
## 3. Orchestrator Internal API
|
||||
|
||||
**Source:** `src/orchestrator/api.rs`, `src/orchestrator/auth.rs`
|
||||
|
||||
### Bind Address
|
||||
|
||||
Platform-dependent:
|
||||
|
||||
- **macOS / Windows**: `127.0.0.1:<port>` — Docker Desktop routes `host.docker.internal` through its VM to `127.0.0.1`
|
||||
- **Linux**: `0.0.0.0:<port>` — containers reach the host via the Docker bridge gateway (`172.17.0.1`), which is not loopback
|
||||
|
||||
Default port: `50051`.
|
||||
|
||||
**Reference:** `src/orchestrator/api.rs` — `OrchestratorApi::start()`, platform-conditional bind address block
|
||||
|
||||
### Authentication
|
||||
|
||||
Per-job bearer tokens validated by `worker_auth_middleware`:
|
||||
|
||||
1. Tokens are **cryptographically random** (32 bytes, hex-encoded = 64 chars)
|
||||
2. Tokens are **scoped to a specific job_id** — a token for job A cannot access endpoints for job B
|
||||
3. Comparison uses **constant-time** `subtle::ConstantTimeEq`
|
||||
4. Tokens are **ephemeral** (in-memory only, never persisted to disk or DB)
|
||||
5. Tokens and associated credential grants are **revoked** when the container is cleaned up
|
||||
|
||||
**Reference:** `src/orchestrator/auth.rs` — `TokenStore::create_token()`, `TokenStore::validate()`, `generate_token()`
|
||||
|
||||
### Token Extraction
|
||||
|
||||
The middleware extracts the job UUID from the URL path (`/worker/{job_id}/...`) and validates the `Authorization: Bearer` header against the stored token for that specific job.
|
||||
|
||||
**Reference:** `src/orchestrator/auth.rs` — `worker_auth_middleware()`, `extract_job_id_from_path()`
|
||||
|
||||
### Credential Grants
|
||||
|
||||
The orchestrator can grant per-job access to specific secrets from the encrypted secrets store. Grants are:
|
||||
|
||||
- Stored alongside the token in the `TokenStore`
|
||||
- Scoped to specific `(secret_name, env_var)` pairs
|
||||
- Revoked when the job token is revoked
|
||||
- Decrypted on-demand when the worker requests `/worker/{job_id}/credentials`
|
||||
|
||||
**Reference:** `src/orchestrator/auth.rs` — `CredentialGrant` struct, `src/orchestrator/api.rs` — `get_credentials_handler()`
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
**None.** The orchestrator API has no rate limiting. All `/worker/*` endpoints are authenticated via per-job bearer tokens, but a compromised container could spam authenticated endpoints without throttling.
|
||||
|
||||
**Mitigation:** Tokens are scoped per-job so a compromised container can only abuse its own job's endpoints. Container execution is time-bounded (see [Docker Container Security](#docker-container-security)), which limits the window for abuse.
|
||||
|
||||
### Routes
|
||||
|
||||
| Route | Auth | Purpose | Response |
|
||||
|-------|------|---------|----------|
|
||||
| `/health` | None | Health check | `"ok"` (plain text) — no fingerprinting data |
|
||||
| `/worker/{job_id}/job` | Per-job token | Get job description | Job JSON |
|
||||
| `/worker/{job_id}/llm/complete` | Per-job token | Proxy LLM completion | LLM response |
|
||||
| `/worker/{job_id}/llm/complete_with_tools` | Per-job token | Proxy LLM tool completion | LLM response |
|
||||
| `/worker/{job_id}/status` | Per-job token | Report worker status | Ack |
|
||||
| `/worker/{job_id}/complete` | Per-job token | Report job completion | Ack |
|
||||
| `/worker/{job_id}/event` | Per-job token | Send job events (SSE broadcast) | Ack |
|
||||
| `/worker/{job_id}/prompt` | Per-job token | Poll for follow-up prompts | Prompt or empty |
|
||||
| `/worker/{job_id}/credentials` | Per-job token | Retrieve decrypted credentials | Credentials JSON |
|
||||
|
||||
### Graceful Shutdown
|
||||
|
||||
**None.** The orchestrator calls `axum::serve(listener, router).await?` without `.with_graceful_shutdown()`. The server stops only when the task is dropped (process exit or tokio task cancellation). In-flight requests may be interrupted.
|
||||
|
||||
**Reference:** `src/orchestrator/api.rs` — `OrchestratorApi::start()`
|
||||
|
||||
---
|
||||
|
||||
## 4. OAuth Callback Listener
|
||||
|
||||
**Source:** `src/cli/oauth_defaults.rs`
|
||||
|
||||
### Bind Address
|
||||
|
||||
Always binds to **loopback only**: `127.0.0.1:9876`. Falls back to `[::1]:9876` (IPv6 loopback) if IPv4 binding fails for reasons other than `AddrInUse`. If the port is already in use, the error is returned immediately (fail-fast).
|
||||
|
||||
Both IPv4 and IPv6 loopback addresses are security-equivalent — they are only reachable from the local machine.
|
||||
|
||||
**Reference:** `src/cli/oauth_defaults.rs` — `OAUTH_CALLBACK_PORT` constant, `bind_callback_listener()`
|
||||
|
||||
### Lifecycle
|
||||
|
||||
The listener is **ephemeral** — it is started only when an OAuth flow is initiated (e.g., `ironclaw tool auth <name>`) and shut down after the callback is received or the timeout expires.
|
||||
|
||||
### Timeout
|
||||
|
||||
**5-minute timeout** (`Duration::from_secs(300)`). If the user does not complete the OAuth flow in the browser within 5 minutes, the listener shuts down.
|
||||
|
||||
**Reference:** `src/cli/oauth_defaults.rs` — `tokio::time::timeout(Duration::from_secs(300), ...)`
|
||||
|
||||
### Security Controls
|
||||
|
||||
- **HTML escaping**: Provider names displayed in the landing page are HTML-escaped to prevent XSS (escapes `&`, `<`, `>`, `"`, `'`)
|
||||
- **Error parameter checking**: The handler checks for `error=` in the callback query string before extracting the auth code
|
||||
- **URL decoding**: Callback parameters are URL-decoded safely
|
||||
|
||||
**Reference:** `src/cli/oauth_defaults.rs` — `html_escape()`
|
||||
|
||||
### Built-in OAuth Credentials
|
||||
|
||||
Google OAuth client ID and secret are compiled into the binary (with compile-time override via `IRONCLAW_GOOGLE_CLIENT_ID` / `IRONCLAW_GOOGLE_CLIENT_SECRET`). As noted in the source, Google Desktop App client secrets are [not actually secret](https://developers.google.com/identity/protocols/oauth2/native-app) per Google's documentation.
|
||||
|
||||
**Reference:** `src/cli/oauth_defaults.rs` — `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` constants
|
||||
|
||||
### Graceful Shutdown
|
||||
|
||||
Implicit. The listener is a raw `TcpListener` (not axum) inside a `tokio::time::timeout` future. Once the authorization code or error is received, the future returns and the `TcpListener` is dropped, closing the port. No explicit shutdown signal is needed.
|
||||
|
||||
**Reference:** `src/cli/oauth_defaults.rs` — `wait_for_callback()`
|
||||
|
||||
---
|
||||
|
||||
## 5. Sandbox HTTP Proxy
|
||||
|
||||
**Source:** `src/sandbox/proxy/http.rs`, `src/sandbox/proxy/allowlist.rs`, `src/sandbox/proxy/policy.rs`
|
||||
|
||||
### Bind Address
|
||||
|
||||
Always binds to **`127.0.0.1`** (localhost only). Port is OS-assigned (port `0`, ephemeral). Falls back to `[::1]` (IPv6 loopback) if IPv4 is unavailable.
|
||||
|
||||
Both IPv4 and IPv6 loopback addresses are security-equivalent — they are only reachable from the local machine.
|
||||
|
||||
**Reference:** `src/sandbox/proxy/http.rs` — `SandboxProxy::start()`, `TcpListener::bind("127.0.0.1:0")`
|
||||
|
||||
### Purpose
|
||||
|
||||
Acts as an HTTP/HTTPS proxy for Docker sandbox containers. Containers are configured with `http_proxy` / `https_proxy` environment variables pointing to this proxy, so all outbound HTTP traffic is routed through it.
|
||||
|
||||
### Domain Allowlisting
|
||||
|
||||
All requests are validated against a domain allowlist before being forwarded:
|
||||
|
||||
- **Empty allowlist = deny all** (fail-closed default)
|
||||
- Supports exact matches and wildcard patterns (`*.example.com`)
|
||||
- Validates URL scheme (HTTP/HTTPS only, rejects `ftp://`, `file://`, etc.)
|
||||
|
||||
**Reference:** `src/sandbox/proxy/allowlist.rs` — `DomainAllowlist` struct, `is_allowed()` method
|
||||
|
||||
### HTTPS Tunneling (CONNECT)
|
||||
|
||||
- CONNECT requests for HTTPS tunneling are subject to the same allowlist
|
||||
- **30-minute timeout** on established tunnels to prevent indefinite holds
|
||||
- **No MITM**: the proxy cannot inspect or inject credentials into HTTPS traffic (by design — containers that need credentials must use the orchestrator's `/worker/{job_id}/credentials` endpoint)
|
||||
|
||||
**Reference:** `src/sandbox/proxy/http.rs` — `handle_connect()` function
|
||||
|
||||
### Credential Injection (HTTP only)
|
||||
|
||||
For plain HTTP requests to allowed hosts, the proxy can inject credentials:
|
||||
|
||||
- Bearer tokens in `Authorization` header
|
||||
- Custom headers (e.g., `X-API-Key`)
|
||||
- Query parameters
|
||||
- Credentials are resolved at request time from the encrypted secrets store
|
||||
- Credentials never enter the container's environment or filesystem
|
||||
|
||||
**Reference:** `src/sandbox/proxy/http.rs` — credential injection block in `handle_request()`
|
||||
|
||||
### Hop-by-Hop Header Filtering
|
||||
|
||||
The proxy strips hop-by-hop headers to prevent header-based attacks: `connection`, `keep-alive`, `proxy-authenticate`, `proxy-authorization`, `te`, `trailers`, `transfer-encoding`, `upgrade`.
|
||||
|
||||
**Reference:** `src/sandbox/proxy/http.rs` — `is_hop_by_hop_header()`
|
||||
|
||||
### Docker Container Security
|
||||
|
||||
Containers that use the proxy are configured with defense-in-depth:
|
||||
|
||||
| Control | Setting | Reference |
|
||||
|---------|---------|-----------|
|
||||
| Capabilities | Drop ALL, add only CHOWN | `src/sandbox/container.rs` — `cap_drop` / `cap_add` |
|
||||
| Privilege escalation | `no-new-privileges:true` | `src/sandbox/container.rs` — `security_opt` |
|
||||
| Root filesystem | Read-only (except FullAccess policy) | `src/sandbox/container.rs` — `readonly_rootfs` |
|
||||
| User | Non-root (UID 1000:1000) | `src/sandbox/container.rs` — `user` field |
|
||||
| Network | Bridge mode (isolated) | `src/sandbox/container.rs` — `network_mode` |
|
||||
| Tmpfs | `/tmp` (512 MB), `/home/sandbox/.cargo/registry` (1 GB) | `src/sandbox/container.rs` — `tmpfs` block |
|
||||
| Auto-remove | Enabled | `src/sandbox/container.rs` — `auto_remove` |
|
||||
| Output limits | Configurable max stdout/stderr | `src/sandbox/container.rs` — `collect_logs()` |
|
||||
| Timeout | Enforced with forced container removal | `src/sandbox/container.rs` — `tokio::time::timeout` in `run()` |
|
||||
|
||||
### Graceful Shutdown
|
||||
|
||||
Shutdown is triggered via a `oneshot::Sender` stored on the proxy. The accept loop uses `tokio::select!` to race `listener.accept()` against the shutdown signal. The `stop()` method fires the signal; the loop breaks on the next iteration. Note: `stop()` does not await a join handle, so there is no drain-and-wait for in-flight connections.
|
||||
|
||||
**Reference:** `src/sandbox/proxy/http.rs` — `stop()` method, `tokio::select!` loop
|
||||
|
||||
---
|
||||
|
||||
## Egress Controls
|
||||
|
||||
### WASM Tool HTTP Requests
|
||||
|
||||
WASM tools execute HTTP requests through the host runtime, subject to:
|
||||
|
||||
1. **Endpoint allowlist** — declared in `<tool>.capabilities.json`, validated by `AllowlistValidator`
|
||||
- Host matching (exact or wildcard)
|
||||
- Path prefix matching
|
||||
- HTTP method restriction
|
||||
- HTTPS required by default
|
||||
- Userinfo in URLs (`user:pass@host`) rejected to prevent allowlist bypass
|
||||
- Path traversal (`../`, `%2e%2e/`) normalized and blocked
|
||||
- Invalid percent-encoding rejected
|
||||
- **Reference:** `src/tools/wasm/allowlist.rs`
|
||||
|
||||
2. **Credential injection** — secrets injected at the host boundary by `CredentialInjector`
|
||||
- WASM code never sees actual credential values
|
||||
- Secrets must be in the tool's `allowed_secrets` list
|
||||
- Injection supports: Bearer header, Basic auth, custom header, query parameter
|
||||
- **Reference:** `src/tools/wasm/credential_injector.rs`
|
||||
|
||||
3. **Leak detection** — `LeakDetector` scans both outbound requests and inbound responses for secret patterns
|
||||
- Runs at two points: before sending and after receiving
|
||||
- Uses Aho-Corasick for fast multi-pattern matching
|
||||
- **Reference:** `src/safety/leak_detector.rs`
|
||||
|
||||
### Built-in HTTP Tool
|
||||
|
||||
The `http` tool (`src/tools/builtin/http.rs`) has its own SSRF protections:
|
||||
|
||||
| Protection | Details | Reference |
|
||||
|-----------|---------|-----------|
|
||||
| HTTPS only | Rejects `http://` URLs | `http.rs` — scheme check |
|
||||
| Localhost blocked | Rejects `localhost` and `*.localhost` | `http.rs` — host check |
|
||||
| Private IP blocked | Rejects RFC 1918, loopback, link-local, multicast, unspecified | `http.rs` — `is_disallowed_ip()` |
|
||||
| DNS rebinding | Resolves hostname and checks all resolved IPs against blocklist | `http.rs` — DNS resolution block |
|
||||
| Cloud metadata | Blocks `169.254.169.254` (AWS/GCP metadata endpoint) | `http.rs` — `is_disallowed_ip()` |
|
||||
| Redirect blocking | Returns error on 3xx responses (prevents SSRF via redirect) | `http.rs` — status code check |
|
||||
| Response size limit | **5 MB** max, enforced both via Content-Length header and streaming | `http.rs` — `MAX_RESPONSE_SIZE` constant, streaming cap |
|
||||
| Outbound leak scan | Scans URL, headers, and body for secrets before sending | `http.rs` — `LeakDetector::scan_http_request()` |
|
||||
| Approval required | Requires user approval before execution | `http.rs` — `requires_approval()` returns `true` |
|
||||
| Timeout | 30 seconds default | `http.rs` — `reqwest::Client` builder |
|
||||
| No redirects | `redirect::Policy::none()` — redirects are not followed | `http.rs` — `reqwest::Client` builder |
|
||||
|
||||
### MCP Client
|
||||
|
||||
MCP servers are external processes accessed via HTTP. The MCP client (`src/tools/mcp/client.rs`) uses `reqwest` with a 30-second timeout but has **no SSRF protections** — it connects to whatever URL is configured for the MCP server.
|
||||
|
||||
This is by design: MCP server URLs come from **operator-controlled configuration** (config files, environment variables, or the CLI `tool install` command), not from user input or LLM output. A compromised config file is outside IronClaw's threat model — it would imply the operator's machine is already compromised.
|
||||
|
||||
**Reference:** `src/tools/mcp/client.rs` — `reqwest::Client` builder
|
||||
|
||||
### Sandbox Domain Allowlists
|
||||
|
||||
Sandbox containers route all HTTP traffic through the proxy, which enforces a domain allowlist. The allowlist is built from:
|
||||
|
||||
1. A default set of domains (`src/sandbox/config.rs` — `default_allowlist()`)
|
||||
2. Additional domains from `SANDBOX_EXTRA_DOMAINS` env var (comma-separated)
|
||||
|
||||
**Reference:** `src/config.rs` — sandbox allowlist assembly
|
||||
|
||||
---
|
||||
|
||||
## Authentication Mechanisms Summary
|
||||
|
||||
| Mechanism | Constant-Time | Used By | Reference |
|
||||
|-----------|:------------:|---------|-----------|
|
||||
| Gateway bearer token | Yes | Web gateway (header + query) | `src/channels/web/auth.rs` — `auth_middleware()` |
|
||||
| Webhook shared secret | Yes | HTTP webhook (`ct_eq` comparison) | `src/channels/http.rs` — `webhook_handler()` |
|
||||
| Per-job bearer token | Yes | Orchestrator worker API | `src/orchestrator/auth.rs` — `TokenStore::validate()` |
|
||||
| OAuth callback | N/A | CLI OAuth flow (no auth, loopback-only) | `src/cli/oauth_defaults.rs` — `bind_callback_listener()` |
|
||||
| Sandbox proxy | N/A | No auth (loopback-only, ephemeral) | `src/sandbox/proxy/http.rs` — `SandboxProxy::start()` |
|
||||
|
||||
---
|
||||
|
||||
## Known Security Findings
|
||||
|
||||
### Open
|
||||
|
||||
#### F-2. No TLS at the application layer
|
||||
|
||||
**Severity:** Low (for local deployment)
|
||||
**Details:** None of the listeners terminate TLS. All communication is plain HTTP.
|
||||
**Mitigation:** The web gateway and OAuth callback bind to loopback by default. For production, users are expected to front the gateway with a reverse proxy (nginx, Caddy) or tunnel (Cloudflare, ngrok) that provides TLS.
|
||||
**Recommendation:** Document the requirement for a TLS-terminating reverse proxy in deployment guides.
|
||||
|
||||
#### F-3. Orchestrator binds to `0.0.0.0` on Linux
|
||||
|
||||
**Severity:** Medium
|
||||
**Location:** `src/orchestrator/api.rs` — platform-conditional bind in `OrchestratorApi::start()`
|
||||
**Details:** On Linux, the orchestrator API binds to all interfaces because Docker containers reach the host via the bridge gateway (`172.17.0.1`), not loopback. This means the API is reachable from any network interface on the host.
|
||||
**Mitigation:** All `/worker/*` endpoints require per-job bearer tokens (constant-time, cryptographically random). The `/health` endpoint is the only unauthenticated route and returns only `"ok"`. Firewall rules should block external access to port 50051.
|
||||
**Recommendation:** Document firewall requirements for Linux deployments. Consider binding to the Docker bridge IP (`172.17.0.1`) instead of `0.0.0.0`.
|
||||
|
||||
#### F-6. WebSocket/SSE connection limit
|
||||
|
||||
**Severity:** Info
|
||||
**Details:** The `SseManager` enforces a hard limit of **100 concurrent connections** (`MAX_CONNECTIONS` constant in `src/channels/web/sse.rs`). Both SSE subscribers and WebSocket connections share this counter. When exceeded, new WebSocket upgrades are rejected with a warning log and the connection is immediately closed.
|
||||
**Reference:** `src/channels/web/sse.rs` — `MAX_CONNECTIONS`, `src/channels/web/ws.rs` — `handle_ws_connection()` early return
|
||||
|
||||
#### F-7. Orchestrator API has no rate limiting
|
||||
|
||||
**Severity:** Low
|
||||
**Details:** The orchestrator API has no request-rate throttling. A compromised container could spam authenticated endpoints (e.g., `/worker/{job_id}/llm/complete`) to drive up LLM costs or degrade service for other jobs.
|
||||
**Mitigation:** Tokens are scoped per-job, limiting blast radius. Container execution is time-bounded by the sandbox timeout, which caps the abuse window.
|
||||
**Recommendation:** Consider adding per-token rate limiting on the LLM proxy endpoints.
|
||||
|
||||
#### F-8. Orchestrator API has no graceful shutdown
|
||||
|
||||
**Severity:** Info
|
||||
**Details:** The orchestrator calls `axum::serve(listener, router).await?` without `.with_graceful_shutdown()`. In-flight requests (including LLM proxy calls) may be interrupted during process shutdown.
|
||||
**Reference:** `src/orchestrator/api.rs` — `OrchestratorApi::start()`
|
||||
|
||||
### Resolved / Mitigated
|
||||
|
||||
<details>
|
||||
<summary>Resolved and mitigated findings (click to expand)</summary>
|
||||
|
||||
#### F-1. ~~Webhook secret comparison is not constant-time~~ (Resolved)
|
||||
|
||||
**Severity:** Low
|
||||
**Location:** `src/channels/http.rs` — `webhook_handler()`
|
||||
**Status:** Resolved — webhook secret now uses `subtle::ConstantTimeEq` (`ct_eq`), consistent with web gateway and orchestrator auth.
|
||||
|
||||
#### F-4. ~~HTTP webhook server binds to `0.0.0.0` by default~~ (Mitigated)
|
||||
|
||||
**Severity:** Low
|
||||
**Location:** `src/config.rs`, `src/main.rs`
|
||||
**Status:** Mitigated — a `tracing::warn!` is now emitted at startup when the webhook server binds to an unspecified address (`0.0.0.0` or `::`), advising operators to set `HTTP_HOST=127.0.0.1` to restrict to localhost. The default bind address remains `0.0.0.0`, so webhook exposure is still controlled by operator configuration and external network controls (firewalls, ingress rules).
|
||||
|
||||
#### F-5. ~~Missing security headers on web gateway~~ (Mitigated)
|
||||
|
||||
**Severity:** Low
|
||||
**Status:** Mitigated — `X-Content-Type-Options: nosniff` and `X-Frame-Options: DENY` are now set on all gateway responses via `SetResponseHeaderLayer::if_not_present`. Layer ordering ensures these headers are applied even to error responses generated by inner layers (e.g., `DefaultBodyLimit` 413 rejections).
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Review Checklist for Network Changes
|
||||
|
||||
Use this checklist for any PR that adds or modifies network-facing code.
|
||||
|
||||
### New Listener
|
||||
|
||||
- [ ] **Bind address**: Does it bind to loopback (`127.0.0.1`) or all interfaces (`0.0.0.0`)? Justify if `0.0.0.0`.
|
||||
- [ ] **Port configuration**: Is the port configurable via env var? Is a sensible default set?
|
||||
- [ ] **Authentication**: Is auth required? If yes, is it constant-time? If no, why not?
|
||||
- [ ] **Rate limiting**: Is there a rate limiter? What are the limits?
|
||||
- [ ] **Body size limit**: Is `DefaultBodyLimit` (or equivalent) set?
|
||||
- [ ] **Content-Type validation**: Does the handler validate Content-Type (e.g., via axum `Json<T>` extractor)?
|
||||
- [ ] **Graceful shutdown**: Does the listener support graceful shutdown via oneshot or similar?
|
||||
- [ ] **Inventory update**: Is this document updated with the new listener?
|
||||
|
||||
### New Route on Existing Listener
|
||||
|
||||
- [ ] **Auth layer**: Is the route behind the auth middleware? If public, why?
|
||||
- [ ] **Input validation**: Are path parameters, query parameters, and body fields validated?
|
||||
- [ ] **Error responses**: Do error responses avoid leaking internal details?
|
||||
|
||||
### Egress (Outbound HTTP)
|
||||
|
||||
- [ ] **SSRF protection**: Does the code block private IPs, localhost, and cloud metadata endpoints?
|
||||
- [ ] **DNS rebinding**: Are resolved IPs checked (not just the hostname)?
|
||||
- [ ] **Redirect handling**: Are redirects blocked or validated?
|
||||
- [ ] **Response size**: Is there a max response size?
|
||||
- [ ] **Timeout**: Is a request timeout set?
|
||||
- [ ] **Leak detection**: Is the outbound request scanned for secrets?
|
||||
|
||||
### Credential Handling
|
||||
|
||||
- [ ] **Constant-time comparison**: Are secrets compared with `subtle::ConstantTimeEq`?
|
||||
- [ ] **No logging**: Are credentials excluded from log messages?
|
||||
- [ ] **Ephemeral storage**: Are tokens stored in memory only (not persisted)?
|
||||
- [ ] **Scope**: Are credentials scoped to the minimum necessary (per-job, per-tool)?
|
||||
- [ ] **Revocation**: Are credentials revoked when no longer needed?
|
||||
|
||||
### Container / Sandbox
|
||||
|
||||
- [ ] **Capabilities**: Are all capabilities dropped except what's needed?
|
||||
- [ ] **Filesystem**: Is the root filesystem read-only?
|
||||
- [ ] **User**: Does the container run as non-root?
|
||||
- [ ] **Network**: Is network access routed through the proxy?
|
||||
- [ ] **Timeout**: Is there an execution timeout with forced cleanup?
|
||||
- [ ] **Output limits**: Are stdout/stderr capped?
|
||||
+1638
-338
File diff suppressed because it is too large
Load Diff
@@ -1,507 +0,0 @@
|
||||
//! System commands and job handlers for the agent.
|
||||
//!
|
||||
//! Extracted from `agent_loop.rs` to isolate the /help, /model, /status,
|
||||
//! and other command processing from the core agent loop.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::agent::session::Session;
|
||||
use crate::agent::submission::SubmissionResult;
|
||||
use crate::agent::{Agent, MessageIntent};
|
||||
use crate::channels::{IncomingMessage, StatusUpdate};
|
||||
use crate::error::Error;
|
||||
use crate::llm::{ChatMessage, Reasoning};
|
||||
|
||||
impl Agent {
|
||||
/// Handle job-related intents without turn tracking.
|
||||
pub(super) async fn handle_job_or_command(
|
||||
&self,
|
||||
intent: MessageIntent,
|
||||
message: &IncomingMessage,
|
||||
) -> Result<SubmissionResult, Error> {
|
||||
// Send thinking status for non-trivial operations
|
||||
if let MessageIntent::CreateJob { .. } = &intent {
|
||||
let _ = self
|
||||
.channels
|
||||
.send_status(
|
||||
&message.channel,
|
||||
StatusUpdate::Thinking("Processing...".into()),
|
||||
&message.metadata,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let response = match intent {
|
||||
MessageIntent::CreateJob {
|
||||
title,
|
||||
description,
|
||||
category,
|
||||
} => {
|
||||
self.handle_create_job(&message.user_id, title, description, category)
|
||||
.await?
|
||||
}
|
||||
MessageIntent::CheckJobStatus { job_id } => {
|
||||
self.handle_check_status(&message.user_id, job_id).await?
|
||||
}
|
||||
MessageIntent::CancelJob { job_id } => {
|
||||
self.handle_cancel_job(&message.user_id, &job_id).await?
|
||||
}
|
||||
MessageIntent::ListJobs { filter } => {
|
||||
self.handle_list_jobs(&message.user_id, filter).await?
|
||||
}
|
||||
MessageIntent::HelpJob { job_id } => {
|
||||
self.handle_help_job(&message.user_id, &job_id).await?
|
||||
}
|
||||
MessageIntent::Command { command, args } => {
|
||||
match self.handle_command(&command, &args).await? {
|
||||
Some(s) => s,
|
||||
None => return Ok(SubmissionResult::Ok { message: None }), // Shutdown signal
|
||||
}
|
||||
}
|
||||
_ => "Unknown intent".to_string(),
|
||||
};
|
||||
Ok(SubmissionResult::response(response))
|
||||
}
|
||||
|
||||
async fn handle_create_job(
|
||||
&self,
|
||||
user_id: &str,
|
||||
title: String,
|
||||
description: String,
|
||||
category: Option<String>,
|
||||
) -> Result<String, Error> {
|
||||
// Create job context
|
||||
let job_id = self
|
||||
.context_manager
|
||||
.create_job_for_user(user_id, &title, &description)
|
||||
.await?;
|
||||
|
||||
// Update category if provided
|
||||
if let Some(cat) = category {
|
||||
self.context_manager
|
||||
.update_context(job_id, |ctx| {
|
||||
ctx.category = Some(cat);
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Persist new job to database (fire-and-forget)
|
||||
if let Some(store) = self.store()
|
||||
&& let Ok(ctx) = self.context_manager.get_context(job_id).await
|
||||
{
|
||||
let store = store.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = store.save_job(&ctx).await {
|
||||
tracing::warn!("Failed to persist new job {}: {}", job_id, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Schedule for execution
|
||||
self.scheduler.schedule(job_id).await?;
|
||||
|
||||
Ok(format!(
|
||||
"Created job: {}\nID: {}\n\nThe job has been scheduled and is now running.",
|
||||
title, job_id
|
||||
))
|
||||
}
|
||||
|
||||
async fn handle_check_status(
|
||||
&self,
|
||||
user_id: &str,
|
||||
job_id: Option<String>,
|
||||
) -> Result<String, Error> {
|
||||
match job_id {
|
||||
Some(id) => {
|
||||
let uuid = Uuid::parse_str(&id)
|
||||
.map_err(|_| crate::error::JobError::NotFound { id: Uuid::nil() })?;
|
||||
|
||||
let ctx = self.context_manager.get_context(uuid).await?;
|
||||
if ctx.user_id != user_id {
|
||||
return Err(crate::error::JobError::NotFound { id: uuid }.into());
|
||||
}
|
||||
|
||||
Ok(format!(
|
||||
"Job: {}\nStatus: {:?}\nCreated: {}\nStarted: {}\nActual cost: {}",
|
||||
ctx.title,
|
||||
ctx.state,
|
||||
ctx.created_at.format("%Y-%m-%d %H:%M:%S"),
|
||||
ctx.started_at
|
||||
.map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||
.unwrap_or_else(|| "Not started".to_string()),
|
||||
ctx.actual_cost
|
||||
))
|
||||
}
|
||||
None => {
|
||||
// Show summary of all jobs
|
||||
let summary = self.context_manager.summary_for(user_id).await;
|
||||
Ok(format!(
|
||||
"Jobs summary:\n Total: {}\n In Progress: {}\n Completed: {}\n Failed: {}\n Stuck: {}",
|
||||
summary.total,
|
||||
summary.in_progress,
|
||||
summary.completed,
|
||||
summary.failed,
|
||||
summary.stuck
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_cancel_job(&self, user_id: &str, job_id: &str) -> Result<String, Error> {
|
||||
let uuid = Uuid::parse_str(job_id)
|
||||
.map_err(|_| crate::error::JobError::NotFound { id: Uuid::nil() })?;
|
||||
|
||||
let ctx = self.context_manager.get_context(uuid).await?;
|
||||
if ctx.user_id != user_id {
|
||||
return Err(crate::error::JobError::NotFound { id: uuid }.into());
|
||||
}
|
||||
|
||||
self.scheduler.stop(uuid).await?;
|
||||
|
||||
Ok(format!("Job {} has been cancelled.", job_id))
|
||||
}
|
||||
|
||||
async fn handle_list_jobs(
|
||||
&self,
|
||||
user_id: &str,
|
||||
_filter: Option<String>,
|
||||
) -> Result<String, Error> {
|
||||
let jobs = self.context_manager.all_jobs_for(user_id).await;
|
||||
|
||||
if jobs.is_empty() {
|
||||
return Ok("No jobs found.".to_string());
|
||||
}
|
||||
|
||||
let mut output = String::from("Jobs:\n");
|
||||
for job_id in jobs {
|
||||
if let Ok(ctx) = self.context_manager.get_context(job_id).await
|
||||
&& ctx.user_id == user_id
|
||||
{
|
||||
output.push_str(&format!(" {} - {} ({:?})\n", job_id, ctx.title, ctx.state));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
async fn handle_help_job(&self, user_id: &str, job_id: &str) -> Result<String, Error> {
|
||||
let uuid = Uuid::parse_str(job_id)
|
||||
.map_err(|_| crate::error::JobError::NotFound { id: Uuid::nil() })?;
|
||||
|
||||
let ctx = self.context_manager.get_context(uuid).await?;
|
||||
if ctx.user_id != user_id {
|
||||
return Err(crate::error::JobError::NotFound { id: uuid }.into());
|
||||
}
|
||||
|
||||
if ctx.state == crate::context::JobState::Stuck {
|
||||
// Attempt recovery
|
||||
self.context_manager
|
||||
.update_context(uuid, |ctx| ctx.attempt_recovery())
|
||||
.await?
|
||||
.map_err(|s| crate::error::JobError::ContextError {
|
||||
id: uuid,
|
||||
reason: s,
|
||||
})?;
|
||||
|
||||
// Reschedule
|
||||
self.scheduler.schedule(uuid).await?;
|
||||
|
||||
Ok(format!(
|
||||
"Job {} was stuck. Attempting recovery (attempt #{}).",
|
||||
job_id,
|
||||
ctx.repair_attempts + 1
|
||||
))
|
||||
} else {
|
||||
Ok(format!(
|
||||
"Job {} is not stuck (current state: {:?}). No help needed.",
|
||||
job_id, ctx.state
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger a manual heartbeat check.
|
||||
pub(super) async fn process_heartbeat(&self) -> Result<SubmissionResult, Error> {
|
||||
let Some(workspace) = self.workspace() else {
|
||||
return Ok(SubmissionResult::error(
|
||||
"Heartbeat requires a workspace (database must be connected).",
|
||||
));
|
||||
};
|
||||
|
||||
let runner = crate::agent::HeartbeatRunner::new(
|
||||
crate::agent::HeartbeatConfig::default(),
|
||||
crate::workspace::hygiene::HygieneConfig::default(),
|
||||
workspace.clone(),
|
||||
self.llm().clone(),
|
||||
self.safety().clone(),
|
||||
);
|
||||
|
||||
match runner.check_heartbeat().await {
|
||||
crate::agent::HeartbeatResult::Ok => Ok(SubmissionResult::ok_with_message(
|
||||
"Heartbeat: all clear, nothing needs attention.",
|
||||
)),
|
||||
crate::agent::HeartbeatResult::NeedsAttention(msg) => Ok(SubmissionResult::response(
|
||||
format!("Heartbeat findings:\n\n{}", msg),
|
||||
)),
|
||||
crate::agent::HeartbeatResult::Skipped => Ok(SubmissionResult::ok_with_message(
|
||||
"Heartbeat skipped: no HEARTBEAT.md checklist found in workspace.",
|
||||
)),
|
||||
crate::agent::HeartbeatResult::Failed(err) => Ok(SubmissionResult::error(format!(
|
||||
"Heartbeat failed: {}",
|
||||
err
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Summarize the current thread's conversation.
|
||||
pub(super) async fn process_summarize(
|
||||
&self,
|
||||
session: Arc<Mutex<Session>>,
|
||||
thread_id: Uuid,
|
||||
) -> Result<SubmissionResult, Error> {
|
||||
let messages = {
|
||||
let sess = session.lock().await;
|
||||
let thread = sess
|
||||
.threads
|
||||
.get(&thread_id)
|
||||
.ok_or_else(|| Error::from(crate::error::JobError::NotFound { id: thread_id }))?;
|
||||
thread.messages()
|
||||
};
|
||||
|
||||
if messages.is_empty() {
|
||||
return Ok(SubmissionResult::ok_with_message(
|
||||
"Nothing to summarize (empty thread).",
|
||||
));
|
||||
}
|
||||
|
||||
// Build a summary prompt with the conversation
|
||||
let mut context = Vec::new();
|
||||
context.push(ChatMessage::system(
|
||||
"Summarize the conversation so far in 3-5 concise bullet points. \
|
||||
Focus on decisions made, actions taken, and key outcomes. \
|
||||
Be brief and factual.",
|
||||
));
|
||||
// Include the conversation messages (truncate to last 20 to avoid context overflow)
|
||||
let start = if messages.len() > 20 {
|
||||
messages.len() - 20
|
||||
} else {
|
||||
0
|
||||
};
|
||||
context.extend_from_slice(&messages[start..]);
|
||||
context.push(ChatMessage::user("Summarize this conversation."));
|
||||
|
||||
let request = crate::llm::CompletionRequest::new(context)
|
||||
.with_max_tokens(512)
|
||||
.with_temperature(0.3);
|
||||
|
||||
let reasoning = Reasoning::new(self.llm().clone(), self.safety().clone());
|
||||
match reasoning.complete(request).await {
|
||||
Ok((text, _usage)) => Ok(SubmissionResult::response(format!(
|
||||
"Thread Summary:\n\n{}",
|
||||
text.trim()
|
||||
))),
|
||||
Err(e) => Ok(SubmissionResult::error(format!("Summarize failed: {}", e))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Suggest next steps based on the current thread.
|
||||
pub(super) async fn process_suggest(
|
||||
&self,
|
||||
session: Arc<Mutex<Session>>,
|
||||
thread_id: Uuid,
|
||||
) -> Result<SubmissionResult, Error> {
|
||||
let messages = {
|
||||
let sess = session.lock().await;
|
||||
let thread = sess
|
||||
.threads
|
||||
.get(&thread_id)
|
||||
.ok_or_else(|| Error::from(crate::error::JobError::NotFound { id: thread_id }))?;
|
||||
thread.messages()
|
||||
};
|
||||
|
||||
if messages.is_empty() {
|
||||
return Ok(SubmissionResult::ok_with_message(
|
||||
"Nothing to suggest from (empty thread).",
|
||||
));
|
||||
}
|
||||
|
||||
let mut context = Vec::new();
|
||||
context.push(ChatMessage::system(
|
||||
"Based on the conversation so far, suggest 2-4 concrete next steps the user could take. \
|
||||
Be actionable and specific. Format as a numbered list.",
|
||||
));
|
||||
let start = if messages.len() > 20 {
|
||||
messages.len() - 20
|
||||
} else {
|
||||
0
|
||||
};
|
||||
context.extend_from_slice(&messages[start..]);
|
||||
context.push(ChatMessage::user("What should I do next?"));
|
||||
|
||||
let request = crate::llm::CompletionRequest::new(context)
|
||||
.with_max_tokens(512)
|
||||
.with_temperature(0.5);
|
||||
|
||||
let reasoning = Reasoning::new(self.llm().clone(), self.safety().clone());
|
||||
match reasoning.complete(request).await {
|
||||
Ok((text, _usage)) => Ok(SubmissionResult::response(format!(
|
||||
"Suggested Next Steps:\n\n{}",
|
||||
text.trim()
|
||||
))),
|
||||
Err(e) => Ok(SubmissionResult::error(format!("Suggest failed: {}", e))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle system commands that bypass thread-state checks entirely.
|
||||
pub(super) async fn handle_system_command(
|
||||
&self,
|
||||
command: &str,
|
||||
args: &[String],
|
||||
) -> Result<SubmissionResult, Error> {
|
||||
match command {
|
||||
"help" => Ok(SubmissionResult::response(concat!(
|
||||
"System:\n",
|
||||
" /help Show this help\n",
|
||||
" /model [name] Show or switch the active model\n",
|
||||
" /version Show version info\n",
|
||||
" /tools List available tools\n",
|
||||
" /debug Toggle debug mode\n",
|
||||
" /ping Connectivity check\n",
|
||||
"\n",
|
||||
"Jobs:\n",
|
||||
" /job <desc> Create a new job\n",
|
||||
" /status [id] Check job status\n",
|
||||
" /cancel <id> Cancel a job\n",
|
||||
" /list List all jobs\n",
|
||||
"\n",
|
||||
"Session:\n",
|
||||
" /undo Undo last turn\n",
|
||||
" /redo Redo undone turn\n",
|
||||
" /compact Compress context window\n",
|
||||
" /clear Clear current thread\n",
|
||||
" /interrupt Stop current operation\n",
|
||||
" /new New conversation thread\n",
|
||||
" /thread <id> Switch to thread\n",
|
||||
" /resume <id> Resume from checkpoint\n",
|
||||
"\n",
|
||||
"Agent:\n",
|
||||
" /heartbeat Run heartbeat check\n",
|
||||
" /summarize Summarize current thread\n",
|
||||
" /suggest Suggest next steps\n",
|
||||
"\n",
|
||||
" /quit Exit",
|
||||
))),
|
||||
|
||||
"ping" => Ok(SubmissionResult::response("pong!")),
|
||||
|
||||
"version" => Ok(SubmissionResult::response(format!(
|
||||
"{} v{}",
|
||||
env!("CARGO_PKG_NAME"),
|
||||
env!("CARGO_PKG_VERSION")
|
||||
))),
|
||||
|
||||
"tools" => {
|
||||
let tools = self.tools().list().await;
|
||||
Ok(SubmissionResult::response(format!(
|
||||
"Available tools: {}",
|
||||
tools.join(", ")
|
||||
)))
|
||||
}
|
||||
|
||||
"debug" => {
|
||||
// Debug toggle is handled client-side in the REPL.
|
||||
// For non-REPL channels, just acknowledge.
|
||||
Ok(SubmissionResult::ok_with_message(
|
||||
"Debug toggle is handled by your client.",
|
||||
))
|
||||
}
|
||||
|
||||
"model" => {
|
||||
let current = self.llm().active_model_name();
|
||||
|
||||
if args.is_empty() {
|
||||
// Show current model and list available models
|
||||
let mut out = format!("Active model: {}\n", current);
|
||||
match self.llm().list_models().await {
|
||||
Ok(models) if !models.is_empty() => {
|
||||
out.push_str("\nAvailable models:\n");
|
||||
for m in &models {
|
||||
let marker = if *m == current { " (active)" } else { "" };
|
||||
out.push_str(&format!(" {}{}\n", m, marker));
|
||||
}
|
||||
out.push_str("\nUse /model <name> to switch.");
|
||||
}
|
||||
Ok(_) => {
|
||||
out.push_str(
|
||||
"\nCould not fetch model list. Use /model <name> to switch.",
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
out.push_str(&format!(
|
||||
"\nCould not fetch models: {}. Use /model <name> to switch.",
|
||||
e
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(SubmissionResult::response(out))
|
||||
} else {
|
||||
let requested = &args[0];
|
||||
|
||||
// Validate the model exists
|
||||
match self.llm().list_models().await {
|
||||
Ok(models) if !models.is_empty() => {
|
||||
if !models.iter().any(|m| m == requested) {
|
||||
return Ok(SubmissionResult::error(format!(
|
||||
"Unknown model: {}. Available models:\n {}",
|
||||
requested,
|
||||
models.join("\n ")
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(_) => {
|
||||
// Empty model list, can't validate but try anyway
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Could not fetch model list for validation: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
match self.llm().set_model(requested) {
|
||||
Ok(()) => Ok(SubmissionResult::response(format!(
|
||||
"Switched model to: {}",
|
||||
requested
|
||||
))),
|
||||
Err(e) => Ok(SubmissionResult::error(format!(
|
||||
"Failed to switch model: {}",
|
||||
e
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ => Ok(SubmissionResult::error(format!(
|
||||
"Unknown command: {}. Try /help",
|
||||
command
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle legacy command routing from the Router (job commands that go through
|
||||
/// process_user_input -> router -> handle_job_or_command -> here).
|
||||
pub(super) async fn handle_command(
|
||||
&self,
|
||||
command: &str,
|
||||
args: &[String],
|
||||
) -> Result<Option<String>, Error> {
|
||||
// System commands are now handled directly via Submission::SystemCommand,
|
||||
// but the router may still send us unknown /commands.
|
||||
match self.handle_system_command(command, args).await? {
|
||||
SubmissionResult::Response { content } => Ok(Some(content)),
|
||||
SubmissionResult::Ok { message } => Ok(message),
|
||||
SubmissionResult::Error { message } => Ok(Some(format!("Error: {}", message))),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-28
@@ -12,8 +12,7 @@ use chrono::Utc;
|
||||
use crate::agent::context_monitor::{CompactionStrategy, ContextBreakdown};
|
||||
use crate::agent::session::Thread;
|
||||
use crate::error::Error;
|
||||
use crate::llm::{ChatMessage, CompletionRequest, LlmProvider, Reasoning};
|
||||
use crate::safety::SafetyLayer;
|
||||
use crate::llm::{ChatMessage, CompletionRequest, LlmProvider};
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
/// Result of a compaction operation.
|
||||
@@ -34,13 +33,12 @@ pub struct CompactionResult {
|
||||
/// Compacts conversation context to stay within limits.
|
||||
pub struct ContextCompactor {
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
safety: Arc<SafetyLayer>,
|
||||
}
|
||||
|
||||
impl ContextCompactor {
|
||||
/// Create a new context compactor.
|
||||
pub fn new(llm: Arc<dyn LlmProvider>, safety: Arc<SafetyLayer>) -> Self {
|
||||
Self { llm, safety }
|
||||
pub fn new(llm: Arc<dyn LlmProvider>) -> Self {
|
||||
Self { llm }
|
||||
}
|
||||
|
||||
/// Compact a thread's context using the given strategy.
|
||||
@@ -107,16 +105,7 @@ impl ContextCompactor {
|
||||
|
||||
// Write to workspace if available
|
||||
let summary_written = if let Some(ws) = workspace {
|
||||
match self.write_summary_to_workspace(ws, &summary).await {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Compaction summary write failed (turns will still be truncated): {}",
|
||||
e
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
self.write_summary_to_workspace(ws, &summary).await.is_ok()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
@@ -168,16 +157,7 @@ impl ContextCompactor {
|
||||
let content = format_turns_for_storage(old_turns);
|
||||
|
||||
// Write to workspace
|
||||
let written = match self.write_context_to_workspace(ws, &content).await {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Compaction context write failed (turns will still be truncated): {}",
|
||||
e
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
let written = self.write_context_to_workspace(ws, &content).await.is_ok();
|
||||
|
||||
// Truncate
|
||||
thread.truncate_turns(keep_recent);
|
||||
@@ -233,9 +213,8 @@ Be brief but capture all important details. Use bullet points."#,
|
||||
.with_max_tokens(1024)
|
||||
.with_temperature(0.3);
|
||||
|
||||
let reasoning = Reasoning::new(self.llm.clone(), self.safety.clone());
|
||||
let (text, _) = reasoning.complete(request).await?;
|
||||
Ok(text)
|
||||
let response = self.llm.complete(request).await?;
|
||||
Ok(response.content)
|
||||
}
|
||||
|
||||
/// Write a summary to the workspace daily log.
|
||||
|
||||
@@ -1,339 +0,0 @@
|
||||
//! Cost enforcement guardrails for the agent.
|
||||
//!
|
||||
//! Tracks LLM spending and action rates, enforcing configurable limits
|
||||
//! to prevent runaway agents from burning through API credits. Especially
|
||||
//! important for daemon/heartbeat modes where the agent acts autonomously.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use rust_decimal::Decimal;
|
||||
use rust_decimal_macros::dec;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::llm::costs;
|
||||
|
||||
/// Configuration for cost guardrails.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CostGuardConfig {
|
||||
/// Maximum spend per day in cents (e.g. 10000 = $100). None = unlimited.
|
||||
pub max_cost_per_day_cents: Option<u64>,
|
||||
/// Maximum LLM calls per hour. None = unlimited.
|
||||
pub max_actions_per_hour: Option<u64>,
|
||||
}
|
||||
|
||||
/// Error returned when a cost limit is exceeded.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CostLimitExceeded {
|
||||
/// Daily spending cap reached.
|
||||
DailyBudget { spent_cents: u64, limit_cents: u64 },
|
||||
/// Hourly action rate limit reached.
|
||||
HourlyRate { actions: u64, limit: u64 },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CostLimitExceeded {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::DailyBudget {
|
||||
spent_cents,
|
||||
limit_cents,
|
||||
} => write!(
|
||||
f,
|
||||
"Daily cost limit exceeded: spent ${:.2} of ${:.2} allowed",
|
||||
*spent_cents as f64 / 100.0,
|
||||
*limit_cents as f64 / 100.0
|
||||
),
|
||||
Self::HourlyRate { actions, limit } => write!(
|
||||
f,
|
||||
"Hourly action limit exceeded: {} actions of {} allowed per hour",
|
||||
actions, limit
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks costs and action rates, enforcing configurable limits.
|
||||
///
|
||||
/// Thread-safe; designed to be shared via `Arc<CostGuard>`.
|
||||
pub struct CostGuard {
|
||||
config: CostGuardConfig,
|
||||
|
||||
/// Running cost total for the current day (in USD, not cents).
|
||||
daily_cost: Mutex<DailyCost>,
|
||||
|
||||
/// Sliding window of action timestamps for rate limiting.
|
||||
action_window: Mutex<VecDeque<Instant>>,
|
||||
|
||||
/// Flag set when daily budget is exceeded to short-circuit checks.
|
||||
budget_exceeded: AtomicBool,
|
||||
}
|
||||
|
||||
struct DailyCost {
|
||||
total: Decimal,
|
||||
/// Day boundary (midnight UTC) for resetting the counter.
|
||||
reset_date: chrono::NaiveDate,
|
||||
}
|
||||
|
||||
impl CostGuard {
|
||||
pub fn new(config: CostGuardConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
daily_cost: Mutex::new(DailyCost {
|
||||
total: Decimal::ZERO,
|
||||
reset_date: chrono::Utc::now().date_naive(),
|
||||
}),
|
||||
action_window: Mutex::new(VecDeque::new()),
|
||||
budget_exceeded: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether the next action is allowed under the configured limits.
|
||||
///
|
||||
/// Call this BEFORE making an LLM call. Does NOT record the action yet,
|
||||
/// call `record_action` after the action completes.
|
||||
pub async fn check_allowed(&self) -> Result<(), CostLimitExceeded> {
|
||||
// Fast path: if budget already blown, skip the lock
|
||||
if self.budget_exceeded.load(Ordering::Relaxed) {
|
||||
let daily = self.daily_cost.lock().await;
|
||||
let spent_cents = to_cents(daily.total);
|
||||
return Err(CostLimitExceeded::DailyBudget {
|
||||
spent_cents,
|
||||
limit_cents: self.config.max_cost_per_day_cents.unwrap_or(0),
|
||||
});
|
||||
}
|
||||
|
||||
// Check daily budget
|
||||
if let Some(limit_cents) = self.config.max_cost_per_day_cents {
|
||||
let daily = self.daily_cost.lock().await;
|
||||
let spent_cents = to_cents(daily.total);
|
||||
if spent_cents >= limit_cents {
|
||||
self.budget_exceeded.store(true, Ordering::Relaxed);
|
||||
return Err(CostLimitExceeded::DailyBudget {
|
||||
spent_cents,
|
||||
limit_cents,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check hourly rate
|
||||
if let Some(limit) = self.config.max_actions_per_hour {
|
||||
let mut window = self.action_window.lock().await;
|
||||
let cutoff = Instant::now() - std::time::Duration::from_secs(3600);
|
||||
// Drain expired entries
|
||||
while window.front().is_some_and(|t| *t < cutoff) {
|
||||
window.pop_front();
|
||||
}
|
||||
let count = window.len() as u64;
|
||||
if count >= limit {
|
||||
return Err(CostLimitExceeded::HourlyRate {
|
||||
actions: count,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record a completed LLM action: its token costs and the action timestamp.
|
||||
///
|
||||
/// Call this AFTER an LLM call completes so that costs are tracked.
|
||||
pub async fn record_llm_call(
|
||||
&self,
|
||||
model: &str,
|
||||
input_tokens: u32,
|
||||
output_tokens: u32,
|
||||
) -> Decimal {
|
||||
let (input_rate, output_rate) =
|
||||
costs::model_cost(model).unwrap_or_else(costs::default_cost);
|
||||
let cost =
|
||||
input_rate * Decimal::from(input_tokens) + output_rate * Decimal::from(output_tokens);
|
||||
|
||||
// Update daily cost (reset if new day)
|
||||
{
|
||||
let mut daily = self.daily_cost.lock().await;
|
||||
let today = chrono::Utc::now().date_naive();
|
||||
if today != daily.reset_date {
|
||||
daily.total = Decimal::ZERO;
|
||||
daily.reset_date = today;
|
||||
self.budget_exceeded.store(false, Ordering::Relaxed);
|
||||
tracing::info!("Cost guard: daily counter reset for {}", today);
|
||||
}
|
||||
daily.total += cost;
|
||||
|
||||
// Check if we just crossed the threshold
|
||||
if let Some(limit_cents) = self.config.max_cost_per_day_cents {
|
||||
let spent_cents = to_cents(daily.total);
|
||||
if spent_cents >= limit_cents {
|
||||
self.budget_exceeded.store(true, Ordering::Relaxed);
|
||||
tracing::warn!(
|
||||
"Daily cost limit reached: ${:.2} of ${:.2}",
|
||||
daily.total,
|
||||
Decimal::from(limit_cents) / dec!(100)
|
||||
);
|
||||
}
|
||||
// Warn at 80% threshold
|
||||
let warn_threshold = limit_cents * 80 / 100;
|
||||
if spent_cents >= warn_threshold && spent_cents < limit_cents {
|
||||
tracing::warn!(
|
||||
"Approaching daily cost limit: ${:.2} of ${:.2} ({}%)",
|
||||
daily.total,
|
||||
Decimal::from(limit_cents) / dec!(100),
|
||||
spent_cents * 100 / limit_cents
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Record action in sliding window
|
||||
{
|
||||
let mut window = self.action_window.lock().await;
|
||||
window.push_back(Instant::now());
|
||||
}
|
||||
|
||||
cost
|
||||
}
|
||||
|
||||
/// Current daily spend in USD (as Decimal).
|
||||
pub async fn daily_spend(&self) -> Decimal {
|
||||
let daily = self.daily_cost.lock().await;
|
||||
let today = chrono::Utc::now().date_naive();
|
||||
if today != daily.reset_date {
|
||||
Decimal::ZERO
|
||||
} else {
|
||||
daily.total
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of actions in the current hourly window.
|
||||
pub async fn actions_this_hour(&self) -> u64 {
|
||||
let mut window = self.action_window.lock().await;
|
||||
let cutoff = Instant::now() - std::time::Duration::from_secs(3600);
|
||||
while window.front().is_some_and(|t| *t < cutoff) {
|
||||
window.pop_front();
|
||||
}
|
||||
window.len() as u64
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a Decimal USD amount to whole cents (truncated).
|
||||
fn to_cents(usd: Decimal) -> u64 {
|
||||
let cents = (usd * dec!(100)).trunc();
|
||||
cents.to_string().parse::<u64>().unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_unlimited_allows_everything() {
|
||||
let guard = CostGuard::new(CostGuardConfig::default());
|
||||
|
||||
// No limits set, should always be allowed
|
||||
assert!(guard.check_allowed().await.is_ok());
|
||||
|
||||
// Record a big call, still allowed
|
||||
guard.record_llm_call("gpt-4o", 100_000, 100_000).await;
|
||||
assert!(guard.check_allowed().await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_daily_budget_enforcement() {
|
||||
let guard = CostGuard::new(CostGuardConfig {
|
||||
max_cost_per_day_cents: Some(1), // $0.01 limit
|
||||
max_actions_per_hour: None,
|
||||
});
|
||||
|
||||
// First call allowed
|
||||
assert!(guard.check_allowed().await.is_ok());
|
||||
|
||||
// Record a call that costs more than $0.01
|
||||
// gpt-4o: input=$0.0000025/tok, output=$0.00001/tok
|
||||
// 10000 input + 10000 output = $0.025 + $0.10 = $0.125
|
||||
guard.record_llm_call("gpt-4o", 10_000, 10_000).await;
|
||||
|
||||
// Now should be blocked
|
||||
let result = guard.check_allowed().await;
|
||||
assert!(result.is_err());
|
||||
match result.unwrap_err() {
|
||||
CostLimitExceeded::DailyBudget { limit_cents, .. } => {
|
||||
assert_eq!(limit_cents, 1);
|
||||
}
|
||||
other => panic!("Expected DailyBudget, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_hourly_rate_enforcement() {
|
||||
let guard = CostGuard::new(CostGuardConfig {
|
||||
max_cost_per_day_cents: None,
|
||||
max_actions_per_hour: Some(3),
|
||||
});
|
||||
|
||||
// First 3 actions allowed
|
||||
for _ in 0..3 {
|
||||
assert!(guard.check_allowed().await.is_ok());
|
||||
guard.record_llm_call("gpt-4o", 10, 10).await;
|
||||
}
|
||||
|
||||
// 4th should be blocked
|
||||
let result = guard.check_allowed().await;
|
||||
assert!(result.is_err());
|
||||
match result.unwrap_err() {
|
||||
CostLimitExceeded::HourlyRate { actions, limit } => {
|
||||
assert_eq!(actions, 3);
|
||||
assert_eq!(limit, 3);
|
||||
}
|
||||
other => panic!("Expected HourlyRate, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_daily_spend_tracking() {
|
||||
let guard = CostGuard::new(CostGuardConfig::default());
|
||||
|
||||
assert_eq!(guard.daily_spend().await, Decimal::ZERO);
|
||||
|
||||
let cost = guard.record_llm_call("gpt-4o", 1000, 500).await;
|
||||
assert!(cost > Decimal::ZERO);
|
||||
assert_eq!(guard.daily_spend().await, cost);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_actions_this_hour() {
|
||||
let guard = CostGuard::new(CostGuardConfig::default());
|
||||
|
||||
assert_eq!(guard.actions_this_hour().await, 0);
|
||||
|
||||
guard.record_llm_call("gpt-4o", 10, 10).await;
|
||||
guard.record_llm_call("gpt-4o", 10, 10).await;
|
||||
|
||||
assert_eq!(guard.actions_this_hour().await, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_cents() {
|
||||
assert_eq!(to_cents(dec!(1.50)), 150);
|
||||
assert_eq!(to_cents(dec!(0.01)), 1);
|
||||
assert_eq!(to_cents(Decimal::ZERO), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cost_limit_display() {
|
||||
let budget = CostLimitExceeded::DailyBudget {
|
||||
spent_cents: 1050,
|
||||
limit_cents: 1000,
|
||||
};
|
||||
assert!(budget.to_string().contains("$10.50"));
|
||||
assert!(budget.to_string().contains("$10.00"));
|
||||
|
||||
let rate = CostLimitExceeded::HourlyRate {
|
||||
actions: 101,
|
||||
limit: 100,
|
||||
};
|
||||
assert!(rate.to_string().contains("101 actions"));
|
||||
assert!(rate.to_string().contains("100 allowed"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+6
-56
@@ -29,10 +29,8 @@ use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::channels::OutgoingResponse;
|
||||
use crate::llm::{ChatMessage, CompletionRequest, LlmProvider, Reasoning};
|
||||
use crate::safety::SafetyLayer;
|
||||
use crate::llm::{ChatMessage, CompletionRequest, LlmProvider};
|
||||
use crate::workspace::Workspace;
|
||||
use crate::workspace::hygiene::HygieneConfig;
|
||||
|
||||
/// Configuration for the heartbeat runner.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -98,10 +96,8 @@ pub enum HeartbeatResult {
|
||||
/// Heartbeat runner for proactive periodic execution.
|
||||
pub struct HeartbeatRunner {
|
||||
config: HeartbeatConfig,
|
||||
hygiene_config: HygieneConfig,
|
||||
workspace: Arc<Workspace>,
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
safety: Arc<SafetyLayer>,
|
||||
response_tx: Option<mpsc::Sender<OutgoingResponse>>,
|
||||
consecutive_failures: u32,
|
||||
}
|
||||
@@ -110,17 +106,13 @@ impl HeartbeatRunner {
|
||||
/// Create a new heartbeat runner.
|
||||
pub fn new(
|
||||
config: HeartbeatConfig,
|
||||
hygiene_config: HygieneConfig,
|
||||
workspace: Arc<Workspace>,
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
safety: Arc<SafetyLayer>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
hygiene_config,
|
||||
workspace,
|
||||
llm,
|
||||
safety,
|
||||
response_tx: None,
|
||||
consecutive_failures: 0,
|
||||
}
|
||||
@@ -153,22 +145,6 @@ impl HeartbeatRunner {
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
// Run memory hygiene in the background so it never delays the
|
||||
// heartbeat checklist. Failures are logged inside run_if_due.
|
||||
let hygiene_workspace = Arc::clone(&self.workspace);
|
||||
let hygiene_config = self.hygiene_config.clone();
|
||||
tokio::spawn(async move {
|
||||
let report =
|
||||
crate::workspace::hygiene::run_if_due(&hygiene_workspace, &hygiene_config)
|
||||
.await;
|
||||
if report.had_work() {
|
||||
tracing::info!(
|
||||
daily_logs_deleted = report.daily_logs_deleted,
|
||||
"heartbeat: memory hygiene deleted stale documents"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
match self.check_heartbeat().await {
|
||||
HeartbeatResult::Ok => {
|
||||
tracing::debug!("Heartbeat OK");
|
||||
@@ -241,40 +217,16 @@ impl HeartbeatRunner {
|
||||
]
|
||||
};
|
||||
|
||||
// Use the model's context_length to set max_tokens. The API returns
|
||||
// the total context window; we cap output at half of that (the rest is
|
||||
// the prompt) with a floor of 4096.
|
||||
let max_tokens = match self.llm.model_metadata().await {
|
||||
Ok(meta) => {
|
||||
let from_api = meta.context_length.map(|ctx| ctx / 2).unwrap_or(4096);
|
||||
from_api.max(4096)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Could not fetch model metadata, using default max_tokens: {}",
|
||||
e
|
||||
);
|
||||
4096
|
||||
}
|
||||
};
|
||||
|
||||
let request = CompletionRequest::new(messages)
|
||||
.with_max_tokens(max_tokens)
|
||||
.with_temperature(0.3);
|
||||
.with_max_tokens(1024)
|
||||
.with_temperature(0.3); // Lower temperature for more focused responses
|
||||
|
||||
let reasoning = Reasoning::new(self.llm.clone(), self.safety.clone());
|
||||
let (content, _usage) = match reasoning.complete(request).await {
|
||||
let response = match self.llm.complete(request).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return HeartbeatResult::Failed(format!("LLM call failed: {}", e)),
|
||||
};
|
||||
|
||||
let content = content.trim();
|
||||
|
||||
// Guard against empty content. Reasoning models (e.g. GLM-4.7) may
|
||||
// burn all output tokens on chain-of-thought and return content: null.
|
||||
if content.is_empty() {
|
||||
return HeartbeatResult::Failed("LLM returned empty content.".to_string());
|
||||
}
|
||||
let content = response.content.trim();
|
||||
|
||||
// Check if nothing needs attention
|
||||
if content == "HEARTBEAT_OK" || content.contains("HEARTBEAT_OK") {
|
||||
@@ -349,13 +301,11 @@ fn strip_html_comments(content: &str) -> String {
|
||||
/// Returns a handle that can be used to stop the runner.
|
||||
pub fn spawn_heartbeat(
|
||||
config: HeartbeatConfig,
|
||||
hygiene_config: HygieneConfig,
|
||||
workspace: Arc<Workspace>,
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
safety: Arc<SafetyLayer>,
|
||||
response_tx: Option<mpsc::Sender<OutgoingResponse>>,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
let mut runner = HeartbeatRunner::new(config, hygiene_config, workspace, llm, safety);
|
||||
let mut runner = HeartbeatRunner::new(config, workspace, llm);
|
||||
if let Some(tx) = response_tx {
|
||||
runner = runner.with_response_channel(tx);
|
||||
}
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
//! Background job monitor that forwards Claude Code output to the main agent loop.
|
||||
//!
|
||||
//! When the main agent kicks off a sandbox job (especially Claude Code), this
|
||||
//! monitor subscribes to the broadcast event channel and injects relevant
|
||||
//! assistant messages back into the channel manager's stream. This lets the
|
||||
//! main agent see what the sub-agent is producing and surface it to the user.
|
||||
//!
|
||||
//! ```text
|
||||
//! Container ──NDJSON──► Orchestrator ──broadcast──► JobMonitor
|
||||
//! │
|
||||
//! inject_tx (mpsc)
|
||||
//! │
|
||||
//! ▼
|
||||
//! Agent Loop
|
||||
//! ```
|
||||
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
use tokio::task::JoinHandle;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::channels::IncomingMessage;
|
||||
use crate::channels::web::types::SseEvent;
|
||||
|
||||
/// Spawn a background task that watches for events from a specific job and
|
||||
/// injects assistant messages into the agent loop.
|
||||
///
|
||||
/// The monitor forwards:
|
||||
/// - `SseEvent::JobMessage` (assistant role): injected as incoming messages so
|
||||
/// the main agent can read and relay to the user.
|
||||
/// - `SseEvent::JobResult`: injected as a completion notice, then the task exits.
|
||||
///
|
||||
/// Tool use/result and status events are intentionally skipped (too noisy for
|
||||
/// the main agent's context window).
|
||||
pub fn spawn_job_monitor(
|
||||
job_id: Uuid,
|
||||
mut event_rx: broadcast::Receiver<(Uuid, SseEvent)>,
|
||||
inject_tx: mpsc::Sender<IncomingMessage>,
|
||||
) -> JoinHandle<()> {
|
||||
let short_id = job_id.to_string()[..8].to_string();
|
||||
|
||||
tokio::spawn(async move {
|
||||
tracing::info!(job_id = %short_id, "Job monitor started successfully");
|
||||
|
||||
loop {
|
||||
match event_rx.recv().await {
|
||||
Ok((ev_job_id, event)) => {
|
||||
if ev_job_id != job_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
match event {
|
||||
SseEvent::JobMessage { role, content, .. } if role == "assistant" => {
|
||||
let msg = IncomingMessage::new(
|
||||
"job_monitor",
|
||||
"system",
|
||||
format!("[Job {}] Claude Code: {}", short_id, content),
|
||||
);
|
||||
if inject_tx.send(msg).await.is_err() {
|
||||
tracing::debug!(
|
||||
job_id = %short_id,
|
||||
"Inject channel closed, stopping monitor"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
SseEvent::JobResult { status, .. } => {
|
||||
let msg = IncomingMessage::new(
|
||||
"job_monitor",
|
||||
"system",
|
||||
format!(
|
||||
"[Job {}] Container finished (status: {})",
|
||||
short_id, status
|
||||
),
|
||||
);
|
||||
let _ = inject_tx.send(msg).await;
|
||||
tracing::debug!(
|
||||
job_id = %short_id,
|
||||
status = %status,
|
||||
"Job monitor exiting (job finished)"
|
||||
);
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
// Skip tool_use, tool_result, status events
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
tracing::warn!(
|
||||
job_id = %short_id,
|
||||
skipped = n,
|
||||
"Job monitor lagged, some events were dropped"
|
||||
);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
tracing::debug!(
|
||||
job_id = %short_id,
|
||||
"Broadcast channel closed, stopping monitor"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_monitor_forwards_assistant_messages() {
|
||||
let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
|
||||
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
|
||||
|
||||
let job_id = Uuid::new_v4();
|
||||
let _handle = spawn_job_monitor(job_id, event_tx.subscribe(), inject_tx);
|
||||
|
||||
// Send an assistant message
|
||||
event_tx
|
||||
.send((
|
||||
job_id,
|
||||
SseEvent::JobMessage {
|
||||
job_id: job_id.to_string(),
|
||||
role: "assistant".to_string(),
|
||||
content: "I found a bug".to_string(),
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let msg = tokio::time::timeout(std::time::Duration::from_secs(1), inject_rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(msg.channel, "job_monitor");
|
||||
assert_eq!(msg.user_id, "system");
|
||||
assert!(msg.content.contains("I found a bug"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_monitor_ignores_other_jobs() {
|
||||
let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
|
||||
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
|
||||
|
||||
let job_id = Uuid::new_v4();
|
||||
let other_job_id = Uuid::new_v4();
|
||||
let _handle = spawn_job_monitor(job_id, event_tx.subscribe(), inject_tx);
|
||||
|
||||
// Send a message for a different job
|
||||
event_tx
|
||||
.send((
|
||||
other_job_id,
|
||||
SseEvent::JobMessage {
|
||||
job_id: other_job_id.to_string(),
|
||||
role: "assistant".to_string(),
|
||||
content: "wrong job".to_string(),
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
// Should not receive anything
|
||||
let result =
|
||||
tokio::time::timeout(std::time::Duration::from_millis(100), inject_rx.recv()).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"should have timed out, no message expected"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_monitor_exits_on_job_result() {
|
||||
let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
|
||||
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
|
||||
|
||||
let job_id = Uuid::new_v4();
|
||||
let handle = spawn_job_monitor(job_id, event_tx.subscribe(), inject_tx);
|
||||
|
||||
// Send a completion event
|
||||
event_tx
|
||||
.send((
|
||||
job_id,
|
||||
SseEvent::JobResult {
|
||||
job_id: job_id.to_string(),
|
||||
status: "completed".to_string(),
|
||||
session_id: None,
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
// Should receive the completion message
|
||||
let msg = tokio::time::timeout(std::time::Duration::from_secs(1), inject_rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(msg.content.contains("finished"));
|
||||
|
||||
// The monitor task should exit
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), handle)
|
||||
.await
|
||||
.expect("monitor should have exited")
|
||||
.expect("monitor task should not panic");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_monitor_skips_tool_events() {
|
||||
let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
|
||||
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
|
||||
|
||||
let job_id = Uuid::new_v4();
|
||||
let _handle = spawn_job_monitor(job_id, event_tx.subscribe(), inject_tx);
|
||||
|
||||
// Send tool use event (should be skipped)
|
||||
event_tx
|
||||
.send((
|
||||
job_id,
|
||||
SseEvent::JobToolUse {
|
||||
job_id: job_id.to_string(),
|
||||
tool_name: "shell".to_string(),
|
||||
input: serde_json::json!({"command": "ls"}),
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
// Send user message (should be skipped)
|
||||
event_tx
|
||||
.send((
|
||||
job_id,
|
||||
SseEvent::JobMessage {
|
||||
job_id: job_id.to_string(),
|
||||
role: "user".to_string(),
|
||||
content: "user prompt".to_string(),
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
// Should not receive anything for tool events or user messages
|
||||
let result =
|
||||
tokio::time::timeout(std::time::Duration::from_millis(100), inject_rx.recv()).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"should have timed out, no message expected"
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-12
@@ -6,44 +6,33 @@
|
||||
//! - Tool invocation with safety
|
||||
//! - Self-repair for stuck jobs
|
||||
//! - Proactive heartbeat execution
|
||||
//! - Routine-based scheduled and reactive jobs
|
||||
//! - Turn-based session management with undo
|
||||
//! - Context compaction for long conversations
|
||||
|
||||
mod agent_loop;
|
||||
mod commands;
|
||||
pub mod compaction;
|
||||
pub mod context_monitor;
|
||||
pub mod cost_guard;
|
||||
mod dispatcher;
|
||||
mod heartbeat;
|
||||
pub mod job_monitor;
|
||||
mod router;
|
||||
pub mod routine;
|
||||
pub mod routine_engine;
|
||||
mod scheduler;
|
||||
mod self_repair;
|
||||
pub mod session;
|
||||
mod session_manager;
|
||||
pub mod submission;
|
||||
pub mod task;
|
||||
mod thread_ops;
|
||||
pub mod undo;
|
||||
pub mod worker;
|
||||
|
||||
pub(crate) use agent_loop::truncate_for_preview;
|
||||
pub use agent_loop::{Agent, AgentDeps};
|
||||
pub use compaction::{CompactionResult, ContextCompactor};
|
||||
pub use context_monitor::{CompactionStrategy, ContextBreakdown, ContextMonitor};
|
||||
pub use heartbeat::{HeartbeatConfig, HeartbeatResult, HeartbeatRunner, spawn_heartbeat};
|
||||
pub use router::{MessageIntent, Router};
|
||||
pub use routine::{Routine, RoutineAction, RoutineRun, Trigger};
|
||||
pub use routine_engine::RoutineEngine;
|
||||
pub use scheduler::Scheduler;
|
||||
pub use self_repair::{BrokenTool, RepairResult, RepairTask, SelfRepair, StuckJob};
|
||||
pub use session::{PendingApproval, PendingAuth, Session, Thread, ThreadState, Turn, TurnState};
|
||||
pub use session_manager::SessionManager;
|
||||
pub use submission::{Submission, SubmissionParser, SubmissionResult};
|
||||
pub use task::{Task, TaskContext, TaskHandler, TaskOutput};
|
||||
pub use task::{Task, TaskContext, TaskHandler, TaskOutput, TaskStatus};
|
||||
pub use undo::{Checkpoint, UndoManager};
|
||||
pub use worker::{Worker, WorkerDeps};
|
||||
|
||||
@@ -1,534 +0,0 @@
|
||||
//! Core types for the routines system.
|
||||
//!
|
||||
//! A routine is a named, persistent, user-owned task with a trigger and an action.
|
||||
//! Each routine fires independently when its trigger condition is met, with only
|
||||
//! that routine's prompt and context sent to the LLM.
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌──────────┐ ┌─────────┐ ┌──────────────────┐
|
||||
//! │ Trigger │────▶│ Engine │────▶│ Execution Mode │
|
||||
//! │ cron/event│ │guardrail│ │lightweight│full_job│
|
||||
//! │ webhook │ │ check │ └──────────────────┘
|
||||
//! │ manual │ └─────────┘ │
|
||||
//! └──────────┘ ▼
|
||||
//! ┌──────────────┐
|
||||
//! │ Notify user │
|
||||
//! │ if needed │
|
||||
//! └──────────────┘
|
||||
//! ```
|
||||
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::RoutineError;
|
||||
|
||||
/// A routine is a named, persistent, user-owned task with a trigger and an action.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Routine {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub user_id: String,
|
||||
pub enabled: bool,
|
||||
pub trigger: Trigger,
|
||||
pub action: RoutineAction,
|
||||
pub guardrails: RoutineGuardrails,
|
||||
pub notify: NotifyConfig,
|
||||
|
||||
// Runtime state (DB-managed)
|
||||
pub last_run_at: Option<DateTime<Utc>>,
|
||||
pub next_fire_at: Option<DateTime<Utc>>,
|
||||
pub run_count: u64,
|
||||
pub consecutive_failures: u32,
|
||||
pub state: serde_json::Value,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// When a routine should fire.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Trigger {
|
||||
/// Fire on a cron schedule (e.g. "0 9 * * MON-FRI" or "every 2h").
|
||||
Cron { schedule: String },
|
||||
/// Fire when a channel message matches a pattern.
|
||||
Event {
|
||||
/// Optional channel filter (e.g. "telegram", "slack").
|
||||
channel: Option<String>,
|
||||
/// Regex pattern to match against message content.
|
||||
pattern: String,
|
||||
},
|
||||
/// Fire on incoming webhook POST to /hooks/routine/{id}.
|
||||
Webhook {
|
||||
/// Optional webhook path suffix (defaults to routine id).
|
||||
path: Option<String>,
|
||||
/// Optional shared secret for HMAC validation.
|
||||
secret: Option<String>,
|
||||
},
|
||||
/// Only fires via tool call or CLI.
|
||||
Manual,
|
||||
}
|
||||
|
||||
impl Trigger {
|
||||
/// The string tag stored in the DB trigger_type column.
|
||||
pub fn type_tag(&self) -> &'static str {
|
||||
match self {
|
||||
Trigger::Cron { .. } => "cron",
|
||||
Trigger::Event { .. } => "event",
|
||||
Trigger::Webhook { .. } => "webhook",
|
||||
Trigger::Manual => "manual",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a trigger from its DB representation.
|
||||
pub fn from_db(trigger_type: &str, config: serde_json::Value) -> Result<Self, RoutineError> {
|
||||
match trigger_type {
|
||||
"cron" => {
|
||||
let schedule = config
|
||||
.get("schedule")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| RoutineError::MissingField {
|
||||
context: "cron trigger".into(),
|
||||
field: "schedule".into(),
|
||||
})?
|
||||
.to_string();
|
||||
Ok(Trigger::Cron { schedule })
|
||||
}
|
||||
"event" => {
|
||||
let pattern = config
|
||||
.get("pattern")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| RoutineError::MissingField {
|
||||
context: "event trigger".into(),
|
||||
field: "pattern".into(),
|
||||
})?
|
||||
.to_string();
|
||||
let channel = config
|
||||
.get("channel")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
Ok(Trigger::Event { channel, pattern })
|
||||
}
|
||||
"webhook" => {
|
||||
let path = config
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
let secret = config
|
||||
.get("secret")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
Ok(Trigger::Webhook { path, secret })
|
||||
}
|
||||
"manual" => Ok(Trigger::Manual),
|
||||
other => Err(RoutineError::UnknownTriggerType {
|
||||
trigger_type: other.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize trigger-specific config to JSON for DB storage.
|
||||
pub fn to_config_json(&self) -> serde_json::Value {
|
||||
match self {
|
||||
Trigger::Cron { schedule } => serde_json::json!({ "schedule": schedule }),
|
||||
Trigger::Event { channel, pattern } => serde_json::json!({
|
||||
"pattern": pattern,
|
||||
"channel": channel,
|
||||
}),
|
||||
Trigger::Webhook { path, secret } => serde_json::json!({
|
||||
"path": path,
|
||||
"secret": secret,
|
||||
}),
|
||||
Trigger::Manual => serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What happens when a routine fires.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum RoutineAction {
|
||||
/// Single LLM call, no tools. Cheap and fast.
|
||||
Lightweight {
|
||||
/// The prompt sent to the LLM.
|
||||
prompt: String,
|
||||
/// Workspace paths to load as context (e.g. ["context/priorities.md"]).
|
||||
#[serde(default)]
|
||||
context_paths: Vec<String>,
|
||||
/// Max output tokens (default: 4096).
|
||||
#[serde(default = "default_max_tokens")]
|
||||
max_tokens: u32,
|
||||
},
|
||||
/// Full multi-turn worker job with tool access.
|
||||
FullJob {
|
||||
/// Job title for the scheduler.
|
||||
title: String,
|
||||
/// Job description / initial prompt.
|
||||
description: String,
|
||||
/// Max reasoning iterations (default: 10).
|
||||
#[serde(default = "default_max_iterations")]
|
||||
max_iterations: u32,
|
||||
},
|
||||
}
|
||||
|
||||
fn default_max_tokens() -> u32 {
|
||||
4096
|
||||
}
|
||||
|
||||
fn default_max_iterations() -> u32 {
|
||||
10
|
||||
}
|
||||
|
||||
impl RoutineAction {
|
||||
/// The string tag stored in the DB action_type column.
|
||||
pub fn type_tag(&self) -> &'static str {
|
||||
match self {
|
||||
RoutineAction::Lightweight { .. } => "lightweight",
|
||||
RoutineAction::FullJob { .. } => "full_job",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an action from its DB representation.
|
||||
pub fn from_db(action_type: &str, config: serde_json::Value) -> Result<Self, RoutineError> {
|
||||
match action_type {
|
||||
"lightweight" => {
|
||||
let prompt = config
|
||||
.get("prompt")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| RoutineError::MissingField {
|
||||
context: "lightweight action".into(),
|
||||
field: "prompt".into(),
|
||||
})?
|
||||
.to_string();
|
||||
let context_paths = config
|
||||
.get("context_paths")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let max_tokens = config
|
||||
.get("max_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(default_max_tokens() as u64) as u32;
|
||||
Ok(RoutineAction::Lightweight {
|
||||
prompt,
|
||||
context_paths,
|
||||
max_tokens,
|
||||
})
|
||||
}
|
||||
"full_job" => {
|
||||
let title = config
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| RoutineError::MissingField {
|
||||
context: "full_job action".into(),
|
||||
field: "title".into(),
|
||||
})?
|
||||
.to_string();
|
||||
let description = config
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| RoutineError::MissingField {
|
||||
context: "full_job action".into(),
|
||||
field: "description".into(),
|
||||
})?
|
||||
.to_string();
|
||||
let max_iterations = config
|
||||
.get("max_iterations")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(default_max_iterations() as u64)
|
||||
as u32;
|
||||
Ok(RoutineAction::FullJob {
|
||||
title,
|
||||
description,
|
||||
max_iterations,
|
||||
})
|
||||
}
|
||||
other => Err(RoutineError::UnknownActionType {
|
||||
action_type: other.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize action config to JSON for DB storage.
|
||||
pub fn to_config_json(&self) -> serde_json::Value {
|
||||
match self {
|
||||
RoutineAction::Lightweight {
|
||||
prompt,
|
||||
context_paths,
|
||||
max_tokens,
|
||||
} => serde_json::json!({
|
||||
"prompt": prompt,
|
||||
"context_paths": context_paths,
|
||||
"max_tokens": max_tokens,
|
||||
}),
|
||||
RoutineAction::FullJob {
|
||||
title,
|
||||
description,
|
||||
max_iterations,
|
||||
} => serde_json::json!({
|
||||
"title": title,
|
||||
"description": description,
|
||||
"max_iterations": max_iterations,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Guardrails to prevent runaway execution.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RoutineGuardrails {
|
||||
/// Minimum time between fires.
|
||||
pub cooldown: Duration,
|
||||
/// Max simultaneous runs of this routine.
|
||||
pub max_concurrent: u32,
|
||||
/// Window for content-hash dedup (event triggers). None = no dedup.
|
||||
pub dedup_window: Option<Duration>,
|
||||
}
|
||||
|
||||
impl Default for RoutineGuardrails {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cooldown: Duration::from_secs(300),
|
||||
max_concurrent: 1,
|
||||
dedup_window: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Notification preferences for a routine.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NotifyConfig {
|
||||
/// Channel to notify on (None = default/broadcast all).
|
||||
pub channel: Option<String>,
|
||||
/// User to notify.
|
||||
pub user: String,
|
||||
/// Notify when routine produces actionable output.
|
||||
pub on_attention: bool,
|
||||
/// Notify when routine errors.
|
||||
pub on_failure: bool,
|
||||
/// Notify when routine runs with no findings.
|
||||
pub on_success: bool,
|
||||
}
|
||||
|
||||
impl Default for NotifyConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
channel: None,
|
||||
user: "default".to_string(),
|
||||
on_attention: true,
|
||||
on_failure: true,
|
||||
on_success: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Status of a routine run.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RunStatus {
|
||||
Running,
|
||||
Ok,
|
||||
Attention,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RunStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
RunStatus::Running => write!(f, "running"),
|
||||
RunStatus::Ok => write!(f, "ok"),
|
||||
RunStatus::Attention => write!(f, "attention"),
|
||||
RunStatus::Failed => write!(f, "failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for RunStatus {
|
||||
type Err = RoutineError;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"running" => Ok(RunStatus::Running),
|
||||
"ok" => Ok(RunStatus::Ok),
|
||||
"attention" => Ok(RunStatus::Attention),
|
||||
"failed" => Ok(RunStatus::Failed),
|
||||
other => Err(RoutineError::UnknownRunStatus {
|
||||
status: other.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single execution of a routine.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RoutineRun {
|
||||
pub id: Uuid,
|
||||
pub routine_id: Uuid,
|
||||
pub trigger_type: String,
|
||||
pub trigger_detail: Option<String>,
|
||||
pub started_at: DateTime<Utc>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
pub status: RunStatus,
|
||||
pub result_summary: Option<String>,
|
||||
pub tokens_used: Option<i32>,
|
||||
pub job_id: Option<Uuid>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Compute a content hash for event dedup.
|
||||
pub fn content_hash(content: &str) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
content.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
/// Parse a cron expression and compute the next fire time from now.
|
||||
pub fn next_cron_fire(schedule: &str) -> Result<Option<DateTime<Utc>>, RoutineError> {
|
||||
let cron_schedule =
|
||||
cron::Schedule::from_str(schedule).map_err(|e| RoutineError::InvalidCron {
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
Ok(cron_schedule.upcoming(Utc).next())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::agent::routine::{
|
||||
RoutineAction, RoutineGuardrails, RunStatus, Trigger, content_hash, next_cron_fire,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_trigger_roundtrip() {
|
||||
let trigger = Trigger::Cron {
|
||||
schedule: "0 9 * * MON-FRI".to_string(),
|
||||
};
|
||||
let json = trigger.to_config_json();
|
||||
let parsed = Trigger::from_db("cron", json).expect("parse cron");
|
||||
assert!(matches!(parsed, Trigger::Cron { schedule } if schedule == "0 9 * * MON-FRI"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_trigger_roundtrip() {
|
||||
let trigger = Trigger::Event {
|
||||
channel: Some("telegram".to_string()),
|
||||
pattern: r"deploy\s+\w+".to_string(),
|
||||
};
|
||||
let json = trigger.to_config_json();
|
||||
let parsed = Trigger::from_db("event", json).expect("parse event");
|
||||
assert!(matches!(parsed, Trigger::Event { channel, pattern }
|
||||
if channel == Some("telegram".to_string()) && pattern == r"deploy\s+\w+"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_lightweight_roundtrip() {
|
||||
let action = RoutineAction::Lightweight {
|
||||
prompt: "Check PRs".to_string(),
|
||||
context_paths: vec!["context/priorities.md".to_string()],
|
||||
max_tokens: 2048,
|
||||
};
|
||||
let json = action.to_config_json();
|
||||
let parsed = RoutineAction::from_db("lightweight", json).expect("parse lightweight");
|
||||
assert!(
|
||||
matches!(parsed, RoutineAction::Lightweight { prompt, context_paths, max_tokens }
|
||||
if prompt == "Check PRs" && context_paths.len() == 1 && max_tokens == 2048)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_full_job_roundtrip() {
|
||||
let action = RoutineAction::FullJob {
|
||||
title: "Deploy review".to_string(),
|
||||
description: "Review and deploy pending changes".to_string(),
|
||||
max_iterations: 5,
|
||||
};
|
||||
let json = action.to_config_json();
|
||||
let parsed = RoutineAction::from_db("full_job", json).expect("parse full_job");
|
||||
assert!(
|
||||
matches!(parsed, RoutineAction::FullJob { title, max_iterations, .. }
|
||||
if title == "Deploy review" && max_iterations == 5)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_status_display_parse() {
|
||||
for status in [
|
||||
RunStatus::Running,
|
||||
RunStatus::Ok,
|
||||
RunStatus::Attention,
|
||||
RunStatus::Failed,
|
||||
] {
|
||||
let s = status.to_string();
|
||||
let parsed: RunStatus = s.parse().expect("parse status");
|
||||
assert_eq!(parsed, status);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_hash_deterministic() {
|
||||
let h1 = content_hash("deploy production");
|
||||
let h2 = content_hash("deploy production");
|
||||
assert_eq!(h1, h2);
|
||||
|
||||
let h3 = content_hash("deploy staging");
|
||||
assert_ne!(h1, h3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_next_cron_fire_valid() {
|
||||
// Every minute should always have a next fire
|
||||
let next = next_cron_fire("* * * * * *").expect("valid cron");
|
||||
assert!(next.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_next_cron_fire_invalid() {
|
||||
let result = next_cron_fire("not a cron");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_guardrails_default() {
|
||||
let g = RoutineGuardrails::default();
|
||||
assert_eq!(g.cooldown.as_secs(), 300);
|
||||
assert_eq!(g.max_concurrent, 1);
|
||||
assert!(g.dedup_window.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trigger_type_tag() {
|
||||
assert_eq!(
|
||||
Trigger::Cron {
|
||||
schedule: String::new()
|
||||
}
|
||||
.type_tag(),
|
||||
"cron"
|
||||
);
|
||||
assert_eq!(
|
||||
Trigger::Event {
|
||||
channel: None,
|
||||
pattern: String::new()
|
||||
}
|
||||
.type_tag(),
|
||||
"event"
|
||||
);
|
||||
assert_eq!(
|
||||
Trigger::Webhook {
|
||||
path: None,
|
||||
secret: None
|
||||
}
|
||||
.type_tag(),
|
||||
"webhook"
|
||||
);
|
||||
assert_eq!(Trigger::Manual.type_tag(), "manual");
|
||||
}
|
||||
}
|
||||
@@ -1,634 +0,0 @@
|
||||
//! Routine execution engine.
|
||||
//!
|
||||
//! Handles loading routines, checking triggers, enforcing guardrails,
|
||||
//! and executing both lightweight (single LLM call) and full-job routines.
|
||||
//!
|
||||
//! The engine runs two independent loops:
|
||||
//! - A **cron ticker** that polls the DB every N seconds for due cron routines
|
||||
//! - An **event matcher** called synchronously from the agent main loop
|
||||
//!
|
||||
//! Lightweight routines execute inline (single LLM call, no scheduler slot).
|
||||
//! Full-job routines are delegated to the existing `Scheduler`.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
use regex::Regex;
|
||||
use tokio::sync::{RwLock, mpsc};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::agent::routine::{
|
||||
NotifyConfig, Routine, RoutineAction, RoutineRun, RunStatus, Trigger, next_cron_fire,
|
||||
};
|
||||
use crate::channels::{IncomingMessage, OutgoingResponse};
|
||||
use crate::config::RoutineConfig;
|
||||
use crate::db::Database;
|
||||
use crate::error::RoutineError;
|
||||
use crate::llm::{ChatMessage, CompletionRequest, FinishReason, LlmProvider};
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
/// The routine execution engine.
|
||||
pub struct RoutineEngine {
|
||||
config: RoutineConfig,
|
||||
store: Arc<dyn Database>,
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
workspace: Arc<Workspace>,
|
||||
/// Sender for notifications (routed to channel manager).
|
||||
notify_tx: mpsc::Sender<OutgoingResponse>,
|
||||
/// Currently running routine count (across all routines).
|
||||
running_count: Arc<AtomicUsize>,
|
||||
/// Compiled event regex cache: routine_id -> compiled regex.
|
||||
event_cache: Arc<RwLock<Vec<(Uuid, Routine, Regex)>>>,
|
||||
}
|
||||
|
||||
impl RoutineEngine {
|
||||
pub fn new(
|
||||
config: RoutineConfig,
|
||||
store: Arc<dyn Database>,
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
workspace: Arc<Workspace>,
|
||||
notify_tx: mpsc::Sender<OutgoingResponse>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
store,
|
||||
llm,
|
||||
workspace,
|
||||
notify_tx,
|
||||
running_count: Arc::new(AtomicUsize::new(0)),
|
||||
event_cache: Arc::new(RwLock::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh the in-memory event trigger cache from DB.
|
||||
pub async fn refresh_event_cache(&self) {
|
||||
match self.store.list_event_routines().await {
|
||||
Ok(routines) => {
|
||||
let mut cache = Vec::new();
|
||||
for routine in routines {
|
||||
if let Trigger::Event { ref pattern, .. } = routine.trigger {
|
||||
match Regex::new(pattern) {
|
||||
Ok(re) => cache.push((routine.id, routine.clone(), re)),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
routine = %routine.name,
|
||||
"Invalid event regex '{}': {}",
|
||||
pattern, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let count = cache.len();
|
||||
*self.event_cache.write().await = cache;
|
||||
tracing::debug!("Refreshed event cache: {} routines", count);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to refresh event cache: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check incoming message against event triggers. Returns number of routines fired.
|
||||
///
|
||||
/// Called synchronously from the main loop after handle_message(). The actual
|
||||
/// execution is spawned async so this returns quickly.
|
||||
pub async fn check_event_triggers(&self, message: &IncomingMessage) -> usize {
|
||||
let cache = self.event_cache.read().await;
|
||||
let mut fired = 0;
|
||||
|
||||
for (_, routine, re) in cache.iter() {
|
||||
// Channel filter
|
||||
if let Trigger::Event {
|
||||
channel: Some(ch), ..
|
||||
} = &routine.trigger
|
||||
&& ch != &message.channel
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regex match
|
||||
if !re.is_match(&message.content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Cooldown check
|
||||
if !self.check_cooldown(routine) {
|
||||
tracing::debug!(routine = %routine.name, "Skipped: cooldown active");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Concurrent run check
|
||||
if !self.check_concurrent(routine).await {
|
||||
tracing::debug!(routine = %routine.name, "Skipped: max concurrent reached");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Global capacity check
|
||||
if self.running_count.load(Ordering::Relaxed) >= self.config.max_concurrent_routines {
|
||||
tracing::warn!(routine = %routine.name, "Skipped: global max concurrent reached");
|
||||
continue;
|
||||
}
|
||||
|
||||
let detail = truncate(&message.content, 200);
|
||||
self.spawn_fire(routine.clone(), "event", Some(detail));
|
||||
fired += 1;
|
||||
}
|
||||
|
||||
fired
|
||||
}
|
||||
|
||||
/// Check all due cron routines and fire them. Called by the cron ticker.
|
||||
pub async fn check_cron_triggers(&self) {
|
||||
let routines = match self.store.list_due_cron_routines().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to load due cron routines: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
for routine in routines {
|
||||
if self.running_count.load(Ordering::Relaxed) >= self.config.max_concurrent_routines {
|
||||
tracing::warn!("Global max concurrent routines reached, skipping remaining");
|
||||
break;
|
||||
}
|
||||
|
||||
if !self.check_cooldown(&routine) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !self.check_concurrent(&routine).await {
|
||||
continue;
|
||||
}
|
||||
|
||||
let detail = if let Trigger::Cron { ref schedule } = routine.trigger {
|
||||
Some(schedule.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
self.spawn_fire(routine, "cron", detail);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire a routine manually (from tool call or CLI).
|
||||
pub async fn fire_manual(&self, routine_id: Uuid) -> Result<Uuid, RoutineError> {
|
||||
let routine = self
|
||||
.store
|
||||
.get_routine(routine_id)
|
||||
.await
|
||||
.map_err(|e| RoutineError::Database {
|
||||
reason: e.to_string(),
|
||||
})?
|
||||
.ok_or(RoutineError::NotFound { id: routine_id })?;
|
||||
|
||||
if !routine.enabled {
|
||||
return Err(RoutineError::Disabled {
|
||||
name: routine.name.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if !self.check_concurrent(&routine).await {
|
||||
return Err(RoutineError::MaxConcurrent {
|
||||
name: routine.name.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let run_id = Uuid::new_v4();
|
||||
let run = RoutineRun {
|
||||
id: run_id,
|
||||
routine_id: routine.id,
|
||||
trigger_type: "manual".to_string(),
|
||||
trigger_detail: None,
|
||||
started_at: Utc::now(),
|
||||
completed_at: None,
|
||||
status: RunStatus::Running,
|
||||
result_summary: None,
|
||||
tokens_used: None,
|
||||
job_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
|
||||
if let Err(e) = self.store.create_routine_run(&run).await {
|
||||
return Err(RoutineError::Database {
|
||||
reason: format!("failed to create run record: {e}"),
|
||||
});
|
||||
}
|
||||
|
||||
// Execute inline for manual triggers (caller wants to wait)
|
||||
let engine = EngineContext {
|
||||
store: self.store.clone(),
|
||||
llm: self.llm.clone(),
|
||||
workspace: self.workspace.clone(),
|
||||
notify_tx: self.notify_tx.clone(),
|
||||
running_count: self.running_count.clone(),
|
||||
max_lightweight_tokens: self.config.max_lightweight_tokens,
|
||||
};
|
||||
|
||||
tokio::spawn(async move {
|
||||
execute_routine(engine, routine, run).await;
|
||||
});
|
||||
|
||||
Ok(run_id)
|
||||
}
|
||||
|
||||
/// Spawn a fire in a background task.
|
||||
fn spawn_fire(&self, routine: Routine, trigger_type: &str, trigger_detail: Option<String>) {
|
||||
let run = RoutineRun {
|
||||
id: Uuid::new_v4(),
|
||||
routine_id: routine.id,
|
||||
trigger_type: trigger_type.to_string(),
|
||||
trigger_detail,
|
||||
started_at: Utc::now(),
|
||||
completed_at: None,
|
||||
status: RunStatus::Running,
|
||||
result_summary: None,
|
||||
tokens_used: None,
|
||||
job_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
|
||||
let engine = EngineContext {
|
||||
store: self.store.clone(),
|
||||
llm: self.llm.clone(),
|
||||
workspace: self.workspace.clone(),
|
||||
notify_tx: self.notify_tx.clone(),
|
||||
running_count: self.running_count.clone(),
|
||||
max_lightweight_tokens: self.config.max_lightweight_tokens,
|
||||
};
|
||||
|
||||
// Record the run in DB, then spawn execution
|
||||
let store = self.store.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = store.create_routine_run(&run).await {
|
||||
tracing::error!(routine = %routine.name, "Failed to record run: {}", e);
|
||||
return;
|
||||
}
|
||||
execute_routine(engine, routine, run).await;
|
||||
});
|
||||
}
|
||||
|
||||
fn check_cooldown(&self, routine: &Routine) -> bool {
|
||||
if let Some(last_run) = routine.last_run_at {
|
||||
let elapsed = Utc::now().signed_duration_since(last_run);
|
||||
let cooldown = chrono::Duration::from_std(routine.guardrails.cooldown)
|
||||
.unwrap_or(chrono::Duration::seconds(300));
|
||||
if elapsed < cooldown {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
async fn check_concurrent(&self, routine: &Routine) -> bool {
|
||||
match self.store.count_running_routine_runs(routine.id).await {
|
||||
Ok(count) => count < routine.guardrails.max_concurrent as i64,
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
routine = %routine.name,
|
||||
"Failed to check concurrent runs: {}", e
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared context passed to the execution function.
|
||||
struct EngineContext {
|
||||
store: Arc<dyn Database>,
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
workspace: Arc<Workspace>,
|
||||
notify_tx: mpsc::Sender<OutgoingResponse>,
|
||||
running_count: Arc<AtomicUsize>,
|
||||
max_lightweight_tokens: u32,
|
||||
}
|
||||
|
||||
/// Execute a routine run. Handles both lightweight and full_job modes.
|
||||
async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun) {
|
||||
// Increment running count (atomic: survives panics in the execution below)
|
||||
ctx.running_count.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
let result = match &routine.action {
|
||||
RoutineAction::Lightweight {
|
||||
prompt,
|
||||
context_paths,
|
||||
max_tokens,
|
||||
} => execute_lightweight(&ctx, &routine, prompt, context_paths, *max_tokens).await,
|
||||
RoutineAction::FullJob { description, .. } => {
|
||||
// Full job mode: scheduler integration not yet implemented.
|
||||
// Execute as lightweight and prepend a warning to the summary.
|
||||
tracing::warn!(
|
||||
routine = %routine.name,
|
||||
"FullJob mode not yet implemented; falling back to lightweight execution"
|
||||
);
|
||||
match execute_lightweight(&ctx, &routine, description, &[], ctx.max_lightweight_tokens)
|
||||
.await
|
||||
{
|
||||
Ok((status, summary, tokens)) => {
|
||||
let warning = "[Note: FullJob mode is not yet implemented. This routine ran as \
|
||||
a single LLM call without tool access. Configure as 'lightweight' \
|
||||
or wait for full scheduler integration.]";
|
||||
let summary = match summary {
|
||||
Some(s) => Some(format!("{warning}\n\n{s}")),
|
||||
None => Some(warning.to_string()),
|
||||
};
|
||||
Ok((status, summary, tokens))
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Decrement running count
|
||||
ctx.running_count.fetch_sub(1, Ordering::Relaxed);
|
||||
|
||||
// Process result
|
||||
let (status, summary, tokens) = match result {
|
||||
Ok(execution) => execution,
|
||||
Err(e) => {
|
||||
tracing::error!(routine = %routine.name, "Execution failed: {}", e);
|
||||
(RunStatus::Failed, Some(e.to_string()), None)
|
||||
}
|
||||
};
|
||||
|
||||
// Complete the run record
|
||||
if let Err(e) = ctx
|
||||
.store
|
||||
.complete_routine_run(run.id, status, summary.as_deref(), tokens)
|
||||
.await
|
||||
{
|
||||
tracing::error!(routine = %routine.name, "Failed to complete run record: {}", e);
|
||||
}
|
||||
|
||||
// Update routine runtime state
|
||||
let now = Utc::now();
|
||||
let next_fire = if let Trigger::Cron { ref schedule } = routine.trigger {
|
||||
next_cron_fire(schedule).unwrap_or(None)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let new_failures = if status == RunStatus::Failed {
|
||||
routine.consecutive_failures + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
if let Err(e) = ctx
|
||||
.store
|
||||
.update_routine_runtime(
|
||||
routine.id,
|
||||
now,
|
||||
next_fire,
|
||||
routine.run_count + 1,
|
||||
new_failures,
|
||||
&routine.state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(routine = %routine.name, "Failed to update runtime state: {}", e);
|
||||
}
|
||||
|
||||
// Send notifications based on config
|
||||
send_notification(
|
||||
&ctx.notify_tx,
|
||||
&routine.notify,
|
||||
&routine.name,
|
||||
status,
|
||||
summary.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Sanitize a routine name for use in workspace paths.
|
||||
/// Only keeps alphanumeric, dash, and underscore characters; replaces everything else.
|
||||
fn sanitize_routine_name(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Execute a lightweight routine (single LLM call).
|
||||
async fn execute_lightweight(
|
||||
ctx: &EngineContext,
|
||||
routine: &Routine,
|
||||
prompt: &str,
|
||||
context_paths: &[String],
|
||||
max_tokens: u32,
|
||||
) -> Result<(RunStatus, Option<String>, Option<i32>), RoutineError> {
|
||||
// Load context from workspace
|
||||
let mut context_parts = Vec::new();
|
||||
for path in context_paths {
|
||||
match ctx.workspace.read(path).await {
|
||||
Ok(doc) => {
|
||||
context_parts.push(format!("## {}\n\n{}", path, doc.content));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
routine = %routine.name,
|
||||
"Failed to read context path {}: {}", path, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load routine state from workspace (name sanitized to prevent path traversal)
|
||||
let safe_name = sanitize_routine_name(&routine.name);
|
||||
let state_path = format!("routines/{safe_name}/state.md");
|
||||
let state_content = match ctx.workspace.read(&state_path).await {
|
||||
Ok(doc) => Some(doc.content),
|
||||
Err(_) => None,
|
||||
};
|
||||
|
||||
// Build the prompt
|
||||
let mut full_prompt = String::new();
|
||||
full_prompt.push_str(prompt);
|
||||
|
||||
if !context_parts.is_empty() {
|
||||
full_prompt.push_str("\n\n---\n\n# Context\n\n");
|
||||
full_prompt.push_str(&context_parts.join("\n\n"));
|
||||
}
|
||||
|
||||
if let Some(state) = &state_content {
|
||||
full_prompt.push_str("\n\n---\n\n# Previous State\n\n");
|
||||
full_prompt.push_str(state);
|
||||
}
|
||||
|
||||
full_prompt.push_str(
|
||||
"\n\n---\n\nIf nothing needs attention, reply EXACTLY with: ROUTINE_OK\n\
|
||||
If something needs attention, provide a concise summary.",
|
||||
);
|
||||
|
||||
// Get system prompt
|
||||
let system_prompt = match ctx.workspace.system_prompt().await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::warn!(routine = %routine.name, "Failed to get system prompt: {}", e);
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
|
||||
let messages = if system_prompt.is_empty() {
|
||||
vec![ChatMessage::user(&full_prompt)]
|
||||
} else {
|
||||
vec![
|
||||
ChatMessage::system(&system_prompt),
|
||||
ChatMessage::user(&full_prompt),
|
||||
]
|
||||
};
|
||||
|
||||
// Determine max_tokens from model metadata with fallback
|
||||
let effective_max_tokens = match ctx.llm.model_metadata().await {
|
||||
Ok(meta) => {
|
||||
let from_api = meta.context_length.map(|ctx| ctx / 2).unwrap_or(max_tokens);
|
||||
from_api.max(max_tokens)
|
||||
}
|
||||
Err(_) => max_tokens,
|
||||
};
|
||||
|
||||
let request = CompletionRequest::new(messages)
|
||||
.with_max_tokens(effective_max_tokens)
|
||||
.with_temperature(0.3);
|
||||
|
||||
let response = ctx
|
||||
.llm
|
||||
.complete(request)
|
||||
.await
|
||||
.map_err(|e| RoutineError::LlmFailed {
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
let content = response.content.trim();
|
||||
let tokens_used = Some((response.input_tokens + response.output_tokens) as i32);
|
||||
|
||||
// Empty content guard (same as heartbeat)
|
||||
if content.is_empty() {
|
||||
return if response.finish_reason == FinishReason::Length {
|
||||
Err(RoutineError::TruncatedResponse)
|
||||
} else {
|
||||
Err(RoutineError::EmptyResponse)
|
||||
};
|
||||
}
|
||||
|
||||
// Check for the "nothing to do" sentinel
|
||||
if content == "ROUTINE_OK" || content.contains("ROUTINE_OK") {
|
||||
return Ok((RunStatus::Ok, None, tokens_used));
|
||||
}
|
||||
|
||||
Ok((RunStatus::Attention, Some(content.to_string()), tokens_used))
|
||||
}
|
||||
|
||||
/// Send a notification based on the routine's notify config and run status.
|
||||
async fn send_notification(
|
||||
tx: &mpsc::Sender<OutgoingResponse>,
|
||||
notify: &NotifyConfig,
|
||||
routine_name: &str,
|
||||
status: RunStatus,
|
||||
summary: Option<&str>,
|
||||
) {
|
||||
let should_notify = match status {
|
||||
RunStatus::Ok => notify.on_success,
|
||||
RunStatus::Attention => notify.on_attention,
|
||||
RunStatus::Failed => notify.on_failure,
|
||||
RunStatus::Running => false,
|
||||
};
|
||||
|
||||
if !should_notify {
|
||||
return;
|
||||
}
|
||||
|
||||
let icon = match status {
|
||||
RunStatus::Ok => "✅",
|
||||
RunStatus::Attention => "🔔",
|
||||
RunStatus::Failed => "❌",
|
||||
RunStatus::Running => "⏳",
|
||||
};
|
||||
|
||||
let message = match summary {
|
||||
Some(s) => format!("{} *Routine '{}'*: {}\n\n{}", icon, routine_name, status, s),
|
||||
None => format!("{} *Routine '{}'*: {}", icon, routine_name, status),
|
||||
};
|
||||
|
||||
let response = OutgoingResponse {
|
||||
content: message,
|
||||
thread_id: None,
|
||||
metadata: serde_json::json!({
|
||||
"source": "routine",
|
||||
"routine_name": routine_name,
|
||||
"status": status.to_string(),
|
||||
}),
|
||||
};
|
||||
|
||||
if let Err(e) = tx.send(response).await {
|
||||
tracing::error!(routine = %routine_name, "Failed to send notification: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the cron ticker background task.
|
||||
pub fn spawn_cron_ticker(
|
||||
engine: Arc<RoutineEngine>,
|
||||
interval: Duration,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let mut ticker = tokio::time::interval(interval);
|
||||
// Skip immediate first tick
|
||||
ticker.tick().await;
|
||||
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
engine.check_cron_triggers().await;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
let end = crate::util::floor_char_boundary(s, max);
|
||||
format!("{}...", &s[..end])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::agent::routine::{NotifyConfig, RunStatus};
|
||||
|
||||
#[test]
|
||||
fn test_notification_gating() {
|
||||
let config = NotifyConfig {
|
||||
on_success: false,
|
||||
on_failure: true,
|
||||
on_attention: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// on_success = false means Ok status should not notify
|
||||
assert!(!config.on_success);
|
||||
assert!(config.on_failure);
|
||||
assert!(config.on_attention);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_status_icons() {
|
||||
// Just verify the mapping doesn't panic
|
||||
for status in [
|
||||
RunStatus::Ok,
|
||||
RunStatus::Attention,
|
||||
RunStatus::Failed,
|
||||
RunStatus::Running,
|
||||
] {
|
||||
let _ = status.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
-91
@@ -12,9 +12,8 @@ use crate::agent::task::{Task, TaskContext, TaskOutput};
|
||||
use crate::agent::worker::{Worker, WorkerDeps};
|
||||
use crate::config::AgentConfig;
|
||||
use crate::context::{ContextManager, JobContext, JobState};
|
||||
use crate::db::Database;
|
||||
use crate::error::{Error, JobError};
|
||||
use crate::hooks::HookRegistry;
|
||||
use crate::history::Store;
|
||||
use crate::llm::LlmProvider;
|
||||
use crate::safety::SafetyLayer;
|
||||
use crate::tools::ToolRegistry;
|
||||
@@ -49,8 +48,7 @@ pub struct Scheduler {
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
safety: Arc<SafetyLayer>,
|
||||
tools: Arc<ToolRegistry>,
|
||||
store: Option<Arc<dyn Database>>,
|
||||
hooks: Arc<HookRegistry>,
|
||||
store: Option<Arc<Store>>,
|
||||
/// Running jobs (main LLM-driven jobs).
|
||||
jobs: Arc<RwLock<HashMap<Uuid, ScheduledJob>>>,
|
||||
/// Running sub-tasks (tool executions, background tasks).
|
||||
@@ -65,8 +63,7 @@ impl Scheduler {
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
safety: Arc<SafetyLayer>,
|
||||
tools: Arc<ToolRegistry>,
|
||||
store: Option<Arc<dyn Database>>,
|
||||
hooks: Arc<HookRegistry>,
|
||||
store: Option<Arc<Store>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
@@ -75,7 +72,6 @@ impl Scheduler {
|
||||
safety,
|
||||
tools,
|
||||
store,
|
||||
hooks,
|
||||
jobs: Arc::new(RwLock::new(HashMap::new())),
|
||||
subtasks: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
@@ -83,67 +79,64 @@ impl Scheduler {
|
||||
|
||||
/// Schedule a job for execution.
|
||||
pub async fn schedule(&self, job_id: Uuid) -> Result<(), JobError> {
|
||||
// Hold write lock for the entire check-insert sequence to prevent
|
||||
// TOCTOU races where two concurrent calls both pass the checks.
|
||||
{
|
||||
let mut jobs = self.jobs.write().await;
|
||||
|
||||
if jobs.contains_key(&job_id) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if jobs.len() >= self.config.max_parallel_jobs {
|
||||
return Err(JobError::MaxJobsExceeded {
|
||||
max: self.config.max_parallel_jobs,
|
||||
});
|
||||
}
|
||||
|
||||
// Transition job to in_progress
|
||||
self.context_manager
|
||||
.update_context(job_id, |ctx| {
|
||||
ctx.transition_to(
|
||||
JobState::InProgress,
|
||||
Some("Scheduled for execution".to_string()),
|
||||
)
|
||||
})
|
||||
.await?
|
||||
.map_err(|s| JobError::ContextError {
|
||||
id: job_id,
|
||||
reason: s,
|
||||
})?;
|
||||
|
||||
// Create worker channel
|
||||
let (tx, rx) = mpsc::channel(16);
|
||||
|
||||
// Create worker with shared dependencies
|
||||
let deps = WorkerDeps {
|
||||
context_manager: self.context_manager.clone(),
|
||||
llm: self.llm.clone(),
|
||||
safety: self.safety.clone(),
|
||||
tools: self.tools.clone(),
|
||||
store: self.store.clone(),
|
||||
hooks: self.hooks.clone(),
|
||||
timeout: self.config.job_timeout,
|
||||
use_planning: self.config.use_planning,
|
||||
};
|
||||
let worker = Worker::new(job_id, deps);
|
||||
|
||||
// Spawn worker task
|
||||
let handle = tokio::spawn(async move {
|
||||
if let Err(e) = worker.run(rx).await {
|
||||
tracing::error!("Worker for job {} failed: {}", job_id, e);
|
||||
}
|
||||
});
|
||||
|
||||
// Start the worker
|
||||
if tx.send(WorkerMessage::Start).await.is_err() {
|
||||
tracing::error!(job_id = %job_id, "Worker died before receiving Start message");
|
||||
}
|
||||
|
||||
// Insert while still holding the write lock
|
||||
jobs.insert(job_id, ScheduledJob { handle, tx });
|
||||
// Check if already scheduled
|
||||
if self.jobs.read().await.contains_key(&job_id) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check capacity
|
||||
let current_count = self.jobs.read().await.len();
|
||||
if current_count >= self.config.max_parallel_jobs {
|
||||
return Err(JobError::MaxJobsExceeded {
|
||||
max: self.config.max_parallel_jobs,
|
||||
});
|
||||
}
|
||||
|
||||
// Transition job to in_progress
|
||||
self.context_manager
|
||||
.update_context(job_id, |ctx| {
|
||||
ctx.transition_to(
|
||||
JobState::InProgress,
|
||||
Some("Scheduled for execution".to_string()),
|
||||
)
|
||||
})
|
||||
.await?
|
||||
.map_err(|s| JobError::ContextError {
|
||||
id: job_id,
|
||||
reason: s,
|
||||
})?;
|
||||
|
||||
// Create worker channel
|
||||
let (tx, rx) = mpsc::channel(16);
|
||||
|
||||
// Create worker with shared dependencies
|
||||
let deps = WorkerDeps {
|
||||
context_manager: self.context_manager.clone(),
|
||||
llm: self.llm.clone(),
|
||||
safety: self.safety.clone(),
|
||||
tools: self.tools.clone(),
|
||||
store: self.store.clone(),
|
||||
timeout: self.config.job_timeout,
|
||||
use_planning: self.config.use_planning,
|
||||
};
|
||||
let worker = Worker::new(job_id, deps);
|
||||
|
||||
// Spawn worker task
|
||||
let handle = tokio::spawn(async move {
|
||||
if let Err(e) = worker.run(rx).await {
|
||||
tracing::error!("Worker for job {} failed: {}", job_id, e);
|
||||
}
|
||||
});
|
||||
|
||||
// Start the worker
|
||||
let _ = tx.send(WorkerMessage::Start).await;
|
||||
|
||||
// Store the scheduled job
|
||||
self.jobs
|
||||
.write()
|
||||
.await
|
||||
.insert(job_id, ScheduledJob { handle, tx });
|
||||
|
||||
// Cleanup task for this job to avoid capacity leaks
|
||||
let jobs = Arc::clone(&self.jobs);
|
||||
tokio::spawn(async move {
|
||||
@@ -380,23 +373,23 @@ impl Scheduler {
|
||||
.into());
|
||||
}
|
||||
|
||||
// Execute with per-tool timeout
|
||||
let tool_timeout = tool.execution_timeout();
|
||||
let result =
|
||||
tokio::time::timeout(tool_timeout, async { tool.execute(params, &job_ctx).await })
|
||||
.await
|
||||
.map_err(|_| {
|
||||
Error::Tool(crate::error::ToolError::Timeout {
|
||||
name: tool_name.to_string(),
|
||||
timeout: tool_timeout,
|
||||
})
|
||||
})?
|
||||
.map_err(|e| {
|
||||
Error::Tool(crate::error::ToolError::ExecutionFailed {
|
||||
name: tool_name.to_string(),
|
||||
reason: e.to_string(),
|
||||
})
|
||||
})?;
|
||||
// Execute with timeout
|
||||
let result = tokio::time::timeout(Duration::from_secs(60), async {
|
||||
tool.execute(params, &job_ctx).await
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
Error::Tool(crate::error::ToolError::Timeout {
|
||||
name: tool_name.to_string(),
|
||||
timeout: Duration::from_secs(60),
|
||||
})
|
||||
})?
|
||||
.map_err(|e| {
|
||||
Error::Tool(crate::error::ToolError::ExecutionFailed {
|
||||
name: tool_name.to_string(),
|
||||
reason: e.to_string(),
|
||||
})
|
||||
})?;
|
||||
|
||||
Ok(TaskOutput::new(result.result, start.elapsed()))
|
||||
}
|
||||
@@ -420,16 +413,10 @@ impl Scheduler {
|
||||
// Update job state
|
||||
self.context_manager
|
||||
.update_context(job_id, |ctx| {
|
||||
if let Err(e) = ctx.transition_to(
|
||||
let _ = ctx.transition_to(
|
||||
JobState::Cancelled,
|
||||
Some("Stopped by scheduler".to_string()),
|
||||
) {
|
||||
tracing::warn!(
|
||||
job_id = %job_id,
|
||||
error = %e,
|
||||
"Failed to transition job to Cancelled state"
|
||||
);
|
||||
}
|
||||
);
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
||||
+26
-28
@@ -8,8 +8,8 @@ use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::context::{ContextManager, JobState};
|
||||
use crate::db::Database;
|
||||
use crate::error::RepairError;
|
||||
use crate::history::Store;
|
||||
use crate::tools::{BuildRequirement, Language, SoftwareBuilder, SoftwareType, ToolRegistry};
|
||||
|
||||
/// A job that has been detected as stuck.
|
||||
@@ -66,14 +66,12 @@ pub trait SelfRepair: Send + Sync {
|
||||
/// Default self-repair implementation.
|
||||
pub struct DefaultSelfRepair {
|
||||
context_manager: Arc<ContextManager>,
|
||||
// TODO: use for time-based stuck detection (currently only max_repair_attempts is checked)
|
||||
#[allow(dead_code)]
|
||||
#[allow(dead_code)] // Will be used for time-based stuck detection
|
||||
stuck_threshold: Duration,
|
||||
max_repair_attempts: u32,
|
||||
store: Option<Arc<dyn Database>>,
|
||||
store: Option<Arc<Store>>,
|
||||
builder: Option<Arc<dyn SoftwareBuilder>>,
|
||||
// TODO: use for tool hot-reload after repair
|
||||
#[allow(dead_code)]
|
||||
#[allow(dead_code)] // Will be used for tool hot-reload after repair
|
||||
tools: Option<Arc<ToolRegistry>>,
|
||||
}
|
||||
|
||||
@@ -95,15 +93,15 @@ impl DefaultSelfRepair {
|
||||
}
|
||||
|
||||
/// Add a Store for tool failure tracking.
|
||||
#[allow(dead_code)] // TODO: wire up in main.rs when persistence is needed
|
||||
pub(crate) fn with_store(mut self, store: Arc<dyn Database>) -> Self {
|
||||
#[allow(dead_code)] // Public API for configuring repair with persistence
|
||||
pub fn with_store(mut self, store: Arc<Store>) -> Self {
|
||||
self.store = Some(store);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a Builder and ToolRegistry for automatic tool repair.
|
||||
#[allow(dead_code)] // TODO: wire up in main.rs when auto-repair is needed
|
||||
pub(crate) fn with_builder(
|
||||
#[allow(dead_code)] // Public API for enabling automatic tool repair
|
||||
pub fn with_builder(
|
||||
mut self,
|
||||
builder: Arc<dyn SoftwareBuilder>,
|
||||
tools: Arc<ToolRegistry>,
|
||||
@@ -121,25 +119,25 @@ impl SelfRepair for DefaultSelfRepair {
|
||||
let mut stuck_jobs = Vec::new();
|
||||
|
||||
for job_id in stuck_ids {
|
||||
if let Ok(ctx) = self.context_manager.get_context(job_id).await
|
||||
&& ctx.state == JobState::Stuck
|
||||
{
|
||||
let stuck_duration = ctx
|
||||
.started_at
|
||||
.map(|start| {
|
||||
let now = Utc::now();
|
||||
let duration = now.signed_duration_since(start);
|
||||
Duration::from_secs(duration.num_seconds().max(0) as u64)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if let Ok(ctx) = self.context_manager.get_context(job_id).await {
|
||||
if ctx.state == JobState::Stuck {
|
||||
let stuck_duration = ctx
|
||||
.started_at
|
||||
.map(|start| {
|
||||
let now = Utc::now();
|
||||
let duration = now.signed_duration_since(start);
|
||||
Duration::from_secs(duration.num_seconds().max(0) as u64)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
stuck_jobs.push(StuckJob {
|
||||
job_id,
|
||||
last_activity: ctx.started_at.unwrap_or(ctx.created_at),
|
||||
stuck_duration,
|
||||
last_error: None,
|
||||
repair_attempts: ctx.repair_attempts,
|
||||
});
|
||||
stuck_jobs.push(StuckJob {
|
||||
job_id,
|
||||
last_activity: ctx.started_at.unwrap_or(ctx.created_at),
|
||||
stuck_duration,
|
||||
last_error: None,
|
||||
repair_attempts: ctx.repair_attempts,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-423
@@ -16,7 +16,7 @@ use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::llm::{ChatMessage, ToolCall};
|
||||
use crate::llm::ChatMessage;
|
||||
|
||||
/// A session containing one or more threads.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -70,9 +70,10 @@ impl Session {
|
||||
pub fn create_thread(&mut self) -> &mut Thread {
|
||||
let thread = Thread::new(self.id);
|
||||
let thread_id = thread.id;
|
||||
self.threads.insert(thread_id, thread);
|
||||
self.active_thread = Some(thread_id);
|
||||
self.last_active_at = Utc::now();
|
||||
self.threads.entry(thread_id).or_insert(thread)
|
||||
self.threads.get_mut(&thread_id).expect("just inserted")
|
||||
}
|
||||
|
||||
/// Get the active thread.
|
||||
@@ -87,19 +88,10 @@ impl Session {
|
||||
|
||||
/// Get or create the active thread.
|
||||
pub fn get_or_create_thread(&mut self) -> &mut Thread {
|
||||
match self.active_thread {
|
||||
None => self.create_thread(),
|
||||
Some(id) => {
|
||||
if self.threads.contains_key(&id) {
|
||||
// Safe: contains_key confirmed the entry exists.
|
||||
self.threads.get_mut(&id).unwrap()
|
||||
} else {
|
||||
// Stale active_thread ID: create a new thread, which
|
||||
// updates self.active_thread to the new thread's ID.
|
||||
self.create_thread()
|
||||
}
|
||||
}
|
||||
if self.active_thread.is_none() {
|
||||
self.create_thread();
|
||||
}
|
||||
self.active_thread_mut().expect("just created")
|
||||
}
|
||||
|
||||
/// Switch to a different thread.
|
||||
@@ -156,10 +148,6 @@ pub struct PendingApproval {
|
||||
pub tool_call_id: String,
|
||||
/// Context messages at the time of the request (to resume from).
|
||||
pub context_messages: Vec<ChatMessage>,
|
||||
/// Remaining tool calls from the same assistant message that were not
|
||||
/// executed yet when approval was requested.
|
||||
#[serde(default)]
|
||||
pub deferred_tool_calls: Vec<ToolCall>,
|
||||
}
|
||||
|
||||
/// A conversation thread within a session.
|
||||
@@ -204,22 +192,6 @@ impl Thread {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a thread with a specific ID (for DB hydration).
|
||||
pub fn with_id(id: Uuid, session_id: Uuid) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id,
|
||||
session_id,
|
||||
state: ThreadState::Idle,
|
||||
turns: Vec::new(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
metadata: serde_json::Value::Null,
|
||||
pending_approval: None,
|
||||
pending_auth: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current turn number (1-indexed for display).
|
||||
pub fn turn_number(&self) -> usize {
|
||||
self.turns.len() + 1
|
||||
@@ -242,8 +214,7 @@ impl Thread {
|
||||
self.turns.push(turn);
|
||||
self.state = ThreadState::Processing;
|
||||
self.updated_at = Utc::now();
|
||||
// turn_number was len() before push, so it's a valid index after push
|
||||
&mut self.turns[turn_number]
|
||||
self.turns.last_mut().expect("just pushed")
|
||||
}
|
||||
|
||||
/// Complete the current turn with a response.
|
||||
@@ -353,11 +324,9 @@ impl Thread {
|
||||
let mut turn = Turn::new(turn_number, &msg.content);
|
||||
|
||||
// Check if next is assistant response
|
||||
if let Some(next) = iter.peek()
|
||||
&& next.role == crate::llm::Role::Assistant
|
||||
{
|
||||
// iter.next() is guaranteed Some after a successful peek()
|
||||
if let Some(response) = iter.next() {
|
||||
if let Some(next) = iter.peek() {
|
||||
if next.role == crate::llm::Role::Assistant {
|
||||
let response = iter.next().expect("peeked");
|
||||
turn.complete(&response.content);
|
||||
}
|
||||
}
|
||||
@@ -624,386 +593,4 @@ mod tests {
|
||||
let restored: Thread = serde_json::from_str(&json).expect("should deserialize");
|
||||
assert!(restored.pending_auth.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thread_with_id() {
|
||||
let specific_id = Uuid::new_v4();
|
||||
let session_id = Uuid::new_v4();
|
||||
let thread = Thread::with_id(specific_id, session_id);
|
||||
|
||||
assert_eq!(thread.id, specific_id);
|
||||
assert_eq!(thread.session_id, session_id);
|
||||
assert_eq!(thread.state, ThreadState::Idle);
|
||||
assert!(thread.turns.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thread_with_id_restore_messages() {
|
||||
let thread_id = Uuid::new_v4();
|
||||
let session_id = Uuid::new_v4();
|
||||
let mut thread = Thread::with_id(thread_id, session_id);
|
||||
|
||||
let messages = vec![
|
||||
ChatMessage::user("Hello from DB"),
|
||||
ChatMessage::assistant("Restored response"),
|
||||
];
|
||||
thread.restore_from_messages(messages);
|
||||
|
||||
assert_eq!(thread.id, thread_id);
|
||||
assert_eq!(thread.turns.len(), 1);
|
||||
assert_eq!(thread.turns[0].user_input, "Hello from DB");
|
||||
assert_eq!(
|
||||
thread.turns[0].response,
|
||||
Some("Restored response".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restore_from_messages_empty() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
|
||||
// Add a turn first, then restore with empty vec
|
||||
thread.start_turn("hello");
|
||||
thread.complete_turn("hi");
|
||||
assert_eq!(thread.turns.len(), 1);
|
||||
|
||||
thread.restore_from_messages(Vec::new());
|
||||
|
||||
// Should clear all turns and stay idle
|
||||
assert!(thread.turns.is_empty());
|
||||
assert_eq!(thread.state, ThreadState::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restore_from_messages_only_assistant_messages() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
|
||||
// Only assistant messages (no user messages to anchor turns)
|
||||
let messages = vec![
|
||||
ChatMessage::assistant("I'm here"),
|
||||
ChatMessage::assistant("Still here"),
|
||||
];
|
||||
|
||||
thread.restore_from_messages(messages);
|
||||
|
||||
// Assistant-only messages have no user turn to attach to, so
|
||||
// they should be skipped entirely.
|
||||
assert!(thread.turns.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restore_from_messages_multiple_user_messages_in_a_row() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
|
||||
// Two user messages with no assistant response between them
|
||||
let messages = vec![
|
||||
ChatMessage::user("first"),
|
||||
ChatMessage::user("second"),
|
||||
ChatMessage::assistant("reply to second"),
|
||||
];
|
||||
|
||||
thread.restore_from_messages(messages);
|
||||
|
||||
// First user message becomes a turn with no response,
|
||||
// second user message pairs with the assistant response.
|
||||
assert_eq!(thread.turns.len(), 2);
|
||||
assert_eq!(thread.turns[0].user_input, "first");
|
||||
assert!(thread.turns[0].response.is_none());
|
||||
assert_eq!(thread.turns[1].user_input, "second");
|
||||
assert_eq!(
|
||||
thread.turns[1].response,
|
||||
Some("reply to second".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thread_switch() {
|
||||
let mut session = Session::new("user-1");
|
||||
|
||||
let t1_id = session.create_thread().id;
|
||||
let t2_id = session.create_thread().id;
|
||||
|
||||
// After creating two threads, active should be the last one
|
||||
assert_eq!(session.active_thread, Some(t2_id));
|
||||
|
||||
// Switch back to the first
|
||||
assert!(session.switch_thread(t1_id));
|
||||
assert_eq!(session.active_thread, Some(t1_id));
|
||||
|
||||
// Switching to a nonexistent thread should fail
|
||||
let fake_id = Uuid::new_v4();
|
||||
assert!(!session.switch_thread(fake_id));
|
||||
// Active thread should remain unchanged
|
||||
assert_eq!(session.active_thread, Some(t1_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_or_create_thread_idempotent() {
|
||||
let mut session = Session::new("user-1");
|
||||
|
||||
let tid1 = session.get_or_create_thread().id;
|
||||
let tid2 = session.get_or_create_thread().id;
|
||||
|
||||
// Should return the same thread (not create a new one each time)
|
||||
assert_eq!(tid1, tid2);
|
||||
assert_eq!(session.threads.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_turns() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
|
||||
for i in 0..5 {
|
||||
thread.start_turn(format!("msg-{}", i));
|
||||
thread.complete_turn(format!("resp-{}", i));
|
||||
}
|
||||
assert_eq!(thread.turns.len(), 5);
|
||||
|
||||
thread.truncate_turns(3);
|
||||
assert_eq!(thread.turns.len(), 3);
|
||||
|
||||
// Should keep the most recent turns
|
||||
assert_eq!(thread.turns[0].user_input, "msg-2");
|
||||
assert_eq!(thread.turns[1].user_input, "msg-3");
|
||||
assert_eq!(thread.turns[2].user_input, "msg-4");
|
||||
|
||||
// Turn numbers should be re-indexed
|
||||
assert_eq!(thread.turns[0].turn_number, 0);
|
||||
assert_eq!(thread.turns[1].turn_number, 1);
|
||||
assert_eq!(thread.turns[2].turn_number, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_turns_noop_when_fewer() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
|
||||
thread.start_turn("only one");
|
||||
thread.complete_turn("response");
|
||||
|
||||
thread.truncate_turns(10);
|
||||
assert_eq!(thread.turns.len(), 1);
|
||||
assert_eq!(thread.turns[0].user_input, "only one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thread_interrupt_and_resume() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
|
||||
thread.start_turn("do something");
|
||||
assert_eq!(thread.state, ThreadState::Processing);
|
||||
|
||||
thread.interrupt();
|
||||
assert_eq!(thread.state, ThreadState::Interrupted);
|
||||
|
||||
let last_turn = thread.last_turn().unwrap();
|
||||
assert_eq!(last_turn.state, TurnState::Interrupted);
|
||||
assert!(last_turn.completed_at.is_some());
|
||||
|
||||
thread.resume();
|
||||
assert_eq!(thread.state, ThreadState::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resume_only_from_interrupted() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
|
||||
// Idle thread: resume should be a no-op
|
||||
assert_eq!(thread.state, ThreadState::Idle);
|
||||
thread.resume();
|
||||
assert_eq!(thread.state, ThreadState::Idle);
|
||||
|
||||
// Processing thread: resume should not change state
|
||||
thread.start_turn("work");
|
||||
assert_eq!(thread.state, ThreadState::Processing);
|
||||
thread.resume();
|
||||
assert_eq!(thread.state, ThreadState::Processing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_turn_fail() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
|
||||
thread.start_turn("risky operation");
|
||||
thread.fail_turn("connection timed out");
|
||||
|
||||
assert_eq!(thread.state, ThreadState::Idle);
|
||||
|
||||
let turn = thread.last_turn().unwrap();
|
||||
assert_eq!(turn.state, TurnState::Failed);
|
||||
assert_eq!(turn.error, Some("connection timed out".to_string()));
|
||||
assert!(turn.response.is_none());
|
||||
assert!(turn.completed_at.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_messages_with_incomplete_last_turn() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
|
||||
thread.start_turn("first");
|
||||
thread.complete_turn("first reply");
|
||||
thread.start_turn("second (in progress)");
|
||||
|
||||
let messages = thread.messages();
|
||||
// Should have 3 messages: user, assistant, user (no assistant for in-progress)
|
||||
assert_eq!(messages.len(), 3);
|
||||
assert_eq!(messages[0].content, "first");
|
||||
assert_eq!(messages[1].content, "first reply");
|
||||
assert_eq!(messages[2].content, "second (in progress)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thread_serialization_round_trip() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
|
||||
thread.start_turn("hello");
|
||||
thread.complete_turn("world");
|
||||
|
||||
let json = serde_json::to_string(&thread).unwrap();
|
||||
let restored: Thread = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(restored.id, thread.id);
|
||||
assert_eq!(restored.session_id, thread.session_id);
|
||||
assert_eq!(restored.turns.len(), 1);
|
||||
assert_eq!(restored.turns[0].user_input, "hello");
|
||||
assert_eq!(restored.turns[0].response, Some("world".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_serialization_round_trip() {
|
||||
let mut session = Session::new("user-ser");
|
||||
session.create_thread();
|
||||
session.auto_approve_tool("echo");
|
||||
|
||||
let json = serde_json::to_string(&session).unwrap();
|
||||
let restored: Session = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(restored.user_id, "user-ser");
|
||||
assert_eq!(restored.threads.len(), 1);
|
||||
assert!(restored.is_tool_auto_approved("echo"));
|
||||
assert!(!restored.is_tool_auto_approved("shell"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auto_approved_tools() {
|
||||
let mut session = Session::new("user-1");
|
||||
|
||||
assert!(!session.is_tool_auto_approved("shell"));
|
||||
session.auto_approve_tool("shell");
|
||||
assert!(session.is_tool_auto_approved("shell"));
|
||||
|
||||
// Idempotent
|
||||
session.auto_approve_tool("shell");
|
||||
assert_eq!(session.auto_approved_tools.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_turn_tool_call_error() {
|
||||
let mut turn = Turn::new(0, "test");
|
||||
turn.record_tool_call("http", serde_json::json!({"url": "example.com"}));
|
||||
turn.record_tool_error("timeout");
|
||||
|
||||
assert_eq!(turn.tool_calls.len(), 1);
|
||||
assert_eq!(turn.tool_calls[0].error, Some("timeout".to_string()));
|
||||
assert!(turn.tool_calls[0].result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_turn_number_increments() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
|
||||
// Before any turns, turn_number() is 1 (1-indexed for display)
|
||||
assert_eq!(thread.turn_number(), 1);
|
||||
|
||||
thread.start_turn("first");
|
||||
thread.complete_turn("done");
|
||||
assert_eq!(thread.turn_number(), 2);
|
||||
|
||||
thread.start_turn("second");
|
||||
assert_eq!(thread.turn_number(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complete_turn_on_empty_thread() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
|
||||
// Completing a turn when there are no turns should be a safe no-op
|
||||
thread.complete_turn("phantom response");
|
||||
assert_eq!(thread.state, ThreadState::Idle);
|
||||
assert!(thread.turns.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fail_turn_on_empty_thread() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
|
||||
// Failing a turn when there are no turns should be a safe no-op
|
||||
thread.fail_turn("phantom error");
|
||||
assert_eq!(thread.state, ThreadState::Idle);
|
||||
assert!(thread.turns.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pending_approval_flow() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
|
||||
let approval = PendingApproval {
|
||||
request_id: Uuid::new_v4(),
|
||||
tool_name: "shell".to_string(),
|
||||
parameters: serde_json::json!({"command": "rm -rf /"}),
|
||||
description: "dangerous command".to_string(),
|
||||
tool_call_id: "call_123".to_string(),
|
||||
context_messages: vec![ChatMessage::user("do it")],
|
||||
deferred_tool_calls: vec![],
|
||||
};
|
||||
|
||||
thread.await_approval(approval);
|
||||
assert_eq!(thread.state, ThreadState::AwaitingApproval);
|
||||
assert!(thread.pending_approval.is_some());
|
||||
|
||||
let taken = thread.take_pending_approval();
|
||||
assert!(taken.is_some());
|
||||
assert_eq!(taken.unwrap().tool_name, "shell");
|
||||
assert!(thread.pending_approval.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear_pending_approval() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
|
||||
let approval = PendingApproval {
|
||||
request_id: Uuid::new_v4(),
|
||||
tool_name: "http".to_string(),
|
||||
parameters: serde_json::json!({}),
|
||||
description: "test".to_string(),
|
||||
tool_call_id: "call_456".to_string(),
|
||||
context_messages: vec![],
|
||||
deferred_tool_calls: vec![],
|
||||
};
|
||||
|
||||
thread.await_approval(approval);
|
||||
thread.clear_pending_approval();
|
||||
|
||||
assert_eq!(thread.state, ThreadState::Idle);
|
||||
assert!(thread.pending_approval.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_active_thread_accessors() {
|
||||
let mut session = Session::new("user-1");
|
||||
|
||||
assert!(session.active_thread().is_none());
|
||||
assert!(session.active_thread_mut().is_none());
|
||||
|
||||
let tid = session.create_thread().id;
|
||||
|
||||
assert!(session.active_thread().is_some());
|
||||
assert_eq!(session.active_thread().unwrap().id, tid);
|
||||
|
||||
// Mutably modify through accessor
|
||||
session.active_thread_mut().unwrap().start_turn("test");
|
||||
assert_eq!(
|
||||
session.active_thread().unwrap().state,
|
||||
ThreadState::Processing
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,6 @@ use uuid::Uuid;
|
||||
|
||||
use crate::agent::session::Session;
|
||||
use crate::agent::undo::UndoManager;
|
||||
use crate::hooks::HookRegistry;
|
||||
|
||||
/// Warn when session count exceeds this threshold.
|
||||
const SESSION_COUNT_WARNING_THRESHOLD: usize = 1000;
|
||||
|
||||
/// Key for mapping external thread IDs to internal ones.
|
||||
#[derive(Clone, Hash, Eq, PartialEq)]
|
||||
@@ -29,7 +25,6 @@ pub struct SessionManager {
|
||||
sessions: RwLock<HashMap<String, Arc<Mutex<Session>>>>,
|
||||
thread_map: RwLock<HashMap<ThreadKey, Uuid>>,
|
||||
undo_managers: RwLock<HashMap<Uuid, Arc<Mutex<UndoManager>>>>,
|
||||
hooks: Option<Arc<HookRegistry>>,
|
||||
}
|
||||
|
||||
impl SessionManager {
|
||||
@@ -39,16 +34,9 @@ impl SessionManager {
|
||||
sessions: RwLock::new(HashMap::new()),
|
||||
thread_map: RwLock::new(HashMap::new()),
|
||||
undo_managers: RwLock::new(HashMap::new()),
|
||||
hooks: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach a hook registry for session lifecycle events.
|
||||
pub fn with_hooks(mut self, hooks: Arc<HookRegistry>) -> Self {
|
||||
self.hooks = Some(hooks);
|
||||
self
|
||||
}
|
||||
|
||||
/// Get or create a session for a user.
|
||||
pub async fn get_or_create_session(&self, user_id: &str) -> Arc<Mutex<Session>> {
|
||||
// Fast path: check if session exists
|
||||
@@ -66,36 +54,8 @@ impl SessionManager {
|
||||
return Arc::clone(session);
|
||||
}
|
||||
|
||||
let new_session = Session::new(user_id);
|
||||
let session_id = new_session.id.to_string();
|
||||
let session = Arc::new(Mutex::new(new_session));
|
||||
let session = Arc::new(Mutex::new(Session::new(user_id)));
|
||||
sessions.insert(user_id.to_string(), Arc::clone(&session));
|
||||
|
||||
if sessions.len() >= SESSION_COUNT_WARNING_THRESHOLD && sessions.len() % 100 == 0 {
|
||||
tracing::warn!(
|
||||
"High session count: {} active sessions. \
|
||||
Pruning runs every 10 minutes; consider reducing session_idle_timeout.",
|
||||
sessions.len()
|
||||
);
|
||||
}
|
||||
|
||||
// Fire OnSessionStart hook (fire-and-forget)
|
||||
if let Some(ref hooks) = self.hooks {
|
||||
let hooks = hooks.clone();
|
||||
let uid = user_id.to_string();
|
||||
let sid = session_id;
|
||||
tokio::spawn(async move {
|
||||
use crate::hooks::HookEvent;
|
||||
let event = HookEvent::SessionStart {
|
||||
user_id: uid,
|
||||
session_id: sid,
|
||||
};
|
||||
if let Err(e) = hooks.run(&event).await {
|
||||
tracing::warn!("OnSessionStart hook error: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
session
|
||||
}
|
||||
|
||||
@@ -150,41 +110,6 @@ impl SessionManager {
|
||||
(session, thread_id)
|
||||
}
|
||||
|
||||
/// Register a hydrated thread so subsequent `resolve_thread` calls find it.
|
||||
///
|
||||
/// Inserts into the thread_map and creates an undo manager for the thread.
|
||||
pub async fn register_thread(
|
||||
&self,
|
||||
user_id: &str,
|
||||
channel: &str,
|
||||
thread_id: Uuid,
|
||||
session: Arc<Mutex<Session>>,
|
||||
) {
|
||||
let key = ThreadKey {
|
||||
user_id: user_id.to_string(),
|
||||
channel: channel.to_string(),
|
||||
external_thread_id: Some(thread_id.to_string()),
|
||||
};
|
||||
|
||||
{
|
||||
let mut thread_map = self.thread_map.write().await;
|
||||
thread_map.insert(key, thread_id);
|
||||
}
|
||||
|
||||
{
|
||||
let mut undo_managers = self.undo_managers.write().await;
|
||||
undo_managers
|
||||
.entry(thread_id)
|
||||
.or_insert_with(|| Arc::new(Mutex::new(UndoManager::new())));
|
||||
}
|
||||
|
||||
// Ensure the session is tracked
|
||||
{
|
||||
let mut sessions = self.sessions.write().await;
|
||||
sessions.entry(user_id.to_string()).or_insert(session);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get undo manager for a thread.
|
||||
pub async fn get_undo_manager(&self, thread_id: Uuid) -> Arc<Mutex<UndoManager>> {
|
||||
// Fast path
|
||||
@@ -213,8 +138,8 @@ impl SessionManager {
|
||||
pub async fn prune_stale_sessions(&self, max_idle: std::time::Duration) -> usize {
|
||||
let cutoff = chrono::Utc::now() - chrono::TimeDelta::seconds(max_idle.as_secs() as i64);
|
||||
|
||||
// Find stale sessions (user_id + session_id)
|
||||
let stale_sessions: Vec<(String, String)> = {
|
||||
// Find stale session user_ids
|
||||
let stale_users: Vec<String> = {
|
||||
let sessions = self.sessions.read().await;
|
||||
sessions
|
||||
.iter()
|
||||
@@ -222,7 +147,7 @@ impl SessionManager {
|
||||
// Try to lock; skip if contended (someone is actively using it)
|
||||
let sess = session.try_lock().ok()?;
|
||||
if sess.last_active_at < cutoff {
|
||||
Some((user_id.clone(), sess.id.to_string()))
|
||||
Some(user_id.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -230,11 +155,6 @@ impl SessionManager {
|
||||
.collect()
|
||||
};
|
||||
|
||||
let stale_users: Vec<String> = stale_sessions
|
||||
.iter()
|
||||
.map(|(user_id, _)| user_id.clone())
|
||||
.collect();
|
||||
|
||||
if stale_users.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
@@ -244,30 +164,11 @@ impl SessionManager {
|
||||
{
|
||||
let sessions = self.sessions.read().await;
|
||||
for user_id in &stale_users {
|
||||
if let Some(session) = sessions.get(user_id)
|
||||
&& let Ok(sess) = session.try_lock()
|
||||
{
|
||||
stale_thread_ids.extend(sess.threads.keys());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fire OnSessionEnd hooks for stale sessions (fire-and-forget)
|
||||
if let Some(ref hooks) = self.hooks {
|
||||
for (user_id, session_id) in &stale_sessions {
|
||||
let hooks = hooks.clone();
|
||||
let uid = user_id.clone();
|
||||
let sid = session_id.clone();
|
||||
tokio::spawn(async move {
|
||||
use crate::hooks::HookEvent;
|
||||
let event = HookEvent::SessionEnd {
|
||||
user_id: uid,
|
||||
session_id: sid,
|
||||
};
|
||||
if let Err(e) = hooks.run(&event).await {
|
||||
tracing::warn!("OnSessionEnd hook error: {}", e);
|
||||
if let Some(session) = sessions.get(user_id) {
|
||||
if let Ok(sess) = session.try_lock() {
|
||||
stale_thread_ids.extend(sess.threads.keys());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,344 +296,4 @@ mod tests {
|
||||
.await;
|
||||
assert_eq!(pruned, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_register_thread() {
|
||||
use crate::agent::session::{Session, Thread};
|
||||
|
||||
let manager = SessionManager::new();
|
||||
let thread_id = Uuid::new_v4();
|
||||
|
||||
// Create a session with a hydrated thread
|
||||
let session = Arc::new(Mutex::new(Session::new("user-hydrate")));
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
let thread = Thread::with_id(thread_id, sess.id);
|
||||
sess.threads.insert(thread_id, thread);
|
||||
sess.active_thread = Some(thread_id);
|
||||
}
|
||||
|
||||
// Register the thread
|
||||
manager
|
||||
.register_thread("user-hydrate", "gateway", thread_id, Arc::clone(&session))
|
||||
.await;
|
||||
|
||||
// resolve_thread should find it (using the UUID as external_thread_id)
|
||||
let (resolved_session, resolved_tid) = manager
|
||||
.resolve_thread("user-hydrate", "gateway", Some(&thread_id.to_string()))
|
||||
.await;
|
||||
assert_eq!(resolved_tid, thread_id);
|
||||
|
||||
// Should be the same session object
|
||||
let sess = resolved_session.lock().await;
|
||||
assert!(sess.threads.contains_key(&thread_id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_thread_with_explicit_external_id() {
|
||||
let manager = SessionManager::new();
|
||||
|
||||
// Two calls with the same explicit external thread ID should resolve
|
||||
// to the same internal thread.
|
||||
let (_, t1) = manager
|
||||
.resolve_thread("user-1", "gateway", Some("ext-abc"))
|
||||
.await;
|
||||
let (_, t2) = manager
|
||||
.resolve_thread("user-1", "gateway", Some("ext-abc"))
|
||||
.await;
|
||||
assert_eq!(t1, t2);
|
||||
|
||||
// A different external ID on the same channel/user gets a new thread.
|
||||
let (_, t3) = manager
|
||||
.resolve_thread("user-1", "gateway", Some("ext-xyz"))
|
||||
.await;
|
||||
assert_ne!(t1, t3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_thread_none_vs_some_external_id() {
|
||||
let manager = SessionManager::new();
|
||||
|
||||
// None external_thread_id is a distinct key from Some("ext-1").
|
||||
let (_, t_none) = manager.resolve_thread("user-1", "cli", None).await;
|
||||
let (_, t_some) = manager.resolve_thread("user-1", "cli", Some("ext-1")).await;
|
||||
assert_ne!(t_none, t_some);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_thread_different_users_isolated() {
|
||||
let manager = SessionManager::new();
|
||||
|
||||
let (_, t1) = manager
|
||||
.resolve_thread("user-a", "gateway", Some("same-ext"))
|
||||
.await;
|
||||
let (_, t2) = manager
|
||||
.resolve_thread("user-b", "gateway", Some("same-ext"))
|
||||
.await;
|
||||
|
||||
// Same channel + same external ID but different users = different threads
|
||||
assert_ne!(t1, t2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_thread_different_channels_isolated() {
|
||||
let manager = SessionManager::new();
|
||||
|
||||
let (_, t1) = manager
|
||||
.resolve_thread("user-1", "gateway", Some("thread-x"))
|
||||
.await;
|
||||
let (_, t2) = manager
|
||||
.resolve_thread("user-1", "telegram", Some("thread-x"))
|
||||
.await;
|
||||
|
||||
// Same user + same external ID but different channels = different threads
|
||||
assert_ne!(t1, t2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_thread_stale_mapping_creates_new_thread() {
|
||||
let manager = SessionManager::new();
|
||||
|
||||
// Create a thread normally
|
||||
let (session, original_tid) = manager
|
||||
.resolve_thread("user-1", "gateway", Some("ext-1"))
|
||||
.await;
|
||||
|
||||
// Simulate the thread being removed from the session (e.g. pruned)
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
sess.threads.remove(&original_tid);
|
||||
}
|
||||
|
||||
// Next resolve should detect the stale mapping and create a fresh thread
|
||||
let (_, new_tid) = manager
|
||||
.resolve_thread("user-1", "gateway", Some("ext-1"))
|
||||
.await;
|
||||
assert_ne!(original_tid, new_tid);
|
||||
|
||||
// The new thread should actually exist in the session
|
||||
let sess = session.lock().await;
|
||||
assert!(sess.threads.contains_key(&new_tid));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_register_thread_preserves_uuid_on_resolve() {
|
||||
use crate::agent::session::{Session, Thread};
|
||||
|
||||
let manager = SessionManager::new();
|
||||
let known_uuid = Uuid::new_v4();
|
||||
|
||||
let session = Arc::new(Mutex::new(Session::new("user-web")));
|
||||
let session_id = {
|
||||
let sess = session.lock().await;
|
||||
sess.id
|
||||
};
|
||||
|
||||
// Simulate hydration: create thread with a known UUID
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
let thread = Thread::with_id(known_uuid, session_id);
|
||||
sess.threads.insert(known_uuid, thread);
|
||||
}
|
||||
|
||||
// Register it
|
||||
manager
|
||||
.register_thread("user-web", "gateway", known_uuid, Arc::clone(&session))
|
||||
.await;
|
||||
|
||||
// resolve_thread with UUID as external_thread_id MUST return the same UUID,
|
||||
// not mint a new one (this was the root cause of the "wrong conversation" bug)
|
||||
let (_, resolved) = manager
|
||||
.resolve_thread("user-web", "gateway", Some(&known_uuid.to_string()))
|
||||
.await;
|
||||
assert_eq!(resolved, known_uuid);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_register_thread_idempotent() {
|
||||
use crate::agent::session::{Session, Thread};
|
||||
|
||||
let manager = SessionManager::new();
|
||||
let tid = Uuid::new_v4();
|
||||
|
||||
let session = Arc::new(Mutex::new(Session::new("user-idem")));
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
let thread = Thread::with_id(tid, sess.id);
|
||||
sess.threads.insert(tid, thread);
|
||||
}
|
||||
|
||||
// Register twice
|
||||
manager
|
||||
.register_thread("user-idem", "gateway", tid, Arc::clone(&session))
|
||||
.await;
|
||||
manager
|
||||
.register_thread("user-idem", "gateway", tid, Arc::clone(&session))
|
||||
.await;
|
||||
|
||||
// Should still resolve to the same thread
|
||||
let (_, resolved) = manager
|
||||
.resolve_thread("user-idem", "gateway", Some(&tid.to_string()))
|
||||
.await;
|
||||
assert_eq!(resolved, tid);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_register_thread_creates_undo_manager() {
|
||||
use crate::agent::session::{Session, Thread};
|
||||
|
||||
let manager = SessionManager::new();
|
||||
let tid = Uuid::new_v4();
|
||||
|
||||
let session = Arc::new(Mutex::new(Session::new("user-undo")));
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
let thread = Thread::with_id(tid, sess.id);
|
||||
sess.threads.insert(tid, thread);
|
||||
}
|
||||
|
||||
manager
|
||||
.register_thread("user-undo", "gateway", tid, Arc::clone(&session))
|
||||
.await;
|
||||
|
||||
// Undo manager should exist for the registered thread
|
||||
let undo = manager.get_undo_manager(tid).await;
|
||||
let undo2 = manager.get_undo_manager(tid).await;
|
||||
assert!(Arc::ptr_eq(&undo, &undo2));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_register_thread_stores_session() {
|
||||
use crate::agent::session::{Session, Thread};
|
||||
|
||||
let manager = SessionManager::new();
|
||||
let tid = Uuid::new_v4();
|
||||
|
||||
let session = Arc::new(Mutex::new(Session::new("user-new")));
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
let thread = Thread::with_id(tid, sess.id);
|
||||
sess.threads.insert(tid, thread);
|
||||
}
|
||||
|
||||
// The user has no session yet in the manager
|
||||
{
|
||||
let sessions = manager.sessions.read().await;
|
||||
assert!(!sessions.contains_key("user-new"));
|
||||
}
|
||||
|
||||
manager
|
||||
.register_thread("user-new", "gateway", tid, Arc::clone(&session))
|
||||
.await;
|
||||
|
||||
// Now the session should be tracked
|
||||
{
|
||||
let sessions = manager.sessions.read().await;
|
||||
assert!(sessions.contains_key("user-new"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_multiple_threads_per_user() {
|
||||
let manager = SessionManager::new();
|
||||
|
||||
let (_, t1) = manager
|
||||
.resolve_thread("user-1", "gateway", Some("thread-a"))
|
||||
.await;
|
||||
let (_, t2) = manager
|
||||
.resolve_thread("user-1", "gateway", Some("thread-b"))
|
||||
.await;
|
||||
let (session, t3) = manager
|
||||
.resolve_thread("user-1", "gateway", Some("thread-c"))
|
||||
.await;
|
||||
|
||||
// All three should be distinct
|
||||
assert_ne!(t1, t2);
|
||||
assert_ne!(t2, t3);
|
||||
assert_ne!(t1, t3);
|
||||
|
||||
// All three should exist in the same session
|
||||
let sess = session.lock().await;
|
||||
assert!(sess.threads.contains_key(&t1));
|
||||
assert!(sess.threads.contains_key(&t2));
|
||||
assert!(sess.threads.contains_key(&t3));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_prune_cleans_thread_map_and_undo_managers() {
|
||||
let manager = SessionManager::new();
|
||||
|
||||
let (stale_session, stale_tid) = manager.resolve_thread("user-stale", "cli", None).await;
|
||||
|
||||
// Backdate the session
|
||||
{
|
||||
let mut sess = stale_session.lock().await;
|
||||
sess.last_active_at = chrono::Utc::now() - chrono::TimeDelta::seconds(86400 * 30);
|
||||
}
|
||||
|
||||
// Verify thread_map and undo_managers have entries
|
||||
{
|
||||
let tm = manager.thread_map.read().await;
|
||||
assert!(!tm.is_empty());
|
||||
}
|
||||
{
|
||||
let um = manager.undo_managers.read().await;
|
||||
assert!(um.contains_key(&stale_tid));
|
||||
}
|
||||
|
||||
let pruned = manager
|
||||
.prune_stale_sessions(std::time::Duration::from_secs(86400 * 7))
|
||||
.await;
|
||||
assert_eq!(pruned, 1);
|
||||
|
||||
// Thread map and undo managers should be cleaned up
|
||||
{
|
||||
let tm = manager.thread_map.read().await;
|
||||
assert!(tm.is_empty());
|
||||
}
|
||||
{
|
||||
let um = manager.undo_managers.read().await;
|
||||
assert!(!um.contains_key(&stale_tid));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_thread_active_thread_set() {
|
||||
let manager = SessionManager::new();
|
||||
|
||||
let (session, thread_id) = manager
|
||||
.resolve_thread("user-1", "gateway", Some("ext-1"))
|
||||
.await;
|
||||
|
||||
// The resolved thread should be set as the active thread
|
||||
let sess = session.lock().await;
|
||||
assert_eq!(sess.active_thread, Some(thread_id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_register_then_resolve_different_channel_creates_new() {
|
||||
use crate::agent::session::{Session, Thread};
|
||||
|
||||
let manager = SessionManager::new();
|
||||
let tid = Uuid::new_v4();
|
||||
|
||||
let session = Arc::new(Mutex::new(Session::new("user-cross")));
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
let thread = Thread::with_id(tid, sess.id);
|
||||
sess.threads.insert(tid, thread);
|
||||
}
|
||||
|
||||
// Register on "gateway" channel
|
||||
manager
|
||||
.register_thread("user-cross", "gateway", tid, Arc::clone(&session))
|
||||
.await;
|
||||
|
||||
// Resolve on a different channel with the same UUID string should NOT
|
||||
// find the registered thread (channel is part of the key)
|
||||
let (_, resolved) = manager
|
||||
.resolve_thread("user-cross", "telegram", Some(&tid.to_string()))
|
||||
.await;
|
||||
assert_ne!(resolved, tid);
|
||||
}
|
||||
}
|
||||
|
||||
+17
-206
@@ -43,49 +43,6 @@ impl SubmissionParser {
|
||||
if lower == "/thread new" || lower == "/new" {
|
||||
return Submission::NewThread;
|
||||
}
|
||||
// System commands (bypass thread-state checks)
|
||||
if lower == "/help" || lower == "/?" {
|
||||
return Submission::SystemCommand {
|
||||
command: "help".to_string(),
|
||||
args: vec![],
|
||||
};
|
||||
}
|
||||
if lower == "/version" {
|
||||
return Submission::SystemCommand {
|
||||
command: "version".to_string(),
|
||||
args: vec![],
|
||||
};
|
||||
}
|
||||
if lower == "/tools" {
|
||||
return Submission::SystemCommand {
|
||||
command: "tools".to_string(),
|
||||
args: vec![],
|
||||
};
|
||||
}
|
||||
if lower == "/ping" {
|
||||
return Submission::SystemCommand {
|
||||
command: "ping".to_string(),
|
||||
args: vec![],
|
||||
};
|
||||
}
|
||||
if lower == "/debug" {
|
||||
return Submission::SystemCommand {
|
||||
command: "debug".to_string(),
|
||||
args: vec![],
|
||||
};
|
||||
}
|
||||
if lower.starts_with("/model") {
|
||||
let args: Vec<String> = trimmed
|
||||
.split_whitespace()
|
||||
.skip(1)
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
return Submission::SystemCommand {
|
||||
command: "model".to_string(),
|
||||
args,
|
||||
};
|
||||
}
|
||||
|
||||
if lower == "/quit" || lower == "/exit" || lower == "/shutdown" {
|
||||
return Submission::Quit;
|
||||
}
|
||||
@@ -93,44 +50,45 @@ impl SubmissionParser {
|
||||
// /thread <uuid> - switch thread
|
||||
if let Some(rest) = lower.strip_prefix("/thread ") {
|
||||
let rest = rest.trim();
|
||||
if rest != "new"
|
||||
&& let Ok(id) = Uuid::parse_str(rest)
|
||||
{
|
||||
return Submission::SwitchThread { thread_id: id };
|
||||
if rest != "new" {
|
||||
if let Ok(id) = Uuid::parse_str(rest) {
|
||||
return Submission::SwitchThread { thread_id: id };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// /resume <uuid> - resume from checkpoint
|
||||
if let Some(rest) = lower.strip_prefix("/resume ")
|
||||
&& let Ok(id) = Uuid::parse_str(rest.trim())
|
||||
{
|
||||
return Submission::Resume { checkpoint_id: id };
|
||||
if let Some(rest) = lower.strip_prefix("/resume ") {
|
||||
if let Ok(id) = Uuid::parse_str(rest.trim()) {
|
||||
return Submission::Resume { checkpoint_id: id };
|
||||
}
|
||||
}
|
||||
|
||||
// Try structured JSON approval (from web gateway's /api/chat/approval endpoint)
|
||||
if trimmed.starts_with('{')
|
||||
&& let Ok(submission) = serde_json::from_str::<Submission>(trimmed)
|
||||
&& matches!(submission, Submission::ExecApproval { .. })
|
||||
{
|
||||
return submission;
|
||||
if trimmed.starts_with('{') {
|
||||
if let Ok(submission) = serde_json::from_str::<Submission>(trimmed) {
|
||||
if matches!(submission, Submission::ExecApproval { .. }) {
|
||||
return submission;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Approval responses (simple yes/no/always for pending approvals)
|
||||
// These are short enough to check explicitly
|
||||
match lower.as_str() {
|
||||
"yes" | "y" | "approve" | "ok" | "/approve" | "/yes" | "/y" => {
|
||||
"yes" | "y" | "approve" | "ok" => {
|
||||
return Submission::ApprovalResponse {
|
||||
approved: true,
|
||||
always: false,
|
||||
};
|
||||
}
|
||||
"always" | "a" | "yes always" | "approve always" | "/always" | "/a" => {
|
||||
"always" | "yes always" | "approve always" => {
|
||||
return Submission::ApprovalResponse {
|
||||
approved: true,
|
||||
always: true,
|
||||
};
|
||||
}
|
||||
"no" | "n" | "deny" | "reject" | "cancel" | "/deny" | "/no" | "/n" => {
|
||||
"no" | "n" | "deny" | "reject" | "cancel" => {
|
||||
return Submission::ApprovalResponse {
|
||||
approved: false,
|
||||
always: false,
|
||||
@@ -214,15 +172,6 @@ pub enum Submission {
|
||||
|
||||
/// Quit the agent. Bypasses thread-state checks.
|
||||
Quit,
|
||||
|
||||
/// System command (help, model, version, tools, ping, debug).
|
||||
/// Bypasses thread-state checks and safety validation.
|
||||
SystemCommand {
|
||||
/// The command name (e.g. "help", "model", "version").
|
||||
command: String,
|
||||
/// Arguments to the command.
|
||||
args: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Submission {
|
||||
@@ -234,7 +183,6 @@ impl Submission {
|
||||
}
|
||||
|
||||
/// Create an approval submission.
|
||||
#[cfg(test)]
|
||||
pub fn approval(request_id: Uuid, approved: bool) -> Self {
|
||||
Self::ExecApproval {
|
||||
request_id,
|
||||
@@ -244,7 +192,6 @@ impl Submission {
|
||||
}
|
||||
|
||||
/// Create an "always approve" submission.
|
||||
#[cfg(test)]
|
||||
pub fn always_approve(request_id: Uuid) -> Self {
|
||||
Self::ExecApproval {
|
||||
request_id,
|
||||
@@ -254,31 +201,26 @@ impl Submission {
|
||||
}
|
||||
|
||||
/// Create an interrupt submission.
|
||||
#[cfg(test)]
|
||||
pub fn interrupt() -> Self {
|
||||
Self::Interrupt
|
||||
}
|
||||
|
||||
/// Create a compact submission.
|
||||
#[cfg(test)]
|
||||
pub fn compact() -> Self {
|
||||
Self::Compact
|
||||
}
|
||||
|
||||
/// Create an undo submission.
|
||||
#[cfg(test)]
|
||||
pub fn undo() -> Self {
|
||||
Self::Undo
|
||||
}
|
||||
|
||||
/// Create a redo submission.
|
||||
#[cfg(test)]
|
||||
pub fn redo() -> Self {
|
||||
Self::Redo
|
||||
}
|
||||
|
||||
/// Check if this submission starts a new turn.
|
||||
#[cfg(test)]
|
||||
pub fn starts_turn(&self) -> bool {
|
||||
matches!(self, Self::UserInput { .. })
|
||||
}
|
||||
@@ -296,7 +238,6 @@ impl Submission {
|
||||
| Self::Heartbeat
|
||||
| Self::Summarize
|
||||
| Self::Suggest
|
||||
| Self::SystemCommand { .. }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -347,7 +288,6 @@ impl SubmissionResult {
|
||||
}
|
||||
|
||||
/// Create an OK result.
|
||||
#[cfg(test)]
|
||||
pub fn ok() -> Self {
|
||||
Self::Ok { message: None }
|
||||
}
|
||||
@@ -483,57 +423,6 @@ mod tests {
|
||||
assert!(matches!(submission, Submission::UserInput { content } if content == "/unknown"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parser_approval_response_aliases() {
|
||||
// approve once
|
||||
assert!(matches!(
|
||||
SubmissionParser::parse("y"),
|
||||
Submission::ApprovalResponse {
|
||||
approved: true,
|
||||
always: false
|
||||
}
|
||||
));
|
||||
assert!(matches!(
|
||||
SubmissionParser::parse("/approve"),
|
||||
Submission::ApprovalResponse {
|
||||
approved: true,
|
||||
always: false
|
||||
}
|
||||
));
|
||||
|
||||
// approve always
|
||||
assert!(matches!(
|
||||
SubmissionParser::parse("a"),
|
||||
Submission::ApprovalResponse {
|
||||
approved: true,
|
||||
always: true
|
||||
}
|
||||
));
|
||||
assert!(matches!(
|
||||
SubmissionParser::parse("/always"),
|
||||
Submission::ApprovalResponse {
|
||||
approved: true,
|
||||
always: true
|
||||
}
|
||||
));
|
||||
|
||||
// deny
|
||||
assert!(matches!(
|
||||
SubmissionParser::parse("n"),
|
||||
Submission::ApprovalResponse {
|
||||
approved: false,
|
||||
always: false
|
||||
}
|
||||
));
|
||||
assert!(matches!(
|
||||
SubmissionParser::parse("/deny"),
|
||||
Submission::ApprovalResponse {
|
||||
approved: false,
|
||||
always: false
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parser_json_exec_approval() {
|
||||
let req_id = Uuid::new_v4();
|
||||
@@ -615,84 +504,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parser_system_command_help() {
|
||||
let submission = SubmissionParser::parse("/help");
|
||||
assert!(
|
||||
matches!(submission, Submission::SystemCommand { command, args } if command == "help" && args.is_empty())
|
||||
);
|
||||
|
||||
let submission = SubmissionParser::parse("/?");
|
||||
assert!(
|
||||
matches!(submission, Submission::SystemCommand { command, .. } if command == "help")
|
||||
);
|
||||
|
||||
let submission = SubmissionParser::parse("/HELP");
|
||||
assert!(
|
||||
matches!(submission, Submission::SystemCommand { command, .. } if command == "help")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parser_system_command_model() {
|
||||
// No args: show current model
|
||||
let submission = SubmissionParser::parse("/model");
|
||||
assert!(
|
||||
matches!(submission, Submission::SystemCommand { command, args } if command == "model" && args.is_empty())
|
||||
);
|
||||
|
||||
// With args: switch model
|
||||
let submission = SubmissionParser::parse("/model gpt-4o");
|
||||
assert!(
|
||||
matches!(submission, Submission::SystemCommand { command, args } if command == "model" && args == vec!["gpt-4o"])
|
||||
);
|
||||
|
||||
// Case insensitive command, preserves arg case
|
||||
let submission = SubmissionParser::parse("/MODEL Claude-3.5");
|
||||
assert!(
|
||||
matches!(submission, Submission::SystemCommand { command, args } if command == "model" && args == vec!["Claude-3.5"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parser_system_command_version() {
|
||||
let submission = SubmissionParser::parse("/version");
|
||||
assert!(
|
||||
matches!(submission, Submission::SystemCommand { command, args } if command == "version" && args.is_empty())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parser_system_command_tools() {
|
||||
let submission = SubmissionParser::parse("/tools");
|
||||
assert!(
|
||||
matches!(submission, Submission::SystemCommand { command, args } if command == "tools" && args.is_empty())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parser_system_command_ping() {
|
||||
let submission = SubmissionParser::parse("/ping");
|
||||
assert!(
|
||||
matches!(submission, Submission::SystemCommand { command, args } if command == "ping" && args.is_empty())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parser_system_command_debug() {
|
||||
let submission = SubmissionParser::parse("/debug");
|
||||
assert!(
|
||||
matches!(submission, Submission::SystemCommand { command, args } if command == "debug" && args.is_empty())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parser_system_command_is_control() {
|
||||
let submission = SubmissionParser::parse("/help");
|
||||
assert!(submission.is_control());
|
||||
assert!(!submission.starts_turn());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parser_quit() {
|
||||
assert!(matches!(SubmissionParser::parse("/quit"), Submission::Quit));
|
||||
|
||||
@@ -29,7 +29,6 @@ impl TaskOutput {
|
||||
}
|
||||
|
||||
/// Create a text result.
|
||||
#[cfg(test)]
|
||||
pub fn text(text: impl Into<String>, duration: Duration) -> Self {
|
||||
Self {
|
||||
result: serde_json::Value::String(text.into()),
|
||||
@@ -38,7 +37,6 @@ impl TaskOutput {
|
||||
}
|
||||
|
||||
/// Create an empty success result.
|
||||
#[cfg(test)]
|
||||
pub fn empty(duration: Duration) -> Self {
|
||||
Self {
|
||||
result: serde_json::Value::Null,
|
||||
@@ -132,7 +130,6 @@ impl Task {
|
||||
}
|
||||
|
||||
/// Create a new Job task with a specific ID.
|
||||
#[cfg(test)]
|
||||
pub fn job_with_id(id: Uuid, title: impl Into<String>, description: impl Into<String>) -> Self {
|
||||
Self::Job {
|
||||
id,
|
||||
@@ -155,7 +152,6 @@ impl Task {
|
||||
}
|
||||
|
||||
/// Create a new Background task.
|
||||
#[cfg(test)]
|
||||
pub fn background(handler: std::sync::Arc<dyn TaskHandler>) -> Self {
|
||||
Self::Background {
|
||||
id: Uuid::new_v4(),
|
||||
@@ -164,7 +160,6 @@ impl Task {
|
||||
}
|
||||
|
||||
/// Create a new Background task with a specific ID.
|
||||
#[cfg(test)]
|
||||
pub fn background_with_id(id: Uuid, handler: std::sync::Arc<dyn TaskHandler>) -> Self {
|
||||
Self::Background { id, handler }
|
||||
}
|
||||
@@ -179,7 +174,6 @@ impl Task {
|
||||
}
|
||||
|
||||
/// Get the parent ID for sub-tasks.
|
||||
#[cfg(test)]
|
||||
pub fn parent_id(&self) -> Option<Uuid> {
|
||||
match self {
|
||||
Self::Job { .. } => None,
|
||||
@@ -231,7 +225,6 @@ impl fmt::Debug for Task {
|
||||
}
|
||||
|
||||
/// Status of a scheduled task.
|
||||
#[cfg(test)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TaskStatus {
|
||||
/// Task is queued waiting for execution.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+16
-140
@@ -43,10 +43,6 @@ impl Checkpoint {
|
||||
}
|
||||
|
||||
/// Manager for undo/redo functionality.
|
||||
///
|
||||
/// Each undo/redo operation pops from one stack and pushes the current state
|
||||
/// onto the other, so `undo_count() + redo_count()` stays constant across
|
||||
/// undo/redo cycles (only `checkpoint()` and `clear()` change the total).
|
||||
pub struct UndoManager {
|
||||
/// Stack of past checkpoints (for undo).
|
||||
undo_stack: VecDeque<Checkpoint>,
|
||||
@@ -67,20 +63,11 @@ impl UndoManager {
|
||||
}
|
||||
|
||||
/// Create with a custom checkpoint limit.
|
||||
#[cfg(test)]
|
||||
pub fn with_max_checkpoints(mut self, max: usize) -> Self {
|
||||
self.max_checkpoints = max;
|
||||
self
|
||||
}
|
||||
|
||||
/// Push a checkpoint onto the undo stack, trimming oldest entries if over limit.
|
||||
fn push_undo(&mut self, checkpoint: Checkpoint) {
|
||||
self.undo_stack.push_back(checkpoint);
|
||||
while self.undo_stack.len() > self.max_checkpoints {
|
||||
self.undo_stack.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a checkpoint at the current state.
|
||||
///
|
||||
/// This clears the redo stack since we're creating a new history branch.
|
||||
@@ -93,23 +80,24 @@ impl UndoManager {
|
||||
// Clear redo stack (new branch of history)
|
||||
self.redo_stack.clear();
|
||||
|
||||
// Create and push checkpoint
|
||||
let checkpoint = Checkpoint::new(turn_number, messages, description);
|
||||
self.push_undo(checkpoint);
|
||||
self.undo_stack.push_back(checkpoint);
|
||||
|
||||
// Trim if over limit
|
||||
while self.undo_stack.len() > self.max_checkpoints {
|
||||
self.undo_stack.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
/// Undo: pop the last checkpoint and return it.
|
||||
///
|
||||
/// Saves the current state to the redo stack and pops the most recent
|
||||
/// checkpoint from the undo stack so that repeated undos walk backwards
|
||||
/// through history.
|
||||
///
|
||||
/// Takes ownership of `current_messages`; callers must clone first if
|
||||
/// they need to retain a copy.
|
||||
/// The current state should be saved to redo stack before calling this.
|
||||
pub fn undo(
|
||||
&mut self,
|
||||
current_turn: usize,
|
||||
current_messages: Vec<ChatMessage>,
|
||||
) -> Option<Checkpoint> {
|
||||
) -> Option<&Checkpoint> {
|
||||
if self.undo_stack.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -122,40 +110,18 @@ impl UndoManager {
|
||||
);
|
||||
self.redo_stack.push(current);
|
||||
|
||||
// Pop and return the most recent checkpoint
|
||||
self.undo_stack.pop_back()
|
||||
// Return the most recent checkpoint without removing it
|
||||
// (we keep it so multiple undos can work)
|
||||
self.undo_stack.back()
|
||||
}
|
||||
|
||||
/// Pop the last checkpoint from the undo stack.
|
||||
#[cfg(test)]
|
||||
pub fn pop_undo(&mut self) -> Option<Checkpoint> {
|
||||
self.undo_stack.pop_back()
|
||||
}
|
||||
|
||||
/// Redo: restore a previously undone state.
|
||||
///
|
||||
/// Saves the current state to the undo stack and pops the most recent
|
||||
/// checkpoint from the redo stack.
|
||||
///
|
||||
/// Takes ownership of `current_messages`; callers must clone first if
|
||||
/// they need to retain a copy.
|
||||
pub fn redo(
|
||||
&mut self,
|
||||
current_turn: usize,
|
||||
current_messages: Vec<ChatMessage>,
|
||||
) -> Option<Checkpoint> {
|
||||
if self.redo_stack.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Save current state to undo stack
|
||||
let current = Checkpoint::new(
|
||||
current_turn,
|
||||
current_messages,
|
||||
format!("Turn {}", current_turn),
|
||||
);
|
||||
self.push_undo(current);
|
||||
|
||||
pub fn redo(&mut self) -> Option<Checkpoint> {
|
||||
self.redo_stack.pop()
|
||||
}
|
||||
|
||||
@@ -180,7 +146,6 @@ impl UndoManager {
|
||||
}
|
||||
|
||||
/// Get a checkpoint by ID.
|
||||
#[cfg(test)]
|
||||
pub fn get_checkpoint(&self, id: Uuid) -> Option<&Checkpoint> {
|
||||
self.undo_stack
|
||||
.iter()
|
||||
@@ -189,7 +154,6 @@ impl UndoManager {
|
||||
}
|
||||
|
||||
/// List all available checkpoints (for UI display).
|
||||
#[cfg(test)]
|
||||
pub fn list_checkpoints(&self) -> Vec<&Checkpoint> {
|
||||
self.undo_stack.iter().collect()
|
||||
}
|
||||
@@ -250,16 +214,14 @@ mod tests {
|
||||
assert!(manager.can_undo());
|
||||
assert!(!manager.can_redo());
|
||||
|
||||
// Undo - returns owned Checkpoint now
|
||||
// Undo
|
||||
let current = vec![ChatMessage::user("Hello"), ChatMessage::assistant("Hi")];
|
||||
let checkpoint = manager.undo(2, current);
|
||||
assert!(checkpoint.is_some());
|
||||
let checkpoint = checkpoint.unwrap();
|
||||
assert_eq!(checkpoint.turn_number, 1);
|
||||
assert!(manager.can_redo());
|
||||
|
||||
// Redo - now requires current state parameters
|
||||
let restored = manager.redo(checkpoint.turn_number, checkpoint.messages);
|
||||
// Redo
|
||||
let restored = manager.redo();
|
||||
assert!(restored.is_some());
|
||||
}
|
||||
|
||||
@@ -287,90 +249,4 @@ mod tests {
|
||||
assert!(restored.is_some());
|
||||
assert_eq!(manager.undo_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repeated_undo_advances_through_stack() {
|
||||
let mut manager = UndoManager::new();
|
||||
|
||||
// Create 3 checkpoints at turns 0, 1, 2
|
||||
manager.checkpoint(0, vec![], "Turn 0");
|
||||
manager.checkpoint(1, vec![ChatMessage::user("msg1")], "Turn 1");
|
||||
manager.checkpoint(2, vec![ChatMessage::user("msg2")], "Turn 2");
|
||||
assert_eq!(manager.undo_count(), 3);
|
||||
|
||||
// First undo: should return turn 2 checkpoint, stack shrinks to 2
|
||||
let cp1 = manager
|
||||
.undo(3, vec![ChatMessage::user("msg3")])
|
||||
.expect("first undo should succeed");
|
||||
assert_eq!(cp1.turn_number, 2);
|
||||
assert_eq!(manager.undo_count(), 2);
|
||||
|
||||
// Second undo: should return turn 1 checkpoint (different!), stack shrinks to 1
|
||||
let cp2 = manager
|
||||
.undo(cp1.turn_number, cp1.messages)
|
||||
.expect("second undo should succeed");
|
||||
assert_eq!(cp2.turn_number, 1);
|
||||
assert_eq!(manager.undo_count(), 1);
|
||||
|
||||
// Verify we walked backwards through distinct checkpoints
|
||||
assert_ne!(cp1.turn_number, cp2.turn_number);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_undo_redo_cycle_preserves_state() {
|
||||
let mut manager = UndoManager::new();
|
||||
|
||||
let msgs_t0: Vec<ChatMessage> = vec![];
|
||||
let msgs_t1 = vec![ChatMessage::user("hello")];
|
||||
let msgs_t2 = vec![ChatMessage::user("hello"), ChatMessage::assistant("hi")];
|
||||
|
||||
manager.checkpoint(0, msgs_t0, "Turn 0");
|
||||
manager.checkpoint(1, msgs_t1, "Turn 1");
|
||||
|
||||
// Undo from turn 2 -> get turn 1 checkpoint
|
||||
let cp_undo1 = manager
|
||||
.undo(2, msgs_t2.clone())
|
||||
.expect("undo should succeed");
|
||||
assert_eq!(cp_undo1.turn_number, 1);
|
||||
|
||||
// Redo from turn 1 -> get turn 2 state back
|
||||
let cp_redo = manager
|
||||
.redo(cp_undo1.turn_number, cp_undo1.messages)
|
||||
.expect("redo should succeed");
|
||||
assert_eq!(cp_redo.turn_number, 2);
|
||||
assert_eq!(cp_redo.messages.len(), 2);
|
||||
|
||||
// Undo again from turn 2 -> should go back to turn 1 again
|
||||
let cp_undo2 = manager
|
||||
.undo(cp_redo.turn_number, cp_redo.messages)
|
||||
.expect("second undo should succeed");
|
||||
assert_eq!(cp_undo2.turn_number, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_undo_redo_stack_sizes_consistent() {
|
||||
let mut manager = UndoManager::new();
|
||||
|
||||
manager.checkpoint(0, vec![], "Turn 0");
|
||||
manager.checkpoint(1, vec![ChatMessage::user("a")], "Turn 1");
|
||||
manager.checkpoint(2, vec![ChatMessage::user("b")], "Turn 2");
|
||||
|
||||
// Start: undo=3, redo=0, total=3
|
||||
let total = manager.undo_count() + manager.redo_count();
|
||||
assert_eq!(total, 3);
|
||||
|
||||
// After undo: total should still be 3 (one moved from undo to redo,
|
||||
// plus the current state pushed to redo)
|
||||
// Actually: undo pops one (3->2), pushes current to redo (0->1), total=3
|
||||
let cp = manager.undo(3, vec![]).unwrap();
|
||||
assert_eq!(manager.undo_count() + manager.redo_count(), 3);
|
||||
|
||||
// After redo: redo pops one (1->0), pushes current to undo (2->3), total=3
|
||||
let cp2 = manager.redo(cp.turn_number, cp.messages).unwrap();
|
||||
assert_eq!(manager.undo_count() + manager.redo_count(), 3);
|
||||
|
||||
// After another undo: same invariant
|
||||
let _cp3 = manager.undo(cp2.turn_number, cp2.messages).unwrap();
|
||||
assert_eq!(manager.undo_count() + manager.redo_count(), 3);
|
||||
}
|
||||
}
|
||||
|
||||
+114
-543
@@ -3,16 +3,15 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::future::join_all;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinSet;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::agent::scheduler::WorkerMessage;
|
||||
use crate::agent::task::TaskOutput;
|
||||
use crate::context::{ContextManager, JobState};
|
||||
use crate::db::Database;
|
||||
use crate::error::Error;
|
||||
use crate::hooks::HookRegistry;
|
||||
use crate::history::Store;
|
||||
use crate::llm::{
|
||||
ActionPlan, ChatMessage, LlmProvider, Reasoning, ReasoningContext, RespondResult, ToolSelection,
|
||||
};
|
||||
@@ -29,8 +28,7 @@ pub struct WorkerDeps {
|
||||
pub llm: Arc<dyn LlmProvider>,
|
||||
pub safety: Arc<SafetyLayer>,
|
||||
pub tools: Arc<ToolRegistry>,
|
||||
pub store: Option<Arc<dyn Database>>,
|
||||
pub hooks: Arc<HookRegistry>,
|
||||
pub store: Option<Arc<Store>>,
|
||||
pub timeout: Duration,
|
||||
pub use_planning: bool,
|
||||
}
|
||||
@@ -69,7 +67,7 @@ impl Worker {
|
||||
&self.deps.tools
|
||||
}
|
||||
|
||||
fn store(&self) -> Option<&Arc<dyn Database>> {
|
||||
fn store(&self) -> Option<&Arc<Store>> {
|
||||
self.deps.store.as_ref()
|
||||
}
|
||||
|
||||
@@ -229,11 +227,11 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
}
|
||||
|
||||
// Check for cancellation
|
||||
if let Ok(ctx) = self.context_manager().get_context(self.job_id).await
|
||||
&& ctx.state == JobState::Cancelled
|
||||
{
|
||||
tracing::info!("Worker for job {} detected cancellation", self.job_id);
|
||||
return Ok(());
|
||||
if let Ok(ctx) = self.context_manager().get_context(self.job_id).await {
|
||||
if ctx.state == JobState::Cancelled {
|
||||
tracing::info!("Worker for job {} detected cancellation", self.job_id);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
iteration += 1;
|
||||
@@ -250,15 +248,16 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
|
||||
if selections.is_empty() {
|
||||
// No tools from select_tools, ask LLM directly (may still return tool calls)
|
||||
let respond_output = reasoning.respond_with_tools(reason_ctx).await?;
|
||||
let respond_result = reasoning.respond_with_tools(reason_ctx).await?;
|
||||
|
||||
match respond_output.result {
|
||||
match respond_result {
|
||||
RespondResult::Text(response) => {
|
||||
// Check for explicit completion phrases. Use word-boundary
|
||||
// aware checks to avoid false positives like "incomplete",
|
||||
// "not done", or "unfinished". Only the LLM's own response
|
||||
// (not tool output) can trigger this.
|
||||
if crate::util::llm_signals_completion(&response) {
|
||||
// Check for completion keywords
|
||||
let response_lower = response.to_lowercase();
|
||||
if response_lower.contains("complete")
|
||||
|| response_lower.contains("finished")
|
||||
|| response_lower.contains("done")
|
||||
{
|
||||
self.mark_completed().await?;
|
||||
return Ok(());
|
||||
}
|
||||
@@ -273,10 +272,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
));
|
||||
}
|
||||
}
|
||||
RespondResult::ToolCalls {
|
||||
tool_calls,
|
||||
content,
|
||||
} => {
|
||||
RespondResult::ToolCalls(tool_calls) => {
|
||||
// Model returned tool calls - execute them
|
||||
tracing::debug!(
|
||||
"Job {} respond_with_tools returned {} tool calls",
|
||||
@@ -284,29 +280,18 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
tool_calls.len()
|
||||
);
|
||||
|
||||
// Add assistant message with tool_calls (OpenAI protocol)
|
||||
reason_ctx
|
||||
.messages
|
||||
.push(ChatMessage::assistant_with_tool_calls(
|
||||
content,
|
||||
tool_calls.clone(),
|
||||
));
|
||||
for tc in tool_calls {
|
||||
let result = self.execute_tool(&tc.name, &tc.arguments).await;
|
||||
|
||||
// Convert ToolCalls to ToolSelections and execute in parallel
|
||||
let selections: Vec<ToolSelection> = tool_calls
|
||||
.iter()
|
||||
.map(|tc| ToolSelection {
|
||||
// Create synthetic selection for process_tool_result
|
||||
let selection = ToolSelection {
|
||||
tool_name: tc.name.clone(),
|
||||
parameters: tc.arguments.clone(),
|
||||
reasoning: String::new(),
|
||||
alternatives: vec![],
|
||||
tool_call_id: tc.id.clone(),
|
||||
})
|
||||
.collect();
|
||||
};
|
||||
|
||||
let results = self.execute_tools_parallel(&selections).await;
|
||||
for (selection, result) in selections.iter().zip(results) {
|
||||
self.process_tool_result(reason_ctx, selection, result.result)
|
||||
self.process_tool_result(reason_ctx, &selection, result)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
@@ -349,87 +334,54 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute multiple tools in parallel using a JoinSet.
|
||||
///
|
||||
/// Each task is tagged with its original index so results are returned
|
||||
/// in the same order as `selections`, regardless of completion order.
|
||||
/// Execute multiple tools in parallel.
|
||||
async fn execute_tools_parallel(&self, selections: &[ToolSelection]) -> Vec<ToolExecResult> {
|
||||
let count = selections.len();
|
||||
let futures: Vec<_> = selections
|
||||
.iter()
|
||||
.map(|selection| {
|
||||
let tool_name = selection.tool_name.clone();
|
||||
let params = selection.parameters.clone();
|
||||
let tools = self.tools().clone();
|
||||
let context_manager = self.context_manager().clone();
|
||||
let safety = self.safety().clone();
|
||||
let job_id = self.job_id;
|
||||
let store = self.deps.store.clone();
|
||||
|
||||
// Short-circuit for single tool: execute directly without JoinSet overhead
|
||||
if count <= 1 {
|
||||
let mut results = Vec::with_capacity(count);
|
||||
for selection in selections {
|
||||
let result = Self::execute_tool_inner(
|
||||
&self.deps,
|
||||
self.job_id,
|
||||
&selection.tool_name,
|
||||
&selection.parameters,
|
||||
)
|
||||
.await;
|
||||
results.push(ToolExecResult { result });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
let mut join_set = JoinSet::new();
|
||||
|
||||
for (idx, selection) in selections.iter().enumerate() {
|
||||
let deps = self.deps.clone();
|
||||
let job_id = self.job_id;
|
||||
let tool_name = selection.tool_name.clone();
|
||||
let params = selection.parameters.clone();
|
||||
join_set.spawn(async move {
|
||||
let result = Self::execute_tool_inner(&deps, job_id, &tool_name, ¶ms).await;
|
||||
(idx, ToolExecResult { result })
|
||||
});
|
||||
}
|
||||
|
||||
// Collect and reorder by original index
|
||||
let mut results: Vec<Option<ToolExecResult>> = (0..count).map(|_| None).collect();
|
||||
while let Some(join_result) = join_set.join_next().await {
|
||||
match join_result {
|
||||
Ok((idx, exec_result)) => results[idx] = Some(exec_result),
|
||||
Err(e) => {
|
||||
if e.is_panic() {
|
||||
tracing::error!("Tool execution task panicked: {}", e);
|
||||
} else {
|
||||
tracing::error!("Tool execution task cancelled: {}", e);
|
||||
}
|
||||
async move {
|
||||
let result = Self::execute_tool_inner(
|
||||
tools,
|
||||
context_manager,
|
||||
safety,
|
||||
store,
|
||||
job_id,
|
||||
&tool_name,
|
||||
¶ms,
|
||||
)
|
||||
.await;
|
||||
ToolExecResult { result }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fill any panicked slots with error results
|
||||
results
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, opt)| {
|
||||
opt.unwrap_or_else(|| ToolExecResult {
|
||||
result: Err(crate::error::ToolError::ExecutionFailed {
|
||||
name: selections[i].tool_name.clone(),
|
||||
reason: "Task failed during execution".to_string(),
|
||||
}
|
||||
.into()),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
|
||||
join_all(futures).await
|
||||
}
|
||||
|
||||
/// Inner tool execution logic that can be called from both single and parallel paths.
|
||||
async fn execute_tool_inner(
|
||||
deps: &WorkerDeps,
|
||||
tools: Arc<ToolRegistry>,
|
||||
context_manager: Arc<ContextManager>,
|
||||
safety: Arc<SafetyLayer>,
|
||||
store: Option<Arc<Store>>,
|
||||
job_id: Uuid,
|
||||
tool_name: &str,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<String, Error> {
|
||||
let tool =
|
||||
deps.tools
|
||||
.get(tool_name)
|
||||
.await
|
||||
.ok_or_else(|| crate::error::ToolError::NotFound {
|
||||
name: tool_name.to_string(),
|
||||
})?;
|
||||
let tool = tools
|
||||
.get(tool_name)
|
||||
.await
|
||||
.ok_or_else(|| crate::error::ToolError::NotFound {
|
||||
name: tool_name.to_string(),
|
||||
})?;
|
||||
|
||||
// Tools requiring approval are blocked in autonomous jobs
|
||||
if tool.requires_approval() {
|
||||
@@ -439,46 +391,8 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
.into());
|
||||
}
|
||||
|
||||
// Fetch job context early so we have the real user_id for hooks
|
||||
let job_ctx = deps.context_manager.get_context(job_id).await?;
|
||||
|
||||
// Run BeforeToolCall hook
|
||||
let params = {
|
||||
use crate::hooks::{HookError, HookEvent, HookOutcome};
|
||||
let event = HookEvent::ToolCall {
|
||||
tool_name: tool_name.to_string(),
|
||||
parameters: params.clone(),
|
||||
user_id: job_ctx.user_id.clone(),
|
||||
context: format!("job:{}", job_id),
|
||||
};
|
||||
match deps.hooks.run(&event).await {
|
||||
Err(HookError::Rejected { reason }) => {
|
||||
return Err(crate::error::ToolError::ExecutionFailed {
|
||||
name: tool_name.to_string(),
|
||||
reason: format!("Blocked by hook: {}", reason),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(crate::error::ToolError::ExecutionFailed {
|
||||
name: tool_name.to_string(),
|
||||
reason: format!("Blocked by hook failure mode: {}", err),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
Ok(HookOutcome::Continue {
|
||||
modified: Some(new_params),
|
||||
}) => serde_json::from_str(&new_params).unwrap_or_else(|e| {
|
||||
tracing::warn!(
|
||||
tool = %tool_name,
|
||||
"Hook returned non-JSON modification for ToolCall, ignoring: {}",
|
||||
e
|
||||
);
|
||||
params.clone()
|
||||
}),
|
||||
_ => params.clone(),
|
||||
}
|
||||
};
|
||||
// Get job context for the tool
|
||||
let job_ctx = context_manager.get_context(job_id).await?;
|
||||
if job_ctx.state == JobState::Cancelled {
|
||||
return Err(crate::error::ToolError::ExecutionFailed {
|
||||
name: tool_name.to_string(),
|
||||
@@ -488,7 +402,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
}
|
||||
|
||||
// Validate tool parameters
|
||||
let validation = deps.safety.validator().validate_tool_params(¶ms);
|
||||
let validation = safety.validator().validate_tool_params(params);
|
||||
if !validation.is_valid {
|
||||
let details = validation
|
||||
.errors
|
||||
@@ -503,59 +417,21 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
.into());
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
tool = %tool_name,
|
||||
params = %params,
|
||||
job = %job_id,
|
||||
"Tool call started"
|
||||
);
|
||||
|
||||
// Execute with per-tool timeout and timing
|
||||
let tool_timeout = tool.execution_timeout();
|
||||
// Execute with timeout and timing
|
||||
let start = std::time::Instant::now();
|
||||
let result = tokio::time::timeout(tool_timeout, async {
|
||||
let result = tokio::time::timeout(Duration::from_secs(60), async {
|
||||
tool.execute(params.clone(), &job_ctx).await
|
||||
})
|
||||
.await;
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
match &result {
|
||||
Ok(Ok(output)) => {
|
||||
let result_str = serde_json::to_string(&output.result)
|
||||
.unwrap_or_else(|_| "<serialize error>".to_string());
|
||||
tracing::debug!(
|
||||
tool = %tool_name,
|
||||
elapsed_ms = elapsed.as_millis() as u64,
|
||||
result = %result_str,
|
||||
"Tool call succeeded"
|
||||
);
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::debug!(
|
||||
tool = %tool_name,
|
||||
elapsed_ms = elapsed.as_millis() as u64,
|
||||
error = %e,
|
||||
"Tool call failed"
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::debug!(
|
||||
tool = %tool_name,
|
||||
elapsed_ms = elapsed.as_millis() as u64,
|
||||
timeout_secs = tool_timeout.as_secs(),
|
||||
"Tool call timed out"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Record action in memory and get the ActionRecord for persistence
|
||||
let action = match &result {
|
||||
Ok(Ok(output)) => {
|
||||
let output_str = serde_json::to_string_pretty(&output.result)
|
||||
.ok()
|
||||
.map(|s| deps.safety.sanitize_tool_output(tool_name, &s).content);
|
||||
match deps
|
||||
.context_manager
|
||||
.map(|s| safety.sanitize_tool_output(tool_name, &s).content);
|
||||
context_manager
|
||||
.update_memory(job_id, |mem| {
|
||||
let rec = mem.create_action(tool_name, params.clone()).succeed(
|
||||
output_str.clone(),
|
||||
@@ -566,56 +442,32 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
rec
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(rec) => Some(rec),
|
||||
Err(e) => {
|
||||
tracing::warn!(job_id = %job_id, tool = tool_name, "Failed to record action in memory: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
match deps
|
||||
.context_manager
|
||||
.update_memory(job_id, |mem| {
|
||||
let rec = mem
|
||||
.create_action(tool_name, params.clone())
|
||||
.fail(e.to_string(), elapsed);
|
||||
mem.record_action(rec.clone());
|
||||
rec
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(rec) => Some(rec),
|
||||
Err(e) => {
|
||||
tracing::warn!(job_id = %job_id, tool = tool_name, "Failed to record action in memory: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
match deps
|
||||
.context_manager
|
||||
.update_memory(job_id, |mem| {
|
||||
let rec = mem
|
||||
.create_action(tool_name, params.clone())
|
||||
.fail("Execution timeout", elapsed);
|
||||
mem.record_action(rec.clone());
|
||||
rec
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(rec) => Some(rec),
|
||||
Err(e) => {
|
||||
tracing::warn!(job_id = %job_id, tool = tool_name, "Failed to record action in memory: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
.ok()
|
||||
}
|
||||
Ok(Err(e)) => context_manager
|
||||
.update_memory(job_id, |mem| {
|
||||
let rec = mem
|
||||
.create_action(tool_name, params.clone())
|
||||
.fail(e.to_string(), elapsed);
|
||||
mem.record_action(rec.clone());
|
||||
rec
|
||||
})
|
||||
.await
|
||||
.ok(),
|
||||
Err(_) => context_manager
|
||||
.update_memory(job_id, |mem| {
|
||||
let rec = mem
|
||||
.create_action(tool_name, params.clone())
|
||||
.fail("Execution timeout", elapsed);
|
||||
mem.record_action(rec.clone());
|
||||
rec
|
||||
})
|
||||
.await
|
||||
.ok(),
|
||||
};
|
||||
|
||||
// Persist action to database (fire-and-forget)
|
||||
if let (Some(action), Some(store)) = (action, deps.store.clone()) {
|
||||
if let (Some(action), Some(store)) = (action, store) {
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = store.save_action(job_id, &action).await {
|
||||
tracing::warn!("Failed to persist action for job {}: {}", job_id, e);
|
||||
@@ -627,7 +479,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
let output = result
|
||||
.map_err(|_| crate::error::ToolError::Timeout {
|
||||
name: tool_name.to_string(),
|
||||
timeout: tool_timeout,
|
||||
timeout: Duration::from_secs(60),
|
||||
})?
|
||||
.map_err(|e| crate::error::ToolError::ExecutionFailed {
|
||||
name: tool_name.to_string(),
|
||||
@@ -666,14 +518,17 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
);
|
||||
|
||||
reason_ctx.messages.push(ChatMessage::tool_result(
|
||||
&selection.tool_call_id,
|
||||
"tool_call_id",
|
||||
&selection.tool_name,
|
||||
wrapped,
|
||||
));
|
||||
|
||||
// Tool output never drives job completion. A malicious tool could
|
||||
// emit "TASK_COMPLETE" to force premature completion. Only the LLM's
|
||||
// own structured response (in execution_loop) can mark a job done.
|
||||
// Check if job is complete
|
||||
if output.contains("TASK_COMPLETE") || output.contains("JOB_DONE") {
|
||||
self.mark_completed().await?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -698,7 +553,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
}
|
||||
|
||||
reason_ctx.messages.push(ChatMessage::tool_result(
|
||||
&selection.tool_call_id,
|
||||
"tool_call_id",
|
||||
&selection.tool_name,
|
||||
format!("Error: {}", e),
|
||||
));
|
||||
@@ -748,15 +603,12 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
.execute_tool(&action.tool_name, &action.parameters)
|
||||
.await;
|
||||
|
||||
// Create a synthetic ToolSelection for process_tool_result.
|
||||
// Plan actions don't originate from an LLM tool_call response so
|
||||
// there is no real tool_call_id; generate a unique one.
|
||||
// Create a synthetic ToolSelection for process_tool_result
|
||||
let selection = ToolSelection {
|
||||
tool_name: action.tool_name.clone(),
|
||||
parameters: action.parameters.clone(),
|
||||
reasoning: action.reasoning.clone(),
|
||||
alternatives: vec![],
|
||||
tool_call_id: format!("plan_{}_{}", self.job_id, i),
|
||||
};
|
||||
|
||||
// Process the result
|
||||
@@ -780,7 +632,11 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
let response = reasoning.respond(reason_ctx).await?;
|
||||
reason_ctx.messages.push(ChatMessage::assistant(&response));
|
||||
|
||||
if crate::util::llm_signals_completion(&response) {
|
||||
let response_lower = response.to_lowercase();
|
||||
if response_lower.contains("complete")
|
||||
|| response_lower.contains("finished")
|
||||
|| response_lower.contains("done")
|
||||
{
|
||||
self.mark_completed().await?;
|
||||
} else {
|
||||
// Job not complete, could re-plan or fall back to direct selection
|
||||
@@ -801,7 +657,16 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
tool_name: &str,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<String, Error> {
|
||||
Self::execute_tool_inner(&self.deps, self.job_id, tool_name, params).await
|
||||
Self::execute_tool_inner(
|
||||
self.tools().clone(),
|
||||
self.context_manager().clone(),
|
||||
self.safety().clone(),
|
||||
self.deps.store.clone(),
|
||||
self.job_id,
|
||||
tool_name,
|
||||
params,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn mark_completed(&self) -> Result<(), Error> {
|
||||
@@ -866,297 +731,3 @@ impl From<TaskOutput> for Result<String, Error> {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::llm::ToolSelection;
|
||||
use crate::util::llm_signals_completion;
|
||||
|
||||
use super::*;
|
||||
use crate::config::SafetyConfig;
|
||||
use crate::context::JobContext;
|
||||
use crate::llm::{
|
||||
CompletionRequest, CompletionResponse, LlmProvider, ToolCompletionRequest,
|
||||
ToolCompletionResponse,
|
||||
};
|
||||
use crate::safety::SafetyLayer;
|
||||
use crate::tools::{Tool, ToolError, ToolOutput};
|
||||
|
||||
/// A test tool that sleeps for a configurable duration before returning.
|
||||
struct SlowTool {
|
||||
tool_name: String,
|
||||
delay: Duration,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Tool for SlowTool {
|
||||
fn name(&self) -> &str {
|
||||
&self.tool_name
|
||||
}
|
||||
fn description(&self) -> &str {
|
||||
"Test tool with configurable delay"
|
||||
}
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({"type": "object", "properties": {}})
|
||||
}
|
||||
async fn execute(
|
||||
&self,
|
||||
_params: serde_json::Value,
|
||||
_ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let start = std::time::Instant::now();
|
||||
tokio::time::sleep(self.delay).await;
|
||||
Ok(ToolOutput::text(
|
||||
format!("done_{}", self.tool_name),
|
||||
start.elapsed(),
|
||||
))
|
||||
}
|
||||
fn requires_sanitization(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Stub LLM provider (never called in these tests).
|
||||
struct StubLlm;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl LlmProvider for StubLlm {
|
||||
fn model_name(&self) -> &str {
|
||||
"stub"
|
||||
}
|
||||
fn cost_per_token(&self) -> (rust_decimal::Decimal, rust_decimal::Decimal) {
|
||||
(rust_decimal::Decimal::ZERO, rust_decimal::Decimal::ZERO)
|
||||
}
|
||||
async fn complete(
|
||||
&self,
|
||||
_req: CompletionRequest,
|
||||
) -> Result<CompletionResponse, crate::error::LlmError> {
|
||||
unimplemented!("stub")
|
||||
}
|
||||
async fn complete_with_tools(
|
||||
&self,
|
||||
_req: ToolCompletionRequest,
|
||||
) -> Result<ToolCompletionResponse, crate::error::LlmError> {
|
||||
unimplemented!("stub")
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a Worker wired to a ToolRegistry containing the given tools.
|
||||
async fn make_worker(tools: Vec<Arc<dyn Tool>>) -> Worker {
|
||||
let registry = ToolRegistry::new();
|
||||
for t in tools {
|
||||
registry.register(t).await;
|
||||
}
|
||||
|
||||
let cm = Arc::new(crate::context::ContextManager::new(5));
|
||||
let job_id = cm.create_job("test", "test job").await.unwrap();
|
||||
|
||||
let deps = WorkerDeps {
|
||||
context_manager: cm,
|
||||
llm: Arc::new(StubLlm),
|
||||
safety: Arc::new(SafetyLayer::new(&SafetyConfig {
|
||||
max_output_length: 100_000,
|
||||
injection_check_enabled: false,
|
||||
})),
|
||||
tools: Arc::new(registry),
|
||||
store: None,
|
||||
hooks: Arc::new(crate::hooks::HookRegistry::new()),
|
||||
timeout: Duration::from_secs(30),
|
||||
use_planning: false,
|
||||
};
|
||||
|
||||
Worker::new(job_id, deps)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_selection_preserves_call_id() {
|
||||
let selection = ToolSelection {
|
||||
tool_name: "memory_search".to_string(),
|
||||
parameters: serde_json::json!({"query": "test"}),
|
||||
reasoning: "Need to search memory".to_string(),
|
||||
alternatives: vec![],
|
||||
tool_call_id: "call_abc123".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(selection.tool_call_id, "call_abc123");
|
||||
assert_ne!(
|
||||
selection.tool_call_id, "tool_call_id",
|
||||
"tool_call_id must not be the hardcoded placeholder string"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_completion_positive_signals() {
|
||||
assert!(llm_signals_completion("The job is complete."));
|
||||
assert!(llm_signals_completion(
|
||||
"I have completed the task successfully."
|
||||
));
|
||||
assert!(llm_signals_completion("The task is done."));
|
||||
assert!(llm_signals_completion("The task is finished."));
|
||||
assert!(llm_signals_completion(
|
||||
"All steps are complete and verified."
|
||||
));
|
||||
assert!(llm_signals_completion(
|
||||
"I've done all the work. The work is done."
|
||||
));
|
||||
assert!(llm_signals_completion(
|
||||
"Successfully completed the migration."
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_completion_negative_signals_block_false_positives() {
|
||||
// These contain completion keywords but also negation, should NOT trigger.
|
||||
assert!(!llm_signals_completion("The task is not complete yet."));
|
||||
assert!(!llm_signals_completion("This is not done."));
|
||||
assert!(!llm_signals_completion("The work is incomplete."));
|
||||
assert!(!llm_signals_completion(
|
||||
"The migration is not yet finished."
|
||||
));
|
||||
assert!(!llm_signals_completion("The job isn't done yet."));
|
||||
assert!(!llm_signals_completion("This remains unfinished."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_completion_does_not_match_bare_substrings() {
|
||||
// Bare words embedded in other text should NOT trigger completion.
|
||||
assert!(!llm_signals_completion(
|
||||
"I need to complete more work first."
|
||||
));
|
||||
assert!(!llm_signals_completion(
|
||||
"Let me finish the remaining steps."
|
||||
));
|
||||
assert!(!llm_signals_completion(
|
||||
"I'm done analyzing, now let me fix it."
|
||||
));
|
||||
assert!(!llm_signals_completion(
|
||||
"I completed step 1 but step 2 remains."
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_completion_tool_output_injection() {
|
||||
// A malicious tool output echoed by the LLM should not trigger
|
||||
// completion unless it forms a genuine completion phrase.
|
||||
assert!(!llm_signals_completion("TASK_COMPLETE"));
|
||||
assert!(!llm_signals_completion("JOB_DONE"));
|
||||
assert!(!llm_signals_completion(
|
||||
"The tool returned: TASK_COMPLETE signal"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parallel_speedup() {
|
||||
// 3 tools each sleeping 200ms should finish in roughly 200ms (parallel),
|
||||
// not ~600ms (sequential).
|
||||
let tools: Vec<Arc<dyn Tool>> = (0..3)
|
||||
.map(|i| {
|
||||
Arc::new(SlowTool {
|
||||
tool_name: format!("slow_{}", i),
|
||||
delay: Duration::from_millis(200),
|
||||
}) as Arc<dyn Tool>
|
||||
})
|
||||
.collect();
|
||||
|
||||
let worker = make_worker(tools).await;
|
||||
|
||||
let selections: Vec<ToolSelection> = (0..3)
|
||||
.map(|i| ToolSelection {
|
||||
tool_name: format!("slow_{}", i),
|
||||
parameters: serde_json::json!({}),
|
||||
reasoning: String::new(),
|
||||
alternatives: vec![],
|
||||
tool_call_id: format!("call_{}", i),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let results = worker.execute_tools_parallel(&selections).await;
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
assert_eq!(results.len(), 3);
|
||||
for r in &results {
|
||||
assert!(r.result.is_ok(), "Tool should succeed");
|
||||
}
|
||||
// Parallel should complete well under the sequential 600ms threshold.
|
||||
assert!(
|
||||
elapsed < Duration::from_millis(500),
|
||||
"Parallel execution took {:?}, expected < 500ms",
|
||||
elapsed
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_result_ordering_preserved() {
|
||||
// Tools with different delays finish in different order.
|
||||
// Results must be returned in the original request order.
|
||||
let tools: Vec<Arc<dyn Tool>> = vec![
|
||||
Arc::new(SlowTool {
|
||||
tool_name: "tool_a".into(),
|
||||
delay: Duration::from_millis(300),
|
||||
}),
|
||||
Arc::new(SlowTool {
|
||||
tool_name: "tool_b".into(),
|
||||
delay: Duration::from_millis(100),
|
||||
}),
|
||||
Arc::new(SlowTool {
|
||||
tool_name: "tool_c".into(),
|
||||
delay: Duration::from_millis(200),
|
||||
}),
|
||||
];
|
||||
|
||||
let worker = make_worker(tools).await;
|
||||
|
||||
let selections = vec![
|
||||
ToolSelection {
|
||||
tool_name: "tool_a".into(),
|
||||
parameters: serde_json::json!({}),
|
||||
reasoning: String::new(),
|
||||
alternatives: vec![],
|
||||
tool_call_id: "call_a".into(),
|
||||
},
|
||||
ToolSelection {
|
||||
tool_name: "tool_b".into(),
|
||||
parameters: serde_json::json!({}),
|
||||
reasoning: String::new(),
|
||||
alternatives: vec![],
|
||||
tool_call_id: "call_b".into(),
|
||||
},
|
||||
ToolSelection {
|
||||
tool_name: "tool_c".into(),
|
||||
parameters: serde_json::json!({}),
|
||||
reasoning: String::new(),
|
||||
alternatives: vec![],
|
||||
tool_call_id: "call_c".into(),
|
||||
},
|
||||
];
|
||||
|
||||
let results = worker.execute_tools_parallel(&selections).await;
|
||||
|
||||
// Results must be in same order as selections, not completion order.
|
||||
assert!(results[0].result.as_ref().unwrap().contains("done_tool_a"));
|
||||
assert!(results[1].result.as_ref().unwrap().contains("done_tool_b"));
|
||||
assert!(results[2].result.as_ref().unwrap().contains("done_tool_c"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_missing_tool_produces_error_not_panic() {
|
||||
// If a tool doesn't exist, the result slot should contain an error.
|
||||
let worker = make_worker(vec![]).await;
|
||||
|
||||
let selections = vec![ToolSelection {
|
||||
tool_name: "nonexistent_tool".into(),
|
||||
parameters: serde_json::json!({}),
|
||||
reasoning: String::new(),
|
||||
alternatives: vec![],
|
||||
tool_call_id: "call_x".into(),
|
||||
}];
|
||||
|
||||
let results = worker.execute_tools_parallel(&selections).await;
|
||||
assert_eq!(results.len(), 1);
|
||||
assert!(
|
||||
results[0].result.is_err(),
|
||||
"Missing tool should produce an error, not a panic"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
-780
@@ -1,780 +0,0 @@
|
||||
//! Application builder for initializing core IronClaw components.
|
||||
//!
|
||||
//! Extracts the mechanical initialization phases from `main.rs` into a
|
||||
//! reusable builder so that:
|
||||
//!
|
||||
//! - Tests can construct a full `AppComponents` without wiring channels
|
||||
//! - Main stays focused on CLI dispatch and channel setup
|
||||
//! - Each init phase is independently testable
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::channels::web::log_layer::LogBroadcaster;
|
||||
use crate::config::Config;
|
||||
use crate::context::ContextManager;
|
||||
use crate::db::Database;
|
||||
use crate::extensions::ExtensionManager;
|
||||
use crate::hooks::HookRegistry;
|
||||
use crate::llm::{LlmProvider, SessionManager};
|
||||
use crate::safety::SafetyLayer;
|
||||
use crate::secrets::SecretsStore;
|
||||
use crate::skills::SkillRegistry;
|
||||
use crate::skills::catalog::SkillCatalog;
|
||||
use crate::tools::ToolRegistry;
|
||||
use crate::tools::mcp::McpSessionManager;
|
||||
use crate::tools::wasm::WasmToolRuntime;
|
||||
use crate::workspace::{EmbeddingProvider, Workspace};
|
||||
|
||||
/// Fully initialized application components, ready for channel wiring
|
||||
/// and agent construction.
|
||||
pub struct AppComponents {
|
||||
/// The (potentially mutated) config after DB reload and secret injection.
|
||||
pub config: Config,
|
||||
pub db: Option<Arc<dyn Database>>,
|
||||
pub secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>>,
|
||||
pub llm: Arc<dyn LlmProvider>,
|
||||
pub cheap_llm: Option<Arc<dyn LlmProvider>>,
|
||||
pub safety: Arc<SafetyLayer>,
|
||||
pub tools: Arc<ToolRegistry>,
|
||||
pub embeddings: Option<Arc<dyn EmbeddingProvider>>,
|
||||
pub workspace: Option<Arc<Workspace>>,
|
||||
pub extension_manager: Option<Arc<ExtensionManager>>,
|
||||
pub mcp_session_manager: Arc<McpSessionManager>,
|
||||
pub wasm_tool_runtime: Option<Arc<WasmToolRuntime>>,
|
||||
pub log_broadcaster: Arc<LogBroadcaster>,
|
||||
pub context_manager: Arc<ContextManager>,
|
||||
pub hooks: Arc<HookRegistry>,
|
||||
pub skill_registry: Option<Arc<std::sync::RwLock<SkillRegistry>>>,
|
||||
pub skill_catalog: Option<Arc<SkillCatalog>>,
|
||||
pub cost_guard: Arc<crate::agent::cost_guard::CostGuard>,
|
||||
pub session: Arc<SessionManager>,
|
||||
}
|
||||
|
||||
/// Options that control optional init phases.
|
||||
#[derive(Default)]
|
||||
pub struct AppBuilderFlags {
|
||||
pub no_db: bool,
|
||||
}
|
||||
|
||||
/// Builder that orchestrates the 5 mechanical init phases.
|
||||
pub struct AppBuilder {
|
||||
config: Config,
|
||||
flags: AppBuilderFlags,
|
||||
toml_path: Option<std::path::PathBuf>,
|
||||
session: Arc<SessionManager>,
|
||||
log_broadcaster: Arc<LogBroadcaster>,
|
||||
|
||||
// Accumulated state
|
||||
db: Option<Arc<dyn Database>>,
|
||||
secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>>,
|
||||
|
||||
// Backend-specific handles needed by secrets store
|
||||
#[cfg(feature = "postgres")]
|
||||
pg_pool: Option<deadpool_postgres::Pool>,
|
||||
#[cfg(feature = "libsql")]
|
||||
libsql_db: Option<Arc<libsql::Database>>,
|
||||
}
|
||||
|
||||
impl AppBuilder {
|
||||
/// Create a new builder.
|
||||
///
|
||||
/// The `session` and `log_broadcaster` are created before the builder
|
||||
/// because tracing must be initialized before any init phase runs,
|
||||
/// and the log broadcaster is part of the tracing layer.
|
||||
pub fn new(
|
||||
config: Config,
|
||||
flags: AppBuilderFlags,
|
||||
toml_path: Option<std::path::PathBuf>,
|
||||
session: Arc<SessionManager>,
|
||||
log_broadcaster: Arc<LogBroadcaster>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
flags,
|
||||
toml_path,
|
||||
session,
|
||||
log_broadcaster,
|
||||
db: None,
|
||||
secrets_store: None,
|
||||
#[cfg(feature = "postgres")]
|
||||
pg_pool: None,
|
||||
#[cfg(feature = "libsql")]
|
||||
libsql_db: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 1: Initialize database backend.
|
||||
///
|
||||
/// Creates the database connection, runs migrations, reloads config
|
||||
/// from DB, attaches DB to session manager, and cleans up stale jobs.
|
||||
pub async fn init_database(&mut self) -> Result<(), anyhow::Error> {
|
||||
if self.flags.no_db {
|
||||
tracing::warn!("Running without database connection");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let db: Arc<dyn Database> = match self.config.database.backend {
|
||||
#[cfg(feature = "libsql")]
|
||||
crate::config::DatabaseBackend::LibSql => {
|
||||
use crate::db::Database as _;
|
||||
use crate::db::libsql::LibSqlBackend;
|
||||
use secrecy::ExposeSecret as _;
|
||||
|
||||
let default_path = crate::config::default_libsql_path();
|
||||
let db_path = self
|
||||
.config
|
||||
.database
|
||||
.libsql_path
|
||||
.as_deref()
|
||||
.unwrap_or(&default_path);
|
||||
|
||||
let backend = if let Some(ref url) = self.config.database.libsql_url {
|
||||
let token =
|
||||
self.config
|
||||
.database
|
||||
.libsql_auth_token
|
||||
.as_ref()
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"LIBSQL_AUTH_TOKEN is required when LIBSQL_URL is set"
|
||||
)
|
||||
})?;
|
||||
LibSqlBackend::new_remote_replica(db_path, url, token.expose_secret()).await?
|
||||
} else {
|
||||
LibSqlBackend::new_local(db_path).await?
|
||||
};
|
||||
backend.run_migrations().await?;
|
||||
tracing::info!("libSQL database connected and migrations applied");
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
{
|
||||
self.libsql_db = Some(backend.shared_db());
|
||||
}
|
||||
|
||||
Arc::new(backend) as Arc<dyn Database>
|
||||
}
|
||||
#[cfg(feature = "postgres")]
|
||||
_ => {
|
||||
use crate::db::Database as _;
|
||||
let pg = crate::db::postgres::PgBackend::new(&self.config.database)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
pg.run_migrations()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
tracing::info!("PostgreSQL database connected and migrations applied");
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
{
|
||||
self.pg_pool = Some(pg.pool());
|
||||
}
|
||||
|
||||
Arc::new(pg) as Arc<dyn Database>
|
||||
}
|
||||
#[cfg(not(feature = "postgres"))]
|
||||
_ => {
|
||||
anyhow::bail!(
|
||||
"No database backend available. Enable 'postgres' or 'libsql' feature."
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Post-init: migrate disk config, reload config from DB, attach session, cleanup
|
||||
if let Err(e) = crate::bootstrap::migrate_disk_to_db(db.as_ref(), "default").await {
|
||||
tracing::warn!("Disk-to-DB settings migration failed: {}", e);
|
||||
}
|
||||
|
||||
let toml_path = self.toml_path.as_deref();
|
||||
match Config::from_db_with_toml(db.as_ref(), "default", toml_path).await {
|
||||
Ok(db_config) => {
|
||||
self.config = db_config;
|
||||
tracing::info!("Configuration reloaded from database");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Failed to reload config from DB, keeping env-based config: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
self.session.attach_store(db.clone(), "default").await;
|
||||
|
||||
if let Err(e) = db.cleanup_stale_sandbox_jobs().await {
|
||||
tracing::warn!("Failed to cleanup stale sandbox jobs: {}", e);
|
||||
}
|
||||
|
||||
self.db = Some(db);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Phase 2: Create secrets store.
|
||||
///
|
||||
/// Requires a master key and a backend-specific DB handle. After creating
|
||||
/// the store, injects any encrypted LLM API keys into the config overlay
|
||||
/// and re-resolves config.
|
||||
pub async fn init_secrets(&mut self) -> Result<(), anyhow::Error> {
|
||||
let master_key = match self.config.secrets.master_key() {
|
||||
Some(k) => k,
|
||||
None => {
|
||||
// Consume unused handles
|
||||
#[cfg(feature = "libsql")]
|
||||
{
|
||||
self.libsql_db.take();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let crypto = match crate::secrets::SecretsCrypto::new(master_key.clone()) {
|
||||
Ok(c) => Arc::new(c),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to initialize secrets crypto: {}", e);
|
||||
#[cfg(feature = "libsql")]
|
||||
{
|
||||
self.libsql_db.take();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let store: Option<Arc<dyn SecretsStore + Send + Sync>> = None;
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
let store = store.or_else(|| {
|
||||
self.libsql_db.take().map(|db| {
|
||||
Arc::new(crate::secrets::LibSqlSecretsStore::new(
|
||||
db,
|
||||
Arc::clone(&crypto),
|
||||
)) as Arc<dyn SecretsStore + Send + Sync>
|
||||
})
|
||||
});
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
let store = store.or_else(|| {
|
||||
self.pg_pool.as_ref().map(|pool| {
|
||||
Arc::new(crate::secrets::PostgresSecretsStore::new(
|
||||
pool.clone(),
|
||||
Arc::clone(&crypto),
|
||||
)) as Arc<dyn SecretsStore + Send + Sync>
|
||||
})
|
||||
});
|
||||
|
||||
if let Some(ref secrets) = store {
|
||||
// Inject LLM API keys from encrypted storage
|
||||
crate::config::inject_llm_keys_from_secrets(secrets.as_ref(), "default").await;
|
||||
|
||||
// Re-resolve config with newly available keys
|
||||
if let Some(ref db) = self.db {
|
||||
let toml_path = self.toml_path.as_deref();
|
||||
match Config::from_db_with_toml(db.as_ref(), "default", toml_path).await {
|
||||
Ok(refreshed) => {
|
||||
self.config = refreshed;
|
||||
tracing::debug!("LlmConfig re-resolved after secret injection");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to re-resolve config after secret injection: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.secrets_store = store;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Phase 3: Initialize LLM provider chain.
|
||||
///
|
||||
/// Creates the primary provider, then wraps with failover, circuit
|
||||
/// breaker, and response cache as configured.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn init_llm(
|
||||
&self,
|
||||
) -> Result<(Arc<dyn LlmProvider>, Option<Arc<dyn LlmProvider>>), anyhow::Error> {
|
||||
use crate::llm::{
|
||||
CachedProvider, CircuitBreakerConfig, CircuitBreakerProvider, CooldownConfig,
|
||||
FailoverProvider, ResponseCacheConfig, create_cheap_llm_provider, create_llm_provider,
|
||||
create_llm_provider_with_config,
|
||||
};
|
||||
|
||||
let llm = create_llm_provider(&self.config.llm, self.session.clone())?;
|
||||
tracing::info!("LLM provider initialized: {}", llm.model_name());
|
||||
|
||||
// Wrap in failover if a fallback model is configured
|
||||
let llm: Arc<dyn LlmProvider> = if let Some(fallback_model) =
|
||||
self.config.llm.nearai.fallback_model.as_ref()
|
||||
{
|
||||
if fallback_model == &self.config.llm.nearai.model {
|
||||
tracing::warn!(
|
||||
"fallback_model is the same as primary model, failover may not be effective"
|
||||
);
|
||||
}
|
||||
let mut fallback_config = self.config.llm.nearai.clone();
|
||||
fallback_config.model = fallback_model.clone();
|
||||
let fallback = create_llm_provider_with_config(&fallback_config, self.session.clone())?;
|
||||
tracing::info!(
|
||||
primary = %llm.model_name(),
|
||||
fallback = %fallback.model_name(),
|
||||
"LLM failover enabled"
|
||||
);
|
||||
let cooldown_config = CooldownConfig {
|
||||
cooldown_duration: std::time::Duration::from_secs(
|
||||
self.config.llm.nearai.failover_cooldown_secs,
|
||||
),
|
||||
failure_threshold: self.config.llm.nearai.failover_cooldown_threshold,
|
||||
};
|
||||
Arc::new(FailoverProvider::with_cooldown(
|
||||
vec![llm, fallback],
|
||||
cooldown_config,
|
||||
)?)
|
||||
} else {
|
||||
llm
|
||||
};
|
||||
|
||||
// Wrap in circuit breaker if configured
|
||||
let llm: Arc<dyn LlmProvider> =
|
||||
if let Some(threshold) = self.config.llm.nearai.circuit_breaker_threshold {
|
||||
let cb_config = CircuitBreakerConfig {
|
||||
failure_threshold: threshold,
|
||||
recovery_timeout: std::time::Duration::from_secs(
|
||||
self.config.llm.nearai.circuit_breaker_recovery_secs,
|
||||
),
|
||||
..CircuitBreakerConfig::default()
|
||||
};
|
||||
tracing::info!(
|
||||
threshold,
|
||||
recovery_secs = self.config.llm.nearai.circuit_breaker_recovery_secs,
|
||||
"LLM circuit breaker enabled"
|
||||
);
|
||||
Arc::new(CircuitBreakerProvider::new(llm, cb_config))
|
||||
} else {
|
||||
llm
|
||||
};
|
||||
|
||||
// Wrap in response cache if configured
|
||||
let llm: Arc<dyn LlmProvider> = if self.config.llm.nearai.response_cache_enabled {
|
||||
let rc_config = ResponseCacheConfig {
|
||||
ttl: std::time::Duration::from_secs(self.config.llm.nearai.response_cache_ttl_secs),
|
||||
max_entries: self.config.llm.nearai.response_cache_max_entries,
|
||||
};
|
||||
tracing::info!(
|
||||
ttl_secs = self.config.llm.nearai.response_cache_ttl_secs,
|
||||
max_entries = self.config.llm.nearai.response_cache_max_entries,
|
||||
"LLM response cache enabled"
|
||||
);
|
||||
Arc::new(CachedProvider::new(llm, rc_config))
|
||||
} else {
|
||||
llm
|
||||
};
|
||||
|
||||
// Cheap LLM for lightweight tasks
|
||||
let cheap_llm = create_cheap_llm_provider(&self.config.llm, self.session.clone())?;
|
||||
if let Some(ref cheap) = cheap_llm {
|
||||
tracing::info!("Cheap LLM provider initialized: {}", cheap.model_name());
|
||||
}
|
||||
|
||||
Ok((llm, cheap_llm))
|
||||
}
|
||||
|
||||
/// Phase 4: Initialize safety, tools, embeddings, and workspace.
|
||||
pub async fn init_tools(
|
||||
&self,
|
||||
llm: &Arc<dyn LlmProvider>,
|
||||
) -> Result<
|
||||
(
|
||||
Arc<SafetyLayer>,
|
||||
Arc<ToolRegistry>,
|
||||
Option<Arc<dyn EmbeddingProvider>>,
|
||||
Option<Arc<Workspace>>,
|
||||
),
|
||||
anyhow::Error,
|
||||
> {
|
||||
use crate::workspace::{NearAiEmbeddings, OpenAiEmbeddings};
|
||||
|
||||
let safety = Arc::new(SafetyLayer::new(&self.config.safety));
|
||||
tracing::info!("Safety layer initialized");
|
||||
|
||||
let tools = Arc::new(ToolRegistry::new());
|
||||
tools.register_builtin_tools();
|
||||
|
||||
// Create embeddings provider if configured
|
||||
let embeddings: Option<Arc<dyn EmbeddingProvider>> = if self.config.embeddings.enabled {
|
||||
match self.config.embeddings.provider.as_str() {
|
||||
"nearai" => {
|
||||
tracing::info!(
|
||||
"Embeddings enabled via NEAR AI (model: {})",
|
||||
self.config.embeddings.model
|
||||
);
|
||||
Some(Arc::new(
|
||||
NearAiEmbeddings::new(
|
||||
&self.config.llm.nearai.base_url,
|
||||
self.session.clone(),
|
||||
)
|
||||
.with_model(&self.config.embeddings.model, 1536),
|
||||
))
|
||||
}
|
||||
_ => {
|
||||
if let Some(api_key) = self.config.embeddings.openai_api_key() {
|
||||
tracing::info!(
|
||||
"Embeddings enabled via OpenAI (model: {})",
|
||||
self.config.embeddings.model
|
||||
);
|
||||
Some(Arc::new(OpenAiEmbeddings::with_model(
|
||||
api_key,
|
||||
&self.config.embeddings.model,
|
||||
match self.config.embeddings.model.as_str() {
|
||||
"text-embedding-3-large" => 3072,
|
||||
_ => 1536,
|
||||
},
|
||||
)))
|
||||
} else {
|
||||
tracing::warn!("Embeddings configured but OPENAI_API_KEY not set");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::info!("Embeddings disabled (set OPENAI_API_KEY or EMBEDDING_ENABLED=true)");
|
||||
None
|
||||
};
|
||||
|
||||
// Register memory tools if database is available
|
||||
let workspace = if let Some(ref db) = self.db {
|
||||
let mut ws = Workspace::new_with_db("default", db.clone());
|
||||
if let Some(ref emb) = embeddings {
|
||||
ws = ws.with_embeddings(emb.clone());
|
||||
}
|
||||
let ws = Arc::new(ws);
|
||||
tools.register_memory_tools(Arc::clone(&ws));
|
||||
Some(ws)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Register builder tool if enabled
|
||||
if self.config.builder.enabled
|
||||
&& (self.config.agent.allow_local_tools || !self.config.sandbox.enabled)
|
||||
{
|
||||
tools
|
||||
.register_builder_tool(
|
||||
llm.clone(),
|
||||
safety.clone(),
|
||||
Some(self.config.builder.to_builder_config()),
|
||||
)
|
||||
.await;
|
||||
tracing::info!("Builder mode enabled");
|
||||
}
|
||||
|
||||
Ok((safety, tools, embeddings, workspace))
|
||||
}
|
||||
|
||||
/// Phase 5: Load WASM tools, MCP servers, and create extension manager.
|
||||
pub async fn init_extensions(
|
||||
&self,
|
||||
tools: &Arc<ToolRegistry>,
|
||||
hooks: &Arc<HookRegistry>,
|
||||
) -> Result<
|
||||
(
|
||||
Arc<McpSessionManager>,
|
||||
Option<Arc<WasmToolRuntime>>,
|
||||
Option<Arc<ExtensionManager>>,
|
||||
),
|
||||
anyhow::Error,
|
||||
> {
|
||||
use crate::tools::mcp::{McpClient, config::load_mcp_servers_from_db, is_authenticated};
|
||||
use crate::tools::wasm::{WasmToolLoader, load_dev_tools};
|
||||
|
||||
let mcp_session_manager = Arc::new(McpSessionManager::new());
|
||||
|
||||
// Create WASM tool runtime
|
||||
let wasm_tool_runtime: Option<Arc<WasmToolRuntime>> =
|
||||
if self.config.wasm.enabled && self.config.wasm.tools_dir.exists() {
|
||||
match WasmToolRuntime::new(self.config.wasm.to_runtime_config()) {
|
||||
Ok(runtime) => Some(Arc::new(runtime)),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to initialize WASM runtime: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Load WASM tools and MCP servers concurrently
|
||||
let wasm_tools_future = {
|
||||
let wasm_tool_runtime = wasm_tool_runtime.clone();
|
||||
let secrets_store = self.secrets_store.clone();
|
||||
let tools = Arc::clone(tools);
|
||||
let wasm_config = self.config.wasm.clone();
|
||||
async move {
|
||||
if let Some(ref runtime) = wasm_tool_runtime {
|
||||
let mut loader = WasmToolLoader::new(Arc::clone(runtime), Arc::clone(&tools));
|
||||
if let Some(ref secrets) = secrets_store {
|
||||
loader = loader.with_secrets_store(Arc::clone(secrets));
|
||||
}
|
||||
|
||||
match loader.load_from_dir(&wasm_config.tools_dir).await {
|
||||
Ok(results) => {
|
||||
if !results.loaded.is_empty() {
|
||||
tracing::info!(
|
||||
"Loaded {} WASM tools from {}",
|
||||
results.loaded.len(),
|
||||
wasm_config.tools_dir.display()
|
||||
);
|
||||
}
|
||||
for (path, err) in &results.errors {
|
||||
tracing::warn!(
|
||||
"Failed to load WASM tool {}: {}",
|
||||
path.display(),
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to scan WASM tools directory: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
match load_dev_tools(&loader, &wasm_config.tools_dir).await {
|
||||
Ok(results) => {
|
||||
if !results.loaded.is_empty() {
|
||||
tracing::info!(
|
||||
"Loaded {} dev WASM tools from build artifacts",
|
||||
results.loaded.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("No dev WASM tools found: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mcp_servers_future = {
|
||||
let secrets_store = self.secrets_store.clone();
|
||||
let db = self.db.clone();
|
||||
let tools = Arc::clone(tools);
|
||||
let mcp_sm = Arc::clone(&mcp_session_manager);
|
||||
async move {
|
||||
if let Some(ref secrets) = secrets_store {
|
||||
let servers_result = if let Some(ref d) = db {
|
||||
load_mcp_servers_from_db(d.as_ref(), "default").await
|
||||
} else {
|
||||
crate::tools::mcp::config::load_mcp_servers().await
|
||||
};
|
||||
match servers_result {
|
||||
Ok(servers) => {
|
||||
let enabled: Vec<_> = servers.enabled_servers().cloned().collect();
|
||||
if !enabled.is_empty() {
|
||||
tracing::info!(
|
||||
"Loading {} configured MCP server(s)...",
|
||||
enabled.len()
|
||||
);
|
||||
}
|
||||
|
||||
let mut join_set = tokio::task::JoinSet::new();
|
||||
for server in enabled {
|
||||
let mcp_sm = Arc::clone(&mcp_sm);
|
||||
let secrets = Arc::clone(secrets);
|
||||
let tools = Arc::clone(&tools);
|
||||
|
||||
join_set.spawn(async move {
|
||||
let server_name = server.name.clone();
|
||||
let has_tokens =
|
||||
is_authenticated(&server, &secrets, "default").await;
|
||||
|
||||
let client = if has_tokens || server.requires_auth() {
|
||||
McpClient::new_authenticated(
|
||||
server, mcp_sm, secrets, "default",
|
||||
)
|
||||
} else {
|
||||
McpClient::new_with_name(&server_name, &server.url)
|
||||
};
|
||||
|
||||
match client.list_tools().await {
|
||||
Ok(mcp_tools) => {
|
||||
let tool_count = mcp_tools.len();
|
||||
match client.create_tools().await {
|
||||
Ok(tool_impls) => {
|
||||
for tool in tool_impls {
|
||||
tools.register(tool).await;
|
||||
}
|
||||
tracing::info!(
|
||||
"Loaded {} tools from MCP server '{}'",
|
||||
tool_count,
|
||||
server_name
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Failed to create tools from MCP server '{}': {}",
|
||||
server_name,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let err_str = e.to_string();
|
||||
if err_str.contains("401")
|
||||
|| err_str.contains("authentication")
|
||||
{
|
||||
tracing::warn!(
|
||||
"MCP server '{}' requires authentication. \
|
||||
Run: ironclaw mcp auth {}",
|
||||
server_name,
|
||||
server_name
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"Failed to connect to MCP server '{}': {}",
|
||||
server_name,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
while let Some(result) = join_set.join_next().await {
|
||||
if let Err(e) = result {
|
||||
tracing::warn!("MCP server loading task panicked: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("No MCP servers configured ({})", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
tokio::join!(wasm_tools_future, mcp_servers_future);
|
||||
|
||||
// Create extension manager
|
||||
let extension_manager = if let Some(ref secrets) = self.secrets_store {
|
||||
let manager = Arc::new(ExtensionManager::new(
|
||||
Arc::clone(&mcp_session_manager),
|
||||
Arc::clone(secrets),
|
||||
Arc::clone(tools),
|
||||
Some(Arc::clone(hooks)),
|
||||
wasm_tool_runtime.clone(),
|
||||
self.config.wasm.tools_dir.clone(),
|
||||
self.config.channels.wasm_channels_dir.clone(),
|
||||
self.config.tunnel.public_url.clone(),
|
||||
"default".to_string(),
|
||||
self.db.clone(),
|
||||
));
|
||||
tools.register_extension_tools(Arc::clone(&manager));
|
||||
tracing::info!("Extension manager initialized with in-chat discovery tools");
|
||||
Some(manager)
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"Extension manager not available (no secrets store). \
|
||||
Extension tools won't be registered."
|
||||
);
|
||||
None
|
||||
};
|
||||
|
||||
// register_builder_tool() already calls register_dev_tools() internally,
|
||||
// so only register them here when the builder didn't already do it.
|
||||
let builder_registered_dev_tools = self.config.builder.enabled
|
||||
&& (self.config.agent.allow_local_tools || !self.config.sandbox.enabled);
|
||||
if self.config.agent.allow_local_tools && !builder_registered_dev_tools {
|
||||
tools.register_dev_tools();
|
||||
}
|
||||
|
||||
Ok((mcp_session_manager, wasm_tool_runtime, extension_manager))
|
||||
}
|
||||
|
||||
/// Run all init phases in order and return the assembled components.
|
||||
pub async fn build_all(mut self) -> Result<AppComponents, anyhow::Error> {
|
||||
self.init_database().await?;
|
||||
self.init_secrets().await?;
|
||||
|
||||
let (llm, cheap_llm) = self.init_llm()?;
|
||||
let (safety, tools, embeddings, workspace) = self.init_tools(&llm).await?;
|
||||
|
||||
// Create hook registry early so runtime extension activation can register hooks.
|
||||
let hooks = Arc::new(HookRegistry::new());
|
||||
|
||||
let (mcp_session_manager, wasm_tool_runtime, extension_manager) =
|
||||
self.init_extensions(&tools, &hooks).await?;
|
||||
|
||||
// Seed workspace and backfill embeddings
|
||||
if let Some(ref ws) = workspace {
|
||||
match ws.seed_if_empty().await {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to seed workspace: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
if embeddings.is_some() {
|
||||
match ws.backfill_embeddings().await {
|
||||
Ok(count) if count > 0 => {
|
||||
tracing::info!("Backfilled embeddings for {} chunks", count);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to backfill embeddings: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skills system
|
||||
let (skill_registry, skill_catalog) = if self.config.skills.enabled {
|
||||
let mut registry = SkillRegistry::new(self.config.skills.local_dir.clone());
|
||||
let loaded = registry.discover_all().await;
|
||||
if !loaded.is_empty() {
|
||||
tracing::info!("Loaded {} skill(s): {}", loaded.len(), loaded.join(", "));
|
||||
}
|
||||
let registry = Arc::new(std::sync::RwLock::new(registry));
|
||||
let catalog = crate::skills::catalog::shared_catalog();
|
||||
tools.register_skill_tools(Arc::clone(®istry), Arc::clone(&catalog));
|
||||
(Some(registry), Some(catalog))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
let context_manager = Arc::new(ContextManager::new(self.config.agent.max_parallel_jobs));
|
||||
let cost_guard = Arc::new(crate::agent::cost_guard::CostGuard::new(
|
||||
crate::agent::cost_guard::CostGuardConfig {
|
||||
max_cost_per_day_cents: self.config.agent.max_cost_per_day_cents,
|
||||
max_actions_per_hour: self.config.agent.max_actions_per_hour,
|
||||
},
|
||||
));
|
||||
|
||||
tracing::info!(
|
||||
"Tool registry initialized with {} total tools",
|
||||
tools.count()
|
||||
);
|
||||
|
||||
Ok(AppComponents {
|
||||
config: self.config,
|
||||
db: self.db,
|
||||
secrets_store: self.secrets_store,
|
||||
llm,
|
||||
cheap_llm,
|
||||
safety,
|
||||
tools,
|
||||
embeddings,
|
||||
workspace,
|
||||
extension_manager,
|
||||
mcp_session_manager,
|
||||
wasm_tool_runtime,
|
||||
log_broadcaster: self.log_broadcaster,
|
||||
context_manager,
|
||||
hooks,
|
||||
skill_registry,
|
||||
skill_catalog,
|
||||
cost_guard,
|
||||
session: self.session,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
//! Boot screen displayed after all initialization completes.
|
||||
//!
|
||||
//! Shows a polished ANSI-styled status panel summarizing the agent's runtime
|
||||
//! state: model, database, tool count, enabled features, active channels,
|
||||
//! and the gateway URL.
|
||||
|
||||
/// All displayable fields for the boot screen.
|
||||
pub struct BootInfo {
|
||||
pub version: String,
|
||||
pub agent_name: String,
|
||||
pub llm_backend: String,
|
||||
pub llm_model: String,
|
||||
pub cheap_model: Option<String>,
|
||||
pub db_backend: String,
|
||||
pub db_connected: bool,
|
||||
pub tool_count: usize,
|
||||
pub gateway_url: Option<String>,
|
||||
pub embeddings_enabled: bool,
|
||||
pub embeddings_provider: Option<String>,
|
||||
pub heartbeat_enabled: bool,
|
||||
pub heartbeat_interval_secs: u64,
|
||||
pub sandbox_enabled: bool,
|
||||
pub claude_code_enabled: bool,
|
||||
pub routines_enabled: bool,
|
||||
pub channels: Vec<String>,
|
||||
/// Public URL from a managed tunnel (e.g., "https://abc.ngrok.io").
|
||||
pub tunnel_url: Option<String>,
|
||||
/// Provider name for the managed tunnel (e.g., "ngrok").
|
||||
pub tunnel_provider: Option<String>,
|
||||
}
|
||||
|
||||
/// Print the boot screen to stdout.
|
||||
pub fn print_boot_screen(info: &BootInfo) {
|
||||
// ANSI codes matching existing REPL palette
|
||||
let bold = "\x1b[1m";
|
||||
let cyan = "\x1b[36m";
|
||||
let dim = "\x1b[90m";
|
||||
let yellow_underline = "\x1b[33;4m";
|
||||
let reset = "\x1b[0m";
|
||||
|
||||
let border = format!(" {dim}{}{reset}", "\u{2576}".repeat(58));
|
||||
|
||||
println!();
|
||||
println!("{border}");
|
||||
println!();
|
||||
println!(" {bold}{}{reset} v{}", info.agent_name, info.version);
|
||||
println!();
|
||||
|
||||
// Model line
|
||||
let model_display = if let Some(ref cheap) = info.cheap_model {
|
||||
format!(
|
||||
"{cyan}{}{reset} {dim}cheap{reset} {cyan}{}{reset}",
|
||||
info.llm_model, cheap
|
||||
)
|
||||
} else {
|
||||
format!("{cyan}{}{reset}", info.llm_model)
|
||||
};
|
||||
println!(
|
||||
" {dim}model{reset} {model_display} {dim}via {}{reset}",
|
||||
info.llm_backend
|
||||
);
|
||||
|
||||
// Database line
|
||||
let db_status = if info.db_connected {
|
||||
"connected"
|
||||
} else {
|
||||
"none"
|
||||
};
|
||||
println!(
|
||||
" {dim}database{reset} {cyan}{}{reset} {dim}({db_status}){reset}",
|
||||
info.db_backend
|
||||
);
|
||||
|
||||
// Tools line
|
||||
println!(
|
||||
" {dim}tools{reset} {cyan}{}{reset} {dim}registered{reset}",
|
||||
info.tool_count
|
||||
);
|
||||
|
||||
// Features line
|
||||
let mut features = Vec::new();
|
||||
if info.embeddings_enabled {
|
||||
if let Some(ref provider) = info.embeddings_provider {
|
||||
features.push(format!("embeddings ({provider})"));
|
||||
} else {
|
||||
features.push("embeddings".to_string());
|
||||
}
|
||||
}
|
||||
if info.heartbeat_enabled {
|
||||
let mins = info.heartbeat_interval_secs / 60;
|
||||
features.push(format!("heartbeat ({mins}m)"));
|
||||
}
|
||||
if info.sandbox_enabled {
|
||||
features.push("sandbox".to_string());
|
||||
}
|
||||
if info.claude_code_enabled {
|
||||
features.push("claude-code".to_string());
|
||||
}
|
||||
if info.routines_enabled {
|
||||
features.push("routines".to_string());
|
||||
}
|
||||
if !features.is_empty() {
|
||||
println!(
|
||||
" {dim}features{reset} {cyan}{}{reset}",
|
||||
features.join(" ")
|
||||
);
|
||||
}
|
||||
|
||||
// Channels line
|
||||
if !info.channels.is_empty() {
|
||||
println!(
|
||||
" {dim}channels{reset} {cyan}{}{reset}",
|
||||
info.channels.join(" ")
|
||||
);
|
||||
}
|
||||
|
||||
// Gateway URL (highlighted)
|
||||
if let Some(ref url) = info.gateway_url {
|
||||
println!();
|
||||
println!(" {dim}gateway{reset} {yellow_underline}{url}{reset}");
|
||||
}
|
||||
|
||||
// Tunnel URL
|
||||
if let Some(ref url) = info.tunnel_url {
|
||||
let provider_tag = info
|
||||
.tunnel_provider
|
||||
.as_deref()
|
||||
.map(|p| format!(" {dim}({p}){reset}"))
|
||||
.unwrap_or_default();
|
||||
println!(" {dim}tunnel{reset} {yellow_underline}{url}{reset}{provider_tag}");
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{border}");
|
||||
println!();
|
||||
println!(" /help for commands, /quit to exit");
|
||||
println!();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_print_boot_screen_full() {
|
||||
let info = BootInfo {
|
||||
version: "0.2.0".to_string(),
|
||||
agent_name: "ironclaw".to_string(),
|
||||
llm_backend: "nearai".to_string(),
|
||||
llm_model: "claude-3-5-sonnet-20241022".to_string(),
|
||||
cheap_model: Some("gpt-4o-mini".to_string()),
|
||||
db_backend: "libsql".to_string(),
|
||||
db_connected: true,
|
||||
tool_count: 24,
|
||||
gateway_url: Some("http://127.0.0.1:3001/?token=abc123".to_string()),
|
||||
embeddings_enabled: true,
|
||||
embeddings_provider: Some("openai".to_string()),
|
||||
heartbeat_enabled: true,
|
||||
heartbeat_interval_secs: 1800,
|
||||
sandbox_enabled: true,
|
||||
claude_code_enabled: false,
|
||||
routines_enabled: true,
|
||||
channels: vec![
|
||||
"repl".to_string(),
|
||||
"gateway".to_string(),
|
||||
"telegram".to_string(),
|
||||
],
|
||||
tunnel_url: Some("https://abc123.ngrok.io".to_string()),
|
||||
tunnel_provider: Some("ngrok".to_string()),
|
||||
};
|
||||
// Should not panic
|
||||
print_boot_screen(&info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_print_boot_screen_minimal() {
|
||||
let info = BootInfo {
|
||||
version: "0.2.0".to_string(),
|
||||
agent_name: "ironclaw".to_string(),
|
||||
llm_backend: "nearai".to_string(),
|
||||
llm_model: "gpt-4o".to_string(),
|
||||
cheap_model: None,
|
||||
db_backend: "none".to_string(),
|
||||
db_connected: false,
|
||||
tool_count: 5,
|
||||
gateway_url: None,
|
||||
embeddings_enabled: false,
|
||||
embeddings_provider: None,
|
||||
heartbeat_enabled: false,
|
||||
heartbeat_interval_secs: 0,
|
||||
sandbox_enabled: false,
|
||||
claude_code_enabled: false,
|
||||
routines_enabled: false,
|
||||
channels: vec![],
|
||||
tunnel_url: None,
|
||||
tunnel_provider: None,
|
||||
};
|
||||
// Should not panic
|
||||
print_boot_screen(&info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_print_boot_screen_no_features() {
|
||||
let info = BootInfo {
|
||||
version: "0.1.0".to_string(),
|
||||
agent_name: "test".to_string(),
|
||||
llm_backend: "openai".to_string(),
|
||||
llm_model: "gpt-4o".to_string(),
|
||||
cheap_model: None,
|
||||
db_backend: "postgres".to_string(),
|
||||
db_connected: true,
|
||||
tool_count: 10,
|
||||
gateway_url: None,
|
||||
embeddings_enabled: false,
|
||||
embeddings_provider: None,
|
||||
heartbeat_enabled: false,
|
||||
heartbeat_interval_secs: 0,
|
||||
sandbox_enabled: false,
|
||||
claude_code_enabled: false,
|
||||
routines_enabled: false,
|
||||
channels: vec!["repl".to_string()],
|
||||
tunnel_url: None,
|
||||
tunnel_provider: None,
|
||||
};
|
||||
// Should not panic
|
||||
print_boot_screen(&info);
|
||||
}
|
||||
}
|
||||
@@ -1,583 +0,0 @@
|
||||
//! Bootstrap helpers for IronClaw.
|
||||
//!
|
||||
//! The only setting that truly needs disk persistence before the database is
|
||||
//! available is `DATABASE_URL` (chicken-and-egg: can't connect to DB without
|
||||
//! it). Everything else is auto-detected or read from env vars.
|
||||
//!
|
||||
//! File: `~/.ironclaw/.env` (standard dotenvy format)
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Path to the IronClaw-specific `.env` file: `~/.ironclaw/.env`.
|
||||
pub fn ironclaw_env_path() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".ironclaw")
|
||||
.join(".env")
|
||||
}
|
||||
|
||||
/// Load env vars from `~/.ironclaw/.env` (in addition to the standard `.env`).
|
||||
///
|
||||
/// Call this **after** `dotenvy::dotenv()` so that the standard `./.env`
|
||||
/// takes priority over `~/.ironclaw/.env`. dotenvy never overwrites
|
||||
/// existing env vars, so the effective priority is:
|
||||
///
|
||||
/// explicit env vars > `./.env` > `~/.ironclaw/.env`
|
||||
///
|
||||
/// If `~/.ironclaw/.env` doesn't exist but the legacy `bootstrap.json` does,
|
||||
/// extracts `DATABASE_URL` from it and writes the `.env` file (one-time
|
||||
/// upgrade from the old config format).
|
||||
pub fn load_ironclaw_env() {
|
||||
let path = ironclaw_env_path();
|
||||
|
||||
if !path.exists() {
|
||||
// One-time upgrade: extract DATABASE_URL from legacy bootstrap.json
|
||||
migrate_bootstrap_json_to_env(&path);
|
||||
}
|
||||
|
||||
if path.exists() {
|
||||
let _ = dotenvy::from_path(&path);
|
||||
}
|
||||
}
|
||||
|
||||
/// If `bootstrap.json` exists, pull `database_url` out of it and write `.env`.
|
||||
fn migrate_bootstrap_json_to_env(env_path: &std::path::Path) {
|
||||
let ironclaw_dir = env_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new("."));
|
||||
let bootstrap_path = ironclaw_dir.join("bootstrap.json");
|
||||
|
||||
if !bootstrap_path.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let content = match std::fs::read_to_string(&bootstrap_path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
// Minimal parse: just grab database_url from the JSON
|
||||
let parsed: serde_json::Value = match serde_json::from_str(&content) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
if let Some(url) = parsed.get("database_url").and_then(|v| v.as_str()) {
|
||||
if let Some(parent) = env_path.parent()
|
||||
&& let Err(e) = std::fs::create_dir_all(parent)
|
||||
{
|
||||
eprintln!("Warning: failed to create {}: {}", parent.display(), e);
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::write(env_path, format!("DATABASE_URL=\"{}\"\n", url)) {
|
||||
eprintln!("Warning: failed to migrate bootstrap.json to .env: {}", e);
|
||||
return;
|
||||
}
|
||||
rename_to_migrated(&bootstrap_path);
|
||||
eprintln!(
|
||||
"Migrated DATABASE_URL from bootstrap.json to {}",
|
||||
env_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Write database bootstrap vars to `~/.ironclaw/.env`.
|
||||
///
|
||||
/// These settings form the chicken-and-egg layer: they must be available
|
||||
/// from the filesystem (env vars) BEFORE any database connection, because
|
||||
/// they determine which database to connect to. Everything else is stored
|
||||
/// in the database itself.
|
||||
///
|
||||
/// Creates the parent directory if it doesn't exist.
|
||||
/// Values are double-quoted so that `#` (common in URL-encoded passwords)
|
||||
/// and other shell-special characters are preserved by dotenvy.
|
||||
pub fn save_bootstrap_env(vars: &[(&str, &str)]) -> std::io::Result<()> {
|
||||
let path = ironclaw_env_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut content = String::new();
|
||||
for (key, value) in vars {
|
||||
// Escape backslashes and double quotes to prevent env var injection
|
||||
// (e.g. a value containing `"\nINJECTED="x` would break out of quotes).
|
||||
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
content.push_str(&format!("{}=\"{}\"\n", key, escaped));
|
||||
}
|
||||
std::fs::write(&path, &content)?;
|
||||
restrict_file_permissions(&path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update or add a single variable in `~/.ironclaw/.env`, preserving existing content.
|
||||
///
|
||||
/// Unlike `save_bootstrap_env` (which overwrites the entire file), this
|
||||
/// reads the current `.env`, replaces the line for `key` if it exists,
|
||||
/// or appends it otherwise. Use this when writing a single bootstrap var
|
||||
/// outside the wizard (which manages the full set via `save_bootstrap_env`).
|
||||
pub fn upsert_bootstrap_var(key: &str, value: &str) -> std::io::Result<()> {
|
||||
let path = ironclaw_env_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
let new_line = format!("{}=\"{}\"", key, escaped);
|
||||
let prefix = format!("{}=", key);
|
||||
|
||||
let existing = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
|
||||
let mut found = false;
|
||||
let mut result = String::new();
|
||||
for line in existing.lines() {
|
||||
if line.starts_with(&prefix) {
|
||||
if !found {
|
||||
result.push_str(&new_line);
|
||||
result.push('\n');
|
||||
found = true;
|
||||
}
|
||||
// Skip duplicate lines for this key
|
||||
continue;
|
||||
}
|
||||
result.push_str(line);
|
||||
result.push('\n');
|
||||
}
|
||||
|
||||
if !found {
|
||||
result.push_str(&new_line);
|
||||
result.push('\n');
|
||||
}
|
||||
|
||||
std::fs::write(&path, result)?;
|
||||
restrict_file_permissions(&path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set restrictive file permissions (0o600) on Unix systems.
|
||||
///
|
||||
/// The `.env` file may contain database credentials and API keys,
|
||||
/// so it should only be readable by the owner.
|
||||
fn restrict_file_permissions(_path: &std::path::Path) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perms = std::fs::Permissions::from_mode(0o600);
|
||||
std::fs::set_permissions(_path, perms)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write `DATABASE_URL` to `~/.ironclaw/.env`.
|
||||
///
|
||||
/// Convenience wrapper around `save_bootstrap_env` for single-value migration
|
||||
/// paths. Prefer `save_bootstrap_env` for new code.
|
||||
pub fn save_database_url(url: &str) -> std::io::Result<()> {
|
||||
save_bootstrap_env(&[("DATABASE_URL", url)])
|
||||
}
|
||||
|
||||
/// One-time migration of legacy `~/.ironclaw/settings.json` into the database.
|
||||
///
|
||||
/// Only runs when a `settings.json` exists on disk AND the DB has no settings
|
||||
/// yet. After the wizard writes directly to the DB, this path is only hit by
|
||||
/// users upgrading from the old disk-only configuration.
|
||||
///
|
||||
/// After syncing, renames `settings.json` to `.migrated` so it won't trigger again.
|
||||
pub async fn migrate_disk_to_db(
|
||||
store: &dyn crate::db::Database,
|
||||
user_id: &str,
|
||||
) -> Result<(), MigrationError> {
|
||||
let ironclaw_dir = dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".ironclaw");
|
||||
let legacy_settings_path = ironclaw_dir.join("settings.json");
|
||||
|
||||
if !legacy_settings_path.exists() {
|
||||
tracing::debug!("No legacy settings.json found, skipping disk-to-DB migration");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// If DB already has settings, this is not a first boot, the wizard already
|
||||
// wrote directly to the DB. Just clean up the stale file.
|
||||
let has_settings = store.has_settings(user_id).await.map_err(|e| {
|
||||
MigrationError::Database(format!("Failed to check existing settings: {}", e))
|
||||
})?;
|
||||
if has_settings {
|
||||
tracing::info!("DB already has settings, renaming stale settings.json");
|
||||
rename_to_migrated(&legacy_settings_path);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracing::info!("Migrating disk settings to database...");
|
||||
|
||||
// 1. Load and migrate settings.json
|
||||
let settings = crate::settings::Settings::load_from(&legacy_settings_path);
|
||||
let db_map = settings.to_db_map();
|
||||
if !db_map.is_empty() {
|
||||
store
|
||||
.set_all_settings(user_id, &db_map)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
MigrationError::Database(format!("Failed to write settings to DB: {}", e))
|
||||
})?;
|
||||
tracing::info!("Migrated {} settings to database", db_map.len());
|
||||
}
|
||||
|
||||
// 2. Write DATABASE_URL to ~/.ironclaw/.env
|
||||
if let Some(ref url) = settings.database_url {
|
||||
save_database_url(url)
|
||||
.map_err(|e| MigrationError::Io(format!("Failed to write .env: {}", e)))?;
|
||||
tracing::info!("Wrote DATABASE_URL to {}", ironclaw_env_path().display());
|
||||
}
|
||||
|
||||
// 3. Migrate mcp-servers.json if it exists
|
||||
let mcp_path = ironclaw_dir.join("mcp-servers.json");
|
||||
if mcp_path.exists() {
|
||||
match std::fs::read_to_string(&mcp_path) {
|
||||
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
|
||||
Ok(value) => {
|
||||
store
|
||||
.set_setting(user_id, "mcp_servers", &value)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
MigrationError::Database(format!(
|
||||
"Failed to write MCP servers to DB: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
tracing::info!("Migrated mcp-servers.json to database");
|
||||
|
||||
rename_to_migrated(&mcp_path);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to parse mcp-servers.json: {}", e);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to read mcp-servers.json: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Migrate session.json if it exists
|
||||
let session_path = ironclaw_dir.join("session.json");
|
||||
if session_path.exists() {
|
||||
match std::fs::read_to_string(&session_path) {
|
||||
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
|
||||
Ok(value) => {
|
||||
store
|
||||
.set_setting(user_id, "nearai.session_token", &value)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
MigrationError::Database(format!(
|
||||
"Failed to write session to DB: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
tracing::info!("Migrated session.json to database");
|
||||
|
||||
rename_to_migrated(&session_path);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to parse session.json: {}", e);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to read session.json: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Rename settings.json to .migrated (don't delete, safety net)
|
||||
rename_to_migrated(&legacy_settings_path);
|
||||
|
||||
// 6. Clean up old bootstrap.json if it exists (superseded by .env)
|
||||
let old_bootstrap = ironclaw_dir.join("bootstrap.json");
|
||||
if old_bootstrap.exists() {
|
||||
rename_to_migrated(&old_bootstrap);
|
||||
tracing::info!("Renamed old bootstrap.json to .migrated");
|
||||
}
|
||||
|
||||
tracing::info!("Disk-to-DB migration complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rename a file to `<name>.migrated` as a safety net.
|
||||
fn rename_to_migrated(path: &std::path::Path) {
|
||||
let mut migrated = path.as_os_str().to_owned();
|
||||
migrated.push(".migrated");
|
||||
if let Err(e) = std::fs::rename(path, &migrated) {
|
||||
tracing::warn!("Failed to rename {} to .migrated: {}", path.display(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors that can occur during disk-to-DB migration.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum MigrationError {
|
||||
#[error("Database error: {0}")]
|
||||
Database(String),
|
||||
#[error("IO error: {0}")]
|
||||
Io(String),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_save_and_load_database_url() {
|
||||
let dir = tempdir().unwrap();
|
||||
let env_path = dir.path().join(".env");
|
||||
|
||||
// Write in the quoted format that save_database_url uses
|
||||
let url = "postgres://localhost:5432/ironclaw_test";
|
||||
std::fs::write(&env_path, format!("DATABASE_URL=\"{}\"\n", url)).unwrap();
|
||||
|
||||
// Verify the content is a valid dotenv line (quoted)
|
||||
let content = std::fs::read_to_string(&env_path).unwrap();
|
||||
assert_eq!(
|
||||
content,
|
||||
"DATABASE_URL=\"postgres://localhost:5432/ironclaw_test\"\n"
|
||||
);
|
||||
|
||||
// Verify dotenvy can parse it (strips quotes automatically)
|
||||
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert_eq!(parsed[0].0, "DATABASE_URL");
|
||||
assert_eq!(parsed[0].1, url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_database_url_with_hash_in_password() {
|
||||
let dir = tempdir().unwrap();
|
||||
let env_path = dir.path().join(".env");
|
||||
|
||||
// URLs with # in the password are common (URL-encoded special chars).
|
||||
// Without quoting, dotenvy treats # as a comment delimiter.
|
||||
let url = "postgres://user:p%23ss@localhost:5432/ironclaw";
|
||||
std::fs::write(&env_path, format!("DATABASE_URL=\"{}\"\n", url)).unwrap();
|
||||
|
||||
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert_eq!(parsed[0].0, "DATABASE_URL");
|
||||
assert_eq!(parsed[0].1, url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_database_url_creates_parent_dirs() {
|
||||
let dir = tempdir().unwrap();
|
||||
let nested = dir.path().join("deep").join("nested");
|
||||
let env_path = nested.join(".env");
|
||||
|
||||
// Parent doesn't exist yet
|
||||
assert!(!nested.exists());
|
||||
|
||||
// The global function uses a fixed path, so we test the logic directly
|
||||
std::fs::create_dir_all(&nested).unwrap();
|
||||
std::fs::write(&env_path, "DATABASE_URL=postgres://test\n").unwrap();
|
||||
|
||||
assert!(env_path.exists());
|
||||
let content = std::fs::read_to_string(&env_path).unwrap();
|
||||
assert!(content.contains("DATABASE_URL=postgres://test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_bootstrap_env_escapes_quotes() {
|
||||
let dir = tempdir().unwrap();
|
||||
let env_path = dir.path().join(".env");
|
||||
|
||||
// A malicious URL attempting to inject a second env var
|
||||
let malicious = r#"http://evil.com"
|
||||
INJECTED="pwned"#;
|
||||
let mut content = String::new();
|
||||
let escaped = malicious.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
content.push_str(&format!("LLM_BASE_URL=\"{}\"\n", escaped));
|
||||
std::fs::write(&env_path, &content).unwrap();
|
||||
|
||||
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
// Must parse as exactly one variable, not two
|
||||
assert_eq!(parsed.len(), 1, "injection must not create extra vars");
|
||||
assert_eq!(parsed[0].0, "LLM_BASE_URL");
|
||||
// The value should contain the original malicious content (unescaped by dotenvy)
|
||||
assert!(
|
||||
parsed[0].1.contains("INJECTED"),
|
||||
"value should contain the literal injection attempt, not execute it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ironclaw_env_path() {
|
||||
let path = ironclaw_env_path();
|
||||
assert!(path.ends_with(".ironclaw/.env"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_bootstrap_json_to_env() {
|
||||
let dir = tempdir().unwrap();
|
||||
let env_path = dir.path().join(".env");
|
||||
let bootstrap_path = dir.path().join("bootstrap.json");
|
||||
|
||||
// Write a legacy bootstrap.json
|
||||
let bootstrap_json = serde_json::json!({
|
||||
"database_url": "postgres://localhost/ironclaw_upgrade",
|
||||
"database_pool_size": 5,
|
||||
"secrets_master_key_source": "keychain",
|
||||
"onboard_completed": true
|
||||
});
|
||||
std::fs::write(
|
||||
&bootstrap_path,
|
||||
serde_json::to_string_pretty(&bootstrap_json).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(!env_path.exists());
|
||||
assert!(bootstrap_path.exists());
|
||||
|
||||
// Run the migration
|
||||
migrate_bootstrap_json_to_env(&env_path);
|
||||
|
||||
// .env should now exist with DATABASE_URL
|
||||
assert!(env_path.exists());
|
||||
let content = std::fs::read_to_string(&env_path).unwrap();
|
||||
assert_eq!(
|
||||
content,
|
||||
"DATABASE_URL=\"postgres://localhost/ironclaw_upgrade\"\n"
|
||||
);
|
||||
|
||||
// bootstrap.json should be renamed to .migrated
|
||||
assert!(!bootstrap_path.exists());
|
||||
assert!(dir.path().join("bootstrap.json.migrated").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_bootstrap_json_no_database_url() {
|
||||
let dir = tempdir().unwrap();
|
||||
let env_path = dir.path().join(".env");
|
||||
let bootstrap_path = dir.path().join("bootstrap.json");
|
||||
|
||||
// bootstrap.json with no database_url
|
||||
let bootstrap_json = serde_json::json!({
|
||||
"onboard_completed": false
|
||||
});
|
||||
std::fs::write(
|
||||
&bootstrap_path,
|
||||
serde_json::to_string_pretty(&bootstrap_json).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
migrate_bootstrap_json_to_env(&env_path);
|
||||
|
||||
// .env should NOT be created
|
||||
assert!(!env_path.exists());
|
||||
// bootstrap.json should remain (no migration happened)
|
||||
assert!(bootstrap_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_bootstrap_json_missing() {
|
||||
let dir = tempdir().unwrap();
|
||||
let env_path = dir.path().join(".env");
|
||||
|
||||
// No bootstrap.json at all
|
||||
migrate_bootstrap_json_to_env(&env_path);
|
||||
|
||||
// Nothing should happen
|
||||
assert!(!env_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_bootstrap_env_multiple_vars() {
|
||||
let dir = tempdir().unwrap();
|
||||
let env_path = dir.path().join("nested").join(".env");
|
||||
|
||||
std::fs::create_dir_all(env_path.parent().unwrap()).unwrap();
|
||||
|
||||
let vars = [
|
||||
("DATABASE_BACKEND", "libsql"),
|
||||
("LIBSQL_PATH", "/home/user/.ironclaw/ironclaw.db"),
|
||||
];
|
||||
|
||||
// Write manually to the temp path (save_bootstrap_env uses the global path)
|
||||
let mut content = String::new();
|
||||
for (key, value) in &vars {
|
||||
content.push_str(&format!("{}=\"{}\"\n", key, value));
|
||||
}
|
||||
std::fs::write(&env_path, &content).unwrap();
|
||||
|
||||
// Verify dotenvy can parse all entries
|
||||
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
assert_eq!(parsed.len(), 2);
|
||||
assert_eq!(
|
||||
parsed[0],
|
||||
("DATABASE_BACKEND".to_string(), "libsql".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parsed[1],
|
||||
(
|
||||
"LIBSQL_PATH".to_string(),
|
||||
"/home/user/.ironclaw/ironclaw.db".to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_bootstrap_env_overwrites_previous() {
|
||||
let dir = tempdir().unwrap();
|
||||
let env_path = dir.path().join(".env");
|
||||
|
||||
// Write initial content
|
||||
std::fs::write(&env_path, "DATABASE_URL=\"postgres://old\"\n").unwrap();
|
||||
|
||||
// Overwrite with new vars (simulating save_bootstrap_env behavior)
|
||||
let content = "DATABASE_BACKEND=\"libsql\"\nLIBSQL_PATH=\"/new/path.db\"\n";
|
||||
std::fs::write(&env_path, content).unwrap();
|
||||
|
||||
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
// Old DATABASE_URL should be gone
|
||||
assert_eq!(parsed.len(), 2);
|
||||
assert!(parsed.iter().all(|(k, _)| k != "DATABASE_URL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_onboard_completed_round_trips_through_env() {
|
||||
let dir = tempdir().unwrap();
|
||||
let env_path = dir.path().join(".env");
|
||||
|
||||
// Simulate what the wizard writes: bootstrap vars + ONBOARD_COMPLETED
|
||||
let vars = [
|
||||
("DATABASE_BACKEND", "libsql"),
|
||||
("ONBOARD_COMPLETED", "true"),
|
||||
];
|
||||
let mut content = String::new();
|
||||
for (key, value) in &vars {
|
||||
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
content.push_str(&format!("{}=\"{}\"\n", key, escaped));
|
||||
}
|
||||
std::fs::write(&env_path, &content).unwrap();
|
||||
|
||||
// Verify dotenvy parses ONBOARD_COMPLETED correctly
|
||||
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
assert_eq!(parsed.len(), 2);
|
||||
let onboard = parsed.iter().find(|(k, _)| k == "ONBOARD_COMPLETED");
|
||||
assert!(onboard.is_some(), "ONBOARD_COMPLETED must be present");
|
||||
assert_eq!(onboard.unwrap().1, "true");
|
||||
}
|
||||
}
|
||||
@@ -114,12 +114,6 @@ pub enum StatusUpdate {
|
||||
StreamChunk(String),
|
||||
/// General status message.
|
||||
Status(String),
|
||||
/// A sandbox job has started (shown as a clickable card in the UI).
|
||||
JobStarted {
|
||||
job_id: String,
|
||||
title: String,
|
||||
browse_url: String,
|
||||
},
|
||||
/// Tool requires user approval before execution.
|
||||
ApprovalNeeded {
|
||||
request_id: String,
|
||||
@@ -127,19 +121,6 @@ pub enum StatusUpdate {
|
||||
description: String,
|
||||
parameters: serde_json::Value,
|
||||
},
|
||||
/// Extension needs user authentication (token or OAuth).
|
||||
AuthRequired {
|
||||
extension_name: String,
|
||||
instructions: Option<String>,
|
||||
auth_url: Option<String>,
|
||||
setup_url: Option<String>,
|
||||
},
|
||||
/// Extension authentication completed.
|
||||
AuthCompleted {
|
||||
extension_name: String,
|
||||
success: bool,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Trait for message channels.
|
||||
|
||||
+9
-80
@@ -12,7 +12,6 @@ use axum::{
|
||||
};
|
||||
use secrecy::ExposeSecret;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use subtle::ConstantTimeEq;
|
||||
use tokio::sync::{RwLock, mpsc, oneshot};
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use uuid::Uuid;
|
||||
@@ -174,7 +173,7 @@ async fn webhook_handler(
|
||||
// Validate secret if configured
|
||||
if let Some(ref expected_secret) = state.webhook_secret {
|
||||
match &req.secret {
|
||||
Some(provided) if bool::from(provided.as_bytes().ct_eq(expected_secret.as_bytes())) => {
|
||||
Some(provided) if provided == expected_secret => {
|
||||
// Secret matches, continue
|
||||
}
|
||||
Some(_) => {
|
||||
@@ -357,89 +356,19 @@ impl Channel for HttpChannel {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::body::Body;
|
||||
use axum::http::Request;
|
||||
use secrecy::SecretString;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn test_channel(secret: Option<&str>) -> HttpChannel {
|
||||
HttpChannel::new(HttpConfig {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 0,
|
||||
webhook_secret: secret.map(|s| SecretString::from(s.to_string())),
|
||||
user_id: "http".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_http_channel_requires_secret() {
|
||||
let channel = test_channel(None);
|
||||
let config = HttpConfig {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 0,
|
||||
webhook_secret: None,
|
||||
user_id: "http".to_string(),
|
||||
};
|
||||
|
||||
let channel = HttpChannel::new(config);
|
||||
let result = channel.start().await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn webhook_correct_secret_returns_ok() {
|
||||
let channel = test_channel(Some("test-secret-123"));
|
||||
// Start the channel so the tx sender is populated (otherwise 503).
|
||||
let _stream = channel.start().await.unwrap();
|
||||
let app = channel.routes();
|
||||
|
||||
let body = serde_json::json!({
|
||||
"content": "hello",
|
||||
"secret": "test-secret-123"
|
||||
});
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/webhook")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&body).unwrap()))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn webhook_wrong_secret_returns_unauthorized() {
|
||||
let channel = test_channel(Some("correct-secret"));
|
||||
let _stream = channel.start().await.unwrap();
|
||||
let app = channel.routes();
|
||||
|
||||
let body = serde_json::json!({
|
||||
"content": "hello",
|
||||
"secret": "wrong-secret"
|
||||
});
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/webhook")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&body).unwrap()))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn webhook_missing_secret_returns_unauthorized() {
|
||||
let channel = test_channel(Some("correct-secret"));
|
||||
let _stream = channel.start().await.unwrap();
|
||||
let app = channel.routes();
|
||||
|
||||
let body = serde_json::json!({
|
||||
"content": "hello"
|
||||
});
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/webhook")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&body).unwrap()))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-29
@@ -4,41 +4,24 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures::stream;
|
||||
use tokio::sync::{RwLock, mpsc};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
||||
use crate::error::ChannelError;
|
||||
|
||||
/// Manages multiple input channels and merges their message streams.
|
||||
///
|
||||
/// Includes an injection channel so background tasks (e.g., job monitors) can
|
||||
/// push messages into the agent loop without being a full `Channel` impl.
|
||||
pub struct ChannelManager {
|
||||
channels: Arc<RwLock<HashMap<String, Box<dyn Channel>>>>,
|
||||
inject_tx: mpsc::Sender<IncomingMessage>,
|
||||
/// Taken once in `start_all()` and merged into the stream.
|
||||
inject_rx: tokio::sync::Mutex<Option<mpsc::Receiver<IncomingMessage>>>,
|
||||
}
|
||||
|
||||
impl ChannelManager {
|
||||
/// Create a new channel manager.
|
||||
pub fn new() -> Self {
|
||||
let (inject_tx, inject_rx) = mpsc::channel(64);
|
||||
Self {
|
||||
channels: Arc::new(RwLock::new(HashMap::new())),
|
||||
inject_tx,
|
||||
inject_rx: tokio::sync::Mutex::new(Some(inject_rx)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a clone of the injection sender.
|
||||
///
|
||||
/// Background tasks (like job monitors) use this to push messages into the
|
||||
/// agent loop without being a full `Channel` implementation.
|
||||
pub fn inject_sender(&self) -> mpsc::Sender<IncomingMessage> {
|
||||
self.inject_tx.clone()
|
||||
}
|
||||
|
||||
/// Add a channel to the manager.
|
||||
pub fn add(&mut self, channel: Box<dyn Channel>) {
|
||||
let name = channel.name().to_string();
|
||||
@@ -53,12 +36,9 @@ impl ChannelManager {
|
||||
}
|
||||
|
||||
/// Start all channels and return a merged stream of messages.
|
||||
///
|
||||
/// Also merges the injection channel so background tasks can push messages
|
||||
/// into the same stream.
|
||||
pub async fn start_all(&self) -> Result<MessageStream, ChannelError> {
|
||||
let channels = self.channels.read().await;
|
||||
let mut streams: Vec<MessageStream> = Vec::new();
|
||||
let mut streams = Vec::new();
|
||||
|
||||
for (name, channel) in channels.iter() {
|
||||
match channel.start().await {
|
||||
@@ -80,13 +60,6 @@ impl ChannelManager {
|
||||
});
|
||||
}
|
||||
|
||||
// Take the injection receiver (can only be taken once)
|
||||
if let Some(inject_rx) = self.inject_rx.lock().await.take() {
|
||||
let inject_stream = tokio_stream::wrappers::ReceiverStream::new(inject_rx);
|
||||
streams.push(Box::pin(inject_stream));
|
||||
tracing::debug!("Injection channel merged into message stream");
|
||||
}
|
||||
|
||||
// Merge all streams into one
|
||||
let merged = stream::select_all(streams);
|
||||
Ok(Box::pin(merged))
|
||||
|
||||
+12
-90
@@ -33,41 +33,21 @@ use termimad::MadSkin;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
|
||||
use crate::agent::truncate_for_preview;
|
||||
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
||||
use crate::error::ChannelError;
|
||||
|
||||
/// Max characters for tool result previews in the terminal.
|
||||
const CLI_TOOL_RESULT_MAX: usize = 200;
|
||||
|
||||
/// Max characters for thinking/status messages in the terminal.
|
||||
const CLI_STATUS_MAX: usize = 200;
|
||||
|
||||
/// Slash commands available in the REPL.
|
||||
const SLASH_COMMANDS: &[&str] = &[
|
||||
"/help",
|
||||
"/quit",
|
||||
"/exit",
|
||||
"/debug",
|
||||
"/model",
|
||||
"/undo",
|
||||
"/redo",
|
||||
"/clear",
|
||||
"/compact",
|
||||
"/new",
|
||||
"/interrupt",
|
||||
"/version",
|
||||
"/tools",
|
||||
"/ping",
|
||||
"/job",
|
||||
"/status",
|
||||
"/cancel",
|
||||
"/list",
|
||||
"/heartbeat",
|
||||
"/summarize",
|
||||
"/suggest",
|
||||
"/thread",
|
||||
"/resume",
|
||||
];
|
||||
|
||||
/// Rustyline helper for slash-command tab completion.
|
||||
@@ -184,8 +164,6 @@ pub struct ReplChannel {
|
||||
debug_mode: Arc<AtomicBool>,
|
||||
/// Whether we're currently streaming (chunks have been printed without a trailing newline).
|
||||
is_streaming: Arc<AtomicBool>,
|
||||
/// When true, the one-liner startup banner is suppressed (boot screen shown instead).
|
||||
suppress_banner: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl ReplChannel {
|
||||
@@ -195,7 +173,6 @@ impl ReplChannel {
|
||||
single_message: None,
|
||||
debug_mode: Arc::new(AtomicBool::new(false)),
|
||||
is_streaming: Arc::new(AtomicBool::new(false)),
|
||||
suppress_banner: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,15 +182,9 @@ impl ReplChannel {
|
||||
single_message: Some(message),
|
||||
debug_mode: Arc::new(AtomicBool::new(false)),
|
||||
is_streaming: Arc::new(AtomicBool::new(false)),
|
||||
suppress_banner: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Suppress the one-liner startup banner (boot screen will be shown instead).
|
||||
pub fn suppress_banner(&self) {
|
||||
self.suppress_banner.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn is_debug(&self) -> bool {
|
||||
self.debug_mode.load(Ordering::Relaxed)
|
||||
}
|
||||
@@ -273,12 +244,11 @@ impl Channel for ReplChannel {
|
||||
let (tx, rx) = mpsc::channel(32);
|
||||
let single_message = self.single_message.clone();
|
||||
let debug_mode = Arc::clone(&self.debug_mode);
|
||||
let suppress_banner = Arc::clone(&self.suppress_banner);
|
||||
|
||||
std::thread::spawn(move || {
|
||||
// Single message mode: send it and return
|
||||
if let Some(msg) = single_message {
|
||||
let incoming = IncomingMessage::new("repl", "default", &msg);
|
||||
let incoming = IncomingMessage::new("repl", "user", &msg);
|
||||
let _ = tx.blocking_send(incoming);
|
||||
return;
|
||||
}
|
||||
@@ -308,10 +278,8 @@ impl Channel for ReplChannel {
|
||||
}
|
||||
let _ = rl.load_history(&hist_path);
|
||||
|
||||
if !suppress_banner.load(Ordering::Relaxed) {
|
||||
println!("\x1b[1mIronClaw\x1b[0m /help for commands, /quit to exit");
|
||||
println!();
|
||||
}
|
||||
println!("\x1b[1mIronClaw\x1b[0m /help for commands, /quit to exit");
|
||||
println!();
|
||||
|
||||
loop {
|
||||
let prompt = if debug_mode.load(Ordering::Relaxed) {
|
||||
@@ -327,17 +295,10 @@ impl Channel for ReplChannel {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle local REPL commands (only commands that need
|
||||
// immediate local handling stay here)
|
||||
// Handle local REPL commands
|
||||
match line.to_lowercase().as_str() {
|
||||
"/quit" | "/exit" => {
|
||||
// Forward shutdown command so the agent loop exits even
|
||||
// when other channels (e.g. web gateway) are still active.
|
||||
let msg = IncomingMessage::new("repl", "default", "/quit");
|
||||
let _ = tx.blocking_send(msg);
|
||||
break;
|
||||
}
|
||||
"/help" => {
|
||||
"/quit" | "/exit" => break,
|
||||
"/help" | "/?" => {
|
||||
print_help();
|
||||
continue;
|
||||
}
|
||||
@@ -354,21 +315,21 @@ impl Channel for ReplChannel {
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let msg = IncomingMessage::new("repl", "default", line);
|
||||
let msg = IncomingMessage::new("repl", "user", line);
|
||||
if tx.blocking_send(msg).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(ReadlineError::Interrupted) => {
|
||||
// Ctrl+C: send /interrupt
|
||||
let msg = IncomingMessage::new("repl", "default", "/interrupt");
|
||||
let msg = IncomingMessage::new("repl", "user", "/interrupt");
|
||||
if tx.blocking_send(msg).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(ReadlineError::Eof) => {
|
||||
// Ctrl+D: send /quit so the agent loop runs graceful shutdown
|
||||
let msg = IncomingMessage::new("repl", "default", "/quit");
|
||||
let msg = IncomingMessage::new("repl", "user", "/quit");
|
||||
let _ = tx.blocking_send(msg);
|
||||
break;
|
||||
}
|
||||
@@ -425,8 +386,7 @@ impl Channel for ReplChannel {
|
||||
|
||||
match status {
|
||||
StatusUpdate::Thinking(msg) => {
|
||||
let display = truncate_for_preview(&msg, CLI_STATUS_MAX);
|
||||
eprintln!(" \x1b[90m\u{25CB} {display}\x1b[0m");
|
||||
eprintln!(" \x1b[90m\u{25CB} {msg}\x1b[0m");
|
||||
}
|
||||
StatusUpdate::ToolStarted { name } => {
|
||||
eprintln!(" \x1b[33m\u{25CB} {name}\x1b[0m");
|
||||
@@ -439,8 +399,7 @@ impl Channel for ReplChannel {
|
||||
}
|
||||
}
|
||||
StatusUpdate::ToolResult { name: _, preview } => {
|
||||
let display = truncate_for_preview(&preview, CLI_TOOL_RESULT_MAX);
|
||||
eprintln!(" \x1b[90m{display}\x1b[0m");
|
||||
eprintln!(" \x1b[90m{preview}\x1b[0m");
|
||||
}
|
||||
StatusUpdate::StreamChunk(chunk) => {
|
||||
// Print separator on the false-to-true transition
|
||||
@@ -454,19 +413,9 @@ impl Channel for ReplChannel {
|
||||
print!("{chunk}");
|
||||
let _ = io::stdout().flush();
|
||||
}
|
||||
StatusUpdate::JobStarted {
|
||||
job_id,
|
||||
title,
|
||||
browse_url,
|
||||
} => {
|
||||
eprintln!(
|
||||
" \x1b[36m[job]\x1b[0m {title} \x1b[90m({job_id})\x1b[0m \x1b[4m{browse_url}\x1b[0m"
|
||||
);
|
||||
}
|
||||
StatusUpdate::Status(msg) => {
|
||||
if debug || msg.contains("approval") || msg.contains("Approval") {
|
||||
let display = truncate_for_preview(&msg, CLI_STATUS_MAX);
|
||||
eprintln!(" \x1b[90m{display}\x1b[0m");
|
||||
eprintln!(" \x1b[90m{msg}\x1b[0m");
|
||||
}
|
||||
}
|
||||
StatusUpdate::ApprovalNeeded {
|
||||
@@ -523,33 +472,6 @@ impl Channel for ReplChannel {
|
||||
eprintln!(" {bot_border}");
|
||||
eprintln!();
|
||||
}
|
||||
StatusUpdate::AuthRequired {
|
||||
extension_name,
|
||||
instructions,
|
||||
setup_url,
|
||||
..
|
||||
} => {
|
||||
eprintln!();
|
||||
eprintln!("\x1b[33m Authentication required for {extension_name}\x1b[0m");
|
||||
if let Some(ref instr) = instructions {
|
||||
eprintln!(" {instr}");
|
||||
}
|
||||
if let Some(ref url) = setup_url {
|
||||
eprintln!(" \x1b[4m{url}\x1b[0m");
|
||||
}
|
||||
eprintln!();
|
||||
}
|
||||
StatusUpdate::AuthCompleted {
|
||||
extension_name,
|
||||
success,
|
||||
message,
|
||||
} => {
|
||||
if success {
|
||||
eprintln!("\x1b[32m {extension_name}: {message}\x1b[0m");
|
||||
} else {
|
||||
eprintln!("\x1b[31m {extension_name}: {message}\x1b[0m");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+48
-109
@@ -1,125 +1,68 @@
|
||||
//! Known WASM channels that can be installed from build artifacts.
|
||||
//!
|
||||
//! Instead of embedding WASM binaries in the host binary via include_bytes!,
|
||||
//! channels are compiled separately and installed from their build output
|
||||
//! directories during onboarding.
|
||||
//!
|
||||
//! Channel source layout:
|
||||
//! channels-src/<name>/
|
||||
//! target/wasm32-wasip2/release/<name>_channel.wasm
|
||||
//! <name>.capabilities.json
|
||||
//! Bundled WASM channels that can be installed locally.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
|
||||
use tokio::fs;
|
||||
|
||||
/// Compile-time project root, used to locate channels-src/ in dev builds.
|
||||
const CARGO_MANIFEST_DIR: &str = env!("CARGO_MANIFEST_DIR");
|
||||
|
||||
/// Known channel names and their crate names (for locating build artifacts).
|
||||
const KNOWN_CHANNELS: &[(&str, &str)] = &[
|
||||
("telegram", "telegram_channel"),
|
||||
("slack", "slack_channel"),
|
||||
("whatsapp", "whatsapp_channel"),
|
||||
];
|
||||
|
||||
/// Names of known channels that can be installed.
|
||||
pub fn bundled_channel_names() -> Vec<&'static str> {
|
||||
KNOWN_CHANNELS.iter().map(|(name, _)| *name).collect()
|
||||
#[derive(Clone, Copy)]
|
||||
struct BundledChannel {
|
||||
name: &'static str,
|
||||
wasm: &'static [u8],
|
||||
capabilities: &'static [u8],
|
||||
}
|
||||
|
||||
/// Resolve the channels source directory.
|
||||
///
|
||||
/// Checks (in order):
|
||||
/// 1. `IRONCLAW_CHANNELS_SRC` env var
|
||||
/// 2. `<CARGO_MANIFEST_DIR>/channels-src/` (dev builds)
|
||||
fn channels_src_dir() -> PathBuf {
|
||||
if let Ok(dir) = std::env::var("IRONCLAW_CHANNELS_SRC") {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
PathBuf::from(CARGO_MANIFEST_DIR).join("channels-src")
|
||||
/// Names of bundled channels shipped with IronClaw.
|
||||
pub fn bundled_channel_names() -> &'static [&'static str] {
|
||||
&["telegram"]
|
||||
}
|
||||
|
||||
/// Locate the build artifacts for a channel.
|
||||
///
|
||||
/// Returns (wasm_path, capabilities_path) or an error if files are missing.
|
||||
fn locate_channel_artifacts(name: &str) -> Result<(PathBuf, PathBuf), String> {
|
||||
let (_, crate_name) = KNOWN_CHANNELS
|
||||
.iter()
|
||||
.find(|(n, _)| *n == name)
|
||||
.ok_or_else(|| format!("Unknown channel '{}'", name))?;
|
||||
|
||||
let src_dir = channels_src_dir();
|
||||
let channel_dir = src_dir.join(name);
|
||||
|
||||
let wasm_path = channel_dir
|
||||
.join("target/wasm32-wasip2/release")
|
||||
.join(format!("{}.wasm", crate_name));
|
||||
|
||||
let caps_path = channel_dir.join(format!("{}.capabilities.json", name));
|
||||
|
||||
if !wasm_path.exists() {
|
||||
return Err(format!(
|
||||
"Channel '{}' WASM not found at {}. Build it first:\n \
|
||||
cd {} && cargo build --target wasm32-wasip2 --release",
|
||||
name,
|
||||
wasm_path.display(),
|
||||
channel_dir.display()
|
||||
));
|
||||
}
|
||||
|
||||
if !caps_path.exists() {
|
||||
return Err(format!(
|
||||
"Channel '{}' capabilities not found at {}",
|
||||
name,
|
||||
caps_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
Ok((wasm_path, caps_path))
|
||||
}
|
||||
|
||||
/// Install a channel from build artifacts into the channels directory.
|
||||
/// Install a bundled channel into a channels directory.
|
||||
pub async fn install_bundled_channel(
|
||||
name: &str,
|
||||
target_dir: &Path,
|
||||
force: bool,
|
||||
) -> Result<(), String> {
|
||||
let (wasm_src, caps_src) = locate_channel_artifacts(name)?;
|
||||
let channel = bundled_channel(name)
|
||||
.ok_or_else(|| format!("Unknown bundled channel '{}'", name.to_lowercase()))?;
|
||||
|
||||
fs::create_dir_all(target_dir)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create channels directory: {}", e))?;
|
||||
|
||||
let wasm_dst = target_dir.join(format!("{}.wasm", name));
|
||||
let caps_dst = target_dir.join(format!("{}.capabilities.json", name));
|
||||
let wasm_path = target_dir.join(format!("{}.wasm", channel.name));
|
||||
let caps_path = target_dir.join(format!("{}.capabilities.json", channel.name));
|
||||
|
||||
let has_existing = wasm_dst.exists() || caps_dst.exists();
|
||||
let has_existing = wasm_path.exists() || caps_path.exists();
|
||||
if has_existing && !force {
|
||||
return Err(format!(
|
||||
"Channel '{}' already exists at {}",
|
||||
name,
|
||||
channel.name,
|
||||
target_dir.display()
|
||||
));
|
||||
}
|
||||
|
||||
fs::copy(&wasm_src, &wasm_dst)
|
||||
fs::write(&wasm_path, channel.wasm)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to copy {}: {}", wasm_src.display(), e))?;
|
||||
fs::copy(&caps_src, &caps_dst)
|
||||
.map_err(|e| format!("Failed to write {}: {}", wasm_path.display(), e))?;
|
||||
fs::write(&caps_path, channel.capabilities)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to copy {}: {}", caps_src.display(), e))?;
|
||||
.map_err(|e| format!("Failed to write {}: {}", caps_path.display(), e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check which known channels have build artifacts available.
|
||||
pub fn available_channel_names() -> Vec<&'static str> {
|
||||
KNOWN_CHANNELS
|
||||
.iter()
|
||||
.filter(|(name, _)| locate_channel_artifacts(name).is_ok())
|
||||
.map(|(name, _)| *name)
|
||||
.collect()
|
||||
fn bundled_channel(name: &str) -> Option<BundledChannel> {
|
||||
if name.eq_ignore_ascii_case("telegram") {
|
||||
Some(BundledChannel {
|
||||
name: "telegram",
|
||||
wasm: include_bytes!("../../../channels-src/telegram/telegram.wasm"),
|
||||
capabilities: include_bytes!(
|
||||
"../../../channels-src/telegram/telegram.capabilities.json"
|
||||
),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -130,35 +73,31 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_known_channels_includes_all_three() {
|
||||
let names = bundled_channel_names();
|
||||
assert!(names.contains(&"telegram"));
|
||||
assert!(names.contains(&"slack"));
|
||||
assert!(names.contains(&"whatsapp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channels_src_dir_default() {
|
||||
let dir = channels_src_dir();
|
||||
assert!(dir.ends_with("channels-src"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_locate_unknown_channel_errors() {
|
||||
assert!(locate_channel_artifacts("nonexistent").is_err());
|
||||
fn test_bundled_channel_names_contains_telegram() {
|
||||
assert!(bundled_channel_names().contains(&"telegram"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_install_refuses_overwrite_without_force() {
|
||||
async fn test_install_bundled_channel_writes_files() {
|
||||
let dir = tempdir().unwrap();
|
||||
|
||||
install_bundled_channel("telegram", dir.path(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(dir.path().join("telegram.wasm").exists());
|
||||
assert!(dir.path().join("telegram.capabilities.json").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_install_bundled_channel_refuses_overwrite_without_force() {
|
||||
let dir = tempdir().unwrap();
|
||||
let wasm_path = dir.path().join("telegram.wasm");
|
||||
fs::write(&wasm_path, b"custom").await.unwrap();
|
||||
|
||||
let result = install_bundled_channel("telegram", dir.path(), false).await;
|
||||
// Either fails because artifacts missing OR because file exists
|
||||
assert!(result.is_err());
|
||||
|
||||
// Original file should be untouched
|
||||
let existing = fs::read(&wasm_path).await.unwrap();
|
||||
assert_eq!(existing, b"custom");
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user