Compare commits

..
Author SHA1 Message Date
Henry ParkandClaude Opus 4.6 6e972863e7 fix(ci): prevent staging-ci tag failure and chained PR auto-close
- Use fetch-depth: 0 in update-tag to ensure current_head SHA is available
- Only merge promotion PRs targeting main; leave chained PRs open to
  prevent delete_branch_on_merge from auto-closing downstream PRs

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-10 13:35:10 -07:00
236 changed files with 4776 additions and 25301 deletions
+1 -1
View File
@@ -64,7 +64,7 @@ If the event needs custom UI (cards, badges, etc.), add styles. Follow the exist
Identify where in the backend this event should be triggered. Common locations:
- `src/agent/agent_loop.rs` - During message processing or tool execution
- `src/worker/job.rs` - During job execution
- `src/agent/worker.rs` - During job execution
- `src/agent/heartbeat.rs` - During periodic execution
Use the existing pattern:
-25
View File
@@ -98,19 +98,6 @@ TELEGRAM_BOT_TOKEN=...
HTTP_HOST=0.0.0.0
HTTP_PORT=8080
HTTP_WEBHOOK_SECRET=your-webhook-secret
# Webhook authentication uses HMAC-SHA256 signature verification.
# Callers must send an X-IronClaw-Signature header with format: sha256=<hex_digest>
# where the digest is HMAC-SHA256(HTTP_WEBHOOK_SECRET, raw_request_body) in lowercase hex.
#
# Example (bash):
# BODY='{"content":"hello"}'
# SIG=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$HTTP_WEBHOOK_SECRET" | cut -d' ' -f2)
# curl -X POST http://localhost:8080/webhook \
# -H "Content-Type: application/json" \
# -H "X-IronClaw-Signature: sha256=$SIG" \
# -d "$BODY"
#
# DEPRECATED: Passing "secret" in the JSON body still works but will be removed in a future release.
# Signal Channel (optional, requires signal-cli daemon --http)
# SIGNAL_HTTP_URL=http://127.0.0.1:8080
@@ -151,18 +138,6 @@ HEARTBEAT_NOTIFY_USER=default
# MEMORY_HYGIENE_CONVERSATION_RETENTION_DAYS=7 # delete conversations/ docs older than this many days
# MEMORY_HYGIENE_CADENCE_HOURS=12 # minimum hours between cleanup passes
# Docker Sandbox
# SANDBOX_ENABLED=true
# SANDBOX_POLICY=readonly # readonly, workspace_write, or full_access
# SANDBOX_ALLOW_FULL_ACCESS=false # REQUIRED second opt-in for full_access policy.
# # FullAccess bypasses Docker entirely and runs
# # commands directly on the host. Without this
# # set to "true", full_access is downgraded to
# # workspace_write.
# SANDBOX_IMAGE=ironclaw-worker:latest
# SANDBOX_TIMEOUT_SECS=120
# SANDBOX_MEMORY_LIMIT_MB=2048
# Safety settings
SAFETY_MAX_OUTPUT_LENGTH=100000
SAFETY_INJECTION_CHECK_ENABLED=true
-50
View File
@@ -1,50 +0,0 @@
## Summary
<!-- 2-5 bullet points: what changed and why -->
-
## Change Type
<!-- Check one -->
- [ ] Bug fix
- [ ] New feature
- [ ] Refactor
- [ ] Documentation
- [ ] CI/Infrastructure
- [ ] Security
- [ ] Dependencies
## Linked Issue
<!-- Closes #N, or "None" -->
## Validation
<!-- How did you verify this works? -->
- [ ] `cargo fmt`
- [ ] `cargo clippy --all --benches --tests --examples --all-features`
- [ ] Relevant tests pass: <!-- list specific tests -->
- [ ] Manual testing: <!-- describe what you tested -->
## Security Impact
<!-- Does this change affect: permissions, network calls, secrets, file access, tool execution, sandbox policy? If yes, describe. If no, write "None". -->
## Database Impact
<!-- Does this add/modify migrations, change schema, or affect both PostgreSQL and libSQL? If yes, describe. If no, write "None". -->
## Blast Radius
<!-- What subsystems does this touch? What could break? -->
## Rollback Plan
<!-- How to revert if this causes problems? For Track C changes, this is mandatory. -->
---
**Review track**: <!-- A (docs/tests/chore) | B (feature/refactor) | C (security/runtime/DB/CI) -->
+23 -32
View File
@@ -29,36 +29,18 @@ jobs:
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
allowed_bots: "ironclaw-ci[bot]"
claude_args: "--max-turns 50 --model claude-haiku-4-5-20251001 --allowedTools 'Read,Glob,Grep,Agent,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh search:*),Bash(git blame:*),Bash(git log:*),Bash(git diff:*)'"
claude_args: "--max-turns 50 --model claude-haiku-4-5-20251001 --allowedTools 'Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh search:*),Bash(git blame:*),Bash(git log:*),Bash(git diff:*)'"
prompt: |
Code review this pull request. Follow these steps precisely:
1. Find relevant CLAUDE.md files: the root CLAUDE.md and any CLAUDE.md files
in directories whose files this PR modifies. Use Glob to find them, then Read
to load their contents.
1. Use a Haiku agent to find relevant CLAUDE.md files: the root CLAUDE.md
and any CLAUDE.md files in directories whose files this PR modifies.
2. Get the PR diff with `gh pr diff` and summarize the change.
2. Use a Haiku agent to summarize the PR change (use `gh pr diff`).
3. Launch 4 parallel agents to review the change independently. Each agent should
read the PR diff with `gh pr diff` and the full source files for changed
code (using Read), then return a list of issues. Each agent MUST score its
own findings inline using the severity and confidence rubric below.
Severity levels:
- CRITICAL: security vulns, panics in prod (.unwrap/.expect), data exfiltration, race conditions
- HIGH: logic bugs, missing error handling, breaking API/schema changes
- MEDIUM: missing tests, unnecessary complexity, performance issues
- LOW: documentation gaps, naming suggestions
Confidence scoring (0-100):
0: False positive, doesn't stand up to scrutiny, or pre-existing issue.
25: Might be real, but may be false positive. Stylistic issues not in CLAUDE.md.
50: Real issue but nitpick or rare in practice. Not very important.
75: Verified real issue, will be hit in practice. Directly impacts functionality
or explicitly mentioned in CLAUDE.md.
100: Certain, confirmed, will happen frequently. Evidence directly confirms.
Each agent returns findings as: [SEVERITY:CONFIDENCE] <brief description>
code, then return a list of issues found:
Agent 1 — Security & Safety
Check for: command injection, path traversal, SSRF, XSS, auth bypass,
@@ -81,9 +63,22 @@ jobs:
timeouts, resource leaks (file handles, connections), large allocations
in hot paths.
4. Consolidate all agent findings and post exactly one comment on the PR
using `gh pr comment` with this format. If no issues were found,
post "No issues found." instead:
4. For each issue found, launch a parallel Haiku agent to:
a. Assign a severity:
- CRITICAL: security vulns, panics in prod (.unwrap/.expect), data exfiltration, race conditions
- HIGH: logic bugs, missing error handling, breaking API/schema changes
- MEDIUM: missing tests, unnecessary complexity, performance issues
- LOW: documentation gaps, naming suggestions
b. Score confidence 0-100 (give this rubric verbatim):
0: False positive, doesn't stand up to scrutiny, or pre-existing issue.
25: Might be real, but may be false positive. Stylistic issues not in CLAUDE.md.
50: Real issue but nitpick or rare in practice. Not very important.
75: Verified real issue, will be hit in practice. Directly impacts functionality
or explicitly mentioned in CLAUDE.md.
100: Certain, confirmed, will happen frequently. Evidence directly confirms.
5. Post a single comment on the PR using `gh pr comment` with this format.
If no issues were found, post "No issues found." instead:
### Code review
@@ -98,12 +93,8 @@ jobs:
You MUST use the full git SHA in links (not HEAD or branch name).
Provide 1 line of context before and after each linked range.
IMPORTANT rules:
- Only YOU (the main process) may call `gh pr comment`. Agents must return
their findings to you — they must NOT post comments themselves.
- You MUST post exactly one `gh pr comment` before finishing, even if agents
fail or return empty results. If review is incomplete, post "No issues found."
- Use Read/Glob for file access, `gh` for GitHub interactions, not web fetch
Notes:
- Use `gh` for all GitHub interactions, not web fetch
- Do NOT check build signal or attempt to build/test the code
- Ignore pre-existing issues not introduced by this PR
- Ignore issues a linter/compiler would catch (formatting, imports, types)
+6 -15
View File
@@ -16,15 +16,6 @@ jobs:
- name: Check formatting
run: cargo fmt --all -- --check
deny-check:
name: cargo-deny
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Run cargo deny
uses: EmbarkStudios/cargo-deny-action@v2
clippy:
name: Clippy (${{ matrix.name }})
runs-on: ubuntu-latest
@@ -80,18 +71,18 @@ jobs:
# Roll-up job for branch protection
code-style:
name: Code Style (fmt + clippy + deny)
name: Code Style (fmt + clippy)
runs-on: ubuntu-latest
if: always()
needs: [format, clippy, clippy-windows, deny-check]
needs: [format, clippy, clippy-windows]
steps:
- run: |
if [[ "${{ needs.format.result }}" != "success" || "${{ needs.clippy.result }}" != "success" || "${{ needs.deny-check.result }}" != "success" ]]; then
if [[ "${{ needs.format.result }}" != "success" || "${{ needs.clippy.result }}" != "success" ]]; then
echo "One or more jobs failed"
exit 1
fi
# clippy-windows only runs on main PRs, so skipped is acceptable but failure is not
if [[ "${{ needs.clippy-windows.result }}" != "success" && "${{ needs.clippy-windows.result }}" != "skipped" ]]; then
echo "Windows clippy failed: ${{ needs.clippy-windows.result }}"
# clippy-windows only runs on main PRs, so skip/success are both acceptable
if [[ "${{ needs.clippy-windows.result }}" == "failure" ]]; then
echo "Windows clippy failed"
exit 1
fi
+31 -89
View File
@@ -144,8 +144,6 @@ jobs:
- name: Patch manifests with WASM checksums
if: ${{ needs.plan.outputs.publishing == 'true' }}
shell: bash
env:
RELEASE_TAG: ${{ github.ref_name }}
run: |
CHECKSUMS="target/distrib/checksums.txt"
if [ ! -f "$CHECKSUMS" ]; then
@@ -156,25 +154,14 @@ jobs:
while IFS= read -r line; do
sha256=$(echo "$line" | awk '{print $1}')
filename=$(echo "$line" | awk '{print $2}')
# Skip non-WASM entries (e.g. binary tarballs from cargo-dist)
case "$filename" in *-wasm32-wasip2.tar.gz) ;; *) continue ;; esac
# Parse kind-prefixed filename: "tool-slack-0.2.1-wasm32-wasip2.tar.gz"
# → kind=tool, name=slack
kind=$(echo "$filename" | cut -d'-' -f1)
if [ "$kind" != "tool" ] && [ "$kind" != "channel" ]; then
echo "::warning::Skipping '$filename': unrecognized kind prefix '$kind'"
continue
fi
name=$(echo "$filename" | sed "s/^${kind}-//" | sed 's/-[0-9].*-wasm32-wasip2\.tar\.gz$//')
url="https://github.com/nearai/ironclaw/releases/download/${RELEASE_TAG}/${filename}"
name=$(echo "$filename" | sed 's/-wasm32-wasip2\.tar\.gz$//')
manifest="registry/${kind}s/${name}.json"
if [ -f "$manifest" ]; then
jq --arg sha "$sha256" --arg url "$url" \
'.artifacts["wasm32-wasip2"].sha256 = $sha | .artifacts["wasm32-wasip2"].url = $url' \
"$manifest" > "${manifest}.tmp" && mv "${manifest}.tmp" "$manifest"
echo "Patched $manifest with sha256=$sha256 url=$url"
fi
for manifest in registry/tools/${name}.json registry/channels/${name}.json; do
if [ -f "$manifest" ]; then
jq --arg sha "$sha256" '.artifacts["wasm32-wasip2"].sha256 = $sha' "$manifest" > "${manifest}.tmp" && mv "${manifest}.tmp" "$manifest"
echo "Patched $manifest with sha256=$sha256"
fi
done
done < "$CHECKSUMS"
- name: Install dependencies
run: |
@@ -281,46 +268,21 @@ jobs:
for manifest in registry/tools/*.json registry/channels/*.json; do
[ -f "$manifest" ] || continue
# file_stem: JSON filename without extension (e.g. "slack" for slack.json).
file_stem=$(basename "$manifest" .json)
# kind: "tool" or "channel" — used as bundle filename prefix to avoid
# collisions when a tool and channel share the same file_stem (e.g. slack).
kind=$(jq -r '.kind' "$manifest")
if [ "$kind" != "tool" ] && [ "$kind" != "channel" ]; then
echo "::error::Manifest '$manifest' has invalid or missing .kind ('$kind'); expected 'tool' or 'channel'"
exit 1
fi
# ext_name: the manifest's .name field (e.g. "slack-tool").
# Used for file names *inside* the archive — the installer extracts by manifest.name.
ext_name=$(jq -r '.name' "$manifest")
name=$(jq -r '.name' "$manifest")
source_dir=$(jq -r '.source.dir' "$manifest")
caps_file=$(jq -r '.source.capabilities' "$manifest")
crate_name=$(jq -r '.source.crate_name' "$manifest")
ext_version=$(jq -r '.version // ""' "$manifest")
if [ ! -d "$source_dir" ]; then
echo "::warning::Source dir '$source_dir' not found for '$file_stem', skipping"
echo "::warning::Source dir '$source_dir' not found for '$name', skipping"
continue
fi
# Skip rebuild if this exact version was already built and checksummed.
# Checks that (1) the manifest already has a sha256, and (2) the version
# embedded in the existing artifact URL matches the current manifest version.
# This ensures stable checksums: only rebuild when the source version changes.
existing_sha=$(jq -r '.artifacts["wasm32-wasip2"].sha256 // ""' "$manifest")
existing_url=$(jq -r '.artifacts["wasm32-wasip2"].url // ""' "$manifest")
url_version=$(echo "$existing_url" | sed -n 's/.*-\([0-9].*\)-wasm32-wasip2\.tar\.gz$/\1/p')
if [[ -n "$ext_version" && "$url_version" == "$ext_version" && -n "$existing_sha" ]]; then
echo "=== Skipping $file_stem v$ext_version — already checksummed at $existing_url ==="
continue
fi
echo "=== Building $file_stem ($ext_name) v$ext_version from $source_dir ==="
echo "=== Building $name from $source_dir ==="
# Build WASM component
cargo component build --release --manifest-path "$source_dir/Cargo.toml" || {
echo "::warning::Build failed for '$file_stem', skipping"
echo "::warning::Build failed for '$name', skipping"
continue
}
@@ -336,37 +298,30 @@ jobs:
done
if [ -z "$wasm_path" ]; then
echo "::warning::No WASM output found for '$file_stem', skipping"
echo "::warning::No WASM output found for '$name', skipping"
continue
fi
# Archive contents use ext_name (manifest .name) — the installer extracts
# files by manifest.name, so these must match even when file_stem differs.
cp "$wasm_path" "target/wasm-bundles/${ext_name}.wasm"
# Copy files with standardized names for the archive
cp "$wasm_path" "target/wasm-bundles/${name}.wasm"
caps_path="$source_dir/$caps_file"
if [ -f "$caps_path" ]; then
cp "$caps_path" "target/wasm-bundles/${ext_name}.capabilities.json"
cp "$caps_path" "target/wasm-bundles/${name}.capabilities.json"
else
echo "::warning::No capabilities file at '$caps_path' for '$file_stem'"
echo "::warning::No capabilities file at '$caps_path' for '$name'"
fi
# Bundle filename uses kind+file_stem to avoid collisions when a tool
# and channel share the same name (e.g. tool-slack vs channel-slack).
bundle_name="${kind}-${file_stem}-${ext_version}-wasm32-wasip2.tar.gz"
bundle="target/wasm-bundles/${bundle_name}"
(cd target/wasm-bundles && if [ -f "${ext_name}.capabilities.json" ]; then
tar czf "${bundle_name}" "${ext_name}.wasm" "${ext_name}.capabilities.json"
else
tar czf "${bundle_name}" "${ext_name}.wasm"
fi)
# Create tar.gz bundle
bundle="target/wasm-bundles/${name}-wasm32-wasip2.tar.gz"
(cd target/wasm-bundles && if [ -f "${name}.capabilities.json" ]; then tar czf "${name}-wasm32-wasip2.tar.gz" "${name}.wasm" "${name}.capabilities.json"; else tar czf "${name}-wasm32-wasip2.tar.gz" "${name}.wasm"; fi)
# Compute SHA256
sha256=$(sha256sum "$bundle" | cut -d' ' -f1)
echo "$sha256 ${bundle_name}" >> target/wasm-bundles/checksums.txt
echo "$sha256 ${name}-wasm32-wasip2.tar.gz" >> target/wasm-bundles/checksums.txt
# Clean up intermediate files
rm -f "target/wasm-bundles/${ext_name}.wasm" "target/wasm-bundles/${ext_name}.capabilities.json"
rm -f "target/wasm-bundles/${name}.wasm" "target/wasm-bundles/${name}.capabilities.json"
echo " -> $bundle ($sha256)"
done
@@ -472,10 +427,8 @@ jobs:
with:
name: artifacts-wasm-extensions
path: target/wasm-bundles/
- name: Patch manifests with SHA256 and version-pinned URL
- name: Patch manifests with SHA256
shell: bash
env:
RELEASE_TAG: ${{ github.ref_name }}
run: |
CHECKSUMS="target/wasm-bundles/checksums.txt"
if [ ! -f "$CHECKSUMS" ]; then
@@ -486,25 +439,14 @@ jobs:
while IFS= read -r line; do
sha256=$(echo "$line" | awk '{print $1}')
filename=$(echo "$line" | awk '{print $2}')
# Skip non-WASM entries (defensive — this checksums.txt should only have WASM)
case "$filename" in *-wasm32-wasip2.tar.gz) ;; *) continue ;; esac
# Parse kind-prefixed filename: "tool-slack-0.2.1-wasm32-wasip2.tar.gz"
# → kind=tool, name=slack
kind=$(echo "$filename" | cut -d'-' -f1)
if [ "$kind" != "tool" ] && [ "$kind" != "channel" ]; then
echo "::warning::Skipping '$filename': unrecognized kind prefix '$kind'"
continue
fi
name=$(echo "$filename" | sed "s/^${kind}-//" | sed 's/-[0-9].*-wasm32-wasip2\.tar\.gz$//')
url="https://github.com/nearai/ironclaw/releases/download/${RELEASE_TAG}/${filename}"
name=$(echo "$filename" | sed 's/-wasm32-wasip2\.tar\.gz$//')
manifest="registry/${kind}s/${name}.json"
if [ -f "$manifest" ]; then
jq --arg sha "$sha256" --arg url "$url" \
'.artifacts["wasm32-wasip2"].sha256 = $sha | .artifacts["wasm32-wasip2"].url = $url' \
"$manifest" > "${manifest}.tmp" && mv "${manifest}.tmp" "$manifest"
echo "Patched $manifest with sha256=$sha256 url=$url"
fi
for manifest in registry/tools/${name}.json registry/channels/${name}.json; do
if [ -f "$manifest" ]; then
jq --arg sha "$sha256" '.artifacts["wasm32-wasip2"].sha256 = $sha' "$manifest" > "${manifest}.tmp" && mv "${manifest}.tmp" "$manifest"
echo "Patched $manifest with sha256=$sha256"
fi
done
done < "$CHECKSUMS"
- name: Create PR with updated manifests
run: |
@@ -519,8 +461,8 @@ jobs:
git commit -m "chore: update WASM artifact SHA256 checksums [skip ci]"
git push origin "$BRANCH"
gh pr create \
--title "chore: update WASM artifact checksums and version-pinned URLs" \
--body "Auto-generated by release CI. Updates SHA256 checksums and version-pinned artifact URLs in registry manifests to match the released WASM artifacts. Only extensions whose version changed since the last release are included." \
--title "chore: update WASM artifact SHA256 checksums" \
--body "Auto-generated by release CI. Updates SHA256 checksums in registry manifests to match the released WASM artifacts." \
--base main \
--head "$BRANCH"
fi
+8 -8
View File
@@ -42,8 +42,8 @@ jobs:
telegram-tests:
name: Telegram Channel Tests
if: >
github.event_name != 'pull_request' ||
github.base_ref != 'staging'
github.event_name == 'push' ||
(github.event_name == 'pull_request' && github.base_ref != 'staging')
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -57,8 +57,8 @@ jobs:
windows-build:
name: Windows Build (${{ matrix.name }})
if: >
github.event_name != 'pull_request' ||
github.base_ref != 'staging'
github.event_name == 'push' ||
(github.event_name == 'pull_request' && github.base_ref != 'staging')
runs-on: windows-latest
strategy:
fail-fast: false
@@ -84,8 +84,8 @@ jobs:
wasm-wit-compat:
name: WASM WIT Compatibility
if: >
github.event_name != 'pull_request' ||
github.base_ref != 'staging'
github.event_name == 'push' ||
(github.event_name == 'pull_request' && github.base_ref != 'staging')
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -107,8 +107,8 @@ jobs:
docker-build:
name: Docker Build
if: >
github.event_name != 'pull_request' ||
github.base_ref != 'staging'
github.event_name == 'push' ||
(github.event_name == 'pull_request' && github.base_ref != 'staging')
runs-on: ubuntu-latest
steps:
- name: Checkout repository
-1
View File
@@ -28,4 +28,3 @@ trace_*.json
# Local Claude Code settings (machine-specific, should not be committed)
.claude/settings.local.json
.worktrees/
+4 -38
View File
@@ -64,13 +64,6 @@ src/
│ ├── repl.rs # Simple REPL (for testing)
│ ├── web/ # Web gateway (browser UI) — see src/channels/web/CLAUDE.md
│ └── wasm/ # WASM channel runtime
│ ├── mod.rs
│ ├── bundled.rs # Bundled channel discovery
│ ├── capabilities.rs # Channel-specific capabilities (HTTP endpoint, emit rate)
│ ├── error.rs # WASM channel error types
│ ├── runtime.rs # WASM channel execution runtime
│ ├── setup.rs # WasmChannelSetup, setup_wasm_channels(), inject_channel_credentials()
│ └── wrapper.rs # Channel trait wrapper for WASM modules
├── cli/ # CLI subcommands (clap)
│ ├── mod.rs # Cli struct, Command enum (run/onboard/config/tool/registry/mcp/memory/pairing/service/doctor/status/completion)
@@ -83,13 +76,7 @@ src/
├── hooks/ # Lifecycle hooks (6 points: BeforeInbound, BeforeToolCall, BeforeOutbound, OnSessionStart, OnSessionEnd, TransformResponse)
├── tunnel/ # Tunnel abstraction for public internet exposure
│ ├── mod.rs # Tunnel trait, TunnelProviderConfig, create_tunnel(), start_managed_tunnel()
│ ├── cloudflare.rs # CloudflareTunnel (cloudflared binary)
│ ├── ngrok.rs # NgrokTunnel
│ ├── tailscale.rs # TailscaleTunnel (serve/funnel modes)
│ ├── custom.rs # CustomTunnel (arbitrary command with {host}/{port})
│ └── none.rs # NoneTunnel (local-only, no exposure)
├── tunnel/ # Tunnel abstraction (cloudflare, ngrok, tailscale, custom, none)
├── observability/ # Pluggable event/metric recording (noop, log, multi)
@@ -99,8 +86,7 @@ src/
│ └── job_manager.rs # Container lifecycle (create, stop, cleanup)
├── worker/ # Runs inside Docker containers
│ ├── container.rs # Container worker runtime (ContainerDelegate + shared agentic loop)
│ ├── job.rs # Background job worker (JobDelegate + shared agentic loop)
│ ├── runtime.rs # Worker execution loop (tool calls, LLM)
│ ├── claude_bridge.rs # Claude Code bridge (spawns claude CLI)
│ └── proxy_llm.rs # LlmProvider that proxies through orchestrator
@@ -119,26 +105,8 @@ src/
│ ├── rate_limiter.rs # Shared sliding-window rate limiter
│ ├── builtin/ # Built-in tools (echo, time, json, http, web_fetch, file, shell, memory, message, job, routine, extension_tools, skill_tools, secrets_tools)
│ ├── builder/ # Dynamic tool building
│ ├── core.rs # BuildRequirement, SoftwareType, Language
│ ├── templates.rs # Project scaffolding
│ │ ├── testing.rs # Test harness integration
│ │ └── validation.rs # WASM validation
│ ├── mcp/ # Model Context Protocol
│ │ ├── client.rs # MCP client over HTTP
│ │ ├── factory.rs # create_client_from_config() — transport dispatch factory
│ │ ├── protocol.rs # JSON-RPC types
│ │ └── session.rs # MCP session management (Mcp-Session-Id header, per-server state)
│ └── wasm/ # Full WASM sandbox (wasmtime)
│ ├── runtime.rs # Module compilation and caching
│ ├── wrapper.rs # Tool trait wrapper for WASM modules
│ ├── host.rs # Host functions (logging, time, workspace)
│ ├── limits.rs # Fuel metering and memory limiting
│ ├── allowlist.rs # Network endpoint allowlisting
│ ├── credential_injector.rs # Safe credential injection
│ ├── loader.rs # WASM tool discovery from filesystem
│ ├── rate_limiter.rs # Per-tool rate limiting
│ ├── error.rs # WASM-specific error types
│ └── storage.rs # Linear memory persistence
│ ├── mcp/ # Model Context Protocol client
└── wasm/ # Full WASM sandbox (wasmtime) — runtime, host functions, fuel metering, allowlist, credential injection
├── db/ # Dual-backend persistence (PostgreSQL + libSQL) — see src/db/CLAUDE.md
@@ -176,8 +144,6 @@ Dual-backend: PostgreSQL + libSQL/Turso. **All new persistence features must sup
When modifying a module with a spec, read the spec first. Code follows spec; spec is the tiebreaker.
**Module-owned initialization:** Module-specific initialization logic (database connection, transport creation, channel setup) must live in the owning module as a public factory function — not in `main.rs` or `app.rs`. These entry-point files orchestrate calls to module factories. Feature-flag branching (`#[cfg(feature = ...)]`) must be confined to the module that owns the abstraction.
| Module | Spec |
|--------|------|
| `src/agent/` | `src/agent/CLAUDE.md` |
-49
View File
@@ -1,34 +1,5 @@
# Contributing
## Getting Started
```bash
git clone https://github.com/nearai/ironclaw.git
cd ironclaw
./scripts/dev-setup.sh
```
This installs the Rust toolchain, WASM targets, git hooks, and runs initial checks.
## Development Workflow
```bash
cargo fmt # format
cargo clippy --all --benches --tests --examples --all-features # lint (zero warnings)
cargo test # unit tests
cargo test --features integration # + PostgreSQL tests
```
## Code Style
- Zero clippy warnings policy
- No `.unwrap()` or `.expect()` in production code (tests are fine)
- Use `thiserror` for error types, map errors with context
- Prefer `crate::` for cross-module imports
- Comments for non-obvious logic only
See `CLAUDE.md` for full style guidelines.
## Feature Parity Requirement
When your change affects a tracked capability, update `FEATURE_PARITY.md` in the same branch.
@@ -38,23 +9,3 @@ When your change affects a tracked capability, update `FEATURE_PARITY.md` in the
1. Review the relevant parity rows in `FEATURE_PARITY.md`.
2. Update status/notes if behavior changed.
3. Include the `FEATURE_PARITY.md` diff in your commit when applicable.
## Review Tracks
All PRs follow a risk-based review process:
| Track | Scope | Requirements |
|-------|-------|-------------|
| **A** | Docs, tests, chore, dependency bumps | 1 approval + CI green |
| **B** | Features, refactors, new tools/channels | 1 approval + CI green + test evidence |
| **C** | Security (`src/safety/`, `src/secrets/`), runtime (`src/agent/`, `src/worker/`), database schema, CI workflows | 2 approvals + rollback plan documented |
Select the appropriate track in the PR template based on what your changes touch.
## Database Changes
IronClaw uses dual-backend persistence (PostgreSQL + libSQL). All new persistence features must support both backends. See `src/db/CLAUDE.md`.
## Adding Dependencies
Run `cargo deny check` before adding new dependencies to verify license compatibility and check for known advisories.
+4 -4
View File
@@ -63,12 +63,12 @@ These files account for the vast majority of the coverage gap:
| `src/main.rs` | 740 | 522 | 29.4% | 485 |
| `src/channels/web/handlers/jobs.rs` | 513 | 456 | 11.1% | 430 |
| `src/tools/builder/core.rs` | 524 | 456 | 13.0% | 429 |
| `src/worker/job.rs` | 1,078 | 467 | 56.7% | 413 |
| `src/agent/worker.rs` | 1,078 | 467 | 56.7% | 413 |
| `src/channels/web/handlers/chat.rs` | 564 | 417 | 26.1% | 388 |
| `src/tools/wasm/wrapper.rs` | 1,005 | 436 | 56.6% | 385 |
| `src/channels/signal.rs` | 1,814 | 472 | 74.0% | 381 |
| `src/tools/mcp/auth.rs` | 472 | 378 | 19.9% | 354 |
| `src/worker/container.rs` | 350 | 330 | 5.7% | 312 |
| `src/worker/runtime.rs` | 350 | 330 | 5.7% | 312 |
| `src/tools/builtin/job.rs` | 1,014 | 359 | 64.6% | 308 |
| `src/cli/mcp.rs` | 322 | 319 | 0.9% | 302 |
| `src/cli/oauth_defaults.rs` | 730 | 335 | 54.1% | 298 |
@@ -346,7 +346,7 @@ Test slash commands through the agent loop.
### Trace: Worker Multi-Turn Execution
**Covers:** `worker/job.rs` (+413 lines), `agent/agent_loop.rs` (+207 lines)
**Covers:** `agent/worker.rs` (+413 lines), `agent/agent_loop.rs` (+207 lines)
Test multi-turn tool calling, error recovery, and completion flows.
@@ -769,7 +769,7 @@ HTTP proxy for container network access.
- `test_proxy_connect_tunnel` -- HTTPS CONNECT method handling
- `test_proxy_logging` -- request/response logging
### `src/worker/container.rs` -- 5.7% -> 95% (+312 lines)
### `src/worker/runtime.rs` -- 5.7% -> 95% (+312 lines)
Worker execution loop (runs inside containers).
Generated
+81 -155
View File
@@ -82,7 +82,7 @@ dependencies = [
"const-random",
"once_cell",
"version_check",
"zerocopy 0.8.42",
"zerocopy 0.8.39",
]
[[package]]
@@ -2654,20 +2654,20 @@ dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"r-efi",
"wasip2",
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.4.2"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
"r-efi",
"wasip2",
"wasip3",
]
@@ -2843,9 +2843,9 @@ dependencies = [
[[package]]
name = "html-to-markdown-rs"
version = "2.28.2"
version = "2.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9377e16af590b764fd98fd176027cf8831c5335f8964f3f643753e38913a4e"
checksum = "c05335c6bf406653110ad8447c84461c6d0cda5e0aff9d3d3518f87502d30abe"
dependencies = [
"ahash 0.8.12",
"astral-tl",
@@ -3110,7 +3110,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.3",
"socket2 0.6.2",
"system-configuration",
"tokio",
"tower-service",
@@ -3334,9 +3334,9 @@ checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983"
[[package]]
name = "ipnet"
version = "2.12.0"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
[[package]]
name = "iri-string"
@@ -3386,7 +3386,6 @@ dependencies = [
"hyper-util",
"iana-time-zone",
"insta",
"json5",
"libsql",
"lru",
"mime_guess",
@@ -3514,25 +3513,14 @@ dependencies = [
[[package]]
name = "js-sys"
version = "0.3.91"
version = "0.3.90"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
checksum = "14dc6f6450b3f6d4ed5b16327f38fed626d375a886159ca555bd7822c0c3a5a6"
dependencies = [
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "json5"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1"
dependencies = [
"pest",
"pest_derive",
"serde",
]
[[package]]
name = "kuchikikiki"
version = "0.9.2"
@@ -3597,9 +3585,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.183"
version = "0.2.182"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
[[package]]
name = "libloading"
@@ -3619,14 +3607,13 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libredox"
version = "0.1.14"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
dependencies = [
"bitflags 2.11.0",
"libc",
"plain",
"redox_syscall 0.7.3",
"redox_syscall 0.7.2",
]
[[package]]
@@ -4410,49 +4397,6 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pest"
version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662"
dependencies = [
"memchr",
"ucd-trie",
]
[[package]]
name = "pest_derive"
version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77"
dependencies = [
"pest",
"pest_generator",
]
[[package]]
name = "pest_generator"
version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f"
dependencies = [
"pest",
"pest_meta",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "pest_meta"
version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220"
dependencies = [
"pest",
"sha2",
]
[[package]]
name = "pgvector"
version = "0.4.1"
@@ -4575,18 +4519,18 @@ dependencies = [
[[package]]
name = "pin-project"
version = "1.1.11"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517"
checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.11"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6"
checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
dependencies = [
"proc-macro2",
"quote",
@@ -4595,9 +4539,9 @@ dependencies = [
[[package]]
name = "pin-project-lite"
version = "0.2.17"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
[[package]]
name = "pin-utils"
@@ -4607,9 +4551,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "piper"
version = "0.2.5"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066"
dependencies = [
"atomic-waker",
"fastrand",
@@ -4632,12 +4576,6 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "plain"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "polling"
version = "3.11.0"
@@ -4742,7 +4680,7 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy 0.8.42",
"zerocopy 0.8.39",
]
[[package]]
@@ -4773,11 +4711,11 @@ dependencies = [
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983"
dependencies = [
"toml_edit 0.25.4+spec-1.1.0",
"toml_edit 0.23.10+spec-1.0.0",
]
[[package]]
@@ -4866,7 +4804,7 @@ dependencies = [
"quinn-udp",
"rustc-hash 2.1.1",
"rustls 0.23.37",
"socket2 0.6.3",
"socket2 0.6.2",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -4875,9 +4813,9 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.14"
version = "0.11.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
dependencies = [
"bytes",
"getrandom 0.3.4",
@@ -4903,16 +4841,16 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.3",
"socket2 0.6.2",
"tracing",
"windows-sys 0.60.2",
]
[[package]]
name = "quote"
version = "1.0.45"
version = "1.0.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
dependencies = [
"proc-macro2",
]
@@ -4923,12 +4861,6 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "radium"
version = "0.7.0"
@@ -5068,9 +5000,9 @@ dependencies = [
[[package]]
name = "redox_syscall"
version = "0.7.3"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16"
checksum = "6d94dd2f7cd932d4dc02cc8b2b50dfd38bd079a4e5d79198b99743d7fcf9a4b4"
dependencies = [
"bitflags 2.11.0",
]
@@ -5608,9 +5540,9 @@ dependencies = [
[[package]]
name = "schannel"
version = "0.1.29"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
dependencies = [
"windows-sys 0.61.2",
]
@@ -6097,12 +6029,12 @@ dependencies = [
[[package]]
name = "socket2"
version = "0.6.3"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -6319,12 +6251,12 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tempfile"
version = "3.27.0"
version = "3.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
dependencies = [
"fastrand",
"getrandom 0.4.2",
"getrandom 0.4.1",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
@@ -6551,9 +6483,9 @@ dependencies = [
[[package]]
name = "tokio"
version = "1.50.0"
version = "1.49.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
dependencies = [
"bytes",
"libc",
@@ -6561,7 +6493,7 @@ dependencies = [
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2 0.6.3",
"socket2 0.6.2",
"tokio-macros",
"tracing",
"windows-sys 0.61.2",
@@ -6579,9 +6511,9 @@ dependencies = [
[[package]]
name = "tokio-macros"
version = "2.6.1"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
"proc-macro2",
"quote",
@@ -6618,7 +6550,7 @@ dependencies = [
"postgres-protocol",
"postgres-types",
"rand 0.9.2",
"socket2 0.6.3",
"socket2 0.6.2",
"tokio",
"tokio-util",
"whoami",
@@ -6768,9 +6700,9 @@ dependencies = [
[[package]]
name = "toml_datetime"
version = "1.0.0+spec-1.1.0"
version = "0.7.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e"
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
dependencies = [
"serde_core",
]
@@ -6791,12 +6723,12 @@ dependencies = [
[[package]]
name = "toml_edit"
version = "0.25.4+spec-1.1.0"
version = "0.23.10+spec-1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2"
checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269"
dependencies = [
"indexmap 2.13.0",
"toml_datetime 1.0.0+spec-1.1.0",
"toml_datetime 0.7.5+spec-1.1.0",
"toml_parser",
"winnow",
]
@@ -7113,21 +7045,15 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "ucd-trie"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "uds_windows"
version = "1.2.0"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51b70b87d15e91f553711b40df3048faf27a7a04e01e0ddc0cf9309f0af7c2ca"
checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
"winapi",
]
[[package]]
@@ -7257,11 +7183,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.22.0"
version = "1.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb"
dependencies = [
"getrandom 0.4.2",
"getrandom 0.4.1",
"js-sys",
"serde_core",
"sha1_smol",
@@ -7361,9 +7287,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.114"
version = "0.2.113"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
checksum = "60722a937f594b7fde9adb894d7c092fc1bb6612897c46368d18e7a20208eff2"
dependencies = [
"cfg-if",
"once_cell",
@@ -7374,9 +7300,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.64"
version = "0.4.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8"
checksum = "8a89f4650b770e4521aa6573724e2aed4704372151bd0de9d16a3bbabb87441a"
dependencies = [
"cfg-if",
"futures-util",
@@ -7388,9 +7314,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.114"
version = "0.2.113"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
checksum = "0fac8c6395094b6b91c4af293f4c79371c163f9a6f56184d2c9a85f5a95f3950"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -7398,9 +7324,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.114"
version = "0.2.113"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
checksum = "ab3fabce6159dc20728033842636887e4877688ae94382766e00b180abac9d60"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -7411,9 +7337,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.114"
version = "0.2.113"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
checksum = "de0e091bdb824da87dc01d967388880d017a0a9bc4f3bdc0d86ee9f9336e3bb5"
dependencies = [
"unicode-ident",
]
@@ -7840,9 +7766,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.91"
version = "0.3.90"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
checksum = "705eceb4ce901230f8625bd1d665128056ccbe4b7408faa625eec1ba80f59a97"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -8312,9 +8238,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.7.15"
version = "0.7.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
dependencies = [
"memchr",
]
@@ -8604,11 +8530,11 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.42"
version = "0.8.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3"
checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a"
dependencies = [
"zerocopy-derive 0.8.42",
"zerocopy-derive 0.8.39",
]
[[package]]
@@ -8624,9 +8550,9 @@ dependencies = [
[[package]]
name = "zerocopy-derive"
version = "0.8.42"
version = "0.8.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f"
checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
dependencies = [
"proc-macro2",
"quote",
+1 -10
View File
@@ -14,7 +14,6 @@ exclude = [
"tools-src/google-slides",
"tools-src/slack",
"tools-src/telegram",
"fuzz",
]
[package]
@@ -175,9 +174,6 @@ readabilityrs = { version = "0.1.2", optional = true }
ed25519-dalek = { version = "2.2.0", features = ["std"] }
hex = "0.4.3"
# OpenClaw import (feature gated)
json5 = { version = "0.4", optional = true }
# macOS keychain
[target.'cfg(target_os = "macos")'.dependencies]
security-framework = "3"
@@ -213,20 +209,15 @@ libsql = ["dep:libsql"]
integration = []
html-to-markdown = ["dep:html-to-markdown-rs", "dep:readabilityrs"]
bedrock = ["dep:aws-config", "dep:aws-sdk-bedrockruntime", "dep:aws-smithy-types"]
import = ["dep:json5", "libsql"]
[[test]]
name = "html_to_markdown"
required-features = ["html-to-markdown"]
[profile.release]
strip = true # Remove debug symbols from release binaries
# The profile that 'cargo dist' will build with
[profile.dist]
inherits = "release"
lto = "fat" # Full cross-crate LTO (slow build, better codegen)
codegen-units = 1 # Single codegen unit for maximum optimization
lto = "thin"
# Config for 'dist'
[workspace.metadata.dist]
+5 -6
View File
@@ -46,14 +46,14 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Bonjour/mDNS discovery | ✅ | ❌ | |
| Tailscale integration | ✅ | ❌ | |
| Health check endpoints | ✅ | ✅ | /api/health + /api/gateway/status + /healthz + /readyz, with channel-backed readiness probes |
| `doctor` diagnostics | ✅ | 🚧 | 16 checks: settings, LLM, DB, embeddings, routines, gateway, MCP, skills, secrets, service, Docker daemon, tunnel binaries |
| `doctor` diagnostics | ✅ | | |
| Agent event broadcast | ✅ | 🚧 | SSE broadcast manager exists (SseManager) but tool/job-state events not fully wired |
| Channel health monitor | ✅ | ❌ | Auto-restart with configurable interval |
| Presence system | ✅ | ❌ | Beacons on connect, system presence for agents |
| Trusted-proxy auth mode | ✅ | ❌ | Header-based auth for reverse proxies |
| APNs push pipeline | ✅ | ❌ | Wake disconnected iOS nodes via push |
| Oversized payload guard | ✅ | 🚧 | HTTP webhook has 64KB body limit + Content-Length check; no chat.history cap |
| Pre-prompt context diagnostics | ✅ | 🚧 | Token breakdown logged before LLM call (conversational dispatcher path); other LLM entry points not yet covered |
| Pre-prompt context diagnostics | ✅ | | Context size logging before prompt |
### Owner: _Unassigned_
@@ -165,7 +165,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| `agents` | ✅ | ❌ | P3 | Multi-agent management |
| `sessions` | ✅ | ❌ | P3 | Session listing (shows subagent models) |
| `memory` | ✅ | ✅ | - | Memory search CLI |
| `skills` | ✅ | ✅ | - | CLI subcommands (list, search, info) + agent tools + web API endpoints |
| `skills` | ✅ | ✅ | - | Skills tools + web API endpoints (install, list, activate) |
| `pairing` | ✅ | ✅ | - | list/approve, account selector |
| `nodes` | ✅ | ❌ | P3 | Device management, remove/clear flows |
| `plugins` | ✅ | ❌ | P3 | Plugin management |
@@ -175,7 +175,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| `message send` | ✅ | ❌ | P2 | Send to channels |
| `browser` | ✅ | ❌ | P3 | Browser automation |
| `sandbox` | ✅ | ✅ | - | WASM sandbox |
| `doctor` | ✅ | 🚧 | P2 | 16 subsystem checks |
| `doctor` | ✅ | | P2 | Diagnostics |
| `logs` | ✅ | ❌ | P3 | Query logs |
| `update` | ✅ | ❌ | P3 | Self-update |
| `completion` | ✅ | ✅ | - | Shell completion |
@@ -440,7 +440,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| `before_agent_start` hook | ✅ | ❌ | P2 | Model/provider override |
| `before_message_write` hook | ✅ | ❌ | P2 | Pre-write interception |
| `onMessage` hook | ✅ | ✅ | - | Routines with event trigger |
| Structured system-event routines | ✅ | ✅ | P2 | `system_event` trigger + `event_emit` tool for event-driven automation |
| `onSessionStart` hook | ✅ | ✅ | P2 | |
| `onSessionEnd` hook | ✅ | ✅ | P2 | |
| `transcribeAudio` hook | ✅ | ❌ | P3 | |
@@ -559,7 +558,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
- ❌ Media handling (images, PDFs)
- ✅ Ollama/local model support (via rig::providers::ollama)
- ❌ Configuration hot-reload
- ✅ Tool-driven webhook ingress (`/webhook/tools/{tool}` -> host-verified + tool-normalized `system_event` routines)
- ❌ Webhook trigger endpoint in web gateway
- ❌ Channel health monitor with auto-restart
- ❌ Partial output preservation on abort
+1 -2
View File
@@ -16,8 +16,7 @@
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh-CN.md">简体中文</a> |
<a href="README.ru.md">Русский</a>
<a href="README.zh-CN.md">简体中文</a>
</p>
<p align="center">
-321
View File
@@ -1,321 +0,0 @@
<p align="center">
<img src="ironclaw.png?v=2" alt="IronClaw" width="200"/>
</p>
<h1 align="center">IronClaw</h1>
<p align="center">
<strong>Ваш защищенный персональный AI-ассистент, всегда на вашей стороне</strong>
</p>
<p align="center">
<a href="#license"><img src="https://img.shields.io/badge/license-MIT%20OR%20Apache%202.0-blue.svg" alt="Лицензия: MIT OR Apache-2.0" /></a>
<a href="https://t.me/ironclawAI"><img src="https://img.shields.io/badge/Telegram-%40ironclawAI-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @ironclawAI" /></a>
<a href="https://www.reddit.com/r/ironclawAI/"><img src="https://img.shields.io/badge/Reddit-r%2FironclawAI-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/ironclawAI" /></a>
</p>
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh-CN.md">简体中文</a> |
<a href="README.ru.md">Русский</a>
</p>
<p align="center">
<a href="#философия">Философия</a> •
<a href="#возможности">Возможности</a> •
<a href="#установка">Установка</a> •
<a href="#конфигурация">Конфигурация</a> •
<a href="#безопасность">Безопасность</a> •
<a href="#архитектура">Архитектура</a>
</p>
---
## Философия
IronClaw построен на простом принципе: **ваш AI-ассистент должен работать на вас, а не против вас**.
В мире, где системы ИИ становятся все более непрозрачными в вопросах обработки данных и ориентируются на корпоративные интересы, IronClaw выбирает другой путь:
- **Ваши данные остаются вашими** — вся информация хранится локально, зашифрована и никогда не покидает ваш контроль.
- **Прозрачность по умолчанию** — открытый исходный код, возможность аудита, отсутствие скрытой телеметрии или сбора данных.
- **Саморасширяемые возможности** — создавайте новые инструменты «на лету», не дожидаясь обновлений от вендора.
- **Глубокая защита** — несколько уровней безопасности защищают от инъекций промптов и утечки данных.
IronClaw — это AI-ассистент, которому вы действительно можете доверять в личной и профессиональной жизни.
## Возможности
### Безопасность прежде всего
- **Песочница WASM** — непроверенные инструменты запускаются в изолированных контейнерах WebAssembly с правами на основе возможностей.
- **Защита учетных данных** — секреты никогда не раскрываются инструментам; они внедряются на границе хоста с детектированием утечек.
- **Защита от инъекций промптов** — обнаружение паттернов, очистка контента и применение политик безопасности.
- **Список разрешенных эндпоинтов** — HTTP-запросы только к явно одобренным хостам и путям.
### Всегда доступен
- **Многоканальность** — REPL, HTTP-вебхуки, WASM-каналы (Telegram, Slack) и веб-шлюз.
- **Песочница Docker** — изолированное выполнение контейнеров с токенами для каждого задания и паттерном «оркестратор/воркер».
- **Веб-шлюз** — браузерный интерфейс с потоковой передачей данных в реальном времени через SSE/WebSocket.
- **Рутины (Routines)** — расписания cron, триггеры событий, обработчики вебхуков для фоновой автоматизации.
- **Система Heartbeat** — проактивное фоновое выполнение задач мониторинга и обслуживания.
- **Параллельные задания** — одновременная обработка нескольких запросов с изолированными контекстами.
- **Самовосстановление** — автоматическое обнаружение и восстановление зависших операций.
### Саморасширяемый
- **Динамическое создание инструментов** — опишите, что вам нужно, и IronClaw создаст это как инструмент WASM.
- **Протокол MCP** — подключайтесь к серверам Model Context Protocol для получения дополнительных возможностей.
- **Плагинная архитектура** — добавляйте новые инструменты WASM и каналы без перезагрузки системы.
### Постоянная память
- **Гибридный поиск** — полнотекстовый + векторный поиск с использованием Reciprocal Rank Fusion.
- **Файловая система Workspace** — гибкое хранилище на основе путей для заметок, логов и контекста.
- **Файлы идентичности (Identity Files)** — сохранение индивидуальности и предпочтений между сессиями.
## Установка
### Предварительные условия
- Rust 1.85+
- PostgreSQL 15+ с расширением [pgvector](https://github.com/pgvector/pgvector)
- Аккаунт NEAR AI (аутентификация через мастер настройки)
## Загрузка и сборка
Посетите [страницу релизов](https://github.com/nearai/ironclaw/releases/), чтобы увидеть последние обновления.
<details>
<summary>Установка через установщик Windows (Windows)</summary>
Загрузите [Windows Installer](https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-x86_64-pc-windows-msvc.msi) и запустите его.
</details>
<details>
<summary>Установка через powershell-скрипт (Windows)</summary>
```sh
irm https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.ps1 | iex
```
</details>
<details>
<summary>Установка через shell-скрипт (macOS, Linux, Windows/WSL)</summary>
```sh
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.sh | sh
```
</details>
<details>
<summary>Установка через Homebrew (macOS/Linux)</summary>
```sh
brew install ironclaw
```
</details>
<details>
<summary>Компиляция из исходного кода (Cargo на Windows, Linux, macOS)</summary>
Для установки используйте `cargo`, предварительно убедившись, что у вас установлен [Rust](https://rustup.rs).
```bash
# Клонируйте репозиторий
git clone https://github.com/nearai/ironclaw.git
cd ironclaw
# Сборка
cargo build --release
# Запуск тестов
cargo test
```
Для **полного релиза** (после модификации исходников каналов) выполните `./scripts/build-all.sh`, чтобы сначала пересобрать каналы.
</details>
### Настройка базы данных
```bash
# Создание базы данных
createdb ironclaw
# Включение pgvector
psql ironclaw -c "CREATE EXTENSION IF NOT EXISTS vector;"
```
## Конфигурация
Запустите мастер настройки для конфигурации IronClaw:
```bash
ironclaw onboard
```
Мастер настройки поможет установить соединение с базой данных, пройти аутентификацию NEAR AI (через браузер OAuth) и настроить шифрование секретов (используя системную связку ключей). Настройки сохраняются в базе данных; базовые переменные (например, `DATABASE_URL`, `LLM_BACKEND`) записываются в `~/.ironclaw/.env`, чтобы они были доступны до подключения к БД.
### Альтернативные LLM-провайдеры
IronClaw по умолчанию использует NEAR AI, но работает с любыми OpenAI-совместимыми эндпоинтами.
Популярные варианты включают **OpenRouter** (300+ моделей), **Together AI**, **Fireworks AI**, **Ollama** (локально) и собственные серверы, такие как **vLLM** или **LiteLLM**.
Выберите *"OpenAI-compatible"* в мастере настройки или установите переменные окружения напрямую:
```env
LLM_BACKEND=openai_compatible
LLM_BASE_URL=https://openrouter.ai/api/v1
LLM_API_KEY=sk-or-...
LLM_MODEL=anthropic/claude-sonnet-4
```
Смотрите [docs/LLM_PROVIDERS.md](docs/LLM_PROVIDERS.md) для получения полного руководства по провайдерам.
## Безопасность
IronClaw реализует эшелонированную защиту для обеспечения безопасности ваших данных и предотвращения злоупотреблений.
### Песочница WASM
Все непроверенные инструменты запускаются в изолированных контейнерах WebAssembly:
- **Права на основе возможностей** — явное разрешение на HTTP, доступ к секретам, вызов инструментов.
- **Список разрешенных эндпоинтов** — HTTP-запросы только к одобренным хостам/путям.
- **Внедрение учетных данных** — секреты внедряются на границе хоста и никогда не раскрываются коду WASM.
- **Детектирование утечек** — сканирование запросов и ответов на попытки кражи секретов.
- **Ограничение частоты запросов** — лимиты для каждого инструмента для предотвращения злоупотреблений.
- **Лимиты ресурсов** — ограничения по памяти, процессору и времени выполнения.
```
WASM ──► Валидатор ──► Сканер ───► Инъектор ──► Выполнение ──► Сканер ───► WASM
хостов утечек секретов запроса утечек
(запрос) (ответ)
```
### Защита от инъекций промптов
Внешний контент проходит через несколько уровней безопасности:
- Обнаружение попыток инъекций на основе паттернов.
- Очистка и экранирование контента.
- Правила политик с уровнями серьезности (Блокировка/Предупреждение/Проверка/Очистка).
- Обертывание вывода инструментов для безопасного внедрения в контекст LLM.
### Защита данных
- Все данные хранятся локально в вашей базе данных PostgreSQL.
- Секреты зашифрованы с использованием AES-256-GCM.
- Никакой телеметрии, аналитики или обмена данными.
- Полный журнал аудита выполнения всех инструментов.
## Архитектура
```
┌────────────────────────────────────────────────────────────────┐
│ Каналы │
│ ┌──────┐ ┌──────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ REPL │ │ HTTP │ │WASM-каналы │ │ Веб-шлюз │ │
│ └──┬───┘ └──┬───┘ └──────┬──────┘ │ (SSE + WS) │ │
│ │ │ │ └──────┬──────┘ │
│ └─────────┴──────────────┴────────────────┘ │
│ │ │
│ ┌─────────▼─────────┐ │
│ │ Цикл агента │ Маршрутизация │
│ └────┬──────────┬───┘ намерений │
│ │ │ │
│ ┌──────────▼────┐ ┌──▼───────────────┐ │
│ │ Планировщик │ │ Движок рутин │ │
│ │ (пар. задачи) │ │(cron, соб., wh) │ │
│ └──────┬────────┘ └────────┬─────────┘ │
│ │ │ │
│ ┌─────────────┼────────────────────┘ │
│ │ │ │
│ ┌───▼─────┐ ┌────▼────────────────┐ │
│ │ Локальн.│ │ Оркестратор │ │
│ │ воркеры │ │ ┌───────────────┐ │ │
│ │(in-proc)│ │ │ Песочница │ │ │
│ └───┬─────┘ │ │ Docker │ │ │
│ │ │ │ ┌───────────┐ │ │ │
│ │ │ │ │Воркер / CC│ │ │ │
│ │ │ │ └───────────┘ │ │ │
│ │ │ └───────────────┘ │ │
│ │ └─────────┬───────────┘ │
│ └──────────────────┤ │
│ │ │
│ ┌───────────▼──────────┐ │
│ │ Реестр инструментов │ │
│ │ Встроенные, MCP, WASM│ │
│ └──────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
```
### Основные компоненты
| Компонент | Назначение |
|-----------|------------|
| **Цикл агента** | Основная обработка сообщений и координация задач |
| **Роутер** | Классификация намерений пользователя (команда, запрос, задача) |
| **Планировщик** | Управление выполнением параллельных задач с приоритетами |
| **Воркер** | Выполнение задач с рассуждениями LLM и вызовами инструментов |
| **Оркестратор** | Жизненный цикл контейнеров, проксирование LLM, аутентификация для каждой задачи |
| **Веб-шлюз** | Браузерный интерфейс (чат, память, задачи, логи, расширения, рутины) |
| **Движок рутин** | Фоновые задачи: запланированные (cron) и реактивные (события, вебхуки) |
| **Workspace** | Постоянная память с гибридным поиском |
| **Слой безопасности** | Защита от инъекций промптов и очистка контента |
## Использование
```bash
# Первоначальная настройка (БД, аутентификация и т.д.)
ironclaw onboard
# Запуск интерактивного REPL
cargo run
# С отладочными логами
RUST_LOG=ironclaw=debug cargo run
```
## Разработка
```bash
# Форматирование кода
cargo fmt
# Линтинг
cargo clippy --all --benches --tests --examples --all-features
# Запуск тестов
createdb ironclaw_test
cargo test
# Запуск конкретного теста
cargo test название_теста
```
- **Telegram-канал**: Смотрите [docs/TELEGRAM_SETUP.md](docs/TELEGRAM_SETUP.md) для настройки и привязки аккаунта.
- **Изменение исходников каналов**: Перед `cargo build` выполните `./channels-src/telegram/build.sh`, чтобы обновить встроенный WASM.
## Наследие OpenClaw
IronClaw — это реализация на Rust, вдохновленная проектом [OpenClaw](https://github.com/openclaw/openclaw). Полную матрицу соответствия функций можно найти в [FEATURE_PARITY.md](FEATURE_PARITY.md).
Ключевые отличия:
- **Rust vs TypeScript** — нативная производительность, безопасность памяти, один бинарный файл.
- **Песочница WASM vs Docker** — легковесность, безопасность на основе возможностей.
- **PostgreSQL vs SQLite** — надежное хранилище, готовое к продакшну.
- **Безопасность прежде всего** — многослойная защита, сохранность учетных данных.
## Лицензия
Лицензировано по вашему выбору:
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE))
- MIT License ([LICENSE-MIT](LICENSE-MIT))
+2 -3
View File
@@ -16,8 +16,7 @@
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh-CN.md">简体中文</a> |
<a href="README.ru.md">Русский</a>
<a href="README.zh-CN.md">简体中文</a>
</p>
<p align="center">
@@ -230,7 +229,7 @@ WASM ──► 白名单 ──► 泄露扫描 ──► 凭据 ──► 执
│ │ │ │
│ ┌──────────▼────┐ ┌──▼───────────────┐ │
│ │ 调度器 │ │ 定时任务引擎 │ │
│ │ (并行任务) │ │(cron, 事件, Webhook)│
│ │ (并行任务) │ │(cron, 事件, wh) │
│ └──────┬────────┘ └────────┬─────────┘ │
│ │ │ │
│ ┌─────────────┼────────────────────┘ │
@@ -20,8 +20,7 @@
"optional": false
}
],
"setup_url": "https://t.me/BotFather",
"validation_endpoint": "https://api.telegram.org/bot{telegram_bot_token}/getMe"
"setup_url": "https://t.me/BotFather"
},
"capabilities": {
"http": {
-50
View File
@@ -1,50 +0,0 @@
[advisories]
unmaintained = "workspace"
yanked = "deny"
ignore = [
# Pre-existing advisories — tracked for upgrade in separate PRs
# serde_yml unsound/unmaintained — direct dep, upgrade tracked separately
"RUSTSEC-2025-0068",
# tokio-tar PAX header parsing — sandbox containers only
"RUSTSEC-2025-0111",
# wasmtime fd_renumber host panic — WASIp1, mitigated by fuel limits
"RUSTSEC-2025-0046",
# wasmtime shared linear memory unsoundness — no shared memory in our guests
"RUSTSEC-2025-0118",
# wasmtime guest-controlled resource exhaustion — mitigated by fuel/memory limits
"RUSTSEC-2026-0020",
# wasmtime wasi:http/types.fields panic — mitigated by fuel limits
"RUSTSEC-2026-0021",
]
[licenses]
version = 2
allow = [
"MIT",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"Unicode-3.0",
"Unicode-DFS-2016",
"OpenSSL",
"Zlib",
"MPL-2.0",
"0BSD",
"BSL-1.0",
"CC0-1.0",
"Unlicense",
"CDLA-Permissive-2.0",
]
unused-allowed-license = "allow"
[bans]
multiple-versions = "warn"
wildcards = "deny"
[sources]
unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
allow-git = []
-40
View File
@@ -1,40 +0,0 @@
[package]
name = "ironclaw-fuzz"
version = "0.0.0"
publish = false
edition = "2021"
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
serde_json = "1"
[dependencies.ironclaw]
path = ".."
[[bin]]
name = "fuzz_safety_sanitizer"
path = "fuzz_targets/fuzz_safety_sanitizer.rs"
doc = false
[[bin]]
name = "fuzz_safety_validator"
path = "fuzz_targets/fuzz_safety_validator.rs"
doc = false
[[bin]]
name = "fuzz_leak_detector"
path = "fuzz_targets/fuzz_leak_detector.rs"
doc = false
[[bin]]
name = "fuzz_tool_params"
path = "fuzz_targets/fuzz_tool_params.rs"
doc = false
[[bin]]
name = "fuzz_config_env"
path = "fuzz_targets/fuzz_config_env.rs"
doc = false
-43
View File
@@ -1,43 +0,0 @@
# IronClaw Fuzz Targets
Fuzz testing for security-critical input parsing paths using [cargo-fuzz](https://github.com/rust-fuzz/cargo-fuzz) (libFuzzer).
## Targets
| Target | What it exercises |
|--------|-------------------|
| `fuzz_safety_sanitizer` | Prompt injection pattern detection (Aho-Corasick + regex) |
| `fuzz_safety_validator` | Input validation (length, encoding, forbidden patterns) |
| `fuzz_leak_detector` | Secret leak detection (API keys, tokens, credentials) |
| `fuzz_tool_params` | Tool parameter and schema JSON validation |
| `fuzz_config_env` | SafetyLayer end-to-end (sanitize, validate, policy check) |
## Setup
```bash
cargo install cargo-fuzz
rustup install nightly
```
## Running
```bash
# Run a specific target (runs until stopped or crash found)
cargo +nightly fuzz run fuzz_safety_sanitizer
# Run with a time limit (5 minutes)
cargo +nightly fuzz run fuzz_leak_detector -- -max_total_time=300
# Run all targets for 60 seconds each
for target in fuzz_safety_sanitizer fuzz_safety_validator fuzz_leak_detector fuzz_tool_params fuzz_config_env; do
echo "==> $target"
cargo +nightly fuzz run "$target" -- -max_total_time=60
done
```
## Adding New Targets
1. Create `fuzz/fuzz_targets/fuzz_<name>.rs` following the existing pattern
2. Add a `[[bin]]` entry in `fuzz/Cargo.toml`
3. Create `fuzz/corpus/fuzz_<name>/` for seed inputs
4. Exercise real IronClaw code paths, not just generic serde
-55
View File
@@ -1,55 +0,0 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
use ironclaw::safety::{LeakDetector, Sanitizer, Validator};
fuzz_target!(|data: &[u8]| {
if let Ok(input) = std::str::from_utf8(data) {
// Exercise Sanitizer: detect and neutralize prompt injection attempts.
let sanitizer = Sanitizer::new();
let sanitized = sanitizer.sanitize(input);
// The sanitized content must never be empty when input is non-empty,
// because sanitization wraps/escapes rather than deleting.
if !input.is_empty() {
assert!(
!sanitized.content.is_empty(),
"sanitize() produced empty content for non-empty input"
);
}
// If no modification occurred, content must equal input.
if !sanitized.was_modified {
assert_eq!(sanitized.content, input);
}
// Exercise Validator: input validation (length, encoding, patterns).
let validator = Validator::new();
let result = validator.validate(input);
// ValidationResult must always be well-formed: if valid, no errors.
if result.is_valid {
assert!(
result.errors.is_empty(),
"valid result should have no errors"
);
}
// Exercise LeakDetector: secret detection (API keys, tokens, etc.).
let detector = LeakDetector::new();
let scan = detector.scan(input);
// scan_and_clean must not panic and must return valid UTF-8.
let cleaned = detector.scan_and_clean(input);
if let Ok(ref clean_str) = cleaned {
// Cleaned output must never be longer than original + redaction markers.
// At minimum it should be valid UTF-8 (guaranteed by String type).
let _ = clean_str.len();
}
// If scan found no matches, scan_and_clean should return the input unchanged.
if scan.matches.is_empty() {
if let Ok(ref clean_str) = cleaned {
assert_eq!(
clean_str, input,
"scan_and_clean changed content despite no matches"
);
}
}
}
});
-23
View File
@@ -1,23 +0,0 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
use ironclaw::safety::LeakDetector;
fuzz_target!(|data: &[u8]| {
if let Ok(s) = std::str::from_utf8(data) {
let detector = LeakDetector::new();
// Exercise scan path
let result = detector.scan(s);
// Invariant: if should_block, there must be matches
if result.should_block {
assert!(!result.matches.is_empty());
}
// Invariant: match locations must be valid
for m in &result.matches {
assert!(m.location.end <= s.len());
}
// Exercise scan_and_clean path
let _ = detector.scan_and_clean(s);
}
});
@@ -1,23 +0,0 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
use ironclaw::safety::Sanitizer;
fuzz_target!(|data: &[u8]| {
if let Ok(s) = std::str::from_utf8(data) {
let sanitizer = Sanitizer::new();
// Exercise the main sanitization path
let result = sanitizer.sanitize(s);
// Verify invariant: warnings should have valid ranges
for w in &result.warnings {
assert!(w.location.end <= s.len());
}
// Verify invariant: critical severity triggers modification
let has_critical = result.warnings.iter().any(|w| {
w.severity == ironclaw::safety::Severity::Critical
});
if has_critical {
assert!(result.was_modified);
}
}
});
@@ -1,21 +0,0 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
use ironclaw::safety::Validator;
fuzz_target!(|data: &[u8]| {
if let Ok(s) = std::str::from_utf8(data) {
let validator = Validator::new();
// Exercise input validation
let result = validator.validate(s);
// Invariant: empty input is always invalid
if s.is_empty() {
assert!(!result.is_valid);
}
// Exercise tool parameter validation with arbitrary JSON
if let Ok(value) = serde_json::from_str::<serde_json::Value>(s) {
let _ = validator.validate_tool_params(&value);
}
}
});
-22
View File
@@ -1,22 +0,0 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
use ironclaw::safety::Validator;
use ironclaw::tools::validate_tool_schema;
fuzz_target!(|data: &[u8]| {
if let Ok(s) = std::str::from_utf8(data) {
// Try parsing as JSON and validating as tool parameters
if let Ok(value) = serde_json::from_str::<serde_json::Value>(s) {
// Exercise Validator::validate_tool_params with arbitrary JSON
let validator = Validator::new();
let result = validator.validate_tool_params(&value);
// Invariant: result should always be well-formed
if !result.is_valid {
assert!(!result.errors.is_empty());
}
// Exercise validate_tool_schema with arbitrary JSON as a schema
let _ = validate_tool_schema(&value, "fuzz");
}
}
});
-7
View File
@@ -1,7 +0,0 @@
-- Add token budget tracking columns to agent_jobs.
--
-- Tracks max_tokens (configured limit per job) and total_tokens_used (running total)
-- to enforce job-level token budgets and prevent budget bypass via user-supplied metadata.
ALTER TABLE agent_jobs ADD COLUMN max_tokens BIGINT NOT NULL DEFAULT 0;
ALTER TABLE agent_jobs ADD COLUMN total_tokens_used BIGINT NOT NULL DEFAULT 0;
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/discord-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "85b424604482da3fb9badb56a0360ff4c93670bc7be0ad7f57ef9d85ff972b6f"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "9190b8250bd20c22a8c97b1ea19a6590624a69d6c63a5f5c240a7840a4966286"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "55f2a56e7afd129a48fd49b019f12f9638705defa53fa323ad3b8978d7c59664"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/whatsapp-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "06bcf315df93af9f683134f4055eb810c602863d8c4a632e3733a10217cc5a89"
}
},
"auth_summary": {
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "github",
"display_name": "GitHub",
"kind": "tool",
"version": "0.2.1",
"version": "0.2.0",
"wit_version": "0.3.0",
"description": "GitHub integration for issues, PRs, repos, and code search",
"keywords": [
@@ -20,7 +20,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/github-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "c443328a3f10b6a4cf4d3d62c9217aca204f6467ef753d986b58ca966ca53514"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/gmail-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "e4f0095890d22e3de8e9d516f2e1e91964f8ff4acdaaa19f0a7094a1f2d7786b"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-calendar-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "2d202bd838de94677c91ea6473c7155f021c0500cf91794d17639b1b27446b3d"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-docs-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "7a5e40fe58199e34f7625e11d22e5601cdfd2a94a10193a83f1925180bbb66df"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-drive-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "d19f856fde0ae0320fd3f636a34116af1df0b59698c3684b686e8412a60e887f"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-sheets-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "e113c317f9fa21ea68d0ec8accbba4a62a8222ff3c4655ae85e1e58e01de3250"
}
},
"auth_summary": {
+1 -1
View File
@@ -18,7 +18,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-slides-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "7875a5ae1283e57937e0618bf14465f4bb4ee7f49110312382670202f4c567a5"
}
},
"auth_summary": {
+1 -1
View File
@@ -18,7 +18,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-tool-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "9190b8250bd20c22a8c97b1ea19a6590624a69d6c63a5f5c240a7840a4966286"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-mtproto-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "55f2a56e7afd129a48fd49b019f12f9638705defa53fa323ad3b8978d7c59664"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/web-search-wasm32-wasip2.tar.gz",
"sha256": null
"sha256": "dd7e54956ee0b3037ca3506dbcbd20efcc4cd2749175ed511b3640b09f77506a"
}
},
"auth_summary": {
-21
View File
@@ -1,21 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Ensure we are running from the repository root
cd "$(git rev-parse --show-toplevel)"
echo "==> fmt check"
cargo fmt --all -- --check
echo "==> clippy (all warnings)"
cargo clippy --locked --all --benches --tests --examples --all-features -- -D warnings
echo "==> cargo deny"
if ! command -v cargo-deny &>/dev/null; then
echo "ERROR: cargo-deny not installed (install with: cargo install cargo-deny)"
exit 1
fi
cargo deny check
echo "==> tests"
cargo test --locked
-60
View File
@@ -1,60 +0,0 @@
#!/usr/bin/env bash
# Test that kind-prefixed artifact filenames are parsed correctly into
# manifest paths. Mirrors the parsing logic in release.yml.
set -euo pipefail
cd "$(dirname "$0")/.."
PASS=0
FAIL=0
assert_parse() {
local filename="$1" expected_kind="$2" expected_name="$3"
local kind name manifest
kind=$(echo "$filename" | cut -d'-' -f1)
name=$(echo "$filename" | sed "s/^${kind}-//" | sed 's/-[0-9].*-wasm32-wasip2\.tar\.gz$//')
manifest="registry/${kind}s/${name}.json"
if [[ "$kind" != "$expected_kind" ]]; then
echo "FAIL: $filename → kind=$kind, expected $expected_kind"
FAIL=$((FAIL + 1))
return
fi
if [[ "$name" != "$expected_name" ]]; then
echo "FAIL: $filename → name=$name, expected $expected_name"
FAIL=$((FAIL + 1))
return
fi
echo "OK: $filename$manifest"
PASS=$((PASS + 1))
}
# Tool and channel with same name must produce different manifest paths
assert_parse "tool-slack-0.2.1-wasm32-wasip2.tar.gz" "tool" "slack"
assert_parse "channel-slack-0.2.1-wasm32-wasip2.tar.gz" "channel" "slack"
# Same collision case for telegram
assert_parse "tool-telegram-0.2.2-wasm32-wasip2.tar.gz" "tool" "telegram"
assert_parse "channel-telegram-0.2.2-wasm32-wasip2.tar.gz" "channel" "telegram"
# Hyphenated extension names
assert_parse "tool-web-search-0.2.0-wasm32-wasip2.tar.gz" "tool" "web-search"
assert_parse "tool-google-calendar-0.1.0-wasm32-wasip2.tar.gz" "tool" "google-calendar"
assert_parse "tool-google-docs-0.1.0-wasm32-wasip2.tar.gz" "tool" "google-docs"
assert_parse "tool-google-drive-0.1.0-wasm32-wasip2.tar.gz" "tool" "google-drive"
assert_parse "tool-google-sheets-0.1.0-wasm32-wasip2.tar.gz" "tool" "google-sheets"
assert_parse "tool-google-slides-0.1.0-wasm32-wasip2.tar.gz" "tool" "google-slides"
# Simple names
assert_parse "channel-discord-0.2.0-wasm32-wasip2.tar.gz" "channel" "discord"
assert_parse "channel-whatsapp-0.1.0-wasm32-wasip2.tar.gz" "channel" "whatsapp"
assert_parse "tool-github-0.2.0-wasm32-wasip2.tar.gz" "tool" "github"
assert_parse "tool-gmail-0.1.0-wasm32-wasip2.tar.gz" "tool" "gmail"
# Pre-release versions
assert_parse "tool-slack-0.2.1-alpha.1-wasm32-wasip2.tar.gz" "tool" "slack"
echo ""
echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]] || exit 1
@@ -1,82 +0,0 @@
---
name: ironclaw-workflow-orchestrator
description: "Install and operate a full GitHub issue-to-merge workflow in IronClaw using event-driven and cron routines. Use when setting up or tuning autonomous project orchestration: issue intake, planning, maintainer feedback handling, branch/PR execution, CI/comment follow-up, batched staging review every 8 hours, and memory updates from merge outcomes."
---
# IronClaw Workflow Orchestrator
## Overview
Use this skill to install and maintain a complete project workflow as routines, not core code changes. It maps GitHub webhook events plus scheduled checks into plan/update/implement/review/merge loops with explicit staging-batch analysis.
## Workflow
1. Gather workflow parameters.
2. Verify runtime prerequisites.
3. Install or update routine set from templates.
4. Run a dry test with `event_emit`.
5. Monitor outcomes and tune prompts/filters.
## Parameters
Collect these values before creating routines:
- `repository`: `owner/repo` (required)
- `maintainers`: GitHub handles allowed to trigger implement/replan actions
- `staging_branch`: default `staging`
- `main_branch`: default `main`
- `batch_interval_hours`: default `8`
- `implementation_label`: default `autonomous-impl`
## Prerequisites
Before installing routines, verify:
- Routines system enabled.
- GitHub tool authenticated (for issue/PR/comment/status operations).
- GitHub webhook delivery configured to `POST /webhook/tools/github`.
- Webhook HMAC secret configured in the secrets store as `github_webhook_secret` (required for GitHub webhook delivery).
- Events can also be emitted via `event_emit` tool calls for testing or when webhook ingestion is not yet configured.
## Install Procedure
1. Open [`workflow-routines.md`](references/workflow-routines.md).
2. For each template block:
- replace placeholders (`{{repository}}`, `{{maintainers}}`, branch names)
- call `routine_create`
3. If a routine already exists:
- use `routine_update` instead of creating duplicates
- keep names stable so long-lived metrics/history stay intact
4. Confirm install with `routine_list` and `routine_history`.
## Routine Set
Install these routines:
- `wf-issue-plan`: on `issue.opened` or `issue.reopened`, generate implementation plan comment/checklist.
- `wf-maintainer-comment-gate`: on maintainer comments, decide update-plan vs start implementation.
- `wf-pr-monitor-loop`: on PR open/sync/review-comment/review, address feedback and refresh branch.
- `wf-ci-fix-loop`: on CI status/check failures, apply fixes and push updates.
- `wf-staging-batch-review`: every 8h, review ready PRs, merge into staging, run deep batch correctness analysis, fix findings, then merge staging -> main.
- `wf-learning-memory`: on merged PRs, extract mistakes/lessons and write to shared memory.
## Event Filters
Prefer top-level filters for stability:
- `repository_name` (string, e.g. `owner/repo`)
- `sender_login` (string)
- `issue_number` / `pr_number`
- `ci_status`, `ci_conclusion`
- `review_state`, `comment_author`
Use narrow filters to avoid accidental triggers across repos.
## Operating Rules
- All implementation work must occur on non-main branches.
- PR loop must resolve both human and AI review comments.
- On conflicts with `origin/main`, refresh branch before continuing.
- Staging-batch routine is the only path for bulk correctness verification before mainline merge.
- Memory update routine runs only after successful merge.
## Validation
After install, run:
1. `event_emit` with a synthetic `issue.opened` payload for the target repo.
2. Confirm at least one routine fired.
3. Check corresponding `routine_history` entries.
4. Confirm no unrelated routines fired.
## When To Update Templates
Update this skill when:
- GitHub event names/payload fields change.
- Team review policy changes (e.g., staging cadence, maintainer gates).
- New CI policy requires different failure routing.
@@ -1,4 +0,0 @@
interface:
display_name: "IronClaw Workflow Orchestrator"
short_description: "Install and run event-driven GitHub workflow routines"
default_prompt: "Set up the full issue-to-merge workflow using routines and event triggers."
@@ -1,128 +0,0 @@
# Workflow Routine Templates
Replace `{{...}}` placeholders before use.
## 1) Issue -> Plan
```json
{
"name": "wf-issue-plan",
"description": "Create implementation plan when a new issue arrives",
"trigger_type": "system_event",
"event_source": "github",
"event_type": "issue.opened",
"event_filters": {
"repository_name": "{{repository}}"
},
"action_type": "full_job",
"prompt": "For issue #{{issue_number}} in {{repository}}, produce a concrete implementation plan with milestones, edge cases, and tests. Post/update an issue comment with the plan.",
"cooldown_secs": 30
}
```
## 2) Maintainer Comment Gate (Update Plan vs Implement)
Trigger per-maintainer by creating one routine per handle, or maintain a shared author convention.
```json
{
"name": "wf-maintainer-comment-gate-{{maintainer}}",
"description": "React to maintainer guidance comments on issues/PRs",
"trigger_type": "system_event",
"event_source": "github",
"event_type": "pr.comment.created",
"event_filters": {
"repository_name": "{{repository}}",
"comment_author": "{{maintainer}}"
},
"action_type": "full_job",
"prompt": "Read the maintainer comment and decide: update plan or start/continue implementation. If plan changes are requested, edit the plan artifact first. If implementation is requested, continue on the feature branch and update PR status/comment.",
"cooldown_secs": 20
}
```
## 3) PR Monitor Loop
```json
{
"name": "wf-pr-monitor-loop",
"description": "Keep PR healthy: address review comments and refresh branch",
"trigger_type": "system_event",
"event_source": "github",
"event_type": "pr.synchronize",
"event_filters": {
"repository_name": "{{repository}}"
},
"action_type": "full_job",
"prompt": "For PR #{{pr_number}}, collect open review comments and unresolved threads, apply fixes, push branch updates, and summarize remaining blockers. If conflict with {{main_branch}}, rebase/merge from origin/{{main_branch}} and resolve safely.",
"cooldown_secs": 20
}
```
## 4) CI Failure Fix Loop
```json
{
"name": "wf-ci-fix-loop",
"description": "Fix failing CI checks on active PRs",
"trigger_type": "system_event",
"event_source": "github",
"event_type": "ci.check_run.completed",
"event_filters": {
"repository_name": "{{repository}}",
"ci_conclusion": "failure"
},
"action_type": "full_job",
"prompt": "Find failing check details for PR #{{pr_number}}, implement minimal safe fixes, rerun or await CI, and post concise status updates. Prioritize deterministic and test-backed fixes.",
"cooldown_secs": 20
}
```
## 5) Staging Batch Review (Every 8h)
```json
{
"name": "wf-staging-batch-review",
"description": "Batch correctness review through staging, then merge to main",
"trigger_type": "cron",
"schedule": "0 0 */{{batch_interval_hours}} * * *",
"action_type": "full_job",
"prompt": "Every cycle: list ready PRs, merge ready ones into {{staging_branch}}, run deep correctness analysis in batch, fix discovered issues on affected branches, ensure CI green, then merge {{staging_branch}} into {{main_branch}} if clean.",
"cooldown_secs": 120
}
```
## 6) Post-Merge Learning -> Common Memory
```json
{
"name": "wf-learning-memory",
"description": "Capture merge learnings into shared memory",
"trigger_type": "system_event",
"event_source": "github",
"event_type": "pr.closed",
"event_filters": {
"repository_name": "{{repository}}",
"pr_merged": "true"
},
"action_type": "full_job",
"prompt": "From merged PR #{{pr_number}}, extract preventable mistakes, reviewer themes, CI failure causes, and successful patterns. Write/update a shared memory doc with actionable rules to reduce cycle time and regressions.",
"cooldown_secs": 30
}
```
## Optional: Synthetic Event Test
```json
{
"source": "github",
"event_type": "issue.opened",
"payload": {
"repository_name": "{{repository}}",
"issue_number": 99999,
"sender_login": "test-bot"
}
}
```
Use with `event_emit` after routine install.
+17 -20
View File
@@ -14,15 +14,14 @@ Core agent logic. This is the most complex subsystem — read this before workin
| `session_manager.rs` | Lifecycle: create/lookup sessions, map external thread IDs to internal UUIDs, prune stale sessions, manage undo managers. |
| `router.rs` | Routes explicit `/commands` to `MessageIntent`. Natural language bypasses the router entirely. |
| `scheduler.rs` | Parallel job scheduling. Maintains `jobs` map (full LLM-driven) and `subtasks` map (tool-exec/background). |
| *(moved to `src/worker/job.rs`)* | Per-job execution now lives in `src/worker/job.rs` as `JobDelegate`, using the shared `run_agentic_loop()` engine. |
| `agentic_loop.rs` | Shared agentic loop engine: `run_agentic_loop()`, `LoopDelegate` trait, `LoopOutcome`, `LoopSignal`, `TextAction`. All three execution paths (chat, job, container) delegate to this. |
| `worker.rs` | Per-job execution for background scheduler jobs: calls LLM, runs tools, handles the reasoning loop. Distinct from `dispatcher.rs`. |
| `compaction.rs` | Context window management: summarize old turns, write to workspace daily log, trim context. Three strategies. |
| `context_monitor.rs` | Detects memory pressure. Suggests `CompactionStrategy` based on usage level. |
| `self_repair.rs` | Detects stuck jobs and broken tools, attempts recovery. |
| `heartbeat.rs` | Proactive periodic execution. Reads `HEARTBEAT.md`, notifies via channel if findings. |
| `submission.rs` | Parses all user submissions into typed variants before routing. |
| `undo.rs` | Turn-based undo/redo with checkpoints. Checkpoints store message lists (max 20 by default). |
| `routine.rs` | `Routine` types: `Trigger` (cron/event/system_event/manual) + `RoutineAction` (lightweight/full_job) + `RoutineGuardrails`. |
| `routine.rs` | `Routine` types: `Trigger` (cron/event/webhook/manual) + `RoutineAction` (lightweight/full_job) + `RoutineGuardrails`. |
| `routine_engine.rs` | Cron ticker and event matcher. Fires routines when triggers match. Lightweight runs inline; full_job dispatches to `Scheduler`. |
| `task.rs` | Task types for the scheduler: `Job`, `ToolExec`, `Background`. Used by `spawn_subtask` and `spawn_batch`. |
| `cost_guard.rs` | LLM spend and action-rate enforcement. Tracks daily budget (cents) and hourly call rate. Lives in `AgentDeps`. |
@@ -50,28 +49,26 @@ Session (per user)
## Agentic Loop (dispatcher.rs)
All three execution paths (chat, job, container) now use the shared `run_agentic_loop()` engine in `agentic_loop.rs`, each providing their own `LoopDelegate` implementation:
- **`ChatDelegate`** (`dispatcher.rs`) — conversational turns, tool approval, skill context injection
- **`JobDelegate`** (`src/worker/job.rs`) — background scheduler jobs, planning support, completion detection
- **`ContainerDelegate`** (`src/worker/container.rs`) — Docker container worker, sequential tool exec, HTTP event streaming
The `dispatcher.rs` module handles **direct conversational turns** (user messages processed inline by the main agent). Background scheduler jobs use `worker.rs` instead — these are two separate execution paths.
```
run_agentic_loop(delegate, reasoning, reason_ctx, config)
1. Check signals (stop/cancel) via delegate.check_signals()
2. Pre-LLM hook via delegate.before_llm_call()
3. LLM call via delegate.call_llm()
4. If text response → delegate.handle_text_response() → Continue or Return
5. If tool callsdelegate.execute_tool_calls() → Continue or Return
6. Post-iteration hook via delegate.after_iteration()
7. Repeat until LoopOutcome returned or max_iterations reached
run_agentic_loop() [dispatcher.rs — conversational turns]
1. Load workspace system prompt (identity files: AGENTS.md, SOUL.md, etc.)
2. Detect group chat from metadata; exclude MEMORY.md if group chat
3. Select active skills (keyword/pattern scoring against message content)
4. Build skill context block (injected before user message)
5. LLM call → text response OR tool calls
6. If tool calls:
a. Check tool approval (session auto-approvals, pending approval queue)
b. Execute tools (parallel via JoinSet)
c. Sanitize results through SafetyLayer
d. Feed results back → goto 5
7. Return AgenticLoopResult::Response or NeedApproval
```
**Tool approval:** Tools flagged `requires_approval` pause the loop `ChatDelegate` returns `LoopOutcome::NeedApproval(pending)`. The web gateway stores the `PendingApproval` in session state and sends an `approval_needed` SSE event. The user's approval/deny resumes the loop.
**Tool approval:** Tools flagged `requires_approval` pause the loop and return `NeedApproval`. The web gateway stores the `PendingApproval` in session state and sends an `approval_needed` SSE event. The user's approval/deny resumes the loop.
**Shared tool execution:** `tools/execute.rs` provides `execute_tool_with_safety()` (validate → timeout → execute → serialize) and `process_tool_result()` (sanitize → wrap → ChatMessage), used by all three delegates.
**ChatDelegate vs JobDelegate:** `ChatDelegate` runs for user-initiated conversational turns (holds session lock, tracks turns). `JobDelegate` is spawned by the `Scheduler` for background jobs created via `CreateJob` / `/job` — it runs independently of the session and has planning support (`use_planning` flag).
**worker.rs vs dispatcher.rs:** `dispatcher.rs` runs the agentic loop for user-initiated conversational turns (holds session lock, tracks turns). `worker.rs` is spawned by the `Scheduler` for background jobs created via `CreateJob` / `/job` — it runs independently of the session and has its own LLM reasoning loop with planning support (`use_planning` flag).
## Command Routing (router.rs)
+9 -37
View File
@@ -516,7 +516,7 @@ impl Agent {
*slot.write().await = Some(Arc::clone(&engine));
}
tracing::debug!(
tracing::info!(
"Routines enabled: cron ticker every {}s, max {} concurrent",
rt_config.cron_check_interval_secs,
rt_config.max_concurrent_routines
@@ -538,20 +538,20 @@ impl Agent {
let routine_engine_for_loop = routine_handle.as_ref().map(|(_, e)| Arc::clone(e));
// Main message loop
tracing::debug!("Agent {} ready and listening", self.config.name);
tracing::info!("Agent {} ready and listening", self.config.name);
loop {
let message = tokio::select! {
biased;
_ = tokio::signal::ctrl_c() => {
tracing::debug!("Ctrl+C received, shutting down...");
tracing::info!("Ctrl+C received, shutting down...");
break;
}
msg = message_stream.next() => {
match msg {
Some(m) => m,
None => {
tracing::debug!("All channel streams ended, shutting down...");
tracing::info!("All channel streams ended, shutting down...");
break;
}
}
@@ -626,7 +626,7 @@ impl Agent {
}
Ok(None) => {
// Shutdown signal received (/quit, /exit, /shutdown)
tracing::debug!("Shutdown command received, exiting...");
tracing::info!("Shutdown command received, exiting...");
break;
}
Err(e) => {
@@ -655,7 +655,7 @@ impl Agent {
}
// Cleanup
tracing::debug!("Agent shutting down...");
tracing::info!("Agent shutting down...");
repair_handle.abort();
pruning_handle.abort();
if let Some(handle) = heartbeat_handle {
@@ -738,18 +738,6 @@ impl Agent {
}
async fn handle_message(&self, message: &IncomingMessage) -> Result<Option<String>, Error> {
// Log at info level only for tracking without exposing PII (user_id can be a phone number)
tracing::info!(message_id = %message.id, "Processing message");
// Log sensitive details at debug level for troubleshooting
tracing::debug!(
message_id = %message.id,
user_id = %message.user_id,
channel = %message.channel,
thread_id = ?message.thread_id,
"Message details"
);
// Set message tool context for this turn (current channel and target)
// For Signal, use signal_target from metadata (group:ID or phone number),
// otherwise fall back to user_id
@@ -765,7 +753,7 @@ impl Agent {
// Parse submission type first
let mut submission = SubmissionParser::parse(&message.content);
tracing::trace!(
tracing::debug!(
"[agent_loop] Parsed submission: {:?}",
std::any::type_name_of_val(&submission)
);
@@ -798,21 +786,10 @@ impl Agent {
// Hydrate thread from DB if it's a historical thread not in memory
if let Some(ref external_thread_id) = message.thread_id {
tracing::trace!(
message_id = %message.id,
thread_id = %external_thread_id,
"Hydrating thread from DB"
);
if let Some(rejection) = self.maybe_hydrate_thread(message, external_thread_id).await {
return Ok(Some(format!("Error: {}", rejection)));
}
self.maybe_hydrate_thread(message, external_thread_id).await;
}
// Resolve session and thread
tracing::debug!(
message_id = %message.id,
"Resolving session and thread"
);
let (session, thread_id) = self
.session_manager
.resolve_thread(
@@ -821,11 +798,6 @@ impl Agent {
message.thread_id.as_deref(),
)
.await;
tracing::debug!(
message_id = %message.id,
thread_id = %thread_id,
"Resolved session and thread"
);
// Auth mode interception: if the thread is awaiting a token, route
// the message directly to the credential store. Nothing touches
@@ -855,7 +827,7 @@ impl Agent {
}
}
tracing::trace!(
tracing::debug!(
"Received message from {} on {} ({} chars)",
message.user_id,
message.channel,
-587
View File
@@ -1,587 +0,0 @@
//! Unified agentic loop engine.
//!
//! Provides a single implementation of the core LLM call → tool execution →
//! result processing → context update → repeat cycle. Three consumers
//! (chat dispatcher, job worker, container runtime) customize behavior
//! via the `LoopDelegate` trait.
use async_trait::async_trait;
use crate::agent::session::PendingApproval;
use crate::error::Error;
use crate::llm::{ChatMessage, Reasoning, ReasoningContext, RespondResult};
/// Signal from the delegate indicating how the loop should proceed.
pub enum LoopSignal {
/// Continue normally.
Continue,
/// Stop the loop gracefully.
Stop,
/// Inject a user message into context and continue.
InjectMessage(String),
}
/// Outcome of a text response from the LLM.
pub enum TextAction {
/// Return this as the final loop result.
Return(LoopOutcome),
/// Continue the loop (text was handled but loop should proceed).
Continue,
}
/// Final outcome of the agentic loop.
pub enum LoopOutcome {
/// Completed with a text response.
Response(String),
/// Loop was stopped by a signal.
Stopped,
/// Max iterations exceeded.
MaxIterations,
/// A tool requires user approval before continuing (chat delegate only).
NeedApproval(Box<PendingApproval>),
}
/// Configuration for the agentic loop.
pub struct AgenticLoopConfig {
pub max_iterations: usize,
pub enable_tool_intent_nudge: bool,
pub max_tool_intent_nudges: u32,
}
impl Default for AgenticLoopConfig {
fn default() -> Self {
Self {
max_iterations: 50,
enable_tool_intent_nudge: true,
max_tool_intent_nudges: 2,
}
}
}
/// Strategy trait — each consumer implements this to customize I/O and lifecycle.
///
/// The shared loop calls these methods at well-defined points. Consumers
/// implement only the behavior that differs between chat, job, and container
/// contexts. The loop itself handles the common logic: tool intent nudge,
/// iteration counting, tool definition refresh, and the respond → execute → process cycle.
///
/// # `Send + Sync` requirement
///
/// This trait requires `Send + Sync` because the loop accepts `&dyn LoopDelegate`.
/// Delegates using borrowed references (e.g. `ChatDelegate<'a>`) must ensure all
/// borrowed fields are `Send + Sync`. This is a load-bearing constraint: if a
/// delegate needs to be spawned into a detached task, it must use `Arc`-based
/// ownership instead of borrows (as `JobDelegate` and `ContainerDelegate` do).
#[async_trait]
pub trait LoopDelegate: Send + Sync {
/// Called at the start of each iteration. Check for external signals
/// (cancellation, user messages, stop requests).
async fn check_signals(&self) -> LoopSignal;
/// Called before the LLM call. Allows the delegate to refresh tool
/// definitions, enforce cost guards, or inject messages.
/// Return `Some(outcome)` to break the loop early.
async fn before_llm_call(
&self,
reason_ctx: &mut ReasoningContext,
iteration: usize,
) -> Option<LoopOutcome>;
/// Call the LLM and return the result. Delegates own the LLM call
/// to handle consumer-specific concerns (rate limiting, auto-compaction,
/// cost tracking, force_text mode).
async fn call_llm(
&self,
reasoning: &Reasoning,
reason_ctx: &mut ReasoningContext,
iteration: usize,
) -> Result<crate::llm::RespondOutput, Error>;
/// Handle a text-only response from the LLM.
/// Return `TextAction::Return` to exit the loop, `TextAction::Continue` to proceed.
async fn handle_text_response(
&self,
text: &str,
reason_ctx: &mut ReasoningContext,
) -> TextAction;
/// Execute tool calls and add results to context.
/// Return `Some(outcome)` to break the loop (e.g. approval needed).
async fn execute_tool_calls(
&self,
tool_calls: Vec<crate::llm::ToolCall>,
content: Option<String>,
reason_ctx: &mut ReasoningContext,
) -> Result<Option<LoopOutcome>, Error>;
/// Called when the LLM expresses tool intent without actually calling a tool.
/// Delegates can use this to emit events or log the nudge for observability.
async fn on_tool_intent_nudge(&self, _text: &str, _reason_ctx: &mut ReasoningContext) {}
/// Called after each successful iteration (no error, no early return).
async fn after_iteration(&self, _iteration: usize) {}
}
/// Run the unified agentic loop.
///
/// This is the single implementation used by all three consumers (chat, job, container).
/// The `delegate` provides consumer-specific behavior via the `LoopDelegate` trait.
pub async fn run_agentic_loop(
delegate: &dyn LoopDelegate,
reasoning: &Reasoning,
reason_ctx: &mut ReasoningContext,
config: &AgenticLoopConfig,
) -> Result<LoopOutcome, Error> {
let mut consecutive_tool_intent_nudges: u32 = 0;
for iteration in 1..=config.max_iterations {
// Check for external signals (stop, cancellation, user messages)
match delegate.check_signals().await {
LoopSignal::Continue => {}
LoopSignal::Stop => return Ok(LoopOutcome::Stopped),
LoopSignal::InjectMessage(msg) => {
reason_ctx.messages.push(ChatMessage::user(&msg));
}
}
// Pre-LLM call hook (cost guard, tool refresh, iteration limit nudge)
if let Some(outcome) = delegate.before_llm_call(reason_ctx, iteration).await {
return Ok(outcome);
}
// Call LLM
let output = delegate.call_llm(reasoning, reason_ctx, iteration).await?;
match output.result {
RespondResult::Text(text) => {
// Tool intent nudge: if the LLM says "let me search..." without
// actually calling a tool, inject a nudge message.
if config.enable_tool_intent_nudge
&& !reason_ctx.available_tools.is_empty()
&& !reason_ctx.force_text
&& consecutive_tool_intent_nudges < config.max_tool_intent_nudges
&& crate::llm::llm_signals_tool_intent(&text)
{
consecutive_tool_intent_nudges += 1;
tracing::info!(
iteration,
"LLM expressed tool intent without calling a tool, nudging"
);
delegate.on_tool_intent_nudge(&text, reason_ctx).await;
reason_ctx.messages.push(ChatMessage::assistant(&text));
reason_ctx
.messages
.push(ChatMessage::user(crate::llm::TOOL_INTENT_NUDGE));
delegate.after_iteration(iteration).await;
continue;
}
// Reset nudge counter since we got a non-intent text response
if !crate::llm::llm_signals_tool_intent(&text) {
consecutive_tool_intent_nudges = 0;
}
match delegate.handle_text_response(&text, reason_ctx).await {
TextAction::Return(outcome) => return Ok(outcome),
TextAction::Continue => {}
}
}
RespondResult::ToolCalls {
tool_calls,
content,
} => {
consecutive_tool_intent_nudges = 0;
if let Some(outcome) = delegate
.execute_tool_calls(tool_calls, content, reason_ctx)
.await?
{
return Ok(outcome);
}
}
}
delegate.after_iteration(iteration).await;
}
Ok(LoopOutcome::MaxIterations)
}
/// Truncate a string for log/status previews.
///
/// `max` is a byte budget. The result is truncated at the last valid char
/// boundary at or before `max` bytes, so it is always valid UTF-8.
pub fn truncate_for_preview(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
let end = crate::util::floor_char_boundary(s, max);
format!("{}...", &s[..end])
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::{RespondOutput, TokenUsage, ToolCall};
use crate::testing::StubLlm;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::sync::Mutex;
fn stub_reasoning() -> Reasoning {
Reasoning::new(Arc::new(StubLlm::default()))
}
fn zero_usage() -> TokenUsage {
TokenUsage {
input_tokens: 0,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
}
}
fn text_output(text: &str) -> RespondOutput {
RespondOutput {
result: RespondResult::Text(text.to_string()),
usage: zero_usage(),
}
}
fn tool_calls_output(calls: Vec<ToolCall>) -> RespondOutput {
RespondOutput {
result: RespondResult::ToolCalls {
tool_calls: calls,
content: None,
},
usage: zero_usage(),
}
}
/// Configurable mock delegate for testing run_agentic_loop.
struct MockDelegate {
signal: Mutex<LoopSignal>,
llm_responses: Mutex<Vec<RespondOutput>>,
tool_exec_count: AtomicUsize,
tool_exec_outcome: Mutex<Option<LoopOutcome>>,
iterations_seen: Mutex<Vec<usize>>,
early_exit: Mutex<Option<(usize, LoopOutcome)>>,
nudge_count: AtomicUsize,
}
impl MockDelegate {
fn new(responses: Vec<RespondOutput>) -> Self {
Self {
signal: Mutex::new(LoopSignal::Continue),
llm_responses: Mutex::new(responses),
tool_exec_count: AtomicUsize::new(0),
tool_exec_outcome: Mutex::new(None),
iterations_seen: Mutex::new(Vec::new()),
early_exit: Mutex::new(None),
nudge_count: AtomicUsize::new(0),
}
}
fn with_signal(mut self, signal: LoopSignal) -> Self {
self.signal = Mutex::new(signal);
self
}
fn with_early_exit(mut self, iteration: usize, outcome: LoopOutcome) -> Self {
self.early_exit = Mutex::new(Some((iteration, outcome)));
self
}
}
#[async_trait]
impl LoopDelegate for MockDelegate {
async fn check_signals(&self) -> LoopSignal {
let mut sig = self.signal.lock().await;
std::mem::replace(&mut *sig, LoopSignal::Continue)
}
async fn before_llm_call(
&self,
_reason_ctx: &mut ReasoningContext,
iteration: usize,
) -> Option<LoopOutcome> {
let mut guard = self.early_exit.lock().await;
let should_take = guard
.as_ref()
.is_some_and(|(target, _)| *target == iteration);
if should_take {
guard.take().map(|(_, o)| o)
} else {
None
}
}
async fn call_llm(
&self,
_reasoning: &Reasoning,
_reason_ctx: &mut ReasoningContext,
_iteration: usize,
) -> Result<crate::llm::RespondOutput, crate::error::Error> {
let mut responses = self.llm_responses.lock().await;
if responses.is_empty() {
panic!("MockDelegate: no more LLM responses queued");
}
Ok(responses.remove(0))
}
async fn handle_text_response(
&self,
text: &str,
_reason_ctx: &mut ReasoningContext,
) -> TextAction {
TextAction::Return(LoopOutcome::Response(text.to_string()))
}
async fn execute_tool_calls(
&self,
_tool_calls: Vec<ToolCall>,
_content: Option<String>,
reason_ctx: &mut ReasoningContext,
) -> Result<Option<LoopOutcome>, crate::error::Error> {
self.tool_exec_count.fetch_add(1, Ordering::SeqCst);
reason_ctx
.messages
.push(ChatMessage::user("tool result stub"));
let outcome = self.tool_exec_outcome.lock().await.take();
Ok(outcome)
}
async fn on_tool_intent_nudge(&self, _text: &str, _reason_ctx: &mut ReasoningContext) {
self.nudge_count.fetch_add(1, Ordering::SeqCst);
}
async fn after_iteration(&self, iteration: usize) {
self.iterations_seen.lock().await.push(iteration);
}
}
// --- Tests ---
#[tokio::test]
async fn test_text_response_returns_immediately() {
let delegate = MockDelegate::new(vec![text_output("Hello, world!")]);
let reasoning = stub_reasoning();
let mut ctx = ReasoningContext::new();
let config = AgenticLoopConfig::default();
let outcome = run_agentic_loop(&delegate, &reasoning, &mut ctx, &config)
.await
.unwrap();
match outcome {
LoopOutcome::Response(text) => assert_eq!(text, "Hello, world!"),
_ => panic!("Expected LoopOutcome::Response"),
}
// after_iteration is NOT called when handle_text_response returns Return
// (the loop exits before reaching after_iteration).
assert!(delegate.iterations_seen.lock().await.is_empty());
}
#[tokio::test]
async fn test_tool_call_then_text_response() {
let tool_call = ToolCall {
id: "call_1".to_string(),
name: "echo".to_string(),
arguments: serde_json::json!({}),
};
let delegate = MockDelegate::new(vec![
tool_calls_output(vec![tool_call]),
text_output("Done!"),
]);
let reasoning = stub_reasoning();
let mut ctx = ReasoningContext::new();
let config = AgenticLoopConfig::default();
let outcome = run_agentic_loop(&delegate, &reasoning, &mut ctx, &config)
.await
.unwrap();
match outcome {
LoopOutcome::Response(text) => assert_eq!(text, "Done!"),
_ => panic!("Expected LoopOutcome::Response"),
}
assert_eq!(delegate.tool_exec_count.load(Ordering::SeqCst), 1);
// after_iteration called for iteration 1 (tool call), but not 2
// (text response exits before after_iteration).
assert_eq!(*delegate.iterations_seen.lock().await, vec![1]);
}
#[tokio::test]
async fn test_stop_signal_exits_immediately() {
let delegate =
MockDelegate::new(vec![text_output("unreachable")]).with_signal(LoopSignal::Stop);
let reasoning = stub_reasoning();
let mut ctx = ReasoningContext::new();
let config = AgenticLoopConfig::default();
let outcome = run_agentic_loop(&delegate, &reasoning, &mut ctx, &config)
.await
.unwrap();
assert!(matches!(outcome, LoopOutcome::Stopped));
assert!(delegate.iterations_seen.lock().await.is_empty());
}
#[tokio::test]
async fn test_inject_message_adds_user_message() {
let delegate = MockDelegate::new(vec![text_output("Got it")])
.with_signal(LoopSignal::InjectMessage("injected prompt".to_string()));
let reasoning = stub_reasoning();
let mut ctx = ReasoningContext::new();
let config = AgenticLoopConfig::default();
let outcome = run_agentic_loop(&delegate, &reasoning, &mut ctx, &config)
.await
.unwrap();
assert!(matches!(outcome, LoopOutcome::Response(_)));
assert!(
ctx.messages
.iter()
.any(|m| m.role == crate::llm::Role::User && m.content.contains("injected prompt")),
"Injected message should appear in context"
);
}
#[tokio::test]
async fn test_max_iterations_reached() {
struct ContinueDelegate;
#[async_trait]
impl LoopDelegate for ContinueDelegate {
async fn check_signals(&self) -> LoopSignal {
LoopSignal::Continue
}
async fn before_llm_call(
&self,
_: &mut ReasoningContext,
_: usize,
) -> Option<LoopOutcome> {
None
}
async fn call_llm(
&self,
_: &Reasoning,
_: &mut ReasoningContext,
_: usize,
) -> Result<crate::llm::RespondOutput, crate::error::Error> {
Ok(text_output("still working"))
}
async fn handle_text_response(
&self,
_: &str,
ctx: &mut ReasoningContext,
) -> TextAction {
ctx.messages.push(ChatMessage::assistant("still working"));
TextAction::Continue
}
async fn execute_tool_calls(
&self,
_: Vec<ToolCall>,
_: Option<String>,
_: &mut ReasoningContext,
) -> Result<Option<LoopOutcome>, crate::error::Error> {
Ok(None)
}
}
let delegate = ContinueDelegate;
let reasoning = stub_reasoning();
let mut ctx = ReasoningContext::new();
let config = AgenticLoopConfig {
max_iterations: 3,
..Default::default()
};
let outcome = run_agentic_loop(&delegate, &reasoning, &mut ctx, &config)
.await
.unwrap();
assert!(matches!(outcome, LoopOutcome::MaxIterations));
let assistant_count = ctx
.messages
.iter()
.filter(|m| m.role == crate::llm::Role::Assistant)
.count();
assert_eq!(assistant_count, 3);
}
#[tokio::test]
async fn test_tool_intent_nudge_fires_and_caps() {
let delegate = MockDelegate::new(vec![
text_output("Let me search for that file"),
text_output("Let me search for that file"),
text_output("Let me search for that file"),
]);
let reasoning = stub_reasoning();
let mut ctx = ReasoningContext::new();
ctx.available_tools.push(crate::llm::ToolDefinition {
name: "search".to_string(),
description: "Search files".to_string(),
parameters: serde_json::json!({"type": "object"}),
});
let config = AgenticLoopConfig {
max_iterations: 10,
enable_tool_intent_nudge: true,
max_tool_intent_nudges: 2,
};
let outcome = run_agentic_loop(&delegate, &reasoning, &mut ctx, &config)
.await
.unwrap();
assert!(matches!(outcome, LoopOutcome::Response(_)));
assert_eq!(delegate.nudge_count.load(Ordering::SeqCst), 2);
let nudge_messages = ctx
.messages
.iter()
.filter(|m| {
m.role == crate::llm::Role::User
&& m.content.contains("you did not include any tool calls")
})
.count();
assert_eq!(
nudge_messages, 2,
"Should have exactly 2 nudge messages in context"
);
}
#[tokio::test]
async fn test_before_llm_call_early_exit() {
let delegate = MockDelegate::new(vec![text_output("unreachable")])
.with_early_exit(1, LoopOutcome::Stopped);
let reasoning = stub_reasoning();
let mut ctx = ReasoningContext::new();
let config = AgenticLoopConfig::default();
let outcome = run_agentic_loop(&delegate, &reasoning, &mut ctx, &config)
.await
.unwrap();
assert!(matches!(outcome, LoopOutcome::Stopped));
assert!(delegate.iterations_seen.lock().await.is_empty());
}
#[test]
fn test_truncate_short_string_unchanged() {
assert_eq!(truncate_for_preview("hello", 10), "hello");
}
#[test]
fn test_truncate_long_string_adds_ellipsis() {
let result = truncate_for_preview("hello world", 5);
assert_eq!(result, "hello...");
}
#[test]
fn test_truncate_multibyte_safe() {
let result = truncate_for_preview("café", 4);
assert_eq!(result, "caf...");
}
}
+2 -4
View File
@@ -405,8 +405,7 @@ impl Agent {
.with_max_tokens(512)
.with_temperature(0.3);
let reasoning =
Reasoning::new(self.llm().clone()).with_model_name(self.llm().active_model_name());
let reasoning = Reasoning::new(self.llm().clone());
match reasoning.complete(request).await {
Ok((text, _usage)) => Ok(SubmissionResult::response(format!(
"Thread Summary:\n\n{}",
@@ -454,8 +453,7 @@ impl Agent {
.with_max_tokens(512)
.with_temperature(0.5);
let reasoning =
Reasoning::new(self.llm().clone()).with_model_name(self.llm().active_model_name());
let reasoning = Reasoning::new(self.llm().clone());
match reasoning.complete(request).await {
Ok((text, _usage)) => Ok(SubmissionResult::response(format!(
"Suggested Next Steps:\n\n{}",
+1 -2
View File
@@ -227,8 +227,7 @@ Be brief but capture all important details. Use bullet points."#,
.with_max_tokens(1024)
.with_temperature(0.3);
let reasoning =
Reasoning::new(self.llm.clone()).with_model_name(self.llm.active_model_name());
let reasoning = Reasoning::new(self.llm.clone());
let (text, _) = reasoning.complete(request).await?;
Ok(text)
}
+782 -711
View File
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -189,7 +189,7 @@ impl HeartbeatRunner {
// Skip during quiet hours
if self.config.is_quiet_hours() {
tracing::trace!("Heartbeat skipped: quiet hours");
tracing::debug!("Heartbeat skipped: quiet hours");
continue;
}
@@ -212,7 +212,7 @@ impl HeartbeatRunner {
match self.check_heartbeat().await {
HeartbeatResult::Ok => {
tracing::trace!("Heartbeat OK");
tracing::debug!("Heartbeat OK");
self.consecutive_failures = 0;
}
HeartbeatResult::NeedsAttention(message) => {
@@ -221,7 +221,7 @@ impl HeartbeatRunner {
self.send_notification(&message).await;
}
HeartbeatResult::Skipped => {
tracing::trace!("Heartbeat skipped");
tracing::debug!("Heartbeat skipped");
}
HeartbeatResult::Failed(error) => {
tracing::error!("Heartbeat failed: {}", error);
@@ -303,8 +303,7 @@ impl HeartbeatRunner {
.with_max_tokens(max_tokens)
.with_temperature(0.3);
let reasoning =
Reasoning::new(self.llm.clone()).with_model_name(self.llm.active_model_name());
let reasoning = Reasoning::new(self.llm.clone());
let (content, _usage) = match reasoning.complete(request).await {
Ok(r) => r,
Err(e) => return HeartbeatResult::Failed(format!("LLM call failed: {}", e)),
+3 -3
View File
@@ -11,7 +11,6 @@
//! - Context compaction for long conversations
mod agent_loop;
pub mod agentic_loop;
mod attachments;
mod commands;
pub mod compaction;
@@ -23,7 +22,7 @@ pub mod job_monitor;
mod router;
pub mod routine;
pub mod routine_engine;
pub(crate) mod scheduler;
mod scheduler;
mod self_repair;
pub mod session;
mod session_manager;
@@ -31,8 +30,8 @@ pub mod submission;
pub mod task;
mod thread_ops;
pub mod undo;
pub mod worker;
pub use crate::worker::{Worker, WorkerDeps};
pub(crate) use agent_loop::truncate_for_preview;
pub use agent_loop::{Agent, AgentDeps};
pub use compaction::{CompactionResult, ContextCompactor};
@@ -48,3 +47,4 @@ pub use session_manager::SessionManager;
pub use submission::{Submission, SubmissionParser, SubmissionResult};
pub use task::{Task, TaskContext, TaskHandler, TaskOutput};
pub use undo::{Checkpoint, UndoManager};
pub use worker::{Worker, WorkerDeps};
+23 -86
View File
@@ -8,7 +8,7 @@
//! ┌──────────┐ ┌─────────┐ ┌──────────────────┐
//! │ Trigger │────▶│ Engine │────▶│ Execution Mode │
//! │ cron/event│ │guardrail│ │lightweight│full_job│
//! │ system │ │ check │ └──────────────────┘
//! │ webhook │ │ check │ └──────────────────┘
//! │ manual │ └─────────┘ │
//! └──────────┘ ▼
//! ┌──────────────┐
@@ -69,15 +69,12 @@ pub enum Trigger {
/// Regex pattern to match against message content.
pattern: String,
},
/// Fire when a structured system event is emitted.
SystemEvent {
/// Event source namespace (e.g. "github", "workflow", "tool").
source: String,
/// Event type within the source (e.g. "issue.opened").
event_type: String,
/// Optional exact-match filters against payload top-level fields.
#[serde(default)]
filters: std::collections::HashMap<String, String>,
/// Fire on incoming webhook POST to /hooks/routine/{id}.
Webhook {
/// Optional webhook path suffix (defaults to routine id).
path: Option<String>,
/// Optional shared secret for HMAC validation.
secret: Option<String>,
},
/// Only fires via tool call or CLI.
Manual,
@@ -89,7 +86,7 @@ impl Trigger {
match self {
Trigger::Cron { .. } => "cron",
Trigger::Event { .. } => "event",
Trigger::SystemEvent { .. } => "system_event",
Trigger::Webhook { .. } => "webhook",
Trigger::Manual => "manual",
}
}
@@ -137,39 +134,16 @@ impl Trigger {
.map(String::from);
Ok(Trigger::Event { channel, pattern })
}
"system_event" => {
let source = config
.get("source")
"webhook" => {
let path = config
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| RoutineError::MissingField {
context: "system_event trigger".into(),
field: "source".into(),
})?
.to_string();
let event_type = config
.get("event_type")
.map(String::from);
let secret = config
.get("secret")
.and_then(|v| v.as_str())
.ok_or_else(|| RoutineError::MissingField {
context: "system_event trigger".into(),
field: "event_type".into(),
})?
.to_string();
let filters = config
.get("filters")
.and_then(|v| v.as_object())
.map(|m| {
m.iter()
.filter_map(|(k, v)| {
json_value_as_filter_string(v).map(|s| (k.clone(), s))
})
.collect()
})
.unwrap_or_default();
Ok(Trigger::SystemEvent {
source,
event_type,
filters,
})
.map(String::from);
Ok(Trigger::Webhook { path, secret })
}
"manual" => Ok(Trigger::Manual),
other => Err(RoutineError::UnknownTriggerType {
@@ -189,14 +163,9 @@ impl Trigger {
"pattern": pattern,
"channel": channel,
}),
Trigger::SystemEvent {
source,
event_type,
filters,
} => serde_json::json!({
"source": source,
"event_type": event_type,
"filters": filters,
Trigger::Webhook { path, secret } => serde_json::json!({
"path": path,
"secret": secret,
}),
Trigger::Manual => serde_json::json!({}),
}
@@ -459,19 +428,6 @@ pub struct RoutineRun {
pub created_at: DateTime<Utc>,
}
/// Convert a JSON value to a string for filter storage.
///
/// Handles strings, numbers, and booleans — consistent with the matching
/// logic in `routine_engine::json_value_as_string`.
pub fn json_value_as_filter_string(v: &serde_json::Value) -> Option<String> {
match v {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
serde_json::Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
/// Compute a content hash for event dedup.
pub fn content_hash(content: &str) -> u64 {
let mut hasher = DefaultHasher::new();
@@ -530,24 +486,6 @@ mod tests {
if channel == Some("telegram".to_string()) && pattern == r"deploy\s+\w+"));
}
#[test]
fn test_system_event_trigger_roundtrip() {
let mut filters = std::collections::HashMap::new();
filters.insert("repo".to_string(), "nearai/ironclaw".to_string());
filters.insert("action".to_string(), "opened".to_string());
let trigger = Trigger::SystemEvent {
source: "github".to_string(),
event_type: "issue".to_string(),
filters: filters.clone(),
};
let json = trigger.to_config_json();
let parsed = Trigger::from_db("system_event", json).expect("parse system_event");
assert!(
matches!(parsed, Trigger::SystemEvent { source, event_type, filters: f }
if source == "github" && event_type == "issue" && f == filters)
);
}
#[test]
fn test_action_lightweight_roundtrip() {
let action = RoutineAction::Lightweight {
@@ -685,13 +623,12 @@ mod tests {
"event"
);
assert_eq!(
Trigger::SystemEvent {
source: String::new(),
event_type: String::new(),
filters: std::collections::HashMap::new(),
Trigger::Webhook {
path: None,
secret: None
}
.type_tag(),
"system_event"
"webhook"
);
assert_eq!(Trigger::Manual.type_tag(), "manual");
}
+20 -117
View File
@@ -32,14 +32,9 @@ use crate::llm::{
ChatMessage, CompletionRequest, FinishReason, LlmProvider, ToolCall, ToolCompletionRequest,
};
use crate::safety::SafetyLayer;
use crate::tools::{ApprovalContext, ApprovalRequirement, ToolError, ToolRegistry};
use crate::tools::{ApprovalContext, ApprovalRequirement, ToolError, ToolRegistry, redact_params};
use crate::workspace::Workspace;
enum EventMatcher {
Message { routine: Routine, regex: Regex },
System { routine: Routine },
}
/// The routine execution engine.
pub struct RoutineEngine {
config: RoutineConfig,
@@ -50,8 +45,8 @@ pub struct RoutineEngine {
notify_tx: mpsc::Sender<OutgoingResponse>,
/// Currently running routine count (across all routines).
running_count: Arc<AtomicUsize>,
/// Cached matchers for all event-driven routines.
event_cache: Arc<RwLock<Vec<EventMatcher>>>,
/// Compiled event regex cache: routine_id -> compiled regex.
event_cache: Arc<RwLock<Vec<(Uuid, Routine, Regex)>>>,
/// Scheduler for dispatching jobs (FullJob mode).
scheduler: Option<Arc<Scheduler>>,
/// Tool registry for lightweight routine tool execution.
@@ -92,12 +87,9 @@ impl RoutineEngine {
Ok(routines) => {
let mut cache = Vec::new();
for routine in routines {
match &routine.trigger {
Trigger::Event { pattern, .. } => match Regex::new(pattern) {
Ok(re) => cache.push(EventMatcher::Message {
routine: routine.clone(),
regex: re,
}),
if let Trigger::Event { ref pattern, .. } = routine.trigger {
match Regex::new(pattern) {
Ok(re) => cache.push((routine.id, routine.clone(), re)),
Err(e) => {
tracing::warn!(
routine = %routine.name,
@@ -105,18 +97,12 @@ impl RoutineEngine {
pattern, e
);
}
},
Trigger::SystemEvent { .. } => {
cache.push(EventMatcher::System {
routine: routine.clone(),
});
}
_ => {}
}
}
let count = cache.len();
*self.event_cache.write().await = cache;
tracing::trace!("Refreshed event cache: {} routines", count);
tracing::debug!("Refreshed event cache: {} routines", count);
}
Err(e) => {
tracing::error!("Failed to refresh event cache: {}", e);
@@ -132,11 +118,7 @@ impl RoutineEngine {
let cache = self.event_cache.read().await;
let mut fired = 0;
for matcher in cache.iter() {
let (routine, re) = match matcher {
EventMatcher::Message { routine, regex } => (routine, regex),
EventMatcher::System { .. } => continue,
};
for (_, routine, re) in cache.iter() {
// Channel filter
if let Trigger::Event {
channel: Some(ch), ..
@@ -153,13 +135,13 @@ impl RoutineEngine {
// Cooldown check
if !self.check_cooldown(routine) {
tracing::trace!(routine = %routine.name, "Skipped: cooldown active");
tracing::debug!(routine = %routine.name, "Skipped: cooldown active");
continue;
}
// Concurrent run check
if !self.check_concurrent(routine).await {
tracing::trace!(routine = %routine.name, "Skipped: max concurrent reached");
tracing::debug!(routine = %routine.name, "Skipped: max concurrent reached");
continue;
}
@@ -177,88 +159,6 @@ impl RoutineEngine {
fired
}
/// Emit a structured event to system-event routines.
///
/// Returns the number of routines that were fired.
pub async fn emit_system_event(
&self,
source: &str,
event_type: &str,
payload: &serde_json::Value,
user_id: Option<&str>,
) -> usize {
let cache = self.event_cache.read().await;
let mut fired = 0;
for matcher in cache.iter() {
let routine = match matcher {
EventMatcher::System { routine } => routine,
EventMatcher::Message { .. } => continue,
};
let Trigger::SystemEvent {
source: expected_source,
event_type: expected_event,
filters,
} = &routine.trigger
else {
continue;
};
if !expected_source.eq_ignore_ascii_case(source)
|| !expected_event.eq_ignore_ascii_case(event_type)
{
continue;
}
if let Some(uid) = user_id
&& routine.user_id != uid
{
continue;
}
let mut matched = true;
for (key, expected) in filters {
let Some(actual) = payload
.get(key)
.and_then(crate::agent::routine::json_value_as_filter_string)
else {
tracing::debug!(routine = %routine.name, filter_key = %key, "Filter key not found in payload");
matched = false;
break;
};
if !actual.eq_ignore_ascii_case(expected) {
matched = false;
break;
}
}
if !matched {
continue;
}
if !self.check_cooldown(routine) {
tracing::debug!(routine = %routine.name, "Skipped: cooldown active");
continue;
}
if !self.check_concurrent(routine).await {
tracing::debug!(routine = %routine.name, "Skipped: max concurrent reached");
continue;
}
if self.running_count.load(Ordering::Relaxed) >= self.config.max_concurrent_routines {
tracing::warn!(routine = %routine.name, "Skipped: global max concurrent reached");
continue;
}
let detail = truncate(&format!("{source}:{event_type}"), 200);
self.spawn_fire(routine.clone(), "system_event", Some(detail));
fired += 1;
}
fired
}
/// Check all due cron routines and fire them. Called by the cron ticker.
pub async fn check_cron_triggers(&self) {
let routines = match self.store.list_due_cron_routines().await {
@@ -1013,6 +913,13 @@ async fn execute_routine_tool(
return Err(format!("Invalid tool parameters: {}", details).into());
}
let safe_params = redact_params(&tc.arguments, tool.sensitive_params());
tracing::debug!(
tool = %tc.name,
params = %safe_params,
"Lightweight routine tool call started"
);
// Execute with per-tool timeout
let timeout = tool.execution_timeout();
let start = std::time::Instant::now();
@@ -1022,14 +929,12 @@ async fn execute_routine_tool(
.await;
let elapsed = start.elapsed();
// Log tool execution result (single consolidated log)
match &result {
Ok(Ok(_)) => {
tracing::debug!(
tool = %tc.name,
elapsed_ms = elapsed.as_millis() as u64,
status = "succeeded",
"Lightweight routine tool execution completed"
"Lightweight routine tool call succeeded"
);
}
Ok(Err(e)) => {
@@ -1037,8 +942,7 @@ async fn execute_routine_tool(
tool = %tc.name,
elapsed_ms = elapsed.as_millis() as u64,
error = %e,
status = "failed",
"Lightweight routine tool execution completed"
"Lightweight routine tool call failed"
);
}
Err(_) => {
@@ -1046,8 +950,7 @@ async fn execute_routine_tool(
tool = %tc.name,
elapsed_ms = elapsed.as_millis() as u64,
timeout_secs = timeout.as_secs(),
status = "timeout",
"Lightweight routine tool execution completed"
"Lightweight routine tool call timed out"
);
}
}
+43 -173
View File
@@ -9,6 +9,7 @@ use tokio::task::JoinHandle;
use uuid::Uuid;
use crate::agent::task::{Task, TaskContext, TaskOutput};
use crate::agent::worker::{Worker, WorkerDeps};
use crate::channels::web::types::SseEvent;
use crate::config::AgentConfig;
use crate::context::{ContextManager, JobContext, JobState};
@@ -18,7 +19,6 @@ use crate::hooks::HookRegistry;
use crate::llm::LlmProvider;
use crate::safety::SafetyLayer;
use crate::tools::{ApprovalContext, ToolRegistry};
use crate::worker::job::{Worker, WorkerDeps};
/// Message to send to a worker.
#[derive(Debug)]
@@ -160,36 +160,24 @@ impl Scheduler {
.create_job_for_user(user_id, title, description)
.await?;
// Apply metadata and token budget in a single atomic update.
// This prevents concurrent workers from observing partial state.
// Cap user-supplied max_tokens at the configured limit (Issue #815).
let user_max_tokens = metadata
// Apply token budget from config, allowing per-job metadata override.
let max_tokens = metadata
.as_ref()
.and_then(|m| m.get("max_tokens"))
.and_then(|v| v.as_u64());
let max_tokens = user_max_tokens
.map(|user_val| {
if self.config.max_tokens_per_job == 0 {
// Config is "unlimited": use the user-supplied value directly.
user_val
} else {
std::cmp::min(user_val, self.config.max_tokens_per_job)
}
})
.and_then(|v| v.as_u64())
.unwrap_or(self.config.max_tokens_per_job);
// Apply both metadata and token budget in one closure (Issue #813: atomic update)
// Apply metadata if provided
if let Some(meta) = metadata {
self.context_manager
.update_context(job_id, |ctx| {
ctx.metadata = meta;
if max_tokens > 0 {
ctx.max_tokens = max_tokens;
}
})
.await?;
} else if max_tokens > 0 {
}
// Set token budget (separate update to avoid overwriting metadata)
if max_tokens > 0 {
self.context_manager
.update_context(job_id, |ctx| {
ctx.max_tokens = max_tokens;
@@ -474,9 +462,6 @@ impl Scheduler {
}
/// Execute a single tool as a subtask.
///
/// Performs scheduler-specific checks (approval, cancellation) then
/// delegates to the shared `execute_tool_with_safety` pipeline.
async fn execute_tool_task(
tools: Arc<ToolRegistry>,
context_manager: Arc<ContextManager>,
@@ -488,7 +473,7 @@ impl Scheduler {
) -> Result<TaskOutput, Error> {
let start = std::time::Instant::now();
// Get the tool for approval check
// Get the tool
let tool = tools.get(tool_name).await.ok_or_else(|| {
Error::Tool(crate::error::ToolError::NotFound {
name: tool_name.to_string(),
@@ -505,7 +490,6 @@ impl Scheduler {
.into());
}
// Scheduler-specific approval check
let requirement = tool.requires_approval(&params);
let blocked =
ApprovalContext::is_blocked_or_default(&approval_context, tool_name, requirement);
@@ -516,23 +500,41 @@ impl Scheduler {
.into());
}
// Delegate to shared tool execution pipeline
let output_str = crate::tools::execute::execute_tool_with_safety(
&tools, &safety, tool_name, &params, &job_ctx,
)
.await?;
// Parse back to Value for TaskOutput; this should be infallible given
// `execute_tool_with_safety` uses `serde_json::to_string_pretty`, but if it
// ever fails we surface a clear error instead of silently changing types.
let result_value: serde_json::Value = serde_json::from_str(&output_str).map_err(|e| {
Error::Tool(crate::error::ToolError::ExecutionFailed {
// Validate tool parameters
let validation = safety.validator().validate_tool_params(&params);
if !validation.is_valid {
let details = validation
.errors
.iter()
.map(|e| format!("{}: {}", e.field, e.message))
.collect::<Vec<_>>()
.join("; ");
return Err(crate::error::ToolError::InvalidParameters {
name: tool_name.to_string(),
reason: format!("Failed to parse tool output as JSON: {}", e),
})
})?;
reason: format!("Invalid tool parameters: {}", details),
}
.into());
}
Ok(TaskOutput::new(result_value, start.elapsed()))
// Execute with per-tool timeout
let tool_timeout = tool.execution_timeout();
let result =
tokio::time::timeout(tool_timeout, async { tool.execute(params, &job_ctx).await })
.await
.map_err(|_| {
Error::Tool(crate::error::ToolError::Timeout {
name: tool_name.to_string(),
timeout: tool_timeout,
})
})?
.map_err(|e| {
Error::Tool(crate::error::ToolError::ExecutionFailed {
name: tool_name.to_string(),
reason: e.to_string(),
})
})?;
Ok(TaskOutput::new(result.result, start.elapsed()))
}
/// Stop a running job.
@@ -697,140 +699,8 @@ impl Scheduler {
mod tests {
use super::*;
use crate::config::SafetyConfig;
use crate::llm::{
CompletionRequest, CompletionResponse, LlmError, LlmProvider, ToolCompletionRequest,
ToolCompletionResponse,
};
use crate::safety::SafetyLayer;
use crate::tools::{ApprovalRequirement, Tool, ToolError, ToolOutput};
use rust_decimal_macros::dec;
/// Minimal LLM provider stub for scheduler tests that don't exercise LLM calls.
struct StubLlm;
#[async_trait::async_trait]
impl LlmProvider for StubLlm {
fn model_name(&self) -> &str {
"stub"
}
fn cost_per_token(&self) -> (rust_decimal::Decimal, rust_decimal::Decimal) {
(dec!(0), dec!(0))
}
async fn complete(&self, _req: CompletionRequest) -> Result<CompletionResponse, LlmError> {
Err(LlmError::RequestFailed {
provider: "stub".into(),
reason: "not implemented".into(),
})
}
async fn complete_with_tools(
&self,
_req: ToolCompletionRequest,
) -> Result<ToolCompletionResponse, LlmError> {
Err(LlmError::RequestFailed {
provider: "stub".into(),
reason: "not implemented".into(),
})
}
}
/// Create a Scheduler for token-budget tests. The LLM stub will fail if a
/// worker actually tries to call it, but `dispatch_job` sets the token
/// budget *before* spawning the worker so we can inspect the context
/// immediately after dispatch.
fn make_test_scheduler(max_tokens_per_job: u64) -> Scheduler {
let config = AgentConfig {
name: "test".to_string(),
max_parallel_jobs: 5,
job_timeout: std::time::Duration::from_secs(30),
stuck_threshold: std::time::Duration::from_secs(300),
repair_check_interval: std::time::Duration::from_secs(3600),
max_repair_attempts: 0,
use_planning: false,
session_idle_timeout: std::time::Duration::from_secs(3600),
allow_local_tools: true,
max_cost_per_day_cents: None,
max_actions_per_hour: None,
max_tool_iterations: 10,
auto_approve_tools: true,
default_timezone: "UTC".to_string(),
max_tokens_per_job,
};
let cm = Arc::new(ContextManager::new(5));
let llm: Arc<dyn LlmProvider> = Arc::new(StubLlm);
let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
max_output_length: 100_000,
injection_check_enabled: false,
}));
let tools = Arc::new(ToolRegistry::new());
let hooks = Arc::new(HookRegistry::default());
Scheduler::new(config, cm, llm, safety, tools, None, hooks)
}
#[tokio::test]
async fn test_dispatch_job_caps_user_max_tokens() {
let sched = make_test_scheduler(1000);
let meta = serde_json::json!({ "max_tokens": 5000 });
let job_id = sched
.dispatch_job("user1", "test", "desc", Some(meta))
.await
.unwrap();
let ctx = sched.context_manager.get_context(job_id).await.unwrap();
assert_eq!(ctx.max_tokens, 1000, "should cap at configured limit");
}
#[tokio::test]
async fn test_dispatch_job_unlimited_config_preserves_user_tokens() {
let sched = make_test_scheduler(0); // 0 = unlimited
let meta = serde_json::json!({ "max_tokens": 5000 });
let job_id = sched
.dispatch_job("user1", "test", "desc", Some(meta))
.await
.unwrap();
let ctx = sched.context_manager.get_context(job_id).await.unwrap();
assert_eq!(
ctx.max_tokens, 5000,
"unlimited config should preserve user value"
);
}
#[tokio::test]
async fn test_dispatch_job_no_user_tokens_uses_config() {
let sched = make_test_scheduler(2000);
let job_id = sched
.dispatch_job("user1", "test", "desc", None)
.await
.unwrap();
let ctx = sched.context_manager.get_context(job_id).await.unwrap();
assert_eq!(
ctx.max_tokens, 2000,
"should use config default when no user value"
);
}
#[tokio::test]
async fn test_dispatch_job_atomic_metadata_and_tokens() {
let sched = make_test_scheduler(10_000);
let meta = serde_json::json!({
"max_tokens": 3000,
"custom_key": "custom_value"
});
let job_id = sched
.dispatch_job("user1", "test", "desc", Some(meta))
.await
.unwrap();
let ctx = sched.context_manager.get_context(job_id).await.unwrap();
assert_eq!(ctx.max_tokens, 3000, "should use user value within limit");
assert_eq!(
ctx.metadata.get("custom_key").and_then(|v| v.as_str()),
Some("custom_value"),
"metadata should be set atomically with token budget"
);
}
#[test]
fn test_scheduler_creation() {
+9 -7
View File
@@ -334,21 +334,22 @@ impl RepairTask {
// Check for stuck jobs
let stuck_jobs = self.repair.detect_stuck_jobs().await;
for job in stuck_jobs {
tracing::info!("Attempting to repair stuck job {}", job.job_id);
match self.repair.repair_stuck_job(&job).await {
Ok(RepairResult::Success { message }) => {
tracing::info!(job = %job.job_id, status = "success", "Stuck job repair completed: {}", message);
tracing::info!("Repair succeeded: {}", message);
}
Ok(RepairResult::Retry { message }) => {
tracing::debug!(job = %job.job_id, status = "retry", "Stuck job repair needs retry: {}", message);
tracing::warn!("Repair needs retry: {}", message);
}
Ok(RepairResult::Failed { message }) => {
tracing::error!(job = %job.job_id, status = "failed", "Stuck job repair failed: {}", message);
tracing::error!("Repair failed: {}", message);
}
Ok(RepairResult::ManualRequired { message }) => {
tracing::warn!(job = %job.job_id, status = "manual", "Stuck job repair requires manual intervention: {}", message);
tracing::warn!("Manual intervention needed: {}", message);
}
Err(e) => {
tracing::error!(job = %job.job_id, "Stuck job repair error: {}", e);
tracing::error!("Repair error: {}", e);
}
}
}
@@ -356,12 +357,13 @@ impl RepairTask {
// Check for broken tools
let broken_tools = self.repair.detect_broken_tools().await;
for tool in broken_tools {
tracing::info!("Attempting to repair broken tool: {}", tool.name);
match self.repair.repair_broken_tool(&tool).await {
Ok(result) => {
tracing::debug!(tool = %tool.name, status = "completed", "Tool repair completed: {:?}", result);
tracing::info!("Tool repair result: {:?}", result);
}
Err(e) => {
tracing::error!(tool = %tool.name, "Tool repair error: {}", e);
tracing::error!("Tool repair error: {}", e);
}
}
}
+138 -266
View File
@@ -23,14 +23,6 @@ use crate::error::Error;
use crate::llm::{ChatMessage, ToolCall};
use crate::tools::redact_params;
const FORGED_THREAD_ID_ERROR: &str = "Invalid or unauthorized thread ID.";
fn requires_preexisting_uuid_thread(channel: &str) -> bool {
// Gateway-style channels send server-issued conversation UUIDs.
// Unknown UUIDs should be rejected instead of silently creating a new thread.
matches!(channel, "gateway" | "test")
}
impl Agent {
/// Hydrate a historical thread from DB into memory if not already present.
///
@@ -45,11 +37,11 @@ impl Agent {
&self,
message: &IncomingMessage,
external_thread_id: &str,
) -> Option<String> {
) {
// Only hydrate UUID-shaped thread IDs (web gateway uses UUIDs)
let thread_uuid = match Uuid::parse_str(external_thread_id) {
Ok(id) => id,
Err(_) => return None,
Err(_) => return,
};
// Check if already in memory
@@ -60,7 +52,7 @@ impl Agent {
{
let sess = session.lock().await;
if sess.threads.contains_key(&thread_uuid) {
return None;
return;
}
}
@@ -69,62 +61,6 @@ impl Agent {
let msg_count;
if let Some(store) = self.store() {
// Never hydrate history from a conversation UUID that isn't owned
// by the current authenticated user.
let owned = match store
.conversation_belongs_to_user(thread_uuid, &message.user_id)
.await
{
Ok(v) => v,
Err(e) => {
tracing::warn!(
"Failed to verify conversation ownership for hydration {}: {}",
thread_uuid,
e
);
if requires_preexisting_uuid_thread(&message.channel) {
return Some(FORGED_THREAD_ID_ERROR.to_string());
}
return None;
}
};
if !owned {
let exists = match store.get_conversation_metadata(thread_uuid).await {
Ok(Some(_)) => true,
Ok(None) => false,
Err(e) => {
tracing::warn!(
"Failed to inspect conversation metadata for hydration {}: {}",
thread_uuid,
e
);
if requires_preexisting_uuid_thread(&message.channel) {
return Some(FORGED_THREAD_ID_ERROR.to_string());
}
return None;
}
};
if requires_preexisting_uuid_thread(&message.channel) {
tracing::warn!(
user = %message.user_id,
channel = %message.channel,
thread_id = %thread_uuid,
exists,
"Rejected message for unavailable thread id"
);
return Some(FORGED_THREAD_ID_ERROR.to_string());
}
tracing::warn!(
user = %message.user_id,
thread_id = %thread_uuid,
exists,
"Skipped hydration for thread id not owned by sender"
);
return None;
}
let db_messages = store
.list_conversation_messages(thread_uuid)
.await
@@ -168,8 +104,6 @@ impl Agent {
thread_uuid,
msg_count
);
None
}
pub(super) async fn process_user_input(
@@ -179,13 +113,6 @@ impl Agent {
thread_id: Uuid,
content: &str,
) -> Result<SubmissionResult, Error> {
tracing::debug!(
message_id = %message.id,
thread_id = %thread_id,
content_len = content.len(),
"Processing user input"
);
// First check thread state without holding lock during I/O
let thread_state = {
let sess = session.lock().await;
@@ -196,41 +123,19 @@ impl Agent {
thread.state
};
tracing::debug!(
message_id = %message.id,
thread_id = %thread_id,
thread_state = ?thread_state,
"Checked thread state"
);
// Check thread state
match thread_state {
ThreadState::Processing => {
tracing::warn!(
message_id = %message.id,
thread_id = %thread_id,
"Thread is processing, rejecting new input"
);
return Ok(SubmissionResult::error(
"Turn in progress. Use /interrupt to cancel.",
));
}
ThreadState::AwaitingApproval => {
tracing::warn!(
message_id = %message.id,
thread_id = %thread_id,
"Thread awaiting approval, rejecting new input"
);
return Ok(SubmissionResult::error(
"Waiting for approval. Use /interrupt to cancel.",
));
}
ThreadState::Completed => {
tracing::warn!(
message_id = %message.id,
thread_id = %thread_id,
"Thread completed, rejecting new input"
);
return Ok(SubmissionResult::error(
"Thread completed. Use /thread new.",
));
@@ -364,24 +269,8 @@ impl Agent {
};
// Persist user message to DB immediately so it survives crashes
tracing::debug!(
message_id = %message.id,
thread_id = %thread_id,
"Persisting user message to DB"
);
self.persist_user_message(
thread_id,
&message.channel,
&message.user_id,
effective_content,
)
.await;
tracing::debug!(
message_id = %message.id,
thread_id = %thread_id,
"User message persisted, starting agentic loop"
);
self.persist_user_message(thread_id, &message.user_id, effective_content)
.await;
// Send thinking status
let _ = self
@@ -457,21 +346,10 @@ impl Agent {
.await;
// Persist tool calls then assistant response (user message already persisted at turn start)
self.persist_tool_calls(
thread_id,
&message.channel,
&message.user_id,
turn_number,
&tool_calls,
)
.await;
self.persist_assistant_response(
thread_id,
&message.channel,
&message.user_id,
&response,
)
.await;
self.persist_tool_calls(thread_id, &message.user_id, turn_number, &tool_calls)
.await;
self.persist_assistant_response(thread_id, &message.user_id, &response)
.await;
Ok(SubmissionResult::response(response))
}
@@ -505,41 +383,6 @@ impl Agent {
}
}
/// Ensure a thread UUID is writable for `(channel, user_id)`.
///
/// Returns `false` for foreign/unowned conversation IDs or DB errors.
async fn ensure_writable_conversation(
&self,
store: &Arc<dyn crate::db::Database>,
thread_id: Uuid,
channel: &str,
user_id: &str,
) -> bool {
match store
.ensure_conversation(thread_id, channel, user_id, None)
.await
{
Ok(true) => true,
Ok(false) => {
tracing::warn!(
user = %user_id,
channel = %channel,
thread_id = %thread_id,
"Rejected write for unavailable thread id"
);
false
}
Err(e) => {
tracing::warn!(
"Failed to ensure writable conversation {}: {}",
thread_id,
e
);
false
}
}
}
/// Persist the user message to the DB at turn start (before the agentic loop).
///
/// This ensures the user message is durable even if the process crashes
@@ -547,7 +390,6 @@ impl Agent {
pub(super) async fn persist_user_message(
&self,
thread_id: Uuid,
channel: &str,
user_id: &str,
user_input: &str,
) {
@@ -556,10 +398,11 @@ impl Agent {
None => return,
};
if !self
.ensure_writable_conversation(&store, thread_id, channel, user_id)
if let Err(e) = store
.ensure_conversation(thread_id, "gateway", user_id, None)
.await
{
tracing::warn!("Failed to ensure conversation {}: {}", thread_id, e);
return;
}
@@ -579,7 +422,6 @@ impl Agent {
pub(super) async fn persist_assistant_response(
&self,
thread_id: Uuid,
channel: &str,
user_id: &str,
response: &str,
) {
@@ -588,10 +430,11 @@ impl Agent {
None => return,
};
if !self
.ensure_writable_conversation(&store, thread_id, channel, user_id)
if let Err(e) = store
.ensure_conversation(thread_id, "gateway", user_id, None)
.await
{
tracing::warn!("Failed to ensure conversation {}: {}", thread_id, e);
return;
}
@@ -611,7 +454,6 @@ impl Agent {
pub(super) async fn persist_tool_calls(
&self,
thread_id: Uuid,
channel: &str,
user_id: &str,
turn_number: usize,
tool_calls: &[crate::agent::session::TurnToolCall],
@@ -661,10 +503,11 @@ impl Agent {
}
};
if !self
.ensure_writable_conversation(&store, thread_id, channel, user_id)
if let Err(e) = store
.ensure_conversation(thread_id, "gateway", user_id, None)
.await
{
tracing::warn!("Failed to ensure conversation {}: {}", thread_id, e);
return;
}
@@ -969,12 +812,19 @@ impl Agent {
// Sanitize tool result, then record the cleaned version in the
// thread. Must happen before auth intercept check which may return early.
let is_tool_error = tool_result.is_err();
let (result_content, _) = crate::tools::execute::process_tool_result(
self.safety(),
&pending.tool_name,
&pending.tool_call_id,
&tool_result,
);
let result_content = match &tool_result {
Ok(output) => {
let sanitized = self
.safety()
.sanitize_tool_output(&pending.tool_name, output);
self.safety().wrap_for_llm(
&pending.tool_name,
&sanitized.content,
sanitized.was_modified,
)
}
Err(e) => format!("Error: {}", e),
};
// Record sanitized result in thread
{
@@ -1042,20 +892,14 @@ impl Agent {
for (idx, tc) in deferred_tool_calls.iter().enumerate() {
if let Some(tool) = self.tools().get(&tc.name).await {
// Match dispatcher.rs: when auto_approve_tools is true, skip
// all approval checks (including ApprovalRequirement::Always).
let needs_approval = if self.config.auto_approve_tools {
false
} else {
use crate::tools::ApprovalRequirement;
match tool.requires_approval(&tc.arguments) {
ApprovalRequirement::Never => false,
ApprovalRequirement::UnlessAutoApproved => {
let sess = session.lock().await;
!sess.is_tool_auto_approved(&tc.name)
}
ApprovalRequirement::Always => true,
use crate::tools::ApprovalRequirement;
let needs_approval = match tool.requires_approval(&tc.arguments) {
ApprovalRequirement::Never => false,
ApprovalRequirement::UnlessAutoApproved => {
let sess = session.lock().await;
!sess.is_tool_auto_approved(&tc.name)
}
ApprovalRequirement::Always => true,
};
if needs_approval {
@@ -1220,12 +1064,17 @@ impl Agent {
// Sanitize first, then record the cleaned version in thread.
// Must happen before auth detection which may set deferred_auth.
let is_deferred_error = deferred_result.is_err();
let (deferred_content, _) = crate::tools::execute::process_tool_result(
self.safety(),
&tc.name,
&tc.id,
&deferred_result,
);
let deferred_content = match &deferred_result {
Ok(output) => {
let sanitized = self.safety().sanitize_tool_output(&tc.name, output);
self.safety().wrap_for_llm(
&tc.name,
&sanitized.content,
sanitized.was_modified,
)
}
Err(e) => format!("Error: {}", e),
};
// Record sanitized result in thread
{
@@ -1331,21 +1180,10 @@ impl Agent {
.map(|t| (t.turn_number, t.tool_calls.clone()))
.unwrap_or_default();
// User message already persisted at turn start; save tool calls then assistant response
self.persist_tool_calls(
thread_id,
&message.channel,
&message.user_id,
turn_number,
&tool_calls,
)
.await;
self.persist_assistant_response(
thread_id,
&message.channel,
&message.user_id,
&response,
)
.await;
self.persist_tool_calls(thread_id, &message.user_id, turn_number, &tool_calls)
.await;
self.persist_assistant_response(thread_id, &message.user_id, &response)
.await;
let _ = self
.channels
.send_status(
@@ -1398,13 +1236,8 @@ impl Agent {
thread.clear_pending_approval();
thread.complete_turn(&rejection);
// User message already persisted at turn start; save rejection response
self.persist_assistant_response(
thread_id,
&message.channel,
&message.user_id,
&rejection,
)
.await;
self.persist_assistant_response(thread_id, &message.user_id, &rejection)
.await;
}
}
@@ -1442,13 +1275,8 @@ impl Agent {
thread.enter_auth_mode(ext_name.clone());
thread.complete_turn(&instructions);
// User message already persisted at turn start; save auth instructions
self.persist_assistant_response(
thread_id,
&message.channel,
&message.user_id,
&instructions,
)
.await;
self.persist_assistant_response(thread_id, &message.user_id, &instructions)
.await;
}
}
let _ = self
@@ -1493,56 +1321,100 @@ impl Agent {
None => return Ok(Some("Extension manager not available.".to_string())),
};
match ext_mgr
.configure_token(&pending.extension_name, token)
.await
{
Ok(result) => {
match ext_mgr.auth(&pending.extension_name, Some(token)).await {
Ok(result) if result.is_authenticated() => {
tracing::info!(
"Extension '{}' configured via auth mode: {}",
pending.extension_name,
result.message
"Extension '{}' authenticated via auth mode",
pending.extension_name
);
// Auto-activate so tools are available immediately after auth
match ext_mgr.activate(&pending.extension_name).await {
Ok(activate_result) => {
let tool_count = activate_result.tools_loaded.len();
let tool_list = if activate_result.tools_loaded.is_empty() {
String::new()
} else {
format!("\n\nTools: {}", activate_result.tools_loaded.join(", "))
};
let msg = format!(
"{} authenticated and activated ({} tools loaded).{}",
pending.extension_name, tool_count, tool_list
);
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::AuthCompleted {
extension_name: pending.extension_name.clone(),
success: true,
message: msg.clone(),
},
&message.metadata,
)
.await;
Ok(Some(msg))
}
Err(e) => {
tracing::warn!(
"Extension '{}' authenticated but activation failed: {}",
pending.extension_name,
e
);
let msg = format!(
"{} authenticated successfully, but activation failed: {}. \
Try activating manually.",
pending.extension_name, e
);
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::AuthCompleted {
extension_name: pending.extension_name.clone(),
success: true,
message: msg.clone(),
},
&message.metadata,
)
.await;
Ok(Some(msg))
}
}
}
Ok(result) => {
// Invalid token, re-enter auth mode
{
let mut sess = session.lock().await;
if let Some(thread) = sess.threads.get_mut(&thread_id) {
thread.enter_auth_mode(pending.extension_name.clone());
}
}
let msg = result
.instructions()
.map(String::from)
.unwrap_or_else(|| "Invalid token. Please try again.".to_string());
// Re-emit AuthRequired so web UI re-shows the card
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::AuthCompleted {
StatusUpdate::AuthRequired {
extension_name: pending.extension_name.clone(),
success: true,
message: result.message.clone(),
instructions: Some(msg.clone()),
auth_url: result.auth_url().map(String::from),
setup_url: result.setup_url().map(String::from),
},
&message.metadata,
)
.await;
Ok(Some(result.message))
Ok(Some(msg))
}
Err(e) => {
let msg = e.to_string();
// Token validation errors: re-enter auth mode and re-prompt
if matches!(e, crate::extensions::ExtensionError::ValidationFailed(_)) {
{
let mut sess = session.lock().await;
if let Some(thread) = sess.threads.get_mut(&thread_id) {
thread.enter_auth_mode(pending.extension_name.clone());
}
}
let _ = self
.channels
.send_status(
&message.channel,
StatusUpdate::AuthRequired {
extension_name: pending.extension_name.clone(),
instructions: Some(msg.clone()),
auth_url: None,
setup_url: None,
},
&message.metadata,
)
.await;
return Ok(Some(msg));
}
// Infrastructure errors
let msg = format!(
"Authentication failed for {}: {}",
pending.extension_name, e
);
let _ = self
.channels
.send_status(
File diff suppressed because it is too large Load Diff
+205 -68
View File
@@ -77,7 +77,10 @@ pub struct AppBuilder {
llm_override: Option<Arc<dyn LlmProvider>>,
// Backend-specific handles needed by secrets store
handles: Option<crate::db::DatabaseHandles>,
#[cfg(feature = "postgres")]
pg_pool: Option<deadpool_postgres::Pool>,
#[cfg(feature = "libsql")]
libsql_db: Option<Arc<libsql::Database>>,
}
impl AppBuilder {
@@ -102,7 +105,10 @@ impl AppBuilder {
db: None,
secrets_store: None,
llm_override: None,
handles: None,
#[cfg(feature = "postgres")]
pg_pool: None,
#[cfg(feature = "libsql")]
libsql_db: None,
}
}
@@ -131,10 +137,71 @@ impl AppBuilder {
return Ok(());
}
let (db, handles) = crate::db::connect_with_handles(&self.config.database)
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
self.handles = Some(handles);
let db: Arc<dyn Database> = match self.config.database.backend {
#[cfg(feature = "libsql")]
crate::config::DatabaseBackend::LibSql => {
use crate::db::Database as _;
use crate::db::libsql::LibSqlBackend;
use secrecy::ExposeSecret as _;
let default_path = crate::config::default_libsql_path();
let db_path = self
.config
.database
.libsql_path
.as_deref()
.unwrap_or(&default_path);
let backend = if let Some(ref url) = self.config.database.libsql_url {
let token =
self.config
.database
.libsql_auth_token
.as_ref()
.ok_or_else(|| {
anyhow::anyhow!(
"LIBSQL_AUTH_TOKEN is required when LIBSQL_URL is set"
)
})?;
LibSqlBackend::new_remote_replica(db_path, url, token.expose_secret()).await?
} else {
LibSqlBackend::new_local(db_path).await?
};
backend.run_migrations().await?;
tracing::info!("libSQL database connected and migrations applied");
#[cfg(feature = "libsql")]
{
self.libsql_db = Some(backend.shared_db());
}
Arc::new(backend) as Arc<dyn Database>
}
#[cfg(feature = "postgres")]
_ => {
use crate::db::Database as _;
let pg = crate::db::postgres::PgBackend::new(&self.config.database)
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
pg.run_migrations()
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;
tracing::info!("PostgreSQL database connected and migrations applied");
#[cfg(feature = "postgres")]
{
self.pg_pool = Some(pg.pool());
}
Arc::new(pg) as Arc<dyn Database>
}
#[cfg(not(feature = "postgres"))]
_ => {
anyhow::bail!(
"No database backend available. Enable 'postgres' or 'libsql' feature."
);
}
};
// Post-init: migrate disk config, reload config from DB, attach session, cleanup
if let Err(e) = crate::bootstrap::migrate_disk_to_db(db.as_ref(), "default").await {
@@ -145,7 +212,7 @@ impl AppBuilder {
match Config::from_db_with_toml(db.as_ref(), "default", toml_path).await {
Ok(db_config) => {
self.config = db_config;
tracing::debug!("Configuration reloaded from database");
tracing::info!("Configuration reloaded from database");
}
Err(e) => {
tracing::warn!(
@@ -184,7 +251,10 @@ impl AppBuilder {
crate::config::inject_os_credentials();
// Consume unused handles
self.handles.take();
#[cfg(feature = "libsql")]
{
self.libsql_db.take();
}
// Re-resolve only the LLM config with OS credentials.
let store: Option<&(dyn crate::db::SettingsStore + Sync)> =
@@ -208,16 +278,35 @@ impl AppBuilder {
Ok(c) => Arc::new(c),
Err(e) => {
tracing::warn!("Failed to initialize secrets crypto: {}", e);
self.handles.take();
#[cfg(feature = "libsql")]
{
self.libsql_db.take();
}
return Ok(());
}
};
// Fallback covers the no-database path where `init_database` returned
// early before populating `self.handles`.
let empty_handles = crate::db::DatabaseHandles::default();
let handles = self.handles.as_ref().unwrap_or(&empty_handles);
let store = crate::secrets::create_secrets_store(crypto, handles);
let store: Option<Arc<dyn SecretsStore + Send + Sync>> = None;
#[cfg(feature = "libsql")]
let store = store.or_else(|| {
self.libsql_db.take().map(|db| {
Arc::new(crate::secrets::LibSqlSecretsStore::new(
db,
Arc::clone(&crypto),
)) as Arc<dyn SecretsStore + Send + Sync>
})
});
#[cfg(feature = "postgres")]
let store = store.or_else(|| {
self.pg_pool.as_ref().map(|pool| {
Arc::new(crate::secrets::PostgresSecretsStore::new(
pool.clone(),
Arc::clone(&crypto),
)) as Arc<dyn SecretsStore + Send + Sync>
})
});
if let Some(ref secrets) = store {
// Inject LLM API keys from encrypted storage
@@ -274,7 +363,7 @@ impl AppBuilder {
anyhow::Error,
> {
let safety = Arc::new(SafetyLayer::new(&self.config.safety));
tracing::debug!("Safety layer initialized");
tracing::info!("Safety layer initialized");
// Initialize tool registry with credential injection support
let credential_registry = Arc::new(SharedCredentialRegistry::new());
@@ -361,7 +450,7 @@ impl AppBuilder {
tools
.register_builder_tool(llm.clone(), Some(self.config.builder.to_builder_config()))
.await;
tracing::debug!("Builder mode enabled");
tracing::info!("Builder mode enabled");
}
Ok((safety, tools, embeddings, workspace))
@@ -383,7 +472,9 @@ impl AppBuilder {
),
anyhow::Error,
> {
use crate::tools::mcp::config::load_mcp_servers_from_db;
use crate::tools::mcp::{
McpClient, McpTransport, config::load_mcp_servers_from_db, is_authenticated,
};
use crate::tools::wasm::{WasmToolLoader, load_dev_tools};
let mcp_session_manager = Arc::new(McpSessionManager::new());
@@ -419,7 +510,7 @@ impl AppBuilder {
match loader.load_from_dir(&wasm_config.tools_dir).await {
Ok(results) => {
if !results.loaded.is_empty() {
tracing::debug!(
tracing::info!(
"Loaded {} WASM tools from {}",
results.loaded.len(),
wasm_config.tools_dir.display()
@@ -442,7 +533,7 @@ impl AppBuilder {
Ok(results) => {
dev_loaded_tool_names.extend(results.loaded.iter().cloned());
if !dev_loaded_tool_names.is_empty() {
tracing::debug!(
tracing::info!(
"Loaded {} dev WASM tools from build artifacts",
dev_loaded_tool_names.len()
);
@@ -474,10 +565,7 @@ impl AppBuilder {
Ok(servers) => {
let enabled: Vec<_> = servers.enabled_servers().cloned().collect();
if !enabled.is_empty() {
tracing::debug!(
"Loading {} configured MCP server(s)...",
enabled.len()
);
tracing::info!("Loading {} configured MCP server(s)...", enabled.len());
}
let mut join_set = tokio::task::JoinSet::new();
@@ -490,24 +578,95 @@ impl AppBuilder {
join_set.spawn(async move {
let server_name = server.name.clone();
let client = match crate::tools::mcp::create_client_from_config(
server,
&mcp_sm,
&pm,
secrets,
"default",
)
.await
{
Ok(c) => c,
Err(e) => {
let client: McpClient = match server.effective_transport() {
crate::tools::mcp::config::EffectiveTransport::Stdio {
command,
args,
env,
} => {
match pm
.spawn_stdio(
&server_name,
command,
args.to_vec(),
env.clone(),
)
.await
{
Ok(transport) => McpClient::new_with_transport(
&server_name,
transport as Arc<dyn McpTransport>,
None,
secrets,
"default",
Some(server),
),
Err(e) => {
tracing::warn!(
"Failed to spawn stdio MCP server '{}': {}",
server_name,
e
);
return;
}
}
}
#[cfg(unix)]
crate::tools::mcp::config::EffectiveTransport::Unix {
socket_path,
} => {
match crate::tools::mcp::unix_transport::UnixMcpTransport::connect(
&server_name,
socket_path,
)
.await
{
Ok(transport) => McpClient::new_with_transport(
&server_name,
Arc::new(transport) as Arc<dyn McpTransport>,
None,
secrets,
"default",
Some(server),
),
Err(e) => {
tracing::warn!(
"Failed to connect to Unix MCP server '{}': {}",
server_name,
e
);
return;
}
}
}
#[cfg(not(unix))]
crate::tools::mcp::config::EffectiveTransport::Unix { .. } => {
tracing::warn!(
"Failed to create MCP client for '{}': {}",
server_name,
e
"Unix socket transport is not supported on this platform (server '{}')",
server_name
);
return;
}
crate::tools::mcp::config::EffectiveTransport::Http => {
if let Some(ref secrets) = secrets {
let has_tokens =
is_authenticated(&server, secrets, "default")
.await;
if has_tokens || server.requires_auth() {
McpClient::new_authenticated(
server,
Arc::clone(&mcp_sm),
Arc::clone(secrets),
"default",
)
} else {
McpClient::new_with_config(server)
}
} else {
McpClient::new_with_config(server)
}
}
};
match client.list_tools().await {
@@ -518,7 +677,7 @@ impl AppBuilder {
for tool in tool_impls {
tools.register(tool).await;
}
tracing::debug!(
tracing::info!(
"Loaded {} tools from MCP server '{}'",
tool_count,
server_name
@@ -563,19 +722,7 @@ impl AppBuilder {
}
}
Err(e) => {
if matches!(
e,
crate::tools::mcp::config::ConfigError::InvalidConfig { .. }
| crate::tools::mcp::config::ConfigError::Json(_)
) {
tracing::warn!(
"MCP server configuration is invalid: {}. \
Fix or remove the corrupted config.",
e
);
} else {
tracing::debug!("No MCP servers configured ({})", e);
}
tracing::debug!("No MCP servers configured ({})", e);
}
}
}
@@ -584,14 +731,14 @@ impl AppBuilder {
let (dev_loaded_tool_names, _) = tokio::join!(wasm_tools_future, mcp_servers_future);
// Load registry catalog entries for extension discovery
let mut catalog_entries = match crate::registry::RegistryCatalog::load_or_embedded() {
let catalog_entries = match crate::registry::RegistryCatalog::load_or_embedded() {
Ok(catalog) => {
let entries: Vec<_> = catalog
.all()
.iter()
.map(|m| m.to_registry_entry())
.collect();
tracing::debug!(
tracing::info!(
count = entries.len(),
"Loaded registry catalog entries for extension discovery"
);
@@ -603,15 +750,6 @@ impl AppBuilder {
}
};
// Append builtin entries (e.g. channel-relay integrations) so they appear
// in the web UI's available extensions list.
let builtin = crate::extensions::registry::builtin_entries();
for entry in builtin {
if !catalog_entries.iter().any(|e| e.name == entry.name) {
catalog_entries.push(entry);
}
}
// Create extension manager. Use ephemeral in-memory secrets if no
// persistent store is configured (listing/install/activate still work).
let ext_secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync> = if let Some(ref s) =
@@ -629,7 +767,6 @@ impl AppBuilder {
let extension_manager = {
let manager = Arc::new(ExtensionManager::new(
Arc::clone(&mcp_session_manager),
Arc::clone(&mcp_process_manager),
ext_secrets,
Arc::clone(tools),
Some(Arc::clone(hooks)),
@@ -642,7 +779,7 @@ impl AppBuilder {
catalog_entries.clone(),
));
tools.register_extension_tools(Arc::clone(&manager));
tracing::debug!("Extension manager initialized with in-chat discovery tools");
tracing::info!("Extension manager initialized with in-chat discovery tools");
Some(manager)
};
@@ -713,7 +850,7 @@ impl AppBuilder {
let import_path = std::path::Path::new(&import_dir);
match ws.import_from_directory(import_path).await {
Ok(count) if count > 0 => {
tracing::debug!("Imported {} workspace file(s) from {}", count, import_dir);
tracing::info!("Imported {} workspace file(s) from {}", count, import_dir);
}
Ok(_) => {}
Err(e) => {
@@ -738,7 +875,7 @@ impl AppBuilder {
tokio::spawn(async move {
match ws_bg.backfill_embeddings().await {
Ok(count) if count > 0 => {
tracing::debug!("Backfilled embeddings for {} chunks", count);
tracing::info!("Backfilled embeddings for {} chunks", count);
}
Ok(_) => {}
Err(e) => {
@@ -755,7 +892,7 @@ impl AppBuilder {
.with_installed_dir(self.config.skills.installed_dir.clone());
let loaded = registry.discover_all().await;
if !loaded.is_empty() {
tracing::debug!("Loaded {} skill(s): {}", loaded.len(), loaded.join(", "));
tracing::info!("Loaded {} skill(s): {}", loaded.len(), loaded.join(", "));
}
let registry = Arc::new(std::sync::RwLock::new(registry));
let catalog = crate::skills::catalog::shared_catalog();
@@ -773,7 +910,7 @@ impl AppBuilder {
},
));
tracing::debug!(
tracing::info!(
"Tool registry initialized with {} total tools",
tools.count()
);
+3 -168
View File
@@ -116,18 +116,9 @@ pub fn load_ironclaw_env() {
.join(".ironclaw")
.join("ironclaw.db");
if default_db.exists() {
if tokio::runtime::Handle::try_current().is_ok() {
// Tokio runtime is active (multi-threaded); std::env::set_var is UB here.
// Fall back to the thread-safe runtime overlay so the value is always set.
tracing::warn!(
"load_ironclaw_env called with active Tokio runtime; \
using runtime env overlay for DATABASE_BACKEND"
);
crate::config::set_runtime_env("DATABASE_BACKEND", "libsql");
} else {
// SAFETY: No Tokio runtime = no other threads = safe to call set_var.
unsafe { std::env::set_var("DATABASE_BACKEND", "libsql") };
}
// SAFETY: `load_ironclaw_env` is called from a synchronous `fn main()`
// before the Tokio runtime is started, so no other threads exist yet.
unsafe { std::env::set_var("DATABASE_BACKEND", "libsql") };
}
}
}
@@ -207,58 +198,6 @@ pub fn save_bootstrap_env_to(path: &std::path::Path, vars: &[(&str, &str)]) -> s
Ok(())
}
/// Update or add multiple variables in `~/.ironclaw/.env`, preserving existing content.
///
/// Like `upsert_bootstrap_var` but batched — replaces lines for any key in `vars`
/// and preserves all other existing lines. Use this instead of `save_bootstrap_env`
/// when you want to update specific keys without destroying user-added variables.
pub fn upsert_bootstrap_vars(vars: &[(&str, &str)]) -> std::io::Result<()> {
upsert_bootstrap_vars_to(&ironclaw_env_path(), vars)
}
/// Update or add multiple variables at an arbitrary path (testable variant).
pub fn upsert_bootstrap_vars_to(
path: &std::path::Path,
vars: &[(&str, &str)],
) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let keys_being_written: std::collections::HashSet<&str> =
vars.iter().map(|(k, _)| *k).collect();
let existing = match std::fs::read_to_string(path) {
Ok(contents) => contents,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e),
};
let mut result = String::new();
for line in existing.lines() {
// Extract key from lines matching `KEY=...`
let is_overwritten = line
.split_once('=')
.map(|(k, _)| keys_being_written.contains(k.trim()))
.unwrap_or(false);
if !is_overwritten {
result.push_str(line);
result.push('\n');
}
}
// Append all new key=value pairs
for (key, value) in vars {
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
result.push_str(&format!("{}=\"{}\"\n", key, escaped));
}
std::fs::write(path, &result)?;
restrict_file_permissions(path)?;
Ok(())
}
/// Update or add a single variable in `~/.ironclaw/.env`, preserving existing content.
///
/// Unlike `save_bootstrap_env` (which overwrites the entire file), this
@@ -1298,108 +1237,4 @@ INJECTED="pwned"#;
let lock = PidLock::acquire_at(pid_path).unwrap();
drop(lock);
}
#[test]
fn upsert_bootstrap_vars_preserves_unknown_keys() {
let dir = tempdir().unwrap();
let env_path = dir.path().join(".env");
// Simulate a user-edited .env with custom vars
let initial =
"HTTP_HOST=\"0.0.0.0\"\nDATABASE_BACKEND=\"postgres\"\nCUSTOM_VAR=\"keep_me\"\n";
std::fs::write(&env_path, initial).unwrap();
// Upsert wizard vars — should preserve HTTP_HOST and CUSTOM_VAR
let vars = [("DATABASE_BACKEND", "libsql"), ("LLM_BACKEND", "openai")];
upsert_bootstrap_vars_to(&env_path, &vars).unwrap();
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert_eq!(
parsed.len(),
4,
"should have 4 vars (2 preserved + 2 upserted)"
);
// User-added vars must be preserved
assert!(
parsed
.iter()
.any(|(k, v)| k == "HTTP_HOST" && v == "0.0.0.0"),
"HTTP_HOST must be preserved"
);
assert!(
parsed
.iter()
.any(|(k, v)| k == "CUSTOM_VAR" && v == "keep_me"),
"CUSTOM_VAR must be preserved"
);
// Wizard vars must be updated/added
assert!(
parsed
.iter()
.any(|(k, v)| k == "DATABASE_BACKEND" && v == "libsql"),
"DATABASE_BACKEND must be updated to libsql"
);
assert!(
parsed
.iter()
.any(|(k, v)| k == "LLM_BACKEND" && v == "openai"),
"LLM_BACKEND must be added"
);
// Now update LLM_BACKEND and verify HTTP_HOST still preserved
let vars2 = [("LLM_BACKEND", "anthropic")];
upsert_bootstrap_vars_to(&env_path, &vars2).unwrap();
let parsed2: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert_eq!(
parsed2.len(),
4,
"should still have 4 vars after second upsert"
);
assert!(
parsed2
.iter()
.any(|(k, v)| k == "HTTP_HOST" && v == "0.0.0.0"),
"HTTP_HOST must still be preserved after second upsert"
);
assert!(
parsed2
.iter()
.any(|(k, v)| k == "LLM_BACKEND" && v == "anthropic"),
"LLM_BACKEND must be updated to anthropic"
);
}
#[test]
fn upsert_bootstrap_vars_creates_file_if_missing() {
let dir = tempdir().unwrap();
let env_path = dir.path().join("subdir").join(".env");
// File doesn't exist yet
assert!(!env_path.exists());
let vars = [("DATABASE_BACKEND", "libsql")];
upsert_bootstrap_vars_to(&env_path, &vars).unwrap();
assert!(env_path.exists());
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert_eq!(parsed.len(), 1);
assert_eq!(
parsed[0],
("DATABASE_BACKEND".to_string(), "libsql".to_string())
);
}
}
+2 -21
View File
@@ -344,28 +344,9 @@ pub trait Channel: Send + Sync {
}
}
/// Trait for channels that support hot-secret-swapping during SIGHUP reload.
///
/// This allows channels to update authentication credentials without restarting,
/// enabling zero-downtime configuration reloads. Channels that don't support
/// secret updates can simply not implement this trait.
#[async_trait]
pub trait ChannelSecretUpdater: Send + Sync {
/// Update the secret for this channel.
///
/// Called during SIGHUP configuration reload. Implementation should:
/// - Apply the new secret atomically
/// - Not fail the entire reload if secret update fails
/// - Log appropriate errors/info messages
///
/// The secret is optional (may be None if secret is no longer configured).
async fn update_secret(&self, new_secret: Option<secrecy::SecretString>);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::credentials::TEST_REDACT_SECRET_123;
/// Stub tool that marks `"value"` as sensitive.
struct SecretTool;
@@ -395,7 +376,7 @@ mod tests {
#[test]
fn tool_completed_redacts_sensitive_params_on_failure() {
let params = serde_json::json!({"name": "api_key", "value": TEST_REDACT_SECRET_123});
let params = serde_json::json!({"name": "api_key", "value": "sk-secret-123"});
let err: Result<String, crate::error::Error> =
Err(crate::error::ToolError::ExecutionFailed {
name: "secret_save".into(),
@@ -430,7 +411,7 @@ mod tests {
param_str
);
assert!(
!param_str.contains(TEST_REDACT_SECRET_123),
!param_str.contains("sk-secret-123"),
"raw secret should not appear: {}",
param_str
);
+59 -606
View File
@@ -6,45 +6,36 @@ use async_trait::async_trait;
use axum::{
Json, Router,
extract::{DefaultBodyLimit, State},
http::{HeaderMap, StatusCode},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
};
use bytes::Bytes;
use hmac::{Hmac, Mac};
use secrecy::{ExposeSecret, SecretString};
use secrecy::ExposeSecret;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use subtle::ConstantTimeEq;
use tokio::sync::{RwLock, mpsc, oneshot};
use tokio_stream::wrappers::ReceiverStream;
use uuid::Uuid;
use crate::channels::{
AttachmentKind, Channel, ChannelSecretUpdater, IncomingAttachment, IncomingMessage,
MessageStream, OutgoingResponse,
AttachmentKind, Channel, IncomingAttachment, IncomingMessage, MessageStream, OutgoingResponse,
};
use crate::config::HttpConfig;
use crate::error::ChannelError;
type HmacSha256 = Hmac<Sha256>;
/// HTTP webhook channel.
pub struct HttpChannel {
config: HttpConfig,
state: Arc<HttpChannelState>,
}
pub struct HttpChannelState {
struct HttpChannelState {
/// Sender for incoming messages.
tx: RwLock<Option<mpsc::Sender<IncomingMessage>>>,
/// Pending responses keyed by message ID.
pending_responses: RwLock<std::collections::HashMap<Uuid, oneshot::Sender<String>>>,
/// Expected webhook secret for authentication (if configured).
/// Stored in a separate Arc<RwLock<>> to avoid contending with other state operations.
/// Rarely changes (only on SIGHUP), so isolated from hot-path state accesses.
/// Uses SecretString to prevent accidental logging and memory dump exposure.
webhook_secret: Arc<RwLock<Option<SecretString>>>,
webhook_secret: Option<String>,
/// Fixed user ID for this HTTP channel.
user_id: String,
/// Rate limiting state.
@@ -57,14 +48,6 @@ struct RateLimitState {
request_count: u32,
}
impl HttpChannelState {
/// Update the webhook secret in-place without restarting the listener.
/// Called during SIGHUP to hot-swap credentials.
pub async fn update_secret(&self, new_secret: Option<SecretString>) {
*self.webhook_secret.write().await = new_secret;
}
}
/// Maximum JSON body size for webhook requests (15 MB, to support base64 image attachments
/// with ~33% overhead from base64 encoding).
const MAX_BODY_BYTES: usize = 15 * 1024 * 1024;
@@ -84,7 +67,7 @@ impl HttpChannel {
let webhook_secret = config
.webhook_secret
.as_ref()
.map(|s| SecretString::from(s.expose_secret().to_string()));
.map(|s| s.expose_secret().to_string());
let user_id = config.user_id.clone();
Self {
@@ -92,7 +75,7 @@ impl HttpChannel {
state: Arc::new(HttpChannelState {
tx: RwLock::new(None),
pending_responses: RwLock::new(std::collections::HashMap::new()),
webhook_secret: Arc::new(RwLock::new(webhook_secret)),
webhook_secret,
user_id,
rate_limit: tokio::sync::Mutex::new(RateLimitState {
window_start: std::time::Instant::now(),
@@ -119,16 +102,6 @@ impl HttpChannel {
pub fn addr(&self) -> (&str, u16) {
(&self.config.host, self.config.port)
}
/// Return a shared handle to the channel state for out-of-band updates.
pub fn shared_state(&self) -> Arc<HttpChannelState> {
Arc::clone(&self.state)
}
/// Update the webhook secret in-place without restarting the listener.
pub async fn update_secret(&self, new_secret: Option<SecretString>) {
self.state.update_secret(new_secret).await;
}
}
#[derive(Debug, Deserialize)]
@@ -140,8 +113,7 @@ struct WebhookRequest {
content: String,
/// Optional thread ID for conversation tracking.
thread_id: Option<String>,
/// Deprecated: webhook secret in request body. Use X-IronClaw-Signature header instead.
/// This field is accepted for backward compatibility but will be removed in a future release.
/// Optional webhook secret for authentication.
secret: Option<String>,
/// Whether to wait for a synchronous response.
#[serde(default)]
@@ -197,36 +169,10 @@ async fn health_handler() -> impl IntoResponse {
})
}
/// Verify an HMAC-SHA256 signature against the raw request body.
///
/// The expected header format is: `sha256=<hex_digest>`
/// where the digest is HMAC-SHA256(secret_key, body_bytes) encoded as lowercase hex.
fn verify_hmac_signature(secret: &str, body: &[u8], signature_header: &str) -> bool {
let hex_digest = match signature_header.strip_prefix("sha256=") {
Some(h) => h,
None => return false,
};
let provided_mac = match hex::decode(hex_digest) {
Ok(bytes) => bytes,
Err(_) => return false,
};
let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) {
Ok(mac) => mac,
Err(_) => return false,
};
mac.update(body);
let expected_mac = mac.finalize().into_bytes();
bool::from(expected_mac.as_slice().ct_eq(&provided_mac))
}
async fn webhook_handler(
State(state): State<Arc<HttpChannelState>>,
headers: HeaderMap,
body: Bytes,
) -> impl IntoResponse {
Json(req): Json<WebhookRequest>,
) -> (StatusCode, Json<WebhookResponse>) {
// Rate limiting
{
let mut limiter = state.rate_limit.lock().await;
@@ -243,153 +189,10 @@ async fn webhook_handler(
status: "error".to_string(),
response: Some("Rate limit exceeded".to_string()),
}),
)
.into_response();
);
}
}
let content_type_ok = headers
.get("content-type")
.and_then(|value| value.to_str().ok())
.map(|value| value.starts_with("application/json"))
.unwrap_or(false);
if !content_type_ok {
return (
StatusCode::UNSUPPORTED_MEDIA_TYPE,
Json(WebhookResponse {
message_id: Uuid::nil(),
status: "error".to_string(),
response: Some("Content-Type must be application/json".to_string()),
}),
)
.into_response();
}
let mut fallback_req = None;
{
let webhook_secret = state.webhook_secret.read().await;
if let Some(expected_secret) = webhook_secret.as_ref() {
let expected_secret = expected_secret.expose_secret();
match headers.get("x-ironclaw-signature") {
Some(raw_signature) => match raw_signature.to_str() {
Ok(signature) => {
if !verify_hmac_signature(expected_secret, &body, signature) {
return (
StatusCode::UNAUTHORIZED,
Json(WebhookResponse {
message_id: Uuid::nil(),
status: "error".to_string(),
response: Some("Invalid webhook signature".to_string()),
}),
)
.into_response();
}
}
Err(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(WebhookResponse {
message_id: Uuid::nil(),
status: "error".to_string(),
response: Some("Invalid signature header encoding".to_string()),
}),
)
.into_response();
}
},
None => {
let req: WebhookRequest = match serde_json::from_slice(&body) {
Ok(req) => req,
Err(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(WebhookResponse {
message_id: Uuid::nil(),
status: "error".to_string(),
response: Some(
"Webhook authentication required. Provide X-IronClaw-Signature header \
(preferred) or 'secret' field in body (deprecated)."
.to_string(),
),
}),
)
.into_response();
}
};
match &req.secret {
Some(provided)
if bool::from(
provided.as_bytes().ct_eq(expected_secret.as_bytes()),
) =>
{
tracing::warn!(
"Webhook authenticated via deprecated 'secret' field in request body. \
Migrate to X-IronClaw-Signature header (HMAC-SHA256). \
Body secret support will be removed in a future release."
);
fallback_req = Some(req);
}
Some(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(WebhookResponse {
message_id: Uuid::nil(),
status: "error".to_string(),
response: Some("Invalid webhook secret".to_string()),
}),
)
.into_response();
}
None => {
return (
StatusCode::UNAUTHORIZED,
Json(WebhookResponse {
message_id: Uuid::nil(),
status: "error".to_string(),
response: Some(
"Webhook authentication required. Provide X-IronClaw-Signature header \
(preferred) or 'secret' field in body (deprecated)."
.to_string(),
),
}),
)
.into_response();
}
}
}
}
}
}
if let Some(req) = fallback_req {
return process_authenticated_request(state, req).await;
}
let req: WebhookRequest = match serde_json::from_slice(&body) {
Ok(req) => req,
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(WebhookResponse {
message_id: Uuid::nil(),
status: "error".to_string(),
response: Some(format!("Invalid JSON: {e}")),
}),
)
.into_response();
}
};
process_authenticated_request(state, req).await
}
async fn process_authenticated_request(
state: Arc<HttpChannelState>,
req: WebhookRequest,
) -> axum::response::Response {
let _ = req.user_id.as_ref().map(|user_id| {
tracing::debug!(
provided_user_id = %user_id,
@@ -397,6 +200,35 @@ async fn process_authenticated_request(
);
});
// 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())) => {
// Secret matches, continue
}
Some(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(WebhookResponse {
message_id: Uuid::nil(),
status: "error".to_string(),
response: Some("Invalid webhook secret".to_string()),
}),
);
}
None => {
return (
StatusCode::UNAUTHORIZED,
Json(WebhookResponse {
message_id: Uuid::nil(),
status: "error".to_string(),
response: Some("Webhook secret required".to_string()),
}),
);
}
}
}
if req.content.len() > MAX_CONTENT_BYTES {
return (
StatusCode::PAYLOAD_TOO_LARGE,
@@ -405,12 +237,10 @@ async fn process_authenticated_request(
status: "error".to_string(),
response: Some("Content too large".to_string()),
}),
)
.into_response();
);
}
let wait_for_response = req.wait_for_response;
// Validate and decode attachments
let attachments = if !req.attachments.is_empty() {
if req.attachments.len() > MAX_ATTACHMENTS {
return (
@@ -420,8 +250,7 @@ async fn process_authenticated_request(
status: "error".to_string(),
response: Some(format!("Too many attachments (max {})", MAX_ATTACHMENTS)),
}),
)
.into_response();
);
}
let mut decoded_attachments = Vec::new();
@@ -439,8 +268,7 @@ async fn process_authenticated_request(
status: "error".to_string(),
response: Some("Invalid base64 in attachment".to_string()),
}),
)
.into_response();
);
}
};
if data.len() > MAX_ATTACHMENT_BYTES {
@@ -454,8 +282,7 @@ async fn process_authenticated_request(
MAX_ATTACHMENT_BYTES
)),
}),
)
.into_response();
);
}
total_bytes += data.len();
if total_bytes > MAX_TOTAL_ATTACHMENT_BYTES {
@@ -466,8 +293,7 @@ async fn process_authenticated_request(
status: "error".to_string(),
response: Some("Total attachment size exceeds limit".to_string()),
}),
)
.into_response();
);
}
decoded_attachments.push(IncomingAttachment {
id: Uuid::new_v4().to_string(),
@@ -482,6 +308,7 @@ async fn process_authenticated_request(
duration_secs: None,
});
} else if let Some(ref url) = att.url {
// URL-only attachment: set source_url but don't download (SSRF prevention)
decoded_attachments.push(IncomingAttachment {
id: Uuid::new_v4().to_string(),
kind: AttachmentKind::from_mime_type(&att.mime_type),
@@ -503,7 +330,7 @@ async fn process_authenticated_request(
let mut msg = IncomingMessage::new("http", &state.user_id, &req.content).with_metadata(
serde_json::json!({
"wait_for_response": wait_for_response,
"wait_for_response": req.wait_for_response,
}),
);
@@ -515,9 +342,7 @@ async fn process_authenticated_request(
msg = msg.with_thread(thread_id);
}
process_message(state, msg, wait_for_response)
.await
.into_response()
process_message(state, msg, req.wait_for_response).await
}
async fn process_message(
@@ -547,14 +372,9 @@ async fn process_message(
None
};
// Clone sender while holding read lock, then release lock before async send.
// This prevents blocking other webhook handlers during the async I/O.
let tx = {
let guard = state.tx.read().await;
guard.as_ref().cloned()
};
if let Some(tx) = tx {
// Send message to the channel
let tx_guard = state.tx.read().await;
if let Some(tx) = tx_guard.as_ref() {
if tx.send(msg).await.is_err() {
return (
StatusCode::INTERNAL_SERVER_ERROR,
@@ -575,6 +395,7 @@ async fn process_message(
}),
);
}
drop(tx_guard);
// Wait for response if requested
let response = if let Some(rx) = response_rx {
@@ -607,7 +428,7 @@ impl Channel for HttpChannel {
}
async fn start(&self) -> Result<MessageStream, ChannelError> {
if self.state.webhook_secret.read().await.is_none() {
if self.state.webhook_secret.is_none() {
return Err(ChannelError::StartupFailed {
name: "http".to_string(),
reason: "HTTP webhook secret is required (set HTTP_WEBHOOK_SECRET)".to_string(),
@@ -654,20 +475,10 @@ impl Channel for HttpChannel {
}
}
/// Implement secret update for HTTP channel state.
/// This allows SIGHUP handler to update secrets generically via the trait.
#[async_trait]
impl ChannelSecretUpdater for HttpChannelState {
async fn update_secret(&self, new_secret: Option<SecretString>) {
*self.webhook_secret.write().await = new_secret;
tracing::info!("HTTP webhook secret updated");
}
}
#[cfg(test)]
mod tests {
use axum::body::Body;
use axum::http::{HeaderValue, Request};
use axum::http::Request;
use secrecy::SecretString;
use tower::ServiceExt;
@@ -682,14 +493,6 @@ mod tests {
})
}
fn compute_signature(secret: &str, body: &[u8]) -> String {
let mut mac =
HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC key creation failed");
mac.update(body);
let result = mac.finalize().into_bytes();
format!("sha256={}", hex::encode(result))
}
#[tokio::test]
async fn test_http_channel_requires_secret() {
let channel = test_channel(None);
@@ -698,76 +501,9 @@ mod tests {
}
#[tokio::test]
async fn webhook_hmac_signature_returns_ok() {
let secret = "test-secret-123";
let channel = test_channel(Some(secret));
let _stream = channel.start().await.unwrap();
let app = channel.routes();
let body = serde_json::json!({
"content": "hello"
});
let body_bytes = serde_json::to_vec(&body).unwrap();
let signature = compute_signature(secret, &body_bytes);
let req = Request::builder()
.method("POST")
.uri("/webhook")
.header("content-type", "application/json")
.header("x-ironclaw-signature", signature)
.body(Body::from(body_bytes))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn webhook_wrong_hmac_signature_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 body_bytes = serde_json::to_vec(&body).unwrap();
let signature = compute_signature("wrong-secret", &body_bytes);
let req = Request::builder()
.method("POST")
.uri("/webhook")
.header("content-type", "application/json")
.header("x-ironclaw-signature", signature)
.body(Body::from(body_bytes))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn webhook_malformed_signature_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")
.header("x-ironclaw-signature", "not-a-valid-signature")
.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_deprecated_body_secret_still_works() {
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();
@@ -787,7 +523,7 @@ mod tests {
}
#[tokio::test]
async fn webhook_wrong_body_secret_returns_unauthorized() {
async fn webhook_wrong_secret_returns_unauthorized() {
let channel = test_channel(Some("correct-secret"));
let _stream = channel.start().await.unwrap();
let app = channel.routes();
@@ -808,7 +544,7 @@ mod tests {
}
#[tokio::test]
async fn webhook_missing_all_auth_returns_unauthorized() {
async fn webhook_missing_secret_returns_unauthorized() {
let channel = test_channel(Some("correct-secret"));
let _stream = channel.start().await.unwrap();
let app = channel.routes();
@@ -826,287 +562,4 @@ mod tests {
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn webhook_hmac_takes_precedence_over_body_secret() {
let secret = "test-secret-123";
let channel = test_channel(Some(secret));
let _stream = channel.start().await.unwrap();
let app = channel.routes();
let body = serde_json::json!({
"content": "hello",
"secret": "wrong-secret-in-body"
});
let body_bytes = serde_json::to_vec(&body).unwrap();
let signature = compute_signature(secret, &body_bytes);
let req = Request::builder()
.method("POST")
.uri("/webhook")
.header("content-type", "application/json")
.header("x-ironclaw-signature", signature)
.body(Body::from(body_bytes))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn webhook_invalid_json_returns_bad_request() {
let secret = "test-secret";
let channel = test_channel(Some(secret));
let _stream = channel.start().await.unwrap();
let app = channel.routes();
let body = b"not json".to_vec();
let signature = compute_signature(secret, &body);
let req = Request::builder()
.method("POST")
.uri("/webhook")
.header("content-type", "application/json")
.header("x-ironclaw-signature", signature)
.body(Body::from(body))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn webhook_rejects_non_json_content_type() {
let secret = "test-secret";
let channel = test_channel(Some(secret));
let _stream = channel.start().await.unwrap();
let app = channel.routes();
let body = serde_json::json!({
"content": "hello"
});
let body_bytes = serde_json::to_vec(&body).unwrap();
let signature = compute_signature(secret, &body_bytes);
let req = Request::builder()
.method("POST")
.uri("/webhook")
.header("content-type", "text/plain")
.header("x-ironclaw-signature", signature)
.body(Body::from(body_bytes))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
}
#[tokio::test]
async fn webhook_invalid_signature_header_encoding_returns_unauthorized() {
let channel = test_channel(Some("test-secret"));
let _stream = channel.start().await.unwrap();
let app = channel.routes();
let body = serde_json::json!({
"content": "hello"
});
let mut req = Request::builder()
.method("POST")
.uri("/webhook")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
req.headers_mut().insert(
"x-ironclaw-signature",
HeaderValue::from_bytes(b"\xFF").unwrap(),
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_update_secret_hot_swap() {
let channel = test_channel(Some("old-secret"));
let _stream = channel.start().await.unwrap();
let app1 = channel.routes();
// Request with old-secret should succeed
let body_old = serde_json::json!({
"content": "hello",
"secret": "old-secret"
});
let req1 = Request::builder()
.method("POST")
.uri("/webhook")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body_old).unwrap()))
.unwrap();
let resp1 = app1.oneshot(req1).await.unwrap();
assert_eq!(
resp1.status(),
StatusCode::OK,
"old secret should work initially"
);
// Update secret to new-secret
channel
.update_secret(Some(SecretString::from("new-secret".to_string())))
.await;
let app2 = channel.routes();
// Request with old-secret should fail
let req2 = Request::builder()
.method("POST")
.uri("/webhook")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body_old).unwrap()))
.unwrap();
let resp2 = app2.oneshot(req2).await.unwrap();
assert_eq!(
resp2.status(),
StatusCode::UNAUTHORIZED,
"old secret should fail after update"
);
let app3 = channel.routes();
// Request with new-secret should succeed
let body_new = serde_json::json!({
"content": "hello",
"secret": "new-secret"
});
let req3 = Request::builder()
.method("POST")
.uri("/webhook")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body_new).unwrap()))
.unwrap();
let resp3 = app3.oneshot(req3).await.unwrap();
assert_eq!(
resp3.status(),
StatusCode::OK,
"new secret should work after update"
);
}
#[tokio::test]
async fn test_concurrent_requests_during_secret_update() {
use std::sync::Arc as StdArc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
let channel = test_channel(Some("initial-secret"));
let _stream = channel.start().await.unwrap();
let app = channel.routes();
// Counters for request outcomes
let success_count = StdArc::new(AtomicUsize::new(0));
let mut handles = vec![];
// Spawn 5 concurrent tasks that keep making requests with the initial secret
for i in 0..5 {
let app = app.clone();
let success = StdArc::clone(&success_count);
let handle = tokio::spawn(async move {
let body = serde_json::json!({
"content": format!("test-{}", i),
"secret": "initial-secret"
});
let req = Request::builder()
.method("POST")
.uri("/webhook")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
if resp.status() == StatusCode::OK {
success.fetch_add(1, Ordering::SeqCst);
}
});
handles.push(handle);
}
// Update secret mid-flight (tests that RwLock allows readers while writer holds lock)
tokio::time::sleep(Duration::from_millis(5)).await;
channel
.update_secret(Some(SecretString::from("updated-secret".to_string())))
.await;
// Spawn 5 more tasks that use the new secret
for i in 5..10 {
let app = app.clone();
let success = StdArc::clone(&success_count);
let handle = tokio::spawn(async move {
let body = serde_json::json!({
"content": format!("test-{}", i),
"secret": "updated-secret"
});
let req = Request::builder()
.method("POST")
.uri("/webhook")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
if resp.status() == StatusCode::OK {
success.fetch_add(1, Ordering::SeqCst);
}
});
handles.push(handle);
}
// Wait for all tasks to complete
for handle in handles {
let _ = handle.await;
}
// Verify all requests succeeded with their respective secrets
assert_eq!(
success_count.load(Ordering::SeqCst),
10,
"All concurrent requests should succeed with correct secrets after update"
);
}
#[test]
fn verify_hmac_signature_valid() {
let secret = "my-secret";
let body = b"test body content";
let sig = compute_signature(secret, body);
assert!(verify_hmac_signature(secret, body, &sig));
}
#[test]
fn verify_hmac_signature_invalid_digest() {
let secret = "my-secret";
let body = b"test body content";
assert!(!verify_hmac_signature(
secret,
body,
"sha256=0000000000000000000000000000000000000000000000000000000000000000"
));
}
#[test]
fn verify_hmac_signature_missing_prefix() {
let secret = "my-secret";
let body = b"test body content";
assert!(!verify_hmac_signature(secret, body, "deadbeef"));
}
#[test]
fn verify_hmac_signature_invalid_hex() {
let secret = "my-secret";
let body = b"test body content";
assert!(!verify_hmac_signature(secret, body, "sha256=not-hex!"));
}
}
+2 -39
View File
@@ -56,17 +56,6 @@ impl ChannelManager {
/// the agent loop.
pub async fn hot_add(&self, channel: Box<dyn Channel>) -> Result<(), ChannelError> {
let name = channel.name().to_string();
// Shut down any existing channel with the same name to avoid parallel consumers.
// The old forwarding task will stop when the channel's stream ends after shutdown.
{
let channels = self.channels.read().await;
if let Some(existing) = channels.get(&name) {
tracing::debug!(channel = %name, "Shutting down existing channel before hot-add replacement");
let _ = existing.shutdown().await;
}
}
let stream = channel.start().await?;
// Register for respond/broadcast/send_status
@@ -86,7 +75,7 @@ impl ChannelManager {
break;
}
}
tracing::debug!(channel = %name, "Hot-added channel stream ended");
tracing::info!(channel = %name, "Hot-added channel stream ended");
});
Ok(())
@@ -103,7 +92,7 @@ impl ChannelManager {
for (name, channel) in channels.iter() {
match channel.start().await {
Ok(stream) => {
tracing::debug!("Started channel: {}", name);
tracing::info!("Started channel: {}", name);
streams.push(stream);
}
Err(e) => {
@@ -348,30 +337,4 @@ mod tests {
let msg = stream.next().await.expect("stream ended");
assert_eq!(msg.content, "background alert");
}
#[tokio::test]
async fn test_hot_add_replaces_existing_channel() {
// Regression: hot_add must shut down the existing channel before replacing it,
// to prevent duplicate SSE consumers from running in parallel.
let manager = ChannelManager::new();
let (stub1, _tx1) = StubChannel::new("relay");
manager.add(Box::new(stub1)).await;
let mut stream = manager.start_all().await.expect("start_all");
// Hot-add a replacement channel with the same name
let (stub2, tx2) = StubChannel::new("relay");
manager.hot_add(Box::new(stub2)).await.expect("hot_add");
// Send through the new channel — should arrive in the merged stream
tx2.send(IncomingMessage::new("relay", "u1", "from new"))
.await
.expect("send");
let msg = stream.next().await.expect("stream");
assert_eq!(msg.content, "from new");
// Verify only one channel entry exists
let channels = manager.channels.read().await;
assert_eq!(channels.len(), 1);
assert!(channels.contains_key("relay"));
}
}
+3 -4
View File
@@ -30,7 +30,6 @@
mod channel;
mod http;
mod manager;
pub mod relay;
mod repl;
mod signal;
pub mod wasm;
@@ -38,10 +37,10 @@ pub mod web;
mod webhook_server;
pub use channel::{
AttachmentKind, Channel, ChannelSecretUpdater, IncomingAttachment, IncomingMessage,
MessageStream, OutgoingResponse, StatusUpdate,
AttachmentKind, Channel, IncomingAttachment, IncomingMessage, MessageStream, OutgoingResponse,
StatusUpdate,
};
pub use http::{HttpChannel, HttpChannelState};
pub use http::HttpChannel;
pub use manager::ChannelManager;
pub use repl::ReplChannel;
pub use signal::SignalChannel;
-642
View File
@@ -1,642 +0,0 @@
//! Channel trait implementation for channel-relay SSE streams.
//!
//! `RelayChannel` connects to a channel-relay service via SSE, converts
//! incoming events to `IncomingMessage`s, and sends responses via the
//! relay's provider-specific proxy API (Slack).
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use tokio::sync::{RwLock, mpsc};
use crate::channels::relay::client::{RelayClient, RelayError};
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
use crate::error::ChannelError;
/// Default channel name for the Slack relay integration.
pub const DEFAULT_RELAY_NAME: &str = "slack-relay";
/// The messaging provider backing a relay channel.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RelayProvider {
Slack,
}
impl RelayProvider {
/// Provider string used in proxy API routes and metadata.
pub fn as_str(&self) -> &'static str {
match self {
Self::Slack => "slack",
}
}
/// The default channel name for this provider.
pub fn channel_name(&self) -> &'static str {
match self {
Self::Slack => DEFAULT_RELAY_NAME,
}
}
}
/// Channel implementation that connects to a channel-relay SSE stream.
pub struct RelayChannel {
client: RelayClient,
provider: RelayProvider,
stream_token: Arc<RwLock<String>>,
team_id: String,
instance_id: String,
user_id: String,
/// SSE stream long-poll timeout in seconds.
stream_timeout_secs: u64,
/// Initial exponential backoff in milliseconds.
backoff_initial_ms: u64,
/// Maximum exponential backoff in milliseconds.
backoff_max_ms: u64,
/// Handle to the reconnect task for clean shutdown.
reconnect_handle: RwLock<Option<tokio::task::JoinHandle<()>>>,
/// Handle to the SSE parser task for clean shutdown.
parser_handle: Arc<RwLock<Option<tokio::task::JoinHandle<()>>>>,
/// Maximum consecutive reconnect failures before giving up.
max_consecutive_failures: u64,
}
impl RelayChannel {
/// Create a new relay channel for Slack (default provider).
pub fn new(
client: RelayClient,
stream_token: String,
team_id: String,
instance_id: String,
user_id: String,
) -> Self {
Self::new_with_provider(
client,
RelayProvider::Slack,
stream_token,
team_id,
instance_id,
user_id,
)
}
/// Create a new relay channel with a specific provider.
pub fn new_with_provider(
client: RelayClient,
provider: RelayProvider,
stream_token: String,
team_id: String,
instance_id: String,
user_id: String,
) -> Self {
Self {
client,
provider,
stream_token: Arc::new(RwLock::new(stream_token)),
team_id,
instance_id,
user_id,
stream_timeout_secs: 86400,
backoff_initial_ms: 1000,
backoff_max_ms: 60000,
reconnect_handle: RwLock::new(None),
parser_handle: Arc::new(RwLock::new(None)),
max_consecutive_failures: 50,
}
}
/// Set backoff/timeout parameters from relay config values.
pub fn with_timeouts(
mut self,
stream_timeout_secs: u64,
backoff_initial_ms: u64,
backoff_max_ms: u64,
) -> Self {
self.stream_timeout_secs = stream_timeout_secs;
self.backoff_initial_ms = backoff_initial_ms;
self.backoff_max_ms = backoff_max_ms;
self
}
/// Set the maximum number of consecutive reconnect failures before giving up.
pub fn with_max_failures(mut self, max: u64) -> Self {
self.max_consecutive_failures = max;
self
}
/// Build a provider-appropriate proxy body for sending a message.
fn build_send_body(
&self,
channel_id: &str,
text: &str,
thread_id: Option<&str>,
) -> (String, serde_json::Value) {
match self.provider {
RelayProvider::Slack => {
let mut body = serde_json::json!({
"channel": channel_id,
"text": text,
});
if let Some(tid) = thread_id {
body["thread_ts"] = serde_json::Value::String(tid.to_string());
}
("chat.postMessage".to_string(), body)
}
}
}
/// Send a message via the provider proxy.
async fn proxy_send(
&self,
team_id: &str,
method: &str,
body: serde_json::Value,
) -> Result<serde_json::Value, RelayError> {
self.client
.proxy_provider(
self.provider.as_str(),
team_id,
method,
body,
Some(&self.instance_id),
)
.await
}
}
#[async_trait]
impl Channel for RelayChannel {
fn name(&self) -> &str {
self.provider.channel_name()
}
async fn start(&self) -> Result<MessageStream, ChannelError> {
let channel_name = self.name().to_string();
let token = self.stream_token.read().await.clone();
let (stream, initial_parser_handle) = self
.client
.connect_stream(&token, self.stream_timeout_secs)
.await
.map_err(|e| ChannelError::StartupFailed {
name: channel_name.clone(),
reason: e.to_string(),
})?;
*self.parser_handle.write().await = Some(initial_parser_handle);
let (tx, rx) = mpsc::channel(64);
// Spawn the stream reader + reconnect task
let client = self.client.clone();
let stream_token = Arc::clone(&self.stream_token);
let instance_id = self.instance_id.clone();
let user_id = self.user_id.clone();
let team_id = self.team_id.clone();
let stream_timeout_secs = self.stream_timeout_secs;
let backoff_initial_ms = self.backoff_initial_ms;
let backoff_max_ms = self.backoff_max_ms;
let max_consecutive_failures = self.max_consecutive_failures;
let parser_handle = Arc::clone(&self.parser_handle);
let provider_str = self.provider.as_str().to_string();
let relay_name = channel_name.clone();
let handle = tokio::spawn(async move {
use futures::StreamExt;
let mut current_stream = stream;
let mut backoff_ms = backoff_initial_ms;
let mut consecutive_failures: u64 = 0;
loop {
// Read events from the current stream
while let Some(event) = current_stream.next().await {
// Reset backoff and failure count on successful event
backoff_ms = backoff_initial_ms;
consecutive_failures = 0;
// Validate required fields
if event.sender_id.is_empty()
|| event.channel_id.is_empty()
|| event.provider_scope.is_empty()
{
tracing::debug!(
event_type = %event.event_type,
sender_id = %event.sender_id,
channel_id = %event.channel_id,
"Relay: skipping event with missing required fields"
);
continue;
}
// Skip non-message events
if !event.is_message() {
tracing::debug!(
event_type = %event.event_type,
"Relay: skipping non-message event"
);
continue;
}
tracing::info!(
event_type = %event.event_type,
sender = %event.sender_id,
channel = %event.channel_id,
provider = %provider_str,
"Relay: received message from {}", provider_str
);
let msg = IncomingMessage::new(&relay_name, &event.sender_id, event.text())
.with_user_name(event.display_name())
.with_metadata(serde_json::json!({
"team_id": event.team_id(),
"channel_id": event.channel_id,
"sender_id": event.sender_id,
"sender_name": event.display_name(),
"event_type": event.event_type,
"thread_id": event.thread_id,
"provider": event.provider,
}));
let msg = if let Some(ref thread_id) = event.thread_id {
msg.with_thread(thread_id)
} else {
msg.with_thread(&event.channel_id)
};
if tx.send(msg).await.is_err() {
tracing::info!("Relay channel receiver dropped, stopping");
return;
}
}
// Stream ended, attempt reconnect with backoff
consecutive_failures += 1;
if consecutive_failures >= max_consecutive_failures {
tracing::error!(
channel = %relay_name,
failures = consecutive_failures,
"Relay channel giving up after {} consecutive failures",
consecutive_failures
);
break;
}
tracing::warn!(
backoff_ms = backoff_ms,
failures = consecutive_failures,
"Relay SSE stream ended, reconnecting..."
);
tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await;
backoff_ms = (backoff_ms * 2).min(backoff_max_ms);
// Try to reconnect
let token = stream_token.read().await.clone();
match client.connect_stream(&token, stream_timeout_secs).await {
Ok((new_stream, new_parser)) => {
tracing::info!("Relay SSE stream reconnected");
current_stream = new_stream;
// Abort old parser before replacing
if let Some(old) = parser_handle.write().await.take() {
old.abort();
}
*parser_handle.write().await = Some(new_parser);
}
Err(RelayError::TokenExpired) => {
// Attempt token renewal
tracing::info!("Relay stream token expired, renewing...");
match client.renew_token(&instance_id, &user_id).await {
Ok(new_token) => {
*stream_token.write().await = new_token.clone();
match client.connect_stream(&new_token, stream_timeout_secs).await {
Ok((new_stream, new_parser)) => {
tracing::info!(
"Relay SSE stream reconnected with new token"
);
current_stream = new_stream;
if let Some(old) = parser_handle.write().await.take() {
old.abort();
}
*parser_handle.write().await = Some(new_parser);
}
Err(e) => {
tracing::error!(
error = %e,
"Failed to reconnect after token renewal"
);
}
}
}
Err(e) => {
tracing::error!(
error = %e,
"Failed to renew relay stream token"
);
}
}
}
Err(e) => {
tracing::error!(error = %e, "Failed to reconnect relay SSE stream");
}
}
// Check if the team is still valid (skip when team_id is unknown,
// e.g. when no DB store was available at activation time)
if !team_id.is_empty() {
match client.list_connections(&instance_id).await {
Ok(conns) => {
let has_team =
conns.iter().any(|c| c.team_id == team_id && c.connected);
if !has_team {
tracing::warn!(
team_id = %team_id,
"Team no longer connected, stopping relay channel"
);
return;
}
}
Err(e) => {
tracing::warn!(
error = %e,
"Could not verify team connection, will retry next iteration"
);
}
}
}
}
});
*self.reconnect_handle.write().await = Some(handle);
let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
Ok(Box::pin(stream))
}
async fn respond(
&self,
msg: &IncomingMessage,
response: OutgoingResponse,
) -> Result<(), ChannelError> {
let channel_name = self.name().to_string();
let metadata = &msg.metadata;
let team_id = metadata
.get("team_id")
.and_then(|v| v.as_str())
.unwrap_or(&self.team_id);
let channel_id = metadata
.get("channel_id")
.and_then(|v| v.as_str())
.ok_or_else(|| ChannelError::SendFailed {
name: channel_name.clone(),
reason: "Missing channel_id in message metadata".to_string(),
})?;
// Determine thread_id from response or metadata
let thread_id = response
.thread_id
.as_deref()
.or_else(|| metadata.get("thread_id").and_then(|v| v.as_str()));
let (method, body) = self.build_send_body(channel_id, &response.content, thread_id);
self.proxy_send(team_id, &method, body)
.await
.map_err(|e| ChannelError::SendFailed {
name: channel_name,
reason: e.to_string(),
})?;
Ok(())
}
/// Status updates are not forwarded to messaging providers to avoid noise.
async fn send_status(
&self,
_status: StatusUpdate,
_metadata: &serde_json::Value,
) -> Result<(), ChannelError> {
Ok(())
}
async fn broadcast(
&self,
target: &str,
response: OutgoingResponse,
) -> Result<(), ChannelError> {
let channel_name = self.name().to_string();
// Determine thread_id from response or metadata
let thread_id = response
.thread_id
.as_deref()
.or_else(|| response.metadata.get("thread_ts").and_then(|v| v.as_str()));
let (method, body) = self.build_send_body(target, &response.content, thread_id);
self.proxy_send(&self.team_id, &method, body)
.await
.map_err(|e| ChannelError::SendFailed {
name: channel_name,
reason: e.to_string(),
})?;
Ok(())
}
async fn health_check(&self) -> Result<(), ChannelError> {
self.client
.list_connections(&self.instance_id)
.await
.map_err(|_| ChannelError::HealthCheckFailed {
name: self.name().to_string(),
})?;
Ok(())
}
fn conversation_context(&self, metadata: &serde_json::Value) -> HashMap<String, String> {
let mut ctx = HashMap::new();
if let Some(sender) = metadata.get("sender_name").and_then(|v| v.as_str()) {
ctx.insert("sender".to_string(), sender.to_string());
}
if let Some(sender_id) = metadata.get("sender_id").and_then(|v| v.as_str()) {
ctx.insert("sender_uuid".to_string(), sender_id.to_string());
}
if let Some(channel_id) = metadata.get("channel_id").and_then(|v| v.as_str()) {
ctx.insert("group".to_string(), channel_id.to_string());
}
ctx.insert("platform".to_string(), self.provider.as_str().to_string());
ctx
}
async fn shutdown(&self) -> Result<(), ChannelError> {
if let Some(handle) = self.reconnect_handle.write().await.take() {
handle.abort();
}
if let Some(handle) = self.parser_handle.write().await.take() {
handle.abort();
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_client() -> RelayClient {
RelayClient::new(
"http://localhost:3001".into(),
secrecy::SecretString::from("key".to_string()),
30,
)
.expect("client")
}
#[test]
fn relay_channel_name() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
assert_eq!(channel.name(), DEFAULT_RELAY_NAME);
}
#[test]
fn conversation_context_extracts_metadata() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
let metadata = serde_json::json!({
"sender_name": "bob",
"sender_id": "U123",
"channel_id": "C456",
});
let ctx = channel.conversation_context(&metadata);
assert_eq!(ctx.get("sender"), Some(&"bob".to_string()));
assert_eq!(ctx.get("sender_uuid"), Some(&"U123".to_string()));
assert_eq!(ctx.get("platform"), Some(&"slack".to_string()));
}
#[test]
fn metadata_shape_includes_event_type_and_sender_name() {
// Regression: metadata JSON must include event_type and sender_name
// for downstream routing (DM vs channel) and conversation_context().
let metadata = serde_json::json!({
"team_id": "T123",
"channel_id": "C456",
"sender_id": "U789",
"sender_name": "alice",
"event_type": "direct_message",
"thread_id": null,
"provider": "slack",
});
// event_type must be present for DM-vs-channel routing
assert_eq!(
metadata.get("event_type").and_then(|v| v.as_str()),
Some("direct_message")
);
// sender_name must be present for conversation_context
assert_eq!(
metadata.get("sender_name").and_then(|v| v.as_str()),
Some("alice")
);
}
#[test]
fn with_timeouts_sets_values() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
)
.with_timeouts(43200, 2000, 120000);
assert_eq!(channel.stream_timeout_secs, 43200);
assert_eq!(channel.backoff_initial_ms, 2000);
assert_eq!(channel.backoff_max_ms, 120000);
}
#[test]
fn build_send_body_slack() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
let (method, body) = channel.build_send_body("C456", "hello", Some("1234567.890"));
assert_eq!(method, "chat.postMessage");
assert_eq!(body["channel"], "C456");
assert_eq!(body["text"], "hello");
assert_eq!(body["thread_ts"], "1234567.890");
}
#[test]
fn parser_handle_is_shared_arc() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
// parser_handle should be an Arc — cloning should give a second reference
let handle_clone = Arc::clone(&channel.parser_handle);
// Both point to the same allocation
assert!(Arc::ptr_eq(&channel.parser_handle, &handle_clone));
}
#[test]
fn with_max_failures_sets_value() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
)
.with_max_failures(10);
assert_eq!(channel.max_consecutive_failures, 10);
}
#[test]
fn default_max_failures_is_50() {
let channel = RelayChannel::new(
test_client(),
"token".into(),
"T123".into(),
"inst1".into(),
"user1".into(),
);
assert_eq!(channel.max_consecutive_failures, 50);
}
#[test]
fn empty_team_id_accepted_at_construction() {
// Regression: empty team_id (when no DB store is available) must not
// prevent channel construction or cause immediate shutdown.
let channel = RelayChannel::new(
test_client(),
"token".into(),
String::new(), // empty team_id
"inst1".into(),
"user1".into(),
);
assert_eq!(channel.team_id, "");
// The reconnect loop now skips team validation when team_id is empty,
// so the channel remains alive.
}
}
-549
View File
@@ -1,549 +0,0 @@
//! HTTP client for the channel-relay service.
//!
//! Wraps reqwest for all channel-relay API calls: OAuth initiation,
//! SSE streaming, token renewal, and Slack API proxy.
use std::pin::Pin;
use std::task::{Context, Poll};
use futures::Stream;
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
/// Known relay event types.
pub mod event_types {
pub const MESSAGE: &str = "message";
pub const DIRECT_MESSAGE: &str = "direct_message";
pub const MENTION: &str = "mention";
}
/// A parsed SSE event from the channel-relay stream.
///
/// Field names match the channel-relay `ChannelEvent` struct exactly.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelEvent {
/// Unique event ID.
#[serde(default)]
pub id: String,
/// Event type enum from channel-relay (e.g., "direct_message", "message", "mention").
pub event_type: String,
/// Provider (e.g., "slack").
#[serde(default)]
pub provider: String,
/// Team/workspace ID (called `provider_scope` in channel-relay).
#[serde(alias = "team_id", default)]
pub provider_scope: String,
/// Channel or DM conversation ID.
#[serde(default)]
pub channel_id: String,
/// Sender user ID.
#[serde(default)]
pub sender_id: String,
/// Sender display name.
#[serde(default)]
pub sender_name: Option<String>,
/// Message text content (called `content` in channel-relay).
#[serde(alias = "text", default)]
pub content: Option<String>,
/// Thread ID (for threaded replies, called `thread_id` in channel-relay).
#[serde(alias = "thread_ts", default)]
pub thread_id: Option<String>,
/// Full raw event data.
#[serde(default)]
pub raw: serde_json::Value,
/// Event timestamp (ISO 8601 from channel-relay).
#[serde(default)]
pub timestamp: Option<String>,
}
impl ChannelEvent {
/// Get the team_id (provider_scope).
pub fn team_id(&self) -> &str {
&self.provider_scope
}
/// Get the message text content.
pub fn text(&self) -> &str {
self.content.as_deref().unwrap_or("")
}
/// Get the sender name or fallback to sender_id.
pub fn display_name(&self) -> &str {
self.sender_name.as_deref().unwrap_or(&self.sender_id)
}
/// Check if this is a message-like event that should be forwarded to the agent.
pub fn is_message(&self) -> bool {
matches!(
self.event_type.as_str(),
event_types::MESSAGE | event_types::DIRECT_MESSAGE | event_types::MENTION
)
}
}
/// Connection info returned by list_connections.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Connection {
pub provider: String,
pub team_id: String,
pub team_name: Option<String>,
pub connected: bool,
}
/// HTTP client for the channel-relay service.
#[derive(Clone)]
pub struct RelayClient {
http: reqwest::Client,
base_url: String,
api_key: SecretString,
}
impl RelayClient {
/// Create a new relay client.
pub fn new(
base_url: String,
api_key: SecretString,
request_timeout_secs: u64,
) -> Result<Self, RelayError> {
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(request_timeout_secs))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|e| RelayError::Network(format!("Failed to build HTTP client: {e}")))?;
Ok(Self {
http,
base_url: base_url.trim_end_matches('/').to_string(),
api_key,
})
}
/// Initiate Slack OAuth flow via channel-relay.
///
/// Calls `GET /oauth/slack/auth` with `redirect(Policy::none())` and
/// returns the `Location` header (Slack OAuth URL) without following it.
pub async fn initiate_oauth(
&self,
instance_id: &str,
user_id: &str,
callback_url: &str,
) -> Result<String, RelayError> {
let resp = self
.http
.get(format!("{}/oauth/slack/auth", self.base_url))
.header("X-API-Key", self.api_key.expose_secret())
.query(&[
("instance_id", instance_id),
("user_id", user_id),
("callback", callback_url),
])
.send()
.await
.map_err(|e| RelayError::Network(e.to_string()))?;
let status = resp.status();
if status.is_redirection() {
let location = resp
.headers()
.get(reqwest::header::LOCATION)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.ok_or_else(|| {
RelayError::Protocol("Redirect response missing Location header".to_string())
})?;
Ok(location)
} else if status.is_success() {
// Some relay implementations return the URL in JSON body instead
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| RelayError::Protocol(e.to_string()))?;
body.get("auth_url")
.or_else(|| body.get("url"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| RelayError::Protocol("Response missing auth_url field".to_string()))
} else {
let body = resp.text().await.unwrap_or_default();
Err(RelayError::Api {
status: status.as_u16(),
message: body,
})
}
}
/// Connect to the SSE event stream.
///
/// Returns a stream of parsed `ChannelEvent`s and the `JoinHandle` of the
/// background SSE parser task. The caller is responsible for reconnection
/// logic on stream end/error and for aborting the handle on shutdown.
pub async fn connect_stream(
&self,
stream_token: &str,
stream_timeout_secs: u64,
) -> Result<(ChannelEventStream, tokio::task::JoinHandle<()>), RelayError> {
let resp = self
.http
.get(format!("{}/stream", self.base_url))
.query(&[("token", stream_token)])
.timeout(std::time::Duration::from_secs(stream_timeout_secs))
.send()
.await
.map_err(|e| RelayError::Network(e.to_string()))?;
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED {
return Err(RelayError::TokenExpired);
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(RelayError::Api {
status: status.as_u16(),
message: body,
});
}
// Spawn a background task that reads the SSE stream and sends parsed events
let (tx, rx) = mpsc::channel(64);
let byte_stream = resp.bytes_stream();
let handle = tokio::spawn(parse_sse_stream(byte_stream, tx));
Ok((ChannelEventStream { rx }, handle))
}
/// Renew an expired stream token.
///
/// Calls `POST /stream/renew` with API key auth, returns a new stream token.
pub async fn renew_token(
&self,
instance_id: &str,
user_id: &str,
) -> Result<String, RelayError> {
let resp = self
.http
.post(format!("{}/stream/renew", self.base_url))
.header("X-API-Key", self.api_key.expose_secret())
.json(&serde_json::json!({
"instance_id": instance_id,
"user_id": user_id,
}))
.send()
.await
.map_err(|e| RelayError::Network(e.to_string()))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(RelayError::Api {
status: status.as_u16(),
message: body,
});
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| RelayError::Protocol(e.to_string()))?;
body.get("stream_token")
.or_else(|| body.get("token"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| RelayError::Protocol("Response missing stream_token field".to_string()))
}
/// Proxy an API call through channel-relay for any provider.
///
/// Calls `POST /proxy/{provider}/{method}?team_id=X&instance_id=Y` with the given JSON body.
pub async fn proxy_provider(
&self,
provider: &str,
team_id: &str,
method: &str,
body: serde_json::Value,
instance_id: Option<&str>,
) -> Result<serde_json::Value, RelayError> {
let mut query: Vec<(&str, &str)> = vec![("team_id", team_id)];
if let Some(iid) = instance_id {
query.push(("instance_id", iid));
}
let resp = self
.http
.post(format!("{}/proxy/{}/{}", self.base_url, provider, method))
.header("X-API-Key", self.api_key.expose_secret())
.query(&query)
.json(&body)
.send()
.await
.map_err(|e| RelayError::Network(e.to_string()))?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(RelayError::Api {
status,
message: body,
});
}
resp.json()
.await
.map_err(|e| RelayError::Protocol(e.to_string()))
}
/// List active connections for an instance.
pub async fn list_connections(&self, instance_id: &str) -> Result<Vec<Connection>, RelayError> {
let resp = self
.http
.get(format!("{}/connections", self.base_url))
.header("X-API-Key", self.api_key.expose_secret())
.query(&[("instance_id", instance_id)])
.send()
.await
.map_err(|e| RelayError::Network(e.to_string()))?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(RelayError::Api {
status,
message: body,
});
}
resp.json()
.await
.map_err(|e| RelayError::Protocol(e.to_string()))
}
}
/// Async stream of parsed channel events from SSE.
pub struct ChannelEventStream {
rx: mpsc::Receiver<ChannelEvent>,
}
impl Stream for ChannelEventStream {
type Item = ChannelEvent;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
self.rx.poll_recv(cx)
}
}
/// Parse SSE format from a reqwest bytes stream.
///
/// SSE format:
/// ```text
/// event: message
/// data: {"key": "value"}
///
/// ```
/// Blank line terminates an event.
async fn parse_sse_stream(
byte_stream: impl futures::Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Send + 'static,
tx: mpsc::Sender<ChannelEvent>,
) {
use futures::StreamExt;
let mut buffer = Vec::<u8>::new();
let mut event_type = String::new();
let mut data_lines = Vec::new();
let mut byte_stream = std::pin::pin!(byte_stream);
while let Some(chunk_result) = byte_stream.next().await {
let chunk = match chunk_result {
Ok(c) => c,
Err(e) => {
tracing::debug!(error = %e, "SSE stream chunk error");
break;
}
};
buffer.extend_from_slice(&chunk);
// Process complete lines (decode UTF-8 only on full lines to avoid
// corruption when multi-byte characters span chunk boundaries)
while let Some(newline_pos) = buffer.iter().position(|&b| b == b'\n') {
let line = String::from_utf8_lossy(&buffer[..newline_pos])
.trim_end_matches('\r')
.to_string();
buffer.drain(..=newline_pos);
if line.is_empty() {
// Blank line = end of event
if !data_lines.is_empty() {
let data = data_lines.join("\n");
if let Ok(mut event) = serde_json::from_str::<ChannelEvent>(&data) {
if event.event_type.is_empty() && !event_type.is_empty() {
event.event_type = event_type.clone();
}
if tx.send(event).await.is_err() {
return; // receiver dropped
}
} else {
tracing::debug!(
event_type = %event_type,
data_len = data.len(),
"Failed to parse SSE event data as ChannelEvent"
);
}
}
event_type.clear();
data_lines.clear();
} else if let Some(value) = line.strip_prefix("event:") {
event_type = value.trim().to_string();
} else if let Some(value) = line.strip_prefix("data:") {
data_lines.push(value.trim().to_string());
}
// Ignore other fields (id:, retry:, comments)
}
}
tracing::debug!("SSE stream ended");
}
/// Errors from relay client operations.
#[derive(Debug, thiserror::Error)]
pub enum RelayError {
#[error("Network error: {0}")]
Network(String),
#[error("API error (HTTP {status}): {message}")]
Api { status: u16, message: String },
#[error("Protocol error: {0}")]
Protocol(String),
#[error("Stream token expired")]
TokenExpired,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn channel_event_deserialize_minimal() {
let json = r#"{"event_type": "message", "content": "hello"}"#;
let event: ChannelEvent = serde_json::from_str(json).expect("parse failed");
assert_eq!(event.event_type, "message");
assert_eq!(event.text(), "hello");
assert!(event.provider_scope.is_empty());
}
#[test]
fn channel_event_deserialize_relay_format() {
// Matches the actual channel-relay ChannelEvent serialization format.
let json = r#"{
"id": "evt_123",
"event_type": "direct_message",
"provider": "slack",
"provider_scope": "T123",
"channel_id": "D456",
"sender_id": "U789",
"sender_name": "bob",
"content": "hi there",
"thread_id": "1234567890.123456",
"raw": {},
"timestamp": "2026-03-09T21:00:00Z"
}"#;
let event: ChannelEvent = serde_json::from_str(json).expect("parse failed");
assert_eq!(event.provider, "slack");
assert_eq!(event.team_id(), "T123");
assert_eq!(event.display_name(), "bob");
assert_eq!(event.thread_id, Some("1234567890.123456".to_string()));
assert!(event.is_message());
}
#[test]
fn channel_event_is_message() {
let make = |et: &str| ChannelEvent {
id: String::new(),
event_type: et.to_string(),
provider: String::new(),
provider_scope: String::new(),
channel_id: String::new(),
sender_id: String::new(),
sender_name: None,
content: None,
thread_id: None,
raw: serde_json::Value::Null,
timestamp: None,
};
assert!(make("message").is_message());
assert!(make("direct_message").is_message());
assert!(make("mention").is_message());
assert!(!make("reaction").is_message());
}
#[test]
fn connection_deserialize() {
let json = r#"{"provider": "slack", "team_id": "T123", "team_name": "My Team", "connected": true}"#;
let conn: Connection = serde_json::from_str(json).expect("parse failed");
assert_eq!(conn.provider, "slack");
assert!(conn.connected);
}
#[test]
fn relay_error_display() {
let err = RelayError::Network("timeout".into());
assert_eq!(err.to_string(), "Network error: timeout");
let err = RelayError::Api {
status: 401,
message: "unauthorized".into(),
};
assert_eq!(err.to_string(), "API error (HTTP 401): unauthorized");
let err = RelayError::TokenExpired;
assert_eq!(err.to_string(), "Stream token expired");
}
#[test]
fn event_type_constants_match_is_message() {
let make = |et: &str| ChannelEvent {
id: String::new(),
event_type: et.to_string(),
provider: String::new(),
provider_scope: String::new(),
channel_id: String::new(),
sender_id: String::new(),
sender_name: None,
content: None,
thread_id: None,
raw: serde_json::Value::Null,
timestamp: None,
};
assert!(make(event_types::MESSAGE).is_message());
assert!(make(event_types::DIRECT_MESSAGE).is_message());
assert!(make(event_types::MENTION).is_message());
}
#[tokio::test]
async fn parse_sse_handles_multibyte_utf8_across_chunks() {
// The crab emoji (🦀) is 4 bytes: [0xF0, 0x9F, 0xA6, 0x80].
// Split it across two chunks to verify no U+FFFD corruption.
let event_json = r#"{"event_type":"message","content":"hello 🦀 world","provider_scope":"T1","channel_id":"C1","sender_id":"U1"}"#;
let full = format!("event: message\ndata: {}\n\n", event_json);
let bytes = full.as_bytes();
// Find the crab emoji and split mid-character
let crab_pos = bytes
.windows(4)
.position(|w| w == [0xF0, 0x9F, 0xA6, 0x80])
.expect("crab emoji not found");
let split_at = crab_pos + 2; // split in the middle of the 4-byte emoji
let chunk1 = bytes::Bytes::copy_from_slice(&bytes[..split_at]);
let chunk2 = bytes::Bytes::copy_from_slice(&bytes[split_at..]);
let chunks: Vec<Result<bytes::Bytes, reqwest::Error>> = vec![Ok(chunk1), Ok(chunk2)];
let stream = futures::stream::iter(chunks);
let (tx, mut rx) = mpsc::channel(8);
parse_sse_stream(stream, tx).await;
let event = rx.recv().await.expect("should receive event");
assert_eq!(event.text(), "hello 🦀 world");
}
}
-12
View File
@@ -1,12 +0,0 @@
//! Channel-relay integration for connecting to external messaging platforms
//! (Slack) via the channel-relay service.
//!
//! The relay service handles OAuth, credential storage, webhook ingestion,
//! and SSE event streaming. IronClaw consumes the SSE stream and sends
//! messages via the relay's proxy API.
pub mod channel;
pub mod client;
pub use channel::{DEFAULT_RELAY_NAME, RelayChannel};
pub use client::RelayClient;
+6 -37
View File
@@ -184,32 +184,18 @@ impl WasmChannelLoader {
/// └── telegram.capabilities.json
/// ```
pub async fn load_from_dir(&self, dir: &Path) -> Result<LoadResults, WasmChannelError> {
match fs::metadata(dir).await {
Ok(meta) if meta.is_dir() => {}
Ok(_) => {
return Err(WasmChannelError::Io(std::io::Error::new(
std::io::ErrorKind::NotADirectory,
format!("{} is not a directory", dir.display()),
)));
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(LoadResults::default());
}
Err(e) => return Err(WasmChannelError::Io(e)),
if !dir.is_dir() {
return Err(WasmChannelError::Io(std::io::Error::new(
std::io::ErrorKind::NotADirectory,
format!("{} is not a directory", dir.display()),
)));
}
let mut results = LoadResults::default();
// Collect all .wasm entries first, then load in parallel
let mut channel_entries = Vec::new();
// Handle TOCTOU: if read_dir fails with NotFound, treat as empty
let mut entries = match fs::read_dir(dir).await {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(LoadResults::default());
}
Err(e) => return Err(WasmChannelError::Io(e)),
};
let mut entries = fs::read_dir(dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
@@ -500,21 +486,4 @@ mod tests {
let result = loader.load_from_files("", &wasm_path, None).await;
assert!(result.is_err());
}
#[tokio::test]
async fn load_from_dir_returns_empty_when_dir_missing() {
let config = WasmChannelRuntimeConfig::for_testing();
let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap());
let loader = WasmChannelLoader::new(runtime, Arc::new(PairingStore::new()), None);
let dir = TempDir::new().unwrap();
let missing = dir.path().join("nonexistent_channels_dir");
let results = loader.load_from_dir(&missing).await;
// Must succeed with empty results, not error
let results = results.expect("missing dir should return Ok, not Err");
assert!(results.loaded.is_empty());
assert!(results.errors.is_empty());
}
}
-2
View File
@@ -86,7 +86,6 @@ mod loader;
mod router;
mod runtime;
mod schema;
pub mod setup;
pub(crate) mod signature;
#[allow(dead_code)]
pub(crate) mod storage;
@@ -106,5 +105,4 @@ pub use runtime::{PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeC
pub use schema::{
ChannelCapabilitiesFile, ChannelConfig, SecretSetupSchema, SetupSchema, WebhookSchema,
};
pub use setup::{WasmChannelSetup, inject_channel_credentials, setup_wasm_channels};
pub use wrapper::{HttpResponse, SharedWasmChannel, WasmChannel};
-350
View File
@@ -1,350 +0,0 @@
//! WASM channel setup and credential injection.
//!
//! Encapsulates the logic for loading WASM channels, registering their
//! webhook routes, and injecting credentials from the secrets store.
use std::collections::HashSet;
use std::sync::Arc;
use crate::channels::wasm::{
LoadedChannel, RegisteredEndpoint, SharedWasmChannel, WasmChannel, WasmChannelLoader,
WasmChannelRouter, WasmChannelRuntime, WasmChannelRuntimeConfig, create_wasm_channel_router,
};
use crate::config::Config;
use crate::db::Database;
use crate::extensions::ExtensionManager;
use crate::pairing::PairingStore;
use crate::secrets::SecretsStore;
/// Result of WASM channel setup.
pub struct WasmChannelSetup {
pub channels: Vec<(String, Box<dyn crate::channels::Channel>)>,
pub channel_names: Vec<String>,
pub webhook_routes: Option<axum::Router>,
/// Runtime objects needed for hot-activation via ExtensionManager.
pub wasm_channel_runtime: Arc<WasmChannelRuntime>,
pub pairing_store: Arc<PairingStore>,
pub wasm_channel_router: Arc<WasmChannelRouter>,
}
/// Load WASM channels and register their webhook routes.
pub async fn setup_wasm_channels(
config: &Config,
secrets_store: &Option<Arc<dyn SecretsStore + Send + Sync>>,
extension_manager: Option<&Arc<ExtensionManager>>,
database: Option<&Arc<dyn Database>>,
) -> Option<WasmChannelSetup> {
let runtime = match WasmChannelRuntime::new(WasmChannelRuntimeConfig::default()) {
Ok(r) => Arc::new(r),
Err(e) => {
tracing::warn!("Failed to initialize WASM channel runtime: {}", e);
return None;
}
};
let pairing_store = Arc::new(PairingStore::new());
let settings_store: Option<Arc<dyn crate::db::SettingsStore>> =
database.map(|db| Arc::clone(db) as Arc<dyn crate::db::SettingsStore>);
let mut loader = WasmChannelLoader::new(
Arc::clone(&runtime),
Arc::clone(&pairing_store),
settings_store,
);
if let Some(secrets) = secrets_store {
loader = loader.with_secrets_store(Arc::clone(secrets));
}
let results = match loader
.load_from_dir(&config.channels.wasm_channels_dir)
.await
{
Ok(r) => r,
Err(e) => {
tracing::warn!("Failed to scan WASM channels directory: {}", e);
return None;
}
};
let wasm_router = Arc::new(WasmChannelRouter::new());
let mut channels: Vec<(String, Box<dyn crate::channels::Channel>)> = Vec::new();
let mut channel_names: Vec<String> = Vec::new();
for loaded in results.loaded {
let (name, channel) = register_channel(loaded, config, secrets_store, &wasm_router).await;
channel_names.push(name.clone());
channels.push((name, channel));
}
for (path, err) in &results.errors {
tracing::warn!("Failed to load WASM channel {}: {}", path.display(), err);
}
// Always create webhook routes (even with no channels loaded) so that
// channels hot-added at runtime can receive webhooks without a restart.
let webhook_routes = {
Some(create_wasm_channel_router(
Arc::clone(&wasm_router),
extension_manager.map(Arc::clone),
))
};
Some(WasmChannelSetup {
channels,
channel_names,
webhook_routes,
wasm_channel_runtime: runtime,
pairing_store,
wasm_channel_router: wasm_router,
})
}
/// Process a single loaded WASM channel: retrieve secrets, inject config,
/// register with the router, and set up signing keys and credentials.
async fn register_channel(
loaded: LoadedChannel,
config: &Config,
secrets_store: &Option<Arc<dyn SecretsStore + Send + Sync>>,
wasm_router: &Arc<WasmChannelRouter>,
) -> (String, Box<dyn crate::channels::Channel>) {
let channel_name = loaded.name().to_string();
tracing::info!("Loaded WASM channel: {}", channel_name);
let secret_name = loaded.webhook_secret_name();
let sig_key_secret_name = loaded.signature_key_secret_name();
let hmac_secret_name = loaded.hmac_secret_name();
let webhook_secret = if let Some(secrets) = secrets_store {
secrets
.get_decrypted("default", &secret_name)
.await
.ok()
.map(|s| s.expose().to_string())
} else {
None
};
let secret_header = loaded.webhook_secret_header().map(|s| s.to_string());
let webhook_path = format!("/webhook/{}", channel_name);
let endpoints = vec![RegisteredEndpoint {
channel_name: channel_name.clone(),
path: webhook_path,
methods: vec!["POST".to_string()],
require_secret: webhook_secret.is_some(),
}];
let channel_arc = Arc::new(loaded.channel);
// Inject runtime config (tunnel URL, webhook secret, owner_id).
{
let mut config_updates = std::collections::HashMap::new();
if let Some(ref tunnel_url) = config.tunnel.public_url {
config_updates.insert(
"tunnel_url".to_string(),
serde_json::Value::String(tunnel_url.clone()),
);
}
if let Some(ref secret) = webhook_secret {
config_updates.insert(
"webhook_secret".to_string(),
serde_json::Value::String(secret.clone()),
);
}
if let Some(&owner_id) = config
.channels
.wasm_channel_owner_ids
.get(channel_name.as_str())
{
config_updates.insert("owner_id".to_string(), serde_json::json!(owner_id));
}
if !config_updates.is_empty() {
channel_arc.update_config(config_updates).await;
tracing::info!(
channel = %channel_name,
has_tunnel = config.tunnel.public_url.is_some(),
has_webhook_secret = webhook_secret.is_some(),
"Injected runtime config into channel"
);
}
}
tracing::info!(
channel = %channel_name,
has_webhook_secret = webhook_secret.is_some(),
secret_header = ?secret_header,
"Registering channel with router"
);
wasm_router
.register(
Arc::clone(&channel_arc),
endpoints,
webhook_secret.clone(),
secret_header,
)
.await;
// Register Ed25519 signature key if declared in capabilities.
if let Some(ref sig_key_name) = sig_key_secret_name
&& let Some(secrets) = secrets_store
&& let Ok(key_secret) = secrets.get_decrypted("default", sig_key_name).await
{
match wasm_router
.register_signature_key(&channel_name, key_secret.expose())
.await
{
Ok(()) => {
tracing::info!(channel = %channel_name, "Registered Ed25519 signature key")
}
Err(e) => {
tracing::error!(channel = %channel_name, error = %e, "Invalid signature key in secrets store")
}
}
}
// Register HMAC signing secret if declared in capabilities.
if let Some(ref hmac_secret_name) = hmac_secret_name
&& let Some(secrets) = secrets_store
&& let Ok(secret) = secrets.get_decrypted("default", hmac_secret_name).await
{
wasm_router
.register_hmac_secret(&channel_name, secret.expose())
.await;
tracing::info!(channel = %channel_name, "Registered HMAC signing secret");
}
// Inject credentials from secrets store / environment.
match inject_channel_credentials(
&channel_arc,
secrets_store
.as_ref()
.map(|s| s.as_ref() as &dyn SecretsStore),
&channel_name,
)
.await
{
Ok(count) => {
if count > 0 {
tracing::info!(
channel = %channel_name,
credentials_injected = count,
"Channel credentials injected"
);
}
}
Err(e) => {
tracing::error!(
channel = %channel_name,
error = %e,
"Failed to inject channel credentials"
);
}
}
(channel_name, Box::new(SharedWasmChannel::new(channel_arc)))
}
/// Inject credentials for a channel based on naming convention.
///
/// Looks for secrets matching the pattern `{channel_name}_*` and injects them
/// as credential placeholders (e.g., `telegram_bot_token` -> `{TELEGRAM_BOT_TOKEN}`).
///
/// Falls back to environment variables starting with the uppercase channel name
/// prefix (e.g., `TELEGRAM_` for channel `telegram`) for missing credentials.
///
/// Returns the number of credentials injected.
pub async fn inject_channel_credentials(
channel: &Arc<WasmChannel>,
secrets: Option<&dyn SecretsStore>,
channel_name: &str,
) -> anyhow::Result<usize> {
if channel_name.trim().is_empty() {
return Ok(0);
}
let mut count = 0;
let mut injected_placeholders = HashSet::new();
// 1. Try injecting from persistent secrets store if available
if let Some(secrets) = secrets {
let all_secrets = secrets
.list("default")
.await
.map_err(|e| anyhow::anyhow!("Failed to list secrets: {}", e))?;
let prefix = format!("{}_", channel_name.to_ascii_lowercase());
for secret_meta in all_secrets {
if !secret_meta.name.to_ascii_lowercase().starts_with(&prefix) {
continue;
}
let decrypted = match secrets.get_decrypted("default", &secret_meta.name).await {
Ok(d) => d,
Err(e) => {
tracing::warn!(
secret = %secret_meta.name,
error = %e,
"Failed to decrypt secret for channel credential injection"
);
continue;
}
};
let placeholder = secret_meta.name.to_uppercase();
tracing::debug!(
channel = %channel_name,
secret = %secret_meta.name,
placeholder = %placeholder,
"Injecting credential"
);
channel
.set_credential(&placeholder, decrypted.expose().to_string())
.await;
injected_placeholders.insert(placeholder);
count += 1;
}
}
// 2. Fall back to environment variables for credentials not in the secrets store.
// Only env vars starting with the channel's uppercase prefix are allowed
// (e.g., TELEGRAM_ for channel "telegram") to prevent reading unrelated host
// credentials like AWS_SECRET_ACCESS_KEY.
let prefix = format!("{}_", channel_name.to_ascii_uppercase());
let caps = channel.capabilities();
if let Some(ref http_cap) = caps.tool_capabilities.http {
for cred_mapping in http_cap.credentials.values() {
let placeholder = cred_mapping.secret_name.to_uppercase();
if injected_placeholders.contains(&placeholder) {
continue;
}
if !placeholder.starts_with(&prefix) {
tracing::warn!(
channel = %channel_name,
placeholder = %placeholder,
"Ignoring non-prefixed credential placeholder in environment fallback"
);
continue;
}
if let Ok(env_value) = std::env::var(&placeholder)
&& !env_value.is_empty()
{
tracing::debug!(
channel = %channel_name,
placeholder = %placeholder,
"Injecting credential from environment variable"
);
channel.set_credential(&placeholder, env_value).await;
count += 1;
}
}
}
Ok(count)
}
-46
View File
@@ -106,34 +106,6 @@ pub fn verify_slack_signature(
.into()
}
/// Verify raw-body HMAC-SHA256 signature with a configurable prefix.
///
/// Computes `HMAC-SHA256(secret, body)` and compares against
/// `prefix + hex_digest` in constant time.
pub fn verify_hmac_sha256_prefixed(
secret: &str,
body: &[u8],
signature_header: &str,
prefix: &str,
) -> bool {
use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
let mut mac = match Hmac::<Sha256>::new_from_slice(secret.as_bytes()) {
Ok(m) => m,
Err(_) => return false,
};
mac.update(body);
let computed = mac.finalize().into_bytes();
let computed_hex = hex::encode(computed);
let expected = format!("{prefix}{computed_hex}");
expected
.as_bytes()
.ct_eq(signature_header.as_bytes())
.into()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -526,24 +498,6 @@ mod tests {
);
}
#[test]
fn test_hmac_sha256_prefixed_valid() {
let secret = "github-secret";
let body = br#"{"action":"opened"}"#;
use hmac::{Hmac, Mac};
use sha2::Sha256;
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("hmac key");
mac.update(body);
let sig = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
assert!(verify_hmac_sha256_prefixed(secret, body, &sig, "sha256="));
assert!(!verify_hmac_sha256_prefixed(
secret,
body,
"sha256=deadbeef",
"sha256="
));
}
#[test]
fn test_slack_stale_timestamp_rejected() {
let signing_secret = "my-signing-secret";
+5 -8
View File
@@ -3059,7 +3059,6 @@ mod tests {
};
use crate::channels::wasm::wrapper::{HttpResponse, WasmChannel};
use crate::pairing::PairingStore;
use crate::testing::credentials::TEST_TELEGRAM_BOT_TOKEN;
use crate::tools::wasm::ResourceLimits;
fn create_test_channel() -> WasmChannel {
@@ -4010,7 +4009,7 @@ mod tests {
let mut creds = std::collections::HashMap::new();
creds.insert(
"TELEGRAM_BOT_TOKEN".to_string(),
TEST_TELEGRAM_BOT_TOKEN.to_string(),
"8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis".to_string(),
);
creds.insert("OTHER_SECRET".to_string(), "s3cret".to_string());
@@ -4023,15 +4022,13 @@ mod tests {
Arc::new(PairingStore::new()),
);
let error = format!(
"HTTP request failed: error sending request for url \
(https://api.telegram.org/bot{TEST_TELEGRAM_BOT_TOKEN}/getUpdates)"
);
let error = "HTTP request failed: error sending request for url \
(https://api.telegram.org/bot8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis/getUpdates)";
let redacted = store.redact_credentials(&error);
let redacted = store.redact_credentials(error);
assert!(
!redacted.contains(TEST_TELEGRAM_BOT_TOKEN),
!redacted.contains("8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis"),
"credential value should be redacted"
);
assert!(
+1 -1
View File
@@ -197,7 +197,7 @@ All responses include:
- `X-Content-Type-Options: nosniff`
- `X-Frame-Options: DENY`
**Request body limit:** 10 MB (`DefaultBodyLimit::max(10 * 1024 * 1024)`), sized for image uploads (#725). Larger payloads return 413.
**Request body limit:** 1 MB (`DefaultBodyLimit::max(1024 * 1024)`). Larger payloads return 413.
## Pending Approvals
+26 -27
View File
@@ -83,15 +83,14 @@ pub async fn auth_middleware(
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::credentials::{TEST_AUTH_SECRET_TOKEN, TEST_BEARER_TOKEN};
#[test]
fn test_auth_state_clone() {
let state = AuthState {
token: TEST_BEARER_TOKEN.to_string(),
token: "test-token".to_string(),
};
let cloned = state.clone();
assert_eq!(cloned.token, TEST_BEARER_TOKEN);
assert_eq!(cloned.token, "test-token");
}
use axum::Router;
@@ -121,10 +120,10 @@ mod tests {
#[tokio::test]
async fn test_valid_bearer_token_passes() {
let app = test_app(TEST_AUTH_SECRET_TOKEN);
let app = test_app("secret-token");
let req = Request::builder()
.uri("/api/chat/events")
.header("Authorization", format!("Bearer {TEST_AUTH_SECRET_TOKEN}"))
.header("Authorization", "Bearer secret-token")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
@@ -133,7 +132,7 @@ mod tests {
#[tokio::test]
async fn test_invalid_bearer_token_rejected() {
let app = test_app(TEST_AUTH_SECRET_TOKEN);
let app = test_app("secret-token");
let req = Request::builder()
.uri("/api/chat/events")
.header("Authorization", "Bearer wrong-token")
@@ -145,9 +144,9 @@ mod tests {
#[tokio::test]
async fn test_query_token_allowed_for_chat_events() {
let app = test_app(TEST_AUTH_SECRET_TOKEN);
let app = test_app("secret-token");
let req = Request::builder()
.uri(format!("/api/chat/events?token={TEST_AUTH_SECRET_TOKEN}"))
.uri("/api/chat/events?token=secret-token")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
@@ -156,9 +155,9 @@ mod tests {
#[tokio::test]
async fn test_query_token_allowed_for_logs_events() {
let app = test_app(TEST_AUTH_SECRET_TOKEN);
let app = test_app("secret-token");
let req = Request::builder()
.uri(format!("/api/logs/events?token={TEST_AUTH_SECRET_TOKEN}"))
.uri("/api/logs/events?token=secret-token")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
@@ -167,9 +166,9 @@ mod tests {
#[tokio::test]
async fn test_query_token_allowed_for_ws_upgrade() {
let app = test_app(TEST_AUTH_SECRET_TOKEN);
let app = test_app("secret-token");
let req = Request::builder()
.uri(format!("/api/chat/ws?token={TEST_AUTH_SECRET_TOKEN}"))
.uri("/api/chat/ws?token=secret-token")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
@@ -203,9 +202,9 @@ mod tests {
#[tokio::test]
async fn test_query_token_rejected_for_non_sse_get() {
let app = test_app(TEST_AUTH_SECRET_TOKEN);
let app = test_app("secret-token");
let req = Request::builder()
.uri(format!("/api/chat/history?token={TEST_AUTH_SECRET_TOKEN}"))
.uri("/api/chat/history?token=secret-token")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
@@ -214,10 +213,10 @@ mod tests {
#[tokio::test]
async fn test_query_token_rejected_for_post() {
let app = test_app(TEST_AUTH_SECRET_TOKEN);
let app = test_app("secret-token");
let req = Request::builder()
.method(Method::POST)
.uri(format!("/api/chat/send?token={TEST_AUTH_SECRET_TOKEN}"))
.uri("/api/chat/send?token=secret-token")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
@@ -226,7 +225,7 @@ mod tests {
#[tokio::test]
async fn test_query_token_invalid_rejected() {
let app = test_app(TEST_AUTH_SECRET_TOKEN);
let app = test_app("secret-token");
let req = Request::builder()
.uri("/api/chat/events?token=wrong-token")
.body(Body::empty())
@@ -237,7 +236,7 @@ mod tests {
#[tokio::test]
async fn test_no_auth_at_all_rejected() {
let app = test_app(TEST_AUTH_SECRET_TOKEN);
let app = test_app("secret-token");
let req = Request::builder()
.uri("/api/chat/events")
.body(Body::empty())
@@ -248,11 +247,11 @@ mod tests {
#[tokio::test]
async fn test_bearer_header_works_for_post() {
let app = test_app(TEST_AUTH_SECRET_TOKEN);
let app = test_app("secret-token");
let req = Request::builder()
.method(Method::POST)
.uri("/api/chat/send")
.header("Authorization", format!("Bearer {TEST_AUTH_SECRET_TOKEN}"))
.header("Authorization", "Bearer secret-token")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
@@ -261,10 +260,10 @@ mod tests {
#[tokio::test]
async fn test_bearer_prefix_case_insensitive() {
let app = test_app(TEST_AUTH_SECRET_TOKEN);
let app = test_app("secret-token");
let req = Request::builder()
.uri("/api/chat/events")
.header("Authorization", format!("bearer {TEST_AUTH_SECRET_TOKEN}"))
.header("Authorization", "bearer secret-token")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
@@ -273,10 +272,10 @@ mod tests {
#[tokio::test]
async fn test_bearer_prefix_mixed_case() {
let app = test_app(TEST_AUTH_SECRET_TOKEN);
let app = test_app("secret-token");
let req = Request::builder()
.uri("/api/chat/events")
.header("Authorization", format!("BEARER {TEST_AUTH_SECRET_TOKEN}"))
.header("Authorization", "BEARER secret-token")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
@@ -285,7 +284,7 @@ mod tests {
#[tokio::test]
async fn test_empty_bearer_token_rejected() {
let app = test_app(TEST_AUTH_SECRET_TOKEN);
let app = test_app("secret-token");
let req = Request::builder()
.uri("/api/chat/events")
.header("Authorization", "Bearer ")
@@ -297,10 +296,10 @@ mod tests {
#[tokio::test]
async fn test_token_with_whitespace_rejected() {
let app = test_app(TEST_AUTH_SECRET_TOKEN);
let app = test_app("secret-token");
let req = Request::builder()
.uri("/api/chat/events")
.header("Authorization", format!("Bearer {TEST_AUTH_SECRET_TOKEN}"))
.header("Authorization", "Bearer secret-token")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
+96 -110
View File
@@ -35,7 +35,6 @@ pub async fn chat_send_handler(
}
let msg_id = msg.id;
let thread_id = msg.thread_id.clone();
let tx_guard = state.msg_tx.read().await;
let tx = tx_guard.as_ref().ok_or((
@@ -50,13 +49,6 @@ pub async fn chat_send_handler(
)
})?;
tracing::debug!(
message_id = %msg_id,
thread_id = ?thread_id,
content_len = req.content.len(),
"Message queued to agent loop"
);
Ok((
StatusCode::ACCEPTED,
Json(SendMessageResponse {
@@ -145,33 +137,49 @@ pub async fn chat_auth_token_handler(
"Extension manager not available".to_string(),
))?;
match ext_mgr
.configure_token(&req.extension_name, &req.token)
let result = ext_mgr
.auth(&req.extension_name, Some(&req.token))
.await
{
Ok(result) => {
clear_auth_mode(&state).await;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
state.sse.broadcast(SseEvent::AuthCompleted {
extension_name: req.extension_name.clone(),
success: true,
message: result.message.clone(),
});
if result.is_authenticated() {
// Auto-activate so tools are available immediately
let msg = match ext_mgr.activate(&req.extension_name).await {
Ok(r) => format!(
"{} authenticated ({} tools loaded)",
req.extension_name,
r.tools_loaded.len()
),
Err(e) => format!(
"{} authenticated but activation failed: {}",
req.extension_name, e
),
};
Ok(Json(ActionResponse::ok(result.message)))
}
Err(e) => {
let msg = e.to_string();
if matches!(e, crate::extensions::ExtensionError::ValidationFailed(_)) {
state.sse.broadcast(SseEvent::AuthRequired {
extension_name: req.extension_name.clone(),
instructions: Some(msg.clone()),
auth_url: None,
setup_url: None,
});
}
Ok(Json(ActionResponse::fail(msg)))
}
// Clear auth mode on the active thread
clear_auth_mode(&state).await;
state.sse.broadcast(SseEvent::AuthCompleted {
extension_name: req.extension_name,
success: true,
message: msg.clone(),
});
Ok(Json(ActionResponse::ok(msg)))
} else {
// Re-emit auth_required for retry
state.sse.broadcast(SseEvent::AuthRequired {
extension_name: req.extension_name.clone(),
instructions: result.instructions().map(String::from),
auth_url: result.auth_url().map(String::from),
setup_url: result.setup_url().map(String::from),
});
Ok(Json(ActionResponse::fail(
result
.instructions()
.map(String::from)
.unwrap_or_else(|| "Invalid token".to_string()),
)))
}
}
@@ -255,6 +263,7 @@ pub async fn chat_history_handler(
))?;
let session = session_manager.get_or_create_session(&state.user_id).await;
let sess = session.lock().await;
let limit = query.limit.unwrap_or(50);
let before_cursor = query
@@ -272,12 +281,11 @@ pub async fn chat_history_handler(
})
.transpose()?;
// Find the thread (lock only briefly to get active_thread if needed)
// Find the thread
let thread_id = if let Some(ref tid) = query.thread_id {
Uuid::parse_str(tid)
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid thread_id".to_string()))?
} else {
let sess = session.lock().await;
sess.active_thread
.ok_or((StatusCode::NOT_FOUND, "No active thread".to_string()))?
};
@@ -290,11 +298,8 @@ pub async fn chat_history_handler(
.conversation_belongs_to_user(thread_id, &state.user_id)
.await
.unwrap_or(false);
if !owned {
let sess = session.lock().await;
if !sess.threads.contains_key(&thread_id) {
return Err((StatusCode::NOT_FOUND, "Thread not found".to_string()));
}
if !owned && !sess.threads.contains_key(&thread_id) {
return Err((StatusCode::NOT_FOUND, "Thread not found".to_string()));
}
}
@@ -319,60 +324,56 @@ pub async fn chat_history_handler(
}
// Try in-memory first (freshest data for active threads)
// Lock only when checking in-memory state
if let Some(thread) = sess.threads.get(&thread_id)
&& (!thread.turns.is_empty() || thread.pending_approval.is_some())
{
let sess = session.lock().await;
if let Some(thread) = sess.threads.get(&thread_id)
&& (!thread.turns.is_empty() || thread.pending_approval.is_some())
{
let turns: Vec<TurnInfo> = thread
.turns
.iter()
.map(|t| TurnInfo {
turn_number: t.turn_number,
user_input: t.user_input.clone(),
response: t.response.clone(),
state: format!("{:?}", t.state),
started_at: t.started_at.to_rfc3339(),
completed_at: t.completed_at.map(|dt| dt.to_rfc3339()),
tool_calls: t
.tool_calls
.iter()
.map(|tc| ToolCallInfo {
name: tc.name.clone(),
has_result: tc.result.is_some(),
has_error: tc.error.is_some(),
result_preview: tc.result.as_ref().map(|r| {
let s = match r {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
truncate_preview(&s, 500)
}),
error: tc.error.clone(),
})
.collect(),
})
.collect();
let turns: Vec<TurnInfo> = thread
.turns
.iter()
.map(|t| TurnInfo {
turn_number: t.turn_number,
user_input: t.user_input.clone(),
response: t.response.clone(),
state: format!("{:?}", t.state),
started_at: t.started_at.to_rfc3339(),
completed_at: t.completed_at.map(|dt| dt.to_rfc3339()),
tool_calls: t
.tool_calls
.iter()
.map(|tc| ToolCallInfo {
name: tc.name.clone(),
has_result: tc.result.is_some(),
has_error: tc.error.is_some(),
result_preview: tc.result.as_ref().map(|r| {
let s = match r {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
truncate_preview(&s, 500)
}),
error: tc.error.clone(),
})
.collect(),
})
.collect();
let pending_approval = thread
.pending_approval
.as_ref()
.map(|pa| PendingApprovalInfo {
request_id: pa.request_id.to_string(),
tool_name: pa.tool_name.clone(),
description: pa.description.clone(),
parameters: serde_json::to_string_pretty(&pa.parameters).unwrap_or_default(),
});
let pending_approval = thread
.pending_approval
.as_ref()
.map(|pa| PendingApprovalInfo {
request_id: pa.request_id.to_string(),
tool_name: pa.tool_name.clone(),
description: pa.description.clone(),
parameters: serde_json::to_string_pretty(&pa.parameters).unwrap_or_default(),
});
return Ok(Json(HistoryResponse {
thread_id,
turns,
has_more: false,
oldest_timestamp: None,
pending_approval,
}));
}
return Ok(Json(HistoryResponse {
thread_id,
turns,
has_more: false,
oldest_timestamp: None,
pending_approval,
}));
}
// Fall back to DB for historical threads not in memory (paginated)
@@ -414,6 +415,7 @@ pub async fn chat_threads_handler(
))?;
let session = session_manager.get_or_create_session(&state.user_id).await;
let sess = session.lock().await;
// Try DB first for persistent thread list
if let Some(ref store) = state.store {
@@ -463,22 +465,15 @@ pub async fn chat_threads_handler(
});
}
// Read active thread while holding minimal lock (just before return)
let active_thread = {
let sess = session.lock().await;
sess.active_thread
};
return Ok(Json(ThreadListResponse {
assistant_thread,
threads,
active_thread,
active_thread: sess.active_thread,
}));
}
}
// Fallback: in-memory only (no assistant thread without DB)
let sess = session.lock().await;
let mut sorted_threads: Vec<_> = sess.threads.values().collect();
sorted_threads.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
let threads: Vec<ThreadInfo> = sorted_threads
@@ -495,13 +490,10 @@ pub async fn chat_threads_handler(
})
.collect();
let active_thread = sess.active_thread;
drop(sess); // Explicit drop to release lock
Ok(Json(ThreadListResponse {
assistant_thread: None,
threads,
active_thread,
active_thread: sess.active_thread,
}))
}
@@ -534,17 +526,11 @@ pub async fn chat_new_thread_handler(
// Persist the empty conversation row with thread_type metadata synchronously
// so that the subsequent loadThreads() call from the frontend sees it.
if let Some(ref store) = state.store {
match store
if let Err(e) = store
.ensure_conversation(thread_id, "gateway", &state.user_id, None)
.await
{
Ok(true) => {}
Ok(false) => tracing::warn!(
user = %state.user_id,
thread_id = %thread_id,
"Skipped persisting new thread due to ownership/channel conflict"
),
Err(e) => tracing::warn!("Failed to persist new thread: {}", e),
tracing::warn!("Failed to persist new thread: {}", e);
}
let metadata_val = serde_json::json!("thread");
if let Err(e) = store
+56 -9
View File
@@ -46,14 +46,6 @@ pub async fn extensions_list_handler(
} else {
"configured".to_string()
})
} else if ext.kind == crate::extensions::ExtensionKind::ChannelRelay {
Some(if ext.active {
"active".to_string()
} else if ext.authenticated {
"configured".to_string()
} else {
"installed".to_string()
})
} else {
None
};
@@ -111,7 +103,6 @@ pub async fn extensions_install_handler(
"mcp_server" => Some(crate::extensions::ExtensionKind::McpServer),
"wasm_tool" => Some(crate::extensions::ExtensionKind::WasmTool),
"wasm_channel" => Some(crate::extensions::ExtensionKind::WasmChannel),
"channel_relay" => Some(crate::extensions::ExtensionKind::ChannelRelay),
_ => None,
});
@@ -124,6 +115,62 @@ pub async fn extensions_install_handler(
}
}
pub async fn extensions_activate_handler(
State(state): State<Arc<GatewayState>>,
Path(name): Path<String>,
) -> Result<Json<ActionResponse>, (StatusCode, String)> {
let ext_mgr = state.extension_manager.as_ref().ok_or((
StatusCode::NOT_IMPLEMENTED,
"Extension manager not available (secrets store required)".to_string(),
))?;
match ext_mgr.activate(&name).await {
Ok(result) => {
// Activation just loads the WASM module. Auth (OAuth/manual) is
// triggered separately via save_setup_secrets or the auth endpoint.
Ok(Json(ActionResponse::ok(result.message)))
}
Err(activate_err) => {
let err_str = activate_err.to_string();
let needs_auth = err_str.contains("authentication")
|| err_str.contains("401")
|| err_str.contains("Unauthorized");
if !needs_auth {
return Ok(Json(ActionResponse::fail(err_str)));
}
// Activation failed due to auth; try authenticating first.
match ext_mgr.auth(&name, None).await {
Ok(auth_result) if auth_result.is_authenticated() => {
// Auth succeeded, retry activation.
match ext_mgr.activate(&name).await {
Ok(result) => Ok(Json(ActionResponse::ok(result.message))),
Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))),
}
}
Ok(auth_result) => {
// Auth in progress (OAuth URL or awaiting manual token).
let mut resp = ActionResponse::fail(
auth_result
.instructions()
.map(String::from)
.unwrap_or_else(|| format!("'{}' requires authentication.", name)),
);
resp.auth_url = auth_result.auth_url().map(String::from);
resp.awaiting_token = Some(auth_result.is_awaiting_token());
resp.instructions = auth_result.instructions().map(String::from);
Ok(Json(resp))
}
Err(auth_err) => Ok(Json(ActionResponse::fail(format!(
"Authentication failed: {}",
auth_err
)))),
}
}
}
}
pub async fn extensions_remove_handler(
State(state): State<Arc<GatewayState>>,
Path(name): Path<String>,
+49 -1
View File
@@ -27,7 +27,7 @@ pub async fn routines_list_handler(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let items: Vec<RoutineInfo> = routines.iter().map(RoutineInfo::from_routine).collect();
let items: Vec<RoutineInfo> = routines.iter().map(routine_to_info).collect();
Ok(Json(RoutineListResponse { routines: items }))
}
@@ -263,6 +263,54 @@ pub async fn routines_runs_handler(
})))
}
/// Convert a Routine to the trimmed RoutineInfo for list display.
fn routine_to_info(r: &crate::agent::routine::Routine) -> RoutineInfo {
let (trigger_type, trigger_summary) = match &r.trigger {
crate::agent::routine::Trigger::Cron { schedule, .. } => {
("cron".to_string(), format!("cron: {}", schedule))
}
crate::agent::routine::Trigger::Event {
pattern, channel, ..
} => {
let ch = channel.as_deref().unwrap_or("any");
("event".to_string(), format!("on {} /{}/", ch, pattern))
}
crate::agent::routine::Trigger::Webhook { path, .. } => {
let p = path.as_deref().unwrap_or("/");
("webhook".to_string(), format!("webhook: {}", p))
}
crate::agent::routine::Trigger::Manual => ("manual".to_string(), "manual only".to_string()),
};
let action_type = match &r.action {
crate::agent::routine::RoutineAction::Lightweight { .. } => "lightweight",
crate::agent::routine::RoutineAction::FullJob { .. } => "full_job",
};
let status = if !r.enabled {
"disabled"
} else if r.consecutive_failures > 0 {
"failing"
} else {
"active"
};
RoutineInfo {
id: r.id,
name: r.name.clone(),
description: r.description.clone(),
enabled: r.enabled,
trigger_type,
trigger_summary,
action_type: action_type.to_string(),
last_run_at: r.last_run_at.map(|dt| dt.to_rfc3339()),
next_fire_at: r.next_fire_at.map(|dt| dt.to_rfc3339()),
run_count: r.run_count,
consecutive_failures: r.consecutive_failures,
status: status.to_string(),
}
}
/// Map `RoutineError` variants to appropriate HTTP status codes.
fn routine_error_status(err: &RoutineError) -> StatusCode {
match err {
-8
View File
@@ -97,7 +97,6 @@ impl GatewayChannel {
skill_registry: None,
skill_catalog: None,
chat_rate_limiter: server::RateLimiter::new(30, 60),
oauth_rate_limiter: server::RateLimiter::new(10, 60),
registry_entries: Vec::new(),
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
@@ -134,7 +133,6 @@ impl GatewayChannel {
skill_registry: self.state.skill_registry.clone(),
skill_catalog: self.state.skill_catalog.clone(),
chat_rate_limiter: server::RateLimiter::new(30, 60),
oauth_rate_limiter: server::RateLimiter::new(10, 60),
registry_entries: self.state.registry_entries.clone(),
cost_guard: self.state.cost_guard.clone(),
routine_engine: Arc::clone(&self.state.routine_engine),
@@ -244,12 +242,6 @@ impl GatewayChannel {
self
}
/// Inject a shared routine engine slot used by other HTTP ingress paths.
pub fn with_routine_engine_slot(mut self, slot: server::RoutineEngineSlot) -> Self {
self.rebuild_state(|s| s.routine_engine = slot);
self
}
/// Get the auth token (for printing to console on startup).
pub fn auth_token(&self) -> &str {
&self.auth_token
+113 -553
View File
@@ -28,7 +28,6 @@ use uuid::Uuid;
use crate::agent::SessionManager;
use crate::bootstrap::ironclaw_base_dir;
use crate::channels::IncomingMessage;
use crate::channels::relay::DEFAULT_RELAY_NAME;
use crate::channels::web::auth::{AuthState, auth_middleware};
use crate::channels::web::handlers::jobs::{
job_files_list_handler, job_files_read_handler, jobs_cancel_handler, jobs_detail_handler,
@@ -165,8 +164,6 @@ pub struct GatewayState {
pub scheduler: Option<crate::tools::builtin::SchedulerSlot>,
/// Rate limiter for chat endpoints (30 messages per 60 seconds).
pub chat_rate_limiter: RateLimiter,
/// Rate limiter for OAuth callback endpoints (10 requests per 60 seconds).
pub oauth_rate_limiter: RateLimiter,
/// Registry catalog entries for the available extensions API.
/// Populated at startup from `registry/` manifests, independent of extension manager.
pub registry_entries: Vec<crate::extensions::RegistryEntry>,
@@ -203,11 +200,7 @@ pub async fn start_server(
// Public routes (no auth)
let public = Router::new()
.route("/api/health", get(health_handler))
.route("/oauth/callback", get(oauth_callback_handler))
.route(
"/oauth/slack/callback",
get(slack_relay_oauth_callback_handler),
);
.route("/oauth/callback", get(oauth_callback_handler));
// Protected routes (require auth)
let auth_state = AuthState { token: auth_token };
@@ -318,11 +311,7 @@ pub async fn start_server(
.route("/", get(index_handler))
.route("/style.css", get(css_handler))
.route("/app.js", get(js_handler))
.route("/favicon.ico", get(favicon_handler))
.route("/i18n/index.js", get(i18n_index_handler))
.route("/i18n/en.js", get(i18n_en_handler))
.route("/i18n/zh-CN.js", get(i18n_zh_handler))
.route("/i18n-app.js", get(i18n_app_handler));
.route("/favicon.ico", get(favicon_handler));
// Project file serving (behind auth to prevent unauthorized file access).
let projects = Router::new()
@@ -372,21 +361,6 @@ pub async fn start_server(
header::X_FRAME_OPTIONS,
header::HeaderValue::from_static("DENY"),
))
.layer(SetResponseHeaderLayer::if_not_present(
header::HeaderName::from_static("content-security-policy"),
header::HeaderValue::from_static(
"default-src 'self'; \
script-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com; \
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; \
font-src https://fonts.gstatic.com; \
connect-src 'self'; \
img-src 'self' data:; \
object-src 'none'; \
frame-ancestors 'none'; \
base-uri 'self'; \
form-action 'self'",
),
))
.with_state(state.clone());
let (shutdown_tx, shutdown_rx) = oneshot::channel();
@@ -396,7 +370,7 @@ pub async fn start_server(
if let Err(e) = axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = shutdown_rx.await;
tracing::debug!("Web gateway shutting down");
tracing::info!("Web gateway shutting down");
})
.await
{
@@ -449,46 +423,6 @@ async fn favicon_handler() -> impl IntoResponse {
)
}
async fn i18n_index_handler() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "application/javascript"),
(header::CACHE_CONTROL, "no-cache"),
],
include_str!("static/i18n/index.js"),
)
}
async fn i18n_en_handler() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "application/javascript"),
(header::CACHE_CONTROL, "no-cache"),
],
include_str!("static/i18n/en.js"),
)
}
async fn i18n_zh_handler() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "application/javascript"),
(header::CACHE_CONTROL, "no-cache"),
],
include_str!("static/i18n/zh-CN.js"),
)
}
async fn i18n_app_handler() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "application/javascript"),
(header::CACHE_CONTROL, "no-cache"),
],
include_str!("static/i18n-app.js"),
)
}
// --- Health ---
async fn health_handler() -> Json<HealthResponse> {
@@ -672,208 +606,6 @@ async fn oauth_callback_handler(
axum::response::Html(html).into_response()
}
/// OAuth callback for Slack via channel-relay.
///
/// This is a PUBLIC route (no Bearer token required) because channel-relay
/// redirects the user's browser here after Slack OAuth completes.
/// Query params: `stream_token`, `provider`, `team_id`.
async fn slack_relay_oauth_callback_handler(
State(state): State<Arc<GatewayState>>,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> impl IntoResponse {
// Rate limit
if !state.oauth_rate_limiter.check() {
return axum::response::Html(
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
<h2>Too Many Requests</h2>\
<p>Please try again later.</p>\
</body></html>"
.to_string(),
)
.into_response();
}
// Validate stream_token: required, non-empty, max 2048 bytes
let stream_token = match params.get("stream_token") {
Some(t) if !t.is_empty() && t.len() <= 2048 => t.clone(),
Some(t) if t.len() > 2048 => {
return axum::response::Html(
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
<h2>Error</h2><p>Invalid callback parameters.</p></body></html>"
.to_string(),
)
.into_response();
}
_ => {
return axum::response::Html(
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
<h2>Error</h2><p>Invalid callback parameters.</p></body></html>"
.to_string(),
)
.into_response();
}
};
// Validate team_id format: empty or T followed by alphanumeric (max 20 chars)
let team_id = params.get("team_id").cloned().unwrap_or_default();
if !team_id.is_empty() {
let valid_team_id = team_id.len() <= 21
&& team_id.starts_with('T')
&& team_id[1..].chars().all(|c| c.is_ascii_alphanumeric());
if !valid_team_id {
return axum::response::Html(
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
<h2>Error</h2><p>Invalid callback parameters.</p></body></html>"
.to_string(),
)
.into_response();
}
}
// Validate provider: must be "slack" (only supported provider)
let provider = params
.get("provider")
.cloned()
.unwrap_or_else(|| "slack".into());
if provider != "slack" {
return axum::response::Html(
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
<h2>Error</h2><p>Invalid callback parameters.</p></body></html>"
.to_string(),
)
.into_response();
}
let ext_mgr = match state.extension_manager.as_ref() {
Some(mgr) => mgr,
None => {
return axum::response::Html(
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
<h2>Error</h2><p>Extension manager not available.</p></body></html>"
.to_string(),
)
.into_response();
}
};
// Validate CSRF state parameter
let state_param = match params.get("state") {
Some(s) if !s.is_empty() && s.len() <= 128 => s.clone(),
_ => {
return axum::response::Html(
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
<h2>Error</h2><p>Invalid or expired authorization.</p></body></html>"
.to_string(),
)
.into_response();
}
};
let state_key = format!("relay:{}:oauth_state", DEFAULT_RELAY_NAME);
let stored_state = match ext_mgr
.secrets()
.get_decrypted(&state.user_id, &state_key)
.await
{
Ok(secret) => secret.expose().to_string(),
Err(_) => {
return axum::response::Html(
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
<h2>Error</h2><p>Invalid or expired authorization.</p></body></html>"
.to_string(),
)
.into_response();
}
};
if state_param != stored_state {
return axum::response::Html(
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
<h2>Error</h2><p>Invalid or expired authorization.</p></body></html>"
.to_string(),
)
.into_response();
}
// Delete the nonce (one-time use)
let _ = ext_mgr.secrets().delete(&state.user_id, &state_key).await;
let result: Result<(), String> = async {
// Store the stream token as a secret
let token_key = format!("relay:{}:stream_token", DEFAULT_RELAY_NAME);
let _ = ext_mgr.secrets().delete(&state.user_id, &token_key).await;
ext_mgr
.secrets()
.create(
&state.user_id,
crate::secrets::CreateSecretParams {
name: token_key,
value: secrecy::SecretString::from(stream_token),
provider: Some(provider.clone()),
expires_at: None,
},
)
.await
.map_err(|e| format!("Failed to store stream token: {}", e))?;
// Store team_id in settings
if let Some(ref store) = state.store {
let team_id_key = format!("relay:{}:team_id", DEFAULT_RELAY_NAME);
let _ = store
.set_setting(&state.user_id, &team_id_key, &serde_json::json!(team_id))
.await;
}
// Activate the relay channel
ext_mgr
.activate_stored_relay(DEFAULT_RELAY_NAME)
.await
.map_err(|e| format!("Failed to activate relay channel: {}", e))?;
Ok(())
}
.await;
let (success, message) = match &result {
Ok(()) => (true, "Slack connected successfully!".to_string()),
Err(e) => {
tracing::error!(error = %e, "Slack relay OAuth callback failed");
(
false,
"Connection failed. Check server logs for details.".to_string(),
)
}
};
// Broadcast SSE event to notify the web UI
state.sse.broadcast(SseEvent::AuthCompleted {
extension_name: DEFAULT_RELAY_NAME.to_string(),
success,
message: message.clone(),
});
if success {
axum::response::Html(
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
<h2>Slack Connected!</h2>\
<p>You can close this tab and return to IronClaw.</p>\
<script>window.close()</script>\
</body></html>"
.to_string(),
)
.into_response()
} else {
axum::response::Html(format!(
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
<h2>Connection Failed</h2>\
<p>{}</p>\
</body></html>",
message
))
.into_response()
}
}
// --- Chat handlers ---
/// Convert web gateway `ImageData` to `IncomingAttachment` objects.
@@ -931,9 +663,9 @@ async fn chat_send_handler(
headers: axum::http::HeaderMap,
Json(req): Json<SendMessageRequest>,
) -> Result<(StatusCode, Json<SendMessageResponse>), (StatusCode, String)> {
tracing::trace!(
"[chat_send_handler] Received message: content_len={}, thread_id={:?}",
req.content.len(),
tracing::debug!(
"[chat_send_handler] Received message: content={:?}, thread_id={:?}",
req.content,
req.thread_id
);
@@ -966,10 +698,10 @@ async fn chat_send_handler(
}
let msg_id = msg.id;
tracing::trace!(
"[chat_send_handler] Created message id={}, content_len={}, images={}",
tracing::debug!(
"[chat_send_handler] Created message id={}, content={:?}, images={}",
msg_id,
req.content.len(),
req.content,
req.images.len()
);
@@ -1077,35 +809,49 @@ async fn chat_auth_token_handler(
"Extension manager not available".to_string(),
))?;
match ext_mgr
.configure_token(&req.extension_name, &req.token)
let result = ext_mgr
.auth(&req.extension_name, Some(&req.token))
.await
{
Ok(result) => {
// Clear auth mode on the active thread
clear_auth_mode(&state).await;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
state.sse.broadcast(SseEvent::AuthCompleted {
extension_name: req.extension_name.clone(),
success: true,
message: result.message.clone(),
});
if result.is_authenticated() {
// Auto-activate so tools are available immediately
let msg = match ext_mgr.activate(&req.extension_name).await {
Ok(r) => format!(
"{} authenticated ({} tools loaded)",
req.extension_name,
r.tools_loaded.len()
),
Err(e) => format!(
"{} authenticated but activation failed: {}",
req.extension_name, e
),
};
Ok(Json(ActionResponse::ok(result.message)))
}
Err(e) => {
let msg = e.to_string();
// Re-emit auth_required for retry on validation errors
if matches!(e, crate::extensions::ExtensionError::ValidationFailed(_)) {
state.sse.broadcast(SseEvent::AuthRequired {
extension_name: req.extension_name.clone(),
instructions: Some(msg.clone()),
auth_url: None,
setup_url: None,
});
}
Ok(Json(ActionResponse::fail(msg)))
}
// Clear auth mode on the active thread
clear_auth_mode(&state).await;
state.sse.broadcast(SseEvent::AuthCompleted {
extension_name: req.extension_name,
success: true,
message: msg.clone(),
});
Ok(Json(ActionResponse::ok(msg)))
} else {
// Re-emit auth_required for retry
state.sse.broadcast(SseEvent::AuthRequired {
extension_name: req.extension_name.clone(),
instructions: result.instructions().map(String::from),
auth_url: result.auth_url().map(String::from),
setup_url: result.setup_url().map(String::from),
});
Ok(Json(ActionResponse::fail(
result
.instructions()
.map(String::from)
.unwrap_or_else(|| "Invalid token".to_string()),
)))
}
}
@@ -1463,17 +1209,11 @@ async fn chat_new_thread_handler(
// Persist the empty conversation row with thread_type metadata synchronously
// so that the subsequent loadThreads() call from the frontend sees it.
if let Some(ref store) = state.store {
match store
if let Err(e) = store
.ensure_conversation(thread_id, "gateway", &state.user_id, None)
.await
{
Ok(true) => {}
Ok(false) => tracing::warn!(
user = %state.user_id,
thread_id = %thread_id,
"Skipped persisting new thread due to ownership/channel conflict"
),
Err(e) => tracing::warn!("Failed to persist new thread: {}", e),
tracing::warn!("Failed to persist new thread: {}", e);
}
let metadata_val = serde_json::json!("thread");
if let Err(e) = store
@@ -1860,7 +1600,7 @@ async fn extensions_install_handler(
// expansion and for first-time auth when credentials are already
// configured (e.g., built-in providers). We only surface an auth_url
// when the extension reports it is awaiting authorization.
match ext_mgr.auth(&req.name).await {
match ext_mgr.auth(&req.name, None).await {
Ok(auth_result) if auth_result.auth_url().is_some() => {
// Scope expansion or initial OAuth: user needs to authorize
resp.auth_url = auth_result.auth_url().map(String::from);
@@ -1889,9 +1629,9 @@ async fn extensions_activate_handler(
// Activation loaded the WASM module. Check if the tool needs
// OAuth scope expansion (e.g., adding google-docs when gmail
// already has a token but missing the documents scope).
// Initial OAuth setup is triggered via configure.
// Initial OAuth setup is triggered via save_setup_secrets.
let mut resp = ActionResponse::ok(result.message);
if let Ok(auth_result) = ext_mgr.auth(&name).await
if let Ok(auth_result) = ext_mgr.auth(&name, None).await
&& auth_result.auth_url().is_some()
{
resp.auth_url = auth_result.auth_url().map(String::from);
@@ -1899,17 +1639,17 @@ async fn extensions_activate_handler(
Ok(Json(resp))
}
Err(activate_err) => {
let needs_auth = matches!(
&activate_err,
crate::extensions::ExtensionError::AuthRequired
);
let err_str = activate_err.to_string();
let needs_auth = err_str.contains("authentication")
|| err_str.contains("401")
|| err_str.contains("Unauthorized");
if !needs_auth {
return Ok(Json(ActionResponse::fail(activate_err.to_string())));
return Ok(Json(ActionResponse::fail(err_str)));
}
// Activation failed due to auth; try authenticating first.
match ext_mgr.auth(&name).await {
match ext_mgr.auth(&name, None).await {
Ok(auth_result) if auth_result.is_authenticated() => {
// Auth succeeded, retry activation.
match ext_mgr.activate(&name).await {
@@ -2116,7 +1856,7 @@ async fn extensions_setup_submit_handler(
"Extension manager not available (secrets store required)".to_string(),
))?;
match ext_mgr.configure(&name, &req.secrets).await {
match ext_mgr.save_setup_secrets(&name, &req.secrets).await {
Ok(result) => {
// Broadcast auth_completed so the chat UI can dismiss any in-progress
// auth card or setup modal that was triggered by tool_auth/tool_activate.
@@ -2196,7 +1936,7 @@ async fn routines_list_handler(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let items: Vec<RoutineInfo> = routines.iter().map(RoutineInfo::from_routine).collect();
let items: Vec<RoutineInfo> = routines.iter().map(routine_to_info).collect();
Ok(Json(RoutineListResponse { routines: items }))
}
@@ -2440,6 +2180,54 @@ async fn routines_runs_handler(
})))
}
/// Convert a Routine to the trimmed RoutineInfo for list display.
fn routine_to_info(r: &crate::agent::routine::Routine) -> RoutineInfo {
let (trigger_type, trigger_summary) = match &r.trigger {
crate::agent::routine::Trigger::Cron { schedule, .. } => {
("cron".to_string(), format!("cron: {}", schedule))
}
crate::agent::routine::Trigger::Event {
pattern, channel, ..
} => {
let ch = channel.as_deref().unwrap_or("any");
("event".to_string(), format!("on {} /{}/", ch, pattern))
}
crate::agent::routine::Trigger::Webhook { path, .. } => {
let p = path.as_deref().unwrap_or("/");
("webhook".to_string(), format!("webhook: {}", p))
}
crate::agent::routine::Trigger::Manual => ("manual".to_string(), "manual only".to_string()),
};
let action_type = match &r.action {
crate::agent::routine::RoutineAction::Lightweight { .. } => "lightweight",
crate::agent::routine::RoutineAction::FullJob { .. } => "full_job",
};
let status = if !r.enabled {
"disabled"
} else if r.consecutive_failures > 0 {
"failing"
} else {
"active"
};
RoutineInfo {
id: r.id,
name: r.name.clone(),
description: r.description.clone(),
enabled: r.enabled,
trigger_type,
trigger_summary,
action_type: action_type.to_string(),
last_run_at: r.last_run_at.map(|dt| dt.to_rfc3339()),
next_fire_at: r.next_fire_at.map(|dt| dt.to_rfc3339()),
run_count: r.run_count,
consecutive_failures: r.consecutive_failures,
status: status.to_string(),
}
}
// --- Settings handlers ---
async fn settings_list_handler(
@@ -2639,7 +2427,6 @@ struct GatewayStatusResponse {
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::credentials::TEST_GATEWAY_CRYPTO_KEY;
#[test]
fn test_build_turns_from_db_messages_complete() {
@@ -2741,7 +2528,6 @@ mod tests {
skill_catalog: None,
scheduler: None,
chat_rate_limiter: RateLimiter::new(30, 60),
oauth_rate_limiter: RateLimiter::new(10, 60),
registry_entries: vec![],
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
@@ -2756,56 +2542,6 @@ mod tests {
.with_state(state)
}
#[tokio::test]
async fn test_csp_header_present_on_responses() {
use std::net::SocketAddr;
let state = test_gateway_state(None);
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let bound = start_server(addr, state.clone(), "test-token".to_string())
.await
.expect("server should start");
let client = reqwest::Client::new();
let resp = client
.get(format!("http://{}/api/health", bound))
.send()
.await
.expect("health request should succeed");
assert_eq!(resp.status(), 200);
let csp = resp
.headers()
.get("content-security-policy")
.expect("CSP header must be present");
let csp_str = csp.to_str().expect("CSP header should be valid UTF-8");
assert!(
csp_str.contains("default-src 'self'"),
"CSP must contain default-src"
);
assert!(
csp_str.contains(
"script-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com"
),
"CSP must allow both marked and DOMPurify script CDNs"
);
assert!(
csp_str.contains("object-src 'none'"),
"CSP must contain object-src 'none'"
);
assert!(
csp_str.contains("frame-ancestors 'none'"),
"CSP must contain frame-ancestors 'none'"
);
if let Some(tx) = state.shutdown_tx.write().await.take() {
let _ = tx.send(());
}
}
#[tokio::test]
async fn test_oauth_callback_missing_params() {
use axum::body::Body;
@@ -2864,7 +2600,7 @@ mod tests {
// Build an ExtensionManager so the handler can look up flows
let secrets = Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
TEST_GATEWAY_CRYPTO_KEY.to_string(),
"test-key-at-least-32-chars-long!!".to_string(),
))
.expect("crypto"),
)));
@@ -2873,7 +2609,6 @@ mod tests {
let ext_mgr = Arc::new(ExtensionManager::new(
mcp_sm,
Arc::new(crate::tools::mcp::process::McpProcessManager::new()),
secrets,
tool_registry,
None,
@@ -2914,7 +2649,7 @@ mod tests {
let secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync> =
Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
TEST_GATEWAY_CRYPTO_KEY.to_string(),
"test-key-at-least-32-chars-long!!".to_string(),
))
.expect("crypto"),
)));
@@ -2923,7 +2658,6 @@ mod tests {
let ext_mgr = Arc::new(ExtensionManager::new(
mcp_sm,
Arc::new(crate::tools::mcp::process::McpProcessManager::new()),
secrets.clone(),
tool_registry,
None,
@@ -3020,7 +2754,7 @@ mod tests {
let secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync> =
Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
TEST_GATEWAY_CRYPTO_KEY.to_string(),
"test-key-at-least-32-chars-long!!".to_string(),
))
.expect("crypto"),
)));
@@ -3029,7 +2763,6 @@ mod tests {
let ext_mgr = Arc::new(ExtensionManager::new(
mcp_sm,
Arc::new(crate::tools::mcp::process::McpProcessManager::new()),
secrets.clone(),
tool_registry,
None,
@@ -3114,177 +2847,4 @@ mod tests {
.is_none()
);
}
// --- Slack relay OAuth CSRF tests ---
fn test_relay_oauth_router(state: Arc<GatewayState>) -> Router {
Router::new()
.route(
"/oauth/slack/callback",
get(slack_relay_oauth_callback_handler),
)
.with_state(state)
}
fn test_secrets_store() -> Arc<dyn crate::secrets::SecretsStore + Send + Sync> {
Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
"test-key-at-least-32-chars-long!!".to_string(),
))
.expect("crypto"),
)))
}
fn test_ext_mgr(
secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync>,
) -> Arc<ExtensionManager> {
let tool_registry = Arc::new(ToolRegistry::new());
let mcp_sm = Arc::new(crate::tools::mcp::session::McpSessionManager::new());
let mcp_pm = Arc::new(crate::tools::mcp::process::McpProcessManager::new());
Arc::new(ExtensionManager::new(
mcp_sm,
mcp_pm,
secrets,
tool_registry,
None,
None,
std::path::PathBuf::from("/tmp/wasm_tools"),
std::path::PathBuf::from("/tmp/wasm_channels"),
None,
"test".to_string(),
None,
vec![],
))
}
#[tokio::test]
async fn test_relay_oauth_callback_missing_state_param() {
use axum::body::Body;
use tower::ServiceExt;
let secrets = test_secrets_store();
let ext_mgr = test_ext_mgr(secrets);
let state = test_gateway_state(Some(ext_mgr));
let app = test_relay_oauth_router(state);
// Callback without state param should be rejected
let req = axum::http::Request::builder()
.uri("/oauth/slack/callback?stream_token=tok123&team_id=T123&provider=slack")
.body(Body::empty())
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
.await
.expect("body");
let html = String::from_utf8_lossy(&body);
assert!(
html.contains("Invalid or expired authorization"),
"Expected CSRF error, got: {}",
&html[..html.len().min(300)]
);
}
#[tokio::test]
async fn test_relay_oauth_callback_wrong_state_param() {
use axum::body::Body;
use tower::ServiceExt;
let secrets = test_secrets_store();
// Store a valid nonce
secrets
.create(
"test",
crate::secrets::CreateSecretParams::new(
format!("relay:{}:oauth_state", DEFAULT_RELAY_NAME),
"correct-nonce-value",
),
)
.await
.expect("store nonce");
let ext_mgr = test_ext_mgr(secrets);
let state = test_gateway_state(Some(ext_mgr));
let app = test_relay_oauth_router(state);
// Callback with wrong state param
let req = axum::http::Request::builder()
.uri("/oauth/slack/callback?stream_token=tok123&team_id=T123&provider=slack&state=wrong-nonce")
.body(Body::empty())
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
.await
.expect("body");
let html = String::from_utf8_lossy(&body);
assert!(
html.contains("Invalid or expired authorization"),
"Expected CSRF error for wrong nonce, got: {}",
&html[..html.len().min(300)]
);
}
#[tokio::test]
async fn test_relay_oauth_callback_correct_state_proceeds() {
use axum::body::Body;
use tower::ServiceExt;
let secrets = test_secrets_store();
let nonce = "valid-test-nonce-12345";
// Store the correct nonce
secrets
.create(
"test",
crate::secrets::CreateSecretParams::new(
format!("relay:{}:oauth_state", DEFAULT_RELAY_NAME),
nonce,
),
)
.await
.expect("store nonce");
let ext_mgr = test_ext_mgr(secrets.clone());
let state = test_gateway_state(Some(ext_mgr));
let app = test_relay_oauth_router(state);
// Callback with correct state param — will pass CSRF check
// but may fail downstream (no real relay service) — that's OK,
// we just verify it doesn't return a CSRF error.
let req = axum::http::Request::builder()
.uri(format!(
"/oauth/slack/callback?stream_token=tok123&team_id=T123&provider=slack&state={}",
nonce
))
.body(Body::empty())
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
.await
.expect("body");
let html = String::from_utf8_lossy(&body);
// Should NOT contain the CSRF error message
assert!(
!html.contains("Invalid or expired authorization"),
"Should have passed CSRF check, got: {}",
&html[..html.len().min(300)]
);
// Verify the nonce was consumed (deleted)
let state_key = format!("relay:{}:oauth_state", DEFAULT_RELAY_NAME);
let exists = secrets.exists("test", &state_key).await.unwrap_or(true);
assert!(!exists, "CSRF nonce should be deleted after use");
}
}
+123 -127
View File
@@ -55,7 +55,7 @@ let _activityThinking = null;
function authenticate() {
token = document.getElementById('token-input').value.trim();
if (!token) {
document.getElementById('auth-error').textContent = I18n.t('auth.errorRequired');
document.getElementById('auth-error').textContent = 'Token required';
return;
}
@@ -89,7 +89,7 @@ function authenticate() {
sessionStorage.removeItem('ironclaw_token');
document.getElementById('auth-screen').style.display = '';
document.getElementById('app').style.display = 'none';
document.getElementById('auth-error').textContent = I18n.t('auth.errorInvalid');
document.getElementById('auth-error').textContent = 'Invalid token';
});
}
@@ -144,7 +144,7 @@ let restartEnabled = false; // Track if restart is available in this deployment
function triggerRestart() {
if (!currentThreadId) {
alert(I18n.t('error.startConversation'));
alert('Please start a conversation first');
return;
}
@@ -155,7 +155,7 @@ function triggerRestart() {
function confirmRestart() {
if (!currentThreadId) {
alert(I18n.t('error.startConversation'));
alert('Please start a conversation first');
return;
}
@@ -190,7 +190,7 @@ function confirmRestart() {
})
.catch((err) => {
console.error('[confirmRestart] Restart request failed:', err);
addMessage('system', I18n.t('error.restartFailed', { message: err.message }));
addMessage('system', 'Restart failed: ' + err.message);
isRestarting = false;
restartBtn.disabled = false;
if (restartIcon) restartIcon.classList.remove('spinning');
@@ -234,7 +234,7 @@ function connectSSE() {
eventSource.onopen = () => {
document.getElementById('sse-dot').classList.remove('disconnected');
document.getElementById('sse-status').textContent = I18n.t('status.connected');
document.getElementById('sse-status').textContent = 'Connected';
// If we were restarting, close the modal and reset button now that server is back
if (isRestarting) {
@@ -256,7 +256,7 @@ function connectSSE() {
eventSource.onerror = () => {
document.getElementById('sse-dot').classList.add('disconnected');
document.getElementById('sse-status').textContent = I18n.t('status.reconnecting');
document.getElementById('sse-status').textContent = 'Reconnecting...';
};
eventSource.addEventListener('response', (e) => {
@@ -464,7 +464,7 @@ function enableChatInput() {
const btn = document.getElementById('send-btn');
if (input) {
input.disabled = false;
input.placeholder = I18n.t('chat.inputPlaceholder');
input.placeholder = 'Message or / for commands...';
}
if (btn) btn.disabled = false;
}
@@ -676,20 +676,26 @@ function renderMarkdown(text) {
return escapeHtml(text);
}
// Sanitize rendered HTML using DOMPurify to prevent XSS from tool output
// or prompt injection in LLM responses. DOMPurify is a DOM-based sanitizer
// that handles all known bypass vectors (SVG onload, newline-split event
// handlers, mutation XSS, etc.) unlike the regex approach it replaces.
// Strip dangerous HTML elements and attributes from rendered markdown.
// This prevents XSS from tool output or prompt injection in LLM responses.
function sanitizeRenderedHtml(html) {
if (typeof DOMPurify !== 'undefined') {
return DOMPurify.sanitize(html, {
USE_PROFILES: { html: true },
FORBID_TAGS: ['style', 'script'],
FORBID_ATTR: ['style', 'onerror', 'onload']
});
}
// DOMPurify not available (CDN unreachable) — return empty string rather than unsanitized HTML
return '';
html = html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
html = html.replace(/<iframe\b[^>]*>[\s\S]*?<\/iframe>/gi, '');
html = html.replace(/<object\b[^>]*>[\s\S]*?<\/object>/gi, '');
html = html.replace(/<embed\b[^>]*\/?>/gi, '');
html = html.replace(/<form\b[^>]*>[\s\S]*?<\/form>/gi, '');
html = html.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '');
html = html.replace(/<link\b[^>]*\/?>/gi, '');
html = html.replace(/<base\b[^>]*\/?>/gi, '');
html = html.replace(/<meta\b[^>]*\/?>/gi, '');
// Remove event handler attributes (onclick, onerror, onload, etc.)
html = html.replace(/\s+on\w+\s*=\s*"[^"]*"/gi, '');
html = html.replace(/\s+on\w+\s*=\s*'[^']*'/gi, '');
html = html.replace(/\s+on\w+\s*=\s*[^\s>]+/gi, '');
// Remove javascript: and data: URLs in href/src attributes
html = html.replace(/(href|src|action)\s*=\s*["']?\s*javascript\s*:/gi, '$1="');
html = html.replace(/(href|src|action)\s*=\s*["']?\s*data\s*:/gi, '$1="');
return html;
}
function copyCodeBlock(btn) {
@@ -697,8 +703,8 @@ function copyCodeBlock(btn) {
const code = pre.querySelector('code');
const text = code ? code.textContent : pre.textContent;
navigator.clipboard.writeText(text).then(() => {
btn.textContent = I18n.t('btn.copied');
setTimeout(() => { btn.textContent = I18n.t('btn.copy'); }, 1500);
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = 'Copy'; }, 1500);
});
}
@@ -985,7 +991,7 @@ function showApproval(data) {
const header = document.createElement('div');
header.className = 'approval-header';
header.textContent = I18n.t('approval.title');
header.textContent = 'Tool requires approval';
card.appendChild(header);
const toolName = document.createElement('div');
@@ -1003,7 +1009,7 @@ function showApproval(data) {
if (data.parameters) {
const paramsToggle = document.createElement('button');
paramsToggle.className = 'approval-params-toggle';
paramsToggle.textContent = I18n.t('approval.showParams');
paramsToggle.textContent = 'Show parameters';
const paramsBlock = document.createElement('pre');
paramsBlock.className = 'approval-params';
paramsBlock.textContent = data.parameters;
@@ -1011,7 +1017,7 @@ function showApproval(data) {
paramsToggle.addEventListener('click', () => {
const visible = paramsBlock.style.display !== 'none';
paramsBlock.style.display = visible ? 'none' : 'block';
paramsToggle.textContent = visible ? I18n.t('approval.showParams') : I18n.t('approval.hideParams');
paramsToggle.textContent = visible ? 'Show parameters' : 'Hide parameters';
});
card.appendChild(paramsToggle);
card.appendChild(paramsBlock);
@@ -1022,17 +1028,17 @@ function showApproval(data) {
const approveBtn = document.createElement('button');
approveBtn.className = 'approve';
approveBtn.textContent = I18n.t('approval.approve');
approveBtn.textContent = 'Approve';
approveBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'approve'));
const alwaysBtn = document.createElement('button');
alwaysBtn.className = 'always';
alwaysBtn.textContent = I18n.t('approval.always');
alwaysBtn.textContent = 'Always';
alwaysBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'always'));
const denyBtn = document.createElement('button');
denyBtn.className = 'deny';
denyBtn.textContent = I18n.t('approval.deny');
denyBtn.textContent = 'Deny';
denyBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'deny'));
actions.appendChild(approveBtn);
@@ -1059,7 +1065,7 @@ function showJobCard(data) {
const title = document.createElement('div');
title.className = 'job-card-title';
title.textContent = data.title || I18n.t('sandbox.job');
title.textContent = data.title || 'Sandbox Job';
info.appendChild(title);
const id = document.createElement('div');
@@ -1071,7 +1077,7 @@ function showJobCard(data) {
const viewBtn = document.createElement('button');
viewBtn.className = 'job-card-view';
viewBtn.textContent = I18n.t('jobs.viewJob');
viewBtn.textContent = 'View Job';
viewBtn.addEventListener('click', () => {
switchTab('jobs');
openJobDetail(data.job_id);
@@ -1083,7 +1089,7 @@ function showJobCard(data) {
browseBtn.className = 'job-card-browse';
browseBtn.href = data.browse_url;
browseBtn.target = '_blank';
browseBtn.textContent = I18n.t('jobs.browse');
browseBtn.textContent = 'Browse';
card.appendChild(browseBtn);
}
@@ -1104,7 +1110,7 @@ function showAuthCard(data) {
const header = document.createElement('div');
header.className = 'auth-header';
header.textContent = I18n.t('authRequired.title', {name: data.extension_name});
header.textContent = 'Authentication required for ' + data.extension_name;
card.appendChild(header);
if (data.instructions) {
@@ -1120,7 +1126,7 @@ function showAuthCard(data) {
if (data.auth_url) {
const oauthBtn = document.createElement('button');
oauthBtn.className = 'auth-oauth';
oauthBtn.textContent = I18n.t('authRequired.authenticateWith', {name: data.extension_name});
oauthBtn.textContent = 'Authenticate with ' + data.extension_name;
oauthBtn.addEventListener('click', () => {
openOAuthUrl(data.auth_url);
});
@@ -1131,7 +1137,7 @@ function showAuthCard(data) {
const setupLink = document.createElement('a');
setupLink.href = data.setup_url;
setupLink.target = '_blank';
setupLink.textContent = I18n.t('authRequired.getToken');
setupLink.textContent = 'Get your token';
links.appendChild(setupLink);
}
@@ -1145,9 +1151,7 @@ function showAuthCard(data) {
const tokenInput = document.createElement('input');
tokenInput.type = 'password';
tokenInput.placeholder = data.instructions
|| I18n.t('auth.extensionTokenPlaceholder')
|| I18n.t('auth.tokenPlaceholder');
tokenInput.placeholder = data.instructions || 'Paste your API key or token';
tokenInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') submitAuthToken(data.extension_name, tokenInput.value);
});
@@ -1166,12 +1170,12 @@ function showAuthCard(data) {
const submitBtn = document.createElement('button');
submitBtn.className = 'auth-submit';
submitBtn.textContent = I18n.t('btn.submit');
submitBtn.textContent = 'Submit';
submitBtn.addEventListener('click', () => submitAuthToken(data.extension_name, tokenInput.value));
const cancelBtn = document.createElement('button');
cancelBtn.className = 'auth-cancel';
cancelBtn.textContent = I18n.t('btn.cancel');
cancelBtn.textContent = 'Cancel';
cancelBtn.addEventListener('click', () => cancelAuth(data.extension_name));
actions.appendChild(submitBtn);
@@ -1686,25 +1690,22 @@ function renderNodes(nodes, container, depth) {
const row = document.createElement('div');
row.className = 'tree-row';
row.style.paddingLeft = (depth * 16 + 8) + 'px';
row.tabIndex = 0;
row.setAttribute('role', 'treeitem');
if (node.is_dir) {
row.setAttribute('aria-expanded', node.expanded ? 'true' : 'false');
const arrow = document.createElement('span');
arrow.className = 'expand-arrow' + (node.expanded ? ' expanded' : '');
arrow.textContent = '\u25B6';
arrow.addEventListener('click', (e) => {
e.stopPropagation();
toggleExpand(node);
});
row.appendChild(arrow);
const label = document.createElement('span');
label.className = 'tree-label dir';
label.textContent = node.name;
label.addEventListener('click', () => toggleExpand(node));
row.appendChild(label);
row.addEventListener('click', () => toggleExpand(node));
row.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleExpand(node); }
});
} else {
const spacer = document.createElement('span');
spacer.className = 'expand-arrow-spacer';
@@ -1713,12 +1714,8 @@ function renderNodes(nodes, container, depth) {
const label = document.createElement('span');
label.className = 'tree-label file';
label.textContent = node.name;
label.addEventListener('click', () => readMemoryFile(node.path));
row.appendChild(label);
row.addEventListener('click', () => readMemoryFile(node.path));
row.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); readMemoryFile(node.path); }
});
}
container.appendChild(row);
@@ -1963,7 +1960,7 @@ function prependLogEntry(entry) {
function toggleLogsPause() {
logsPaused = !logsPaused;
const btn = document.getElementById('logs-pause-btn');
btn.textContent = logsPaused ? I18n.t('logs.resume') : I18n.t('logs.pause');
btn.textContent = logsPaused ? 'Resume' : 'Pause';
if (!logsPaused) {
// Flush buffer: oldest-first + prepend naturally puts newest at top
@@ -2035,7 +2032,7 @@ function loadExtensions() {
]).then(([extData, toolData, registryData]) => {
// Render installed extensions
if (extData.extensions.length === 0) {
extList.innerHTML = '<div class="empty-state">' + I18n.t('extensions.noInstalled') + '</div>';
extList.innerHTML = '<div class="empty-state">No extensions installed</div>';
} else {
extList.innerHTML = '';
for (const ext of extData.extensions) {
@@ -2049,7 +2046,7 @@ function loadExtensions() {
// Available WASM extensions
if (wasmEntries.length === 0) {
wasmList.innerHTML = '<div class="empty-state">' + I18n.t('extensions.noAvailable') + '</div>';
wasmList.innerHTML = '<div class="empty-state">No additional WASM extensions available</div>';
} else {
wasmList.innerHTML = '';
for (const entry of wasmEntries) {
@@ -2059,7 +2056,7 @@ function loadExtensions() {
// MCP servers (show both installed and uninstalled)
if (mcpEntries.length === 0) {
mcpList.innerHTML = '<div class="empty-state">' + I18n.t('mcp.noServers') + '</div>';
mcpList.innerHTML = '<div class="empty-state">No MCP servers available</div>';
} else {
mcpList.innerHTML = '';
for (const entry of mcpEntries) {
@@ -2124,16 +2121,16 @@ function renderAvailableExtensionCard(entry) {
const installBtn = document.createElement('button');
installBtn.className = 'btn-ext install';
installBtn.textContent = I18n.t('extensions.install');
installBtn.textContent = 'Install';
installBtn.addEventListener('click', function() {
installBtn.disabled = true;
installBtn.textContent = I18n.t('extensions.installing');
installBtn.textContent = 'Installing...';
apiFetch('/api/extensions/install', {
method: 'POST',
body: { name: entry.name, kind: entry.kind },
}).then(function(res) {
if (res.success) {
showToast(I18n.t('extensions.installedSuccess', {name: entry.display_name}), 'success');
showToast('Installed ' + entry.display_name, 'success');
// OAuth popup if auth started during install (builtin creds)
if (res.auth_url) {
showToast('Opening authentication for ' + entry.display_name, 'info');
@@ -2197,39 +2194,39 @@ function renderMcpServerCard(entry, installedExt) {
if (!installedExt.active) {
var activateBtn = document.createElement('button');
activateBtn.className = 'btn-ext activate';
activateBtn.textContent = I18n.t('common.activate');
activateBtn.textContent = 'Activate';
activateBtn.addEventListener('click', function() { activateExtension(installedExt.name); });
actions.appendChild(activateBtn);
} else {
var activeLabel = document.createElement('span');
activeLabel.className = 'ext-active-label';
activeLabel.textContent = I18n.t('ext.active');
activeLabel.textContent = 'Active';
actions.appendChild(activeLabel);
}
var removeBtn = document.createElement('button');
removeBtn.className = 'btn-ext remove';
removeBtn.textContent = I18n.t('ext.remove');
removeBtn.textContent = 'Remove';
removeBtn.addEventListener('click', function() { removeExtension(installedExt.name); });
actions.appendChild(removeBtn);
} else {
var installBtn = document.createElement('button');
installBtn.className = 'btn-ext install';
installBtn.textContent = I18n.t('ext.install');
installBtn.textContent = 'Install';
installBtn.addEventListener('click', function() {
installBtn.disabled = true;
installBtn.textContent = I18n.t('ext.installing');
installBtn.textContent = 'Installing...';
apiFetch('/api/extensions/install', {
method: 'POST',
body: { name: entry.name, kind: entry.kind },
}).then(function(res) {
if (res.success) {
showToast(I18n.t('extensions.installedSuccess', { name: entry.display_name }), 'success');
showToast('Installed ' + entry.display_name, 'success');
} else {
showToast(I18n.t('ext.install') + ': ' + (res.message || 'unknown error'), 'error');
showToast('Install: ' + (res.message || 'unknown error'), 'error');
}
loadExtensions();
}).catch(function(err) {
showToast(I18n.t('ext.installFailed', { message: err.message }), 'error');
showToast('Install failed: ' + err.message, 'error');
loadExtensions();
});
});
@@ -2243,7 +2240,7 @@ function renderMcpServerCard(entry, installedExt) {
function createReconfigureButton(extName) {
var btn = document.createElement('button');
btn.className = 'btn-ext configure';
btn.textContent = I18n.t('ext.reconfigure');
btn.textContent = 'Reconfigure';
btn.addEventListener('click', function() { showConfigureModal(extName); });
return btn;
}
@@ -2327,13 +2324,13 @@ function renderExtensionCard(ext) {
if (status === 'active') {
var activeLabel = document.createElement('span');
activeLabel.className = 'ext-active-label';
activeLabel.textContent = I18n.t('ext.active');
activeLabel.textContent = 'Active';
actions.appendChild(activeLabel);
actions.appendChild(createReconfigureButton(ext.name));
} else if (status === 'pairing') {
var pairingLabel = document.createElement('span');
pairingLabel.className = 'ext-pairing-label';
pairingLabel.textContent = I18n.t('status.awaitingPairing');
pairingLabel.textContent = 'Awaiting Pairing';
actions.appendChild(pairingLabel);
actions.appendChild(createReconfigureButton(ext.name));
} else if (status === 'failed') {
@@ -2342,7 +2339,7 @@ function renderExtensionCard(ext) {
// installed or configured: show Setup button
var setupBtn = document.createElement('button');
setupBtn.className = 'btn-ext configure';
setupBtn.textContent = I18n.t('ext.setup');
setupBtn.textContent = 'Setup';
setupBtn.addEventListener('click', function() { showConfigureModal(ext.name); });
actions.appendChild(setupBtn);
}
@@ -2350,14 +2347,14 @@ function renderExtensionCard(ext) {
// WASM tools / MCP servers
const activeLabel = document.createElement('span');
activeLabel.className = 'ext-active-label';
activeLabel.textContent = ext.active ? I18n.t('ext.active') : I18n.t('status.installed');
activeLabel.textContent = ext.active ? 'Active' : 'Installed';
actions.appendChild(activeLabel);
// MCP servers and channel-relay extensions may be installed but inactive — show Activate button
if ((ext.kind === 'mcp_server' || ext.kind === 'channel_relay') && !ext.active) {
// MCP servers may be installed but inactive — show Activate button
if (ext.kind === 'mcp_server' && !ext.active) {
const activateBtn = document.createElement('button');
activateBtn.className = 'btn-ext activate';
activateBtn.textContent = I18n.t('common.activate');
activateBtn.textContent = 'Activate';
activateBtn.addEventListener('click', () => activateExtension(ext.name));
actions.appendChild(activateBtn);
}
@@ -2369,7 +2366,7 @@ function renderExtensionCard(ext) {
if (ext.needs_setup || (ext.has_auth && ext.authenticated)) {
const configBtn = document.createElement('button');
configBtn.className = 'btn-ext configure';
configBtn.textContent = ext.authenticated ? I18n.t('ext.reconfigure') : I18n.t('ext.configure');
configBtn.textContent = ext.authenticated ? 'Reconfigure' : 'Configure';
configBtn.addEventListener('click', () => showConfigureModal(ext.name));
actions.appendChild(configBtn);
}
@@ -2377,7 +2374,7 @@ function renderExtensionCard(ext) {
const removeBtn = document.createElement('button');
removeBtn.className = 'btn-ext remove';
removeBtn.textContent = I18n.t('ext.remove');
removeBtn.textContent = 'Remove';
removeBtn.addEventListener('click', () => removeExtension(ext.name));
actions.appendChild(removeBtn);
@@ -2422,17 +2419,17 @@ function activateExtension(name) {
}
function removeExtension(name) {
if (!confirm(I18n.t('ext.confirmRemove', { name: name }))) return;
if (!confirm('Remove extension "' + name + '"?')) return;
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/remove', { method: 'POST' })
.then((res) => {
if (!res.success) {
showToast(I18n.t('ext.removeFailed', { message: res.message }), 'error');
showToast('Remove failed: ' + res.message, 'error');
} else {
showToast(I18n.t('ext.removed', { name: name }), 'success');
showToast('Removed ' + name, 'success');
}
loadExtensions();
})
.catch((err) => showToast(I18n.t('ext.removeFailed', { message: err.message }), 'error'));
.catch((err) => showToast('Remove failed: ' + err.message, 'error'));
}
function showConfigureModal(name) {
@@ -2459,7 +2456,7 @@ function renderConfigureModal(name, secrets) {
modal.className = 'configure-modal';
const header = document.createElement('h3');
header.textContent = I18n.t('config.title', { name: name });
header.textContent = 'Configure ' + name;
modal.appendChild(header);
const form = document.createElement('div');
@@ -2475,7 +2472,7 @@ function renderConfigureModal(name, secrets) {
if (secret.optional) {
const opt = document.createElement('span');
opt.className = 'field-optional';
opt.textContent = I18n.t('config.optional');
opt.textContent = ' (optional)';
label.appendChild(opt);
}
field.appendChild(label);
@@ -2486,7 +2483,7 @@ function renderConfigureModal(name, secrets) {
const input = document.createElement('input');
input.type = 'password';
input.name = secret.name;
input.placeholder = secret.provided ? I18n.t('config.alreadySet') : '';
input.placeholder = secret.provided ? '(already set — leave empty to keep)' : '';
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') submitConfigureModal(name, fields);
});
@@ -2496,13 +2493,13 @@ function renderConfigureModal(name, secrets) {
const badge = document.createElement('span');
badge.className = 'field-provided';
badge.textContent = '\u2713';
badge.title = I18n.t('config.alreadyConfigured');
badge.title = 'Already configured';
inputRow.appendChild(badge);
}
if (secret.auto_generate && !secret.provided) {
const hint = document.createElement('span');
hint.className = 'field-autogen';
hint.textContent = I18n.t('config.autoGenerate');
hint.textContent = 'Auto-generated if empty';
inputRow.appendChild(hint);
}
@@ -2518,13 +2515,13 @@ function renderConfigureModal(name, secrets) {
const submitBtn = document.createElement('button');
submitBtn.className = 'btn-ext activate';
submitBtn.textContent = I18n.t('config.save');
submitBtn.textContent = 'Save';
submitBtn.addEventListener('click', () => submitConfigureModal(name, fields));
actions.appendChild(submitBtn);
const cancelBtn = document.createElement('button');
cancelBtn.className = 'btn-ext remove';
cancelBtn.textContent = I18n.t('config.cancel');
cancelBtn.textContent = 'Cancel';
cancelBtn.addEventListener('click', closeConfigureModal);
actions.appendChild(cancelBtn);
@@ -2764,11 +2761,11 @@ function loadJobs() {
function renderJobsSummary(s) {
document.getElementById('jobs-summary').innerHTML = ''
+ summaryCard(I18n.t('jobs.summary.total'), s.total, '')
+ summaryCard(I18n.t('jobs.summary.inProgress'), s.in_progress, 'active')
+ summaryCard(I18n.t('jobs.summary.completed'), s.completed, 'completed')
+ summaryCard(I18n.t('jobs.summary.failed'), s.failed, 'failed')
+ summaryCard(I18n.t('jobs.summary.stuck'), s.stuck, 'stuck');
+ summaryCard('Total', s.total, '')
+ summaryCard('In Progress', s.in_progress, 'active')
+ summaryCard('Completed', s.completed, 'completed')
+ summaryCard('Failed', s.failed, 'failed')
+ summaryCard('Stuck', s.stuck, 'stuck');
}
function summaryCard(label, count, cls) {
@@ -3298,11 +3295,11 @@ function loadRoutines() {
function renderRoutinesSummary(s) {
document.getElementById('routines-summary').innerHTML = ''
+ summaryCard(I18n.t('routines.summary.total'), s.total, '')
+ summaryCard(I18n.t('routines.summary.enabled'), s.enabled, 'active')
+ summaryCard(I18n.t('routines.summary.disabled'), s.disabled, '')
+ summaryCard(I18n.t('routines.summary.failing'), s.failing, 'failed')
+ summaryCard(I18n.t('routines.summary.runsToday'), s.runs_today, 'completed');
+ summaryCard('Total', s.total, '')
+ summaryCard('Enabled', s.enabled, 'active')
+ summaryCard('Disabled', s.disabled, '')
+ summaryCard('Failing', s.failing, 'failed')
+ summaryCard('Runs Today', s.runs_today, 'completed');
}
function renderRoutinesList(routines) {
@@ -3468,18 +3465,17 @@ function formatRelativeTime(isoString) {
const absDiff = Math.abs(diffMs);
const future = diffMs < 0;
if (absDiff < 60000)
return future ? I18n.t('time.lessThan1MinuteFromNow') : I18n.t('time.lessThan1MinuteAgo');
if (absDiff < 60000) return future ? 'in <1m' : '<1m ago';
if (absDiff < 3600000) {
const m = Math.floor(absDiff / 60000);
return future ? I18n.t('time.minutesFromNow', { n: m }) : I18n.t('time.minutesAgo', { n: m });
return future ? 'in ' + m + 'm' : m + 'm ago';
}
if (absDiff < 86400000) {
const h = Math.floor(absDiff / 3600000);
return future ? I18n.t('time.hoursFromNow', { n: h }) : I18n.t('time.hoursAgo', { n: h });
return future ? 'in ' + h + 'h' : h + 'h ago';
}
const days = Math.floor(absDiff / 86400000);
return future ? I18n.t('time.daysFromNow', { n: days }) : I18n.t('time.daysAgo', { n: days });
return future ? 'in ' + days + 'd' : days + 'd ago';
}
// --- Gateway status widget ---
@@ -3529,18 +3525,18 @@ function fetchGatewayStatus() {
}
// Connection info
html += '<div class="gw-section-label">' + I18n.t('dashboard.connections') + '</div>';
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.sse') + '</span><span>' + (data.sse_connections || 0) + '</span></div>';
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.websocket') + '</span><span>' + (data.ws_connections || 0) + '</span></div>';
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.uptime') + '</span><span>' + formatDuration(data.uptime_secs) + '</span></div>';
html += '<div class="gw-section-label">Connections</div>';
html += '<div class="gw-stat"><span>SSE</span><span>' + (data.sse_connections || 0) + '</span></div>';
html += '<div class="gw-stat"><span>WebSocket</span><span>' + (data.ws_connections || 0) + '</span></div>';
html += '<div class="gw-stat"><span>Uptime</span><span>' + formatDuration(data.uptime_secs) + '</span></div>';
// Cost tracker
if (data.daily_cost != null) {
html += '<div class="gw-divider"></div>';
html += '<div class="gw-section-label">' + I18n.t('dashboard.costToday') + '</div>';
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.spent') + '</span><span>' + formatCost(data.daily_cost) + '</span></div>';
html += '<div class="gw-section-label">Cost Today</div>';
html += '<div class="gw-stat"><span>Spent</span><span>' + formatCost(data.daily_cost) + '</span></div>';
if (data.actions_this_hour != null) {
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.actionsPerHour') + '</span><span>' + data.actions_this_hour + '</span></div>';
html += '<div class="gw-stat"><span>Actions/hr</span><span>' + data.actions_this_hour + '</span></div>';
}
}
@@ -3748,7 +3744,7 @@ function loadSkills() {
var skillsList = document.getElementById('skills-list');
apiFetch('/api/skills').then(function(data) {
if (!data.skills || data.skills.length === 0) {
skillsList.innerHTML = '<div class="empty-state">' + I18n.t('skills.noInstalled') + '</div>';
skillsList.innerHTML = '<div class="empty-state">No skills installed</div>';
return;
}
skillsList.innerHTML = '';
@@ -3756,7 +3752,7 @@ function loadSkills() {
skillsList.appendChild(renderSkillCard(data.skills[i]));
}
}).catch(function(err) {
skillsList.innerHTML = '<div class="empty-state">' + I18n.t('skills.loadFailed', {message: escapeHtml(err.message)}) + '</div>';
skillsList.innerHTML = '<div class="empty-state">Failed to load skills: ' + escapeHtml(err.message) + '</div>';
});
}
@@ -3793,7 +3789,7 @@ function renderSkillCard(skill) {
if (skill.keywords && skill.keywords.length > 0) {
var kw = document.createElement('div');
kw.className = 'ext-keywords';
kw.textContent = I18n.t('skills.activatesOn') + ': ' + skill.keywords.join(', ');
kw.textContent = 'Activates on: ' + skill.keywords.join(', ');
card.appendChild(kw);
}
@@ -3804,7 +3800,7 @@ function renderSkillCard(skill) {
if (skill.trust.toLowerCase() !== 'trusted') {
var removeBtn = document.createElement('button');
removeBtn.className = 'btn-ext remove';
removeBtn.textContent = I18n.t('skills.remove');
removeBtn.textContent = 'Remove';
removeBtn.addEventListener('click', function() { removeSkill(skill.name); });
actions.appendChild(removeBtn);
}
@@ -3819,7 +3815,7 @@ function searchClawHub() {
if (!query) return;
var resultsDiv = document.getElementById('skill-search-results');
resultsDiv.innerHTML = '<div class="empty-state">' + I18n.t('skills.searching') + '</div>';
resultsDiv.innerHTML = '<div class="empty-state">Searching...</div>';
apiFetch('/api/skills/search', {
method: 'POST',
@@ -3835,7 +3831,7 @@ function searchClawHub() {
warning.style.borderLeft = '3px solid #f0ad4e';
warning.style.paddingLeft = '12px';
warning.style.marginBottom = '16px';
warning.textContent = I18n.t('skills.registryError', {message: data.catalog_error});
warning.textContent = 'Could not reach ClawHub registry: ' + data.catalog_error;
resultsDiv.appendChild(warning);
}
@@ -3867,10 +3863,10 @@ function searchClawHub() {
}
if (resultsDiv.children.length === 0) {
resultsDiv.innerHTML = '<div class="empty-state">' + I18n.t('skills.noResults', {query: escapeHtml(query)}) + '</div>';
resultsDiv.innerHTML = '<div class="empty-state">No skills found for "' + escapeHtml(query) + '"</div>';
}
}).catch(function(err) {
resultsDiv.innerHTML = '<div class="empty-state">' + I18n.t('skills.searchFailed', {message: escapeHtml(err.message)}) + '</div>';
resultsDiv.innerHTML = '<div class="empty-state">Search failed: ' + escapeHtml(err.message) + '</div>';
});
}
@@ -3964,17 +3960,17 @@ function renderCatalogSkillCard(entry, installedNames) {
if (isInstalled) {
var label = document.createElement('span');
label.className = 'ext-active-label';
label.textContent = I18n.t('status.installed');
label.textContent = 'Installed';
actions.appendChild(label);
} else {
var installBtn = document.createElement('button');
installBtn.className = 'btn-ext install';
installBtn.textContent = I18n.t('extensions.install');
installBtn.textContent = 'Install';
installBtn.addEventListener('click', (function(s, btn) {
return function() {
if (!confirm('Install skill "' + s + '" from ClawHub?')) return;
btn.disabled = true;
btn.textContent = I18n.t('extensions.installing');
btn.textContent = 'Installing...';
installSkill(s, null, btn);
};
})(slug, installBtn));
@@ -4016,7 +4012,7 @@ function installSkill(nameOrSlug, url, btn) {
body: body,
}).then(function(res) {
if (res.success) {
showToast(I18n.t('skills.installedSuccess', {name: nameOrSlug}), 'success');
showToast('Installed skill "' + nameOrSlug + '"', 'success');
} else {
showToast('Install failed: ' + (res.message || 'unknown error'), 'error');
}
@@ -4029,19 +4025,19 @@ function installSkill(nameOrSlug, url, btn) {
}
function removeSkill(name) {
if (!confirm(I18n.t('skills.confirmRemove', { name: name }))) return;
if (!confirm('Remove skill "' + name + '"?')) return;
apiFetch('/api/skills/' + encodeURIComponent(name), {
method: 'DELETE',
headers: { 'X-Confirm-Action': 'true' },
}).then(function(res) {
if (res.success) {
showToast(I18n.t('skills.removed', { name: name }), 'success');
showToast('Removed skill "' + name + '"', 'success');
} else {
showToast(I18n.t('skills.removeFailed', { message: res.message || 'unknown error' }), 'error');
showToast('Remove failed: ' + (res.message || 'unknown error'), 'error');
}
loadSkills();
}).catch(function(err) {
showToast(I18n.t('skills.removeFailed', { message: err.message }), 'error');
showToast('Remove failed: ' + err.message, 'error');
});
}
-74
View File
@@ -1,74 +0,0 @@
// i18n Integration for IronClaw App
// This file contains i18n-related functions that extend app.js
// Initialize i18n when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
// Initialize i18n
I18n.init();
I18n.updatePageContent();
updateSlashCommands();
updateLanguageMenu();
});
// Update slash commands with current language
function updateSlashCommands() {
// Update SLASH_COMMANDS descriptions
SLASH_COMMANDS.forEach(cmd => {
const key = 'cmd.' + cmd.cmd.replace(/\s+/g, '').replace(/\//g, '') + '.desc';
const translated = I18n.t(key);
if (translated !== key) {
cmd.desc = translated;
}
});
}
// Toggle language menu
function toggleLanguageMenu() {
const menu = document.getElementById('language-menu');
if (menu) {
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
}
}
// Switch language
function switchLanguage(lang) {
if (I18n.setLanguage(lang)) {
// Update slash commands
updateSlashCommands();
// Update language menu active state
updateLanguageMenu();
// Close menu
const menu = document.getElementById('language-menu');
if (menu) {
menu.style.display = 'none';
}
// Show toast notification
showToast(I18n.t('language.switch') + ': ' + (lang === 'zh-CN' ? '简体中文' : 'English'));
}
}
// Update language menu active state
function updateLanguageMenu() {
const currentLang = I18n.getCurrentLang();
document.querySelectorAll('.language-option').forEach(option => {
if (option.getAttribute('data-lang') === currentLang) {
option.classList.add('active');
} else {
option.classList.remove('active');
}
});
}
// Close language menu when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.language-switcher')) {
const menu = document.getElementById('language-menu');
if (menu) {
menu.style.display = 'none';
}
}
});
-351
View File
@@ -1,351 +0,0 @@
// English Language Pack for IronClaw
I18n.register('en', {
// Auth Page
'auth.title': 'IronClaw',
'auth.tagline': 'Secure AI Assistant',
'auth.tokenLabel': 'Gateway Token',
'auth.tokenPlaceholder': 'Paste your token',
'auth.connect': 'Connect',
'auth.errorRequired': 'Token required',
'auth.errorInvalid': 'Invalid token',
'auth.hint': 'Enter the GATEWAY_AUTH_TOKEN from your .env file',
// Chat
'chat.inputPlaceholder': 'Message or / for commands...',
// Restart Modal
'restart.title': 'Restart IronClaw Instance',
'restart.description': 'Are you sure you want to restart IronClaw? This will gracefully restart the process.',
'restart.warning': 'Running tasks may be interrupted. Restart will complete in a few seconds.',
'restart.cancel': 'Cancel',
'restart.confirm': 'Confirm Restart',
'restart.progressTitle': 'Restarting IronClaw',
'restart.progressSubtitle': 'Please wait for the process to restart...',
'restart.checkLogs': 'Check the Logs tab for details after restart completes.',
// Tabs
'tab.chat': 'Chat',
'tab.memory': 'Memory',
'tab.jobs': 'Jobs',
'tab.routines': 'Routines',
'tab.extensions': 'Extensions',
'tab.skills': 'Skills',
'tab.logs': 'Logs',
// Status
'status.connected': 'Connected',
'status.disconnected': 'Disconnected',
'status.connecting': 'Connecting...',
'status.reconnecting': 'Reconnecting...',
'status.teeVerified': 'TEE Verified',
'status.restart': 'Restart',
'status.active': 'Active',
'status.installed': 'Installed',
'status.awaitingPairing': 'Awaiting Pairing',
// Dashboard
'dashboard.connections': 'Connections',
'dashboard.uptime': 'Uptime',
'dashboard.costToday': 'Cost Today',
'dashboard.spent': 'Spent',
'dashboard.actionsPerHour': 'Actions/hr',
'dashboard.sse': 'SSE',
'dashboard.websocket': 'WebSocket',
// Chat Tab
'chat.newThread': 'New Thread',
'chat.toggleSidebar': 'Toggle Sidebar',
'chat.assistant': 'Assistant',
'chat.conversations': 'Conversations',
'chat.send': 'Send',
'chat.attachImages': 'Attach Images',
'chat.empty': 'Select a file to view content',
'chat.loading': 'Loading...',
'chat.loadingOlder': 'Loading older messages...',
'chat.noFiles': 'No files in workspace',
'chat.noResults': 'No results',
// Thread Sidebar
'thread.assistant': 'Assistant',
'thread.new': 'New Thread',
// Memory Tab
'memory.searchPlaceholder': 'Search memory...',
'memory.workspace': 'workspace',
'memory.edit': 'Edit',
'memory.save': 'Save',
'memory.cancel': 'Cancel',
'memory.selectFile': 'Select a file to view content',
// Jobs Tab
'jobs.summary': 'Jobs Summary',
'jobs.id': 'ID',
'jobs.title': 'Title',
'jobs.source': 'Source',
'jobs.status': 'Status',
'jobs.created': 'Created',
'jobs.actions': 'Actions',
'jobs.empty': 'No jobs',
'jobs.statusRunning': 'Running',
'jobs.statusCompleted': 'Completed',
'jobs.statusFailed': 'Failed',
'jobs.statusPending': 'Pending',
'jobs.jobId': 'Job ID',
'jobs.description': 'Description',
'jobs.stateTransitions': 'State Transitions',
'jobs.projectFiles': 'Project Files',
'jobs.noProjectFiles': 'No project files',
'jobs.viewJob': 'View Job',
'jobs.browse': 'Browse',
// Routines Tab
'routines.summary': 'Routines Summary',
'routines.name': 'Name',
'routines.trigger': 'Trigger',
'routines.action': 'Action',
'routines.lastRun': 'Last Run',
'routines.nextRun': 'Next Run',
'routines.runs': 'Runs',
'routines.status': 'Status',
'routines.actions': 'Actions',
'routines.runsToday': 'Runs Today',
'routines.empty': 'No routines',
'routines.noConfigured': 'No routines configured. Ask the assistant to create one.',
'routines.triggerFailed': 'Trigger failed: {message}',
// Logs Tab
'logs.serverLevel': 'Server: ERROR',
'logs.clientLevel': 'Client Log Level',
'logs.pause': 'Pause',
'logs.resume': 'Resume',
'logs.clear': 'Clear',
'logs.autoScroll': 'Auto-scroll',
'logs.filter': 'Filter logs...',
'logs.empty': 'No logs',
'logs.allLevels': 'All Levels',
'logs.error': 'Error',
'logs.warn': 'Warn',
'logs.info': 'Info',
'logs.debug': 'Debug',
// Extensions Tab
'extensions.installed': 'Installed Extensions',
'extensions.available': 'Available WASM Extensions',
'extensions.installWasm': 'Install WASM Extension',
'extensions.noInstalled': 'No extensions installed',
'extensions.noAvailable': 'No additional WASM extensions available',
'extensions.loading': 'Loading...',
'extensions.install': 'Install',
'extensions.installing': 'Installing...',
'extensions.installedSuccess': 'Installed {name}',
'extensions.remove': 'Remove',
'extensions.activate': 'Activate',
'extensions.reconfigure': 'Reconfigure',
'extensions.tools': 'Tools',
'extensions.noConfigNeeded': 'No configuration needed for {name}',
'extensions.configure': 'Configure {name}',
'extensions.optional': ' (optional)',
'extensions.autoGenerated': 'Auto-generated if empty',
'extensions.pendingPairing': 'Pending pairing requests',
'extensions.from': 'from',
// MCP Servers
'mcp.servers': 'MCP Servers',
'mcp.noServers': 'No MCP servers available',
'mcp.addCustom': 'Add Custom MCP Server',
'mcp.add': 'Add',
'mcp.addedSuccess': 'Added MCP server {name}',
// Registered Tools
'tools.registered': 'Registered Tools',
'tools.name': 'Name',
'tools.description': 'Description',
'tools.empty': 'No tools registered',
// Skills Tab
'skills.installed': 'Installed Skills',
'skills.noInstalled': 'No skills installed',
'skills.searchClawHub': 'Search ClawHub',
'skills.searchPlaceholder': 'Search...',
'skills.installByUrl': 'Install Skill by URL',
'skills.namePlaceholder': 'Skill name or slug',
'skills.urlPlaceholder': 'HTTPS URL to SKILL.md (optional)',
'skills.search': 'Search',
'skills.searching': 'Searching...',
'skills.noResults': 'No skills found for "{query}"',
'skills.searchFailed': 'Search failed: {message}',
'skills.install': 'Install',
'skills.installing': 'Installing...',
'skills.installedSuccess': 'Installed skill "{name}"',
'skills.remove': 'Remove',
'skills.activatesOn': 'Activates on',
'skills.registryError': 'Could not reach ClawHub registry: {message}',
'skills.by': 'by',
'skills.updated': 'updated',
'skills.loading': 'Loading skills...',
'skills.loadFailed': 'Failed to load skills: {message}',
'skills.confirmRemove': 'Remove skill "{name}"?',
'skills.removeFailed': 'Remove failed: {message}',
'skills.removed': 'Removed skill "{name}"',
// Jobs Summary
'jobs.summary.total': 'Total',
'jobs.summary.inProgress': 'In Progress',
'jobs.summary.completed': 'Completed',
'jobs.summary.failed': 'Failed',
'jobs.summary.stuck': 'Stuck',
// Routines Summary
'routines.summary.total': 'Total',
'routines.summary.enabled': 'Enabled',
'routines.summary.disabled': 'Disabled',
'routines.summary.failing': 'Failing',
'routines.summary.runsToday': 'Runs Today',
// Buttons
'btn.close': 'Close',
'btn.cancel': 'Cancel',
'btn.save': 'Save',
'btn.edit': 'Edit',
'btn.confirm': 'Confirm',
'btn.send': 'Send',
'btn.refresh': 'Refresh',
'btn.loadMore': 'Load More',
'btn.copy': 'Copy',
'btn.copied': 'Copied!',
'btn.submit': 'Submit',
'btn.setup': 'Setup',
// Time
'time.lessThan1MinuteAgo': '<1m ago',
'time.lessThan1MinuteFromNow': 'in <1m',
'time.minutesAgo': '{n}m ago',
'time.minutesFromNow': 'in {n}m',
'time.hoursAgo': '{n}h ago',
'time.hoursFromNow': 'in {n}h',
'time.daysAgo': '{n}d ago',
'time.daysFromNow': 'in {n}d',
// Tool Approval
'approval.title': 'Tool requires approval',
'approval.description': 'A tool is requesting permission to run.',
'approval.approve': 'Approve',
'approval.deny': 'Deny',
'approval.always': 'Always',
'approval.approved': 'Approved',
'approval.alwaysApproved': 'Always approved',
'approval.denied': 'Denied',
'approval.showParams': 'Show parameters',
'approval.hideParams': 'Hide parameters',
// Authentication Required
'authRequired.title': 'Authentication required for {name}',
'authRequired.authenticateWith': 'Authenticate with {name}',
'authRequired.getToken': 'Get your token',
'authRequired.instructions': 'Instructions',
// Sandbox Jobs
'sandbox.job': 'Sandbox Job',
'sandbox.doneSignal': 'Done signal sent',
// Error Messages
'error.startConversation': 'Please start a conversation first',
'error.restartFailed': 'Restart failed: {message}',
'error.tokenRequired': 'Token required',
'error.tokenInvalid': 'Invalid token',
'error.connectionFailed': 'Connection failed',
'error.unknown': 'Unknown error',
'error.loadFailed': 'Failed to load: {message}',
// Success Messages
'success.restartInitiated': 'Restart initiated',
'success.saved': 'Saved successfully',
// Slash Commands
'cmd.status.desc': 'Show all jobs, or /status <id> for a specific job',
'cmd.list.desc': 'List all jobs',
'cmd.cancel.desc': '/cancel <job-id> — Cancel a running job',
'cmd.undo.desc': 'Undo last action',
'cmd.redo.desc': 'Redo undone action',
'cmd.compact.desc': 'Compact context window',
'cmd.clear.desc': 'Clear conversation and start fresh',
'cmd.interrupt.desc': 'Stop current operation',
'cmd.heartbeat.desc': 'Trigger manual heartbeat check',
'cmd.summarize.desc': 'Summarize current conversation',
'cmd.suggest.desc': 'Suggest next actions',
'cmd.help.desc': 'Show help',
'cmd.version.desc': 'Show version info',
'cmd.tools.desc': 'List available tools',
'cmd.skills.desc': 'List installed skills',
'cmd.model.desc': 'Show or switch LLM model',
'cmd.threadNew.desc': 'Create new conversation thread',
// Language Switcher
'language.title': 'Language',
'language.en': 'English',
'language.zhCN': '简体中文',
'language.switch': 'Switch Language',
// Tool Activity
'tool.thinking': 'Thinking...',
'tool.completed': 'Completed',
'tool.failed': 'Failed',
'tool.running': 'Running',
'tool.used': '{count} tool(s) used',
'tool.requiresApproval': 'Tool requires approval',
// TEE
'tee.loadingReport': 'Loading attestation report...',
'tee.loadFailed': 'Could not load attestation report',
// Common
'common.loading': 'Loading...',
'common.noData': 'No data',
'common.search': 'Search',
'common.add': 'Add',
'common.remove': 'Remove',
'common.install': 'Install',
'common.activate': 'Activate',
'common.deactivate': 'Deactivate',
'common.configure': 'Configure',
'common.save': 'Save',
'common.cancel': 'Cancel',
'common.confirm': 'Confirm',
'common.close': 'Close',
'common.edit': 'Edit',
'common.delete': 'Delete',
'common.refresh': 'Refresh',
'common.searchPlaceholder': 'Search...',
'common.name': 'Name',
'common.description': 'Description',
'common.status': 'Status',
'common.actions': 'Actions',
'common.version': 'Version',
'common.owner': 'Owner',
'common.tags': 'Tags',
// Extensions
'ext.active': 'Active',
'ext.remove': 'Remove',
'ext.install': 'Install',
'ext.installing': 'Installing...',
'ext.installed': 'Installed',
'ext.setup': 'Setup',
'ext.reconfigure': 'Reconfigure',
'ext.configure': 'Configure',
'ext.confirmRemove': 'Remove extension "{name}"?',
'ext.removeFailed': 'Remove failed: {message}',
'ext.removed': 'Removed {name}',
'ext.installFailed': 'Install failed: {message}',
// Configure
'config.title': 'Configure {name}',
'config.optional': ' (optional)',
'config.alreadySet': '(already set — leave empty to keep)',
'config.alreadyConfigured': 'Already configured',
'config.autoGenerate': 'Auto-generated if empty',
'config.save': 'Save',
'config.cancel': 'Cancel',
});
-89
View File
@@ -1,89 +0,0 @@
// Lightweight internationalization implementation with dynamic language switching
const I18n = {
currentLang: 'en',
fallbackLang: 'en',
translations: {},
// Initialize i18n
init() {
// Read user preference from localStorage
const savedLang = localStorage.getItem('ironclaw_language');
if (savedLang && this.translations[savedLang]) {
this.currentLang = savedLang;
} else {
// Detect browser language
const browserLang = navigator.language || navigator.userLanguage;
this.currentLang = browserLang.startsWith('zh') ? 'zh-CN' : 'en';
}
this.updateHtmlLang();
},
// Register language pack
register(lang, translations) {
this.translations[lang] = translations;
},
// Switch language
setLanguage(lang) {
if (this.translations[lang]) {
this.currentLang = lang;
localStorage.setItem('ironclaw_language', lang);
this.updateHtmlLang();
this.updatePageContent();
return true;
}
return false;
},
// Get current language
getCurrentLang() {
return this.currentLang;
},
// Translate function
t(key, params = {}) {
const translation = this.translations[this.currentLang]?.[key]
|| this.translations[this.fallbackLang]?.[key]
|| key;
// Support placeholder replacement: {name}
return translation.replace(/\{(\w+)\}/g, (match, key) => {
return params[key] !== undefined ? params[key] : match;
});
},
// Update HTML lang attribute
updateHtmlLang() {
document.documentElement.lang = this.currentLang;
},
// Update page content (traverse all data-i18n elements)
updatePageContent() {
// Update text content
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
const attr = el.getAttribute('data-i18n-attr');
if (attr) {
el.setAttribute(attr, this.t(key));
} else {
el.textContent = this.t(key);
}
});
// Update placeholder attributes
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
el.placeholder = this.t(key);
});
// Update title attributes
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.getAttribute('data-i18n-title');
el.title = this.t(key);
});
}
};
// Global access
window.I18n = I18n;
-351
View File
@@ -1,351 +0,0 @@
// 中文语言包 for IronClaw
I18n.register('zh-CN', {
// 认证页面
'auth.title': 'IronClaw',
'auth.tagline': '安全可靠的 AI 助手',
'auth.tokenLabel': '网关令牌',
'auth.tokenPlaceholder': '粘贴你的网关令牌',
'auth.connect': '连接',
'auth.errorRequired': '请输入令牌',
'auth.errorInvalid': '令牌无效',
'auth.hint': '输入 .env 配置文件中的 GATEWAY_AUTH_TOKEN',
// 聊天
'chat.inputPlaceholder': '输入消息或 / 以使用命令...',
// 重启弹窗
'restart.title': '重启 IronClaw 实例',
'restart.description': '确定要重启 IronClaw 实例吗?这将优雅地重启进程。',
'restart.warning': '正在运行的任务可能会中断。重启将在几秒钟内完成。',
'restart.cancel': '取消',
'restart.confirm': '确认重启',
'restart.progressTitle': '正在重启 IronClaw',
'restart.progressSubtitle': '请等待进程重启...',
'restart.checkLogs': '重启完成后,请查看日志标签页了解详情。',
// 标签页
'tab.chat': '聊天',
'tab.memory': '记忆',
'tab.jobs': '任务',
'tab.routines': '定时任务',
'tab.extensions': '扩展',
'tab.skills': '技能',
'tab.logs': '日志',
// 状态
'status.connected': '已连接',
'status.disconnected': '已断开',
'status.connecting': '连接中...',
'status.reconnecting': '重新连接中...',
'status.teeVerified': 'TEE 已验证',
'status.restart': '重启',
'status.active': '已激活',
'status.installed': '已安装',
'status.awaitingPairing': '等待配对',
// 仪表盘
'dashboard.connections': '连接数',
'dashboard.uptime': '运行时间',
'dashboard.costToday': '今日费用',
'dashboard.spent': '已花费',
'dashboard.actionsPerHour': '每小时操作',
'dashboard.sse': 'SSE',
'dashboard.websocket': 'WebSocket',
// 聊天标签页
'chat.newThread': '新对话',
'chat.toggleSidebar': '切换侧边栏',
'chat.assistant': '助手',
'chat.conversations': '对话列表',
'chat.send': '发送',
'chat.attachImages': '附加图片',
'chat.empty': '选择文件查看内容',
'chat.loading': '加载中...',
'chat.loadingOlder': '加载更早的消息...',
'chat.noFiles': '工作区没有文件',
'chat.noResults': '没有结果',
// 对话侧边栏
'thread.assistant': '助手',
'thread.new': '新对话',
// 记忆标签页
'memory.searchPlaceholder': '搜索记忆...',
'memory.workspace': '工作区',
'memory.edit': '编辑',
'memory.save': '保存',
'memory.cancel': '取消',
'memory.selectFile': '选择文件查看内容',
// 任务标签页
'jobs.summary': '任务摘要',
'jobs.id': 'ID',
'jobs.title': '标题',
'jobs.source': '来源',
'jobs.status': '状态',
'jobs.created': '创建时间',
'jobs.actions': '操作',
'jobs.empty': '暂无任务',
'jobs.statusRunning': '运行中',
'jobs.statusCompleted': '已完成',
'jobs.statusFailed': '失败',
'jobs.statusPending': '等待中',
'jobs.jobId': '任务 ID',
'jobs.description': '描述',
'jobs.stateTransitions': '状态转换',
'jobs.projectFiles': '项目文件',
'jobs.noProjectFiles': '没有项目文件',
'jobs.viewJob': '查看任务',
'jobs.browse': '浏览',
// 定时任务标签页
'routines.summary': '定时任务摘要',
'routines.name': '名称',
'routines.trigger': '触发器',
'routines.action': '操作',
'routines.lastRun': '上次运行',
'routines.nextRun': '下次运行',
'routines.runs': '运行次数',
'routines.status': '状态',
'routines.actions': '操作',
'routines.runsToday': '今日运行',
'routines.empty': '暂无定时任务',
'routines.noConfigured': '暂无配置的定时任务。请让助手创建一个。',
'routines.triggerFailed': '触发失败: {message}',
// 日志标签页
'logs.serverLevel': '服务端日志级别',
'logs.clientLevel': '客户端日志级别',
'logs.pause': '暂停',
'logs.resume': '继续',
'logs.clear': '清空',
'logs.autoScroll': '自动滚动',
'logs.filter': '筛选日志...',
'logs.empty': '暂无日志',
'logs.allLevels': '所有级别',
'logs.error': '错误',
'logs.warn': '警告',
'logs.info': '信息',
'logs.debug': '调试',
// 扩展标签页
'extensions.installed': '已安装扩展',
'extensions.available': '可用 WASM 扩展',
'extensions.installWasm': '安装 WASM 扩展',
'extensions.noInstalled': '没有安装扩展',
'extensions.noAvailable': '没有其他可用的 WASM 扩展',
'extensions.loading': '加载中...',
'extensions.install': '安装',
'extensions.installing': '安装中...',
'extensions.installedSuccess': '已安装 {name}',
'extensions.remove': '移除',
'extensions.activate': '激活',
'extensions.reconfigure': '重新配置',
'extensions.tools': '工具',
'extensions.noConfigNeeded': '{name} 不需要配置',
'extensions.configure': '配置 {name}',
'extensions.optional': ' (可选)',
'extensions.autoGenerated': '留空则自动生成',
'extensions.pendingPairing': '等待配对请求',
'extensions.from': '来自',
// MCP 服务器
'mcp.servers': 'MCP 服务器',
'mcp.noServers': '没有可用的 MCP 服务器',
'mcp.addCustom': '添加自定义 MCP 服务器',
'mcp.add': '添加',
'mcp.addedSuccess': '已添加 MCP 服务器 {name}',
// 注册工具
'tools.registered': '注册工具',
'tools.name': '名称',
'tools.description': '描述',
'tools.empty': '没有注册工具',
// 技能标签页
'skills.installed': '已安装技能',
'skills.noInstalled': '没有安装技能',
'skills.searchClawHub': '搜索 ClawHub',
'skills.searchPlaceholder': '搜索...',
'skills.installByUrl': '通过 URL 安装技能',
'skills.namePlaceholder': '技能名称或标识',
'skills.urlPlaceholder': 'SKILL.md 的 HTTPS URL(可选)',
'skills.search': '搜索',
'skills.searching': '搜索中...',
'skills.noResults': '没有找到 "{query}" 相关技能',
'skills.searchFailed': '搜索失败: {message}',
'skills.install': '安装',
'skills.installing': '安装中...',
'skills.installedSuccess': '已安装技能 "{name}"',
'skills.remove': '移除',
'skills.activatesOn': '激活关键词',
'skills.registryError': '无法连接 ClawHub 注册表: {message}',
'skills.by': '作者',
'skills.updated': '更新于',
'skills.loading': '加载技能中...',
'skills.loadFailed': '加载技能失败: {message}',
'skills.confirmRemove': '确定要移除技能 "{name}" 吗?',
'skills.removeFailed': '移除失败: {message}',
'skills.removed': '已移除技能 "{name}"',
// 任务摘要
'jobs.summary.total': '总计',
'jobs.summary.inProgress': '进行中',
'jobs.summary.completed': '已完成',
'jobs.summary.failed': '失败',
'jobs.summary.stuck': '卡住',
// 定时任务摘要
'routines.summary.total': '总计',
'routines.summary.enabled': '已启用',
'routines.summary.disabled': '已禁用',
'routines.summary.failing': '失败',
'routines.summary.runsToday': '今日运行',
// 按钮
'btn.close': '关闭',
'btn.cancel': '取消',
'btn.save': '保存',
'btn.edit': '编辑',
'btn.confirm': '确认',
'btn.send': '发送',
'btn.refresh': '刷新',
'btn.loadMore': '加载更多',
'btn.copy': '复制',
'btn.copied': '已复制!',
'btn.submit': '提交',
'btn.setup': '设置',
// 时间
'time.lessThan1MinuteAgo': '刚刚',
'time.lessThan1MinuteFromNow': '1分钟内',
'time.minutesAgo': '{n}分钟前',
'time.minutesFromNow': '{n}分钟后',
'time.hoursAgo': '{n}小时前',
'time.hoursFromNow': '{n}小时后',
'time.daysAgo': '{n}天前',
'time.daysFromNow': '{n}天后',
// 工具审批
'approval.title': '工具需要审批',
'approval.description': '一个工具请求运行权限。',
'approval.approve': '批准',
'approval.deny': '拒绝',
'approval.always': '始终允许',
'approval.approved': '已批准',
'approval.alwaysApproved': '始终批准',
'approval.denied': '已拒绝',
'approval.showParams': '显示参数',
'approval.hideParams': '隐藏参数',
// 认证
'authRequired.title': '{name} 需要认证',
'authRequired.authenticateWith': '使用 {name} 认证',
'authRequired.getToken': '获取令牌',
'authRequired.instructions': '说明',
// 沙盒任务
'sandbox.job': '沙盒任务',
'sandbox.doneSignal': '完成信号已发送',
// 错误消息
'error.startConversation': '请先开始一个对话',
'error.restartFailed': '重启失败: {message}',
'error.tokenRequired': '请输入令牌',
'error.tokenInvalid': '令牌无效',
'error.connectionFailed': '连接失败',
'error.unknown': '未知错误',
'error.loadFailed': '加载失败: {message}',
// 成功消息
'success.restartInitiated': '已开始重启',
'success.saved': '保存成功',
// 斜杠命令
'cmd.status.desc': '显示所有任务,或使用 /status <id> 查看特定任务',
'cmd.list.desc': '列出所有任务',
'cmd.cancel.desc': '/cancel <job-id> — 取消正在运行的任务',
'cmd.undo.desc': '撤销上一步',
'cmd.redo.desc': '重做已撤销的操作',
'cmd.compact.desc': '压缩上下文窗口',
'cmd.clear.desc': '清空对话并重新开始',
'cmd.interrupt.desc': '停止当前操作',
'cmd.heartbeat.desc': '触发手动心跳检查',
'cmd.summarize.desc': '总结当前对话',
'cmd.suggest.desc': '建议下一步操作',
'cmd.help.desc': '显示帮助',
'cmd.version.desc': '显示版本信息',
'cmd.tools.desc': '列出可用工具',
'cmd.skills.desc': '列出已安装的 AI 技能',
'cmd.model.desc': '显示或切换 LLM 模型',
'cmd.threadNew.desc': '创建新对话线程',
// 语言切换
'language.title': '语言',
'language.en': 'English',
'language.zhCN': '简体中文',
'language.switch': '切换语言',
// 工具活动
'tool.thinking': '思考中...',
'tool.completed': '已完成',
'tool.failed': '失败',
'tool.running': '运行中',
'tool.used': '{count} 个工具已使用',
'tool.requiresApproval': '工具需要审批',
// TEE
'tee.loadingReport': '正在加载证明报告...',
'tee.loadFailed': '无法加载证明报告',
// 通用
'common.loading': '加载中...',
'common.noData': '暂无数据',
'common.search': '搜索',
'common.add': '添加',
'common.remove': '移除',
'common.install': '安装',
'common.activate': '激活',
'common.deactivate': '停用',
'common.configure': '配置',
'common.save': '保存',
'common.cancel': '取消',
'common.confirm': '确认',
'common.close': '关闭',
'common.edit': '编辑',
'common.delete': '删除',
'common.refresh': '刷新',
'common.searchPlaceholder': '搜索...',
'common.name': '名称',
'common.description': '描述',
'common.status': '状态',
'common.actions': '操作',
'common.version': '版本',
'common.owner': '作者',
'common.tags': '标签',
// 扩展
'ext.active': '已激活',
'ext.remove': '移除',
'ext.install': '安装',
'ext.installing': '安装中...',
'ext.installed': '已安装',
'ext.setup': '设置',
'ext.reconfigure': '重新配置',
'ext.configure': '配置',
'ext.confirmRemove': '确定要移除扩展 "{name}" 吗?',
'ext.removeFailed': '移除失败: {message}',
'ext.removed': '已移除 {name}',
'ext.installFailed': '安装失败: {message}',
// 配置
'config.title': '配置 {name}',
'config.optional': '(可选)',
'config.alreadySet': '(已设置 — 留空以保持不变)',
'config.alreadyConfigured': '已配置',
'config.autoGenerate': '如果为空则自动生成',
'config.save': '保存',
'config.cancel': '取消',
});
+86 -114
View File
@@ -9,17 +9,6 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/style.css">
<!-- i18n Modules -->
<script src="/i18n/index.js"></script>
<script src="/i18n/en.js"></script>
<script src="/i18n/zh-CN.js"></script>
<script
src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js"
integrity="sha384-osZDKVu4ipZP703HmPOhWdyBajcFyjX2Psjk//TG1Rc0AdwEtuToaylrmcK3LdAl"
crossorigin="anonymous"
></script>
<script
src="https://cdn.jsdelivr.net/npm/[email protected]/lib/marked.umd.min.js"
integrity="sha384-pN9zSKOnTZwXRtYZAu0PBPEgR2B7DOC1aeLxQ33oJ0oy5iN1we6gm57xldM2irDG"
@@ -31,16 +20,16 @@
<div id="auth-screen">
<div class="auth-card-login">
<div class="auth-brand">
<h1 data-i18n="auth.title">IronClaw</h1>
<p class="auth-tagline" data-i18n="auth.tagline">Secure AI Assistant</p>
<h1>IronClaw</h1>
<p class="auth-tagline">Secure AI Assistant</p>
</div>
<div class="auth-form">
<label for="token-input" data-i18n="auth.tokenLabel">Gateway Token</label>
<input type="password" id="token-input" data-i18n="auth.tokenPlaceholder" data-i18n-attr="placeholder" placeholder="Paste your auth token" autofocus>
<button onclick="authenticate()" data-i18n="auth.connect">Connect</button>
<label for="token-input">Gateway Token</label>
<input type="password" id="token-input" placeholder="Paste your auth token" autofocus>
<button onclick="authenticate()">Connect</button>
</div>
<div id="auth-error"></div>
<p class="auth-hint" data-i18n="auth.hint">Enter the GATEWAY_AUTH_TOKEN from your .env configuration.</p>
<p class="auth-hint">Enter the GATEWAY_AUTH_TOKEN from your .env configuration.</p>
</div>
</div>
@@ -49,22 +38,21 @@
<div class="restart-modal-overlay" onclick="cancelRestart()"></div>
<div class="restart-modal-content">
<div class="restart-modal-header">
<h2 data-i18n="restart.title">Restart IronClaw Instance</h2>
<button class="restart-modal-close" onclick="cancelRestart()" data-i18n="restart.closeTooltip" data-i18n-attr="title"
title="Close">×</button>
<h2>Restart IronClaw Instance</h2>
<button class="restart-modal-close" onclick="cancelRestart()" title="Close">×</button>
</div>
<div class="restart-modal-body">
<p class="restart-modal-description" data-i18n="restart.description">
<p class="restart-modal-description">
Are you sure you want to restart the IronClaw instance? This will gracefully restart the process.
</p>
<div class="restart-modal-warning">
<span class="restart-modal-warning-icon">⚠️</span>
<p data-i18n="restart.warning">Any in-progress jobs may be interrupted. The restart will complete within a few seconds.</p>
<p>Any in-progress jobs may be interrupted. The restart will complete within a few seconds.</p>
</div>
</div>
<div class="restart-modal-footer">
<button class="restart-modal-btn cancel" onclick="cancelRestart()" data-i18n="restart.cancel">Cancel</button>
<button class="restart-modal-btn confirm" onclick="confirmRestart()" data-i18n="restart.confirm">Confirm Restart</button>
<button class="restart-modal-btn cancel" onclick="cancelRestart()">Cancel</button>
<button class="restart-modal-btn confirm" onclick="confirmRestart()">Confirm Restart</button>
</div>
</div>
</div>
@@ -75,13 +63,13 @@
<div class="restart-loader-content">
<div class="restart-spinner"></div>
<div class="restart-loader-text">
<p class="restart-title" data-i18n="restart.progressTitle">Restarting IronClaw</p>
<p class="restart-subtitle" data-i18n="restart.progressSubtitle">Please wait while the process restarts...</p>
<p class="restart-title">Restarting IronClaw</p>
<p class="restart-subtitle">Please wait while the process restarts...</p>
</div>
<div class="restart-progress-bar">
<div class="restart-progress-fill"></div>
</div>
<p class="restart-modal-info" data-i18n="restart.checkLogs">
<p class="restart-modal-info">
Check the Logs tab for details after the restart completes.
</p>
</div>
@@ -91,45 +79,33 @@
<div id="app">
<!-- Tab Bar -->
<div class="tab-bar">
<button class="active" data-tab="chat" data-i18n="tab.chat">Chat</button>
<button data-tab="memory" data-i18n="tab.memory">Memory</button>
<button data-tab="jobs" data-i18n="tab.jobs">Jobs</button>
<button data-tab="routines" data-i18n="tab.routines">Routines</button>
<button data-tab="extensions" data-i18n="tab.extensions">Extensions</button>
<button data-tab="skills" data-i18n="tab.skills">Skills</button>
<button class="active" data-tab="chat">Chat</button>
<button data-tab="memory">Memory</button>
<button data-tab="jobs">Jobs</button>
<button data-tab="routines">Routines</button>
<button data-tab="extensions">Extensions</button>
<button data-tab="skills">Skills</button>
<div class="spacer"></div>
<!-- Language Switcher -->
<div class="language-switcher">
<button class="language-btn" id="language-btn" type="button" onclick="toggleLanguageMenu()" title="Switch Language"
aria-label="Switch language" aria-haspopup="true" aria-expanded="false" aria-controls="language-menu">🌐</button>
<div class="language-menu" id="language-menu" style="display: none;">
<button type="button" class="language-option" onclick="switchLanguage('en')" data-lang="en">English</button>
<button type="button" class="language-option" onclick="switchLanguage('zh-CN')" data-lang="zh-CN">简体中文</button>
</div>
</div>
<button class="status-logs-btn" data-tab="logs" data-i18n="tab.logs" title="Logs">Logs</button>
<button class="status-logs-btn" data-tab="logs" title="Logs">Logs</button>
<div class="tee-shield" id="tee-shield" style="display:none" title="Running in a Trusted Execution Environment">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
<span id="tee-shield-label" data-i18n="status.teeVerified">TEE Verified</span>
<span id="tee-shield-label">TEE Verified</span>
<div class="tee-popover" id="tee-popover"></div>
</div>
<div class="status" id="gateway-status-trigger">
<div class="dot" id="sse-dot"></div>
<span id="sse-status" data-i18n="status.connected">Connected</span>
<span id="sse-status">Connected</span>
<div class="gateway-popover" id="gateway-popover"></div>
</div>
<button class="restart-btn" id="restart-btn" onclick="triggerRestart()" data-i18n="status.restartTooltip"
data-i18n-attr="title" title="Gracefully restart the process" style="display: none;">
<button class="restart-btn" id="restart-btn" onclick="triggerRestart()" title="Gracefully restart the process" style="display: none;">
<svg id="restart-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M23 4v6h-6"></path>
<path d="M1 20v-6h6"></path>
<path d="M3.51 9a9 9 0 0114.85-3.36M20.49 15a9 9 0 01-14.85 3.36"></path>
</svg>
<span data-i18n="status.restart">Restart</span>
<span>Restart</span>
</button>
</div>
@@ -137,18 +113,16 @@
<div class="tab-panel active" id="tab-chat">
<div class="thread-sidebar" id="thread-sidebar">
<div class="thread-sidebar-header">
<button class="thread-new-btn" onclick="createNewThread()" data-i18n="chat.newThread" data-i18n-attr="title"
title="New thread (Ctrl/Cmd+N)">+</button>
<button class="thread-new-btn" onclick="createNewThread()" title="New thread (Ctrl/Cmd+N)">+</button>
<div class="spacer"></div>
<button class="thread-toggle-btn" id="thread-toggle-btn" onclick="toggleThreadSidebar()" data-i18n="chat.toggleSidebar"
data-i18n-attr="title" title="Toggle sidebar">&laquo;</button>
<button class="thread-toggle-btn" id="thread-toggle-btn" onclick="toggleThreadSidebar()" title="Toggle sidebar">&laquo;</button>
</div>
<div class="assistant-item" id="assistant-thread" onclick="switchToAssistant()">
<span class="assistant-label" id="assistant-label" data-i18n="chat.assistant">Assistant</span>
<span class="assistant-label" id="assistant-label">Assistant</span>
<span class="assistant-meta" id="assistant-meta"></span>
</div>
<div class="threads-section-header">
<span data-i18n="chat.conversations">Conversations</span>
<span>Conversations</span>
</div>
<div class="thread-list" id="thread-list"></div>
</div>
@@ -157,11 +131,10 @@
<div id="slash-autocomplete" class="slash-autocomplete" style="display:none"></div>
<div class="chat-input">
<div id="image-preview-strip" class="image-preview-strip"></div>
<textarea id="chat-input" data-i18n="chat.inputPlaceholder" data-i18n-attr="placeholder" placeholder="Message or / for commands..." rows="1"></textarea>
<textarea id="chat-input" placeholder="Message or / for commands..." rows="1"></textarea>
<input type="file" id="image-file-input" accept="image/*" multiple style="display:none">
<button id="attach-btn" class="attach-btn" data-i18n="chat.attachImages" data-i18n-attr="title" title="Attach images"
aria-label="Attach images">&#x1F4CE;</button>
<button id="send-btn" onclick="sendMessage()" data-i18n="chat.send">Send</button>
<button id="attach-btn" class="attach-btn" title="Attach images" aria-label="Attach images">&#x1F4CE;</button>
<button id="send-btn" onclick="sendMessage()">Send</button>
</div>
</div>
</div>
@@ -171,23 +144,23 @@
<div class="memory-container">
<div class="memory-sidebar">
<div class="search-box">
<input type="text" id="memory-search" data-i18n="memory.searchPlaceholder" data-i18n-attr="placeholder" placeholder="Search memory...">
<input type="text" id="memory-search" placeholder="Search memory...">
</div>
<div class="memory-tree" id="memory-tree"></div>
</div>
<div class="memory-content">
<div class="memory-breadcrumb" id="memory-breadcrumb">
<span id="memory-breadcrumb-path">workspace /</span>
<button class="memory-edit-btn" id="memory-edit-btn" style="display:none" onclick="startMemoryEdit()" data-i18n="memory.edit">Edit</button>
<button class="memory-edit-btn" id="memory-edit-btn" style="display:none" onclick="startMemoryEdit()">Edit</button>
</div>
<div class="memory-viewer" id="memory-viewer">
<div class="empty" data-i18n="memory.selectFile">Select a file to view its contents</div>
<div class="empty">Select a file to view its contents</div>
</div>
<div class="memory-editor" id="memory-editor" style="display:none">
<textarea id="memory-edit-textarea"></textarea>
<div class="memory-editor-actions">
<button class="btn-save" onclick="saveMemoryEdit()" data-i18n="memory.save">Save</button>
<button class="btn-cancel-edit" onclick="cancelMemoryEdit()" data-i18n="memory.cancel">Cancel</button>
<button class="btn-save" onclick="saveMemoryEdit()">Save</button>
<button class="btn-cancel-edit" onclick="cancelMemoryEdit()">Cancel</button>
</div>
</div>
</div>
@@ -201,17 +174,17 @@
<table class="jobs-table" id="jobs-table">
<thead>
<tr>
<th data-i18n="jobs.id">ID</th>
<th data-i18n="jobs.title">Title</th>
<th data-i18n="jobs.source">Source</th>
<th data-i18n="jobs.status">Status</th>
<th data-i18n="jobs.created">Created</th>
<th data-i18n="jobs.actions">Actions</th>
<th>ID</th>
<th>Title</th>
<th>Source</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="jobs-tbody"></tbody>
</table>
<div class="empty-state" id="jobs-empty" style="display:none" data-i18n="jobs.empty">No jobs found</div>
<div class="empty-state" id="jobs-empty" style="display:none">No jobs found</div>
</div>
</div>
@@ -226,16 +199,16 @@
<option value="debug">Server: DEBUG</option>
</select>
<select id="logs-level-filter">
<option value="all" data-i18n="logs.allLevels">All Levels</option>
<option value="ERROR" data-i18n="logs.error">Error</option>
<option value="WARN" data-i18n="logs.warn">Warn</option>
<option value="INFO" data-i18n="logs.info">Info</option>
<option value="DEBUG" data-i18n="logs.debug">Debug</option>
<option value="all">All Levels</option>
<option value="ERROR">Error</option>
<option value="WARN">Warn</option>
<option value="INFO">Info</option>
<option value="DEBUG">Debug</option>
</select>
<input type="text" id="logs-target-filter" placeholder="Filter by target...">
<label class="logs-checkbox"><input type="checkbox" id="logs-autoscroll" checked> <span data-i18n="logs.autoScroll">Auto-scroll</span></label>
<button id="logs-pause-btn" onclick="toggleLogsPause()" data-i18n="logs.pause">Pause</button>
<button onclick="clearLogs()" data-i18n="logs.clear">Clear</button>
<label class="logs-checkbox"><input type="checkbox" id="logs-autoscroll" checked> Auto-scroll</label>
<button id="logs-pause-btn" onclick="toggleLogsPause()">Pause</button>
<button onclick="clearLogs()">Clear</button>
</div>
<div class="logs-output" id="logs-output"></div>
</div>
@@ -248,20 +221,20 @@
<table class="routines-table" id="routines-table">
<thead>
<tr>
<th data-i18n="routines.name">Name</th>
<th data-i18n="routines.trigger">Trigger</th>
<th data-i18n="routines.action">Action</th>
<th data-i18n="routines.lastRun">Last Run</th>
<th data-i18n="routines.nextRun">Next Run</th>
<th data-i18n="routines.runs">Runs</th>
<th data-i18n="routines.status">Status</th>
<th data-i18n="routines.actions">Actions</th>
<th>Name</th>
<th>Trigger</th>
<th>Action</th>
<th>Last Run</th>
<th>Next Run</th>
<th>Runs</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="routines-tbody"></tbody>
</table>
<div class="empty-state" id="routines-empty" style="display:none">
<span data-i18n="routines.noConfigured">No routines configured. Ask the assistant to create one.</span>
No routines configured. Ask the assistant to create one.
</div>
<div class="routine-detail" id="routine-detail" style="display:none"></div>
</div>
@@ -271,44 +244,44 @@
<div class="tab-panel" id="tab-extensions">
<div class="extensions-container">
<div class="extensions-section">
<h3 data-i18n="extensions.installed">Installed Extensions</h3>
<h3>Installed Extensions</h3>
<div class="extensions-list" id="extensions-list">
<div class="empty-state" data-i18n="common.loading">Loading...</div>
<div class="empty-state">Loading extensions...</div>
</div>
</div>
<div class="extensions-section" id="available-wasm-section">
<h3 data-i18n="extensions.available">Available WASM Extensions</h3>
<h3>Available WASM Extensions</h3>
<div class="extensions-list" id="available-wasm-list">
<div class="empty-state" data-i18n="common.loading">Loading...</div>
<div class="empty-state">Loading...</div>
</div>
</div>
<div class="extensions-section">
<h3 data-i18n="extensions.installWasm">Install WASM Extension</h3>
<h3>Install WASM Extension</h3>
<div class="ext-install-form">
<input type="text" id="wasm-install-name" data-i18n-placeholder="common.name" placeholder="Extension name">
<input type="text" id="wasm-install-name" placeholder="Extension name">
<input type="text" id="wasm-install-url" placeholder="URL to .tar.gz bundle">
<button onclick="installWasmExtension()" data-i18n="extensions.install">Install</button>
<button onclick="installWasmExtension()">Install</button>
</div>
</div>
<div class="extensions-section">
<h3 data-i18n="mcp.servers">MCP Servers</h3>
<h3>MCP Servers</h3>
<div class="extensions-list" id="mcp-servers-list">
<div class="empty-state" data-i18n="common.loading">Loading...</div>
<div class="empty-state">Loading...</div>
</div>
<h4 data-i18n="mcp.addCustom">Add Custom MCP Server</h4>
<h4>Add Custom MCP Server</h4>
<div class="ext-install-form">
<input type="text" id="mcp-install-name" data-i18n-placeholder="common.name" placeholder="Server name">
<input type="text" id="mcp-install-name" placeholder="Server name">
<input type="text" id="mcp-install-url" placeholder="MCP server URL (https://...)">
<button onclick="addMcpServer()" data-i18n="mcp.add">Add</button>
<button onclick="addMcpServer()">Add</button>
</div>
</div>
<div class="extensions-section">
<h3 data-i18n="tools.registered">Registered Tools</h3>
<h3>Registered Tools</h3>
<table class="tools-table" id="tools-table">
<thead><tr><th data-i18n="tools.name">Name</th><th data-i18n="tools.description">Description</th></tr></thead>
<thead><tr><th>Name</th><th>Description</th></tr></thead>
<tbody id="tools-tbody"></tbody>
</table>
<div class="empty-state" id="tools-empty" style="display:none" data-i18n="tools.empty">No tools registered</div>
<div class="empty-state" id="tools-empty" style="display:none">No tools registered</div>
</div>
</div>
</div>
@@ -317,25 +290,25 @@
<div class="tab-panel" id="tab-skills">
<div class="extensions-container">
<div class="extensions-section">
<h3 data-i18n="skills.searchClawHub">Search ClawHub</h3>
<h3>Search ClawHub</h3>
<div class="skill-search-box">
<input type="text" id="skill-search-input" data-i18n-placeholder="skills.searchPlaceholder" placeholder="Search...">
<button onclick="searchClawHub()" data-i18n="skills.search">Search</button>
<input type="text" id="skill-search-input" placeholder="Search for skills...">
<button onclick="searchClawHub()">Search</button>
</div>
<div class="extensions-list" id="skill-search-results"></div>
</div>
<div class="extensions-section">
<h3 data-i18n="skills.installed">Installed Skills</h3>
<h3>Installed Skills</h3>
<div class="extensions-list" id="skills-list">
<div class="empty-state" data-i18n="skills.loading">Loading skills...</div>
<div class="empty-state">Loading skills...</div>
</div>
</div>
<div class="extensions-section">
<h3 data-i18n="skills.installByUrl">Install Skill by URL</h3>
<h3>Install Skill by URL</h3>
<div class="ext-install-form">
<input type="text" id="skill-install-name" data-i18n-placeholder="skills.namePlaceholder" placeholder="Skill name or slug">
<input type="text" id="skill-install-url" data-i18n-placeholder="skills.urlPlaceholder" placeholder="HTTPS URL to SKILL.md (optional)">
<button onclick="installSkillFromForm()" data-i18n="extensions.install">Install</button>
<input type="text" id="skill-install-name" placeholder="Skill name or slug">
<input type="text" id="skill-install-url" placeholder="HTTPS URL to SKILL.md (optional)">
<button onclick="installSkillFromForm()">Install</button>
</div>
</div>
</div>
@@ -344,6 +317,5 @@
<div id="toasts"></div>
<script src="/app.js"></script>
<script src="/i18n-app.js"></script>
</body>
</html>
+14 -108
View File
@@ -9,7 +9,6 @@
--text-secondary: #a1a1aa;
--accent: #34d399;
--accent-hover: #2fc48d;
--accent-soft: rgba(52, 211, 153, 0.15);
--success: #34d399;
--warning: #F5A623;
--danger: #E64C4C;
@@ -656,11 +655,11 @@ body {
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
gap: 12px;
}
.message {
max-width: 72%;
max-width: 80%;
padding: 10px 14px;
border-radius: var(--radius);
font-size: 14px;
@@ -670,8 +669,8 @@ body {
.message.user {
align-self: flex-end;
background: var(--accent-soft);
color: var(--accent);
background: var(--accent);
color: #09090b;
border-bottom-right-radius: 2px;
white-space: pre-wrap;
}
@@ -681,9 +680,6 @@ body {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-bottom-left-radius: 2px;
padding: 14px 18px;
font-size: 15px;
line-height: 1.6;
}
.message.system {
@@ -714,10 +710,10 @@ body {
padding: 0;
}
.message p { margin: 0 0 10px 0; }
.message p { margin: 0 0 8px 0; }
.message p:last-child { margin-bottom: 0; }
.message ul, .message ol { margin: 4px 0; padding-left: 20px; }
.message li { margin: 4px 0; }
.message li { margin: 2px 0; }
.message blockquote {
margin: 6px 0;
padding: 4px 12px;
@@ -1066,7 +1062,7 @@ body {
}
.approval-card .approval-actions button:disabled {
opacity: 0.5;
opacity: 0.4;
cursor: not-allowed;
}
@@ -1245,7 +1241,7 @@ body {
}
.auth-card .auth-actions button:disabled {
opacity: 0.5;
opacity: 0.4;
cursor: not-allowed;
}
@@ -1281,8 +1277,6 @@ body {
gap: 8px;
background: var(--bg-secondary);
border-top: 1px solid var(--border);
flex-shrink: 0;
min-height: 56px;
}
.chat-input textarea {
@@ -1305,11 +1299,6 @@ body {
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
}
.chat-input textarea:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.chat-input button {
padding: 8px 20px;
background: var(--accent);
@@ -1323,7 +1312,7 @@ body {
transition: background 0.2s, transform 0.2s;
}
.chat-input button:hover:not(:disabled) {
.chat-input button:hover {
background: var(--accent-hover);
transform: translateY(-1px);
}
@@ -1333,18 +1322,8 @@ body {
}
.chat-input button:disabled {
opacity: 0.6;
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
/* Keyboard accessibility focus rings */
.chat-input textarea:focus-visible,
.chat-input button:focus-visible,
.tab-bar button:focus-visible,
.tree-row:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Memory Tab */
@@ -1444,7 +1423,7 @@ body {
color: var(--text-secondary);
}
.tree-row:hover .tree-label.file {
.tree-label.file:hover {
color: var(--accent);
}
@@ -2326,7 +2305,7 @@ body {
}
.log-entry:hover {
background: var(--bg-tertiary);
background: var(--bg-secondary);
}
.log-ts {
@@ -3741,21 +3720,6 @@ mark {
.ext-install-form input {
width: 100%;
}
/* Chat input: ensure visibility on mobile */
.chat-input {
min-height: 52px;
}
.chat-input textarea {
min-height: 36px;
max-height: 100px;
}
.chat-input button {
padding: 6px 16px;
font-size: 14px;
}
}
/* Slash command autocomplete dropdown */
@@ -3800,7 +3764,7 @@ mark {
}
/* Image Upload */
.chat-input .attach-btn {
.attach-btn {
background: none;
border: none;
cursor: pointer;
@@ -3813,13 +3777,10 @@ mark {
display: flex;
align-items: center;
justify-content: center;
font-weight: 400;
}
.chat-input .attach-btn:hover {
background: none;
.attach-btn:hover {
color: var(--text);
transform: none;
}
.image-preview-strip {
@@ -3885,61 +3846,6 @@ mark {
display: block;
}
/* Language Switcher */
.language-switcher {
position: relative;
display: flex;
align-items: center;
}
.language-btn {
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 8px;
font-size: 16px;
border-radius: var(--radius);
transition: all 0.2s;
}
.language-btn:hover {
color: var(--text);
background: var(--bg-tertiary);
}
.language-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 4px;
min-width: 120px;
z-index: 1000;
box-shadow: var(--shadow);
}
.language-option {
padding: 8px 12px;
cursor: pointer;
border-radius: var(--radius);
color: var(--text);
font-size: 13px;
transition: all 0.2s;
}
.language-option:hover {
background: var(--bg-tertiary);
}
.language-option.active {
background: var(--accent);
color: var(--bg);
}
.generated-image-path {
font-size: 12px;
color: var(--text-secondary);
-1
View File
@@ -82,7 +82,6 @@ impl TestGatewayBuilder {
skill_catalog: None,
scheduler: None,
chat_rate_limiter: RateLimiter::new(30, 60),
oauth_rate_limiter: RateLimiter::new(10, 60),
registry_entries: Vec::new(),
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
-54
View File
@@ -735,60 +735,6 @@ pub struct RoutineInfo {
pub status: String,
}
impl RoutineInfo {
/// Convert a `Routine` to the trimmed `RoutineInfo` for list display.
pub fn from_routine(r: &crate::agent::routine::Routine) -> Self {
let (trigger_type, trigger_summary) = match &r.trigger {
crate::agent::routine::Trigger::Cron { schedule, .. } => {
("cron".to_string(), format!("cron: {}", schedule))
}
crate::agent::routine::Trigger::Event {
pattern, channel, ..
} => {
let ch = channel.as_deref().unwrap_or("any");
("event".to_string(), format!("on {} /{}/", ch, pattern))
}
crate::agent::routine::Trigger::SystemEvent {
source, event_type, ..
} => (
"system_event".to_string(),
format!("event: {}.{}", source, event_type),
),
crate::agent::routine::Trigger::Manual => {
("manual".to_string(), "manual only".to_string())
}
};
let action_type = match &r.action {
crate::agent::routine::RoutineAction::Lightweight { .. } => "lightweight",
crate::agent::routine::RoutineAction::FullJob { .. } => "full_job",
};
let status = if !r.enabled {
"disabled"
} else if r.consecutive_failures > 0 {
"failing"
} else {
"active"
};
RoutineInfo {
id: r.id,
name: r.name.clone(),
description: r.description.clone(),
enabled: r.enabled,
trigger_type,
trigger_summary,
action_type: action_type.to_string(),
last_run_at: r.last_run_at.map(|dt| dt.to_rfc3339()),
next_fire_at: r.next_fire_at.map(|dt| dt.to_rfc3339()),
run_count: r.run_count,
consecutive_failures: r.consecutive_failures,
status: status.to_string(),
}
}
}
#[derive(Debug, Serialize)]
pub struct RoutineListResponse {
pub routines: Vec<RoutineInfo>,
+27 -16
View File
@@ -255,31 +255,43 @@ async fn handle_client_message(
token,
} => {
if let Some(ref ext_mgr) = state.extension_manager {
match ext_mgr.configure_token(&extension_name, &token).await {
Ok(result) => {
match ext_mgr.auth(&extension_name, Some(&token)).await {
Ok(result) if result.is_authenticated() => {
let msg = match ext_mgr.activate(&extension_name).await {
Ok(r) => format!(
"{} authenticated ({} tools loaded)",
extension_name,
r.tools_loaded.len()
),
Err(e) => format!(
"{} authenticated but activation failed: {}",
extension_name, e
),
};
crate::channels::web::server::clear_auth_mode(state).await;
state
.sse
.broadcast(crate::channels::web::types::SseEvent::AuthCompleted {
extension_name,
success: true,
message: result.message,
message: msg,
});
}
Ok(result) => {
state
.sse
.broadcast(crate::channels::web::types::SseEvent::AuthRequired {
extension_name,
instructions: result.instructions().map(String::from),
auth_url: result.auth_url().map(String::from),
setup_url: result.setup_url().map(String::from),
});
}
Err(e) => {
let msg = format!("Auth failed: {}", e);
if matches!(e, crate::extensions::ExtensionError::ValidationFailed(_)) {
state.sse.broadcast(
crate::channels::web::types::SseEvent::AuthRequired {
extension_name: extension_name.clone(),
instructions: Some(msg.clone()),
auth_url: None,
setup_url: None,
},
);
}
let _ = direct_tx
.send(WsServerMessage::Error { message: msg })
.send(WsServerMessage::Error {
message: format!("Auth failed: {}", e),
})
.await;
}
}
@@ -497,7 +509,6 @@ mod tests {
skill_registry: None,
skill_catalog: None,
chat_rate_limiter: crate::channels::web::server::RateLimiter::new(30, 60),
oauth_rate_limiter: crate::channels::web::server::RateLimiter::new(10, 60),
registry_entries: Vec::new(),
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
+1 -253
View File
@@ -24,8 +24,6 @@ pub struct WebhookServerConfig {
pub struct WebhookServer {
config: WebhookServerConfig,
routes: Vec<Router>,
/// Merged router saved after start() for restarts via `install_listener()`.
merged_router: Option<Router>,
shutdown_tx: Option<oneshot::Sender<()>>,
handle: Option<JoinHandle<()>>,
}
@@ -36,7 +34,6 @@ impl WebhookServer {
Self {
config,
routes: Vec::new(),
merged_router: None,
shutdown_tx: None,
handle: None,
}
@@ -54,13 +51,7 @@ impl WebhookServer {
for fragment in self.routes.drain(..) {
app = app.merge(fragment);
}
self.merged_router = Some(app.clone());
self.bind_and_spawn(app).await
}
/// Bind a listener to the configured address and spawn the server task.
/// Private helper used by `start()`.
async fn bind_and_spawn(&mut self, app: Router) -> Result<(), ChannelError> {
let listener = tokio::net::TcpListener::bind(self.config.addr)
.await
.map_err(|e| ChannelError::StartupFailed {
@@ -77,7 +68,7 @@ impl WebhookServer {
if let Err(e) = axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = shutdown_rx.await;
tracing::debug!("Webhook server shutting down");
tracing::info!("Webhook server shutting down");
})
.await
{
@@ -89,56 +80,6 @@ impl WebhookServer {
Ok(())
}
/// Clone the merged router, if `start()` has been called.
pub fn merged_router_clone(&self) -> Option<Router> {
self.merged_router.clone()
}
/// Install a pre-bound listener, replacing the current one.
///
/// The caller is responsible for binding the `TcpListener` *outside* any
/// lock so that the async bind does not block other lock waiters. This
/// method only does synchronous bookkeeping plus spawning the (non-blocking)
/// server task, so it is safe to call while holding a mutex.
pub fn install_listener(
&mut self,
new_addr: SocketAddr,
listener: tokio::net::TcpListener,
app: Router,
) -> (Option<oneshot::Sender<()>>, Option<JoinHandle<()>>) {
// Capture old handles so the caller can shut them down outside the lock.
let old_shutdown_tx = self.shutdown_tx.take();
let old_handle = self.handle.take();
self.config.addr = new_addr;
// Spawn the new server task (non-blocking).
let (shutdown_tx, shutdown_rx) = oneshot::channel();
self.shutdown_tx = Some(shutdown_tx);
let handle = tokio::spawn(async move {
if let Err(e) = axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = shutdown_rx.await;
tracing::debug!("Webhook server shutting down");
})
.await
{
tracing::error!("Webhook server error: {}", e);
}
});
self.handle = Some(handle);
tracing::info!("Webhook server listening on {}", new_addr);
(old_shutdown_tx, old_handle)
}
/// Return the current bind address.
pub fn current_addr(&self) -> SocketAddr {
self.config.addr
}
/// Signal graceful shutdown and wait for the server task to finish.
pub async fn shutdown(&mut self) {
if let Some(tx) = self.shutdown_tx.take() {
@@ -149,196 +90,3 @@ impl WebhookServer {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::Json;
use serde_json::json;
#[tokio::test]
async fn test_restart_with_addr_rebinds_listener() {
use std::net::TcpListener as StdTcpListener;
// Find two available ports by binding and immediately closing
let port1 = {
let listener =
StdTcpListener::bind("127.0.0.1:0").expect("Failed to find available port 1");
listener
.local_addr()
.expect("Failed to get local addr")
.port()
};
let port2 = {
let listener =
StdTcpListener::bind("127.0.0.1:0").expect("Failed to find available port 2");
listener
.local_addr()
.expect("Failed to get local addr")
.port()
};
assert_ne!(port1, port2, "Should have different ports");
assert_ne!(port1, 0, "Port 1 should be non-zero");
assert_ne!(port2, 0, "Port 2 should be non-zero");
// Start server on first port
let addr1 = format!("127.0.0.1:{}", port1).parse().unwrap();
let mut server = WebhookServer::new(WebhookServerConfig { addr: addr1 });
// Create a test router that responds to health checks
let test_router = axum::Router::new().route(
"/health",
axum::routing::get(|| async { Json(json!({"status": "ok"})) }),
);
server.add_routes(test_router);
// Start the server on first port
server.start().await.expect("Failed to start server");
assert_eq!(
server.current_addr(),
addr1,
"Server should be bound to initial address"
);
// Verify the first server is actually listening
let client = reqwest::Client::new();
let response = client
.get(format!("http://{}/health", addr1))
.send()
.await
.expect("Failed to send request to first server");
assert_eq!(
response.status(),
200,
"First server should respond to health check"
);
// Restart on second port using two-phase approach
let addr2: SocketAddr = format!("127.0.0.1:{}", port2).parse().unwrap();
let app = server
.merged_router_clone()
.expect("Router should exist after start()");
let listener = tokio::net::TcpListener::bind(addr2)
.await
.expect("Failed to bind to new addr");
let (old_tx, old_handle) = server.install_listener(addr2, listener, app);
if let Some(tx) = old_tx {
let _ = tx.send(());
}
if let Some(handle) = old_handle {
let _ = handle.await;
}
// Assert the address changed
assert_eq!(
server.current_addr(),
addr2,
"Server address should be updated after restart"
);
assert_ne!(
addr1, addr2,
"Address should change after restart_with_addr"
);
// Verify the new server is actually listening on the new address
let response = client
.get(format!("http://{}/health", addr2))
.send()
.await
.expect("Failed to send request to restarted server");
assert_eq!(
response.status(),
200,
"Restarted server should respond to health check on new address"
);
// Verify the old address is no longer responding
let old_result = tokio::time::timeout(
std::time::Duration::from_millis(200),
client.get(format!("http://{}/health", addr1)).send(),
)
.await;
assert!(
old_result.is_err() || old_result.as_ref().unwrap().is_err(),
"Old address should not respond after server restarts"
);
// Clean up
server.shutdown().await;
}
#[tokio::test]
async fn test_restart_with_addr_rollback_on_bind_failure() {
use std::net::TcpListener as StdTcpListener;
// Find an available port
let port1 = {
let listener =
StdTcpListener::bind("127.0.0.1:0").expect("Failed to find available port");
listener
.local_addr()
.expect("Failed to get local addr")
.port()
};
// Start server on first port
let addr1 = format!("127.0.0.1:{}", port1).parse().unwrap();
let mut server = WebhookServer::new(WebhookServerConfig { addr: addr1 });
// Create a test router
let test_router = axum::Router::new().route(
"/health",
axum::routing::get(|| async { Json(json!({"status": "ok"})) }),
);
server.add_routes(test_router);
// Start the server on first port
server.start().await.expect("Failed to start server");
// Verify the server is listening
let client = reqwest::Client::new();
let response = client
.get(format!("http://{}/health", addr1))
.send()
.await
.expect("Failed to send request");
assert_eq!(response.status(), 200, "Server should be listening");
// Try to restart on an invalid address (port 1 typically requires elevated privileges)
let invalid_addr: SocketAddr = "127.0.0.1:1".parse().unwrap();
// Attempt bind (should fail); server state is untouched because we
// never call install_listener on failure.
let app = server
.merged_router_clone()
.expect("Router should exist after start()");
let result = tokio::net::TcpListener::bind(invalid_addr).await;
assert!(result.is_err(), "Bind to privileged port should fail");
// `app` is dropped — server state unchanged (rollback by construction)
drop(app);
// Verify the old address is still responding (rollback succeeded)
let response = client
.get(format!("http://{}/health", addr1))
.send()
.await
.expect("Failed to send request to old address");
assert_eq!(
response.status(),
200,
"Old listener should still be running after failed restart"
);
// Verify the server address is unchanged
assert_eq!(
server.current_addr(),
addr1,
"Server address should be restored after failed restart"
);
// Clean up
server.shutdown().await;
}
}
+6 -542
View File
@@ -7,7 +7,6 @@
use std::path::PathBuf;
use crate::bootstrap::ironclaw_base_dir;
use crate::settings::Settings;
/// Run all diagnostic checks and print results.
pub async fn run_doctor_command() -> anyhow::Result<()> {
@@ -16,35 +15,14 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
let mut passed = 0u32;
let mut failed = 0u32;
let mut skipped = 0u32;
// Load settings once for checks that need them.
let settings = Settings::load();
// ── Settings & core config ─────────────────────────────────
check(
"Settings file",
check_settings_file(),
&mut passed,
&mut failed,
&mut skipped,
);
// ── Configuration checks ──────────────────────────────────
check(
"NEAR AI session",
check_nearai_session().await,
&mut passed,
&mut failed,
&mut skipped,
);
check(
"LLM configuration",
check_llm_config(&settings),
&mut passed,
&mut failed,
&mut skipped,
);
check(
@@ -52,7 +30,6 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
check_database().await,
&mut passed,
&mut failed,
&mut skipped,
);
check(
@@ -60,75 +37,15 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
check_workspace_dir(),
&mut passed,
&mut failed,
&mut skipped,
);
// ── Subsystem configuration checks ─────────────────────────
check(
"Embeddings",
check_embeddings(&settings),
&mut passed,
&mut failed,
&mut skipped,
);
check(
"Routines config",
check_routines_config(),
&mut passed,
&mut failed,
&mut skipped,
);
check(
"Gateway config",
check_gateway_config(&settings),
&mut passed,
&mut failed,
&mut skipped,
);
check(
"MCP servers",
check_mcp_config().await,
&mut passed,
&mut failed,
&mut skipped,
);
check(
"Skills",
check_skills().await,
&mut passed,
&mut failed,
&mut skipped,
);
check(
"Secrets",
check_secrets(&settings),
&mut passed,
&mut failed,
&mut skipped,
);
check(
"Service",
check_service_installed(),
&mut passed,
&mut failed,
&mut skipped,
);
// ── External binary checks ────────────────────────────────
check(
"Docker daemon",
check_docker_daemon().await,
"Docker",
check_binary("docker", &["--version"]),
&mut passed,
&mut failed,
&mut skipped,
);
check(
@@ -136,7 +53,6 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
check_binary("cloudflared", &["--version"]),
&mut passed,
&mut failed,
&mut skipped,
);
check(
@@ -144,7 +60,6 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
check_binary("ngrok", &["version"]),
&mut passed,
&mut failed,
&mut skipped,
);
check(
@@ -152,13 +67,12 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
check_binary("tailscale", &["version"]),
&mut passed,
&mut failed,
&mut skipped,
);
// ── Summary ───────────────────────────────────────────────
println!();
println!(" {passed} passed, {failed} failed, {skipped} skipped");
println!(" {passed} passed, {failed} failed");
if failed > 0 {
println!("\n Some checks failed. This is normal if you don't use those features.");
@@ -169,7 +83,7 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
// ── Individual checks ───────────────────────────────────────
fn check(name: &str, result: CheckResult, passed: &mut u32, failed: &mut u32, skipped: &mut u32) {
fn check(name: &str, result: CheckResult, passed: &mut u32, failed: &mut u32) {
match result {
CheckResult::Pass(detail) => {
*passed += 1;
@@ -180,7 +94,6 @@ fn check(name: &str, result: CheckResult, passed: &mut u32, failed: &mut u32, sk
println!(" [FAIL] {name}: {detail}");
}
CheckResult::Skip(reason) => {
*skipped += 1;
println!(" [skip] {name}: {reason}");
}
}
@@ -192,35 +105,12 @@ enum CheckResult {
Skip(String),
}
// ── Settings file ───────────────────────────────────────────
fn check_settings_file() -> CheckResult {
let path = Settings::default_path();
if !path.exists() {
return CheckResult::Pass("no settings file (defaults will be used)".into());
}
match std::fs::read_to_string(&path) {
Ok(data) => match serde_json::from_str::<serde_json::Value>(&data) {
Ok(_) => CheckResult::Pass(format!("valid ({})", path.display())),
Err(e) => CheckResult::Fail(format!(
"settings.json is malformed: {}. Fix or delete {}",
e,
path.display()
)),
},
Err(e) => CheckResult::Fail(format!("cannot read {}: {}", path.display(), e)),
}
}
// ── NEAR AI session ─────────────────────────────────────────
async fn check_nearai_session() -> CheckResult {
// Check if session file exists
let session_path = crate::config::llm::default_session_path();
if !session_path.exists() {
// Check for API key mode
if crate::config::helpers::env_or_override("NEARAI_API_KEY").is_some() {
if std::env::var("NEARAI_API_KEY").is_ok() {
return CheckResult::Pass("API key configured".into());
}
return CheckResult::Fail(format!(
@@ -239,27 +129,6 @@ async fn check_nearai_session() -> CheckResult {
}
}
// ── LLM configuration ──────────────────────────────────────
fn check_llm_config(settings: &Settings) -> CheckResult {
match crate::llm::LlmConfig::resolve(settings) {
Ok(config) => {
// Show the model for the active backend, not always nearai.model.
let model = if let Some(ref bedrock) = config.bedrock {
&bedrock.model
} else if let Some(ref provider) = config.provider {
&provider.model
} else {
&config.nearai.model
};
CheckResult::Pass(format!("backend={}, model={}", config.backend, model))
}
Err(e) => CheckResult::Fail(format!("LLM config error: {e}")),
}
}
// ── Database ────────────────────────────────────────────────
async fn check_database() -> CheckResult {
let backend = std::env::var("DATABASE_BACKEND")
.ok()
@@ -323,8 +192,6 @@ async fn try_pg_connect() -> Result<(), String> {
Err("postgres feature not compiled in".into())
}
// ── Workspace directory ─────────────────────────────────────
fn check_workspace_dir() -> CheckResult {
let dir = ironclaw_base_dir();
@@ -339,222 +206,6 @@ fn check_workspace_dir() -> CheckResult {
}
}
// ── Embeddings ──────────────────────────────────────────────
fn check_embeddings(settings: &Settings) -> CheckResult {
match crate::config::EmbeddingsConfig::resolve(settings) {
Ok(config) => {
if !config.enabled {
return CheckResult::Skip("disabled (set EMBEDDING_ENABLED=true)".into());
}
let has_creds = match config.provider.as_str() {
"openai" => config.openai_api_key().is_some(),
"nearai" => {
// NearAiEmbeddings uses SessionManager::get_token() which
// only returns session tokens, NOT NEARAI_API_KEY
// (src/workspace/embeddings.rs:309, src/llm/session.rs:132).
let session_path = crate::config::llm::default_session_path();
session_path.exists()
&& std::fs::read_to_string(&session_path)
.map(|s| !s.trim().is_empty())
.unwrap_or(false)
}
"ollama" => true, // local, no creds needed
_ => config.openai_api_key().is_some(),
};
if has_creds {
CheckResult::Pass(format!(
"provider={}, model={}",
config.provider, config.model
))
} else {
let hint = match config.provider.as_str() {
"nearai" => "run `ironclaw onboard` to create a session",
_ => "set OPENAI_API_KEY",
};
CheckResult::Fail(format!(
"provider={} but credentials missing ({})",
config.provider, hint
))
}
}
Err(e) => CheckResult::Fail(format!("config error: {e}")),
}
}
// ── Routines config ─────────────────────────────────────────
fn check_routines_config() -> CheckResult {
match crate::config::RoutineConfig::resolve() {
Ok(config) => {
if config.enabled {
CheckResult::Pass(format!(
"enabled (interval={}s, max_concurrent={})",
config.cron_check_interval_secs, config.max_concurrent_routines
))
} else {
CheckResult::Skip("disabled".into())
}
}
Err(e) => CheckResult::Fail(format!("config error: {e}")),
}
}
// ── Gateway config ──────────────────────────────────────────
fn check_gateway_config(settings: &Settings) -> CheckResult {
// Use the same resolve() path as runtime so invalid env values
// (e.g. GATEWAY_PORT=abc) are caught here too.
match crate::config::ChannelsConfig::resolve(settings) {
Ok(channels) => match channels.gateway {
Some(gw) => {
if gw.auth_token.is_some() {
CheckResult::Pass(format!(
"enabled at {}:{} (auth token set)",
gw.host, gw.port
))
} else {
CheckResult::Pass(format!(
"enabled at {}:{} (no auth token — random token will be generated)",
gw.host, gw.port
))
}
}
None => CheckResult::Skip("disabled (GATEWAY_ENABLED=false)".into()),
},
Err(e) => CheckResult::Fail(format!("config error: {e}")),
}
}
// ── MCP servers ─────────────────────────────────────────────
async fn check_mcp_config() -> CheckResult {
match crate::tools::mcp::config::load_mcp_servers().await {
Ok(file) => {
let servers: Vec<_> = file.enabled_servers().collect();
if servers.is_empty() {
return CheckResult::Skip("no MCP servers configured".into());
}
let mut invalid = Vec::new();
for server in &servers {
if let Err(e) = server.validate() {
invalid.push(format!("{}: {}", server.name, e));
}
}
if invalid.is_empty() {
CheckResult::Pass(format!("{} server(s) configured, all valid", servers.len()))
} else {
CheckResult::Fail(format!(
"{} server(s), {} invalid: {}",
servers.len(),
invalid.len(),
invalid.join("; ")
))
}
}
Err(e) => {
// Distinguish no config from corrupted config
let msg = e.to_string();
if msg.contains("not found") || msg.contains("No such file") {
CheckResult::Skip("no MCP config file".into())
} else {
CheckResult::Fail(format!("config error: {e}"))
}
}
}
}
// ── Skills ──────────────────────────────────────────────────
async fn check_skills() -> CheckResult {
let user_dir = ironclaw_base_dir().join("skills");
let installed_dir = ironclaw_base_dir().join("installed_skills");
let mut registry = crate::skills::SkillRegistry::new(user_dir.clone());
registry = registry.with_installed_dir(installed_dir);
// discover_all() returns loaded skill names (not warnings).
let _loaded_names = registry.discover_all().await;
let count = registry.count();
if count == 0 {
return CheckResult::Skip("no skills discovered".into());
}
CheckResult::Pass(format!("{count} skill(s) loaded"))
}
// ── Secrets ─────────────────────────────────────────────────
fn check_secrets(settings: &Settings) -> CheckResult {
match settings.secrets_master_key_source {
crate::settings::KeySource::Keychain => {
CheckResult::Pass("master key source: OS keychain".into())
}
crate::settings::KeySource::Env => {
if std::env::var("SECRETS_MASTER_KEY").is_ok() {
CheckResult::Pass("master key source: env var (set)".into())
} else {
CheckResult::Fail(
"master key source: env var but SECRETS_MASTER_KEY not set".into(),
)
}
}
crate::settings::KeySource::None => {
CheckResult::Skip("secrets not configured (run `ironclaw onboard`)".into())
}
}
}
// ── Service ─────────────────────────────────────────────────
fn check_service_installed() -> CheckResult {
if cfg!(target_os = "macos") {
let plist =
dirs::home_dir().map(|h| h.join("Library/LaunchAgents/com.ironclaw.daemon.plist"));
match plist {
Some(path) if path.exists() => {
CheckResult::Pass(format!("launchd plist installed ({})", path.display()))
}
Some(_) => CheckResult::Skip("not installed (run `ironclaw service install`)".into()),
None => CheckResult::Skip("cannot determine home directory".into()),
}
} else if cfg!(target_os = "linux") {
let unit = dirs::home_dir().map(|h| h.join(".config/systemd/user/ironclaw.service"));
match unit {
Some(path) if path.exists() => {
CheckResult::Pass(format!("systemd unit installed ({})", path.display()))
}
Some(_) => CheckResult::Skip("not installed (run `ironclaw service install`)".into()),
None => CheckResult::Skip("cannot determine home directory".into()),
}
} else {
CheckResult::Skip("service management not supported on this platform".into())
}
}
// ── Docker daemon ───────────────────────────────────────────
async fn check_docker_daemon() -> CheckResult {
let detection = crate::sandbox::check_docker().await;
match detection.status {
crate::sandbox::DockerStatus::Available => CheckResult::Pass("running".into()),
crate::sandbox::DockerStatus::NotInstalled => CheckResult::Skip(format!(
"not installed. {}",
detection.platform.install_hint()
)),
crate::sandbox::DockerStatus::NotRunning => CheckResult::Fail(format!(
"installed but not running. {}",
detection.platform.start_hint()
)),
crate::sandbox::DockerStatus::Disabled => CheckResult::Skip("sandbox disabled".into()),
}
}
// ── External binary ─────────────────────────────────────────
fn check_binary(name: &str, args: &[&str]) -> CheckResult {
match std::process::Command::new(name)
.args(args)
@@ -622,193 +273,6 @@ mod tests {
}
}
#[test]
fn check_settings_file_handles_missing() {
// Settings::default_path() might or might not exist, but must not panic
let result = check_settings_file();
match result {
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
}
}
#[test]
fn check_llm_config_does_not_panic() {
let settings = Settings::default();
let result = check_llm_config(&settings);
match result {
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
}
}
#[test]
fn check_routines_config_does_not_panic() {
let result = check_routines_config();
match result {
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
}
}
#[test]
fn check_gateway_config_does_not_panic() {
let settings = Settings::default();
let result = check_gateway_config(&settings);
match result {
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
}
}
#[test]
fn check_embeddings_does_not_panic() {
let settings = Settings::default();
let result = check_embeddings(&settings);
match result {
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
}
}
#[test]
fn check_secrets_none_returns_skip() {
let settings = Settings::default();
match check_secrets(&settings) {
CheckResult::Skip(msg) => {
assert!(
msg.contains("not configured"),
"expected 'not configured' in skip message, got: {msg}"
);
}
other => panic!(
"expected Skip for default settings, got: {}",
format_result(&other)
),
}
}
#[test]
fn check_service_installed_does_not_panic() {
let result = check_service_installed();
match result {
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
}
}
#[tokio::test]
async fn check_docker_daemon_does_not_panic() {
let result = check_docker_daemon().await;
match result {
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
}
}
#[tokio::test]
async fn check_mcp_config_does_not_panic() {
let result = check_mcp_config().await;
match result {
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
}
}
#[tokio::test]
async fn check_skills_does_not_panic() {
let result = check_skills().await;
match result {
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
}
}
#[test]
fn check_llm_config_shows_nearai_model_for_nearai_backend() {
let _guard = crate::config::helpers::ENV_MUTEX.lock().expect("env mutex");
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
std::env::remove_var("LLM_BACKEND");
}
let settings = Settings::default();
match check_llm_config(&settings) {
CheckResult::Pass(msg) => {
assert!(
msg.contains("backend=nearai"),
"expected nearai backend, got: {msg}"
);
// Must NOT show a bedrock or registry model when backend is nearai
assert!(
!msg.contains("anthropic.claude"),
"should not show bedrock model for nearai backend: {msg}"
);
}
other => panic!(
"expected Pass for default LLM config, got: {}",
format_result(&other)
),
}
}
#[test]
fn check_embeddings_disabled_by_default_returns_skip() {
let _guard = crate::config::helpers::ENV_MUTEX.lock().expect("env mutex");
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::remove_var("EMBEDDING_ENABLED");
}
let settings = Settings::default();
match check_embeddings(&settings) {
CheckResult::Skip(msg) => {
assert!(
msg.contains("disabled"),
"expected 'disabled' in skip message, got: {msg}"
);
}
other => panic!(
"expected Skip for disabled embeddings, got: {}",
format_result(&other)
),
}
}
#[test]
fn check_routines_enabled_by_default() {
let _guard = crate::config::helpers::ENV_MUTEX.lock().expect("env mutex");
// SAFETY: Under ENV_MUTEX.
unsafe {
std::env::remove_var("ROUTINES_ENABLED");
}
match check_routines_config() {
CheckResult::Pass(msg) => {
assert!(
msg.contains("enabled"),
"routines should be enabled by default, got: {msg}"
);
}
other => panic!(
"expected Pass for default routines, got: {}",
format_result(&other)
),
}
}
#[test]
fn check_secrets_env_without_var_returns_fail() {
let settings = Settings {
secrets_master_key_source: crate::settings::KeySource::Env,
..Default::default()
};
match check_secrets(&settings) {
CheckResult::Fail(msg) => {
assert!(
msg.contains("SECRETS_MASTER_KEY not set"),
"expected mention of missing env var, got: {msg}"
);
}
CheckResult::Pass(_) => {
// If SECRETS_MASTER_KEY happens to be set in the environment,
// Pass is correct — don't fail the test.
}
other => panic!(
"expected Fail or Pass for env key source, got: {}",
format_result(&other)
),
}
}
fn format_result(r: &CheckResult) -> String {
match r {
CheckResult::Pass(s) => format!("Pass({s})"),
-162
View File
@@ -1,162 +0,0 @@
//! Import command for migrating data from other AI systems.
use std::path::PathBuf;
use std::sync::Arc;
use clap::Subcommand;
#[cfg(feature = "import")]
use crate::import::ImportOptions;
#[cfg(feature = "import")]
use crate::import::openclaw::OpenClawImporter;
/// Import data from other AI systems.
#[derive(Subcommand, Debug, Clone)]
pub enum ImportCommand {
/// Import from OpenClaw (memory, history, settings, credentials)
#[cfg(feature = "import")]
Openclaw {
/// Path to OpenClaw directory (default: ~/.openclaw)
#[arg(long)]
path: Option<PathBuf>,
/// Dry-run mode: show what would be imported without writing
#[arg(long)]
dry_run: bool,
/// Re-embed memory if dimensions don't match target provider
#[arg(long)]
re_embed: bool,
/// User ID for imported data (default: 'default')
#[arg(long)]
user_id: Option<String>,
},
}
/// Run an import command.
#[cfg(feature = "import")]
pub async fn run_import_command(
cmd: &ImportCommand,
config: &crate::config::Config,
) -> anyhow::Result<()> {
match cmd {
ImportCommand::Openclaw {
path,
dry_run,
re_embed,
user_id,
} => run_import_openclaw(config, path.clone(), *dry_run, *re_embed, user_id.clone()).await,
}
}
/// Run the OpenClaw import.
#[cfg(feature = "import")]
async fn run_import_openclaw(
config: &crate::config::Config,
openclaw_path: Option<PathBuf>,
dry_run: bool,
re_embed: bool,
user_id: Option<String>,
) -> anyhow::Result<()> {
use secrecy::SecretString;
// Determine OpenClaw path
let openclaw_path = if let Some(path) = openclaw_path {
path
} else if let Some(path) = OpenClawImporter::detect() {
path
} else {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".openclaw")
};
let user_id = user_id.unwrap_or_else(|| "default".to_string());
println!("🔍 OpenClaw Import");
println!(" Path: {}", openclaw_path.display());
println!(" User: {}", user_id);
if dry_run {
println!(" Mode: DRY RUN (no data will be written)");
}
println!();
// Initialize database
let db = crate::db::connect_from_config(&config.database)
.await
.map_err(|e| anyhow::anyhow!("Failed to initialize database: {}", e))?;
// Initialize secrets store with master key from env or keychain
let secrets_crypto = if let Ok(master_key_hex) = std::env::var("SECRETS_MASTER_KEY") {
Arc::new(
crate::secrets::SecretsCrypto::new(SecretString::from(master_key_hex))
.map_err(|e| anyhow::anyhow!("Failed to initialize secrets: {}", e))?,
)
} else {
match crate::secrets::keychain::get_master_key().await {
Ok(key_bytes) => {
let key_hex: String = key_bytes.iter().map(|b| format!("{:02x}", b)).collect();
Arc::new(
crate::secrets::SecretsCrypto::new(SecretString::from(key_hex))
.map_err(|e| anyhow::anyhow!("Failed to initialize secrets: {}", e))?,
)
}
Err(_) => {
return Err(anyhow::anyhow!(
"No secrets master key found. Set SECRETS_MASTER_KEY env var or run 'ironclaw onboard' first."
));
}
}
};
let secrets: Arc<dyn crate::secrets::SecretsStore> = Arc::new(
crate::secrets::InMemorySecretsStore::new(secrets_crypto.clone()),
);
// Initialize workspace
let workspace = crate::workspace::Workspace::new_with_db(user_id.clone(), db.clone());
let opts = ImportOptions {
openclaw_path,
dry_run,
re_embed,
user_id,
};
let importer = OpenClawImporter::new(db, workspace, secrets, opts);
let stats = importer.import().await?;
// Print results
println!("Import Complete");
println!();
println!("Summary:");
println!(" Documents: {}", stats.documents);
println!(" Chunks: {}", stats.chunks);
println!(" Conversations: {}", stats.conversations);
println!(" Messages: {}", stats.messages);
println!(" Settings: {}", stats.settings);
println!(" Secrets: {}", stats.secrets);
if stats.skipped > 0 {
println!(" Skipped: {}", stats.skipped);
}
if stats.re_embed_queued > 0 {
println!(" Re-embed queued: {}", stats.re_embed_queued);
}
println!();
println!("Total imported: {}", stats.total_imported());
if dry_run {
println!();
println!("[DRY RUN] No data was written.");
}
Ok(())
}
#[cfg(not(feature = "import"))]
pub async fn run_import_command(
_cmd: &ImportCommand,
_config: &crate::config::Config,
) -> anyhow::Result<()> {
anyhow::bail!("Import feature not enabled. Compile with --features import")
}
+16 -16
View File
@@ -10,12 +10,11 @@ use clap::{Args, Subcommand};
use crate::config::Config;
use crate::db::Database;
use crate::secrets::SecretsStore;
use crate::secrets::{SecretsCrypto, SecretsStore};
use crate::tools::mcp::{
McpClient, McpProcessManager, McpServerConfig, McpSessionManager, OAuthConfig,
McpClient, McpServerConfig, McpSessionManager, OAuthConfig,
auth::{authorize_mcp_server, is_authenticated},
config::{self, EffectiveTransport, McpServersFile},
factory::create_client_from_config,
};
/// Arguments for the `mcp add` subcommand.
@@ -495,7 +494,7 @@ async fn test_server(name: String, user_id: String) -> anyhow::Result<()> {
let client = if has_tokens {
// We have stored tokens, use authenticated client
McpClient::new_authenticated(server.clone(), session_manager.clone(), secrets, user_id)
McpClient::new_authenticated(server.clone(), session_manager, secrets, user_id)
} else if server.requires_auth() {
// OAuth configured but no tokens - need to authenticate
println!();
@@ -506,17 +505,8 @@ async fn test_server(name: String, user_id: String) -> anyhow::Result<()> {
println!();
return Ok(());
} else {
// Use the factory to dispatch on transport type (HTTP, stdio, unix)
let process_manager = Arc::new(McpProcessManager::new());
create_client_from_config(
server.clone(),
&session_manager,
&process_manager,
None,
"default",
)
.await
.map_err(|e| anyhow::anyhow!("{}", e))?
// No OAuth and no tokens - try unauthenticated
McpClient::new_with_config(server.clone())
};
// Test connection
@@ -638,7 +628,17 @@ async fn save_servers(
/// Initialize and return the secrets store.
async fn get_secrets_store() -> anyhow::Result<Arc<dyn SecretsStore + Send + Sync>> {
crate::cli::init_secrets_store().await
let config = Config::from_env().await?;
let master_key = config.secrets.master_key().ok_or_else(|| {
anyhow::anyhow!(
"SECRETS_MASTER_KEY not set. Run 'ironclaw onboard' first or set it in .env"
)
})?;
let crypto = Arc::new(SecretsCrypto::new(master_key.clone())?);
Ok(crate::db::create_secrets_store(&config.database, crypto).await?)
}
#[cfg(test)]

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