mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f714b0a3dd |
@@ -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:
|
||||
|
||||
@@ -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) -->
|
||||
@@ -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,17 +154,12 @@ jobs:
|
||||
while IFS= read -r line; do
|
||||
sha256=$(echo "$line" | awk '{print $1}')
|
||||
filename=$(echo "$line" | awk '{print $2}')
|
||||
# Strip -{version}-wasm32-wasip2.tar.gz to get the extension name.
|
||||
# Use '.*' (greedy) so pre-release suffixes like -alpha.1 are consumed too.
|
||||
name=$(echo "$filename" | sed 's/-[0-9].*-wasm32-wasip2\.tar\.gz$//')
|
||||
url="https://github.com/nearai/ironclaw/releases/download/${RELEASE_TAG}/${filename}"
|
||||
name=$(echo "$filename" | sed 's/-wasm32-wasip2\.tar\.gz$//')
|
||||
|
||||
for manifest in registry/tools/${name}.json registry/channels/${name}.json; do
|
||||
if [ -f "$manifest" ]; then
|
||||
jq --arg sha "$sha256" --arg url "$url" \
|
||||
'.artifacts["wasm32-wasip2"].sha256 = $sha | .artifacts["wasm32-wasip2"].url = $url' \
|
||||
"$manifest" > "${manifest}.tmp" && mv "${manifest}.tmp" "$manifest"
|
||||
echo "Patched $manifest with sha256=$sha256 url=$url"
|
||||
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"
|
||||
@@ -275,41 +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).
|
||||
# Used for the bundle filename and CI manifest lookup, so patching always
|
||||
# finds the right file regardless of whether manifest.name matches the filename.
|
||||
file_stem=$(basename "$manifest" .json)
|
||||
# ext_name: the manifest's .name field (e.g. "slack-tool").
|
||||
# Used for file names *inside* the archive — the installer extracts by manifest.name.
|
||||
ext_name=$(jq -r '.name' "$manifest")
|
||||
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
|
||||
}
|
||||
|
||||
@@ -325,36 +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 file_stem so CI patching can find the manifest by
|
||||
# filename (e.g. slack-0.1.0-wasm32-wasip2.tar.gz → registry/tools/slack.json).
|
||||
bundle="target/wasm-bundles/${file_stem}-${ext_version}-wasm32-wasip2.tar.gz"
|
||||
(cd target/wasm-bundles && if [ -f "${ext_name}.capabilities.json" ]; then
|
||||
tar czf "${file_stem}-${ext_version}-wasm32-wasip2.tar.gz" "${ext_name}.wasm" "${ext_name}.capabilities.json"
|
||||
else
|
||||
tar czf "${file_stem}-${ext_version}-wasm32-wasip2.tar.gz" "${ext_name}.wasm"
|
||||
fi)
|
||||
# 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 ${file_stem}-${ext_version}-wasm32-wasip2.tar.gz" >> 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
|
||||
@@ -460,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
|
||||
@@ -474,17 +439,12 @@ jobs:
|
||||
while IFS= read -r line; do
|
||||
sha256=$(echo "$line" | awk '{print $1}')
|
||||
filename=$(echo "$line" | awk '{print $2}')
|
||||
# Strip -{version}-wasm32-wasip2.tar.gz to get the extension name.
|
||||
# Use '.*' (greedy) so pre-release suffixes like -alpha.1 are consumed too.
|
||||
name=$(echo "$filename" | sed 's/-[0-9].*-wasm32-wasip2\.tar\.gz$//')
|
||||
url="https://github.com/nearai/ironclaw/releases/download/${RELEASE_TAG}/${filename}"
|
||||
name=$(echo "$filename" | sed 's/-wasm32-wasip2\.tar\.gz$//')
|
||||
|
||||
for manifest in registry/tools/${name}.json registry/channels/${name}.json; do
|
||||
if [ -f "$manifest" ]; then
|
||||
jq --arg sha "$sha256" --arg url "$url" \
|
||||
'.artifacts["wasm32-wasip2"].sha256 = $sha | .artifacts["wasm32-wasip2"].url = $url' \
|
||||
"$manifest" > "${manifest}.tmp" && mv "${manifest}.tmp" "$manifest"
|
||||
echo "Patched $manifest with sha256=$sha256 url=$url"
|
||||
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"
|
||||
@@ -501,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
|
||||
|
||||
@@ -43,6 +43,7 @@ jobs:
|
||||
name: Telegram Channel Tests
|
||||
if: >
|
||||
github.event_name == 'push' ||
|
||||
github.event_name == 'workflow_call' ||
|
||||
(github.event_name == 'pull_request' && github.base_ref != 'staging')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -58,6 +59,7 @@ jobs:
|
||||
name: Windows Build (${{ matrix.name }})
|
||||
if: >
|
||||
github.event_name == 'push' ||
|
||||
github.event_name == 'workflow_call' ||
|
||||
(github.event_name == 'pull_request' && github.base_ref != 'staging')
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
@@ -85,6 +87,7 @@ jobs:
|
||||
name: WASM WIT Compatibility
|
||||
if: >
|
||||
github.event_name == 'push' ||
|
||||
github.event_name == 'workflow_call' ||
|
||||
(github.event_name == 'pull_request' && github.base_ref != 'staging')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -108,6 +111,7 @@ jobs:
|
||||
name: Docker Build
|
||||
if: >
|
||||
github.event_name == 'push' ||
|
||||
github.event_name == 'workflow_call' ||
|
||||
(github.event_name == 'pull_request' && github.base_ref != 'staging')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -28,4 +28,3 @@ trace_*.json
|
||||
|
||||
# Local Claude Code settings (machine-specific, should not be committed)
|
||||
.claude/settings.local.json
|
||||
.worktrees/
|
||||
|
||||
@@ -7,90 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.18.0](https://github.com/nearai/ironclaw/compare/v0.17.0...v0.18.0) - 2026-03-11
|
||||
|
||||
### Other
|
||||
|
||||
- Merge pull request #907 from nearai/staging-promote/b0214fef-22930316561
|
||||
- promote staging to main (2026-03-10 15:19 UTC) ([#865](https://github.com/nearai/ironclaw/pull/865))
|
||||
- Merge pull request #830 from nearai/staging-promote/3a2989d0-22888378864
|
||||
- update WASM artifact SHA256 checksums [skip ci] ([#876](https://github.com/nearai/ironclaw/pull/876))
|
||||
|
||||
## [0.17.0](https://github.com/nearai/ironclaw/compare/v0.16.1...v0.17.0) - 2026-03-10
|
||||
|
||||
### Added
|
||||
|
||||
- *(llm)* per-provider unsupported parameter filtering (#749, #728) ([#809](https://github.com/nearai/ironclaw/pull/809))
|
||||
- persist user_id in save_job and expose job_id on routine runs ([#709](https://github.com/nearai/ironclaw/pull/709))
|
||||
- *(ci)* chained promotion PRs with multi-agent Claude review ([#776](https://github.com/nearai/ironclaw/pull/776))
|
||||
- add background sandbox reaper for orphaned Docker containers ([#634](https://github.com/nearai/ironclaw/pull/634))
|
||||
- *(wasm)* lazy schema injection on WASM tool errors ([#638](https://github.com/nearai/ironclaw/pull/638))
|
||||
- add AWS Bedrock LLM provider via native Converse API ([#713](https://github.com/nearai/ironclaw/pull/713))
|
||||
- full image support across all channels ([#725](https://github.com/nearai/ironclaw/pull/725))
|
||||
- *(skills)* exclude_keywords veto in skill activation scoring ([#688](https://github.com/nearai/ironclaw/pull/688))
|
||||
- *(mcp)* transport abstraction, stdio/UDS transports, and OAuth fixes ([#721](https://github.com/nearai/ironclaw/pull/721))
|
||||
- add PID-based gateway lock to prevent multiple instances ([#717](https://github.com/nearai/ironclaw/pull/717))
|
||||
- configurable LLM request timeout via LLM_REQUEST_TIMEOUT_SECS ([#615](https://github.com/nearai/ironclaw/pull/615)) ([#630](https://github.com/nearai/ironclaw/pull/630))
|
||||
- *(timezone)* add timezone-aware session context ([#671](https://github.com/nearai/ironclaw/pull/671))
|
||||
- *(setup)* Anthropic OAuth onboarding with setup-token support ([#384](https://github.com/nearai/ironclaw/pull/384))
|
||||
- *(llm)* add Google Gemini, AWS Bedrock, io.net, Mistral, Yandex, and Cloudflare WS AI providers ([#676](https://github.com/nearai/ironclaw/pull/676))
|
||||
- unified thread model for web gateway ([#607](https://github.com/nearai/ironclaw/pull/607))
|
||||
- WASM channel attachments with LLM pipeline integration ([#596](https://github.com/nearai/ironclaw/pull/596))
|
||||
- enable Anthropic prompt caching via automatic cache_control injection ([#660](https://github.com/nearai/ironclaw/pull/660))
|
||||
- *(routines)* approval context for autonomous job execution ([#577](https://github.com/nearai/ironclaw/pull/577))
|
||||
- *(llm)* declarative provider registry ([#618](https://github.com/nearai/ironclaw/pull/618))
|
||||
- *(gateway)* show IronClaw version in status popover [skip-regression-check] ([#636](https://github.com/nearai/ironclaw/pull/636))
|
||||
- Wire memory hygiene retention policy into heartbeat loop ([#629](https://github.com/nearai/ironclaw/pull/629))
|
||||
|
||||
### Fixed
|
||||
|
||||
- *(ci)* run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] ([#802](https://github.com/nearai/ironclaw/pull/802))
|
||||
- *(ci)* clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] ([#794](https://github.com/nearai/ironclaw/pull/794))
|
||||
- *(ci)* secrets can't be used in step if conditions [skip-regression-check] ([#787](https://github.com/nearai/ironclaw/pull/787))
|
||||
- prevent irreversible context loss when compaction archive write fails ([#754](https://github.com/nearai/ironclaw/pull/754))
|
||||
- button styles ([#637](https://github.com/nearai/ironclaw/pull/637))
|
||||
- *(mcp)* JSON-RPC spec compliance — flexible id, correct notification format ([#685](https://github.com/nearai/ironclaw/pull/685))
|
||||
- preserve tool-call history across thread hydration ([#568](https://github.com/nearai/ironclaw/pull/568)) ([#670](https://github.com/nearai/ironclaw/pull/670))
|
||||
- CLI commands ignore runtime DATABASE_BACKEND when both features compiled ([#740](https://github.com/nearai/ironclaw/pull/740))
|
||||
- *(web)* prevent fetch error when hostname is an IP address in TEE check ([#672](https://github.com/nearai/ironclaw/pull/672))
|
||||
- add timezone conversion support to time tool ([#687](https://github.com/nearai/ironclaw/pull/687))
|
||||
- standardize libSQL timestamps as RFC 3339 UTC ([#683](https://github.com/nearai/ironclaw/pull/683))
|
||||
- *(docker)* bind postgres to localhost only ([#686](https://github.com/nearai/ironclaw/pull/686))
|
||||
- *(repl)* skip /quit on EOF when stdin is not a TTY ([#724](https://github.com/nearai/ironclaw/pull/724))
|
||||
- *(web)* prevent Enter key from sending message during IME composition ([#715](https://github.com/nearai/ironclaw/pull/715))
|
||||
- *(config)* init_secrets no longer overwrites entire config ([#726](https://github.com/nearai/ironclaw/pull/726))
|
||||
- *(cli)* status command ignores config.toml and settings.json ([#354](https://github.com/nearai/ironclaw/pull/354)) ([#734](https://github.com/nearai/ironclaw/pull/734))
|
||||
- *(setup)* preserve model name when re-running onboarding with same provider ([#600](https://github.com/nearai/ironclaw/pull/600)) ([#694](https://github.com/nearai/ironclaw/pull/694))
|
||||
- *(setup)* initialize secrets crypto for env-var security option ([#666](https://github.com/nearai/ironclaw/pull/666)) ([#706](https://github.com/nearai/ironclaw/pull/706))
|
||||
- persist /model selection across restarts ([#707](https://github.com/nearai/ironclaw/pull/707))
|
||||
- *(routines)* resolve message tool channel/target from per-job metadata ([#708](https://github.com/nearai/ironclaw/pull/708))
|
||||
- sanitize HTML error bodies from MCP servers to prevent web UI white screen ([#263](https://github.com/nearai/ironclaw/pull/263)) ([#656](https://github.com/nearai/ironclaw/pull/656))
|
||||
- prevent Instant duration overflow on Windows ([#657](https://github.com/nearai/ironclaw/pull/657)) ([#664](https://github.com/nearai/ironclaw/pull/664))
|
||||
- enable libsql remote + tls features for Turso cloud sync ([#587](https://github.com/nearai/ironclaw/pull/587))
|
||||
- *(tests)* replace hardcoded /tmp paths with tempdir + add 300 unit tests ([#659](https://github.com/nearai/ironclaw/pull/659))
|
||||
- *(llm)* nudge LLM when it expresses tool intent without calling tools ([#653](https://github.com/nearai/ironclaw/pull/653))
|
||||
- *(llm)* report zero cost for OpenRouter free-tier models ([#463](https://github.com/nearai/ironclaw/pull/463)) ([#613](https://github.com/nearai/ironclaw/pull/613))
|
||||
- reliable network tests and improved tool error messages ([#626](https://github.com/nearai/ironclaw/pull/626))
|
||||
- *(wasm)* use per-engine cache dirs on Windows to avoid file lock error ([#624](https://github.com/nearai/ironclaw/pull/624))
|
||||
- *(libsql)* support flexible embedding dimensions ([#534](https://github.com/nearai/ironclaw/pull/534))
|
||||
|
||||
### Other
|
||||
|
||||
- Restructure CLAUDE.md into modular rules + add pr-shepherd command ([#750](https://github.com/nearai/ironclaw/pull/750))
|
||||
- make src/llm/ self-contained for crate extraction ([#767](https://github.com/nearai/ironclaw/pull/767))
|
||||
- add simplified Chinese (zh-CN) README translation ([#488](https://github.com/nearai/ironclaw/pull/488))
|
||||
- *(job)* cover job tool validation and state transitions ([#681](https://github.com/nearai/ironclaw/pull/681))
|
||||
- *(agent)* wire TestRig job tools through the scheduler ([#716](https://github.com/nearai/ironclaw/pull/716))
|
||||
- Fix single-message mode to exit after one turn when background channels are enabled ([#719](https://github.com/nearai/ironclaw/pull/719))
|
||||
- remove dead code ([#648](https://github.com/nearai/ironclaw/pull/648)) ([#703](https://github.com/nearai/ironclaw/pull/703))
|
||||
- add reviewer-feedback guardrails (CLAUDE.md, pre-commit hook, skill) ([#665](https://github.com/nearai/ironclaw/pull/665))
|
||||
- update WASM artifact SHA256 checksums [skip ci] ([#631](https://github.com/nearai/ironclaw/pull/631))
|
||||
- add explanatory comments to coverage workflow ([#610](https://github.com/nearai/ironclaw/pull/610))
|
||||
- build system prompt once per turn, skip tools on force-text ([#583](https://github.com/nearai/ironclaw/pull/583))
|
||||
- add comprehensive subdirectory CLAUDE.md files and update root ([#589](https://github.com/nearai/ironclaw/pull/589))
|
||||
- Improve test infrastructure: StubChannel, gateway helpers, security tests, search edge cases ([#623](https://github.com/nearai/ironclaw/pull/623))
|
||||
- *(workspace)* regression test for document_path in search results ([#509](https://github.com/nearai/ironclaw/pull/509))
|
||||
|
||||
### Added
|
||||
|
||||
- AWS Bedrock LLM provider via native Converse API with IAM and SSO auth support (feature-gated: `--features bedrock`)
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
@@ -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
+1
-1
@@ -3350,7 +3350,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ironclaw"
|
||||
version = "0.18.0"
|
||||
version = "0.16.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"aho-corasick",
|
||||
|
||||
+2
-7
@@ -14,12 +14,11 @@ exclude = [
|
||||
"tools-src/google-slides",
|
||||
"tools-src/slack",
|
||||
"tools-src/telegram",
|
||||
"fuzz",
|
||||
]
|
||||
|
||||
[package]
|
||||
name = "ironclaw"
|
||||
version = "0.18.0"
|
||||
version = "0.16.1"
|
||||
edition = "2024"
|
||||
rust-version = "1.92"
|
||||
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
||||
@@ -215,14 +214,10 @@ bedrock = ["dep:aws-config", "dep:aws-sdk-bedrockruntime", "dep:aws-smithy-types
|
||||
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]
|
||||
|
||||
+47
-54
@@ -10,8 +10,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
- 🚫 Out of scope (intentionally skipped)
|
||||
- ➖ N/A (not applicable to Rust implementation)
|
||||
|
||||
**Last reviewed against OpenClaw PRs:** 2026-03-10 (merged 2026-02-24 through 2026-03-10)
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture
|
||||
@@ -41,19 +39,19 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Network modes (loopback/LAN/remote) | ✅ | 🚧 | HTTP only |
|
||||
| OpenAI-compatible HTTP API | ✅ | ✅ | /v1/chat/completions, per-request `model` override |
|
||||
| Canvas hosting | ✅ | ❌ | Agent-driven UI |
|
||||
| Gateway lock (PID-based) | ✅ | ❌ | |
|
||||
| Gateway lock (PID-based) | ✅ | ✅ | `fs4` flock-based, acquired in `main.rs` before agent startup |
|
||||
| launchd/systemd integration | ✅ | ❌ | |
|
||||
| Bonjour/mDNS discovery | ✅ | ❌ | |
|
||||
| Tailscale integration | ✅ | ❌ | |
|
||||
| Health check endpoints | ✅ | ✅ | /api/health + /api/gateway/status + /healthz + /readyz, with channel-backed readiness probes |
|
||||
| `doctor` diagnostics | ✅ | 🚧 | 16 checks: settings, LLM, DB, embeddings, routines, gateway, MCP, skills, secrets, service, Docker daemon, tunnel binaries |
|
||||
| Health check endpoints | ✅ | ✅ | /api/health + /api/gateway/status |
|
||||
| `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_
|
||||
|
||||
@@ -68,17 +66,17 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| REPL (simple) | ✅ | ✅ | - | For testing |
|
||||
| WASM channels | ❌ | ✅ | - | IronClaw innovation |
|
||||
| WhatsApp | ✅ | ❌ | P1 | Baileys (Web), same-phone mode with echo detection |
|
||||
| Telegram | ✅ | ✅ | - | WASM channel(MTProto), DM pairing, caption, /start, bot_username, DM topics |
|
||||
| Telegram | ✅ | ✅ | - | WASM channel(MTProto), DM pairing, caption, /start, bot_username |
|
||||
| Discord | ✅ | ❌ | P2 | discord.js, thread parent binding inheritance |
|
||||
| Signal | ✅ | ✅ | P2 | signal-cli daemonPC, SSE listener HTTP/JSON-R, user/group allowlists, DM pairing |
|
||||
| Slack | ✅ | ✅ | - | WASM tool |
|
||||
| iMessage | ✅ | ❌ | P3 | BlueBubbles or Linq recommended |
|
||||
| Linq | ✅ | ❌ | P3 | Real iMessage via API, no Mac required |
|
||||
| Feishu/Lark | ✅ | ❌ | P3 | Bitable create app/field tools, Docx table/image/file actions, rich-text media extraction |
|
||||
| Feishu/Lark | ✅ | ❌ | P3 | Bitable create app/field tools |
|
||||
| LINE | ✅ | ❌ | P3 | |
|
||||
| WebChat | ✅ | ✅ | - | Web gateway chat |
|
||||
| Matrix | ✅ | ❌ | P3 | E2EE support |
|
||||
| Mattermost | ✅ | ❌ | P3 | Emoji reactions, interactive buttons, model picker |
|
||||
| Mattermost | ✅ | ❌ | P3 | Emoji reactions |
|
||||
| Google Chat | ✅ | ❌ | P3 | |
|
||||
| MS Teams | ✅ | ❌ | P3 | |
|
||||
| Twitch | ✅ | ❌ | P3 | |
|
||||
@@ -94,8 +92,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| User message reactions | ✅ | ❌ | Surface inbound reactions |
|
||||
| sendPoll | ✅ | ❌ | Poll creation via agent |
|
||||
| Cron/heartbeat topic targeting | ✅ | ❌ | Messages land in correct topic |
|
||||
| DM topics support | ✅ | ❌ | Agent/topic bindings in DMs and agent-scoped SessionKeys |
|
||||
| Persistent ACP topic binding | ✅ | ❌ | ACP harness sessions can pin to Telegram forum or DM topics |
|
||||
|
||||
### Discord-Specific Features (since Feb 2025)
|
||||
|
||||
@@ -111,36 +107,21 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
|---------|----------|----------|-------|
|
||||
| Streaming draft replies | ✅ | ❌ | Partial replies via draft message updates |
|
||||
| Configurable stream modes | ✅ | ❌ | Per-channel stream behavior |
|
||||
| Thread ownership | ✅ | ❌ | Thread-level ownership tracking plus reply participation memory |
|
||||
| Download-file action | ✅ | ❌ | On-demand attachment downloads via message actions |
|
||||
|
||||
### Mattermost-Specific Features (since Mar 2026)
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Interactive buttons | ✅ | ❌ | Clickable message buttons with signed callback flow |
|
||||
| Interactive model picker | ✅ | ❌ | In-channel provider/model chooser |
|
||||
|
||||
### Feishu/Lark-Specific Features (since Mar 2026)
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| Doc/table actions | ✅ | ❌ | `feishu_doc` supports tables, positional insert, color_text, image upload, and file upload |
|
||||
| Rich-text embedded media extraction | ✅ | ❌ | Pull video/media attachments from post messages |
|
||||
| Thread ownership | ✅ | ❌ | Thread-level ownership tracking |
|
||||
|
||||
### Channel Features
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Notes |
|
||||
|---------|----------|----------|-------|
|
||||
| DM pairing codes | ✅ | ✅ | `ironclaw pairing list/approve`, host APIs |
|
||||
| Allowlist/blocklist | ✅ | 🚧 | `allow_from` + pairing store + hardened command/group allowlists |
|
||||
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
|
||||
| Self-message bypass | ✅ | ❌ | Own messages skip pairing |
|
||||
| Mention-based activation | ✅ | ✅ | bot_username + respond_to_all_group_messages |
|
||||
| Per-group tool policies | ✅ | ❌ | Allow/deny specific tools |
|
||||
| Thread isolation | ✅ | ✅ | Separate sessions per thread/topic |
|
||||
| Per-channel media limits | ✅ | 🚧 | Caption support plus `mediaMaxMb` enforcement for WhatsApp, Telegram, and Discord |
|
||||
| Typing indicators | ✅ | 🚧 | TUI + channel typing, with configurable silence timeout; richer parity pending |
|
||||
| Per-channel ackReaction config | ✅ | ❌ | Customizable acknowledgement reactions/scopes |
|
||||
| Thread isolation | ✅ | ✅ | Separate sessions per thread |
|
||||
| Per-channel media limits | ✅ | ✅ | Attachment type in WIT; max 10 per msg, 20MB total, MIME allowlist |
|
||||
| Typing indicators | ✅ | 🚧 | TUI + Telegram typing/actionable status prompts; richer parity pending |
|
||||
| Per-channel ackReaction config | ✅ | ❌ | Customizable acknowledgement reactions |
|
||||
| Group session priming | ✅ | ❌ | Member roster injected for context |
|
||||
| Sender_id in trusted metadata | ✅ | ❌ | Exposed in system metadata |
|
||||
|
||||
@@ -157,8 +138,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| `gateway start/stop` | ✅ | ❌ | P2 | |
|
||||
| `onboard` (wizard) | ✅ | ✅ | - | Interactive setup |
|
||||
| `tui` | ✅ | ✅ | - | Ratatui TUI |
|
||||
| `config` | ✅ | ✅ | - | Read/write config plus validate/path helpers |
|
||||
| `backup` | ✅ | ❌ | P3 | Create/verify local backup archives |
|
||||
| `config` | ✅ | ✅ | - | Read/write config |
|
||||
| `channels` | ✅ | ❌ | P2 | Channel management |
|
||||
| `models` | ✅ | 🚧 | - | Model selector in TUI |
|
||||
| `status` | ✅ | ✅ | - | System status (enriched session details) |
|
||||
@@ -175,7 +155,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 |
|
||||
@@ -197,15 +177,14 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Global sessions | ✅ | ❌ | Optional shared context |
|
||||
| Session pruning | ✅ | ❌ | Auto cleanup old sessions |
|
||||
| Context compaction | ✅ | ✅ | Auto summarization |
|
||||
| Compaction model override | ✅ | ❌ | Use a dedicated provider/model for summarization only |
|
||||
| Post-compaction read audit | ✅ | ❌ | Layer 3: workspace rules appended to summaries |
|
||||
| Post-compaction context injection | ✅ | ❌ | Workspace context as system event |
|
||||
| Custom system prompts | ✅ | ✅ | Template variables, safety guardrails |
|
||||
| Skills (modular capabilities) | ✅ | ✅ | Prompt-based skills with trust gating, attenuation, activation criteria, catalog, selector |
|
||||
| Skill routing blocks | ✅ | 🚧 | ActivationCriteria (keywords, patterns, tags) but no "Use when / Don't use when" blocks |
|
||||
| Skill path compaction | ✅ | ❌ | ~ prefix to reduce prompt tokens |
|
||||
| Thinking modes (off/minimal/low/medium/high/xhigh/adaptive) | ✅ | ❌ | Configurable reasoning depth |
|
||||
| Per-model thinkingDefault override | ✅ | ❌ | Override thinking level per model; Anthropic Claude 4.6 defaults to adaptive |
|
||||
| Thinking modes (low/med/high) | ✅ | ❌ | Configurable reasoning depth |
|
||||
| Per-model thinkingDefault override | ✅ | ❌ | Override thinking level per model |
|
||||
| Block-level streaming | ✅ | ❌ | |
|
||||
| Tool-level streaming | ✅ | ❌ | |
|
||||
| Z.AI tool_stream | ✅ | ❌ | Real-time tool call streaming |
|
||||
@@ -234,11 +213,15 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Provider | OpenClaw | IronClaw | Priority | Notes |
|
||||
|----------|----------|----------|----------|-------|
|
||||
| NEAR AI | ✅ | ✅ | - | Primary provider |
|
||||
| Anthropic (Claude) | ✅ | 🚧 | - | Via NEAR AI proxy; Opus 4.5, Sonnet 4, Sonnet 4.6, adaptive thinking default |
|
||||
| OpenAI | ✅ | 🚧 | - | Via NEAR AI proxy; GPT-5.4 + Codex OAuth |
|
||||
| AWS Bedrock | ✅ | ❌ | P3 | |
|
||||
| Google Gemini | ✅ | ❌ | P3 | |
|
||||
| NVIDIA API | ✅ | ❌ | P3 | New provider |
|
||||
| Anthropic (Claude) | ✅ | 🚧 | - | Via NEAR AI proxy; Opus 4.5, Sonnet 4, Sonnet 4.6 |
|
||||
| OpenAI | ✅ | 🚧 | - | Via NEAR AI proxy |
|
||||
| AWS Bedrock | ✅ | ✅ | - | Native Converse API via aws-sdk-bedrockruntime (requires `--features bedrock`) |
|
||||
| Google Gemini | ✅ | ✅ | P3 | Via `gemini` adapter |
|
||||
| io.net | ✅ | ✅ | P3 | Via `ionet` adapter |
|
||||
| Mistral | ✅ | ✅ | P3 | Via `mistral` adapter |
|
||||
| Yandex AI Studio | ✅ | ✅ | P3 | Via `yandex` adapter |
|
||||
| Cloudflare Workers AI | ✅ | ✅ | P3 | Via `cloudflare` adapter |
|
||||
| NVIDIA API | ✅ | ✅ | P3 | Via `nvidia` adapter and `providers.json` |
|
||||
| OpenRouter | ✅ | ✅ | - | Via OpenAI-compatible provider (RigAdapter) |
|
||||
| Tinfoil | ❌ | ✅ | - | Private inference provider (IronClaw-only) |
|
||||
| OpenAI-compatible | ❌ | ✅ | - | Generic OpenAI-compatible endpoint (RigAdapter) |
|
||||
@@ -259,7 +242,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Per-session model override | ✅ | ✅ | Model selector in TUI |
|
||||
| Model selection UI | ✅ | ✅ | TUI keyboard shortcut |
|
||||
| Per-model thinkingDefault | ✅ | ❌ | Override thinking level per model in config |
|
||||
| 1M context support | ✅ | ❌ | Anthropic extended context beta + OpenAI Codex GPT-5.4 1M context |
|
||||
| 1M context beta header | ✅ | ❌ | Anthropic extended context support |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -269,20 +252,32 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
|
||||
| Feature | OpenClaw | IronClaw | Priority | Notes |
|
||||
|---------|----------|----------|----------|-------|
|
||||
| WIT inbound-attachment type | N/A | ✅ | P1 | `inbound-attachment` record in channel-host (id, mime_type, filename, size_bytes, source_url, storage_key, extracted_text) |
|
||||
| WIT outbound attachment type | N/A | ✅ | P1 | `attachment` record in channel (filename, mime_type, data) on `agent-response` |
|
||||
| WIT on-broadcast export | N/A | ✅ | P1 | Proactive message sending without prior incoming message |
|
||||
| IncomingMessage attachments | N/A | ✅ | P1 | `IncomingAttachment` struct on `IncomingMessage`, populated from WASM channels |
|
||||
| OutgoingResponse attachments | N/A | ✅ | P1 | File paths on `OutgoingResponse`, read from disk and sent as WIT attachments |
|
||||
| Attachment security (size/MIME) | N/A | ✅ | P1 | Inbound: max 10, 20MB total, MIME allowlist. Outbound: 50MB total |
|
||||
| Telegram media parsing | ✅ | ✅ | P1 | Photo, document, audio, video, voice, sticker parsed and emitted as attachments |
|
||||
| Telegram media sending | ✅ | ✅ | P1 | sendPhoto/sendDocument multipart upload, auto photo→document fallback >10MB |
|
||||
| Slack file parsing | ✅ | ✅ | P1 | `files` array from Events API parsed into attachments |
|
||||
| WhatsApp media parsing | ✅ | ✅ | P1 | Image, audio, video, document parsed with caption as extracted_text |
|
||||
| Discord attachment parsing | ✅ | ❌ | P2 | Discord interaction payloads don't include file attachments (needs message events) |
|
||||
| HTTP tool save_to | N/A | ✅ | P1 | Download binary files to /tmp/ for attachment sending (50MB limit, path traversal protection) |
|
||||
| Credential env var fallback | N/A | ✅ | P2 | Channels can use env vars (e.g., TELEGRAM_BOT_TOKEN) when secrets store not configured |
|
||||
| Image processing (Sharp) | ✅ | ❌ | P2 | Resize, format convert |
|
||||
| Configurable image resize dims | ✅ | ❌ | P2 | Per-agent dimension config |
|
||||
| Multiple images per tool call | ✅ | ❌ | P2 | Single tool invocation, multiple images |
|
||||
| Audio transcription | ✅ | ❌ | P2 | |
|
||||
| Video support | ✅ | ❌ | P3 | |
|
||||
| PDF analysis tool | ✅ | ❌ | P2 | Native Anthropic/Gemini path with text/image extraction fallback |
|
||||
| PDF parsing | ✅ | ❌ | P2 | `pdfjs-dist` fallback path |
|
||||
| MIME detection | ✅ | ❌ | P2 | |
|
||||
| PDF parsing | ✅ | ❌ | P2 | pdfjs-dist |
|
||||
| MIME detection | ✅ | ✅ | P2 | MIME allowlist in host validates attachment types |
|
||||
| Media caching | ✅ | ❌ | P3 | |
|
||||
| Vision model integration | ✅ | ❌ | P2 | Image understanding |
|
||||
| TTS (Edge TTS) | ✅ | ❌ | P3 | Text-to-speech |
|
||||
| TTS (OpenAI) | ✅ | ❌ | P3 | |
|
||||
| Incremental TTS playback | ✅ | ❌ | P3 | iOS progressive playback |
|
||||
| Sticker-to-image | ✅ | ❌ | P3 | Telegram stickers |
|
||||
| Sticker-to-image | ✅ | ✅ | P3 | Telegram stickers emitted as image/webp attachments |
|
||||
|
||||
### Owner: _Unassigned_
|
||||
|
||||
@@ -298,8 +293,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Workspace-relative install | ✅ | ✅ | ~/.ironclaw/tools/ |
|
||||
| Channel plugins | ✅ | ✅ | WASM channels |
|
||||
| Auth plugins | ✅ | ❌ | |
|
||||
| Memory plugins | ✅ | ❌ | Custom backends + selectable memory slot |
|
||||
| Context-engine plugins | ✅ | ❌ | Custom context management + subagent/context hooks |
|
||||
| Memory plugins | ✅ | ❌ | Custom backends |
|
||||
| Tool plugins | ✅ | ✅ | WASM tools |
|
||||
| Hook plugins | ✅ | ✅ | Declarative hooks from extension capabilities |
|
||||
| Provider plugins | ✅ | ❌ | |
|
||||
@@ -321,7 +315,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| JSON5 support | ✅ | ❌ | Comments, trailing commas |
|
||||
| YAML alternative | ✅ | ❌ | |
|
||||
| Environment variable interpolation | ✅ | ✅ | `${VAR}` |
|
||||
| Config validation/schema | ✅ | ✅ | Type-safe Config struct + `openclaw config validate` |
|
||||
| Config validation/schema | ✅ | ✅ | Type-safe Config struct |
|
||||
| Hot-reload | ✅ | ❌ | |
|
||||
| Legacy migration | ✅ | ➖ | |
|
||||
| State directory | ✅ `~/.openclaw-state/` | ✅ `~/.ironclaw/` | |
|
||||
@@ -428,7 +422,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Feature | OpenClaw | IronClaw | Priority | Notes |
|
||||
|---------|----------|----------|----------|-------|
|
||||
| Cron jobs | ✅ | ✅ | - | Routines with cron trigger |
|
||||
| Per-job model fallback override | ✅ | ❌ | P2 | `payload.fallbacks` overrides agent-level fallbacks |
|
||||
| Cron stagger controls | ✅ | ❌ | P3 | Default stagger for scheduled jobs |
|
||||
| Cron finished-run webhook | ✅ | ❌ | P3 | Webhook on job completion |
|
||||
| Timezone support | ✅ | ✅ | - | Via cron expressions |
|
||||
@@ -482,10 +475,10 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Elevated mode | ✅ | ❌ | |
|
||||
| Safe bins allowlist | ✅ | ❌ | Hardened path trust |
|
||||
| LD*/DYLD* validation | ✅ | ❌ | |
|
||||
| Path traversal prevention | ✅ | ✅ | Including config includes (OC-06) + workspace-only tool mounts |
|
||||
| Path traversal prevention | ✅ | ✅ | Including config includes (OC-06) |
|
||||
| Credential theft via env injection | ✅ | 🚧 | Shell env scrubbing + command injection detection; no full OC-09 defense |
|
||||
| Session file permissions (0o600) | ✅ | ✅ | Session token file set to 0o600 in llm/session.rs |
|
||||
| Skill download path restriction | ✅ | ❌ | Validated download roots prevent arbitrary write targets |
|
||||
| Skill download path restriction | ✅ | ❌ | Prevent arbitrary write targets |
|
||||
| Webhook signature verification | ✅ | ✅ | |
|
||||
| Media URL validation | ✅ | ❌ | |
|
||||
| Prompt injection defense | ✅ | ✅ | Pattern detection, sanitization |
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
[package]
|
||||
name = "ironclaw-fuzz"
|
||||
version = "0.0.0"
|
||||
publish = false
|
||||
edition = "2021"
|
||||
|
||||
[package.metadata]
|
||||
cargo-fuzz = true
|
||||
|
||||
[dependencies]
|
||||
libfuzzer-sys = "0.4"
|
||||
serde_json = "1"
|
||||
|
||||
[dependencies.ironclaw]
|
||||
path = ".."
|
||||
|
||||
[[bin]]
|
||||
name = "fuzz_safety_sanitizer"
|
||||
path = "fuzz_targets/fuzz_safety_sanitizer.rs"
|
||||
doc = false
|
||||
|
||||
[[bin]]
|
||||
name = "fuzz_safety_validator"
|
||||
path = "fuzz_targets/fuzz_safety_validator.rs"
|
||||
doc = false
|
||||
|
||||
[[bin]]
|
||||
name = "fuzz_leak_detector"
|
||||
path = "fuzz_targets/fuzz_leak_detector.rs"
|
||||
doc = false
|
||||
|
||||
[[bin]]
|
||||
name = "fuzz_tool_params"
|
||||
path = "fuzz_targets/fuzz_tool_params.rs"
|
||||
doc = false
|
||||
|
||||
[[bin]]
|
||||
name = "fuzz_config_env"
|
||||
path = "fuzz_targets/fuzz_config_env.rs"
|
||||
doc = false
|
||||
@@ -1,43 +0,0 @@
|
||||
# IronClaw Fuzz Targets
|
||||
|
||||
Fuzz testing for security-critical input parsing paths using [cargo-fuzz](https://github.com/rust-fuzz/cargo-fuzz) (libFuzzer).
|
||||
|
||||
## Targets
|
||||
|
||||
| Target | What it exercises |
|
||||
|--------|-------------------|
|
||||
| `fuzz_safety_sanitizer` | Prompt injection pattern detection (Aho-Corasick + regex) |
|
||||
| `fuzz_safety_validator` | Input validation (length, encoding, forbidden patterns) |
|
||||
| `fuzz_leak_detector` | Secret leak detection (API keys, tokens, credentials) |
|
||||
| `fuzz_tool_params` | Tool parameter and schema JSON validation |
|
||||
| `fuzz_config_env` | SafetyLayer end-to-end (sanitize, validate, policy check) |
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cargo install cargo-fuzz
|
||||
rustup install nightly
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
# Run a specific target (runs until stopped or crash found)
|
||||
cargo +nightly fuzz run fuzz_safety_sanitizer
|
||||
|
||||
# Run with a time limit (5 minutes)
|
||||
cargo +nightly fuzz run fuzz_leak_detector -- -max_total_time=300
|
||||
|
||||
# Run all targets for 60 seconds each
|
||||
for target in fuzz_safety_sanitizer fuzz_safety_validator fuzz_leak_detector fuzz_tool_params fuzz_config_env; do
|
||||
echo "==> $target"
|
||||
cargo +nightly fuzz run "$target" -- -max_total_time=60
|
||||
done
|
||||
```
|
||||
|
||||
## Adding New Targets
|
||||
|
||||
1. Create `fuzz/fuzz_targets/fuzz_<name>.rs` following the existing pattern
|
||||
2. Add a `[[bin]]` entry in `fuzz/Cargo.toml`
|
||||
3. Create `fuzz/corpus/fuzz_<name>/` for seed inputs
|
||||
4. Exercise real IronClaw code paths, not just generic serde
|
||||
@@ -1,55 +0,0 @@
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
use ironclaw::safety::{LeakDetector, Sanitizer, Validator};
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
if let Ok(input) = std::str::from_utf8(data) {
|
||||
// Exercise Sanitizer: detect and neutralize prompt injection attempts.
|
||||
let sanitizer = Sanitizer::new();
|
||||
let sanitized = sanitizer.sanitize(input);
|
||||
// The sanitized content must never be empty when input is non-empty,
|
||||
// because sanitization wraps/escapes rather than deleting.
|
||||
if !input.is_empty() {
|
||||
assert!(
|
||||
!sanitized.content.is_empty(),
|
||||
"sanitize() produced empty content for non-empty input"
|
||||
);
|
||||
}
|
||||
// If no modification occurred, content must equal input.
|
||||
if !sanitized.was_modified {
|
||||
assert_eq!(sanitized.content, input);
|
||||
}
|
||||
|
||||
// Exercise Validator: input validation (length, encoding, patterns).
|
||||
let validator = Validator::new();
|
||||
let result = validator.validate(input);
|
||||
// ValidationResult must always be well-formed: if valid, no errors.
|
||||
if result.is_valid {
|
||||
assert!(
|
||||
result.errors.is_empty(),
|
||||
"valid result should have no errors"
|
||||
);
|
||||
}
|
||||
|
||||
// Exercise LeakDetector: secret detection (API keys, tokens, etc.).
|
||||
let detector = LeakDetector::new();
|
||||
let scan = detector.scan(input);
|
||||
// scan_and_clean must not panic and must return valid UTF-8.
|
||||
let cleaned = detector.scan_and_clean(input);
|
||||
if let Ok(ref clean_str) = cleaned {
|
||||
// Cleaned output must never be longer than original + redaction markers.
|
||||
// At minimum it should be valid UTF-8 (guaranteed by String type).
|
||||
let _ = clean_str.len();
|
||||
}
|
||||
// If scan found no matches, scan_and_clean should return the input unchanged.
|
||||
if scan.matches.is_empty() {
|
||||
if let Ok(ref clean_str) = cleaned {
|
||||
assert_eq!(
|
||||
clean_str, input,
|
||||
"scan_and_clean changed content despite no matches"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use ironclaw::safety::LeakDetector;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
if let Ok(s) = std::str::from_utf8(data) {
|
||||
let detector = LeakDetector::new();
|
||||
|
||||
// Exercise scan path
|
||||
let result = detector.scan(s);
|
||||
// Invariant: if should_block, there must be matches
|
||||
if result.should_block {
|
||||
assert!(!result.matches.is_empty());
|
||||
}
|
||||
// Invariant: match locations must be valid
|
||||
for m in &result.matches {
|
||||
assert!(m.location.end <= s.len());
|
||||
}
|
||||
|
||||
// Exercise scan_and_clean path
|
||||
let _ = detector.scan_and_clean(s);
|
||||
}
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use ironclaw::safety::Sanitizer;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
if let Ok(s) = std::str::from_utf8(data) {
|
||||
let sanitizer = Sanitizer::new();
|
||||
|
||||
// Exercise the main sanitization path
|
||||
let result = sanitizer.sanitize(s);
|
||||
// Verify invariant: warnings should have valid ranges
|
||||
for w in &result.warnings {
|
||||
assert!(w.location.end <= s.len());
|
||||
}
|
||||
// Verify invariant: critical severity triggers modification
|
||||
let has_critical = result.warnings.iter().any(|w| {
|
||||
w.severity == ironclaw::safety::Severity::Critical
|
||||
});
|
||||
if has_critical {
|
||||
assert!(result.was_modified);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use ironclaw::safety::Validator;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
if let Ok(s) = std::str::from_utf8(data) {
|
||||
let validator = Validator::new();
|
||||
|
||||
// Exercise input validation
|
||||
let result = validator.validate(s);
|
||||
// Invariant: empty input is always invalid
|
||||
if s.is_empty() {
|
||||
assert!(!result.is_valid);
|
||||
}
|
||||
|
||||
// Exercise tool parameter validation with arbitrary JSON
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(s) {
|
||||
let _ = validator.validate_tool_params(&value);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use ironclaw::safety::Validator;
|
||||
use ironclaw::tools::validate_tool_schema;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
if let Ok(s) = std::str::from_utf8(data) {
|
||||
// Try parsing as JSON and validating as tool parameters
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(s) {
|
||||
// Exercise Validator::validate_tool_params with arbitrary JSON
|
||||
let validator = Validator::new();
|
||||
let result = validator.validate_tool_params(&value);
|
||||
// Invariant: result should always be well-formed
|
||||
if !result.is_valid {
|
||||
assert!(!result.errors.is_empty());
|
||||
}
|
||||
|
||||
// Exercise validate_tool_schema with arbitrary JSON as a schema
|
||||
let _ = validate_tool_schema(&value, "fuzz");
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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
-3
@@ -9,9 +9,8 @@
|
||||
"api_key_required": true,
|
||||
"base_url_env": "OPENAI_BASE_URL",
|
||||
"model_env": "OPENAI_MODEL",
|
||||
"default_model": "gpt-5-mini",
|
||||
"default_model": "gpt-4o",
|
||||
"description": "OpenAI GPT models (direct API)",
|
||||
"unsupported_params": ["temperature"],
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_openai_api_key",
|
||||
@@ -87,7 +86,6 @@
|
||||
"model_env": "TINFOIL_MODEL",
|
||||
"default_model": "kimi-k2-5",
|
||||
"description": "Tinfoil private inference (hardware-attested TEE)",
|
||||
"unsupported_params": ["temperature"],
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_tinfoil_api_key",
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/discord-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
"sha256": "030707431717bca3411a48f311c6ab5f92a45c747de26cafe4f6e3e23a8b3b2d"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
"sha256": "6ed36077b67ac70a041f06f760f93ba79b33269885413c3c3f2c8c87ee60807e"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
"sha256": "98c86895a9c4b0a1e19fe8a47f1ccbfe7e972e112b05e584bc897130dc32283a"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/whatsapp-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
"sha256": "bd35cad18d87292ea8d2f52db9b514ed9f814a414de910f59073d475c26c4c14"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/github-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
"sha256": "6fcd32719a4ff15641a4b50fff8984686550f0c491dce60518f4126857d0c544"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/gmail-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
"sha256": "023da7000b17568bf0e64b2e5013c8a042b2f323c85f1632339231c73d500e39"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -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": "fc42277b65881d6e9bcc5403dc54c7f5b3ddeaaaf04617fce2c5da05d76325f0"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -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": "385c04abd1e6b8011ccc330e1f4bd7ce58577e488959b51594aa04eb26cbe7cc"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -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": "1b107d575a5d52cc8c76d9a681802190f4373fb485f7f54f445533f097fa37c0"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -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": "c4f6b1e8c5126ac2c8a4b98e4283a3afa32223d2488fc3c3a609758c0c9beb90"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -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": "7110b8565340c888e51f99e9c013bf4de8f8a7f7b33bace00eb8fc47831ff20b"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -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": "6ed36077b67ac70a041f06f760f93ba79b33269885413c3c3f2c8c87ee60807e"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -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": "98c86895a9c4b0a1e19fe8a47f1ccbfe7e972e112b05e584bc897130dc32283a"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -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": "66cb2b9b00652385e9f30f17c74902b9222c17c53e9d3bd1ef42f5cab705bcf6"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -1,80 +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).
|
||||
- Events are emitted via `event_emit` tool calls (a future HTTP webhook ingestion endpoint is planned but not yet available).
|
||||
|
||||
## 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` (string)
|
||||
- `sender` (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": "{{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": "{{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": "{{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": "{{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": "{{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": "{{repository}}",
|
||||
"issue_number": 99999,
|
||||
"sender": "test-bot"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use with `event_emit` after routine install.
|
||||
+17
-20
@@ -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 calls → delegate.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)
|
||||
|
||||
|
||||
+8
-36
@@ -446,8 +446,6 @@ impl Agent {
|
||||
Arc::clone(workspace),
|
||||
notify_tx,
|
||||
Some(self.scheduler.clone()),
|
||||
self.tools().clone(),
|
||||
self.safety().clone(),
|
||||
));
|
||||
|
||||
// Register routine tools
|
||||
@@ -516,7 +514,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 +536,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 +624,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 +653,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 +736,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 +751,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,19 +784,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"
|
||||
);
|
||||
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(
|
||||
@@ -819,11 +796,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
|
||||
@@ -853,7 +825,7 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
tracing::trace!(
|
||||
tracing::debug!(
|
||||
"Received message from {} on {} ({} chars)",
|
||||
message.user_id,
|
||||
message.channel,
|
||||
|
||||
@@ -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...");
|
||||
}
|
||||
}
|
||||
@@ -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{}",
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
+23
-565
@@ -25,21 +25,12 @@ use crate::agent::routine::{
|
||||
};
|
||||
use crate::channels::{IncomingMessage, OutgoingResponse};
|
||||
use crate::config::RoutineConfig;
|
||||
use crate::context::JobContext;
|
||||
use crate::db::Database;
|
||||
use crate::error::RoutineError;
|
||||
use crate::llm::{
|
||||
ChatMessage, CompletionRequest, FinishReason, LlmProvider, ToolCall, ToolCompletionRequest,
|
||||
};
|
||||
use crate::safety::SafetyLayer;
|
||||
use crate::tools::{ApprovalContext, ApprovalRequirement, ToolError, ToolRegistry};
|
||||
use crate::llm::{ChatMessage, CompletionRequest, FinishReason, LlmProvider};
|
||||
use crate::tools::ApprovalContext;
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
enum EventMatcher {
|
||||
Message { routine: Routine, regex: Regex },
|
||||
System { routine: Routine },
|
||||
}
|
||||
|
||||
/// The routine execution engine.
|
||||
pub struct RoutineEngine {
|
||||
config: RoutineConfig,
|
||||
@@ -50,18 +41,13 @@ 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.
|
||||
tools: Arc<ToolRegistry>,
|
||||
/// Safety layer for tool output sanitization.
|
||||
safety: Arc<SafetyLayer>,
|
||||
}
|
||||
|
||||
impl RoutineEngine {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
config: RoutineConfig,
|
||||
store: Arc<dyn Database>,
|
||||
@@ -69,8 +55,6 @@ impl RoutineEngine {
|
||||
workspace: Arc<Workspace>,
|
||||
notify_tx: mpsc::Sender<OutgoingResponse>,
|
||||
scheduler: Option<Arc<Scheduler>>,
|
||||
tools: Arc<ToolRegistry>,
|
||||
safety: Arc<SafetyLayer>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
@@ -81,8 +65,6 @@ impl RoutineEngine {
|
||||
running_count: Arc::new(AtomicUsize::new(0)),
|
||||
event_cache: Arc::new(RwLock::new(Vec::new())),
|
||||
scheduler,
|
||||
tools,
|
||||
safety,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,12 +74,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 +84,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 +105,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 +122,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 +146,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 {
|
||||
@@ -353,15 +240,12 @@ impl RoutineEngine {
|
||||
|
||||
// Execute inline for manual triggers (caller wants to wait)
|
||||
let engine = EngineContext {
|
||||
config: self.config.clone(),
|
||||
store: self.store.clone(),
|
||||
llm: self.llm.clone(),
|
||||
workspace: self.workspace.clone(),
|
||||
notify_tx: self.notify_tx.clone(),
|
||||
running_count: self.running_count.clone(),
|
||||
scheduler: self.scheduler.clone(),
|
||||
tools: self.tools.clone(),
|
||||
safety: self.safety.clone(),
|
||||
};
|
||||
|
||||
tokio::spawn(async move {
|
||||
@@ -388,15 +272,12 @@ impl RoutineEngine {
|
||||
};
|
||||
|
||||
let engine = EngineContext {
|
||||
config: self.config.clone(),
|
||||
store: self.store.clone(),
|
||||
llm: self.llm.clone(),
|
||||
workspace: self.workspace.clone(),
|
||||
notify_tx: self.notify_tx.clone(),
|
||||
running_count: self.running_count.clone(),
|
||||
scheduler: self.scheduler.clone(),
|
||||
tools: self.tools.clone(),
|
||||
safety: self.safety.clone(),
|
||||
};
|
||||
|
||||
// Record the run in DB, then spawn execution
|
||||
@@ -438,15 +319,12 @@ impl RoutineEngine {
|
||||
|
||||
/// Shared context passed to the execution function.
|
||||
struct EngineContext {
|
||||
config: RoutineConfig,
|
||||
store: Arc<dyn Database>,
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
workspace: Arc<Workspace>,
|
||||
notify_tx: mpsc::Sender<OutgoingResponse>,
|
||||
running_count: Arc<AtomicUsize>,
|
||||
scheduler: Option<Arc<Scheduler>>,
|
||||
tools: Arc<ToolRegistry>,
|
||||
safety: Arc<SafetyLayer>,
|
||||
}
|
||||
|
||||
/// Execute a routine run. Handles both lightweight and full_job modes.
|
||||
@@ -660,10 +538,7 @@ async fn execute_full_job(
|
||||
Ok((RunStatus::Ok, Some(summary), None))
|
||||
}
|
||||
|
||||
/// Execute a lightweight routine with optional tool support.
|
||||
///
|
||||
/// If tools are enabled, this runs a simplified agentic loop (max 3-5 iterations).
|
||||
/// If tools are disabled, this does a single LLM call (original behavior).
|
||||
/// Execute a lightweight routine (single LLM call).
|
||||
async fn execute_lightweight(
|
||||
ctx: &EngineContext,
|
||||
routine: &Routine,
|
||||
@@ -695,7 +570,7 @@ async fn execute_lightweight(
|
||||
Err(_) => None,
|
||||
};
|
||||
|
||||
// Build the user-facing prompt
|
||||
// Build the prompt
|
||||
let mut full_prompt = String::new();
|
||||
full_prompt.push_str(prompt);
|
||||
|
||||
@@ -723,6 +598,15 @@ async fn execute_lightweight(
|
||||
}
|
||||
};
|
||||
|
||||
let messages = if system_prompt.is_empty() {
|
||||
vec![ChatMessage::user(&full_prompt)]
|
||||
} else {
|
||||
vec![
|
||||
ChatMessage::system(&system_prompt),
|
||||
ChatMessage::user(&full_prompt),
|
||||
]
|
||||
};
|
||||
|
||||
// Determine max_tokens from model metadata with fallback
|
||||
let effective_max_tokens = match ctx.llm.model_metadata().await {
|
||||
Ok(meta) => {
|
||||
@@ -732,45 +616,6 @@ async fn execute_lightweight(
|
||||
Err(_) => max_tokens,
|
||||
};
|
||||
|
||||
// If tools are enabled, use the tool execution loop; otherwise, single LLM call
|
||||
if ctx.config.lightweight_tools_enabled {
|
||||
execute_lightweight_with_tools(
|
||||
ctx,
|
||||
routine,
|
||||
&system_prompt,
|
||||
&full_prompt,
|
||||
effective_max_tokens,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
execute_lightweight_no_tools(
|
||||
ctx,
|
||||
routine,
|
||||
&system_prompt,
|
||||
&full_prompt,
|
||||
effective_max_tokens,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a lightweight routine without tool support (original single-call behavior).
|
||||
async fn execute_lightweight_no_tools(
|
||||
ctx: &EngineContext,
|
||||
_routine: &Routine,
|
||||
system_prompt: &str,
|
||||
full_prompt: &str,
|
||||
effective_max_tokens: u32,
|
||||
) -> Result<(RunStatus, Option<String>, Option<i32>), RoutineError> {
|
||||
let messages = if system_prompt.is_empty() {
|
||||
vec![ChatMessage::user(full_prompt)]
|
||||
} else {
|
||||
vec![
|
||||
ChatMessage::system(system_prompt),
|
||||
ChatMessage::user(full_prompt),
|
||||
]
|
||||
};
|
||||
|
||||
let request = CompletionRequest::new(messages)
|
||||
.with_max_tokens(effective_max_tokens)
|
||||
.with_temperature(0.3);
|
||||
@@ -786,7 +631,7 @@ async fn execute_lightweight_no_tools(
|
||||
let content = response.content.trim();
|
||||
let tokens_used = Some((response.input_tokens + response.output_tokens) as i32);
|
||||
|
||||
// Empty content guard
|
||||
// Empty content guard (same as heartbeat)
|
||||
if content.is_empty() {
|
||||
return if response.finish_reason == FinishReason::Length {
|
||||
Err(RoutineError::TruncatedResponse)
|
||||
@@ -803,266 +648,6 @@ async fn execute_lightweight_no_tools(
|
||||
Ok((RunStatus::Attention, Some(content.to_string()), tokens_used))
|
||||
}
|
||||
|
||||
/// Handle a text-only LLM response in lightweight routine execution.
|
||||
///
|
||||
/// Checks for the ROUTINE_OK sentinel, validates content, and returns appropriate status.
|
||||
fn handle_text_response(
|
||||
content: &str,
|
||||
finish_reason: FinishReason,
|
||||
total_input_tokens: u32,
|
||||
total_output_tokens: u32,
|
||||
) -> Result<(RunStatus, Option<String>, Option<i32>), RoutineError> {
|
||||
let content = content.trim();
|
||||
|
||||
// Empty content guard
|
||||
if content.is_empty() {
|
||||
return if finish_reason == FinishReason::Length {
|
||||
Err(RoutineError::TruncatedResponse)
|
||||
} else {
|
||||
Err(RoutineError::EmptyResponse)
|
||||
};
|
||||
}
|
||||
|
||||
// Check for the "nothing to do" sentinel
|
||||
if content == "ROUTINE_OK" || content.contains("ROUTINE_OK") {
|
||||
let total_tokens = Some((total_input_tokens + total_output_tokens) as i32);
|
||||
return Ok((RunStatus::Ok, None, total_tokens));
|
||||
}
|
||||
|
||||
let total_tokens = Some((total_input_tokens + total_output_tokens) as i32);
|
||||
Ok((
|
||||
RunStatus::Attention,
|
||||
Some(content.to_string()),
|
||||
total_tokens,
|
||||
))
|
||||
}
|
||||
|
||||
/// Execute a lightweight routine with tool execution support (agentic loop).
|
||||
///
|
||||
/// This is a simplified version of the full dispatcher loop:
|
||||
/// - Max 3-5 iterations (configurable)
|
||||
/// - Sequential tool execution (not parallel)
|
||||
/// - Auto-approval of non-Always tools
|
||||
/// - No hooks or approval dialogs
|
||||
async fn execute_lightweight_with_tools(
|
||||
ctx: &EngineContext,
|
||||
routine: &Routine,
|
||||
system_prompt: &str,
|
||||
full_prompt: &str,
|
||||
effective_max_tokens: u32,
|
||||
) -> Result<(RunStatus, Option<String>, Option<i32>), RoutineError> {
|
||||
let mut messages = if system_prompt.is_empty() {
|
||||
vec![ChatMessage::user(full_prompt)]
|
||||
} else {
|
||||
vec![
|
||||
ChatMessage::system(system_prompt),
|
||||
ChatMessage::user(full_prompt),
|
||||
]
|
||||
};
|
||||
|
||||
let max_iterations = ctx.config.lightweight_max_iterations.min(5);
|
||||
let mut iteration = 0;
|
||||
let mut total_input_tokens = 0;
|
||||
let mut total_output_tokens = 0;
|
||||
|
||||
// Create a minimal job context for tool execution with unique run ID
|
||||
let run_id = Uuid::new_v4();
|
||||
let job_ctx = JobContext {
|
||||
job_id: run_id,
|
||||
user_id: routine.user_id.clone(),
|
||||
title: "Lightweight Routine".to_string(),
|
||||
description: routine.name.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
loop {
|
||||
iteration += 1;
|
||||
|
||||
// Force text-only response at iteration limit
|
||||
let force_text = iteration >= max_iterations;
|
||||
|
||||
if force_text {
|
||||
// Final iteration: no tools, just get text response
|
||||
let request = CompletionRequest::new(messages)
|
||||
.with_max_tokens(effective_max_tokens)
|
||||
.with_temperature(0.3);
|
||||
|
||||
let response =
|
||||
ctx.llm
|
||||
.complete(request)
|
||||
.await
|
||||
.map_err(|e| RoutineError::LlmFailed {
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
total_input_tokens += response.input_tokens;
|
||||
total_output_tokens += response.output_tokens;
|
||||
|
||||
return handle_text_response(
|
||||
&response.content,
|
||||
response.finish_reason,
|
||||
total_input_tokens,
|
||||
total_output_tokens,
|
||||
);
|
||||
} else {
|
||||
// Tool-enabled iteration
|
||||
let tool_defs = ctx.tools.tool_definitions().await;
|
||||
|
||||
let request = ToolCompletionRequest::new(messages.clone(), tool_defs)
|
||||
.with_max_tokens(effective_max_tokens)
|
||||
.with_temperature(0.3);
|
||||
|
||||
let response = ctx.llm.complete_with_tools(request).await.map_err(|e| {
|
||||
RoutineError::LlmFailed {
|
||||
reason: e.to_string(),
|
||||
}
|
||||
})?;
|
||||
|
||||
total_input_tokens += response.input_tokens;
|
||||
total_output_tokens += response.output_tokens;
|
||||
|
||||
// Check if LLM returned text (no tool calls)
|
||||
if response.tool_calls.is_empty() {
|
||||
let content = response.content.unwrap_or_default();
|
||||
return handle_text_response(
|
||||
&content,
|
||||
response.finish_reason,
|
||||
total_input_tokens,
|
||||
total_output_tokens,
|
||||
);
|
||||
}
|
||||
|
||||
// LLM returned tool calls: add assistant message and execute tools
|
||||
messages.push(ChatMessage::assistant_with_tool_calls(
|
||||
response.content.clone(),
|
||||
response.tool_calls.clone(),
|
||||
));
|
||||
|
||||
// Execute tools sequentially
|
||||
for tc in response.tool_calls {
|
||||
let result = execute_routine_tool(ctx, &job_ctx, &tc).await;
|
||||
|
||||
// Sanitize and wrap result (including errors)
|
||||
let result_content = match result {
|
||||
Ok(output) => {
|
||||
let sanitized = ctx.safety.sanitize_tool_output(&tc.name, &output);
|
||||
ctx.safety.wrap_for_llm(
|
||||
&tc.name,
|
||||
&sanitized.content,
|
||||
sanitized.was_modified,
|
||||
)
|
||||
}
|
||||
Err(e) => {
|
||||
let error_msg = format!("Tool '{}' failed: {}", tc.name, e);
|
||||
let sanitized = ctx.safety.sanitize_tool_output(&tc.name, &error_msg);
|
||||
ctx.safety.wrap_for_llm(
|
||||
&tc.name,
|
||||
&sanitized.content,
|
||||
sanitized.was_modified,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// Add tool result to context
|
||||
messages.push(ChatMessage::tool_result(&tc.id, &tc.name, &result_content));
|
||||
}
|
||||
|
||||
// Continue loop to next LLM call
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a single tool for a lightweight routine.
|
||||
async fn execute_routine_tool(
|
||||
ctx: &EngineContext,
|
||||
job_ctx: &JobContext,
|
||||
tc: &ToolCall,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Check if tool exists
|
||||
let tool = ctx
|
||||
.tools
|
||||
.get(&tc.name)
|
||||
.await
|
||||
.ok_or_else(|| format!("Tool '{}' not found", tc.name))?;
|
||||
|
||||
// Check approval requirement: only allow Never tools in lightweight routines.
|
||||
// UnlessAutoApproved and Always tools are blocked to prevent prompt injection attacks.
|
||||
// Lightweight routines can be triggered by external events and may process untrusted data,
|
||||
// making them vulnerable to prompt injection that could trick the LLM into calling
|
||||
// sensitive tools. Blocking these tools entirely is the safest approach.
|
||||
match tool.requires_approval(&tc.arguments) {
|
||||
ApprovalRequirement::Never => {}
|
||||
ApprovalRequirement::UnlessAutoApproved | ApprovalRequirement::Always => {
|
||||
return Err(format!(
|
||||
"Tool '{}' requires manual approval and cannot be used in lightweight routines",
|
||||
tc.name
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
// Validate tool parameters
|
||||
let validation = ctx.safety.validator().validate_tool_params(&tc.arguments);
|
||||
if !validation.is_valid {
|
||||
let details = validation
|
||||
.errors
|
||||
.iter()
|
||||
.map(|e| format!("{}: {}", e.field, e.message))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
return Err(format!("Invalid tool parameters: {}", details).into());
|
||||
}
|
||||
|
||||
// Execute with per-tool timeout
|
||||
let timeout = tool.execution_timeout();
|
||||
let start = std::time::Instant::now();
|
||||
let result = tokio::time::timeout(timeout, async {
|
||||
tool.execute(tc.arguments.clone(), job_ctx).await
|
||||
})
|
||||
.await;
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
// 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"
|
||||
);
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::debug!(
|
||||
tool = %tc.name,
|
||||
elapsed_ms = elapsed.as_millis() as u64,
|
||||
error = %e,
|
||||
status = "failed",
|
||||
"Lightweight routine tool execution completed"
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::debug!(
|
||||
tool = %tc.name,
|
||||
elapsed_ms = elapsed.as_millis() as u64,
|
||||
timeout_secs = timeout.as_secs(),
|
||||
status = "timeout",
|
||||
"Lightweight routine tool execution completed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let result = result
|
||||
.map_err(|_| ToolError::Timeout(timeout))
|
||||
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?
|
||||
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
|
||||
|
||||
// Serialize result to JSON string
|
||||
let result_str =
|
||||
serde_json::to_string(&result.result).unwrap_or_else(|_| "<serialize error>".to_string());
|
||||
Ok(result_str)
|
||||
}
|
||||
|
||||
/// Send a notification based on the routine's notify config and run status.
|
||||
async fn send_notification(
|
||||
tx: &mpsc::Sender<OutgoingResponse>,
|
||||
@@ -1142,7 +727,6 @@ fn truncate(s: &str, max: usize) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::agent::routine::{NotifyConfig, RunStatus};
|
||||
use crate::config::RoutineConfig;
|
||||
|
||||
#[test]
|
||||
fn test_notification_gating() {
|
||||
@@ -1171,130 +755,4 @@ mod tests {
|
||||
let _ = status.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_routine_config_lightweight_tools_enabled_default() {
|
||||
let config = RoutineConfig::default();
|
||||
assert!(
|
||||
config.lightweight_tools_enabled,
|
||||
"Tools should be enabled by default"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_routine_config_lightweight_max_iterations_default() {
|
||||
let config = RoutineConfig::default();
|
||||
assert_eq!(
|
||||
config.lightweight_max_iterations, 3,
|
||||
"Default should be 3 iterations"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_routine_config_can_hold_uncapped_max_iterations() {
|
||||
// The `RoutineConfig` struct can hold a value greater than the safety cap.
|
||||
let config = RoutineConfig {
|
||||
lightweight_max_iterations: 10, // Set a value higher than the cap.
|
||||
..RoutineConfig::default()
|
||||
};
|
||||
// The actual capping to a maximum of 5 is handled at runtime in
|
||||
// `execute_lightweight_with_tools` and during config resolution from env vars.
|
||||
assert_eq!(
|
||||
config.lightweight_max_iterations, 10,
|
||||
"Config struct should store the provided value"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_routine_name_replaces_special_chars() {
|
||||
let test_cases = vec![
|
||||
("valid-routine", "valid-routine"),
|
||||
("routine_with_underscore", "routine_with_underscore"),
|
||||
("Routine With Spaces", "Routine_With_Spaces"),
|
||||
("routine/with/slashes", "routine_with_slashes"),
|
||||
("routine@with#symbols", "routine_with_symbols"),
|
||||
];
|
||||
|
||||
for (input, expected) in test_cases {
|
||||
let result = super::sanitize_routine_name(input);
|
||||
assert_eq!(
|
||||
result, expected,
|
||||
"sanitize_routine_name({}) should be {}",
|
||||
input, expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_routine_name_preserves_alphanumeric_dash_underscore() {
|
||||
let names = vec!["routine123", "routine-name", "routine_name", "ROUTINE"];
|
||||
for name in names {
|
||||
let result = super::sanitize_routine_name(name);
|
||||
assert_eq!(result, name, "Should preserve {}", name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_routine_sentinel_detection_exact_match() {
|
||||
// The execute_lightweight_no_tools checks: content == "ROUTINE_OK" || content.contains("ROUTINE_OK")
|
||||
// After trim(), whitespace is removed
|
||||
let test_cases = vec![
|
||||
("ROUTINE_OK", true),
|
||||
(" ROUTINE_OK ", true), // After trim, whitespace is removed so matches
|
||||
("something ROUTINE_OK something", true),
|
||||
("ROUTINE_OK is done", true),
|
||||
("done ROUTINE_OK", true),
|
||||
("no sentinel here", false),
|
||||
];
|
||||
|
||||
for (content, should_match) in test_cases {
|
||||
let trimmed = content.trim();
|
||||
let matches = trimmed == "ROUTINE_OK" || trimmed.contains("ROUTINE_OK");
|
||||
assert_eq!(
|
||||
matches, should_match,
|
||||
"Content '{}' sentinel detection should be {}, got {}",
|
||||
content, should_match, matches
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approval_requirement_pattern_matching() {
|
||||
// Test the approval requirement logic (Never, UnlessAutoApproved, Always)
|
||||
use crate::tools::ApprovalRequirement;
|
||||
|
||||
let requirements = vec![
|
||||
(ApprovalRequirement::Never, "auto-approved"),
|
||||
(ApprovalRequirement::UnlessAutoApproved, "auto-approved"),
|
||||
(ApprovalRequirement::Always, "blocks"),
|
||||
];
|
||||
|
||||
for (req, expected) in requirements {
|
||||
let can_auto_approve = matches!(
|
||||
req,
|
||||
ApprovalRequirement::Never | ApprovalRequirement::UnlessAutoApproved
|
||||
);
|
||||
let label = if can_auto_approve {
|
||||
"auto-approved"
|
||||
} else {
|
||||
"blocks"
|
||||
};
|
||||
assert_eq!(label, expected, "Approval pattern should match");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_response_handling() {
|
||||
// Simulate the empty content guard logic
|
||||
let empty_content = "";
|
||||
let finish_reason_length = crate::llm::FinishReason::Length;
|
||||
let finish_reason_stop = crate::llm::FinishReason::Stop;
|
||||
|
||||
assert!(
|
||||
empty_content.trim().is_empty(),
|
||||
"Should detect empty content"
|
||||
);
|
||||
assert_eq!(finish_reason_length, crate::llm::FinishReason::Length);
|
||||
assert_eq!(finish_reason_stop, crate::llm::FinishReason::Stop);
|
||||
}
|
||||
}
|
||||
|
||||
+43
-173
@@ -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(¶ms);
|
||||
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, ¶ms, &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(¶ms);
|
||||
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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+24
-52
@@ -113,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;
|
||||
@@ -130,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.",
|
||||
));
|
||||
@@ -298,20 +269,9 @@ 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.user_id, effective_content)
|
||||
.await;
|
||||
|
||||
tracing::debug!(
|
||||
message_id = %message.id,
|
||||
thread_id = %thread_id,
|
||||
"User message persisted, starting agentic loop"
|
||||
);
|
||||
|
||||
// Send thinking status
|
||||
let _ = self
|
||||
.channels
|
||||
@@ -852,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
|
||||
{
|
||||
@@ -1097,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
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+204
-55
@@ -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
|
||||
@@ -572,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"
|
||||
);
|
||||
@@ -591,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) =
|
||||
@@ -617,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)),
|
||||
@@ -630,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)
|
||||
};
|
||||
|
||||
@@ -701,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) => {
|
||||
@@ -726,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) => {
|
||||
@@ -743,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();
|
||||
@@ -761,7 +910,7 @@ impl AppBuilder {
|
||||
},
|
||||
));
|
||||
|
||||
tracing::debug!(
|
||||
tracing::info!(
|
||||
"Tool registry initialized with {} total tools",
|
||||
tools.count()
|
||||
);
|
||||
|
||||
@@ -198,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
|
||||
@@ -1289,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
@@ -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
|
||||
);
|
||||
|
||||
+13
-202
@@ -10,7 +10,7 @@ use axum::{
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
};
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use secrecy::ExposeSecret;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use subtle::ConstantTimeEq;
|
||||
use tokio::sync::{RwLock, mpsc, oneshot};
|
||||
@@ -18,8 +18,7 @@ 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;
|
||||
@@ -30,16 +29,13 @@ pub struct HttpChannel {
|
||||
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.
|
||||
@@ -52,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;
|
||||
@@ -79,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 {
|
||||
@@ -87,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(),
|
||||
@@ -114,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)]
|
||||
@@ -223,10 +201,9 @@ async fn webhook_handler(
|
||||
});
|
||||
|
||||
// Validate secret if configured
|
||||
if let Some(ref expected_secret) = *state.webhook_secret.read().await {
|
||||
let expected_bytes = expected_secret.expose_secret().as_bytes();
|
||||
if let Some(ref expected_secret) = state.webhook_secret {
|
||||
match &req.secret {
|
||||
Some(provided) if bool::from(provided.as_bytes().ct_eq(expected_bytes)) => {
|
||||
Some(provided) if bool::from(provided.as_bytes().ct_eq(expected_secret.as_bytes())) => {
|
||||
// Secret matches, continue
|
||||
}
|
||||
Some(_) => {
|
||||
@@ -395,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,
|
||||
@@ -423,6 +395,7 @@ async fn process_message(
|
||||
}),
|
||||
);
|
||||
}
|
||||
drop(tx_guard);
|
||||
|
||||
// Wait for response if requested
|
||||
let response = if let Some(rx) = response_rx {
|
||||
@@ -455,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(),
|
||||
@@ -502,16 +475,6 @@ 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;
|
||||
@@ -599,156 +562,4 @@ mod tests {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-39
@@ -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
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
+26
-27
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
@@ -271,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
|
||||
@@ -288,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()))?
|
||||
};
|
||||
@@ -306,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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,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)
|
||||
@@ -430,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 {
|
||||
@@ -479,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
|
||||
@@ -511,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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
+65
-404
@@ -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 };
|
||||
@@ -377,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
|
||||
{
|
||||
@@ -613,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.
|
||||
@@ -872,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
|
||||
);
|
||||
|
||||
@@ -907,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()
|
||||
);
|
||||
|
||||
@@ -1848,13 +1639,13 @@ 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.
|
||||
@@ -2145,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 }))
|
||||
}
|
||||
@@ -2389,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(
|
||||
@@ -2588,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() {
|
||||
@@ -2690,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)),
|
||||
@@ -2763,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"),
|
||||
)));
|
||||
@@ -2772,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,
|
||||
@@ -2813,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"),
|
||||
)));
|
||||
@@ -2822,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,
|
||||
@@ -2919,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"),
|
||||
)));
|
||||
@@ -2928,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,
|
||||
@@ -3013,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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2350,8 +2350,8 @@ function renderExtensionCard(ext) {
|
||||
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 = 'Activate';
|
||||
|
||||
@@ -1277,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 {
|
||||
@@ -3722,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 */
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -509,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)),
|
||||
|
||||
@@ -24,8 +24,6 @@ pub struct WebhookServerConfig {
|
||||
pub struct WebhookServer {
|
||||
config: WebhookServerConfig,
|
||||
routes: Vec<Router>,
|
||||
/// Merged router saved after start() for restart_with_addr().
|
||||
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 both start() and restart_with_addr().
|
||||
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,54 +80,6 @@ impl WebhookServer {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gracefully shut down the current listener and rebind to a new address.
|
||||
/// The merged router from the original `start()` call is reused.
|
||||
///
|
||||
/// If binding to the new address fails, the old listener remains active and
|
||||
/// state is restored. This prevents a denial-of-service if the new address
|
||||
/// is invalid or already in use.
|
||||
pub async fn restart_with_addr(&mut self, new_addr: SocketAddr) -> Result<(), ChannelError> {
|
||||
let app = self
|
||||
.merged_router
|
||||
.clone()
|
||||
.ok_or_else(|| ChannelError::StartupFailed {
|
||||
name: "webhook_server".to_string(),
|
||||
reason: "restart_with_addr called before start()".to_string(),
|
||||
})?;
|
||||
|
||||
// Save old state for rollback if new bind fails
|
||||
let old_addr = self.config.addr;
|
||||
let old_shutdown_tx = self.shutdown_tx.take();
|
||||
let old_handle = self.handle.take();
|
||||
|
||||
// Update config to new address and try to bind
|
||||
self.config.addr = new_addr;
|
||||
match self.bind_and_spawn(app).await {
|
||||
Ok(()) => {
|
||||
// New listener is running, gracefully shut down the old one
|
||||
if let Some(tx) = old_shutdown_tx {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
if let Some(handle) = old_handle {
|
||||
let _ = handle.await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
// Restore old state; old listener remains active
|
||||
self.config.addr = old_addr;
|
||||
self.shutdown_tx = old_shutdown_tx;
|
||||
self.handle = old_handle;
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
@@ -147,182 +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
|
||||
let addr2 = format!("127.0.0.1:{}", port2).parse().unwrap();
|
||||
server
|
||||
.restart_with_addr(addr2)
|
||||
.await
|
||||
.expect("Failed to restart with new addr");
|
||||
|
||||
// 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 0 is reserved, won't bind)
|
||||
// Use port 1 which typically requires elevated privileges
|
||||
let invalid_addr: SocketAddr = "127.0.0.1:1".parse().unwrap();
|
||||
|
||||
// Attempt restart (should fail)
|
||||
let result = server.restart_with_addr(invalid_addr).await;
|
||||
assert!(result.is_err(), "Restart with invalid address should fail");
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
+5
-541
@@ -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,29 +105,6 @@ 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();
|
||||
@@ -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})"),
|
||||
|
||||
+12
-2
@@ -10,7 +10,7 @@ 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, McpServerConfig, McpSessionManager, OAuthConfig,
|
||||
auth::{authorize_mcp_server, is_authenticated},
|
||||
@@ -628,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)]
|
||||
|
||||
+4
-45
@@ -28,6 +28,8 @@ pub use config::{ConfigCommand, run_config_command};
|
||||
pub use doctor::run_doctor_command;
|
||||
pub use mcp::{McpCommand, run_mcp_command};
|
||||
pub use memory::MemoryCommand;
|
||||
#[cfg(feature = "postgres")]
|
||||
pub use memory::run_memory_command;
|
||||
pub use memory::run_memory_command_with_db;
|
||||
pub use pairing::{PairingCommand, run_pairing_command, run_pairing_command_with_store};
|
||||
pub use registry::{RegistryCommand, run_registry_command};
|
||||
@@ -35,8 +37,6 @@ pub use service::{ServiceCommand, run_service_command};
|
||||
pub use status::run_status_command;
|
||||
pub use tool::{ToolCommand, run_tool_command};
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use clap::{ColorChoice, Parser, Subcommand};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
@@ -94,16 +94,12 @@ pub enum Command {
|
||||
skip_auth: bool,
|
||||
|
||||
/// Reconfigure channels only
|
||||
#[arg(long, conflicts_with_all = ["provider_only", "quick"])]
|
||||
#[arg(long, conflicts_with = "provider_only")]
|
||||
channels_only: bool,
|
||||
|
||||
/// Reconfigure LLM provider and model only
|
||||
#[arg(long, conflicts_with_all = ["channels_only", "quick"])]
|
||||
#[arg(long, conflicts_with = "channels_only")]
|
||||
provider_only: bool,
|
||||
|
||||
/// Quick setup: auto-defaults everything except LLM provider and model
|
||||
#[arg(long, conflicts_with_all = ["channels_only", "provider_only"])]
|
||||
quick: bool,
|
||||
},
|
||||
|
||||
/// Manage configuration settings
|
||||
@@ -229,43 +225,6 @@ impl Cli {
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize a secrets store from environment config.
|
||||
///
|
||||
/// Shared helper for CLI subcommands (`mcp auth`, `tool auth`, etc.) that need
|
||||
/// access to encrypted secrets without spinning up the full AppBuilder.
|
||||
pub async fn init_secrets_store()
|
||||
-> anyhow::Result<Arc<dyn crate::secrets::SecretsStore + Send + Sync>> {
|
||||
let config = crate::config::Config::from_env().await?;
|
||||
let master_key = config.secrets.master_key().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"SECRETS_MASTER_KEY not set. Run 'ironclaw onboard' first or set it in .env"
|
||||
)
|
||||
})?;
|
||||
|
||||
let crypto = Arc::new(crate::secrets::SecretsCrypto::new(master_key.clone())?);
|
||||
|
||||
Ok(crate::db::create_secrets_store(&config.database, crypto).await?)
|
||||
}
|
||||
|
||||
/// Run the Memory CLI subcommand.
|
||||
pub async fn run_memory_command(mem_cmd: &MemoryCommand) -> anyhow::Result<()> {
|
||||
let config = crate::config::Config::from_env()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
let session = crate::llm::create_session_manager(config.llm.session.clone()).await;
|
||||
|
||||
let embeddings = config
|
||||
.embeddings
|
||||
.create_provider(&config.llm.nearai.base_url, session);
|
||||
|
||||
let db: Arc<dyn crate::db::Database> = crate::db::connect_from_config(&config.database)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
run_memory_command_with_db(mem_cmd.clone(), db, embeddings).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+12
-2
@@ -10,7 +10,8 @@ use clap::Subcommand;
|
||||
use tokio::fs;
|
||||
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
use crate::secrets::{CreateSecretParams, SecretsStore};
|
||||
use crate::config::Config;
|
||||
use crate::secrets::{CreateSecretParams, SecretsCrypto, SecretsStore};
|
||||
use crate::tools::wasm::{CapabilitiesFile, compute_binary_hash};
|
||||
|
||||
/// Default tools directory.
|
||||
@@ -551,7 +552,16 @@ fn validate_tool_name(name: &str) -> anyhow::Result<()> {
|
||||
|
||||
/// Initialize the secrets store from environment config.
|
||||
async fn init_secrets_store() -> anyhow::Result<Arc<dyn SecretsStore + Send + Sync>> {
|
||||
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?)
|
||||
}
|
||||
|
||||
/// Configure authentication for a tool.
|
||||
|
||||
@@ -100,13 +100,13 @@ impl EmbeddingsConfig {
|
||||
session: Arc<SessionManager>,
|
||||
) -> Option<Arc<dyn EmbeddingProvider>> {
|
||||
if !self.enabled {
|
||||
tracing::debug!("Embeddings disabled (set EMBEDDING_ENABLED=true to enable)");
|
||||
tracing::info!("Embeddings disabled (set EMBEDDING_ENABLED=true to enable)");
|
||||
return None;
|
||||
}
|
||||
|
||||
match self.provider.as_str() {
|
||||
"nearai" => {
|
||||
tracing::debug!(
|
||||
tracing::info!(
|
||||
"Embeddings enabled via NEAR AI (model: {}, dim: {})",
|
||||
self.model,
|
||||
self.dimension,
|
||||
@@ -117,7 +117,7 @@ impl EmbeddingsConfig {
|
||||
))
|
||||
}
|
||||
"ollama" => {
|
||||
tracing::debug!(
|
||||
tracing::info!(
|
||||
"Embeddings enabled via Ollama (model: {}, url: {}, dim: {})",
|
||||
self.model,
|
||||
self.ollama_base_url,
|
||||
@@ -130,7 +130,7 @@ impl EmbeddingsConfig {
|
||||
}
|
||||
_ => {
|
||||
if let Some(api_key) = self.openai_api_key() {
|
||||
tracing::debug!(
|
||||
tracing::info!(
|
||||
"Embeddings enabled via OpenAI (model: {}, dim: {})",
|
||||
self.model,
|
||||
self.dimension,
|
||||
@@ -154,7 +154,6 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::config::helpers::ENV_MUTEX;
|
||||
use crate::settings::{EmbeddingsSettings, Settings};
|
||||
use crate::testing::credentials::*;
|
||||
|
||||
/// Clear all embedding-related env vars.
|
||||
fn clear_embedding_env() {
|
||||
@@ -174,7 +173,7 @@ mod tests {
|
||||
clear_embedding_env();
|
||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||
unsafe {
|
||||
std::env::set_var("OPENAI_API_KEY", TEST_OPENAI_API_KEY_ISSUE_129);
|
||||
std::env::set_var("OPENAI_API_KEY", "sk-test-key-for-issue-129");
|
||||
}
|
||||
|
||||
let settings = Settings {
|
||||
|
||||
+7
-18
@@ -209,7 +209,6 @@ impl LlmConfig {
|
||||
extra_headers_env,
|
||||
api_key_required,
|
||||
base_url_required,
|
||||
unsupported_params,
|
||||
) = if let Some(def) = def {
|
||||
(
|
||||
def.id.as_str(),
|
||||
@@ -222,7 +221,6 @@ impl LlmConfig {
|
||||
def.extra_headers_env.as_deref(),
|
||||
def.api_key_required,
|
||||
def.base_url_required,
|
||||
def.unsupported_params.clone(),
|
||||
)
|
||||
} else {
|
||||
// Absolute fallback: treat as generic openai_completions
|
||||
@@ -237,7 +235,6 @@ impl LlmConfig {
|
||||
Some("LLM_EXTRA_HEADERS"),
|
||||
false,
|
||||
true,
|
||||
Vec::new(),
|
||||
)
|
||||
};
|
||||
|
||||
@@ -341,7 +338,6 @@ impl LlmConfig {
|
||||
extra_headers,
|
||||
oauth_token,
|
||||
cache_retention,
|
||||
unsupported_params,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -389,7 +385,6 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::config::helpers::ENV_MUTEX;
|
||||
use crate::settings::Settings;
|
||||
use crate::testing::credentials::*;
|
||||
|
||||
/// Clear all openai-compatible-related env vars.
|
||||
fn clear_openai_compatible_env() {
|
||||
@@ -629,12 +624,6 @@ mod tests {
|
||||
let provider = cfg.provider.expect("provider config should be present");
|
||||
assert_eq!(provider.base_url, "https://inference.tinfoil.sh/v1");
|
||||
assert_eq!(provider.model, "kimi-k2-5");
|
||||
assert!(
|
||||
provider
|
||||
.unsupported_params
|
||||
.contains(&"temperature".to_string()),
|
||||
"tinfoil should propagate unsupported_params from registry"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -658,7 +647,7 @@ mod tests {
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::set_var("LLM_BACKEND", "open_ai");
|
||||
std::env::set_var("OPENAI_API_KEY", TEST_API_KEY);
|
||||
std::env::set_var("OPENAI_API_KEY", "test-key");
|
||||
}
|
||||
|
||||
let settings = Settings::default();
|
||||
@@ -792,7 +781,7 @@ mod tests {
|
||||
clear_anthropic_env();
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::set_var("ANTHROPIC_OAUTH_TOKEN", TEST_ANTHROPIC_OAUTH_TOKEN);
|
||||
std::env::set_var("ANTHROPIC_OAUTH_TOKEN", "sk-ant-oat01-test-token");
|
||||
}
|
||||
|
||||
let settings = Settings {
|
||||
@@ -816,7 +805,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
provider.oauth_token.as_ref().unwrap().expose_secret(),
|
||||
TEST_ANTHROPIC_OAUTH_TOKEN
|
||||
"sk-ant-oat01-test-token"
|
||||
);
|
||||
|
||||
clear_anthropic_env();
|
||||
@@ -830,8 +819,8 @@ mod tests {
|
||||
clear_anthropic_env();
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::set_var("ANTHROPIC_API_KEY", TEST_ANTHROPIC_API_KEY);
|
||||
std::env::set_var("ANTHROPIC_OAUTH_TOKEN", TEST_ANTHROPIC_OAUTH_TOKEN);
|
||||
std::env::set_var("ANTHROPIC_API_KEY", "sk-ant-real-key");
|
||||
std::env::set_var("ANTHROPIC_OAUTH_TOKEN", "sk-ant-oat01-test-token");
|
||||
}
|
||||
|
||||
let settings = Settings {
|
||||
@@ -846,7 +835,7 @@ mod tests {
|
||||
.api_key
|
||||
.as_ref()
|
||||
.map(|k| k.expose_secret().to_string()),
|
||||
Some(TEST_ANTHROPIC_API_KEY.to_string()),
|
||||
Some("sk-ant-real-key".to_string()),
|
||||
"real API key should take priority over OAuth placeholder"
|
||||
);
|
||||
assert!(
|
||||
@@ -863,7 +852,7 @@ mod tests {
|
||||
clear_anthropic_env();
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::set_var("ANTHROPIC_OAUTH_TOKEN", TEST_ANTHROPIC_OAUTH_TOKEN);
|
||||
std::env::set_var("ANTHROPIC_OAUTH_TOKEN", "sk-ant-oat01-test-token");
|
||||
}
|
||||
|
||||
let settings = Settings {
|
||||
|
||||
@@ -14,7 +14,6 @@ mod heartbeat;
|
||||
pub(crate) mod helpers;
|
||||
mod hygiene;
|
||||
pub(crate) mod llm;
|
||||
pub mod relay;
|
||||
mod routines;
|
||||
mod safety;
|
||||
mod sandbox;
|
||||
@@ -39,7 +38,6 @@ pub use self::embeddings::EmbeddingsConfig;
|
||||
pub use self::heartbeat::HeartbeatConfig;
|
||||
pub use self::hygiene::HygieneConfig;
|
||||
pub use self::llm::default_session_path;
|
||||
pub use self::relay::RelayConfig;
|
||||
pub use self::routines::RoutineConfig;
|
||||
pub use self::safety::SafetyConfig;
|
||||
pub use self::sandbox::{ClaudeCodeConfig, SandboxModeConfig};
|
||||
@@ -87,9 +85,6 @@ pub struct Config {
|
||||
pub skills: SkillsConfig,
|
||||
pub transcription: TranscriptionConfig,
|
||||
pub observability: crate::observability::ObservabilityConfig,
|
||||
/// Channel-relay integration (Slack via external relay service).
|
||||
/// Present only when both `CHANNEL_RELAY_URL` and `CHANNEL_RELAY_API_KEY` are set.
|
||||
pub relay: Option<RelayConfig>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -162,7 +157,6 @@ impl Config {
|
||||
},
|
||||
transcription: TranscriptionConfig::default(),
|
||||
observability: crate::observability::ObservabilityConfig::default(),
|
||||
relay: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,7 +310,6 @@ impl Config {
|
||||
observability: crate::observability::ObservabilityConfig {
|
||||
backend: std::env::var("OBSERVABILITY_BACKEND").unwrap_or_else(|_| "none".into()),
|
||||
},
|
||||
relay: RelayConfig::from_env(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
//! Channel-relay service configuration.
|
||||
|
||||
use secrecy::SecretString;
|
||||
|
||||
/// Configuration for connecting to a channel-relay service.
|
||||
#[derive(Clone)]
|
||||
pub struct RelayConfig {
|
||||
/// Base URL of the channel-relay service (e.g., `http://localhost:3001`).
|
||||
pub url: String,
|
||||
/// API key for authenticated channel-relay endpoints.
|
||||
pub api_key: SecretString,
|
||||
/// Override for the OAuth callback URL (e.g., a tunnel URL).
|
||||
pub callback_url: Option<String>,
|
||||
/// Override for the instance identifier.
|
||||
pub instance_id: Option<String>,
|
||||
/// HTTP request timeout in seconds (default: 30).
|
||||
pub request_timeout_secs: u64,
|
||||
/// SSE stream long-poll timeout in seconds (default: 86400 = 24 h).
|
||||
pub stream_timeout_secs: u64,
|
||||
/// Initial exponential backoff in milliseconds (default: 1000).
|
||||
pub backoff_initial_ms: u64,
|
||||
/// Maximum exponential backoff in milliseconds (default: 60000).
|
||||
pub backoff_max_ms: u64,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RelayConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("RelayConfig")
|
||||
.field("url", &self.url)
|
||||
.field("api_key", &"[REDACTED]")
|
||||
.field("callback_url", &self.callback_url)
|
||||
.field("instance_id", &self.instance_id)
|
||||
.field("request_timeout_secs", &self.request_timeout_secs)
|
||||
.field("stream_timeout_secs", &self.stream_timeout_secs)
|
||||
.field("backoff_initial_ms", &self.backoff_initial_ms)
|
||||
.field("backoff_max_ms", &self.backoff_max_ms)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl RelayConfig {
|
||||
/// Load relay config from environment variables.
|
||||
///
|
||||
/// Returns `None` if either `CHANNEL_RELAY_URL` or `CHANNEL_RELAY_API_KEY`
|
||||
/// is not set, making the relay integration opt-in.
|
||||
pub fn from_env() -> Option<Self> {
|
||||
Self::from_env_reader(|key| std::env::var(key).ok())
|
||||
}
|
||||
|
||||
/// Build a config for tests without touching the process environment.
|
||||
pub fn from_values(url: impl Into<String>, api_key: impl Into<String>) -> Self {
|
||||
Self {
|
||||
url: url.into(),
|
||||
api_key: SecretString::from(api_key.into()),
|
||||
callback_url: None,
|
||||
instance_id: None,
|
||||
request_timeout_secs: 30,
|
||||
stream_timeout_secs: 86400,
|
||||
backoff_initial_ms: 1000,
|
||||
backoff_max_ms: 60000,
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal constructor that reads values through a closure, enabling safe testing.
|
||||
fn from_env_reader(env: impl Fn(&str) -> Option<String>) -> Option<Self> {
|
||||
let url = env("CHANNEL_RELAY_URL")?;
|
||||
let api_key = SecretString::from(env("CHANNEL_RELAY_API_KEY")?);
|
||||
Some(Self {
|
||||
url,
|
||||
api_key,
|
||||
callback_url: env("IRONCLAW_OAUTH_CALLBACK_URL"),
|
||||
instance_id: env("IRONCLAW_INSTANCE_ID"),
|
||||
request_timeout_secs: env("RELAY_REQUEST_TIMEOUT_SECS")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(30),
|
||||
stream_timeout_secs: env("RELAY_STREAM_TIMEOUT_SECS")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(86400),
|
||||
backoff_initial_ms: env("RELAY_BACKOFF_INITIAL_MS")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(1000),
|
||||
backoff_max_ms: env("RELAY_BACKOFF_MAX_MS")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(60000),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn from_env_reader_returns_none_when_unset() {
|
||||
let config = RelayConfig::from_env_reader(|_| None);
|
||||
assert!(config.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_env_reader_loads_defaults() {
|
||||
let config = RelayConfig::from_env_reader(|key| match key {
|
||||
"CHANNEL_RELAY_URL" => Some("http://localhost:3001".into()),
|
||||
"CHANNEL_RELAY_API_KEY" => Some("test-key".into()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("config should be Some");
|
||||
|
||||
assert_eq!(config.url, "http://localhost:3001");
|
||||
assert_eq!(config.request_timeout_secs, 30);
|
||||
assert_eq!(config.stream_timeout_secs, 86400);
|
||||
assert_eq!(config.backoff_initial_ms, 1000);
|
||||
assert_eq!(config.backoff_max_ms, 60000);
|
||||
assert!(config.callback_url.is_none());
|
||||
assert!(config.instance_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_env_reader_loads_overrides() {
|
||||
let config = RelayConfig::from_env_reader(|key| match key {
|
||||
"CHANNEL_RELAY_URL" => Some("http://relay:3001".into()),
|
||||
"CHANNEL_RELAY_API_KEY" => Some("secret".into()),
|
||||
"IRONCLAW_OAUTH_CALLBACK_URL" => Some("https://tunnel.example.com".into()),
|
||||
"IRONCLAW_INSTANCE_ID" => Some("my-instance".into()),
|
||||
"RELAY_REQUEST_TIMEOUT_SECS" => Some("60".into()),
|
||||
"RELAY_STREAM_TIMEOUT_SECS" => Some("43200".into()),
|
||||
"RELAY_BACKOFF_INITIAL_MS" => Some("2000".into()),
|
||||
"RELAY_BACKOFF_MAX_MS" => Some("120000".into()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("config should be Some");
|
||||
|
||||
assert_eq!(
|
||||
config.callback_url.as_deref(),
|
||||
Some("https://tunnel.example.com")
|
||||
);
|
||||
assert_eq!(config.instance_id.as_deref(), Some("my-instance"));
|
||||
assert_eq!(config.request_timeout_secs, 60);
|
||||
assert_eq!(config.stream_timeout_secs, 43200);
|
||||
assert_eq!(config.backoff_initial_ms, 2000);
|
||||
assert_eq!(config.backoff_max_ms, 120000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_values_builds_with_defaults() {
|
||||
let config = RelayConfig::from_values("http://localhost:3001", "key");
|
||||
assert_eq!(config.url, "http://localhost:3001");
|
||||
assert_eq!(config.request_timeout_secs, 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_redacts_api_key() {
|
||||
let config = RelayConfig::from_values("http://localhost:3001", "super-secret");
|
||||
let debug = format!("{:?}", config);
|
||||
assert!(debug.contains("[REDACTED]"));
|
||||
assert!(!debug.contains("super-secret"));
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,6 @@ pub struct RoutineConfig {
|
||||
pub default_cooldown_secs: u64,
|
||||
/// Max output tokens for lightweight routine LLM calls.
|
||||
pub max_lightweight_tokens: u32,
|
||||
/// Enable tool execution in lightweight routines (default: true).
|
||||
pub lightweight_tools_enabled: bool,
|
||||
/// Max tool iterations for lightweight routines (default: 3, max: 5).
|
||||
pub lightweight_max_iterations: u32,
|
||||
}
|
||||
|
||||
impl Default for RoutineConfig {
|
||||
@@ -28,23 +24,18 @@ impl Default for RoutineConfig {
|
||||
max_concurrent_routines: 10,
|
||||
default_cooldown_secs: 300,
|
||||
max_lightweight_tokens: 4096,
|
||||
lightweight_tools_enabled: true,
|
||||
lightweight_max_iterations: 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RoutineConfig {
|
||||
pub(crate) fn resolve() -> Result<Self, ConfigError> {
|
||||
let max_iterations: u32 = parse_optional_env("ROUTINES_LIGHTWEIGHT_MAX_ITERATIONS", 3)?;
|
||||
Ok(Self {
|
||||
enabled: parse_bool_env("ROUTINES_ENABLED", true)?,
|
||||
cron_check_interval_secs: parse_optional_env("ROUTINES_CRON_INTERVAL", 15)?,
|
||||
max_concurrent_routines: parse_optional_env("ROUTINES_MAX_CONCURRENT", 10)?,
|
||||
default_cooldown_secs: parse_optional_env("ROUTINES_DEFAULT_COOLDOWN", 300)?,
|
||||
max_lightweight_tokens: parse_optional_env("ROUTINES_MAX_TOKENS", 4096)?,
|
||||
lightweight_tools_enabled: parse_bool_env("ROUTINES_LIGHTWEIGHT_TOOLS", true)?,
|
||||
lightweight_max_iterations: max_iterations.min(5), // cap at 5
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+10
-17
@@ -272,7 +272,6 @@ fn parse_oauth_access_token(json: &str) -> Option<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::config::sandbox::*;
|
||||
use crate::testing::credentials::*;
|
||||
|
||||
// ── SandboxModeConfig defaults ──────────────────────────────────
|
||||
|
||||
@@ -406,12 +405,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_oauth_token_valid() {
|
||||
let json = format!(
|
||||
r#"{{"claudeAiOauth": {{"accessToken": "{}"}}}}"#,
|
||||
TEST_ANTHROPIC_OAUTH_BASIC
|
||||
);
|
||||
let token = parse_oauth_access_token(&json);
|
||||
assert_eq!(token, Some(TEST_ANTHROPIC_OAUTH_BASIC.to_string()));
|
||||
let json = r#"{"claudeAiOauth": {"accessToken": "sk-ant-oat01-fake"}}"#;
|
||||
let token = parse_oauth_access_token(json);
|
||||
assert_eq!(token, Some("sk-ant-oat01-fake".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -438,19 +434,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_oauth_token_nested_extra_fields() {
|
||||
let json = format!(
|
||||
r#"{{
|
||||
"claudeAiOauth": {{
|
||||
"accessToken": "{}",
|
||||
let json = r#"{
|
||||
"claudeAiOauth": {
|
||||
"accessToken": "sk-ant-oat01-real-token",
|
||||
"refreshToken": "rt-abc",
|
||||
"expiresAt": 1700000000
|
||||
}}
|
||||
}}"#,
|
||||
TEST_ANTHROPIC_OAUTH_NESTED
|
||||
);
|
||||
}
|
||||
}"#;
|
||||
assert_eq!(
|
||||
parse_oauth_access_token(&json),
|
||||
Some(TEST_ANTHROPIC_OAUTH_NESTED.to_string())
|
||||
parse_oauth_access_token(json),
|
||||
Some("sk-ant-oat01-real-token".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+8
-14
@@ -30,9 +30,8 @@ impl JobStore for LibSqlBackend {
|
||||
id, conversation_id, title, description, category, status, source,
|
||||
user_id,
|
||||
budget_amount, budget_token, bid_amount, estimated_cost, estimated_time_secs,
|
||||
actual_cost, repair_attempts, max_tokens, total_tokens_used,
|
||||
created_at, started_at, completed_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)
|
||||
actual_cost, repair_attempts, created_at, started_at, completed_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
description = excluded.description,
|
||||
@@ -43,8 +42,6 @@ impl JobStore for LibSqlBackend {
|
||||
estimated_time_secs = excluded.estimated_time_secs,
|
||||
actual_cost = excluded.actual_cost,
|
||||
repair_attempts = excluded.repair_attempts,
|
||||
max_tokens = excluded.max_tokens,
|
||||
total_tokens_used = excluded.total_tokens_used,
|
||||
started_at = excluded.started_at,
|
||||
completed_at = excluded.completed_at
|
||||
"#,
|
||||
@@ -64,8 +61,6 @@ impl JobStore for LibSqlBackend {
|
||||
estimated_time_secs,
|
||||
ctx.actual_cost.to_string(),
|
||||
ctx.repair_attempts as i64,
|
||||
ctx.max_tokens as i64,
|
||||
ctx.total_tokens_used as i64,
|
||||
fmt_ts(&ctx.created_at),
|
||||
fmt_opt_ts(&ctx.started_at),
|
||||
fmt_opt_ts(&ctx.completed_at),
|
||||
@@ -83,8 +78,7 @@ impl JobStore for LibSqlBackend {
|
||||
r#"
|
||||
SELECT id, conversation_id, title, description, category, status, user_id,
|
||||
budget_amount, budget_token, bid_amount, estimated_cost, estimated_time_secs,
|
||||
actual_cost, repair_attempts, max_tokens, total_tokens_used,
|
||||
created_at, started_at, completed_at
|
||||
actual_cost, repair_attempts, created_at, started_at, completed_at
|
||||
FROM agent_jobs WHERE id = ?1
|
||||
"#,
|
||||
params![id.to_string()],
|
||||
@@ -117,12 +111,12 @@ impl JobStore for LibSqlBackend {
|
||||
estimated_duration: estimated_time_secs
|
||||
.map(|s| std::time::Duration::from_secs(s as u64)),
|
||||
actual_cost: get_decimal(&row, 12),
|
||||
max_tokens: get_i64(&row, 14) as u64,
|
||||
total_tokens_used: get_i64(&row, 15) as u64,
|
||||
total_tokens_used: 0,
|
||||
max_tokens: 0,
|
||||
repair_attempts: get_i64(&row, 13) as u32,
|
||||
created_at: get_ts(&row, 16),
|
||||
started_at: get_opt_ts(&row, 17),
|
||||
completed_at: get_opt_ts(&row, 18),
|
||||
created_at: get_ts(&row, 14),
|
||||
started_at: get_opt_ts(&row, 15),
|
||||
completed_at: get_opt_ts(&row, 16),
|
||||
transitions: Vec::new(),
|
||||
metadata: serde_json::Value::Null,
|
||||
extra_env: std::sync::Arc::new(std::collections::HashMap::new()),
|
||||
|
||||
@@ -167,7 +167,7 @@ impl RoutineStore for LibSqlBackend {
|
||||
let mut rows = conn
|
||||
.query(
|
||||
&format!(
|
||||
"SELECT {} FROM routines WHERE enabled = 1 AND trigger_type IN ('event', 'system_event')",
|
||||
"SELECT {} FROM routines WHERE enabled = 1 AND trigger_type = 'event'",
|
||||
ROUTINE_COLUMNS
|
||||
),
|
||||
(),
|
||||
|
||||
+18
-34
@@ -583,21 +583,20 @@ INSERT OR IGNORE INTO leak_detection_patterns (id, name, pattern, severity, acti
|
||||
///
|
||||
/// Each entry is `(version, name, sql)`. Migrations are idempotent: the
|
||||
/// `_migrations` table tracks which versions have been applied.
|
||||
pub const INCREMENTAL_MIGRATIONS: &[(i64, &str, &str)] = &[
|
||||
(
|
||||
9,
|
||||
"flexible_embedding_dimension",
|
||||
// Rebuild memory_chunks to remove the fixed F32_BLOB(1536) type
|
||||
// constraint so any embedding dimension works. Existing embeddings
|
||||
// are preserved; users only need to re-embed if they change models.
|
||||
//
|
||||
// The vector index (libsql_vector_idx) requires a fixed-dimension
|
||||
// F32_BLOB(N), so we drop it entirely. Vector search falls back to
|
||||
// brute-force cosine distance which is fast enough for personal
|
||||
// assistant workspaces. This matches PostgreSQL after its V9 migration.
|
||||
//
|
||||
// SQLite cannot ALTER COLUMN types, so we recreate the table.
|
||||
r#"
|
||||
pub const INCREMENTAL_MIGRATIONS: &[(i64, &str, &str)] = &[(
|
||||
9,
|
||||
"flexible_embedding_dimension",
|
||||
// Rebuild memory_chunks to remove the fixed F32_BLOB(1536) type
|
||||
// constraint so any embedding dimension works. Existing embeddings
|
||||
// are preserved; users only need to re-embed if they change models.
|
||||
//
|
||||
// The vector index (libsql_vector_idx) requires a fixed-dimension
|
||||
// F32_BLOB(N), so we drop it entirely. Vector search falls back to
|
||||
// brute-force cosine distance which is fast enough for personal
|
||||
// assistant workspaces. This matches PostgreSQL after its V9 migration.
|
||||
//
|
||||
// SQLite cannot ALTER COLUMN types, so we recreate the table.
|
||||
r#"
|
||||
-- Drop vector index (requires fixed F32_BLOB(N), incompatible with flexible dimensions)
|
||||
DROP INDEX IF EXISTS idx_memory_chunks_embedding;
|
||||
|
||||
@@ -645,18 +644,7 @@ CREATE TRIGGER IF NOT EXISTS memory_chunks_fts_update AFTER UPDATE ON memory_chu
|
||||
INSERT INTO memory_chunks_fts(rowid, content) VALUES (new._rowid, new.content);
|
||||
END;
|
||||
"#,
|
||||
),
|
||||
(
|
||||
12,
|
||||
"job_token_budget",
|
||||
// Add token budget tracking columns to agent_jobs.
|
||||
// SQLite supports ALTER TABLE ADD COLUMN, so no table rebuild needed.
|
||||
r#"
|
||||
ALTER TABLE agent_jobs ADD COLUMN max_tokens INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE agent_jobs ADD COLUMN total_tokens_used INTEGER NOT NULL DEFAULT 0;
|
||||
"#,
|
||||
),
|
||||
];
|
||||
)];
|
||||
|
||||
/// Run incremental migrations that haven't been applied yet.
|
||||
///
|
||||
@@ -665,7 +653,6 @@ ALTER TABLE agent_jobs ADD COLUMN total_tokens_used INTEGER NOT NULL DEFAULT 0;
|
||||
pub async fn run_incremental(conn: &libsql::Connection) -> Result<(), crate::error::DatabaseError> {
|
||||
use crate::error::DatabaseError;
|
||||
|
||||
let mut applied_count = 0;
|
||||
for &(version, name, sql) in INCREMENTAL_MIGRATIONS {
|
||||
// Check if already applied
|
||||
let mut rows = conn
|
||||
@@ -682,6 +669,8 @@ pub async fn run_incremental(conn: &libsql::Connection) -> Result<(), crate::err
|
||||
continue; // Already applied
|
||||
}
|
||||
|
||||
tracing::info!(version, name, "libSQL: applying incremental migration");
|
||||
|
||||
// Wrap migration + recording in a transaction for atomicity.
|
||||
// If the process crashes mid-migration, the transaction rolls back
|
||||
// and the migration will be retried on next startup.
|
||||
@@ -713,12 +702,7 @@ pub async fn run_incremental(conn: &libsql::Connection) -> Result<(), crate::err
|
||||
))
|
||||
})?;
|
||||
|
||||
applied_count += 1;
|
||||
tracing::debug!(version, name, "libSQL: migration applied");
|
||||
}
|
||||
|
||||
if applied_count > 0 {
|
||||
tracing::info!("libSQL: applied {} incremental migrations", applied_count);
|
||||
tracing::info!(version, name, "libSQL: migration applied successfully");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
+2
-33
@@ -51,29 +51,6 @@ use crate::workspace::{SearchConfig, SearchResult};
|
||||
pub async fn connect_from_config(
|
||||
config: &crate::config::DatabaseConfig,
|
||||
) -> Result<Arc<dyn Database>, DatabaseError> {
|
||||
let (db, _handles) = connect_with_handles(config).await?;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
/// Backend-specific handles retained after database connection.
|
||||
///
|
||||
/// These are needed by satellite stores (e.g., `SecretsStore`) that require
|
||||
/// a backend-specific handle rather than the generic `Arc<dyn Database>`.
|
||||
#[derive(Default)]
|
||||
pub struct DatabaseHandles {
|
||||
#[cfg(feature = "postgres")]
|
||||
pub pg_pool: Option<deadpool_postgres::Pool>,
|
||||
#[cfg(feature = "libsql")]
|
||||
pub libsql_db: Option<Arc<::libsql::Database>>,
|
||||
}
|
||||
|
||||
/// Connect to the database, run migrations, and return both the generic
|
||||
/// `Database` trait object and the backend-specific handles.
|
||||
pub async fn connect_with_handles(
|
||||
config: &crate::config::DatabaseConfig,
|
||||
) -> Result<(Arc<dyn Database>, DatabaseHandles), DatabaseError> {
|
||||
let mut handles = DatabaseHandles::default();
|
||||
|
||||
match config.backend {
|
||||
#[cfg(feature = "libsql")]
|
||||
crate::config::DatabaseBackend::LibSql => {
|
||||
@@ -97,11 +74,7 @@ pub async fn connect_with_handles(
|
||||
.map_err(|e| DatabaseError::Pool(e.to_string()))?
|
||||
};
|
||||
backend.run_migrations().await?;
|
||||
tracing::info!("libSQL database connected and migrations applied");
|
||||
|
||||
handles.libsql_db = Some(backend.shared_db());
|
||||
|
||||
Ok((Arc::new(backend) as Arc<dyn Database>, handles))
|
||||
Ok(Arc::new(backend))
|
||||
}
|
||||
#[cfg(feature = "postgres")]
|
||||
_ => {
|
||||
@@ -109,11 +82,7 @@ pub async fn connect_with_handles(
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Pool(e.to_string()))?;
|
||||
pg.run_migrations().await?;
|
||||
tracing::info!("PostgreSQL database connected and migrations applied");
|
||||
|
||||
handles.pg_pool = Some(pg.pool());
|
||||
|
||||
Ok((Arc::new(pg) as Arc<dyn Database>, handles))
|
||||
Ok(Arc::new(pg))
|
||||
}
|
||||
#[cfg(not(feature = "postgres"))]
|
||||
_ => Err(DatabaseError::Pool(
|
||||
|
||||
@@ -250,7 +250,6 @@ fn extract_source(source: &ExtensionSource) -> String {
|
||||
ExtensionSource::Discovered { url } => url.clone(),
|
||||
ExtensionSource::WasmDownload { wasm_url, .. } => wasm_url.clone(),
|
||||
ExtensionSource::WasmBuildable { source_dir, .. } => source_dir.clone(),
|
||||
ExtensionSource::ChannelRelay { relay_url } => relay_url.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+51
-808
File diff suppressed because it is too large
Load Diff
@@ -37,8 +37,6 @@ pub enum ExtensionKind {
|
||||
WasmTool,
|
||||
/// WASM channel module with hot-activation support.
|
||||
WasmChannel,
|
||||
/// External channel via channel-relay service (Slack, etc.).
|
||||
ChannelRelay,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ExtensionKind {
|
||||
@@ -47,7 +45,6 @@ impl std::fmt::Display for ExtensionKind {
|
||||
ExtensionKind::McpServer => write!(f, "mcp_server"),
|
||||
ExtensionKind::WasmTool => write!(f, "wasm_tool"),
|
||||
ExtensionKind::WasmChannel => write!(f, "wasm_channel"),
|
||||
ExtensionKind::ChannelRelay => write!(f, "channel_relay"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,8 +99,6 @@ pub enum ExtensionSource {
|
||||
},
|
||||
/// Discovered online (not yet validated for a specific source type).
|
||||
Discovered { url: String },
|
||||
/// External channel via channel-relay service.
|
||||
ChannelRelay { relay_url: String },
|
||||
}
|
||||
|
||||
/// Hint about what authentication method is needed.
|
||||
@@ -121,8 +116,6 @@ pub enum AuthHint {
|
||||
CapabilitiesAuth,
|
||||
/// No authentication needed.
|
||||
None,
|
||||
/// OAuth via channel-relay service.
|
||||
ChannelRelayOAuth,
|
||||
}
|
||||
|
||||
/// Where a search result came from.
|
||||
@@ -506,9 +499,6 @@ pub enum ExtensionError {
|
||||
#[error("Activation failed: {0}")]
|
||||
ActivationFailed(String),
|
||||
|
||||
#[error("Authentication required")]
|
||||
AuthRequired,
|
||||
|
||||
#[error("Installation failed: {0}")]
|
||||
InstallFailed(String),
|
||||
|
||||
@@ -986,7 +976,6 @@ mod tests {
|
||||
ExtensionError::Config("missing key".into()),
|
||||
"Config error: missing key",
|
||||
),
|
||||
(ExtensionError::AuthRequired, "Authentication required"),
|
||||
(
|
||||
ExtensionError::Other("something broke".into()),
|
||||
"something broke",
|
||||
|
||||
@@ -224,16 +224,8 @@ fn score_entry(entry: &RegistryEntry, tokens: &[String]) -> u32 {
|
||||
}
|
||||
|
||||
/// Well-known extensions that ship with ironclaw.
|
||||
///
|
||||
/// If `relay_url` is provided, a channel-relay Slack entry is included in the list.
|
||||
/// Pass `None` when the relay is not configured.
|
||||
pub fn builtin_entries() -> Vec<RegistryEntry> {
|
||||
builtin_entries_with_relay(std::env::var("CHANNEL_RELAY_URL").ok())
|
||||
}
|
||||
|
||||
/// Well-known extensions, with an optional relay URL for the channel-relay entry.
|
||||
pub fn builtin_entries_with_relay(relay_url: Option<String>) -> Vec<RegistryEntry> {
|
||||
let mut entries = vec![
|
||||
fn builtin_entries() -> Vec<RegistryEntry> {
|
||||
vec![
|
||||
// -- MCP Servers --
|
||||
RegistryEntry {
|
||||
name: "notion".to_string(),
|
||||
@@ -423,29 +415,7 @@ pub fn builtin_entries_with_relay(relay_url: Option<String>) -> Vec<RegistryEntr
|
||||
// WASM channels (telegram, slack, discord, whatsapp) come from the embedded
|
||||
// registry catalog (registry/channels/*.json) with WasmDownload URLs pointing
|
||||
// to GitHub release artifacts. See new_with_catalog() for merging.
|
||||
];
|
||||
|
||||
// Conditionally add channel-relay entries when relay URL is configured
|
||||
if let Some(relay_url) = relay_url {
|
||||
entries.push(RegistryEntry {
|
||||
name: crate::channels::relay::DEFAULT_RELAY_NAME.to_string(),
|
||||
display_name: "Slack".to_string(),
|
||||
kind: ExtensionKind::ChannelRelay,
|
||||
description: "Connect Slack workspace via channel relay".to_string(),
|
||||
keywords: vec![
|
||||
"slack".into(),
|
||||
"chat".into(),
|
||||
"messaging".into(),
|
||||
"relay".into(),
|
||||
],
|
||||
source: ExtensionSource::ChannelRelay { relay_url },
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::ChannelRelayOAuth,
|
||||
version: None,
|
||||
});
|
||||
}
|
||||
|
||||
entries
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -965,30 +935,4 @@ mod tests {
|
||||
// The first catalog entry added is the channel.
|
||||
assert_eq!(entry.unwrap().kind, ExtensionKind::WasmChannel);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_builtin_entries_with_relay_none_excludes_relay() {
|
||||
let entries = super::builtin_entries_with_relay(None);
|
||||
assert!(
|
||||
!entries
|
||||
.iter()
|
||||
.any(|e| e.kind == ExtensionKind::ChannelRelay),
|
||||
"No ChannelRelay entry when relay URL is None"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_builtin_entries_with_relay_some_includes_relay() {
|
||||
let entries =
|
||||
super::builtin_entries_with_relay(Some("http://relay.example.com".to_string()));
|
||||
let relay = entries
|
||||
.iter()
|
||||
.find(|e| e.kind == ExtensionKind::ChannelRelay);
|
||||
assert!(relay.is_some(), "ChannelRelay entry should be present");
|
||||
if let ExtensionSource::ChannelRelay { relay_url } = &relay.unwrap().source {
|
||||
assert_eq!(relay_url, "http://relay.example.com");
|
||||
} else {
|
||||
panic!("Expected ChannelRelay source");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user