mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-27 08:00:17 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3615967f92 | ||
|
|
704d63f16a | ||
|
|
902492bcdb | ||
|
|
13697976db | ||
|
|
cbcd5adcc0 | ||
|
|
e24c33ff90 | ||
|
|
f99991d27b | ||
|
|
89600e2b5c | ||
|
|
e4e78d8a87 | ||
|
|
b9446712e9 | ||
|
|
31a4330f24 | ||
|
|
9b47dbbaed | ||
|
|
ac3c928853 | ||
|
|
bf2a08be94 |
@@ -9,24 +9,170 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
coverage:
|
coverage:
|
||||||
name: Coverage
|
name: Coverage (${{ matrix.name }})
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
components: llvm-tools-preview
|
components: llvm-tools-preview
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
key: coverage
|
key: coverage-${{ matrix.name }}
|
||||||
|
|
||||||
- name: Install cargo-llvm-cov
|
- name: Install cargo-llvm-cov
|
||||||
uses: taiki-e/install-action@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
|
- name: Generate coverage
|
||||||
run: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info
|
run: cargo llvm-cov ${{ matrix.flags }} --workspace --lcov --output-path lcov.info
|
||||||
|
|
||||||
- name: Upload to Codecov
|
- name: Upload to Codecov
|
||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@v5
|
||||||
with:
|
with:
|
||||||
files: lcov.info
|
files: lcov.info
|
||||||
|
flags: ${{ matrix.name }}
|
||||||
disable_search: true
|
disable_search: true
|
||||||
use_oidc: true
|
use_oidc: true
|
||||||
fail_ci_if_error: 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
|
||||||
|
|||||||
@@ -413,6 +413,9 @@ 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:
|
||||||
@@ -445,7 +448,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
done < "$CHECKSUMS"
|
done < "$CHECKSUMS"
|
||||||
- name: Commit updated manifests
|
- name: Create PR with 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"
|
||||||
@@ -453,8 +456,15 @@ 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
|
git push origin "$BRANCH"
|
||||||
|
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:
|
||||||
|
|||||||
@@ -7,6 +7,29 @@ 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
|
## [0.14.0](https://github.com/nearai/ironclaw/compare/v0.13.1...v0.14.0) - 2026-03-04
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
Generated
+1
-1
@@ -2828,7 +2828,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ironclaw"
|
name = "ironclaw"
|
||||||
version = "0.14.0"
|
version = "0.15.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ exclude = [
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "ironclaw"
|
name = "ironclaw"
|
||||||
version = "0.14.0"
|
version = "0.15.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"
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# 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"]
|
||||||
@@ -6,15 +6,16 @@
|
|||||||
"required_secrets": [
|
"required_secrets": [
|
||||||
{
|
{
|
||||||
"name": "discord_bot_token",
|
"name": "discord_bot_token",
|
||||||
"prompt": "Enter your Discord Bot Token (from Developer Portal)",
|
"prompt": "Enter your Discord Bot Token. Find it under Bot > Token in your Discord Application settings.",
|
||||||
"optional": false
|
"optional": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "discord_public_key",
|
"name": "discord_public_key",
|
||||||
"prompt": "Enter your Discord Application Public Key (from Developer Portal > General Information)",
|
"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": {
|
||||||
|
|||||||
@@ -6,15 +6,16 @@
|
|||||||
"required_secrets": [
|
"required_secrets": [
|
||||||
{
|
{
|
||||||
"name": "slack_bot_token",
|
"name": "slack_bot_token",
|
||||||
"prompt": "Enter your Slack Bot OAuth Token (xoxb-...)",
|
"prompt": "Enter your Slack Bot User OAuth Token (starts with xoxb-). Find it under OAuth & Permissions in your Slack App settings.",
|
||||||
"optional": false
|
"optional": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "slack_signing_secret",
|
"name": "slack_signing_secret",
|
||||||
"prompt": "Enter your Slack Signing Secret (from App Credentials)",
|
"prompt": "Enter your Slack App Signing Secret (found under Basic Information > App Credentials in your Slack App settings).",
|
||||||
"optional": false
|
"optional": false
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"setup_url": "https://api.slack.com/apps"
|
||||||
},
|
},
|
||||||
"capabilities": {
|
"capabilities": {
|
||||||
"http": {
|
"http": {
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
"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": {
|
||||||
@@ -39,6 +40,10 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"required_secrets": [
|
"required_secrets": [
|
||||||
{
|
{
|
||||||
"name": "whatsapp_access_token",
|
"name": "whatsapp_access_token",
|
||||||
"prompt": "Enter your WhatsApp Cloud API access token (from Meta Developer Portal)",
|
"prompt": "Enter your WhatsApp Cloud API permanent access token (from the Meta Developer Portal under your app's WhatsApp > API Setup).",
|
||||||
"validation": "^[A-Za-z0-9_-]+$"
|
"validation": "^[A-Za-z0-9_-]+$"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -16,7 +16,8 @@
|
|||||||
"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": {
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
---
|
||||||
|
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`.
|
||||||
+130
-18
@@ -15,6 +15,7 @@ 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 {
|
||||||
@@ -291,7 +292,11 @@ impl Agent {
|
|||||||
|
|
||||||
match output.result {
|
match output.result {
|
||||||
RespondResult::Text(text) => {
|
RespondResult::Text(text) => {
|
||||||
return Ok(AgenticLoopResult::Response(text));
|
// Strip internal "[Called tool ...]" text that can leak when
|
||||||
|
// 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,
|
||||||
@@ -317,14 +322,25 @@ impl Agent {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Record tool calls in the thread
|
// Record tool calls in the thread with sensitive params redacted.
|
||||||
|
// 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 in &tool_calls {
|
for (tc, safe_args) in tool_calls.iter().zip(redacted_args) {
|
||||||
turn.record_tool_call(&tc.name, tc.arguments.clone());
|
turn.record_tool_call(&tc.name, safe_args);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -353,11 +369,22 @@ 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: tc.arguments.clone(),
|
parameters: hook_params,
|
||||||
user_id: message.user_id.clone(),
|
user_id: message.user_id.clone(),
|
||||||
context: "chat".to_string(),
|
context: "chat".to_string(),
|
||||||
};
|
};
|
||||||
@@ -384,8 +411,20 @@ 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(&new_params) {
|
}) => match serde_json::from_str::<serde_json::Value>(&new_params) {
|
||||||
Ok(parsed) => tc.arguments = parsed,
|
Ok(mut 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,
|
||||||
@@ -400,7 +439,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) = self.tools().get(&tc.name).await
|
&& let Some(tool) = tool_opt
|
||||||
{
|
{
|
||||||
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) {
|
||||||
@@ -447,14 +486,17 @@ 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::ToolCompleted {
|
StatusUpdate::tool_completed(
|
||||||
name: tc.name.clone(),
|
tc.name.clone(),
|
||||||
success: result.is_ok(),
|
&result,
|
||||||
},
|
&tc.arguments,
|
||||||
|
disp_tool.as_deref(),
|
||||||
|
),
|
||||||
&message.metadata,
|
&message.metadata,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -495,13 +537,16 @@ impl Agent {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
let par_tool = tools.get(&tc.name).await;
|
||||||
let _ = channels
|
let _ = channels
|
||||||
.send_status(
|
.send_status(
|
||||||
&channel,
|
&channel,
|
||||||
StatusUpdate::ToolCompleted {
|
StatusUpdate::tool_completed(
|
||||||
name: tc.name.clone(),
|
tc.name.clone(),
|
||||||
success: result.is_ok(),
|
&result,
|
||||||
},
|
&tc.arguments,
|
||||||
|
par_tool.as_deref(),
|
||||||
|
),
|
||||||
&metadata,
|
&metadata,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -671,10 +716,15 @@ 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(),
|
||||||
@@ -734,9 +784,10 @@ 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 = %params,
|
params = %safe_params,
|
||||||
"Tool call started"
|
"Tool call started"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -900,6 +951,38 @@ 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;
|
||||||
@@ -1086,6 +1169,7 @@ 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![],
|
||||||
@@ -1902,4 +1986,32 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,8 +148,12 @@ 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.
|
/// Tool parameters (original values, used for execution).
|
||||||
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).
|
||||||
@@ -950,6 +954,7 @@ 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")],
|
||||||
@@ -974,6 +979,7 @@ 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![],
|
||||||
|
|||||||
+31
-20
@@ -21,6 +21,7 @@ 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.
|
||||||
@@ -357,7 +358,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.parameters.clone();
|
let parameters = pending.display_parameters.clone();
|
||||||
thread.await_approval(pending);
|
thread.await_approval(pending);
|
||||||
let _ = self
|
let _ = self
|
||||||
.channels
|
.channels
|
||||||
@@ -751,14 +752,17 @@ 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::ToolCompleted {
|
StatusUpdate::tool_completed(
|
||||||
name: pending.tool_name.clone(),
|
pending.tool_name.clone(),
|
||||||
success: tool_result.is_ok(),
|
&tool_result,
|
||||||
},
|
&pending.display_parameters,
|
||||||
|
tool_ref.as_deref(),
|
||||||
|
),
|
||||||
&message.metadata,
|
&message.metadata,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -908,14 +912,17 @@ 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::ToolCompleted {
|
StatusUpdate::tool_completed(
|
||||||
name: tc.name.clone(),
|
tc.name.clone(),
|
||||||
success: result.is_ok(),
|
&result,
|
||||||
},
|
&tc.arguments,
|
||||||
|
deferred_tool.as_deref(),
|
||||||
|
),
|
||||||
&message.metadata,
|
&message.metadata,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -957,13 +964,16 @@ impl Agent {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
let par_tool = tools.get(&tc.name).await;
|
||||||
let _ = channels
|
let _ = channels
|
||||||
.send_status(
|
.send_status(
|
||||||
&channel,
|
&channel,
|
||||||
StatusUpdate::ToolCompleted {
|
StatusUpdate::tool_completed(
|
||||||
name: tc.name.clone(),
|
tc.name.clone(),
|
||||||
success: result.is_ok(),
|
&result,
|
||||||
},
|
&tc.arguments,
|
||||||
|
par_tool.as_deref(),
|
||||||
|
),
|
||||||
&metadata,
|
&metadata,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -1086,6 +1096,7 @@ 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(),
|
||||||
@@ -1095,7 +1106,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.parameters.clone();
|
let parameters = new_pending.display_parameters.clone();
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut sess = session.lock().await;
|
let mut sess = session.lock().await;
|
||||||
@@ -1162,7 +1173,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.parameters.clone();
|
let parameters = new_pending.display_parameters.clone();
|
||||||
thread.await_approval(new_pending);
|
thread.await_approval(new_pending);
|
||||||
let _ = self
|
let _ = self
|
||||||
.channels
|
.channels
|
||||||
@@ -1284,7 +1295,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.status == "authenticated" => {
|
Ok(result) if result.is_authenticated() => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Extension '{}' authenticated via auth mode",
|
"Extension '{}' authenticated via auth mode",
|
||||||
pending.extension_name
|
pending.extension_name
|
||||||
@@ -1353,8 +1364,8 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let msg = result
|
let msg = result
|
||||||
.instructions
|
.instructions()
|
||||||
.clone()
|
.map(String::from)
|
||||||
.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
|
||||||
@@ -1364,8 +1375,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,
|
auth_url: result.auth_url().map(String::from),
|
||||||
setup_url: result.setup_url,
|
setup_url: result.setup_url().map(String::from),
|
||||||
},
|
},
|
||||||
&message.metadata,
|
&message.metadata,
|
||||||
)
|
)
|
||||||
|
|||||||
+10
-6
@@ -18,8 +18,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.
|
||||||
///
|
///
|
||||||
@@ -700,9 +700,10 @@ 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: params.clone(),
|
parameters: hook_params,
|
||||||
user_id: job_ctx.user_id.clone(),
|
user_id: job_ctx.user_id.clone(),
|
||||||
context: format!("job:{}", job_id),
|
context: format!("job:{}", job_id),
|
||||||
};
|
};
|
||||||
@@ -758,9 +759,12 @@ 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 = %params,
|
params = %safe_params,
|
||||||
job = %job_id,
|
job = %job_id,
|
||||||
"Tool call started"
|
"Tool call started"
|
||||||
);
|
);
|
||||||
@@ -812,7 +816,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, params.clone()).succeed(
|
let rec = mem.create_action(tool_name, safe_params.clone()).succeed(
|
||||||
output_str.clone(),
|
output_str.clone(),
|
||||||
output.result.clone(),
|
output.result.clone(),
|
||||||
elapsed,
|
elapsed,
|
||||||
@@ -834,7 +838,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, params.clone())
|
.create_action(tool_name, safe_params.clone())
|
||||||
.fail(e.to_string(), elapsed);
|
.fail(e.to_string(), elapsed);
|
||||||
mem.record_action(rec.clone());
|
mem.record_action(rec.clone());
|
||||||
rec
|
rec
|
||||||
@@ -853,7 +857,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, params.clone())
|
.create_action(tool_name, safe_params.clone())
|
||||||
.fail("Execution timeout", elapsed);
|
.fail("Execution timeout", elapsed);
|
||||||
mem.record_action(rec.clone());
|
mem.record_action(rec.clone());
|
||||||
rec
|
rec
|
||||||
|
|||||||
@@ -331,6 +331,10 @@ 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
|
||||||
|
|||||||
+174
-1
@@ -117,7 +117,20 @@ 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.
|
||||||
@@ -152,6 +165,38 @@ 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
|
||||||
@@ -223,3 +268,131 @@ pub trait Channel: Send + Sync {
|
|||||||
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -466,7 +466,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 {
|
||||||
|
|||||||
@@ -974,7 +974,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 {
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ 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!(
|
||||||
|
|||||||
@@ -90,6 +90,37 @@ 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)
|
||||||
@@ -262,6 +293,10 @@ 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.
|
||||||
@@ -605,6 +640,65 @@ mod tests {
|
|||||||
|
|
||||||
// ── Category 5: Discord Capabilities Setup & Configuration ──────────
|
// ── 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]
|
#[test]
|
||||||
fn test_discord_capabilities_has_public_key_secret() {
|
fn test_discord_capabilities_has_public_key_secret() {
|
||||||
let json = include_str!("../../../channels-src/discord/discord.capabilities.json");
|
let json = include_str!("../../../channels-src/discord/discord.capabilities.json");
|
||||||
|
|||||||
@@ -2479,7 +2479,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: {} ({})",
|
||||||
@@ -3387,6 +3387,8 @@ 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,
|
||||||
);
|
);
|
||||||
@@ -3407,6 +3409,8 @@ 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,
|
||||||
);
|
);
|
||||||
|
|||||||
+148
-30
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Request, State},
|
extract::{Request, State},
|
||||||
http::{HeaderMap, StatusCode},
|
http::{HeaderMap, Method, StatusCode},
|
||||||
middleware::Next,
|
middleware::Next,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
@@ -14,10 +14,44 @@ 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.
|
/// `?token=xxx` as a query parameter, but only on SSE endpoints.
|
||||||
pub async fn auth_middleware(
|
pub async fn auth_middleware(
|
||||||
State(auth): State<AuthState>,
|
State(auth): State<AuthState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -35,15 +69,12 @@ pub async fn auth_middleware(
|
|||||||
return next.run(request).await;
|
return next.run(request).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to query parameter for SSE EventSource (constant-time comparison)
|
// Fall back to query parameter, but only for SSE endpoints (constant-time comparison).
|
||||||
if let Some(query) = request.uri().query() {
|
if allows_query_token_auth(&request)
|
||||||
for pair in query.split('&') {
|
&& let Some(token) = query_token(&request)
|
||||||
if let Some(token) = pair.strip_prefix("token=")
|
&& bool::from(token.as_bytes().ct_eq(auth.token.as_bytes()))
|
||||||
&& 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()
|
||||||
@@ -62,24 +93,28 @@ mod tests {
|
|||||||
assert_eq!(cloned.token, "test-token");
|
assert_eq!(cloned.token, "test-token");
|
||||||
}
|
}
|
||||||
|
|
||||||
// === QA Plan - Web gateway auth tests ===
|
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::middleware;
|
use axum::middleware;
|
||||||
use axum::routing::get;
|
use axum::routing::{get, post};
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
|
|
||||||
async fn dummy_handler() -> &'static str {
|
async fn dummy_handler() -> &'static str {
|
||||||
"ok"
|
"ok"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Router with streaming endpoints (query auth allowed) and regular
|
||||||
|
/// endpoints (query auth rejected).
|
||||||
fn test_app(token: &str) -> Router {
|
fn test_app(token: &str) -> Router {
|
||||||
let state = AuthState {
|
let state = AuthState {
|
||||||
token: token.to_string(),
|
token: token.to_string(),
|
||||||
};
|
};
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/test", get(dummy_handler))
|
.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))
|
.layer(middleware::from_fn_with_state(state, auth_middleware))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +122,7 @@ mod tests {
|
|||||||
async fn test_valid_bearer_token_passes() {
|
async fn test_valid_bearer_token_passes() {
|
||||||
let app = test_app("secret-token");
|
let app = test_app("secret-token");
|
||||||
let req = Request::builder()
|
let req = Request::builder()
|
||||||
.uri("/test")
|
.uri("/api/chat/events")
|
||||||
.header("Authorization", "Bearer secret-token")
|
.header("Authorization", "Bearer secret-token")
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -99,7 +134,7 @@ mod tests {
|
|||||||
async fn test_invalid_bearer_token_rejected() {
|
async fn test_invalid_bearer_token_rejected() {
|
||||||
let app = test_app("secret-token");
|
let app = test_app("secret-token");
|
||||||
let req = Request::builder()
|
let req = Request::builder()
|
||||||
.uri("/test")
|
.uri("/api/chat/events")
|
||||||
.header("Authorization", "Bearer wrong-token")
|
.header("Authorization", "Bearer wrong-token")
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -108,10 +143,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_missing_auth_header_falls_through_to_query() {
|
async fn test_query_token_allowed_for_chat_events() {
|
||||||
let app = test_app("secret-token");
|
let app = test_app("secret-token");
|
||||||
let req = Request::builder()
|
let req = Request::builder()
|
||||||
.uri("/test?token=secret-token")
|
.uri("/api/chat/events?token=secret-token")
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let resp = app.oneshot(req).await.unwrap();
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
@@ -119,10 +154,80 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_query_param_invalid_token_rejected() {
|
async fn test_query_token_allowed_for_logs_events() {
|
||||||
let app = test_app("secret-token");
|
let app = test_app("secret-token");
|
||||||
let req = Request::builder()
|
let req = Request::builder()
|
||||||
.uri("/test?token=wrong-token")
|
.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())
|
.body(Body::empty())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let resp = app.oneshot(req).await.unwrap();
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
@@ -132,17 +237,32 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_no_auth_at_all_rejected() {
|
async fn test_no_auth_at_all_rejected() {
|
||||||
let app = test_app("secret-token");
|
let app = test_app("secret-token");
|
||||||
let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
|
let req = Request::builder()
|
||||||
|
.uri("/api/chat/events")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
let resp = app.oneshot(req).await.unwrap();
|
let resp = app.oneshot(req).await.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_bearer_prefix_case_insensitive() {
|
async fn test_bearer_header_works_for_post() {
|
||||||
// RFC 6750 Section 2.1: auth-scheme comparison must be case-insensitive.
|
|
||||||
let app = test_app("secret-token");
|
let app = test_app("secret-token");
|
||||||
let req = Request::builder()
|
let req = Request::builder()
|
||||||
.uri("/test")
|
.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")
|
.header("Authorization", "bearer secret-token")
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -154,7 +274,7 @@ mod tests {
|
|||||||
async fn test_bearer_prefix_mixed_case() {
|
async fn test_bearer_prefix_mixed_case() {
|
||||||
let app = test_app("secret-token");
|
let app = test_app("secret-token");
|
||||||
let req = Request::builder()
|
let req = Request::builder()
|
||||||
.uri("/test")
|
.uri("/api/chat/events")
|
||||||
.header("Authorization", "BEARER secret-token")
|
.header("Authorization", "BEARER secret-token")
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -166,7 +286,7 @@ mod tests {
|
|||||||
async fn test_empty_bearer_token_rejected() {
|
async fn test_empty_bearer_token_rejected() {
|
||||||
let app = test_app("secret-token");
|
let app = test_app("secret-token");
|
||||||
let req = Request::builder()
|
let req = Request::builder()
|
||||||
.uri("/test")
|
.uri("/api/chat/events")
|
||||||
.header("Authorization", "Bearer ")
|
.header("Authorization", "Bearer ")
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -176,11 +296,9 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_token_with_whitespace_rejected() {
|
async fn test_token_with_whitespace_rejected() {
|
||||||
// Extra space after "Bearer " means the token value starts with a space,
|
|
||||||
// which should not match the expected token.
|
|
||||||
let app = test_app("secret-token");
|
let app = test_app("secret-token");
|
||||||
let req = Request::builder()
|
let req = Request::builder()
|
||||||
.uri("/test")
|
.uri("/api/chat/events")
|
||||||
.header("Authorization", "Bearer secret-token")
|
.header("Authorization", "Bearer secret-token")
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -142,7 +142,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.status == "authenticated" {
|
if result.is_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,13 +170,14 @@ 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.clone(),
|
instructions: result.instructions().map(String::from),
|
||||||
auth_url: result.auth_url.clone(),
|
auth_url: result.auth_url().map(String::from),
|
||||||
setup_url: result.setup_url.clone(),
|
setup_url: result.setup_url().map(String::from),
|
||||||
});
|
});
|
||||||
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()),
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,7 +141,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.status == "authenticated" => {
|
Ok(auth_result) if auth_result.is_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 +152,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()
|
||||||
.clone()
|
.map(String::from)
|
||||||
.unwrap_or_else(|| format!("'{}' requires authentication.", name)),
|
.unwrap_or_else(|| format!("'{}' requires authentication.", name)),
|
||||||
);
|
);
|
||||||
resp.auth_url = auth_result.auth_url;
|
resp.auth_url = auth_result.auth_url().map(String::from);
|
||||||
resp.awaiting_token = Some(auth_result.awaiting_token);
|
resp.awaiting_token = Some(auth_result.is_awaiting_token());
|
||||||
resp.instructions = auth_result.instructions;
|
resp.instructions = auth_result.instructions().map(String::from);
|
||||||
Ok(Json(resp))
|
Ok(Json(resp))
|
||||||
}
|
}
|
||||||
Err(auth_err) => Ok(Json(ActionResponse::fail(format!(
|
Err(auth_err) => Ok(Json(ActionResponse::fail(format!(
|
||||||
|
|||||||
@@ -304,9 +304,16 @@ impl Channel for GatewayChannel {
|
|||||||
name,
|
name,
|
||||||
thread_id: thread_id.clone(),
|
thread_id: thread_id.clone(),
|
||||||
},
|
},
|
||||||
StatusUpdate::ToolCompleted { name, success } => SseEvent::ToolCompleted {
|
StatusUpdate::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 {
|
||||||
|
|||||||
+539
-20
@@ -192,7 +192,9 @@ pub async fn start_server(
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Public routes (no auth)
|
// Public routes (no auth)
|
||||||
let public = Router::new().route("/api/health", get(health_handler));
|
let public = Router::new()
|
||||||
|
.route("/api/health", get(health_handler))
|
||||||
|
.route("/oauth/callback", get(oauth_callback_handler));
|
||||||
|
|
||||||
// Protected routes (require auth)
|
// Protected routes (require auth)
|
||||||
let auth_state = AuthState { token: auth_token };
|
let auth_state = AuthState { token: auth_token };
|
||||||
@@ -424,6 +426,180 @@ async fn health_handler() -> Json<HealthResponse> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return an OAuth error landing page response.
|
||||||
|
fn oauth_error_page(label: &str) -> axum::response::Response {
|
||||||
|
let html = crate::cli::oauth_defaults::landing_html(label, false);
|
||||||
|
axum::response::Html(html).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OAuth callback handler for the web gateway.
|
||||||
|
///
|
||||||
|
/// This is a PUBLIC route (no Bearer token required) because OAuth providers
|
||||||
|
/// redirect the user's browser here. The `state` query parameter correlates
|
||||||
|
/// the callback with a pending OAuth flow registered by `start_wasm_oauth()`.
|
||||||
|
///
|
||||||
|
/// Used on hosted instances where `IRONCLAW_OAUTH_CALLBACK_URL` points to
|
||||||
|
/// the gateway (e.g., `https://kind-deer.agent1.near.ai/oauth/callback`).
|
||||||
|
/// Local/desktop mode continues to use the TCP listener on port 9876.
|
||||||
|
async fn oauth_callback_handler(
|
||||||
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
use crate::cli::oauth_defaults;
|
||||||
|
|
||||||
|
// Check for error from OAuth provider (e.g., user denied consent)
|
||||||
|
if let Some(error) = params.get("error") {
|
||||||
|
let description = params
|
||||||
|
.get("error_description")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| error.clone());
|
||||||
|
return oauth_error_page(&description);
|
||||||
|
}
|
||||||
|
|
||||||
|
let state_param = match params.get("state") {
|
||||||
|
Some(s) if !s.is_empty() => s.clone(),
|
||||||
|
_ => return oauth_error_page("IronClaw"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let code = match params.get("code") {
|
||||||
|
Some(c) if !c.is_empty() => c.clone(),
|
||||||
|
_ => return oauth_error_page("IronClaw"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Look up the pending flow by CSRF state (atomic remove prevents replay)
|
||||||
|
let ext_mgr = match state.extension_manager.as_ref() {
|
||||||
|
Some(mgr) => mgr,
|
||||||
|
None => return oauth_error_page("IronClaw"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Strip instance prefix from state for registry lookup.
|
||||||
|
// Platform nginx sends `state=instance:nonce` but flows are keyed by nonce only.
|
||||||
|
let lookup_key = oauth_defaults::strip_instance_prefix(&state_param);
|
||||||
|
|
||||||
|
let flow = ext_mgr
|
||||||
|
.pending_oauth_flows()
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.remove(lookup_key);
|
||||||
|
|
||||||
|
let flow = match flow {
|
||||||
|
Some(f) => f,
|
||||||
|
None => {
|
||||||
|
tracing::warn!(
|
||||||
|
state = %state_param,
|
||||||
|
lookup_key = %lookup_key,
|
||||||
|
"OAuth callback received with unknown or expired state"
|
||||||
|
);
|
||||||
|
return oauth_error_page("IronClaw");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check flow expiry (5 minutes, matching TCP listener timeout)
|
||||||
|
if flow.created_at.elapsed() > oauth_defaults::OAUTH_FLOW_EXPIRY {
|
||||||
|
tracing::warn!(
|
||||||
|
extension = %flow.extension_name,
|
||||||
|
"OAuth flow expired"
|
||||||
|
);
|
||||||
|
return oauth_error_page(&flow.display_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange the authorization code for tokens.
|
||||||
|
// Use the platform exchange proxy when configured (keeps client_secret off container),
|
||||||
|
// otherwise call the provider's token URL directly.
|
||||||
|
let exchange_proxy_url = std::env::var("IRONCLAW_OAUTH_EXCHANGE_URL").ok();
|
||||||
|
|
||||||
|
let result: Result<(), String> = async {
|
||||||
|
let token_response = if let Some(ref proxy_url) = exchange_proxy_url {
|
||||||
|
let gateway_token = flow.gateway_token.as_deref().unwrap_or_default();
|
||||||
|
oauth_defaults::exchange_via_proxy(
|
||||||
|
proxy_url,
|
||||||
|
gateway_token,
|
||||||
|
&code,
|
||||||
|
&flow.redirect_uri,
|
||||||
|
flow.code_verifier.as_deref(),
|
||||||
|
&flow.access_token_field,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
} else {
|
||||||
|
oauth_defaults::exchange_oauth_code(
|
||||||
|
&flow.token_url,
|
||||||
|
&flow.client_id,
|
||||||
|
flow.client_secret.as_deref(),
|
||||||
|
&code,
|
||||||
|
&flow.redirect_uri,
|
||||||
|
flow.code_verifier.as_deref(),
|
||||||
|
&flow.access_token_field,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate the token before storing (catches wrong account, etc.)
|
||||||
|
if let Some(ref validation) = flow.validation_endpoint {
|
||||||
|
oauth_defaults::validate_oauth_token(&token_response.access_token, validation)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store tokens encrypted in the secrets store
|
||||||
|
oauth_defaults::store_oauth_tokens(
|
||||||
|
flow.secrets.as_ref(),
|
||||||
|
&flow.user_id,
|
||||||
|
&flow.secret_name,
|
||||||
|
flow.provider.as_deref(),
|
||||||
|
&token_response.access_token,
|
||||||
|
token_response.refresh_token.as_deref(),
|
||||||
|
token_response.expires_in,
|
||||||
|
&flow.scopes,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let (success, message) = match &result {
|
||||||
|
Ok(()) => (
|
||||||
|
true,
|
||||||
|
format!("{} authenticated successfully", flow.display_name),
|
||||||
|
),
|
||||||
|
Err(e) => (
|
||||||
|
false,
|
||||||
|
format!("{} authentication failed: {}", flow.display_name, e),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
match &result {
|
||||||
|
Ok(()) => {
|
||||||
|
tracing::info!(
|
||||||
|
extension = %flow.extension_name,
|
||||||
|
"OAuth completed successfully via gateway callback"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
extension = %flow.extension_name,
|
||||||
|
error = %e,
|
||||||
|
"OAuth failed via gateway callback"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast SSE event to notify the web UI
|
||||||
|
if let Some(ref sender) = flow.sse_sender {
|
||||||
|
let _ = sender.send(SseEvent::AuthCompleted {
|
||||||
|
extension_name: flow.extension_name,
|
||||||
|
success,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = oauth_defaults::landing_html(&flow.display_name, success);
|
||||||
|
axum::response::Html(html).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
// --- Chat handlers ---
|
// --- Chat handlers ---
|
||||||
|
|
||||||
async fn chat_send_handler(
|
async fn chat_send_handler(
|
||||||
@@ -552,7 +728,7 @@ 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.status == "authenticated" {
|
if result.is_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!(
|
||||||
@@ -580,13 +756,14 @@ 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.clone(),
|
instructions: result.instructions().map(String::from),
|
||||||
auth_url: result.auth_url.clone(),
|
auth_url: result.auth_url().map(String::from),
|
||||||
setup_url: result.setup_url.clone(),
|
setup_url: result.setup_url().map(String::from),
|
||||||
});
|
});
|
||||||
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()),
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
@@ -1332,12 +1509,9 @@ async fn extensions_install_handler(
|
|||||||
// configured (e.g., built-in providers). We only surface an auth_url
|
// configured (e.g., built-in providers). We only surface an auth_url
|
||||||
// when the extension reports it is awaiting authorization.
|
// when the extension reports it is awaiting authorization.
|
||||||
match ext_mgr.auth(&req.name, None).await {
|
match ext_mgr.auth(&req.name, None).await {
|
||||||
Ok(auth_result)
|
Ok(auth_result) if auth_result.auth_url().is_some() => {
|
||||||
if auth_result.auth_url.is_some()
|
|
||||||
&& auth_result.status == "awaiting_authorization" =>
|
|
||||||
{
|
|
||||||
// Scope expansion or initial OAuth: user needs to authorize
|
// Scope expansion or initial OAuth: user needs to authorize
|
||||||
resp.auth_url = auth_result.auth_url;
|
resp.auth_url = auth_result.auth_url().map(String::from);
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -1366,10 +1540,9 @@ async fn extensions_activate_handler(
|
|||||||
// Initial OAuth setup is triggered via save_setup_secrets.
|
// Initial OAuth setup is triggered via save_setup_secrets.
|
||||||
let mut resp = ActionResponse::ok(result.message);
|
let mut resp = ActionResponse::ok(result.message);
|
||||||
if let Ok(auth_result) = ext_mgr.auth(&name, None).await
|
if let Ok(auth_result) = ext_mgr.auth(&name, None).await
|
||||||
&& auth_result.auth_url.is_some()
|
&& auth_result.auth_url().is_some()
|
||||||
&& auth_result.status == "awaiting_authorization"
|
|
||||||
{
|
{
|
||||||
resp.auth_url = auth_result.auth_url;
|
resp.auth_url = auth_result.auth_url().map(String::from);
|
||||||
}
|
}
|
||||||
Ok(Json(resp))
|
Ok(Json(resp))
|
||||||
}
|
}
|
||||||
@@ -1385,7 +1558,7 @@ 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.status == "authenticated" => {
|
Ok(auth_result) if auth_result.is_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))),
|
||||||
@@ -1396,13 +1569,13 @@ 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()
|
||||||
.clone()
|
.map(String::from)
|
||||||
.unwrap_or_else(|| format!("'{}' requires authentication.", name)),
|
.unwrap_or_else(|| format!("'{}' requires authentication.", name)),
|
||||||
);
|
);
|
||||||
resp.auth_url = auth_result.auth_url;
|
resp.auth_url = auth_result.auth_url().map(String::from);
|
||||||
resp.awaiting_token = Some(auth_result.awaiting_token);
|
resp.awaiting_token = Some(auth_result.is_awaiting_token());
|
||||||
resp.instructions = auth_result.instructions;
|
resp.instructions = auth_result.instructions().map(String::from);
|
||||||
Ok(Json(resp))
|
Ok(Json(resp))
|
||||||
}
|
}
|
||||||
Err(auth_err) => Ok(Json(ActionResponse::fail(format!(
|
Err(auth_err) => Ok(Json(ActionResponse::fail(format!(
|
||||||
@@ -1592,6 +1765,13 @@ async fn extensions_setup_submit_handler(
|
|||||||
|
|
||||||
match ext_mgr.save_setup_secrets(&name, &req.secrets).await {
|
match ext_mgr.save_setup_secrets(&name, &req.secrets).await {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
|
// Broadcast auth_completed so the chat UI can dismiss any in-progress
|
||||||
|
// auth card or setup modal that was triggered by tool_auth/tool_activate.
|
||||||
|
state.sse.broadcast(SseEvent::AuthCompleted {
|
||||||
|
extension_name: name.clone(),
|
||||||
|
success: true,
|
||||||
|
message: result.message.clone(),
|
||||||
|
});
|
||||||
let mut resp = ActionResponse::ok(result.message);
|
let mut resp = ActionResponse::ok(result.message);
|
||||||
resp.activated = Some(result.activated);
|
resp.activated = Some(result.activated);
|
||||||
resp.auth_url = result.auth_url;
|
resp.auth_url = result.auth_url;
|
||||||
@@ -2232,4 +2412,343 @@ mod tests {
|
|||||||
let turns = build_turns_from_db_messages(&[]);
|
let turns = build_turns_from_db_messages(&[]);
|
||||||
assert!(turns.is_empty());
|
assert!(turns.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- OAuth callback handler tests ---
|
||||||
|
|
||||||
|
/// Build a minimal `GatewayState` for testing the OAuth callback handler.
|
||||||
|
fn test_gateway_state(ext_mgr: Option<Arc<ExtensionManager>>) -> Arc<GatewayState> {
|
||||||
|
Arc::new(GatewayState {
|
||||||
|
msg_tx: tokio::sync::RwLock::new(None),
|
||||||
|
sse: SseManager::new(),
|
||||||
|
workspace: None,
|
||||||
|
session_manager: None,
|
||||||
|
log_broadcaster: None,
|
||||||
|
log_level_handle: None,
|
||||||
|
extension_manager: ext_mgr,
|
||||||
|
tool_registry: None,
|
||||||
|
store: None,
|
||||||
|
job_manager: None,
|
||||||
|
prompt_queue: None,
|
||||||
|
user_id: "test".to_string(),
|
||||||
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||||
|
ws_tracker: None,
|
||||||
|
llm_provider: None,
|
||||||
|
skill_registry: None,
|
||||||
|
skill_catalog: None,
|
||||||
|
scheduler: None,
|
||||||
|
chat_rate_limiter: RateLimiter::new(30, 60),
|
||||||
|
registry_entries: vec![],
|
||||||
|
cost_guard: None,
|
||||||
|
startup_time: std::time::Instant::now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a test router with just the OAuth callback route.
|
||||||
|
fn test_oauth_router(state: Arc<GatewayState>) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/oauth/callback", get(oauth_callback_handler))
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_oauth_callback_missing_params() {
|
||||||
|
use axum::body::Body;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
let state = test_gateway_state(None);
|
||||||
|
let app = test_oauth_router(state);
|
||||||
|
|
||||||
|
let req = axum::http::Request::builder()
|
||||||
|
.uri("/oauth/callback")
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("request");
|
||||||
|
|
||||||
|
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
|
||||||
|
.await
|
||||||
|
.expect("body");
|
||||||
|
let html = String::from_utf8_lossy(&body);
|
||||||
|
assert!(html.contains("Authorization Failed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_oauth_callback_error_from_provider() {
|
||||||
|
use axum::body::Body;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
let state = test_gateway_state(None);
|
||||||
|
let app = test_oauth_router(state);
|
||||||
|
|
||||||
|
let req = axum::http::Request::builder()
|
||||||
|
.uri("/oauth/callback?error=access_denied&error_description=access_denied")
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("request");
|
||||||
|
|
||||||
|
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
|
||||||
|
.await
|
||||||
|
.expect("body");
|
||||||
|
let html = String::from_utf8_lossy(&body);
|
||||||
|
assert!(html.contains("Authorization Failed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_oauth_callback_unknown_state() {
|
||||||
|
use axum::body::Body;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
// Build an ExtensionManager so the handler can look up flows
|
||||||
|
let secrets = Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
|
||||||
|
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
|
||||||
|
"test-key-at-least-32-chars-long!!".to_string(),
|
||||||
|
))
|
||||||
|
.expect("crypto"),
|
||||||
|
)));
|
||||||
|
let tool_registry = Arc::new(ToolRegistry::new());
|
||||||
|
let mcp_sm = Arc::new(crate::tools::mcp::session::McpSessionManager::new());
|
||||||
|
|
||||||
|
let ext_mgr = Arc::new(ExtensionManager::new(
|
||||||
|
mcp_sm,
|
||||||
|
secrets,
|
||||||
|
tool_registry,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
std::path::PathBuf::from("/tmp/wasm_tools"),
|
||||||
|
std::path::PathBuf::from("/tmp/wasm_channels"),
|
||||||
|
None,
|
||||||
|
"test".to_string(),
|
||||||
|
None,
|
||||||
|
vec![],
|
||||||
|
));
|
||||||
|
|
||||||
|
let state = test_gateway_state(Some(ext_mgr));
|
||||||
|
let app = test_oauth_router(state);
|
||||||
|
|
||||||
|
let req = axum::http::Request::builder()
|
||||||
|
.uri("/oauth/callback?code=test_code&state=unknown_state_value")
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("request");
|
||||||
|
|
||||||
|
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
|
||||||
|
.await
|
||||||
|
.expect("body");
|
||||||
|
let html = String::from_utf8_lossy(&body);
|
||||||
|
assert!(html.contains("Authorization Failed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_oauth_callback_expired_flow() {
|
||||||
|
use axum::body::Body;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
let secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync> =
|
||||||
|
Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
|
||||||
|
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
|
||||||
|
"test-key-at-least-32-chars-long!!".to_string(),
|
||||||
|
))
|
||||||
|
.expect("crypto"),
|
||||||
|
)));
|
||||||
|
let tool_registry = Arc::new(ToolRegistry::new());
|
||||||
|
let mcp_sm = Arc::new(crate::tools::mcp::session::McpSessionManager::new());
|
||||||
|
|
||||||
|
let ext_mgr = Arc::new(ExtensionManager::new(
|
||||||
|
mcp_sm,
|
||||||
|
secrets.clone(),
|
||||||
|
tool_registry,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
std::path::PathBuf::from("/tmp/wasm_tools"),
|
||||||
|
std::path::PathBuf::from("/tmp/wasm_channels"),
|
||||||
|
None,
|
||||||
|
"test".to_string(),
|
||||||
|
None,
|
||||||
|
vec![],
|
||||||
|
));
|
||||||
|
|
||||||
|
// Insert an expired flow (created 10 minutes ago)
|
||||||
|
let flow = crate::cli::oauth_defaults::PendingOAuthFlow {
|
||||||
|
extension_name: "test_tool".to_string(),
|
||||||
|
display_name: "Test Tool".to_string(),
|
||||||
|
token_url: "https://example.com/token".to_string(),
|
||||||
|
client_id: "client123".to_string(),
|
||||||
|
client_secret: None,
|
||||||
|
redirect_uri: "https://example.com/oauth/callback".to_string(),
|
||||||
|
code_verifier: None,
|
||||||
|
access_token_field: "access_token".to_string(),
|
||||||
|
secret_name: "test_token".to_string(),
|
||||||
|
provider: None,
|
||||||
|
validation_endpoint: None,
|
||||||
|
scopes: vec![],
|
||||||
|
user_id: "test".to_string(),
|
||||||
|
secrets,
|
||||||
|
sse_sender: None,
|
||||||
|
gateway_token: None,
|
||||||
|
created_at: std::time::Instant::now() - std::time::Duration::from_secs(600),
|
||||||
|
};
|
||||||
|
|
||||||
|
ext_mgr
|
||||||
|
.pending_oauth_flows()
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.insert("expired_state".to_string(), flow);
|
||||||
|
|
||||||
|
let state = test_gateway_state(Some(ext_mgr));
|
||||||
|
let app = test_oauth_router(state);
|
||||||
|
|
||||||
|
let req = axum::http::Request::builder()
|
||||||
|
.uri("/oauth/callback?code=test_code&state=expired_state")
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("request");
|
||||||
|
|
||||||
|
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
|
||||||
|
.await
|
||||||
|
.expect("body");
|
||||||
|
let html = String::from_utf8_lossy(&body);
|
||||||
|
// Expired flow → error landing page
|
||||||
|
assert!(html.contains("Authorization Failed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_oauth_callback_no_extension_manager() {
|
||||||
|
use axum::body::Body;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
// No extension manager set → graceful error
|
||||||
|
let state = test_gateway_state(None);
|
||||||
|
let app = test_oauth_router(state);
|
||||||
|
|
||||||
|
let req = axum::http::Request::builder()
|
||||||
|
.uri("/oauth/callback?code=test_code&state=some_state")
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("request");
|
||||||
|
|
||||||
|
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
|
||||||
|
.await
|
||||||
|
.expect("body");
|
||||||
|
let html = String::from_utf8_lossy(&body);
|
||||||
|
assert!(html.contains("Authorization Failed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_oauth_callback_strips_instance_prefix() {
|
||||||
|
use axum::body::Body;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
let secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync> =
|
||||||
|
Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
|
||||||
|
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
|
||||||
|
"test-key-at-least-32-chars-long!!".to_string(),
|
||||||
|
))
|
||||||
|
.expect("crypto"),
|
||||||
|
)));
|
||||||
|
let tool_registry = Arc::new(ToolRegistry::new());
|
||||||
|
let mcp_sm = Arc::new(crate::tools::mcp::session::McpSessionManager::new());
|
||||||
|
|
||||||
|
let ext_mgr = Arc::new(ExtensionManager::new(
|
||||||
|
mcp_sm,
|
||||||
|
secrets.clone(),
|
||||||
|
tool_registry,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
std::path::PathBuf::from("/tmp/wasm_tools"),
|
||||||
|
std::path::PathBuf::from("/tmp/wasm_channels"),
|
||||||
|
None,
|
||||||
|
"test".to_string(),
|
||||||
|
None,
|
||||||
|
vec![],
|
||||||
|
));
|
||||||
|
|
||||||
|
// Insert a flow keyed by raw nonce "test_nonce" (without instance prefix).
|
||||||
|
// Use an expired flow so the handler exits before attempting a real HTTP
|
||||||
|
// token exchange — we only need to verify that the instance prefix was
|
||||||
|
// stripped and the flow was found by the raw nonce.
|
||||||
|
let flow = crate::cli::oauth_defaults::PendingOAuthFlow {
|
||||||
|
extension_name: "test_tool".to_string(),
|
||||||
|
display_name: "Test Tool".to_string(),
|
||||||
|
token_url: "https://example.com/token".to_string(),
|
||||||
|
client_id: "client123".to_string(),
|
||||||
|
client_secret: None,
|
||||||
|
redirect_uri: "https://example.com/oauth/callback".to_string(),
|
||||||
|
code_verifier: None,
|
||||||
|
access_token_field: "access_token".to_string(),
|
||||||
|
secret_name: "test_token".to_string(),
|
||||||
|
provider: None,
|
||||||
|
validation_endpoint: None,
|
||||||
|
scopes: vec![],
|
||||||
|
user_id: "test".to_string(),
|
||||||
|
secrets,
|
||||||
|
sse_sender: None,
|
||||||
|
gateway_token: None,
|
||||||
|
// Expired — handler will reject after lookup (no network I/O)
|
||||||
|
created_at: std::time::Instant::now() - std::time::Duration::from_secs(600),
|
||||||
|
};
|
||||||
|
|
||||||
|
ext_mgr
|
||||||
|
.pending_oauth_flows()
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.insert("test_nonce".to_string(), flow);
|
||||||
|
|
||||||
|
let state = test_gateway_state(Some(ext_mgr.clone()));
|
||||||
|
let app = test_oauth_router(state);
|
||||||
|
|
||||||
|
// Send callback with instance prefix: "myinstance:test_nonce"
|
||||||
|
// The handler should strip "myinstance:" and find the flow keyed by "test_nonce"
|
||||||
|
let req = axum::http::Request::builder()
|
||||||
|
.uri("/oauth/callback?code=fake_code&state=myinstance:test_nonce")
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("request");
|
||||||
|
|
||||||
|
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
|
||||||
|
.await
|
||||||
|
.expect("body");
|
||||||
|
let html = String::from_utf8_lossy(&body);
|
||||||
|
|
||||||
|
// The flow was found (stripped prefix matched) but is expired, so the
|
||||||
|
// handler returns an error landing page. The flow being consumed from
|
||||||
|
// the registry (checked below) proves the prefix was stripped correctly.
|
||||||
|
assert!(
|
||||||
|
html.contains("Authorization Failed"),
|
||||||
|
"Expected error page, html was: {}",
|
||||||
|
&html[..html.len().min(500)]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the flow was consumed (removed from registry)
|
||||||
|
assert!(
|
||||||
|
ext_mgr
|
||||||
|
.pending_oauth_flows()
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.get("test_nonce")
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,7 +180,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);
|
completeToolCard(data.name, data.success, data.error, data.parameters);
|
||||||
});
|
});
|
||||||
|
|
||||||
eventSource.addEventListener('tool_result', (e) => {
|
eventSource.addEventListener('tool_result', (e) => {
|
||||||
@@ -222,17 +222,22 @@ function connectSSE() {
|
|||||||
|
|
||||||
eventSource.addEventListener('auth_required', (e) => {
|
eventSource.addEventListener('auth_required', (e) => {
|
||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
showAuthCard(data);
|
if (data.auth_url) {
|
||||||
|
// 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);
|
||||||
if (data.success) {
|
closeConfigureModal();
|
||||||
showToast(data.message, 'success');
|
showToast(data.message, data.success ? 'success' : 'error');
|
||||||
} else {
|
|
||||||
showToast(data.message, 'error');
|
|
||||||
}
|
|
||||||
// Refresh extensions list so status indicators update
|
// Refresh extensions list so status indicators update
|
||||||
if (currentTab === 'extensions') loadExtensions();
|
if (currentTab === 'extensions') loadExtensions();
|
||||||
enableChatInput();
|
enableChatInput();
|
||||||
@@ -590,7 +595,7 @@ function addToolCard(name) {
|
|||||||
container.scrollTop = container.scrollHeight;
|
container.scrollTop = container.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
function completeToolCard(name, success) {
|
function completeToolCard(name, success, error, parameters) {
|
||||||
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
|
||||||
@@ -611,6 +616,27 @@ function completeToolCard(name, success) {
|
|||||||
? '<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) {
|
||||||
@@ -874,7 +900,7 @@ function showAuthCard(data) {
|
|||||||
|
|
||||||
const tokenInput = document.createElement('input');
|
const tokenInput = document.createElement('input');
|
||||||
tokenInput.type = 'password';
|
tokenInput.type = 'password';
|
||||||
tokenInput.placeholder = 'Paste your API key or token';
|
tokenInput.placeholder = data.instructions || '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);
|
||||||
});
|
});
|
||||||
@@ -1987,7 +2013,11 @@ function renderExtensionCard(ext) {
|
|||||||
actions.appendChild(activateBtn);
|
actions.appendChild(activateBtn);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ext.needs_setup || ext.has_auth) {
|
// Show Configure/Reconfigure button when there are secrets to enter.
|
||||||
|
// 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';
|
||||||
@@ -2176,18 +2206,18 @@ function submitConfigureModal(name, fields) {
|
|||||||
closeConfigureModal();
|
closeConfigureModal();
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
if (res.auth_url) {
|
if (res.auth_url) {
|
||||||
// OAuth flow started — open consent popup
|
// OAuth flow started — open consent popup. The auth_completed SSE will
|
||||||
|
// not arrive immediately (it fires after OAuth callback), so show a toast now.
|
||||||
showToast('Opening OAuth authorization for ' + name, 'info');
|
showToast('Opening OAuth authorization for ' + name, 'info');
|
||||||
window.open(res.auth_url, '_blank', 'width=600,height=700');
|
window.open(res.auth_url, '_blank', 'width=600,height=700');
|
||||||
} else if (res.activated) {
|
loadExtensions();
|
||||||
showToast('Configured and activated ' + name, 'success');
|
|
||||||
} else {
|
|
||||||
showToast(res.message || 'Configuration saved but activation failed', 'warning');
|
|
||||||
}
|
}
|
||||||
|
// 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; });
|
||||||
@@ -2427,10 +2457,12 @@ 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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -553,6 +553,10 @@ 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;
|
||||||
|
|||||||
@@ -123,6 +123,10 @@ 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")]
|
||||||
|
|||||||
@@ -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.status == "authenticated" => {
|
Ok(result) if result.is_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,
|
instructions: result.instructions().map(String::from),
|
||||||
auth_url: result.auth_url,
|
auth_url: result.auth_url().map(String::from),
|
||||||
setup_url: result.setup_url,
|
setup_url: result.setup_url().map(String::from),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
//! env vars, which take priority over built-in defaults.
|
//! env vars, which take priority over built-in defaults.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
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 base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||||
@@ -25,6 +26,7 @@ use rand::RngCore;
|
|||||||
use sha2::{Digest, Sha256};
|
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};
|
use crate::secrets::{CreateSecretParams, SecretsStore};
|
||||||
|
|
||||||
@@ -683,6 +685,219 @@ 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;
|
||||||
@@ -939,4 +1154,161 @@ mod tests {
|
|||||||
// State should be different each time (random)
|
// State should be different each time (random)
|
||||||
assert_ne!(result1.state, result2.state);
|
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(""), "");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+440
-403
File diff suppressed because it is too large
Load Diff
+379
-18
@@ -24,6 +24,7 @@ pub use discovery::OnlineDiscovery;
|
|||||||
pub use manager::ExtensionManager;
|
pub use manager::ExtensionManager;
|
||||||
pub use registry::ExtensionRegistry;
|
pub use registry::ExtensionRegistry;
|
||||||
|
|
||||||
|
use serde::ser::SerializeMap;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// The kind of extension, determining how it's installed, authenticated, and activated.
|
/// The kind of extension, determining how it's installed, authenticated, and activated.
|
||||||
@@ -145,28 +146,267 @@ pub struct InstallResult {
|
|||||||
pub message: String,
|
pub message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Auth readiness state for the extensions list UI.
|
||||||
|
///
|
||||||
|
/// Used by `check_tool_auth_status` and `check_channel_auth_status` to
|
||||||
|
/// communicate a tool's credential state to the list handler without
|
||||||
|
/// ambiguous `(bool, bool)` tuples.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ToolAuthState {
|
||||||
|
/// Token/credentials are present — ready to use.
|
||||||
|
Ready,
|
||||||
|
/// Auth section exists but the access token is missing (OAuth not completed).
|
||||||
|
NeedsAuth,
|
||||||
|
/// Setup credentials (client_id/secret) must be configured before OAuth can start.
|
||||||
|
NeedsSetup,
|
||||||
|
/// No auth configuration at all (no capabilities or auth section).
|
||||||
|
NoAuth,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The typed auth status, carrying only the data relevant to each state.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum AuthStatus {
|
||||||
|
/// Authentication is complete; no further action needed.
|
||||||
|
Authenticated,
|
||||||
|
/// No authentication is required for this extension.
|
||||||
|
NoAuthRequired,
|
||||||
|
/// OAuth flow started — user must open `auth_url` in their browser.
|
||||||
|
AwaitingAuthorization {
|
||||||
|
auth_url: String,
|
||||||
|
callback_type: String,
|
||||||
|
},
|
||||||
|
/// Waiting for user to provide a token/key manually.
|
||||||
|
AwaitingToken {
|
||||||
|
instructions: String,
|
||||||
|
setup_url: Option<String>,
|
||||||
|
},
|
||||||
|
/// OAuth client credentials need to be configured before auth can proceed.
|
||||||
|
NeedsSetup {
|
||||||
|
instructions: String,
|
||||||
|
setup_url: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthStatus {
|
||||||
|
/// The wire-format status string (backward-compatible with JS consumers).
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
AuthStatus::Authenticated => "authenticated",
|
||||||
|
AuthStatus::NoAuthRequired => "no_auth_required",
|
||||||
|
AuthStatus::AwaitingAuthorization { .. } => "awaiting_authorization",
|
||||||
|
AuthStatus::AwaitingToken { .. } => "awaiting_token",
|
||||||
|
AuthStatus::NeedsSetup { .. } => "needs_setup",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Result of authenticating an extension.
|
/// Result of authenticating an extension.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AuthResult {
|
pub struct AuthResult {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub kind: ExtensionKind,
|
pub kind: ExtensionKind,
|
||||||
/// OAuth URL to open (for OAuth flows).
|
pub status: AuthStatus,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
}
|
||||||
pub auth_url: Option<String>,
|
|
||||||
/// Whether using local or remote callback.
|
impl AuthResult {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
// ── Constructors ──────────────────────────────────────────────────
|
||||||
pub callback_type: Option<String>,
|
|
||||||
/// Instructions for manual token entry (for WASM tools).
|
pub fn authenticated(name: impl Into<String>, kind: ExtensionKind) -> Self {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
Self {
|
||||||
pub instructions: Option<String>,
|
name: name.into(),
|
||||||
/// URL for manual token setup.
|
kind,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
status: AuthStatus::Authenticated,
|
||||||
pub setup_url: Option<String>,
|
}
|
||||||
/// Whether the tool is waiting for a token from the user.
|
}
|
||||||
#[serde(default)]
|
|
||||||
pub awaiting_token: bool,
|
pub fn no_auth_required(name: impl Into<String>, kind: ExtensionKind) -> Self {
|
||||||
/// Current auth status.
|
Self {
|
||||||
pub status: String,
|
name: name.into(),
|
||||||
|
kind,
|
||||||
|
status: AuthStatus::NoAuthRequired,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn awaiting_authorization(
|
||||||
|
name: impl Into<String>,
|
||||||
|
kind: ExtensionKind,
|
||||||
|
auth_url: String,
|
||||||
|
callback_type: String,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.into(),
|
||||||
|
kind,
|
||||||
|
status: AuthStatus::AwaitingAuthorization {
|
||||||
|
auth_url,
|
||||||
|
callback_type,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn awaiting_token(
|
||||||
|
name: impl Into<String>,
|
||||||
|
kind: ExtensionKind,
|
||||||
|
instructions: String,
|
||||||
|
setup_url: Option<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.into(),
|
||||||
|
kind,
|
||||||
|
status: AuthStatus::AwaitingToken {
|
||||||
|
instructions,
|
||||||
|
setup_url,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn needs_setup(
|
||||||
|
name: impl Into<String>,
|
||||||
|
kind: ExtensionKind,
|
||||||
|
instructions: String,
|
||||||
|
setup_url: Option<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.into(),
|
||||||
|
kind,
|
||||||
|
status: AuthStatus::NeedsSetup {
|
||||||
|
instructions,
|
||||||
|
setup_url,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Accessors ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn is_authenticated(&self) -> bool {
|
||||||
|
matches!(self.status, AuthStatus::Authenticated)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn auth_url(&self) -> Option<&str> {
|
||||||
|
match &self.status {
|
||||||
|
AuthStatus::AwaitingAuthorization { auth_url, .. } => Some(auth_url),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn callback_type(&self) -> Option<&str> {
|
||||||
|
match &self.status {
|
||||||
|
AuthStatus::AwaitingAuthorization { callback_type, .. } => Some(callback_type),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn instructions(&self) -> Option<&str> {
|
||||||
|
match &self.status {
|
||||||
|
AuthStatus::AwaitingToken { instructions, .. }
|
||||||
|
| AuthStatus::NeedsSetup { instructions, .. } => Some(instructions),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn setup_url(&self) -> Option<&str> {
|
||||||
|
match &self.status {
|
||||||
|
AuthStatus::AwaitingToken { setup_url, .. }
|
||||||
|
| AuthStatus::NeedsSetup { setup_url, .. } => setup_url.as_deref(),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_awaiting_token(&self) -> bool {
|
||||||
|
matches!(self.status, AuthStatus::AwaitingToken { .. })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status_str(&self) -> &'static str {
|
||||||
|
self.status.as_str()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize `AuthResult` to the same flat JSON shape the JS frontend expects.
|
||||||
|
impl Serialize for AuthResult {
|
||||||
|
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
// Count fields: name + kind + status + optional fields
|
||||||
|
let optional_count = self.auth_url().is_some() as usize
|
||||||
|
+ self.callback_type().is_some() as usize
|
||||||
|
+ self.instructions().is_some() as usize
|
||||||
|
+ self.setup_url().is_some() as usize;
|
||||||
|
let mut map = serializer.serialize_map(Some(4 + optional_count))?;
|
||||||
|
|
||||||
|
map.serialize_entry("name", &self.name)?;
|
||||||
|
map.serialize_entry("kind", &self.kind)?;
|
||||||
|
if let Some(url) = self.auth_url() {
|
||||||
|
map.serialize_entry("auth_url", url)?;
|
||||||
|
}
|
||||||
|
if let Some(cb) = self.callback_type() {
|
||||||
|
map.serialize_entry("callback_type", cb)?;
|
||||||
|
}
|
||||||
|
if let Some(inst) = self.instructions() {
|
||||||
|
map.serialize_entry("instructions", inst)?;
|
||||||
|
}
|
||||||
|
if let Some(url) = self.setup_url() {
|
||||||
|
map.serialize_entry("setup_url", url)?;
|
||||||
|
}
|
||||||
|
map.serialize_entry("awaiting_token", &self.is_awaiting_token())?;
|
||||||
|
map.serialize_entry("status", self.status_str())?;
|
||||||
|
map.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deserialize from the flat JSON shape back into the typed enum.
|
||||||
|
impl<'de> Deserialize<'de> for AuthResult {
|
||||||
|
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
|
/// Flat helper matching the old JSON shape.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
struct Raw {
|
||||||
|
name: String,
|
||||||
|
kind: ExtensionKind,
|
||||||
|
#[serde(default)]
|
||||||
|
auth_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
callback_type: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
instructions: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
setup_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
awaiting_token: bool,
|
||||||
|
status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw = Raw::deserialize(deserializer)?;
|
||||||
|
let status = match raw.status.as_str() {
|
||||||
|
"authenticated" => AuthStatus::Authenticated,
|
||||||
|
"no_auth_required" => AuthStatus::NoAuthRequired,
|
||||||
|
"awaiting_authorization" => AuthStatus::AwaitingAuthorization {
|
||||||
|
auth_url: raw.auth_url.unwrap_or_default(),
|
||||||
|
callback_type: raw.callback_type.unwrap_or_default(),
|
||||||
|
},
|
||||||
|
"awaiting_token" => AuthStatus::AwaitingToken {
|
||||||
|
instructions: raw.instructions.unwrap_or_default(),
|
||||||
|
setup_url: raw.setup_url,
|
||||||
|
},
|
||||||
|
"needs_setup" => AuthStatus::NeedsSetup {
|
||||||
|
instructions: raw.instructions.unwrap_or_default(),
|
||||||
|
setup_url: raw.setup_url,
|
||||||
|
},
|
||||||
|
other => {
|
||||||
|
return Err(serde::de::Error::unknown_variant(
|
||||||
|
other,
|
||||||
|
&[
|
||||||
|
"authenticated",
|
||||||
|
"no_auth_required",
|
||||||
|
"awaiting_authorization",
|
||||||
|
"awaiting_token",
|
||||||
|
"needs_setup",
|
||||||
|
],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(AuthResult {
|
||||||
|
name: raw.name,
|
||||||
|
kind: raw.kind,
|
||||||
|
status,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of activating an extension.
|
/// Result of activating an extension.
|
||||||
@@ -257,3 +497,124 @@ pub enum ExtensionError {
|
|||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Other(String),
|
Other(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_result_authenticated_round_trip() {
|
||||||
|
let result = AuthResult::authenticated("gmail", ExtensionKind::WasmTool);
|
||||||
|
let json = serde_json::to_value(&result).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(json["status"], "authenticated");
|
||||||
|
assert_eq!(json["name"], "gmail");
|
||||||
|
assert_eq!(json["kind"], "wasm_tool");
|
||||||
|
assert_eq!(json["awaiting_token"], false);
|
||||||
|
assert!(json.get("auth_url").is_none());
|
||||||
|
assert!(json.get("instructions").is_none());
|
||||||
|
|
||||||
|
let back: AuthResult = serde_json::from_value(json).unwrap();
|
||||||
|
assert!(back.is_authenticated());
|
||||||
|
assert!(back.auth_url().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_result_awaiting_authorization_round_trip() {
|
||||||
|
let result = AuthResult::awaiting_authorization(
|
||||||
|
"google-drive",
|
||||||
|
ExtensionKind::WasmTool,
|
||||||
|
"https://accounts.google.com/o/oauth2/v2/auth?state=abc".to_string(),
|
||||||
|
"local".to_string(),
|
||||||
|
);
|
||||||
|
let json = serde_json::to_value(&result).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(json["status"], "awaiting_authorization");
|
||||||
|
assert_eq!(
|
||||||
|
json["auth_url"],
|
||||||
|
"https://accounts.google.com/o/oauth2/v2/auth?state=abc"
|
||||||
|
);
|
||||||
|
assert_eq!(json["callback_type"], "local");
|
||||||
|
assert_eq!(json["awaiting_token"], false);
|
||||||
|
|
||||||
|
let back: AuthResult = serde_json::from_value(json).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
back.auth_url(),
|
||||||
|
Some("https://accounts.google.com/o/oauth2/v2/auth?state=abc")
|
||||||
|
);
|
||||||
|
assert_eq!(back.callback_type(), Some("local"));
|
||||||
|
assert!(!back.is_authenticated());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_result_awaiting_token_round_trip() {
|
||||||
|
let result = AuthResult::awaiting_token(
|
||||||
|
"telegram",
|
||||||
|
ExtensionKind::WasmChannel,
|
||||||
|
"Enter your bot token".to_string(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let json = serde_json::to_value(&result).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(json["status"], "awaiting_token");
|
||||||
|
assert_eq!(json["instructions"], "Enter your bot token");
|
||||||
|
assert_eq!(json["awaiting_token"], true);
|
||||||
|
assert!(json.get("auth_url").is_none());
|
||||||
|
|
||||||
|
let back: AuthResult = serde_json::from_value(json).unwrap();
|
||||||
|
assert!(back.is_awaiting_token());
|
||||||
|
assert_eq!(back.instructions(), Some("Enter your bot token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_result_needs_setup_round_trip() {
|
||||||
|
let result = AuthResult::needs_setup(
|
||||||
|
"custom-tool",
|
||||||
|
ExtensionKind::WasmTool,
|
||||||
|
"Configure OAuth credentials in the Setup tab.".to_string(),
|
||||||
|
Some("https://console.cloud.google.com".to_string()),
|
||||||
|
);
|
||||||
|
let json = serde_json::to_value(&result).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(json["status"], "needs_setup");
|
||||||
|
assert_eq!(json["setup_url"], "https://console.cloud.google.com");
|
||||||
|
assert_eq!(json["awaiting_token"], false);
|
||||||
|
|
||||||
|
let back: AuthResult = serde_json::from_value(json).unwrap();
|
||||||
|
assert!(!back.is_authenticated());
|
||||||
|
assert!(!back.is_awaiting_token());
|
||||||
|
assert_eq!(back.setup_url(), Some("https://console.cloud.google.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_result_no_auth_required_round_trip() {
|
||||||
|
let result = AuthResult::no_auth_required("echo", ExtensionKind::WasmTool);
|
||||||
|
let json = serde_json::to_value(&result).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(json["status"], "no_auth_required");
|
||||||
|
assert_eq!(json["awaiting_token"], false);
|
||||||
|
|
||||||
|
let back: AuthResult = serde_json::from_value(json).unwrap();
|
||||||
|
assert!(!back.is_authenticated());
|
||||||
|
assert_eq!(back.status, AuthStatus::NoAuthRequired);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_status_type_safety() {
|
||||||
|
// AwaitingAuthorization always has auth_url
|
||||||
|
let result = AuthResult::awaiting_authorization(
|
||||||
|
"test",
|
||||||
|
ExtensionKind::WasmTool,
|
||||||
|
"https://example.com".to_string(),
|
||||||
|
"local".to_string(),
|
||||||
|
);
|
||||||
|
assert!(result.auth_url().is_some());
|
||||||
|
assert!(!result.is_awaiting_token());
|
||||||
|
|
||||||
|
// Authenticated never has auth_url
|
||||||
|
let result = AuthResult::authenticated("test", ExtensionKind::WasmTool);
|
||||||
|
assert!(result.auth_url().is_none());
|
||||||
|
assert!(result.instructions().is_none());
|
||||||
|
assert!(result.setup_url().is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+15
-3
@@ -30,6 +30,9 @@ pub enum PairingStoreError {
|
|||||||
#[error("Invalid channel: {0}")]
|
#[error("Invalid channel: {0}")]
|
||||||
InvalidChannel(String),
|
InvalidChannel(String),
|
||||||
|
|
||||||
|
#[error("Invalid path: {0}")]
|
||||||
|
InvalidPath(String),
|
||||||
|
|
||||||
#[error("IO error: {0}")]
|
#[error("IO error: {0}")]
|
||||||
Io(#[from] std::io::Error),
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
@@ -224,7 +227,10 @@ impl PairingStore {
|
|||||||
meta: Option<serde_json::Value>,
|
meta: Option<serde_json::Value>,
|
||||||
) -> Result<UpsertResult, PairingStoreError> {
|
) -> Result<UpsertResult, PairingStoreError> {
|
||||||
let path = pairing_path(&self.base_dir, channel)?;
|
let path = pairing_path(&self.base_dir, channel)?;
|
||||||
fs::create_dir_all(path.parent().unwrap())?;
|
let parent = path.parent().ok_or_else(|| {
|
||||||
|
PairingStoreError::InvalidPath(format!("path has no parent: {}", path.display()))
|
||||||
|
})?;
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
|
||||||
let mut file = fs::OpenOptions::new()
|
let mut file = fs::OpenOptions::new()
|
||||||
.read(true)
|
.read(true)
|
||||||
@@ -319,7 +325,10 @@ impl PairingStore {
|
|||||||
|
|
||||||
fn record_failed_approve(&self, channel: &str) -> Result<(), PairingStoreError> {
|
fn record_failed_approve(&self, channel: &str) -> Result<(), PairingStoreError> {
|
||||||
let path = approve_attempts_path(&self.base_dir, channel)?;
|
let path = approve_attempts_path(&self.base_dir, channel)?;
|
||||||
fs::create_dir_all(path.parent().unwrap())?;
|
let parent = path.parent().ok_or_else(|| {
|
||||||
|
PairingStoreError::InvalidPath(format!("path has no parent: {}", path.display()))
|
||||||
|
})?;
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
|
||||||
// Open (or create) and lock before reading so concurrent callers
|
// Open (or create) and lock before reading so concurrent callers
|
||||||
// don't clobber each other's writes.
|
// don't clobber each other's writes.
|
||||||
@@ -462,7 +471,10 @@ impl PairingStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let path = allow_from_path(&self.base_dir, channel)?;
|
let path = allow_from_path(&self.base_dir, channel)?;
|
||||||
fs::create_dir_all(path.parent().unwrap())?;
|
let parent = path.parent().ok_or_else(|| {
|
||||||
|
PairingStoreError::InvalidPath(format!("path has no parent: {}", path.display()))
|
||||||
|
})?;
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
|
||||||
let file = fs::OpenOptions::new()
|
let file = fs::OpenOptions::new()
|
||||||
.read(true)
|
.read(true)
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ pub enum RegistryError {
|
|||||||
actual_sha256: String,
|
actual_sha256: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
#[error("Missing SHA256 checksum for '{name}' artifact. Use --build to build from source.")]
|
||||||
|
MissingChecksum { name: String },
|
||||||
|
|
||||||
#[error(
|
#[error(
|
||||||
"Source fallback unavailable for '{name}' after artifact install failed. Retry artifact download or run from a repository checkout."
|
"Source fallback unavailable for '{name}' after artifact install failed. Retry artifact download or run from a repository checkout."
|
||||||
)]
|
)]
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ const ALLOWED_ARTIFACT_HOSTS: &[&str] = &[
|
|||||||
];
|
];
|
||||||
|
|
||||||
fn should_attempt_source_fallback(err: &RegistryError) -> bool {
|
fn should_attempt_source_fallback(err: &RegistryError) -> bool {
|
||||||
|
// MissingChecksum is intentionally allowed here — it's a bootstrapping issue
|
||||||
|
// (no release has populated checksums yet), not a security concern. Source
|
||||||
|
// builds use local trusted code. ChecksumMismatch (tampered artifact) and
|
||||||
|
// InvalidManifest (structural problem) remain blocked.
|
||||||
!matches!(
|
!matches!(
|
||||||
err,
|
err,
|
||||||
RegistryError::AlreadyInstalled { .. }
|
RegistryError::AlreadyInstalled { .. }
|
||||||
@@ -367,15 +371,15 @@ impl RegistryInstaller {
|
|||||||
|
|
||||||
// Require SHA256 — refuse to install unverified binaries. Check before
|
// Require SHA256 — refuse to install unverified binaries. Check before
|
||||||
// downloading to avoid wasting bandwidth on manifests that are missing
|
// downloading to avoid wasting bandwidth on manifests that are missing
|
||||||
// checksums.
|
// checksums. Uses MissingChecksum (not InvalidManifest) so that
|
||||||
|
// install_with_source_fallback can fall back to building from source
|
||||||
|
// when checksums haven't been populated yet (bootstrapping).
|
||||||
let expected_sha =
|
let expected_sha =
|
||||||
artifact
|
artifact
|
||||||
.sha256
|
.sha256
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| RegistryError::InvalidManifest {
|
.ok_or_else(|| RegistryError::MissingChecksum {
|
||||||
name: manifest.name.clone(),
|
name: manifest.name.clone(),
|
||||||
field: "artifacts.wasm32-wasip2.sha256",
|
|
||||||
reason: "sha256 is required for artifact downloads".to_string(),
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let target_dir = match manifest.kind {
|
let target_dir = match manifest.kind {
|
||||||
@@ -500,7 +504,7 @@ impl RegistryInstaller {
|
|||||||
if prefer_build || !has_artifact {
|
if prefer_build || !has_artifact {
|
||||||
self.install_from_source(manifest, force).await
|
self.install_from_source(manifest, force).await
|
||||||
} else {
|
} else {
|
||||||
self.install_from_artifact(manifest, force).await
|
self.install_with_source_fallback(manifest, force).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -905,9 +909,8 @@ mod tests {
|
|||||||
|
|
||||||
let result = installer.install_from_artifact(&manifest, false).await;
|
let result = installer.install_from_artifact(&manifest, false).await;
|
||||||
match result {
|
match result {
|
||||||
Err(RegistryError::InvalidManifest { field, reason, .. }) => {
|
Err(RegistryError::MissingChecksum { name }) => {
|
||||||
assert_eq!(field, "artifacts.wasm32-wasip2.sha256");
|
assert_eq!(name, "demo");
|
||||||
assert!(reason.contains("required"), "reason: {}", reason);
|
|
||||||
}
|
}
|
||||||
other => panic!("unexpected result: {:?}", other),
|
other => panic!("unexpected result: {:?}", other),
|
||||||
}
|
}
|
||||||
@@ -942,6 +945,12 @@ mod tests {
|
|||||||
reason: "host not allowed".to_string(),
|
reason: "host not allowed".to_string(),
|
||||||
};
|
};
|
||||||
assert!(!should_attempt_source_fallback(&invalid));
|
assert!(!should_attempt_source_fallback(&invalid));
|
||||||
|
|
||||||
|
// MissingChecksum SHOULD allow source fallback (bootstrapping)
|
||||||
|
let missing = RegistryError::MissingChecksum {
|
||||||
|
name: "demo".to_string(),
|
||||||
|
};
|
||||||
|
assert!(should_attempt_source_fallback(&missing));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+51
-28
@@ -63,6 +63,29 @@ pub struct ContainerRunner {
|
|||||||
proxy_port: u16,
|
proxy_port: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Append `text` into `buffer` up to `limit` bytes without breaking UTF-8.
|
||||||
|
///
|
||||||
|
/// Returns `true` when truncation occurred.
|
||||||
|
fn append_with_limit(buffer: &mut String, text: &str, limit: usize) -> bool {
|
||||||
|
if text.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if buffer.len() >= limit {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let remaining = limit - buffer.len();
|
||||||
|
if text.len() <= remaining {
|
||||||
|
buffer.push_str(text);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let end = crate::util::floor_char_boundary(text, remaining);
|
||||||
|
buffer.push_str(&text[..end]);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
impl ContainerRunner {
|
impl ContainerRunner {
|
||||||
/// Create a new container runner.
|
/// Create a new container runner.
|
||||||
pub fn new(docker: Docker, image: String, proxy_port: u16) -> Self {
|
pub fn new(docker: Docker, image: String, proxy_port: u16) -> Self {
|
||||||
@@ -393,23 +416,11 @@ impl ContainerRunner {
|
|||||||
match result {
|
match result {
|
||||||
Ok(LogOutput::StdOut { message }) => {
|
Ok(LogOutput::StdOut { message }) => {
|
||||||
let text = String::from_utf8_lossy(&message);
|
let text = String::from_utf8_lossy(&message);
|
||||||
if stdout.len() + text.len() > half_max {
|
truncated |= append_with_limit(&mut stdout, &text, half_max);
|
||||||
truncated = true;
|
|
||||||
let remaining = half_max.saturating_sub(stdout.len());
|
|
||||||
stdout.push_str(&text[..remaining.min(text.len())]);
|
|
||||||
} else {
|
|
||||||
stdout.push_str(&text);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(LogOutput::StdErr { message }) => {
|
Ok(LogOutput::StdErr { message }) => {
|
||||||
let text = String::from_utf8_lossy(&message);
|
let text = String::from_utf8_lossy(&message);
|
||||||
if stderr.len() + text.len() > half_max {
|
truncated |= append_with_limit(&mut stderr, &text, half_max);
|
||||||
truncated = true;
|
|
||||||
let remaining = half_max.saturating_sub(stderr.len());
|
|
||||||
stderr.push_str(&text[..remaining.min(text.len())]);
|
|
||||||
} else {
|
|
||||||
stderr.push_str(&text);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -439,23 +450,11 @@ impl ContainerRunner {
|
|||||||
match result {
|
match result {
|
||||||
Ok(LogOutput::StdOut { message }) => {
|
Ok(LogOutput::StdOut { message }) => {
|
||||||
let text = String::from_utf8_lossy(&message);
|
let text = String::from_utf8_lossy(&message);
|
||||||
if stdout.len() < half_max {
|
truncated |= append_with_limit(&mut stdout, &text, half_max);
|
||||||
let remaining = half_max.saturating_sub(stdout.len());
|
|
||||||
stdout.push_str(&text[..remaining.min(text.len())]);
|
|
||||||
if text.len() > remaining {
|
|
||||||
truncated = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(LogOutput::StdErr { message }) => {
|
Ok(LogOutput::StdErr { message }) => {
|
||||||
let text = String::from_utf8_lossy(&message);
|
let text = String::from_utf8_lossy(&message);
|
||||||
if stderr.len() < half_max {
|
truncated |= append_with_limit(&mut stderr, &text, half_max);
|
||||||
let remaining = half_max.saturating_sub(stderr.len());
|
|
||||||
stderr.push_str(&text[..remaining.min(text.len())]);
|
|
||||||
if text.len() > remaining {
|
|
||||||
truncated = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -577,6 +576,30 @@ fn unix_socket_candidates_from_env(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn append_with_limit_truncates_on_utf8_boundary() {
|
||||||
|
let mut out = String::new();
|
||||||
|
let truncated = append_with_limit(&mut out, "ab🙂cd", 5);
|
||||||
|
assert!(truncated);
|
||||||
|
assert_eq!(out, "ab");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn append_with_limit_marks_truncated_when_full() {
|
||||||
|
let mut out = "abc".to_string();
|
||||||
|
let truncated = append_with_limit(&mut out, "z", 3);
|
||||||
|
assert!(truncated);
|
||||||
|
assert_eq!(out, "abc");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn append_with_limit_appends_without_truncation() {
|
||||||
|
let mut out = String::new();
|
||||||
|
let truncated = append_with_limit(&mut out, "hello", 10);
|
||||||
|
assert!(!truncated);
|
||||||
|
assert_eq!(out, "hello");
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
#[test]
|
#[test]
|
||||||
fn test_unix_socket_candidates_include_rootless_paths() {
|
fn test_unix_socket_candidates_include_rootless_paths() {
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ impl Tool for ToolAuthTool {
|
|||||||
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
||||||
|
|
||||||
// Auto-activate after successful auth so tools are available immediately
|
// Auto-activate after successful auth so tools are available immediately
|
||||||
if result.status == "authenticated" {
|
if result.is_authenticated() {
|
||||||
match self.manager.activate(name).await {
|
match self.manager.activate(name).await {
|
||||||
Ok(activate_result) => {
|
Ok(activate_result) => {
|
||||||
let output = serde_json::json!({
|
let output = serde_json::json!({
|
||||||
@@ -324,7 +324,7 @@ impl Tool for ToolActivateTool {
|
|||||||
// Activation failed due to missing auth; initiate auth flow
|
// Activation failed due to missing auth; initiate auth flow
|
||||||
// so the agent loop can show the auth card.
|
// so the agent loop can show the auth card.
|
||||||
match self.manager.auth(name, None).await {
|
match self.manager.auth(name, None).await {
|
||||||
Ok(auth_result) if auth_result.status == "authenticated" => {
|
Ok(auth_result) if auth_result.is_authenticated() => {
|
||||||
// Auth succeeded (e.g. env var was set); retry activation.
|
// Auth succeeded (e.g. env var was set); retry activation.
|
||||||
let result = self
|
let result = self
|
||||||
.manager
|
.manager
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ mod memory;
|
|||||||
mod message;
|
mod message;
|
||||||
pub mod path_utils;
|
pub mod path_utils;
|
||||||
pub mod routine;
|
pub mod routine;
|
||||||
|
pub mod secrets_tools;
|
||||||
pub(crate) mod shell;
|
pub(crate) mod shell;
|
||||||
pub mod skill_tools;
|
pub mod skill_tools;
|
||||||
mod time;
|
mod time;
|
||||||
@@ -31,6 +32,7 @@ pub use message::MessageTool;
|
|||||||
pub use routine::{
|
pub use routine::{
|
||||||
RoutineCreateTool, RoutineDeleteTool, RoutineHistoryTool, RoutineListTool, RoutineUpdateTool,
|
RoutineCreateTool, RoutineDeleteTool, RoutineHistoryTool, RoutineListTool, RoutineUpdateTool,
|
||||||
};
|
};
|
||||||
|
pub use secrets_tools::{SecretDeleteTool, SecretListTool};
|
||||||
pub use shell::ShellTool;
|
pub use shell::ShellTool;
|
||||||
pub use skill_tools::{SkillInstallTool, SkillListTool, SkillRemoveTool, SkillSearchTool};
|
pub use skill_tools::{SkillInstallTool, SkillListTool, SkillRemoveTool, SkillSearchTool};
|
||||||
pub use time::TimeTool;
|
pub use time::TimeTool;
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
//! Agent-callable tools for inspecting user secrets.
|
||||||
|
//!
|
||||||
|
//! These tools allow the LLM to query and manage secrets on behalf of the
|
||||||
|
//! user. The zero-exposure model is preserved throughout:
|
||||||
|
//!
|
||||||
|
//! - `secret_list` returns only names and metadata (no values).
|
||||||
|
//! - `secret_delete` removes a secret by name.
|
||||||
|
//!
|
||||||
|
//! Storing secrets is handled via the extensions setup flow — the user types
|
||||||
|
//! values directly into the secure UI, which submits them to
|
||||||
|
//! `/api/extensions/{name}/setup`. Values never appear in the LLM conversation,
|
||||||
|
//! logs, or ActionRecords.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use crate::context::JobContext;
|
||||||
|
use crate::secrets::SecretsStore;
|
||||||
|
use crate::tools::tool::{ApprovalRequirement, Tool, ToolError, ToolOutput, require_str};
|
||||||
|
|
||||||
|
// ── secret_list ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct SecretListTool {
|
||||||
|
store: Arc<dyn SecretsStore + Send + Sync>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecretListTool {
|
||||||
|
pub fn new(store: Arc<dyn SecretsStore + Send + Sync>) -> Self {
|
||||||
|
Self { store }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for SecretListTool {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"secret_list"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"List all stored secrets by name. Never returns values — only names and \
|
||||||
|
optional provider metadata. Use this to check what credentials are available \
|
||||||
|
before attempting a task that requires them."
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameters_schema(&self) -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
_params: serde_json::Value,
|
||||||
|
ctx: &JobContext,
|
||||||
|
) -> Result<ToolOutput, ToolError> {
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
|
let refs = self
|
||||||
|
.store
|
||||||
|
.list(&ctx.user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
let secrets: Vec<serde_json::Value> = refs
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| {
|
||||||
|
serde_json::json!({
|
||||||
|
"name": r.name,
|
||||||
|
"provider": r.provider,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let count = secrets.len();
|
||||||
|
let output = serde_json::json!({
|
||||||
|
"secrets": secrets,
|
||||||
|
"count": count,
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(ToolOutput::success(output, start.elapsed()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── secret_delete ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct SecretDeleteTool {
|
||||||
|
store: Arc<dyn SecretsStore + Send + Sync>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecretDeleteTool {
|
||||||
|
pub fn new(store: Arc<dyn SecretsStore + Send + Sync>) -> Self {
|
||||||
|
Self { store }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for SecretDeleteTool {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"secret_delete"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"Permanently delete a stored secret by name. This cannot be undone."
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameters_schema(&self) -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Name of the secret to delete."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["name"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
params: serde_json::Value,
|
||||||
|
ctx: &JobContext,
|
||||||
|
) -> Result<ToolOutput, ToolError> {
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
|
let name = require_str(¶ms, "name")?;
|
||||||
|
|
||||||
|
let deleted = self
|
||||||
|
.store
|
||||||
|
.delete(&ctx.user_id, name)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
let output = if deleted {
|
||||||
|
serde_json::json!({
|
||||||
|
"status": "deleted",
|
||||||
|
"name": name,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
serde_json::json!({
|
||||||
|
"status": "not_found",
|
||||||
|
"name": name,
|
||||||
|
"message": format!("No secret named '{}' found.", name),
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ToolOutput::success(output, start.elapsed()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
|
||||||
|
ApprovalRequirement::UnlessAutoApproved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use secrecy::SecretString;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::context::JobContext;
|
||||||
|
use crate::secrets::{CreateSecretParams, InMemorySecretsStore, SecretsCrypto};
|
||||||
|
|
||||||
|
fn test_store() -> Arc<InMemorySecretsStore> {
|
||||||
|
let key = "0123456789abcdef0123456789abcdef";
|
||||||
|
let crypto = Arc::new(SecretsCrypto::new(SecretString::from(key.to_string())).unwrap());
|
||||||
|
Arc::new(InMemorySecretsStore::new(crypto))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_ctx() -> JobContext {
|
||||||
|
JobContext::new("test", "test job")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_secret_list() {
|
||||||
|
let store = test_store();
|
||||||
|
let list = SecretListTool::new(Arc::clone(&store) as Arc<dyn SecretsStore + Send + Sync>);
|
||||||
|
let ctx = test_ctx();
|
||||||
|
|
||||||
|
store
|
||||||
|
.create(
|
||||||
|
&ctx.user_id,
|
||||||
|
CreateSecretParams::new("openai_key", "sk-test"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let list_result = list.execute(serde_json::json!({}), &ctx).await.unwrap();
|
||||||
|
assert_eq!(list_result.result["count"], 1);
|
||||||
|
assert_eq!(list_result.result["secrets"][0]["name"], "openai_key");
|
||||||
|
assert!(list_result.result["secrets"][0].get("value").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_secret_delete() {
|
||||||
|
let store = test_store();
|
||||||
|
let delete =
|
||||||
|
SecretDeleteTool::new(Arc::clone(&store) as Arc<dyn SecretsStore + Send + Sync>);
|
||||||
|
let ctx = test_ctx();
|
||||||
|
|
||||||
|
store
|
||||||
|
.create(&ctx.user_id, CreateSecretParams::new("to_delete", "secret"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = delete
|
||||||
|
.execute(serde_json::json!({"name": "to_delete"}), &ctx)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result.result["status"], "deleted");
|
||||||
|
|
||||||
|
// Deleting again returns not_found
|
||||||
|
let result2 = delete
|
||||||
|
.execute(serde_json::json!({"name": "to_delete"}), &ctx)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result2.result["status"], "not_found");
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -26,5 +26,5 @@ pub use rate_limiter::RateLimiter;
|
|||||||
pub use registry::ToolRegistry;
|
pub use registry::ToolRegistry;
|
||||||
pub use tool::{
|
pub use tool::{
|
||||||
ApprovalRequirement, Tool, ToolDomain, ToolError, ToolOutput, ToolRateLimitConfig,
|
ApprovalRequirement, Tool, ToolDomain, ToolError, ToolOutput, ToolRateLimitConfig,
|
||||||
validate_tool_schema,
|
redact_params, validate_tool_schema,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -346,6 +346,20 @@ impl ToolRegistry {
|
|||||||
tracing::info!("Registered {} job management tools", job_tool_count);
|
tracing::info!("Registered {} job management tools", job_tool_count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Register secret management tools (list, delete).
|
||||||
|
///
|
||||||
|
/// These allow the LLM to persist API keys and tokens encrypted in the database.
|
||||||
|
/// Values are never returned to the LLM; only names and metadata are exposed.
|
||||||
|
pub fn register_secrets_tools(
|
||||||
|
&self,
|
||||||
|
store: Arc<dyn crate::secrets::SecretsStore + Send + Sync>,
|
||||||
|
) {
|
||||||
|
use crate::tools::builtin::{SecretDeleteTool, SecretListTool};
|
||||||
|
self.register_sync(Arc::new(SecretListTool::new(Arc::clone(&store))));
|
||||||
|
self.register_sync(Arc::new(SecretDeleteTool::new(store)));
|
||||||
|
tracing::info!("Registered 2 secret management tools (list, delete)");
|
||||||
|
}
|
||||||
|
|
||||||
/// Register extension management tools (search, install, auth, activate, list, remove).
|
/// Register extension management tools (search, install, auth, activate, list, remove).
|
||||||
///
|
///
|
||||||
/// These allow the LLM to manage MCP servers and WASM tools through conversation.
|
/// These allow the LLM to manage MCP servers and WASM tools through conversation.
|
||||||
|
|||||||
@@ -239,6 +239,23 @@ pub trait Tool: Send + Sync {
|
|||||||
ToolDomain::Orchestrator
|
ToolDomain::Orchestrator
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parameter names whose values must be redacted before logging, hooks, and approvals.
|
||||||
|
///
|
||||||
|
/// The agent framework replaces these parameter values with `"[REDACTED]"` before:
|
||||||
|
/// - Writing to debug logs
|
||||||
|
/// - Storing in `ActionRecord` (in-memory job history)
|
||||||
|
/// - Recording in `TurnToolCall` (session state)
|
||||||
|
/// - Sending to `BeforeToolCall` hooks
|
||||||
|
/// - Displaying in the approval UI
|
||||||
|
///
|
||||||
|
/// **The `execute()` method still receives the original, unredacted parameters.**
|
||||||
|
/// Redaction only applies to the observability and audit paths, not execution.
|
||||||
|
///
|
||||||
|
/// Use this for tools that accept plaintext secrets as parameters (e.g. `secret_save`).
|
||||||
|
fn sensitive_params(&self) -> &[&str] {
|
||||||
|
&[]
|
||||||
|
}
|
||||||
|
|
||||||
/// Per-invocation rate limit for this tool.
|
/// Per-invocation rate limit for this tool.
|
||||||
///
|
///
|
||||||
/// Return `Some(config)` to throttle how often this tool can be called per user.
|
/// Return `Some(config)` to throttle how often this tool can be called per user.
|
||||||
@@ -287,6 +304,33 @@ pub fn require_param<'a>(
|
|||||||
.ok_or_else(|| ToolError::InvalidParameters(format!("missing '{}' parameter", name)))
|
.ok_or_else(|| ToolError::InvalidParameters(format!("missing '{}' parameter", name)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replace sensitive parameter values with `"[REDACTED]"`.
|
||||||
|
///
|
||||||
|
/// Returns a new JSON value with the specified keys replaced. Non-object params
|
||||||
|
/// and unknown keys are passed through unchanged. The original value is cloned
|
||||||
|
/// only if there are sensitive params to redact; otherwise it is cloned once
|
||||||
|
/// (cheap — callers own the result).
|
||||||
|
///
|
||||||
|
/// Used by the agent framework before logging, hook dispatch, approval display,
|
||||||
|
/// and `ActionRecord` storage so plaintext secrets never reach those paths.
|
||||||
|
pub fn redact_params(params: &serde_json::Value, sensitive: &[&str]) -> serde_json::Value {
|
||||||
|
if sensitive.is_empty() {
|
||||||
|
return params.clone();
|
||||||
|
}
|
||||||
|
let mut redacted = params.clone();
|
||||||
|
if let Some(obj) = redacted.as_object_mut() {
|
||||||
|
for key in sensitive {
|
||||||
|
if obj.contains_key(*key) {
|
||||||
|
obj.insert(
|
||||||
|
(*key).to_string(),
|
||||||
|
serde_json::Value::String("[REDACTED]".into()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
redacted
|
||||||
|
}
|
||||||
|
|
||||||
/// Lenient runtime validation of a tool's `parameters_schema()`.
|
/// Lenient runtime validation of a tool's `parameters_schema()`.
|
||||||
///
|
///
|
||||||
/// Use this function at tool-registration time to catch structural mistakes
|
/// Use this function at tool-registration time to catch structural mistakes
|
||||||
@@ -500,6 +544,37 @@ mod tests {
|
|||||||
assert!(ApprovalRequirement::Always.is_required());
|
assert!(ApprovalRequirement::Always.is_required());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_redact_params_replaces_sensitive_key() {
|
||||||
|
let params = serde_json::json!({"name": "openai_key", "value": "sk-secret"});
|
||||||
|
let redacted = redact_params(¶ms, &["value"]);
|
||||||
|
assert_eq!(redacted["name"], "openai_key");
|
||||||
|
assert_eq!(redacted["value"], "[REDACTED]");
|
||||||
|
// Original unchanged
|
||||||
|
assert_eq!(params["value"], "sk-secret");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_redact_params_empty_sensitive_is_noop() {
|
||||||
|
let params = serde_json::json!({"name": "key", "value": "secret"});
|
||||||
|
let redacted = redact_params(¶ms, &[]);
|
||||||
|
assert_eq!(redacted, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_redact_params_missing_key_is_noop() {
|
||||||
|
let params = serde_json::json!({"name": "key"});
|
||||||
|
let redacted = redact_params(¶ms, &["value"]);
|
||||||
|
assert_eq!(redacted, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_redact_params_non_object_is_passthrough() {
|
||||||
|
let params = serde_json::json!("just a string");
|
||||||
|
let redacted = redact_params(¶ms, &["value"]);
|
||||||
|
assert_eq!(redacted, params);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_validate_schema_valid() {
|
fn test_validate_schema_valid() {
|
||||||
let schema = serde_json::json!({
|
let schema = serde_json::json!({
|
||||||
|
|||||||
@@ -105,6 +105,59 @@ impl CapabilitiesFile {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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, name: &str) {
|
||||||
|
const MIN_PROMPT_LENGTH: usize = 30;
|
||||||
|
|
||||||
|
// setup.required_secrets present but no auth section → auth card won't display
|
||||||
|
if let Some(setup) = &self.setup {
|
||||||
|
if !setup.required_secrets.is_empty() && self.auth.is_none() {
|
||||||
|
tracing::warn!(
|
||||||
|
tool = name,
|
||||||
|
"setup.required_secrets defined but no 'auth' section — \
|
||||||
|
chat-based auth card will not display for this tool"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for short prompts
|
||||||
|
for secret in &setup.required_secrets {
|
||||||
|
if secret.prompt.len() < MIN_PROMPT_LENGTH {
|
||||||
|
tracing::warn!(
|
||||||
|
tool = 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual auth (no OAuth) checks
|
||||||
|
if let Some(auth) = &self.auth
|
||||||
|
&& auth.oauth.is_none()
|
||||||
|
{
|
||||||
|
if auth.setup_url.is_none() {
|
||||||
|
tracing::warn!(
|
||||||
|
tool = name,
|
||||||
|
"auth section has no OAuth and no setup_url — \
|
||||||
|
user has no link to obtain credentials"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if auth.instructions.is_none() {
|
||||||
|
tracing::warn!(
|
||||||
|
tool = name,
|
||||||
|
"auth section has no OAuth and no instructions — \
|
||||||
|
user has no guidance on how to obtain credentials"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert to runtime Capabilities.
|
/// Convert to runtime Capabilities.
|
||||||
pub fn to_capabilities(&self) -> Capabilities {
|
pub fn to_capabilities(&self) -> Capabilities {
|
||||||
let mut caps = Capabilities::default();
|
let mut caps = Capabilities::default();
|
||||||
@@ -1056,6 +1109,60 @@ mod tests {
|
|||||||
assert_eq!(caps.setup.unwrap().required_secrets[0].name, "my_secret");
|
assert_eq!(caps.setup.unwrap().required_secrets[0].name, "my_secret");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_setup_without_auth_warns() {
|
||||||
|
// setup.required_secrets with no auth section — should not panic
|
||||||
|
let json = r#"{
|
||||||
|
"setup": {
|
||||||
|
"required_secrets": [
|
||||||
|
{ "name": "api_key", "prompt": "Enter your API key from the provider dashboard settings page" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let caps = CapabilitiesFile::from_json(json).unwrap();
|
||||||
|
// Should not panic; warning is emitted via tracing
|
||||||
|
caps.validate("test-tool");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_manual_auth_missing_fields() {
|
||||||
|
// auth without OAuth, missing setup_url and instructions
|
||||||
|
let json = r#"{
|
||||||
|
"auth": {
|
||||||
|
"secret_name": "my_api_key"
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let caps = CapabilitiesFile::from_json(json).unwrap();
|
||||||
|
// Should not panic; warnings emitted for missing setup_url and instructions
|
||||||
|
caps.validate("test-tool");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_clean_tool() {
|
||||||
|
// Well-configured tool with auth, setup_url, instructions, and good prompts
|
||||||
|
let json = r#"{
|
||||||
|
"auth": {
|
||||||
|
"secret_name": "my_api_key",
|
||||||
|
"setup_url": "https://example.com/api-keys",
|
||||||
|
"instructions": "Go to example.com/api-keys and create a new key"
|
||||||
|
},
|
||||||
|
"setup": {
|
||||||
|
"required_secrets": [
|
||||||
|
{
|
||||||
|
"name": "my_api_key",
|
||||||
|
"prompt": "Enter your API key from https://example.com/api-keys"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let caps = CapabilitiesFile::from_json(json).unwrap();
|
||||||
|
// Should not panic and emits no warnings (has auth, setup_url, instructions, long prompt)
|
||||||
|
caps.validate("clean-tool");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_resolve_nested_empty_capabilities_noop() {
|
fn test_resolve_nested_empty_capabilities_noop() {
|
||||||
// Empty inner capabilities should not clobber outer http
|
// Empty inner capabilities should not clobber outer http
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ impl WasmToolLoader {
|
|||||||
let cap_bytes = fs::read(cap_path).await?;
|
let cap_bytes = fs::read(cap_path).await?;
|
||||||
let cap_file = CapabilitiesFile::from_bytes(&cap_bytes)
|
let cap_file = CapabilitiesFile::from_bytes(&cap_bytes)
|
||||||
.map_err(|e| WasmLoadError::InvalidCapabilities(e.to_string()))?;
|
.map_err(|e| WasmLoadError::InvalidCapabilities(e.to_string()))?;
|
||||||
|
cap_file.validate(name);
|
||||||
let caps = cap_file.to_capabilities();
|
let caps = cap_file.to_capabilities();
|
||||||
let oauth = resolve_oauth_refresh_config(&cap_file);
|
let oauth = resolve_oauth_refresh_config(&cap_file);
|
||||||
(caps, oauth)
|
(caps, oauth)
|
||||||
|
|||||||
+146
-1
@@ -592,6 +592,10 @@ impl WasmToolWrapper {
|
|||||||
let instance = SandboxedTool::instantiate(&mut store, &component, &linker)
|
let instance = SandboxedTool::instantiate(&mut store, &component, &linker)
|
||||||
.map_err(|e| WasmError::InstantiationFailed(e.to_string()))?;
|
.map_err(|e| WasmError::InstantiationFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
// Coerce string-encoded values to their schema-declared types.
|
||||||
|
// LLMs frequently pass numeric values as strings (e.g. "5" instead of 5).
|
||||||
|
let params = coerce_params_to_schema(params, &self.schema);
|
||||||
|
|
||||||
// Prepare the request
|
// Prepare the request
|
||||||
let params_json = serde_json::to_string(¶ms)
|
let params_json = serde_json::to_string(¶ms)
|
||||||
.map_err(|e| WasmError::InvalidResponseJson(e.to_string()))?;
|
.map_err(|e| WasmError::InvalidResponseJson(e.to_string()))?;
|
||||||
@@ -652,10 +656,17 @@ impl Tool for WasmToolWrapper {
|
|||||||
// Pre-resolve host credentials from secrets store (async, before blocking task).
|
// Pre-resolve host credentials from secrets store (async, before blocking task).
|
||||||
// This decrypts the secrets once so the sync http_request() host function
|
// This decrypts the secrets once so the sync http_request() host function
|
||||||
// can inject them without needing async access.
|
// can inject them without needing async access.
|
||||||
|
//
|
||||||
|
// BUG FIX: ExtensionManager stores OAuth tokens under user_id "default"
|
||||||
|
// (hardcoded at construction in app.rs), but this was previously looking
|
||||||
|
// them up under ctx.user_id — which could be a Telegram user ID, web
|
||||||
|
// gateway user, etc. — causing credential resolution to silently fail.
|
||||||
|
// Must match the storage key until per-user credential isolation is added.
|
||||||
|
let credential_user_id = "default";
|
||||||
let host_credentials = resolve_host_credentials(
|
let host_credentials = resolve_host_credentials(
|
||||||
&self.capabilities,
|
&self.capabilities,
|
||||||
self.secrets_store.as_deref(),
|
self.secrets_store.as_deref(),
|
||||||
&ctx.user_id,
|
credential_user_id,
|
||||||
self.oauth_refresh.as_ref(),
|
self.oauth_refresh.as_ref(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -1083,6 +1094,61 @@ fn is_private_ip(ip: std::net::IpAddr) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Coerce parameter values to match their JSON Schema-declared types.
|
||||||
|
///
|
||||||
|
/// LLMs frequently send numeric values as strings (e.g. `"5"` instead of `5`)
|
||||||
|
/// or booleans as strings (`"true"` instead of `true`). This walks the params
|
||||||
|
/// object and converts string values where the schema expects a different type.
|
||||||
|
fn coerce_params_to_schema(
|
||||||
|
mut params: serde_json::Value,
|
||||||
|
schema: &serde_json::Value,
|
||||||
|
) -> serde_json::Value {
|
||||||
|
let properties = schema.get("properties").and_then(|p| p.as_object());
|
||||||
|
|
||||||
|
let properties = match properties {
|
||||||
|
Some(p) => p,
|
||||||
|
None => return params,
|
||||||
|
};
|
||||||
|
|
||||||
|
let obj = match params.as_object_mut() {
|
||||||
|
Some(o) => o,
|
||||||
|
None => return params,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (key, prop_schema) in properties {
|
||||||
|
let declared_type = prop_schema.get("type").and_then(|t| t.as_str());
|
||||||
|
let declared_type = match declared_type {
|
||||||
|
Some(t) => t,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(current_value) = obj.get_mut(key)
|
||||||
|
&& let Some(s) = current_value.as_str()
|
||||||
|
{
|
||||||
|
if declared_type == "string" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let coerced = match declared_type {
|
||||||
|
"number" => s.parse::<f64>().ok().map(serde_json::Value::from),
|
||||||
|
"integer" => s.parse::<i64>().ok().map(serde_json::Value::from),
|
||||||
|
"boolean" => match s.to_lowercase().as_str() {
|
||||||
|
"true" => Some(serde_json::json!(true)),
|
||||||
|
"false" => Some(serde_json::json!(false)),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(new_val) = coerced {
|
||||||
|
*current_value = new_val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
params
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -1588,4 +1654,83 @@ mod tests {
|
|||||||
let result = super::reject_private_ip("https://8.8.8.8/dns-query");
|
let result = super::reject_private_ip("https://8.8.8.8/dns-query");
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_coerce_params_string_to_number() {
|
||||||
|
let schema = serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"count": { "type": "number" },
|
||||||
|
"name": { "type": "string" }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let params = serde_json::json!({"count": "5", "name": "test"});
|
||||||
|
let result = super::coerce_params_to_schema(params, &schema);
|
||||||
|
assert_eq!(result["count"], serde_json::json!(5.0));
|
||||||
|
assert_eq!(result["name"], serde_json::json!("test"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_coerce_params_string_to_integer() {
|
||||||
|
let schema = serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"limit": { "type": "integer" }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let params = serde_json::json!({"limit": "10"});
|
||||||
|
let result = super::coerce_params_to_schema(params, &schema);
|
||||||
|
assert_eq!(result["limit"], serde_json::json!(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_coerce_params_string_to_boolean() {
|
||||||
|
let schema = serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"a": { "type": "boolean" },
|
||||||
|
"b": { "type": "boolean" },
|
||||||
|
"c": { "type": "boolean" },
|
||||||
|
"d": { "type": "boolean" }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let params = serde_json::json!({
|
||||||
|
"a": "true",
|
||||||
|
"b": "false",
|
||||||
|
"c": "True",
|
||||||
|
"d": "FALSE"
|
||||||
|
});
|
||||||
|
let result = super::coerce_params_to_schema(params, &schema);
|
||||||
|
assert_eq!(result["a"], serde_json::json!(true));
|
||||||
|
assert_eq!(result["b"], serde_json::json!(false));
|
||||||
|
assert_eq!(result["c"], serde_json::json!(true));
|
||||||
|
assert_eq!(result["d"], serde_json::json!(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_coerce_params_already_correct_type() {
|
||||||
|
let schema = serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"count": { "type": "number" }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let params = serde_json::json!({"count": 5});
|
||||||
|
let result = super::coerce_params_to_schema(params, &schema);
|
||||||
|
assert_eq!(result["count"], serde_json::json!(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_coerce_params_invalid_string_not_coerced() {
|
||||||
|
let schema = serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"count": { "type": "number" }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let params = serde_json::json!({"count": "not-a-number"});
|
||||||
|
let result = super::coerce_params_to_schema(params, &schema);
|
||||||
|
// Should remain as string since it can't be parsed
|
||||||
|
assert_eq!(result["count"], serde_json::json!("not-a-number"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-2
@@ -98,6 +98,13 @@ async def ironclaw_server(ironclaw_binary, mock_llm_server):
|
|||||||
# Prevent onboarding wizard from triggering
|
# Prevent onboarding wizard from triggering
|
||||||
"ONBOARD_COMPLETED": "true",
|
"ONBOARD_COMPLETED": "true",
|
||||||
}
|
}
|
||||||
|
# Forward LLVM coverage instrumentation env vars when present
|
||||||
|
# (allows cargo-llvm-cov to collect profraw data from E2E runs)
|
||||||
|
for key in ("LLVM_PROFILE_FILE", "CARGO_LLVM_COV", "CARGO_LLVM_COV_SHOW_ENV",
|
||||||
|
"CARGO_LLVM_COV_TARGET_DIR"):
|
||||||
|
val = os.environ.get(key)
|
||||||
|
if val is not None:
|
||||||
|
env[key] = val
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
ironclaw_binary, "--no-onboard",
|
ironclaw_binary, "--no-onboard",
|
||||||
stdin=asyncio.subprocess.DEVNULL,
|
stdin=asyncio.subprocess.DEVNULL,
|
||||||
@@ -126,9 +133,12 @@ async def ironclaw_server(ironclaw_binary, mock_llm_server):
|
|||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
if proc.returncode is None:
|
if proc.returncode is None:
|
||||||
proc.send_signal(signal.SIGTERM)
|
# Use SIGINT (not SIGTERM) so tokio's ctrl_c handler triggers a
|
||||||
|
# graceful shutdown. This lets the LLVM coverage runtime run its
|
||||||
|
# atexit handler and flush .profraw files for cargo-llvm-cov.
|
||||||
|
proc.send_signal(signal.SIGINT)
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(proc.wait(), timeout=5)
|
await asyncio.wait_for(proc.wait(), timeout=10)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
proc.kill()
|
proc.kill()
|
||||||
|
|
||||||
|
|||||||
@@ -312,6 +312,8 @@ async fn test_ws_multiple_events_in_sequence() {
|
|||||||
state.sse.broadcast(SseEvent::ToolCompleted {
|
state.sse.broadcast(SseEvent::ToolCompleted {
|
||||||
name: "shell".to_string(),
|
name: "shell".to_string(),
|
||||||
success: true,
|
success: true,
|
||||||
|
error: None,
|
||||||
|
parameters: None,
|
||||||
thread_id: None,
|
thread_id: None,
|
||||||
});
|
});
|
||||||
state.sse.broadcast(SseEvent::Response {
|
state.sse.broadcast(SseEvent::Response {
|
||||||
|
|||||||
@@ -34,11 +34,19 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"auth": {
|
||||||
|
"secret_name": "github_token",
|
||||||
|
"display_name": "GitHub",
|
||||||
|
"instructions": "Create a Personal Access Token at github.com/settings/tokens with repo scope, then paste it here.",
|
||||||
|
"setup_url": "https://github.com/settings/tokens",
|
||||||
|
"token_hint": "Starts with 'ghp_' or 'github_pat_'",
|
||||||
|
"env_var": "GITHUB_TOKEN"
|
||||||
|
},
|
||||||
"setup": {
|
"setup": {
|
||||||
"required_secrets": [
|
"required_secrets": [
|
||||||
{
|
{
|
||||||
"name": "github_token",
|
"name": "github_token",
|
||||||
"prompt": "GitHub Personal Access Token (from github.com/settings/tokens)"
|
"prompt": "GitHub Personal Access Token (create one at github.com/settings/tokens with 'repo' scope)"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user