mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 23:50: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:
|
||||
coverage:
|
||||
name: Coverage
|
||||
name: Coverage (${{ matrix.name }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: all-features
|
||||
flags: "--all-features"
|
||||
has_postgres: true
|
||||
- name: default
|
||||
flags: ""
|
||||
has_postgres: true
|
||||
- name: libsql-only
|
||||
flags: "--no-default-features --features libsql"
|
||||
has_postgres: false
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: ironclaw_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: llvm-tools-preview
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: coverage
|
||||
key: coverage-${{ matrix.name }}
|
||||
|
||||
- name: Install cargo-llvm-cov
|
||||
uses: taiki-e/install-action@cargo-llvm-cov
|
||||
|
||||
- name: Run database migrations
|
||||
if: matrix.has_postgres
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for f in migrations/V*.sql; do
|
||||
echo "Applying $f..."
|
||||
psql -v ON_ERROR_STOP=1 -f "$f"
|
||||
done
|
||||
env:
|
||||
PGHOST: localhost
|
||||
PGUSER: postgres
|
||||
PGPASSWORD: postgres
|
||||
PGDATABASE: ironclaw_test
|
||||
|
||||
- name: Set DATABASE_URL for postgres configs
|
||||
if: matrix.has_postgres
|
||||
run: echo "DATABASE_URL=postgres://postgres:postgres@localhost/ironclaw_test" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Generate coverage
|
||||
run: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info
|
||||
run: cargo llvm-cov ${{ matrix.flags }} --workspace --lcov --output-path lcov.info
|
||||
|
||||
- name: Upload to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
files: lcov.info
|
||||
flags: ${{ matrix.name }}
|
||||
disable_search: true
|
||||
use_oidc: true
|
||||
fail_ci_if_error: true
|
||||
|
||||
e2e-coverage:
|
||||
name: E2E Coverage
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: llvm-tools-preview
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: e2e-coverage
|
||||
|
||||
- name: Install cargo-llvm-cov
|
||||
uses: taiki-e/install-action@cargo-llvm-cov
|
||||
|
||||
- name: Set up coverage instrumentation
|
||||
run: |
|
||||
source <(cargo llvm-cov show-env --export-prefix)
|
||||
# Persist env vars for subsequent steps
|
||||
echo "RUSTFLAGS=${RUSTFLAGS}" >> "$GITHUB_ENV"
|
||||
echo "LLVM_PROFILE_FILE=${LLVM_PROFILE_FILE}" >> "$GITHUB_ENV"
|
||||
echo "CARGO_LLVM_COV=1" >> "$GITHUB_ENV"
|
||||
echo "CARGO_LLVM_COV_SHOW_ENV=1" >> "$GITHUB_ENV"
|
||||
echo "CARGO_LLVM_COV_TARGET_DIR=${CARGO_LLVM_COV_TARGET_DIR}" >> "$GITHUB_ENV"
|
||||
cargo llvm-cov clean --workspace
|
||||
|
||||
- name: Build instrumented binary
|
||||
run: cargo build --no-default-features --features libsql
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install E2E dependencies
|
||||
run: |
|
||||
cd tests/e2e
|
||||
pip install -e .
|
||||
playwright install --with-deps chromium
|
||||
|
||||
- name: Run E2E tests
|
||||
run: |
|
||||
pytest tests/e2e/ -v -x --timeout=120
|
||||
env:
|
||||
RUST_LOG: ironclaw=info
|
||||
RUST_BACKTRACE: "1"
|
||||
|
||||
- name: Verify profraw files exist
|
||||
if: always()
|
||||
run: |
|
||||
echo "LLVM_PROFILE_FILE=${LLVM_PROFILE_FILE}"
|
||||
echo "CARGO_LLVM_COV_TARGET_DIR=${CARGO_LLVM_COV_TARGET_DIR}"
|
||||
profraw_count=$(find target/ -name '*.profraw' 2>/dev/null | wc -l)
|
||||
echo "Found ${profraw_count} .profraw files under target/"
|
||||
find target/ -name '*.profraw' 2>/dev/null || true
|
||||
if [ "$profraw_count" -eq 0 ]; then
|
||||
echo "::warning::No .profraw files found — coverage report will fail"
|
||||
fi
|
||||
|
||||
- name: Generate coverage report
|
||||
if: always()
|
||||
run: cargo llvm-cov report --lcov --output-path e2e-coverage.info
|
||||
|
||||
- name: Upload to Codecov
|
||||
if: always()
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
files: e2e-coverage.info
|
||||
flags: e2e
|
||||
disable_search: true
|
||||
use_oidc: true
|
||||
fail_ci_if_error: true
|
||||
|
||||
- name: Upload screenshots on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-screenshots
|
||||
path: tests/e2e/screenshots/
|
||||
if-no-files-found: ignore
|
||||
|
||||
coverage-gate:
|
||||
name: Coverage
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
needs: [coverage, e2e-coverage]
|
||||
steps:
|
||||
- run: |
|
||||
if [[ "${{ needs.coverage.result }}" != "success" || "${{ needs.e2e-coverage.result }}" != "success" ]]; then
|
||||
echo "One or more coverage jobs failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -413,6 +413,9 @@ jobs:
|
||||
- build-wasm-extensions
|
||||
if: ${{ always() && needs.host.result == 'success' && needs.build-wasm-extensions.result == 'success' }}
|
||||
runs-on: "ubuntu-22.04"
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
@@ -445,7 +448,7 @@ jobs:
|
||||
fi
|
||||
done
|
||||
done < "$CHECKSUMS"
|
||||
- name: Commit updated manifests
|
||||
- name: Create PR with updated manifests
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
@@ -453,8 +456,15 @@ jobs:
|
||||
if git diff --cached --quiet; then
|
||||
echo "No manifest changes to commit"
|
||||
else
|
||||
BRANCH="chore/update-checksums-$(date +%s)"
|
||||
git checkout -b "$BRANCH"
|
||||
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
|
||||
|
||||
announce:
|
||||
|
||||
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.15.0](https://github.com/nearai/ironclaw/compare/v0.14.0...v0.15.0) - 2026-03-04
|
||||
|
||||
### Added
|
||||
|
||||
- *(oauth)* route callbacks through web gateway for hosted instances ([#555](https://github.com/nearai/ironclaw/pull/555))
|
||||
- *(web)* show error details for failed tool calls ([#490](https://github.com/nearai/ironclaw/pull/490))
|
||||
- *(extensions)* improve auth UX and add load-time validation ([#536](https://github.com/nearai/ironclaw/pull/536))
|
||||
- add local-test skill and Dockerfile.test for web gateway testing ([#524](https://github.com/nearai/ironclaw/pull/524))
|
||||
|
||||
### Fixed
|
||||
|
||||
- *(security)* restrict query-token auth to SSE endpoints only ([#528](https://github.com/nearai/ironclaw/pull/528))
|
||||
- *(ci)* flush profraw coverage data in E2E teardown ([#550](https://github.com/nearai/ironclaw/pull/550))
|
||||
- *(wasm)* coerce string parameters to schema-declared types ([#498](https://github.com/nearai/ironclaw/pull/498))
|
||||
- *(agent)* strip leaked [Called tool ...] text from responses ([#497](https://github.com/nearai/ironclaw/pull/497))
|
||||
- *(web)* reset job list UI on restart failure ([#499](https://github.com/nearai/ironclaw/pull/499))
|
||||
- *(security)* replace .unwrap() panics in pairing store with proper error handling ([#515](https://github.com/nearai/ironclaw/pull/515))
|
||||
|
||||
### Other
|
||||
|
||||
- Fix UTF-8 unsafe truncation in sandbox log capture ([#359](https://github.com/nearai/ironclaw/pull/359))
|
||||
- enhance coverage with feature matrix, postgres, and E2E ([#523](https://github.com/nearai/ironclaw/pull/523))
|
||||
|
||||
## [0.14.0](https://github.com/nearai/ironclaw/compare/v0.13.1...v0.14.0) - 2026-03-04
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+1
-1
@@ -2828,7 +2828,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ironclaw"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"aho-corasick",
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ exclude = [
|
||||
|
||||
[package]
|
||||
name = "ironclaw"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.92"
|
||||
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": [
|
||||
{
|
||||
"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
|
||||
},
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
],
|
||||
"setup_url": "https://discord.com/developers/applications"
|
||||
},
|
||||
"capabilities": {
|
||||
"http": {
|
||||
|
||||
@@ -6,15 +6,16 @@
|
||||
"required_secrets": [
|
||||
{
|
||||
"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
|
||||
},
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
],
|
||||
"setup_url": "https://api.slack.com/apps"
|
||||
},
|
||||
"capabilities": {
|
||||
"http": {
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"prompt": "Enter your Telegram Bot API token (from @BotFather)",
|
||||
"optional": false
|
||||
}
|
||||
]
|
||||
],
|
||||
"setup_url": "https://t.me/BotFather"
|
||||
},
|
||||
"capabilities": {
|
||||
"http": {
|
||||
@@ -39,6 +40,10 @@
|
||||
"emit_rate_limit": {
|
||||
"messages_per_minute": 100,
|
||||
"messages_per_hour": 5000
|
||||
},
|
||||
"webhook": {
|
||||
"secret_header": "X-Telegram-Bot-Api-Secret-Token",
|
||||
"secret_name": "telegram_webhook_secret"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"required_secrets": [
|
||||
{
|
||||
"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_-]+$"
|
||||
},
|
||||
{
|
||||
@@ -16,7 +16,8 @@
|
||||
"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": {
|
||||
"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::error::Error;
|
||||
use crate::llm::{ChatMessage, Reasoning, ReasoningContext, RespondResult};
|
||||
use crate::tools::redact_params;
|
||||
|
||||
/// Result of the agentic loop execution.
|
||||
pub(super) enum AgenticLoopResult {
|
||||
@@ -291,7 +292,11 @@ impl Agent {
|
||||
|
||||
match output.result {
|
||||
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 {
|
||||
tool_calls,
|
||||
@@ -317,14 +322,25 @@ impl Agent {
|
||||
)
|
||||
.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;
|
||||
if let Some(thread) = sess.threads.get_mut(&thread_id)
|
||||
&& let Some(turn) = thread.last_turn_mut()
|
||||
{
|
||||
for tc in &tool_calls {
|
||||
turn.record_tool_call(&tc.name, tc.arguments.clone());
|
||||
for (tc, safe_args) in tool_calls.iter().zip(redacted_args) {
|
||||
turn.record_tool_call(&tc.name, safe_args);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -353,11 +369,22 @@ impl Agent {
|
||||
for (idx, original_tc) in tool_calls.iter().enumerate() {
|
||||
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
|
||||
// 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 {
|
||||
tool_name: tc.name.clone(),
|
||||
parameters: tc.arguments.clone(),
|
||||
parameters: hook_params,
|
||||
user_id: message.user_id.clone(),
|
||||
context: "chat".to_string(),
|
||||
};
|
||||
@@ -384,8 +411,20 @@ impl Agent {
|
||||
}
|
||||
Ok(crate::hooks::HookOutcome::Continue {
|
||||
modified: Some(new_params),
|
||||
}) => match serde_json::from_str(&new_params) {
|
||||
Ok(parsed) => tc.arguments = parsed,
|
||||
}) => match serde_json::from_str::<serde_json::Value>(&new_params) {
|
||||
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) => {
|
||||
tracing::warn!(
|
||||
tool = %tc.name,
|
||||
@@ -400,7 +439,7 @@ impl Agent {
|
||||
// Check if tool requires approval on the final (post-hook)
|
||||
// parameters. Skipped when auto_approve_tools is set.
|
||||
if !self.config.auto_approve_tools
|
||||
&& let Some(tool) = self.tools().get(&tc.name).await
|
||||
&& let Some(tool) = tool_opt
|
||||
{
|
||||
use crate::tools::ApprovalRequirement;
|
||||
let needs_approval = match tool.requires_approval(&tc.arguments) {
|
||||
@@ -447,14 +486,17 @@ impl Agent {
|
||||
.execute_chat_tool(&tc.name, &tc.arguments, &job_ctx)
|
||||
.await;
|
||||
|
||||
let disp_tool = self.tools().get(&tc.name).await;
|
||||
let _ = self
|
||||
.channels
|
||||
.send_status(
|
||||
&message.channel,
|
||||
StatusUpdate::ToolCompleted {
|
||||
name: tc.name.clone(),
|
||||
success: result.is_ok(),
|
||||
},
|
||||
StatusUpdate::tool_completed(
|
||||
tc.name.clone(),
|
||||
&result,
|
||||
&tc.arguments,
|
||||
disp_tool.as_deref(),
|
||||
),
|
||||
&message.metadata,
|
||||
)
|
||||
.await;
|
||||
@@ -495,13 +537,16 @@ impl Agent {
|
||||
)
|
||||
.await;
|
||||
|
||||
let par_tool = tools.get(&tc.name).await;
|
||||
let _ = channels
|
||||
.send_status(
|
||||
&channel,
|
||||
StatusUpdate::ToolCompleted {
|
||||
name: tc.name.clone(),
|
||||
success: result.is_ok(),
|
||||
},
|
||||
StatusUpdate::tool_completed(
|
||||
tc.name.clone(),
|
||||
&result,
|
||||
&tc.arguments,
|
||||
par_tool.as_deref(),
|
||||
),
|
||||
&metadata,
|
||||
)
|
||||
.await;
|
||||
@@ -671,10 +716,15 @@ impl Agent {
|
||||
|
||||
// Handle approval if a tool needed it
|
||||
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 {
|
||||
request_id: Uuid::new_v4(),
|
||||
tool_name: tc.name.clone(),
|
||||
parameters: tc.arguments.clone(),
|
||||
display_parameters: display_params,
|
||||
description: tool.description().to_string(),
|
||||
tool_call_id: tc.id.clone(),
|
||||
context_messages: context_messages.clone(),
|
||||
@@ -734,9 +784,10 @@ pub(super) async fn execute_chat_tool_standalone(
|
||||
.into());
|
||||
}
|
||||
|
||||
let safe_params = redact_params(params, tool.sensitive_params());
|
||||
tracing::debug!(
|
||||
tool = %tool_name,
|
||||
params = %params,
|
||||
params = %safe_params,
|
||||
"Tool call started"
|
||||
);
|
||||
|
||||
@@ -900,6 +951,38 @@ fn compact_messages_for_retry(messages: &[ChatMessage]) -> Vec<ChatMessage> {
|
||||
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)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
@@ -1086,6 +1169,7 @@ mod tests {
|
||||
request_id: uuid::Uuid::new_v4(),
|
||||
tool_name: "shell".to_string(),
|
||||
parameters: serde_json::json!({"command": "echo hi"}),
|
||||
display_parameters: serde_json::json!({"command": "echo hi"}),
|
||||
description: "Run shell command".to_string(),
|
||||
tool_call_id: "call_1".to_string(),
|
||||
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,
|
||||
/// Tool name requiring approval.
|
||||
pub tool_name: String,
|
||||
/// Tool parameters.
|
||||
/// Tool parameters (original values, used for execution).
|
||||
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.
|
||||
pub description: String,
|
||||
/// Tool call ID from LLM (for proper context continuation).
|
||||
@@ -950,6 +954,7 @@ mod tests {
|
||||
request_id: Uuid::new_v4(),
|
||||
tool_name: "shell".to_string(),
|
||||
parameters: serde_json::json!({"command": "rm -rf /"}),
|
||||
display_parameters: serde_json::json!({"command": "rm -rf /"}),
|
||||
description: "dangerous command".to_string(),
|
||||
tool_call_id: "call_123".to_string(),
|
||||
context_messages: vec![ChatMessage::user("do it")],
|
||||
@@ -974,6 +979,7 @@ mod tests {
|
||||
request_id: Uuid::new_v4(),
|
||||
tool_name: "http".to_string(),
|
||||
parameters: serde_json::json!({}),
|
||||
display_parameters: serde_json::json!({}),
|
||||
description: "test".to_string(),
|
||||
tool_call_id: "call_456".to_string(),
|
||||
context_messages: vec![],
|
||||
|
||||
+31
-20
@@ -21,6 +21,7 @@ use crate::channels::{IncomingMessage, StatusUpdate};
|
||||
use crate::context::JobContext;
|
||||
use crate::error::Error;
|
||||
use crate::llm::ChatMessage;
|
||||
use crate::tools::redact_params;
|
||||
|
||||
impl Agent {
|
||||
/// 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 tool_name = pending.tool_name.clone();
|
||||
let description = pending.description.clone();
|
||||
let parameters = pending.parameters.clone();
|
||||
let parameters = pending.display_parameters.clone();
|
||||
thread.await_approval(pending);
|
||||
let _ = self
|
||||
.channels
|
||||
@@ -751,14 +752,17 @@ impl Agent {
|
||||
.execute_chat_tool(&pending.tool_name, &pending.parameters, &job_ctx)
|
||||
.await;
|
||||
|
||||
let tool_ref = self.tools().get(&pending.tool_name).await;
|
||||
let _ = self
|
||||
.channels
|
||||
.send_status(
|
||||
&message.channel,
|
||||
StatusUpdate::ToolCompleted {
|
||||
name: pending.tool_name.clone(),
|
||||
success: tool_result.is_ok(),
|
||||
},
|
||||
StatusUpdate::tool_completed(
|
||||
pending.tool_name.clone(),
|
||||
&tool_result,
|
||||
&pending.display_parameters,
|
||||
tool_ref.as_deref(),
|
||||
),
|
||||
&message.metadata,
|
||||
)
|
||||
.await;
|
||||
@@ -908,14 +912,17 @@ impl Agent {
|
||||
.execute_chat_tool(&tc.name, &tc.arguments, &job_ctx)
|
||||
.await;
|
||||
|
||||
let deferred_tool = self.tools().get(&tc.name).await;
|
||||
let _ = self
|
||||
.channels
|
||||
.send_status(
|
||||
&message.channel,
|
||||
StatusUpdate::ToolCompleted {
|
||||
name: tc.name.clone(),
|
||||
success: result.is_ok(),
|
||||
},
|
||||
StatusUpdate::tool_completed(
|
||||
tc.name.clone(),
|
||||
&result,
|
||||
&tc.arguments,
|
||||
deferred_tool.as_deref(),
|
||||
),
|
||||
&message.metadata,
|
||||
)
|
||||
.await;
|
||||
@@ -957,13 +964,16 @@ impl Agent {
|
||||
)
|
||||
.await;
|
||||
|
||||
let par_tool = tools.get(&tc.name).await;
|
||||
let _ = channels
|
||||
.send_status(
|
||||
&channel,
|
||||
StatusUpdate::ToolCompleted {
|
||||
name: tc.name.clone(),
|
||||
success: result.is_ok(),
|
||||
},
|
||||
StatusUpdate::tool_completed(
|
||||
tc.name.clone(),
|
||||
&result,
|
||||
&tc.arguments,
|
||||
par_tool.as_deref(),
|
||||
),
|
||||
&metadata,
|
||||
)
|
||||
.await;
|
||||
@@ -1086,6 +1096,7 @@ impl Agent {
|
||||
request_id: Uuid::new_v4(),
|
||||
tool_name: tc.name.clone(),
|
||||
parameters: tc.arguments.clone(),
|
||||
display_parameters: redact_params(&tc.arguments, tool.sensitive_params()),
|
||||
description: tool.description().to_string(),
|
||||
tool_call_id: tc.id.clone(),
|
||||
context_messages: context_messages.clone(),
|
||||
@@ -1095,7 +1106,7 @@ impl Agent {
|
||||
let request_id = new_pending.request_id;
|
||||
let tool_name = new_pending.tool_name.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;
|
||||
@@ -1162,7 +1173,7 @@ impl Agent {
|
||||
let request_id = new_pending.request_id;
|
||||
let tool_name = new_pending.tool_name.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);
|
||||
let _ = self
|
||||
.channels
|
||||
@@ -1284,7 +1295,7 @@ impl Agent {
|
||||
};
|
||||
|
||||
match ext_mgr.auth(&pending.extension_name, Some(token)).await {
|
||||
Ok(result) if result.status == "authenticated" => {
|
||||
Ok(result) if result.is_authenticated() => {
|
||||
tracing::info!(
|
||||
"Extension '{}' authenticated via auth mode",
|
||||
pending.extension_name
|
||||
@@ -1353,8 +1364,8 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
let msg = result
|
||||
.instructions
|
||||
.clone()
|
||||
.instructions()
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| "Invalid token. Please try again.".to_string());
|
||||
// Re-emit AuthRequired so web UI re-shows the card
|
||||
let _ = self
|
||||
@@ -1364,8 +1375,8 @@ impl Agent {
|
||||
StatusUpdate::AuthRequired {
|
||||
extension_name: pending.extension_name.clone(),
|
||||
instructions: Some(msg.clone()),
|
||||
auth_url: result.auth_url,
|
||||
setup_url: result.setup_url,
|
||||
auth_url: result.auth_url().map(String::from),
|
||||
setup_url: result.setup_url().map(String::from),
|
||||
},
|
||||
&message.metadata,
|
||||
)
|
||||
|
||||
+10
-6
@@ -18,8 +18,8 @@ use crate::llm::{
|
||||
ActionPlan, ChatMessage, LlmProvider, Reasoning, ReasoningContext, RespondResult, ToolSelection,
|
||||
};
|
||||
use crate::safety::SafetyLayer;
|
||||
use crate::tools::ToolRegistry;
|
||||
use crate::tools::rate_limiter::RateLimitResult;
|
||||
use crate::tools::{ToolRegistry, redact_params};
|
||||
|
||||
/// 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
|
||||
let params = {
|
||||
use crate::hooks::{HookError, HookEvent, HookOutcome};
|
||||
let hook_params = redact_params(params, tool.sensitive_params());
|
||||
let event = HookEvent::ToolCall {
|
||||
tool_name: tool_name.to_string(),
|
||||
parameters: params.clone(),
|
||||
parameters: hook_params,
|
||||
user_id: job_ctx.user_id.clone(),
|
||||
context: format!("job:{}", job_id),
|
||||
};
|
||||
@@ -758,9 +759,12 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
.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!(
|
||||
tool = %tool_name,
|
||||
params = %params,
|
||||
params = %safe_params,
|
||||
job = %job_id,
|
||||
"Tool call started"
|
||||
);
|
||||
@@ -812,7 +816,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
match deps
|
||||
.context_manager
|
||||
.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.result.clone(),
|
||||
elapsed,
|
||||
@@ -834,7 +838,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
.context_manager
|
||||
.update_memory(job_id, |mem| {
|
||||
let rec = mem
|
||||
.create_action(tool_name, params.clone())
|
||||
.create_action(tool_name, safe_params.clone())
|
||||
.fail(e.to_string(), elapsed);
|
||||
mem.record_action(rec.clone());
|
||||
rec
|
||||
@@ -853,7 +857,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
.context_manager
|
||||
.update_memory(job_id, |mem| {
|
||||
let rec = mem
|
||||
.create_action(tool_name, params.clone())
|
||||
.create_action(tool_name, safe_params.clone())
|
||||
.fail("Execution timeout", elapsed);
|
||||
mem.record_action(rec.clone());
|
||||
rec
|
||||
|
||||
@@ -331,6 +331,10 @@ impl AppBuilder {
|
||||
};
|
||||
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
|
||||
let embeddings = self
|
||||
.config
|
||||
|
||||
+174
-1
@@ -117,7 +117,20 @@ pub enum StatusUpdate {
|
||||
/// Tool execution started.
|
||||
ToolStarted { name: String },
|
||||
/// 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.
|
||||
ToolResult { name: String, preview: String },
|
||||
/// 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.
|
||||
///
|
||||
/// Channels receive messages from external sources and convert them to
|
||||
@@ -223,3 +268,131 @@ pub trait Channel: Send + Sync {
|
||||
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 } => {
|
||||
eprintln!(" \x1b[33m\u{25CB} {name}\x1b[0m");
|
||||
}
|
||||
StatusUpdate::ToolCompleted { name, success } => {
|
||||
StatusUpdate::ToolCompleted { name, success, .. } => {
|
||||
if success {
|
||||
eprintln!(" \x1b[32m\u{25CF} {name}\x1b[0m");
|
||||
} else {
|
||||
|
||||
@@ -974,7 +974,7 @@ impl Channel for SignalChannel {
|
||||
|
||||
// Send tool completed notification (debug mode only)
|
||||
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 (icon, color) = if *success {
|
||||
|
||||
@@ -81,6 +81,7 @@ impl WasmChannelLoader {
|
||||
let cap_bytes = fs::read(cap_path).await?;
|
||||
let cap_file = ChannelCapabilitiesFile::from_bytes(&cap_bytes)
|
||||
.map_err(|e| WasmChannelError::InvalidCapabilities(e.to_string()))?;
|
||||
cap_file.validate();
|
||||
|
||||
// Debug: log raw capabilities
|
||||
tracing::debug!(
|
||||
|
||||
@@ -90,6 +90,37 @@ impl ChannelCapabilitiesFile {
|
||||
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.
|
||||
pub fn to_capabilities(&self) -> ChannelCapabilities {
|
||||
self.capabilities.to_channel_capabilities(&self.name)
|
||||
@@ -262,6 +293,10 @@ pub struct SetupSchema {
|
||||
/// Placeholders like {secret_name} are replaced with actual values.
|
||||
#[serde(default)]
|
||||
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.
|
||||
@@ -605,6 +640,65 @@ mod tests {
|
||||
|
||||
// ── Category 5: Discord Capabilities Setup & Configuration ──────────
|
||||
|
||||
#[test]
|
||||
fn test_validate_channel_short_prompt() {
|
||||
// prompt < 30 chars — should not panic
|
||||
let json = r#"{
|
||||
"name": "test-channel",
|
||||
"setup": {
|
||||
"required_secrets": [
|
||||
{ "name": "bot_token", "prompt": "Bot token" }
|
||||
],
|
||||
"setup_url": "https://example.com"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
|
||||
// Should not panic; warning emitted for short prompt
|
||||
file.validate();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_channel_missing_setup_url() {
|
||||
// required_secrets without setup_url — should not panic
|
||||
let json = r#"{
|
||||
"name": "test-channel",
|
||||
"setup": {
|
||||
"required_secrets": [
|
||||
{
|
||||
"name": "bot_token",
|
||||
"prompt": "Enter your bot token from the developer portal settings"
|
||||
}
|
||||
]
|
||||
}
|
||||
}"#;
|
||||
|
||||
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
|
||||
// Should not panic; warning emitted for missing setup_url
|
||||
file.validate();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_clean_channel() {
|
||||
// Well-configured channel — should not panic or warn
|
||||
let json = r#"{
|
||||
"name": "good-channel",
|
||||
"setup": {
|
||||
"required_secrets": [
|
||||
{
|
||||
"name": "bot_token",
|
||||
"prompt": "Enter your bot token from https://example.com/bot-settings"
|
||||
}
|
||||
],
|
||||
"setup_url": "https://example.com/bot-settings"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
|
||||
// Should not panic and emits no warnings
|
||||
file.validate();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discord_capabilities_has_public_key_secret() {
|
||||
let json = include_str!("../../../channels-src/discord/discord.capabilities.json");
|
||||
|
||||
@@ -2479,7 +2479,7 @@ fn status_to_wit(status: &StatusUpdate, metadata: &serde_json::Value) -> wit_cha
|
||||
message: format!("Tool started: {}", name),
|
||||
metadata_json,
|
||||
},
|
||||
StatusUpdate::ToolCompleted { name, success } => wit_channel::StatusUpdate {
|
||||
StatusUpdate::ToolCompleted { name, success, .. } => wit_channel::StatusUpdate {
|
||||
status: wit_channel::StatusType::ToolCompleted,
|
||||
message: format!(
|
||||
"Tool completed: {} ({})",
|
||||
@@ -3387,6 +3387,8 @@ mod tests {
|
||||
&crate::channels::StatusUpdate::ToolCompleted {
|
||||
name: "http_request".to_string(),
|
||||
success: true,
|
||||
error: None,
|
||||
parameters: None,
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
@@ -3407,6 +3409,8 @@ mod tests {
|
||||
&crate::channels::StatusUpdate::ToolCompleted {
|
||||
name: "http_request".to_string(),
|
||||
success: false,
|
||||
error: Some("connection refused".to_string()),
|
||||
parameters: None,
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
|
||||
+148
-30
@@ -2,7 +2,7 @@
|
||||
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
http::{HeaderMap, Method, StatusCode},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
@@ -14,10 +14,44 @@ pub struct AuthState {
|
||||
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.
|
||||
///
|
||||
/// 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(
|
||||
State(auth): State<AuthState>,
|
||||
headers: HeaderMap,
|
||||
@@ -35,15 +69,12 @@ pub async fn auth_middleware(
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
// Fall back to query parameter for SSE EventSource (constant-time comparison)
|
||||
if let Some(query) = request.uri().query() {
|
||||
for pair in query.split('&') {
|
||||
if let Some(token) = pair.strip_prefix("token=")
|
||||
&& bool::from(token.as_bytes().ct_eq(auth.token.as_bytes()))
|
||||
{
|
||||
return next.run(request).await;
|
||||
}
|
||||
}
|
||||
// Fall back to query parameter, but only for SSE endpoints (constant-time comparison).
|
||||
if allows_query_token_auth(&request)
|
||||
&& let Some(token) = query_token(&request)
|
||||
&& bool::from(token.as_bytes().ct_eq(auth.token.as_bytes()))
|
||||
{
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
(StatusCode::UNAUTHORIZED, "Invalid or missing auth token").into_response()
|
||||
@@ -62,24 +93,28 @@ mod tests {
|
||||
assert_eq!(cloned.token, "test-token");
|
||||
}
|
||||
|
||||
// === QA Plan - Web gateway auth tests ===
|
||||
|
||||
use axum::Router;
|
||||
use axum::body::Body;
|
||||
use axum::middleware;
|
||||
use axum::routing::get;
|
||||
use axum::routing::{get, post};
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn dummy_handler() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
/// Router with streaming endpoints (query auth allowed) and regular
|
||||
/// endpoints (query auth rejected).
|
||||
fn test_app(token: &str) -> Router {
|
||||
let state = AuthState {
|
||||
token: token.to_string(),
|
||||
};
|
||||
Router::new()
|
||||
.route("/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))
|
||||
}
|
||||
|
||||
@@ -87,7 +122,7 @@ mod tests {
|
||||
async fn test_valid_bearer_token_passes() {
|
||||
let app = test_app("secret-token");
|
||||
let req = Request::builder()
|
||||
.uri("/test")
|
||||
.uri("/api/chat/events")
|
||||
.header("Authorization", "Bearer secret-token")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
@@ -99,7 +134,7 @@ mod tests {
|
||||
async fn test_invalid_bearer_token_rejected() {
|
||||
let app = test_app("secret-token");
|
||||
let req = Request::builder()
|
||||
.uri("/test")
|
||||
.uri("/api/chat/events")
|
||||
.header("Authorization", "Bearer wrong-token")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
@@ -108,10 +143,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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 req = Request::builder()
|
||||
.uri("/test?token=secret-token")
|
||||
.uri("/api/chat/events?token=secret-token")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
@@ -119,10 +154,80 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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 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())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
@@ -132,17 +237,32 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_no_auth_at_all_rejected() {
|
||||
let app = test_app("secret-token");
|
||||
let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
|
||||
let req = Request::builder()
|
||||
.uri("/api/chat/events")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_bearer_prefix_case_insensitive() {
|
||||
// RFC 6750 Section 2.1: auth-scheme comparison must be case-insensitive.
|
||||
async fn test_bearer_header_works_for_post() {
|
||||
let app = test_app("secret-token");
|
||||
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")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
@@ -154,7 +274,7 @@ mod tests {
|
||||
async fn test_bearer_prefix_mixed_case() {
|
||||
let app = test_app("secret-token");
|
||||
let req = Request::builder()
|
||||
.uri("/test")
|
||||
.uri("/api/chat/events")
|
||||
.header("Authorization", "BEARER secret-token")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
@@ -166,7 +286,7 @@ mod tests {
|
||||
async fn test_empty_bearer_token_rejected() {
|
||||
let app = test_app("secret-token");
|
||||
let req = Request::builder()
|
||||
.uri("/test")
|
||||
.uri("/api/chat/events")
|
||||
.header("Authorization", "Bearer ")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
@@ -176,11 +296,9 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_token_with_whitespace_rejected() {
|
||||
// Extra space after "Bearer " means the token value starts with a space,
|
||||
// which should not match the expected token.
|
||||
let app = test_app("secret-token");
|
||||
let req = Request::builder()
|
||||
.uri("/test")
|
||||
.uri("/api/chat/events")
|
||||
.header("Authorization", "Bearer secret-token")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
@@ -142,7 +142,7 @@ pub async fn chat_auth_token_handler(
|
||||
.await
|
||||
.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
|
||||
let msg = match ext_mgr.activate(&req.extension_name).await {
|
||||
Ok(r) => format!(
|
||||
@@ -170,13 +170,14 @@ pub async fn chat_auth_token_handler(
|
||||
// Re-emit auth_required for retry
|
||||
state.sse.broadcast(SseEvent::AuthRequired {
|
||||
extension_name: req.extension_name.clone(),
|
||||
instructions: result.instructions.clone(),
|
||||
auth_url: result.auth_url.clone(),
|
||||
setup_url: result.setup_url.clone(),
|
||||
instructions: result.instructions().map(String::from),
|
||||
auth_url: result.auth_url().map(String::from),
|
||||
setup_url: result.setup_url().map(String::from),
|
||||
});
|
||||
Ok(Json(ActionResponse::fail(
|
||||
result
|
||||
.instructions
|
||||
.instructions()
|
||||
.map(String::from)
|
||||
.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.
|
||||
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.
|
||||
match ext_mgr.activate(&name).await {
|
||||
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).
|
||||
let mut resp = ActionResponse::fail(
|
||||
auth_result
|
||||
.instructions
|
||||
.clone()
|
||||
.instructions()
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| format!("'{}' requires authentication.", name)),
|
||||
);
|
||||
resp.auth_url = auth_result.auth_url;
|
||||
resp.awaiting_token = Some(auth_result.awaiting_token);
|
||||
resp.instructions = auth_result.instructions;
|
||||
resp.auth_url = auth_result.auth_url().map(String::from);
|
||||
resp.awaiting_token = Some(auth_result.is_awaiting_token());
|
||||
resp.instructions = auth_result.instructions().map(String::from);
|
||||
Ok(Json(resp))
|
||||
}
|
||||
Err(auth_err) => Ok(Json(ActionResponse::fail(format!(
|
||||
|
||||
@@ -304,9 +304,16 @@ impl Channel for GatewayChannel {
|
||||
name,
|
||||
thread_id: thread_id.clone(),
|
||||
},
|
||||
StatusUpdate::ToolCompleted { name, success } => SseEvent::ToolCompleted {
|
||||
StatusUpdate::ToolCompleted {
|
||||
name,
|
||||
success,
|
||||
error,
|
||||
parameters,
|
||||
} => SseEvent::ToolCompleted {
|
||||
name,
|
||||
success,
|
||||
error,
|
||||
parameters,
|
||||
thread_id: thread_id.clone(),
|
||||
},
|
||||
StatusUpdate::ToolResult { name, preview } => SseEvent::ToolResult {
|
||||
|
||||
+539
-20
@@ -192,7 +192,9 @@ pub async fn start_server(
|
||||
})?;
|
||||
|
||||
// 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)
|
||||
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 ---
|
||||
|
||||
async fn chat_send_handler(
|
||||
@@ -552,7 +728,7 @@ async fn chat_auth_token_handler(
|
||||
.await
|
||||
.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
|
||||
let msg = match ext_mgr.activate(&req.extension_name).await {
|
||||
Ok(r) => format!(
|
||||
@@ -580,13 +756,14 @@ async fn chat_auth_token_handler(
|
||||
// Re-emit auth_required for retry
|
||||
state.sse.broadcast(SseEvent::AuthRequired {
|
||||
extension_name: req.extension_name.clone(),
|
||||
instructions: result.instructions.clone(),
|
||||
auth_url: result.auth_url.clone(),
|
||||
setup_url: result.setup_url.clone(),
|
||||
instructions: result.instructions().map(String::from),
|
||||
auth_url: result.auth_url().map(String::from),
|
||||
setup_url: result.setup_url().map(String::from),
|
||||
});
|
||||
Ok(Json(ActionResponse::fail(
|
||||
result
|
||||
.instructions
|
||||
.instructions()
|
||||
.map(String::from)
|
||||
.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
|
||||
// when the extension reports it is awaiting authorization.
|
||||
match ext_mgr.auth(&req.name, None).await {
|
||||
Ok(auth_result)
|
||||
if auth_result.auth_url.is_some()
|
||||
&& auth_result.status == "awaiting_authorization" =>
|
||||
{
|
||||
Ok(auth_result) if auth_result.auth_url().is_some() => {
|
||||
// Scope expansion or initial OAuth: user needs to authorize
|
||||
resp.auth_url = auth_result.auth_url;
|
||||
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.
|
||||
let mut resp = ActionResponse::ok(result.message);
|
||||
if let Ok(auth_result) = ext_mgr.auth(&name, None).await
|
||||
&& auth_result.auth_url.is_some()
|
||||
&& auth_result.status == "awaiting_authorization"
|
||||
&& auth_result.auth_url().is_some()
|
||||
{
|
||||
resp.auth_url = auth_result.auth_url;
|
||||
resp.auth_url = auth_result.auth_url().map(String::from);
|
||||
}
|
||||
Ok(Json(resp))
|
||||
}
|
||||
@@ -1385,7 +1558,7 @@ async fn extensions_activate_handler(
|
||||
|
||||
// Activation failed due to auth; try authenticating first.
|
||||
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.
|
||||
match ext_mgr.activate(&name).await {
|
||||
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).
|
||||
let mut resp = ActionResponse::fail(
|
||||
auth_result
|
||||
.instructions
|
||||
.clone()
|
||||
.instructions()
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| format!("'{}' requires authentication.", name)),
|
||||
);
|
||||
resp.auth_url = auth_result.auth_url;
|
||||
resp.awaiting_token = Some(auth_result.awaiting_token);
|
||||
resp.instructions = auth_result.instructions;
|
||||
resp.auth_url = auth_result.auth_url().map(String::from);
|
||||
resp.awaiting_token = Some(auth_result.is_awaiting_token());
|
||||
resp.instructions = auth_result.instructions().map(String::from);
|
||||
Ok(Json(resp))
|
||||
}
|
||||
Err(auth_err) => Ok(Json(ActionResponse::fail(format!(
|
||||
@@ -1592,6 +1765,13 @@ async fn extensions_setup_submit_handler(
|
||||
|
||||
match ext_mgr.save_setup_secrets(&name, &req.secrets).await {
|
||||
Ok(result) => {
|
||||
// Broadcast auth_completed so the chat UI can dismiss any in-progress
|
||||
// auth card or setup modal that was triggered by tool_auth/tool_activate.
|
||||
state.sse.broadcast(SseEvent::AuthCompleted {
|
||||
extension_name: name.clone(),
|
||||
success: true,
|
||||
message: result.message.clone(),
|
||||
});
|
||||
let mut resp = ActionResponse::ok(result.message);
|
||||
resp.activated = Some(result.activated);
|
||||
resp.auth_url = result.auth_url;
|
||||
@@ -2232,4 +2412,343 @@ mod tests {
|
||||
let turns = build_turns_from_db_messages(&[]);
|
||||
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) => {
|
||||
const data = JSON.parse(e.data);
|
||||
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) => {
|
||||
@@ -222,17 +222,22 @@ function connectSSE() {
|
||||
|
||||
eventSource.addEventListener('auth_required', (e) => {
|
||||
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) => {
|
||||
const data = JSON.parse(e.data);
|
||||
// Dismiss whichever UI path was active: auth card (OAuth) or configure modal (setup).
|
||||
removeAuthCard(data.extension_name);
|
||||
if (data.success) {
|
||||
showToast(data.message, 'success');
|
||||
} else {
|
||||
showToast(data.message, 'error');
|
||||
}
|
||||
closeConfigureModal();
|
||||
showToast(data.message, data.success ? 'success' : 'error');
|
||||
// Refresh extensions list so status indicators update
|
||||
if (currentTab === 'extensions') loadExtensions();
|
||||
enableChatInput();
|
||||
@@ -590,7 +595,7 @@ function addToolCard(name) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
function completeToolCard(name, success) {
|
||||
function completeToolCard(name, success, error, parameters) {
|
||||
const entries = _activeToolCards[name];
|
||||
if (!entries || entries.length === 0) return;
|
||||
// Find first running card
|
||||
@@ -611,6 +616,27 @@ function completeToolCard(name, success) {
|
||||
? '<span class="activity-icon-success">✓</span>'
|
||||
: '<span class="activity-icon-fail">✗</span>';
|
||||
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) {
|
||||
@@ -874,7 +900,7 @@ function showAuthCard(data) {
|
||||
|
||||
const tokenInput = document.createElement('input');
|
||||
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) => {
|
||||
if (e.key === 'Enter') submitAuthToken(data.extension_name, tokenInput.value);
|
||||
});
|
||||
@@ -1987,7 +2013,11 @@ function renderExtensionCard(ext) {
|
||||
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');
|
||||
configBtn.className = 'btn-ext configure';
|
||||
configBtn.textContent = ext.authenticated ? 'Reconfigure' : 'Configure';
|
||||
@@ -2176,18 +2206,18 @@ function submitConfigureModal(name, fields) {
|
||||
closeConfigureModal();
|
||||
if (res.success) {
|
||||
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');
|
||||
window.open(res.auth_url, '_blank', 'width=600,height=700');
|
||||
} else if (res.activated) {
|
||||
showToast('Configured and activated ' + name, 'success');
|
||||
} else {
|
||||
showToast(res.message || 'Configuration saved but activation failed', 'warning');
|
||||
loadExtensions();
|
||||
}
|
||||
// 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 {
|
||||
showToast(res.message || 'Configuration failed', 'error');
|
||||
loadExtensions();
|
||||
}
|
||||
loadExtensions();
|
||||
})
|
||||
.catch((err) => {
|
||||
btns.forEach(function(b) { b.disabled = false; });
|
||||
@@ -2427,10 +2457,12 @@ function restartJob(jobId) {
|
||||
apiFetch('/api/jobs/' + jobId + '/restart', { method: 'POST' })
|
||||
.then((res) => {
|
||||
showToast('Job restarted as ' + (res.new_job_id || '').substring(0, 8), 'success');
|
||||
loadJobs();
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast('Failed to restart job: ' + err.message, 'error');
|
||||
})
|
||||
.finally(() => {
|
||||
loadJobs();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -553,6 +553,10 @@ body {
|
||||
border-color: rgba(230, 76, 76, 0.3);
|
||||
}
|
||||
|
||||
.activity-tool-card[data-status="fail"] .activity-tool-name {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.activity-tool-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -123,6 +123,10 @@ pub enum SseEvent {
|
||||
name: String,
|
||||
success: bool,
|
||||
#[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>,
|
||||
},
|
||||
#[serde(rename = "tool_result")]
|
||||
|
||||
@@ -242,7 +242,7 @@ async fn handle_client_message(
|
||||
} => {
|
||||
if let Some(ref ext_mgr) = state.extension_manager {
|
||||
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 {
|
||||
Ok(r) => format!(
|
||||
"{} authenticated ({} tools loaded)",
|
||||
@@ -268,9 +268,9 @@ async fn handle_client_message(
|
||||
.sse
|
||||
.broadcast(crate::channels::web::types::SseEvent::AuthRequired {
|
||||
extension_name,
|
||||
instructions: result.instructions,
|
||||
auth_url: result.auth_url,
|
||||
setup_url: result.setup_url,
|
||||
instructions: result.instructions().map(String::from),
|
||||
auth_url: result.auth_url().map(String::from),
|
||||
setup_url: result.setup_url().map(String::from),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
//! env vars, which take priority over built-in defaults.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
@@ -25,6 +26,7 @@ use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
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)]
|
||||
mod tests {
|
||||
use std::sync::Mutex;
|
||||
@@ -939,4 +1154,161 @@ mod tests {
|
||||
// State should be different each time (random)
|
||||
assert_ne!(result1.state, result2.state);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_use_gateway_callback_false_by_default() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
|
||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||
unsafe {
|
||||
std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL");
|
||||
}
|
||||
assert!(!crate::cli::oauth_defaults::use_gateway_callback());
|
||||
unsafe {
|
||||
if let Some(val) = original {
|
||||
std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_use_gateway_callback_true_for_hosted() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
|
||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||
unsafe {
|
||||
std::env::set_var(
|
||||
"IRONCLAW_OAUTH_CALLBACK_URL",
|
||||
"https://kind-deer.agent1.near.ai",
|
||||
);
|
||||
}
|
||||
assert!(crate::cli::oauth_defaults::use_gateway_callback());
|
||||
unsafe {
|
||||
if let Some(val) = original {
|
||||
std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", val);
|
||||
} else {
|
||||
std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_use_gateway_callback_false_for_localhost() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
|
||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||
unsafe {
|
||||
std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", "http://127.0.0.1:3001");
|
||||
}
|
||||
assert!(!crate::cli::oauth_defaults::use_gateway_callback());
|
||||
unsafe {
|
||||
if let Some(val) = original {
|
||||
std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", val);
|
||||
} else {
|
||||
std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_use_gateway_callback_false_for_empty() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
|
||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||
unsafe {
|
||||
std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", "");
|
||||
}
|
||||
assert!(!crate::cli::oauth_defaults::use_gateway_callback());
|
||||
unsafe {
|
||||
if let Some(val) = original {
|
||||
std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", val);
|
||||
} else {
|
||||
std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_platform_state_with_instance() {
|
||||
use crate::cli::oauth_defaults::build_platform_state;
|
||||
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
let original = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
|
||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||
unsafe {
|
||||
std::env::set_var("IRONCLAW_INSTANCE_NAME", "kind-deer");
|
||||
}
|
||||
assert_eq!(build_platform_state("abc123"), "kind-deer:abc123");
|
||||
unsafe {
|
||||
if let Some(val) = original {
|
||||
std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
|
||||
} else {
|
||||
std::env::remove_var("IRONCLAW_INSTANCE_NAME");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_platform_state_without_instance() {
|
||||
use crate::cli::oauth_defaults::build_platform_state;
|
||||
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
let original = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
|
||||
let original_oc = std::env::var("OPENCLAW_INSTANCE_NAME").ok();
|
||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||
unsafe {
|
||||
std::env::remove_var("IRONCLAW_INSTANCE_NAME");
|
||||
std::env::remove_var("OPENCLAW_INSTANCE_NAME");
|
||||
}
|
||||
assert_eq!(build_platform_state("abc123"), "abc123");
|
||||
unsafe {
|
||||
if let Some(val) = original {
|
||||
std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
|
||||
}
|
||||
if let Some(val) = original_oc {
|
||||
std::env::set_var("OPENCLAW_INSTANCE_NAME", val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_platform_state_with_openclaw_instance() {
|
||||
use crate::cli::oauth_defaults::build_platform_state;
|
||||
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
let original_ic = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
|
||||
let original_oc = std::env::var("OPENCLAW_INSTANCE_NAME").ok();
|
||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||
unsafe {
|
||||
std::env::remove_var("IRONCLAW_INSTANCE_NAME");
|
||||
std::env::set_var("OPENCLAW_INSTANCE_NAME", "quiet-lion");
|
||||
}
|
||||
assert_eq!(build_platform_state("xyz789"), "quiet-lion:xyz789");
|
||||
unsafe {
|
||||
if let Some(val) = original_ic {
|
||||
std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
|
||||
}
|
||||
if let Some(val) = original_oc {
|
||||
std::env::set_var("OPENCLAW_INSTANCE_NAME", val);
|
||||
} else {
|
||||
std::env::remove_var("OPENCLAW_INSTANCE_NAME");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_instance_prefix_with_colon() {
|
||||
use crate::cli::oauth_defaults::strip_instance_prefix;
|
||||
|
||||
assert_eq!(strip_instance_prefix("kind-deer:abc123"), "abc123");
|
||||
assert_eq!(strip_instance_prefix("my-instance:xyz"), "xyz");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_instance_prefix_without_colon() {
|
||||
use crate::cli::oauth_defaults::strip_instance_prefix;
|
||||
|
||||
assert_eq!(strip_instance_prefix("abc123"), "abc123");
|
||||
assert_eq!(strip_instance_prefix(""), "");
|
||||
}
|
||||
}
|
||||
|
||||
+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 registry::ExtensionRegistry;
|
||||
|
||||
use serde::ser::SerializeMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The kind of extension, determining how it's installed, authenticated, and activated.
|
||||
@@ -145,28 +146,267 @@ pub struct InstallResult {
|
||||
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.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthResult {
|
||||
pub name: String,
|
||||
pub kind: ExtensionKind,
|
||||
/// OAuth URL to open (for OAuth flows).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auth_url: Option<String>,
|
||||
/// Whether using local or remote callback.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub callback_type: Option<String>,
|
||||
/// Instructions for manual token entry (for WASM tools).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub instructions: Option<String>,
|
||||
/// URL for manual token setup.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub setup_url: Option<String>,
|
||||
/// Whether the tool is waiting for a token from the user.
|
||||
#[serde(default)]
|
||||
pub awaiting_token: bool,
|
||||
/// Current auth status.
|
||||
pub status: String,
|
||||
pub status: AuthStatus,
|
||||
}
|
||||
|
||||
impl AuthResult {
|
||||
// ── Constructors ──────────────────────────────────────────────────
|
||||
|
||||
pub fn authenticated(name: impl Into<String>, kind: ExtensionKind) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
kind,
|
||||
status: AuthStatus::Authenticated,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn no_auth_required(name: impl Into<String>, kind: ExtensionKind) -> Self {
|
||||
Self {
|
||||
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.
|
||||
@@ -257,3 +497,124 @@ pub enum ExtensionError {
|
||||
#[error("{0}")]
|
||||
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}")]
|
||||
InvalidChannel(String),
|
||||
|
||||
#[error("Invalid path: {0}")]
|
||||
InvalidPath(String),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
@@ -224,7 +227,10 @@ impl PairingStore {
|
||||
meta: Option<serde_json::Value>,
|
||||
) -> Result<UpsertResult, PairingStoreError> {
|
||||
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()
|
||||
.read(true)
|
||||
@@ -319,7 +325,10 @@ impl PairingStore {
|
||||
|
||||
fn record_failed_approve(&self, channel: &str) -> Result<(), PairingStoreError> {
|
||||
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
|
||||
// don't clobber each other's writes.
|
||||
@@ -462,7 +471,10 @@ impl PairingStore {
|
||||
}
|
||||
|
||||
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()
|
||||
.read(true)
|
||||
|
||||
@@ -47,6 +47,9 @@ pub enum RegistryError {
|
||||
actual_sha256: String,
|
||||
},
|
||||
|
||||
#[error("Missing SHA256 checksum for '{name}' artifact. Use --build to build from source.")]
|
||||
MissingChecksum { name: String },
|
||||
|
||||
#[error(
|
||||
"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 {
|
||||
// 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!(
|
||||
err,
|
||||
RegistryError::AlreadyInstalled { .. }
|
||||
@@ -367,15 +371,15 @@ impl RegistryInstaller {
|
||||
|
||||
// Require SHA256 — refuse to install unverified binaries. Check before
|
||||
// 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 =
|
||||
artifact
|
||||
.sha256
|
||||
.as_ref()
|
||||
.ok_or_else(|| RegistryError::InvalidManifest {
|
||||
.ok_or_else(|| RegistryError::MissingChecksum {
|
||||
name: manifest.name.clone(),
|
||||
field: "artifacts.wasm32-wasip2.sha256",
|
||||
reason: "sha256 is required for artifact downloads".to_string(),
|
||||
})?;
|
||||
|
||||
let target_dir = match manifest.kind {
|
||||
@@ -500,7 +504,7 @@ impl RegistryInstaller {
|
||||
if prefer_build || !has_artifact {
|
||||
self.install_from_source(manifest, force).await
|
||||
} 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;
|
||||
match result {
|
||||
Err(RegistryError::InvalidManifest { field, reason, .. }) => {
|
||||
assert_eq!(field, "artifacts.wasm32-wasip2.sha256");
|
||||
assert!(reason.contains("required"), "reason: {}", reason);
|
||||
Err(RegistryError::MissingChecksum { name }) => {
|
||||
assert_eq!(name, "demo");
|
||||
}
|
||||
other => panic!("unexpected result: {:?}", other),
|
||||
}
|
||||
@@ -942,6 +945,12 @@ mod tests {
|
||||
reason: "host not allowed".to_string(),
|
||||
};
|
||||
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]
|
||||
|
||||
+51
-28
@@ -63,6 +63,29 @@ pub struct ContainerRunner {
|
||||
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 {
|
||||
/// Create a new container runner.
|
||||
pub fn new(docker: Docker, image: String, proxy_port: u16) -> Self {
|
||||
@@ -393,23 +416,11 @@ impl ContainerRunner {
|
||||
match result {
|
||||
Ok(LogOutput::StdOut { message }) => {
|
||||
let text = String::from_utf8_lossy(&message);
|
||||
if stdout.len() + text.len() > 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);
|
||||
}
|
||||
truncated |= append_with_limit(&mut stdout, &text, half_max);
|
||||
}
|
||||
Ok(LogOutput::StdErr { message }) => {
|
||||
let text = String::from_utf8_lossy(&message);
|
||||
if stderr.len() + text.len() > 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);
|
||||
}
|
||||
truncated |= append_with_limit(&mut stderr, &text, half_max);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
@@ -439,23 +450,11 @@ impl ContainerRunner {
|
||||
match result {
|
||||
Ok(LogOutput::StdOut { message }) => {
|
||||
let text = String::from_utf8_lossy(&message);
|
||||
if stdout.len() < half_max {
|
||||
let remaining = half_max.saturating_sub(stdout.len());
|
||||
stdout.push_str(&text[..remaining.min(text.len())]);
|
||||
if text.len() > remaining {
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
truncated |= append_with_limit(&mut stdout, &text, half_max);
|
||||
}
|
||||
Ok(LogOutput::StdErr { message }) => {
|
||||
let text = String::from_utf8_lossy(&message);
|
||||
if stderr.len() < half_max {
|
||||
let remaining = half_max.saturating_sub(stderr.len());
|
||||
stderr.push_str(&text[..remaining.min(text.len())]);
|
||||
if text.len() > remaining {
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
truncated |= append_with_limit(&mut stderr, &text, half_max);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
@@ -577,6 +576,30 @@ fn unix_socket_candidates_from_env(
|
||||
mod tests {
|
||||
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)]
|
||||
#[test]
|
||||
fn test_unix_socket_candidates_include_rootless_paths() {
|
||||
|
||||
@@ -218,7 +218,7 @@ impl Tool for ToolAuthTool {
|
||||
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
||||
|
||||
// Auto-activate after successful auth so tools are available immediately
|
||||
if result.status == "authenticated" {
|
||||
if result.is_authenticated() {
|
||||
match self.manager.activate(name).await {
|
||||
Ok(activate_result) => {
|
||||
let output = serde_json::json!({
|
||||
@@ -324,7 +324,7 @@ impl Tool for ToolActivateTool {
|
||||
// Activation failed due to missing auth; initiate auth flow
|
||||
// so the agent loop can show the auth card.
|
||||
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.
|
||||
let result = self
|
||||
.manager
|
||||
|
||||
@@ -10,6 +10,7 @@ mod memory;
|
||||
mod message;
|
||||
pub mod path_utils;
|
||||
pub mod routine;
|
||||
pub mod secrets_tools;
|
||||
pub(crate) mod shell;
|
||||
pub mod skill_tools;
|
||||
mod time;
|
||||
@@ -31,6 +32,7 @@ pub use message::MessageTool;
|
||||
pub use routine::{
|
||||
RoutineCreateTool, RoutineDeleteTool, RoutineHistoryTool, RoutineListTool, RoutineUpdateTool,
|
||||
};
|
||||
pub use secrets_tools::{SecretDeleteTool, SecretListTool};
|
||||
pub use shell::ShellTool;
|
||||
pub use skill_tools::{SkillInstallTool, SkillListTool, SkillRemoveTool, SkillSearchTool};
|
||||
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 tool::{
|
||||
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);
|
||||
}
|
||||
|
||||
/// 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).
|
||||
///
|
||||
/// These allow the LLM to manage MCP servers and WASM tools through conversation.
|
||||
|
||||
@@ -239,6 +239,23 @@ pub trait Tool: Send + Sync {
|
||||
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.
|
||||
///
|
||||
/// 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)))
|
||||
}
|
||||
|
||||
/// 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()`.
|
||||
///
|
||||
/// Use this function at tool-registration time to catch structural mistakes
|
||||
@@ -500,6 +544,37 @@ mod tests {
|
||||
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]
|
||||
fn test_validate_schema_valid() {
|
||||
let schema = serde_json::json!({
|
||||
|
||||
@@ -105,6 +105,59 @@ impl CapabilitiesFile {
|
||||
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.
|
||||
pub fn to_capabilities(&self) -> Capabilities {
|
||||
let mut caps = Capabilities::default();
|
||||
@@ -1056,6 +1109,60 @@ mod tests {
|
||||
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]
|
||||
fn test_resolve_nested_empty_capabilities_noop() {
|
||||
// Empty inner capabilities should not clobber outer http
|
||||
|
||||
@@ -126,6 +126,7 @@ impl WasmToolLoader {
|
||||
let cap_bytes = fs::read(cap_path).await?;
|
||||
let cap_file = CapabilitiesFile::from_bytes(&cap_bytes)
|
||||
.map_err(|e| WasmLoadError::InvalidCapabilities(e.to_string()))?;
|
||||
cap_file.validate(name);
|
||||
let caps = cap_file.to_capabilities();
|
||||
let oauth = resolve_oauth_refresh_config(&cap_file);
|
||||
(caps, oauth)
|
||||
|
||||
+146
-1
@@ -592,6 +592,10 @@ impl WasmToolWrapper {
|
||||
let instance = SandboxedTool::instantiate(&mut store, &component, &linker)
|
||||
.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
|
||||
let params_json = serde_json::to_string(¶ms)
|
||||
.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).
|
||||
// This decrypts the secrets once so the sync http_request() host function
|
||||
// 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(
|
||||
&self.capabilities,
|
||||
self.secrets_store.as_deref(),
|
||||
&ctx.user_id,
|
||||
credential_user_id,
|
||||
self.oauth_refresh.as_ref(),
|
||||
)
|
||||
.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)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
@@ -1588,4 +1654,83 @@ mod tests {
|
||||
let result = super::reject_private_ip("https://8.8.8.8/dns-query");
|
||||
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
|
||||
"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(
|
||||
ironclaw_binary, "--no-onboard",
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
@@ -126,9 +133,12 @@ async def ironclaw_server(ironclaw_binary, mock_llm_server):
|
||||
)
|
||||
finally:
|
||||
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:
|
||||
await asyncio.wait_for(proc.wait(), timeout=5)
|
||||
await asyncio.wait_for(proc.wait(), timeout=10)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
|
||||
|
||||
@@ -312,6 +312,8 @@ async fn test_ws_multiple_events_in_sequence() {
|
||||
state.sse.broadcast(SseEvent::ToolCompleted {
|
||||
name: "shell".to_string(),
|
||||
success: true,
|
||||
error: None,
|
||||
parameters: None,
|
||||
thread_id: None,
|
||||
});
|
||||
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": {
|
||||
"required_secrets": [
|
||||
{
|
||||
"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