Compare commits

...
14 Commits
Author SHA1 Message Date
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
3615967f92 chore: release v0.15.0 (#526)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-04 16:37:10 -08:00
704d63f16a feat(oauth): route callbacks through web gateway for hosted instances (#555)
* feat: route OAuth callbacks through web gateway for hosted instances

On hosted instances (e.g., NEAR AI), OAuth callbacks can't reach the
local TCP listener on port 9876. This adds a gateway-routed OAuth flow
that works behind reverse proxies and load balancers.

Backend changes:
- Add /oauth/callback as a public route on the web gateway
- PendingOAuthFlow registry shared between ExtensionManager and handler
- Gateway mode auto-detected via IRONCLAW_OAUTH_CALLBACK_URL env var
- Platform state format (instance:nonce) for nginx routing
- Token exchange proxy support via IRONCLAW_OAUTH_EXCHANGE_URL
- Local TCP listener mode preserved as backward-compatible fallback

UX improvements:
- Hide Configure button for tools with auto-resolved OAuth credentials
  (builtin defaults or platform-injected env vars)
- Skip client_id/client_secret fields in setup schema when auto-resolved
- Show Reconfigure only after successful authentication

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(oauth): harden gateway callback and refactor AuthResult

- Add 60s timeout to exchange_via_proxy HTTP client (matching exchange_oauth_code)
- Read GATEWAY_AUTH_TOKEN once at ExtensionManager construction instead of
  per-flow from env (prevents coupling and clarifies token provenance)
- Extract oauth_error_page() helper to deduplicate error landing pages
- Remove IRONCLAW_FORCE_GATEWAY_CALLBACK env var (auto-detection suffices)
- Refactor AuthResult into typed AuthStatus enum with constructors,
  eliminating stringly-typed status and Option fields that were always None
- Adapt all handlers (chat, extensions, ws) to new AuthResult/AuthStatus API
- Use setup_url (not validation_endpoint) for awaiting_token responses

[skip-regression-check]

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix(oauth): address review feedback — empty token guard, test flakiness, doc typos

- Fail early in exchange_via_proxy() when gateway_token is empty instead
  of sending an unauthenticated request to the exchange proxy
- Fix test_oauth_callback_strips_instance_prefix to use an expired flow
  so it never attempts a real HTTP token exchange (prevents CI flakiness)
- Fix doc comments: /auth/callback → /oauth/callback in PendingOAuthFlow
  and ExtensionManager pending_oauth_flows docs

[skip-regression-check]

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix: clarify strip_instance_prefix safety, wrapper credential fix, test assertion

- Add comment to strip_instance_prefix noting nonces are base64url (no colons)
- Expand wrapper.rs comment explaining the credential_user_id bug fix
- Fix test_oauth_callback_strips_instance_prefix assertion: landing_html
  does not include provider_name on error pages

[skip-regression-check]

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-04 23:47:45 +00:00
902492bcdb feat(web): show error details for failed tool calls (#490)
* feat(web): show error details and input params for failed tool calls

Failed tool calls in the gateway UI previously showed only a red X icon
with an empty expandable body. This change:

- Adds optional `error` and `parameters` fields to `ToolCompleted` SSE
  events so the browser receives failure details in real-time
- Auto-expands failed tool cards to make errors immediately visible
- Adds `StatusUpdate::tool_completed()` constructor that centralizes
  the 5 duplicated construction sites and applies `redact_params()` to
  prevent sensitive values (e.g. secret_save's "value" param) from
  leaking through SSE broadcasts
- Adds `sensitive_params()` trait method to `Tool` for declaring which
  parameters must be redacted before logging, hooks, and UI display
- Adds `redact_params()` utility and wires it through hooks, approvals,
  ActionRecord storage, and debug logs in dispatcher/worker
- Adds `SecretListTool` and `SecretDeleteTool` for LLM-driven secret
  management (values never returned, only names/metadata)
- Fixes auth flow: setup-only extensions show configure modal instead
  of OAuth card; auth_completed SSE dismisses both UI paths
- CI: release workflow creates PR instead of pushing directly to main
- Registry: MissingChecksum error enables source fallback for
  bootstrapping when checksums haven't been populated yet

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* style: apply cargo fmt

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: keep original params in PendingApproval for execution, redact only for display

Address two PR review comments:

1. execute_chat_tool_standalone now redacts sensitive params before logging,
   matching the pattern already used in worker.rs.

2. PendingApproval previously stored redacted parameters, which meant
   approved tool calls received "[REDACTED]" instead of the actual values.
   Add a display_parameters field for UI/logs and keep parameters as the
   original values used for execution.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address PR review comments

- worker.rs: redact sensitive params before BeforeToolCall hook, matching
  dispatcher.rs — hooks in the autonomous job path now receive redacted
  params instead of raw values
- registry.rs: fix docstring for register_secrets_tools (list, delete,
  not save/list/delete — no SecretSaveTool is registered)
- app.js: fix double toast/loadExtensions in submitConfigureModal —
  for non-OAuth success the auth_completed SSE already handles both,
  so skip them in the HTTP response handler to avoid duplicates

[skip-regression-check]

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-04 15:38:26 -08:00
13697976db feat(extensions): improve auth UX and add load-time validation (#536)
* feat(extensions): add load-time validation for auth capabilities

Catch common misconfigurations (missing auth section, missing setup_url,
short prompts) at startup via tracing::warn instead of silently failing
at auth time.

* feat(extensions): improve auth prompts, setup_url, and showAuthCard

Add setup_url and descriptive prompts to channel and tool capabilities
files. Fix showAuthCard in web gateway and improve extension manager
auth flow messaging.

* refactor(extensions): extract MIN_PROMPT_LENGTH constant in validate()

Address review feedback: replace magic number 30 with a named constant
for readability and maintainability.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-04 13:57:10 -08:00
cbcd5adcc0 fix(security): restrict query-token auth to SSE endpoints only (#528)
* fix(security): restrict query-token auth to SSE endpoints only

Query-string `?token=xxx` auth was accepted on all endpoints, exposing
the main auth token in server logs, Referer headers, and browser history
for state-changing routes. Now only GET /api/chat/events and
GET /api/logs/events accept query tokens; all other endpoints require
the Authorization header.

Supersedes #364.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: add WebSocket endpoint to query-token allowlist, add URL-encoding tests

The WS upgrade at /api/chat/ws also can't set custom headers, so it
needs query-token auth like the SSE endpoints. Also adds tests for
URL-encoded token values to cover the form_urlencoded parser.

Addresses review feedback from Gemini (partially, /api/jobs/{id}/events
is a JSON endpoint not SSE, so it correctly stays excluded) and Copilot
(URL-encoded token test).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-04 20:06:51 +00:00
e24c33ff90 fix(ci): flush profraw coverage data in E2E teardown (#550)
The ironclaw binary only handles SIGINT (via tokio::signal::ctrl_c),
not SIGTERM. When conftest.py sent SIGTERM during teardown, the OS
killed the process immediately without running atexit handlers, so
LLVM never flushed .profraw files. cargo llvm-cov report then found
zero profraw files and failed.

- Send SIGINT instead of SIGTERM so the existing ctrl_c handler
  triggers graceful shutdown → main() returns → atexit runs → profraw
  flushed
- Increase shutdown wait from 5s to 10s for graceful cleanup
- Add a diagnostic step to verify profraw files exist before the
  report step, making future issues visible in CI logs

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-04 20:05:46 +00:00
f99991d27b fix(wasm): coerce string parameters to schema-declared types (#498)
* fix(wasm): coerce string parameters to schema-declared types

LLMs frequently pass numeric values as JSON strings ("5" instead of 5)
or booleans as strings ("true" instead of true). The WASM module's
serde deserializer rejects these type mismatches. This adds a
coerce_params_to_schema() helper that walks the params JSON object
and converts string values to their schema-declared types (number,
integer, boolean) before passing to the WASM module.

Adds 5 unit tests covering number, integer, boolean coercion,
already-correct types, and unparseable strings.

Closes #486

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* refactor: use in-place mutation and case-insensitive boolean coercion

Address review feedback:
- Use get_mut instead of clone+insert to avoid allocations
- Make boolean coercion case-insensitive (handles "True", "FALSE", etc.)
- Expand boolean test to cover false and mixed-case values

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: cargo fmt

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: collapse nested if-let to satisfy clippy collapsible_if lint

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-04 18:19:01 +00:00
89600e2b5c fix(agent): strip leaked [Called tool ...] text from responses (#497)
* fix(agent): strip leaked [Called tool ...] text from agent responses

When the NEAR AI provider flattens tool_call messages to plain text,
markers like [Called tool ...] and [Tool ... returned: ...] can leak
into the user-visible response if the LLM echoes them back. This adds
a sanitization step in the agentic loop's text response path that
strips these internal markers before returning. If stripping leaves
the response empty, a generic fallback message is returned instead.

Closes #487

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* refactor: use fold instead of collect+join to avoid heap allocation

Address review feedback: replace Vec collect + join with fold to build
the filtered string directly, avoiding an intermediate heap allocation.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: cargo fmt

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: Pierre LE GUEN <[email protected]>
2026-03-04 18:16:26 +00:00
e4e78d8a87 fix(web): reset job list UI on restart failure (#499)
* fix(web): reset job list UI on restart failure

The restartJob() catch handler was missing a loadJobs() call, so the
job row stayed in a stale highlighted state after a failed restart
attempt. Add loadJobs() to match the success path behavior.

Closes #485

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* refactor: use .finally() for loadJobs() instead of duplicating

Move loadJobs() to a .finally() block so it runs on both success and
failure without duplication.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-04 10:05:42 -08:00
b9446712e9 fix(telegram): add missing webhook section to capabilities.json (#381)
The Telegram channel capabilities file was missing the `webhook`
block inside `capabilities.channel`, causing the router to fall back
to the default `X-Webhook-Secret` header instead of the Telegram-
specific `X-Telegram-Bot-Api-Secret-Token`.

When a webhook secret is configured (via `telegram_webhook_secret`),
incoming updates are rejected with 401 because Telegram sends the
token in `X-Telegram-Bot-Api-Secret-Token` but the router looks for
`X-Webhook-Secret`.

The existing test in `schema.rs` already expects the correct header
name, confirming this is an oversight in the shipped capabilities
file.

Co-authored-by: SMKRV <[email protected]>
Co-authored-by: firat.sertgoz <[email protected]>
2026-03-04 14:40:28 +00:00
LawyeredandGitHub 31a4330f24 Fix UTF-8 unsafe truncation in sandbox log capture (#359) 2026-03-04 15:25:26 +01:00
9b47dbbaed fix(security): replace .unwrap() panics in pairing store with proper error handling (#515)
The pairing store called .unwrap() on path.parent() in three locations
(upsert_request, record_failed_approve, add_allow_from). If a path has
no parent (root path or empty), this panics — a potential denial-of-service
vector if an attacker can influence the path.

Added InvalidPath variant to PairingStoreError and replaced all three
.unwrap() calls with ok_or_else error propagation. This follows the
project's no-panics-in-production policy.

Locations fixed:
- upsert_request (line ~227)
- record_failed_approve (line ~322)
- add_allow_from (line ~465)

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-03-04 08:21:19 +00:00
ac3c928853 ci: enhance coverage with feature matrix, postgres, and E2E (#523)
* ci: enhance coverage workflow with feature matrix, postgres, and E2E

Replace single-config coverage job with a multi-job pipeline:

- Mirror test.yml's 3-config feature matrix (all-features, default, libsql-only)
- Add PostgreSQL service (pgvector/pgvector:pg16) with migrations for
  postgres configs so integration tests actually run instead of skipping
- Add E2E coverage job using cargo-llvm-cov instrumented binary with
  Playwright browser tests
- Add coverage-gate roll-up job for branch protection
- Upload per-config flags to Codecov (all-features, default, libsql-only, e2e)
- Forward LLVM coverage env vars in E2E conftest.py so profraw data
  lands where cargo-llvm-cov report expects it

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address PR review feedback on coverage workflow

- Avoid setting DATABASE_URL to empty string for libsql-only config;
  use $GITHUB_ENV conditional step so the var is unset entirely
- Add set -euo pipefail and psql -v ON_ERROR_STOP=1 to migrations
  so SQL errors fail the job immediately

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-04 06:23:53 +00:00
Pierre LE GUENandGitHub bf2a08be94 feat: add local-test skill and Dockerfile.test for web gateway testing (#524)
Add Dockerfile.test as reusable infrastructure for spinning up local
test instances with libsql (no PostgreSQL dependency). Defaults to
port 3003 to avoid conflict with dev server.

Add local-test workspace skill that teaches the agent how to build,
run, and test against local Docker containers using Chrome MCP browser
automation tools. Covers LLM backend configuration, multi-instance
testing, cleanup, and troubleshooting.
2026-03-04 05:53:35 +00:00
50 changed files with 3581 additions and 615 deletions
+149 -3
View File
@@ -9,24 +9,170 @@ permissions:
jobs: jobs:
coverage: coverage:
name: Coverage name: Coverage (${{ matrix.name }})
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- name: all-features
flags: "--all-features"
has_postgres: true
- name: default
flags: ""
has_postgres: true
- name: libsql-only
flags: "--no-default-features --features libsql"
has_postgres: false
services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: ironclaw_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
with: with:
components: llvm-tools-preview components: llvm-tools-preview
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
with: with:
key: coverage key: coverage-${{ matrix.name }}
- name: Install cargo-llvm-cov - name: Install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov uses: taiki-e/install-action@cargo-llvm-cov
- name: Run database migrations
if: matrix.has_postgres
run: |
set -euo pipefail
for f in migrations/V*.sql; do
echo "Applying $f..."
psql -v ON_ERROR_STOP=1 -f "$f"
done
env:
PGHOST: localhost
PGUSER: postgres
PGPASSWORD: postgres
PGDATABASE: ironclaw_test
- name: Set DATABASE_URL for postgres configs
if: matrix.has_postgres
run: echo "DATABASE_URL=postgres://postgres:postgres@localhost/ironclaw_test" >> "$GITHUB_ENV"
- name: Generate coverage - name: Generate coverage
run: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info run: cargo llvm-cov ${{ matrix.flags }} --workspace --lcov --output-path lcov.info
- name: Upload to Codecov - name: Upload to Codecov
uses: codecov/codecov-action@v5 uses: codecov/codecov-action@v5
with: with:
files: lcov.info files: lcov.info
flags: ${{ matrix.name }}
disable_search: true disable_search: true
use_oidc: true use_oidc: true
fail_ci_if_error: true fail_ci_if_error: true
e2e-coverage:
name: E2E Coverage
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: llvm-tools-preview
- uses: Swatinem/rust-cache@v2
with:
key: e2e-coverage
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov
- name: Set up coverage instrumentation
run: |
source <(cargo llvm-cov show-env --export-prefix)
# Persist env vars for subsequent steps
echo "RUSTFLAGS=${RUSTFLAGS}" >> "$GITHUB_ENV"
echo "LLVM_PROFILE_FILE=${LLVM_PROFILE_FILE}" >> "$GITHUB_ENV"
echo "CARGO_LLVM_COV=1" >> "$GITHUB_ENV"
echo "CARGO_LLVM_COV_SHOW_ENV=1" >> "$GITHUB_ENV"
echo "CARGO_LLVM_COV_TARGET_DIR=${CARGO_LLVM_COV_TARGET_DIR}" >> "$GITHUB_ENV"
cargo llvm-cov clean --workspace
- name: Build instrumented binary
run: cargo build --no-default-features --features libsql
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install E2E dependencies
run: |
cd tests/e2e
pip install -e .
playwright install --with-deps chromium
- name: Run E2E tests
run: |
pytest tests/e2e/ -v -x --timeout=120
env:
RUST_LOG: ironclaw=info
RUST_BACKTRACE: "1"
- name: Verify profraw files exist
if: always()
run: |
echo "LLVM_PROFILE_FILE=${LLVM_PROFILE_FILE}"
echo "CARGO_LLVM_COV_TARGET_DIR=${CARGO_LLVM_COV_TARGET_DIR}"
profraw_count=$(find target/ -name '*.profraw' 2>/dev/null | wc -l)
echo "Found ${profraw_count} .profraw files under target/"
find target/ -name '*.profraw' 2>/dev/null || true
if [ "$profraw_count" -eq 0 ]; then
echo "::warning::No .profraw files found — coverage report will fail"
fi
- name: Generate coverage report
if: always()
run: cargo llvm-cov report --lcov --output-path e2e-coverage.info
- name: Upload to Codecov
if: always()
uses: codecov/codecov-action@v5
with:
files: e2e-coverage.info
flags: e2e
disable_search: true
use_oidc: true
fail_ci_if_error: true
- name: Upload screenshots on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: e2e-screenshots
path: tests/e2e/screenshots/
if-no-files-found: ignore
coverage-gate:
name: Coverage
runs-on: ubuntu-latest
if: always()
needs: [coverage, e2e-coverage]
steps:
- run: |
if [[ "${{ needs.coverage.result }}" != "success" || "${{ needs.e2e-coverage.result }}" != "success" ]]; then
echo "One or more coverage jobs failed"
exit 1
fi
+12 -2
View File
@@ -413,6 +413,9 @@ jobs:
- build-wasm-extensions - build-wasm-extensions
if: ${{ always() && needs.host.result == 'success' && needs.build-wasm-extensions.result == 'success' }} if: ${{ always() && needs.host.result == 'success' && needs.build-wasm-extensions.result == 'success' }}
runs-on: "ubuntu-22.04" runs-on: "ubuntu-22.04"
permissions:
contents: write
pull-requests: write
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps: steps:
@@ -445,7 +448,7 @@ jobs:
fi fi
done done
done < "$CHECKSUMS" done < "$CHECKSUMS"
- name: Commit updated manifests - name: Create PR with updated manifests
run: | run: |
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"
@@ -453,8 +456,15 @@ jobs:
if git diff --cached --quiet; then if git diff --cached --quiet; then
echo "No manifest changes to commit" echo "No manifest changes to commit"
else else
BRANCH="chore/update-checksums-$(date +%s)"
git checkout -b "$BRANCH"
git commit -m "chore: update WASM artifact SHA256 checksums [skip ci]" git commit -m "chore: update WASM artifact SHA256 checksums [skip ci]"
git push git push origin "$BRANCH"
gh pr create \
--title "chore: update WASM artifact SHA256 checksums" \
--body "Auto-generated by release CI. Updates SHA256 checksums in registry manifests to match the released WASM artifacts." \
--base main \
--head "$BRANCH"
fi fi
announce: announce:
+23
View File
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.15.0](https://github.com/nearai/ironclaw/compare/v0.14.0...v0.15.0) - 2026-03-04
### Added
- *(oauth)* route callbacks through web gateway for hosted instances ([#555](https://github.com/nearai/ironclaw/pull/555))
- *(web)* show error details for failed tool calls ([#490](https://github.com/nearai/ironclaw/pull/490))
- *(extensions)* improve auth UX and add load-time validation ([#536](https://github.com/nearai/ironclaw/pull/536))
- add local-test skill and Dockerfile.test for web gateway testing ([#524](https://github.com/nearai/ironclaw/pull/524))
### Fixed
- *(security)* restrict query-token auth to SSE endpoints only ([#528](https://github.com/nearai/ironclaw/pull/528))
- *(ci)* flush profraw coverage data in E2E teardown ([#550](https://github.com/nearai/ironclaw/pull/550))
- *(wasm)* coerce string parameters to schema-declared types ([#498](https://github.com/nearai/ironclaw/pull/498))
- *(agent)* strip leaked [Called tool ...] text from responses ([#497](https://github.com/nearai/ironclaw/pull/497))
- *(web)* reset job list UI on restart failure ([#499](https://github.com/nearai/ironclaw/pull/499))
- *(security)* replace .unwrap() panics in pairing store with proper error handling ([#515](https://github.com/nearai/ironclaw/pull/515))
### Other
- Fix UTF-8 unsafe truncation in sandbox log capture ([#359](https://github.com/nearai/ironclaw/pull/359))
- enhance coverage with feature matrix, postgres, and E2E ([#523](https://github.com/nearai/ironclaw/pull/523))
## [0.14.0](https://github.com/nearai/ironclaw/compare/v0.13.1...v0.14.0) - 2026-03-04 ## [0.14.0](https://github.com/nearai/ironclaw/compare/v0.13.1...v0.14.0) - 2026-03-04
### Added ### Added
Generated
+1 -1
View File
@@ -2828,7 +2828,7 @@ dependencies = [
[[package]] [[package]]
name = "ironclaw" name = "ironclaw"
version = "0.14.0" version = "0.15.0"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"aho-corasick", "aho-corasick",
+1 -1
View File
@@ -18,7 +18,7 @@ exclude = [
[package] [package]
name = "ironclaw" name = "ironclaw"
version = "0.14.0" version = "0.15.0"
edition = "2024" edition = "2024"
rust-version = "1.92" rust-version = "1.92"
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly" description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
+57
View File
@@ -0,0 +1,57 @@
# Lightweight test Dockerfile for IronClaw web gateway testing.
#
# Build:
# docker build --platform linux/amd64 -f Dockerfile.test -t ironclaw-test .
#
# Run (each on a different port):
# docker run --rm -p 3003:3003 ironclaw-test
# docker run --rm -p 3004:3003 ironclaw-test
# docker run --rm -p 3005:3003 ironclaw-test
# Stage 1: Build (libsql only — no PostgreSQL dependency)
FROM rust:1.92-slim-bookworm AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config libssl-dev cmake gcc g++ \
&& rm -rf /var/lib/apt/lists/* \
&& rustup target add wasm32-wasip2 \
&& cargo install wasm-tools
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY build.rs build.rs
COPY src/ src/
COPY tests/ tests/
COPY migrations/ migrations/
COPY registry/ registry/
COPY channels-src/ channels-src/
COPY wit/ wit/
RUN cargo build --release --no-default-features --features libsql --bin ironclaw
# Stage 2: Runtime
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libssl3 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/ironclaw /usr/local/bin/ironclaw
RUN useradd -m -u 1000 -s /bin/bash ironclaw
USER ironclaw
WORKDIR /home/ironclaw
EXPOSE 3003
ENV RUST_LOG=ironclaw=info \
GATEWAY_ENABLED=true \
GATEWAY_HOST=0.0.0.0 \
GATEWAY_PORT=3003 \
GATEWAY_AUTH_TOKEN=test \
DATABASE_BACKEND=libsql \
LIBSQL_PATH=/home/ironclaw/test.db \
SANDBOX_ENABLED=false
ENTRYPOINT ["ironclaw", "--no-onboard"]
@@ -6,15 +6,16 @@
"required_secrets": [ "required_secrets": [
{ {
"name": "discord_bot_token", "name": "discord_bot_token",
"prompt": "Enter your Discord Bot Token (from Developer Portal)", "prompt": "Enter your Discord Bot Token. Find it under Bot > Token in your Discord Application settings.",
"optional": false "optional": false
}, },
{ {
"name": "discord_public_key", "name": "discord_public_key",
"prompt": "Enter your Discord Application Public Key (from Developer Portal > General Information)", "prompt": "Enter your Discord Application Public Key (found under General Information in your Discord Application settings).",
"optional": false "optional": false
} }
] ],
"setup_url": "https://discord.com/developers/applications"
}, },
"capabilities": { "capabilities": {
"http": { "http": {
+4 -3
View File
@@ -6,15 +6,16 @@
"required_secrets": [ "required_secrets": [
{ {
"name": "slack_bot_token", "name": "slack_bot_token",
"prompt": "Enter your Slack Bot OAuth Token (xoxb-...)", "prompt": "Enter your Slack Bot User OAuth Token (starts with xoxb-). Find it under OAuth & Permissions in your Slack App settings.",
"optional": false "optional": false
}, },
{ {
"name": "slack_signing_secret", "name": "slack_signing_secret",
"prompt": "Enter your Slack Signing Secret (from App Credentials)", "prompt": "Enter your Slack App Signing Secret (found under Basic Information > App Credentials in your Slack App settings).",
"optional": false "optional": false
} }
] ],
"setup_url": "https://api.slack.com/apps"
}, },
"capabilities": { "capabilities": {
"http": { "http": {
@@ -9,7 +9,8 @@
"prompt": "Enter your Telegram Bot API token (from @BotFather)", "prompt": "Enter your Telegram Bot API token (from @BotFather)",
"optional": false "optional": false
} }
] ],
"setup_url": "https://t.me/BotFather"
}, },
"capabilities": { "capabilities": {
"http": { "http": {
@@ -39,6 +40,10 @@
"emit_rate_limit": { "emit_rate_limit": {
"messages_per_minute": 100, "messages_per_minute": 100,
"messages_per_hour": 5000 "messages_per_hour": 5000
},
"webhook": {
"secret_header": "X-Telegram-Bot-Api-Secret-Token",
"secret_name": "telegram_webhook_secret"
} }
} }
}, },
@@ -6,7 +6,7 @@
"required_secrets": [ "required_secrets": [
{ {
"name": "whatsapp_access_token", "name": "whatsapp_access_token",
"prompt": "Enter your WhatsApp Cloud API access token (from Meta Developer Portal)", "prompt": "Enter your WhatsApp Cloud API permanent access token (from the Meta Developer Portal under your app's WhatsApp > API Setup).",
"validation": "^[A-Za-z0-9_-]+$" "validation": "^[A-Za-z0-9_-]+$"
}, },
{ {
@@ -16,7 +16,8 @@
"auto_generate": { "length": 32 } "auto_generate": { "length": 32 }
} }
], ],
"validation_endpoint": "https://graph.facebook.com/v18.0/me?access_token={whatsapp_access_token}" "validation_endpoint": "https://graph.facebook.com/v18.0/me?access_token={whatsapp_access_token}",
"setup_url": "https://developers.facebook.com/apps"
}, },
"capabilities": { "capabilities": {
"http": { "http": {
+225
View File
@@ -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
View File
@@ -15,6 +15,7 @@ use crate::channels::{IncomingMessage, StatusUpdate};
use crate::context::JobContext; use crate::context::JobContext;
use crate::error::Error; use crate::error::Error;
use crate::llm::{ChatMessage, Reasoning, ReasoningContext, RespondResult}; use crate::llm::{ChatMessage, Reasoning, ReasoningContext, RespondResult};
use crate::tools::redact_params;
/// Result of the agentic loop execution. /// Result of the agentic loop execution.
pub(super) enum AgenticLoopResult { pub(super) enum AgenticLoopResult {
@@ -291,7 +292,11 @@ impl Agent {
match output.result { match output.result {
RespondResult::Text(text) => { RespondResult::Text(text) => {
return Ok(AgenticLoopResult::Response(text)); // Strip internal "[Called tool ...]" text that can leak when
// provider flattening (e.g. NEAR AI) converts tool_calls to
// plain text and the LLM echoes it back.
let sanitized = strip_internal_tool_call_text(&text);
return Ok(AgenticLoopResult::Response(sanitized));
} }
RespondResult::ToolCalls { RespondResult::ToolCalls {
tool_calls, tool_calls,
@@ -317,14 +322,25 @@ impl Agent {
) )
.await; .await;
// Record tool calls in the thread // Record tool calls in the thread with sensitive params redacted.
// Look up each tool's sensitive_params before acquiring the session lock.
{ {
let mut redacted_args: Vec<serde_json::Value> =
Vec::with_capacity(tool_calls.len());
for tc in &tool_calls {
let safe = if let Some(tool) = self.tools().get(&tc.name).await {
redact_params(&tc.arguments, tool.sensitive_params())
} else {
tc.arguments.clone()
};
redacted_args.push(safe);
}
let mut sess = session.lock().await; let mut sess = session.lock().await;
if let Some(thread) = sess.threads.get_mut(&thread_id) if let Some(thread) = sess.threads.get_mut(&thread_id)
&& let Some(turn) = thread.last_turn_mut() && let Some(turn) = thread.last_turn_mut()
{ {
for tc in &tool_calls { for (tc, safe_args) in tool_calls.iter().zip(redacted_args) {
turn.record_tool_call(&tc.name, tc.arguments.clone()); turn.record_tool_call(&tc.name, safe_args);
} }
} }
} }
@@ -353,11 +369,22 @@ impl Agent {
for (idx, original_tc) in tool_calls.iter().enumerate() { for (idx, original_tc) in tool_calls.iter().enumerate() {
let mut tc = original_tc.clone(); let mut tc = original_tc.clone();
// Fetch the tool upfront so we can redact sensitive params
// before they touch hooks or approval display.
let tool_opt = self.tools().get(&tc.name).await;
let sensitive = tool_opt
.as_ref()
.map(|t| t.sensitive_params())
.unwrap_or(&[]);
// Hook: BeforeToolCall (runs before approval so hooks can // Hook: BeforeToolCall (runs before approval so hooks can
// modify parameters — approval is checked on final params) // modify parameters — approval is checked on final params).
// Hooks receive redacted params so sensitive values are not
// exposed to hook handlers or their logs.
let hook_params = redact_params(&tc.arguments, sensitive);
let event = crate::hooks::HookEvent::ToolCall { let event = crate::hooks::HookEvent::ToolCall {
tool_name: tc.name.clone(), tool_name: tc.name.clone(),
parameters: tc.arguments.clone(), parameters: hook_params,
user_id: message.user_id.clone(), user_id: message.user_id.clone(),
context: "chat".to_string(), context: "chat".to_string(),
}; };
@@ -384,8 +411,20 @@ impl Agent {
} }
Ok(crate::hooks::HookOutcome::Continue { Ok(crate::hooks::HookOutcome::Continue {
modified: Some(new_params), modified: Some(new_params),
}) => match serde_json::from_str(&new_params) { }) => match serde_json::from_str::<serde_json::Value>(&new_params) {
Ok(parsed) => tc.arguments = parsed, Ok(mut parsed) => {
// Restore original sensitive param values so a hook
// cannot overwrite them (they were sent as [REDACTED]).
if let Some(obj) = parsed.as_object_mut() {
for key in sensitive {
if let Some(orig_val) = original_tc.arguments.get(*key)
{
obj.insert((*key).to_string(), orig_val.clone());
}
}
}
tc.arguments = parsed;
}
Err(e) => { Err(e) => {
tracing::warn!( tracing::warn!(
tool = %tc.name, tool = %tc.name,
@@ -400,7 +439,7 @@ impl Agent {
// Check if tool requires approval on the final (post-hook) // Check if tool requires approval on the final (post-hook)
// parameters. Skipped when auto_approve_tools is set. // parameters. Skipped when auto_approve_tools is set.
if !self.config.auto_approve_tools if !self.config.auto_approve_tools
&& let Some(tool) = self.tools().get(&tc.name).await && let Some(tool) = tool_opt
{ {
use crate::tools::ApprovalRequirement; use crate::tools::ApprovalRequirement;
let needs_approval = match tool.requires_approval(&tc.arguments) { let needs_approval = match tool.requires_approval(&tc.arguments) {
@@ -447,14 +486,17 @@ impl Agent {
.execute_chat_tool(&tc.name, &tc.arguments, &job_ctx) .execute_chat_tool(&tc.name, &tc.arguments, &job_ctx)
.await; .await;
let disp_tool = self.tools().get(&tc.name).await;
let _ = self let _ = self
.channels .channels
.send_status( .send_status(
&message.channel, &message.channel,
StatusUpdate::ToolCompleted { StatusUpdate::tool_completed(
name: tc.name.clone(), tc.name.clone(),
success: result.is_ok(), &result,
}, &tc.arguments,
disp_tool.as_deref(),
),
&message.metadata, &message.metadata,
) )
.await; .await;
@@ -495,13 +537,16 @@ impl Agent {
) )
.await; .await;
let par_tool = tools.get(&tc.name).await;
let _ = channels let _ = channels
.send_status( .send_status(
&channel, &channel,
StatusUpdate::ToolCompleted { StatusUpdate::tool_completed(
name: tc.name.clone(), tc.name.clone(),
success: result.is_ok(), &result,
}, &tc.arguments,
par_tool.as_deref(),
),
&metadata, &metadata,
) )
.await; .await;
@@ -671,10 +716,15 @@ impl Agent {
// Handle approval if a tool needed it // Handle approval if a tool needed it
if let Some((approval_idx, tc, tool)) = approval_needed { if let Some((approval_idx, tc, tool)) = approval_needed {
// Show redacted params in the approval UI — the user already knows
// the sensitive value (they provided it); showing it again is
// unnecessary and creates a leakage path through channel logs.
let display_params = redact_params(&tc.arguments, tool.sensitive_params());
let pending = PendingApproval { let pending = PendingApproval {
request_id: Uuid::new_v4(), request_id: Uuid::new_v4(),
tool_name: tc.name.clone(), tool_name: tc.name.clone(),
parameters: tc.arguments.clone(), parameters: tc.arguments.clone(),
display_parameters: display_params,
description: tool.description().to_string(), description: tool.description().to_string(),
tool_call_id: tc.id.clone(), tool_call_id: tc.id.clone(),
context_messages: context_messages.clone(), context_messages: context_messages.clone(),
@@ -734,9 +784,10 @@ pub(super) async fn execute_chat_tool_standalone(
.into()); .into());
} }
let safe_params = redact_params(params, tool.sensitive_params());
tracing::debug!( tracing::debug!(
tool = %tool_name, tool = %tool_name,
params = %params, params = %safe_params,
"Tool call started" "Tool call started"
); );
@@ -900,6 +951,38 @@ fn compact_messages_for_retry(messages: &[ChatMessage]) -> Vec<ChatMessage> {
compacted compacted
} }
/// Strip internal `[Called tool ...]` and `[Tool ... returned: ...]` markers
/// from a response string. These markers are inserted by provider-level message
/// flattening (e.g. NEAR AI) and can leak into the user-visible response when
/// the LLM echoes them back.
fn strip_internal_tool_call_text(text: &str) -> String {
// Remove lines that are purely internal tool-call markers.
// Pattern: lines matching `[Called tool <name>(...)]` or `[Tool <name> returned: ...]`
let result = text
.lines()
.filter(|line| {
let trimmed = line.trim();
!((trimmed.starts_with("[Called tool ") && trimmed.ends_with(']'))
|| (trimmed.starts_with("[Tool ")
&& trimmed.contains(" returned:")
&& trimmed.ends_with(']')))
})
.fold(String::new(), |mut acc, s| {
if !acc.is_empty() {
acc.push('\n');
}
acc.push_str(s);
acc
});
let result = result.trim();
if result.is_empty() {
"I wasn't able to complete that request. Could you try rephrasing or providing more details?".to_string()
} else {
result.to_string()
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::sync::Arc; use std::sync::Arc;
@@ -1086,6 +1169,7 @@ mod tests {
request_id: uuid::Uuid::new_v4(), request_id: uuid::Uuid::new_v4(),
tool_name: "shell".to_string(), tool_name: "shell".to_string(),
parameters: serde_json::json!({"command": "echo hi"}), parameters: serde_json::json!({"command": "echo hi"}),
display_parameters: serde_json::json!({"command": "echo hi"}),
description: "Run shell command".to_string(), description: "Run shell command".to_string(),
tool_call_id: "call_1".to_string(), tool_call_id: "call_1".to_string(),
context_messages: vec![], context_messages: vec![],
@@ -1902,4 +1986,32 @@ mod tests {
} }
} }
} }
#[test]
fn test_strip_internal_tool_call_text_removes_markers() {
let input = "[Called tool search({\"query\": \"test\"})]\nHere is the answer.";
let result = super::strip_internal_tool_call_text(input);
assert_eq!(result, "Here is the answer.");
}
#[test]
fn test_strip_internal_tool_call_text_removes_returned_markers() {
let input = "[Tool search returned: some result]\nSummary of findings.";
let result = super::strip_internal_tool_call_text(input);
assert_eq!(result, "Summary of findings.");
}
#[test]
fn test_strip_internal_tool_call_text_all_markers_yields_fallback() {
let input = "[Called tool search({\"query\": \"test\"})]\n[Tool search returned: error]";
let result = super::strip_internal_tool_call_text(input);
assert!(result.contains("wasn't able to complete"));
}
#[test]
fn test_strip_internal_tool_call_text_preserves_normal_text() {
let input = "This is a normal response with [brackets] inside.";
let result = super::strip_internal_tool_call_text(input);
assert_eq!(result, input);
}
} }
+7 -1
View File
@@ -148,8 +148,12 @@ pub struct PendingApproval {
pub request_id: Uuid, pub request_id: Uuid,
/// Tool name requiring approval. /// Tool name requiring approval.
pub tool_name: String, pub tool_name: String,
/// Tool parameters. /// Tool parameters (original values, used for execution).
pub parameters: serde_json::Value, pub parameters: serde_json::Value,
/// Redacted tool parameters (sensitive values replaced with `[REDACTED]`).
/// Used for display in approval UI, logs, and SSE broadcasts.
#[serde(default)]
pub display_parameters: serde_json::Value,
/// Description of what the tool will do. /// Description of what the tool will do.
pub description: String, pub description: String,
/// Tool call ID from LLM (for proper context continuation). /// Tool call ID from LLM (for proper context continuation).
@@ -950,6 +954,7 @@ mod tests {
request_id: Uuid::new_v4(), request_id: Uuid::new_v4(),
tool_name: "shell".to_string(), tool_name: "shell".to_string(),
parameters: serde_json::json!({"command": "rm -rf /"}), parameters: serde_json::json!({"command": "rm -rf /"}),
display_parameters: serde_json::json!({"command": "rm -rf /"}),
description: "dangerous command".to_string(), description: "dangerous command".to_string(),
tool_call_id: "call_123".to_string(), tool_call_id: "call_123".to_string(),
context_messages: vec![ChatMessage::user("do it")], context_messages: vec![ChatMessage::user("do it")],
@@ -974,6 +979,7 @@ mod tests {
request_id: Uuid::new_v4(), request_id: Uuid::new_v4(),
tool_name: "http".to_string(), tool_name: "http".to_string(),
parameters: serde_json::json!({}), parameters: serde_json::json!({}),
display_parameters: serde_json::json!({}),
description: "test".to_string(), description: "test".to_string(),
tool_call_id: "call_456".to_string(), tool_call_id: "call_456".to_string(),
context_messages: vec![], context_messages: vec![],
+31 -20
View File
@@ -21,6 +21,7 @@ use crate::channels::{IncomingMessage, StatusUpdate};
use crate::context::JobContext; use crate::context::JobContext;
use crate::error::Error; use crate::error::Error;
use crate::llm::ChatMessage; use crate::llm::ChatMessage;
use crate::tools::redact_params;
impl Agent { impl Agent {
/// Hydrate a historical thread from DB into memory if not already present. /// Hydrate a historical thread from DB into memory if not already present.
@@ -357,7 +358,7 @@ impl Agent {
let request_id = pending.request_id; let request_id = pending.request_id;
let tool_name = pending.tool_name.clone(); let tool_name = pending.tool_name.clone();
let description = pending.description.clone(); let description = pending.description.clone();
let parameters = pending.parameters.clone(); let parameters = pending.display_parameters.clone();
thread.await_approval(pending); thread.await_approval(pending);
let _ = self let _ = self
.channels .channels
@@ -751,14 +752,17 @@ impl Agent {
.execute_chat_tool(&pending.tool_name, &pending.parameters, &job_ctx) .execute_chat_tool(&pending.tool_name, &pending.parameters, &job_ctx)
.await; .await;
let tool_ref = self.tools().get(&pending.tool_name).await;
let _ = self let _ = self
.channels .channels
.send_status( .send_status(
&message.channel, &message.channel,
StatusUpdate::ToolCompleted { StatusUpdate::tool_completed(
name: pending.tool_name.clone(), pending.tool_name.clone(),
success: tool_result.is_ok(), &tool_result,
}, &pending.display_parameters,
tool_ref.as_deref(),
),
&message.metadata, &message.metadata,
) )
.await; .await;
@@ -908,14 +912,17 @@ impl Agent {
.execute_chat_tool(&tc.name, &tc.arguments, &job_ctx) .execute_chat_tool(&tc.name, &tc.arguments, &job_ctx)
.await; .await;
let deferred_tool = self.tools().get(&tc.name).await;
let _ = self let _ = self
.channels .channels
.send_status( .send_status(
&message.channel, &message.channel,
StatusUpdate::ToolCompleted { StatusUpdate::tool_completed(
name: tc.name.clone(), tc.name.clone(),
success: result.is_ok(), &result,
}, &tc.arguments,
deferred_tool.as_deref(),
),
&message.metadata, &message.metadata,
) )
.await; .await;
@@ -957,13 +964,16 @@ impl Agent {
) )
.await; .await;
let par_tool = tools.get(&tc.name).await;
let _ = channels let _ = channels
.send_status( .send_status(
&channel, &channel,
StatusUpdate::ToolCompleted { StatusUpdate::tool_completed(
name: tc.name.clone(), tc.name.clone(),
success: result.is_ok(), &result,
}, &tc.arguments,
par_tool.as_deref(),
),
&metadata, &metadata,
) )
.await; .await;
@@ -1086,6 +1096,7 @@ impl Agent {
request_id: Uuid::new_v4(), request_id: Uuid::new_v4(),
tool_name: tc.name.clone(), tool_name: tc.name.clone(),
parameters: tc.arguments.clone(), parameters: tc.arguments.clone(),
display_parameters: redact_params(&tc.arguments, tool.sensitive_params()),
description: tool.description().to_string(), description: tool.description().to_string(),
tool_call_id: tc.id.clone(), tool_call_id: tc.id.clone(),
context_messages: context_messages.clone(), context_messages: context_messages.clone(),
@@ -1095,7 +1106,7 @@ impl Agent {
let request_id = new_pending.request_id; let request_id = new_pending.request_id;
let tool_name = new_pending.tool_name.clone(); let tool_name = new_pending.tool_name.clone();
let description = new_pending.description.clone(); let description = new_pending.description.clone();
let parameters = new_pending.parameters.clone(); let parameters = new_pending.display_parameters.clone();
{ {
let mut sess = session.lock().await; let mut sess = session.lock().await;
@@ -1162,7 +1173,7 @@ impl Agent {
let request_id = new_pending.request_id; let request_id = new_pending.request_id;
let tool_name = new_pending.tool_name.clone(); let tool_name = new_pending.tool_name.clone();
let description = new_pending.description.clone(); let description = new_pending.description.clone();
let parameters = new_pending.parameters.clone(); let parameters = new_pending.display_parameters.clone();
thread.await_approval(new_pending); thread.await_approval(new_pending);
let _ = self let _ = self
.channels .channels
@@ -1284,7 +1295,7 @@ impl Agent {
}; };
match ext_mgr.auth(&pending.extension_name, Some(token)).await { match ext_mgr.auth(&pending.extension_name, Some(token)).await {
Ok(result) if result.status == "authenticated" => { Ok(result) if result.is_authenticated() => {
tracing::info!( tracing::info!(
"Extension '{}' authenticated via auth mode", "Extension '{}' authenticated via auth mode",
pending.extension_name pending.extension_name
@@ -1353,8 +1364,8 @@ impl Agent {
} }
} }
let msg = result let msg = result
.instructions .instructions()
.clone() .map(String::from)
.unwrap_or_else(|| "Invalid token. Please try again.".to_string()); .unwrap_or_else(|| "Invalid token. Please try again.".to_string());
// Re-emit AuthRequired so web UI re-shows the card // Re-emit AuthRequired so web UI re-shows the card
let _ = self let _ = self
@@ -1364,8 +1375,8 @@ impl Agent {
StatusUpdate::AuthRequired { StatusUpdate::AuthRequired {
extension_name: pending.extension_name.clone(), extension_name: pending.extension_name.clone(),
instructions: Some(msg.clone()), instructions: Some(msg.clone()),
auth_url: result.auth_url, auth_url: result.auth_url().map(String::from),
setup_url: result.setup_url, setup_url: result.setup_url().map(String::from),
}, },
&message.metadata, &message.metadata,
) )
+10 -6
View File
@@ -18,8 +18,8 @@ use crate::llm::{
ActionPlan, ChatMessage, LlmProvider, Reasoning, ReasoningContext, RespondResult, ToolSelection, ActionPlan, ChatMessage, LlmProvider, Reasoning, ReasoningContext, RespondResult, ToolSelection,
}; };
use crate::safety::SafetyLayer; use crate::safety::SafetyLayer;
use crate::tools::ToolRegistry;
use crate::tools::rate_limiter::RateLimitResult; use crate::tools::rate_limiter::RateLimitResult;
use crate::tools::{ToolRegistry, redact_params};
/// Shared dependencies for worker execution. /// Shared dependencies for worker execution.
/// ///
@@ -700,9 +700,10 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
// Run BeforeToolCall hook // Run BeforeToolCall hook
let params = { let params = {
use crate::hooks::{HookError, HookEvent, HookOutcome}; use crate::hooks::{HookError, HookEvent, HookOutcome};
let hook_params = redact_params(params, tool.sensitive_params());
let event = HookEvent::ToolCall { let event = HookEvent::ToolCall {
tool_name: tool_name.to_string(), tool_name: tool_name.to_string(),
parameters: params.clone(), parameters: hook_params,
user_id: job_ctx.user_id.clone(), user_id: job_ctx.user_id.clone(),
context: format!("job:{}", job_id), context: format!("job:{}", job_id),
}; };
@@ -758,9 +759,12 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
.into()); .into());
} }
// Redact sensitive parameter values (e.g. secret_save's "value") before
// they touch any observability or audit path.
let safe_params = redact_params(&params, tool.sensitive_params());
tracing::debug!( tracing::debug!(
tool = %tool_name, tool = %tool_name,
params = %params, params = %safe_params,
job = %job_id, job = %job_id,
"Tool call started" "Tool call started"
); );
@@ -812,7 +816,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
match deps match deps
.context_manager .context_manager
.update_memory(job_id, |mem| { .update_memory(job_id, |mem| {
let rec = mem.create_action(tool_name, params.clone()).succeed( let rec = mem.create_action(tool_name, safe_params.clone()).succeed(
output_str.clone(), output_str.clone(),
output.result.clone(), output.result.clone(),
elapsed, elapsed,
@@ -834,7 +838,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
.context_manager .context_manager
.update_memory(job_id, |mem| { .update_memory(job_id, |mem| {
let rec = mem let rec = mem
.create_action(tool_name, params.clone()) .create_action(tool_name, safe_params.clone())
.fail(e.to_string(), elapsed); .fail(e.to_string(), elapsed);
mem.record_action(rec.clone()); mem.record_action(rec.clone());
rec rec
@@ -853,7 +857,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
.context_manager .context_manager
.update_memory(job_id, |mem| { .update_memory(job_id, |mem| {
let rec = mem let rec = mem
.create_action(tool_name, params.clone()) .create_action(tool_name, safe_params.clone())
.fail("Execution timeout", elapsed); .fail("Execution timeout", elapsed);
mem.record_action(rec.clone()); mem.record_action(rec.clone());
rec rec
+4
View File
@@ -331,6 +331,10 @@ impl AppBuilder {
}; };
tools.register_builtin_tools(); tools.register_builtin_tools();
if let Some(ref ss) = self.secrets_store {
tools.register_secrets_tools(Arc::clone(ss));
}
// Create embeddings provider using the unified method // Create embeddings provider using the unified method
let embeddings = self let embeddings = self
.config .config
+174 -1
View File
@@ -117,7 +117,20 @@ pub enum StatusUpdate {
/// Tool execution started. /// Tool execution started.
ToolStarted { name: String }, ToolStarted { name: String },
/// Tool execution completed. /// Tool execution completed.
ToolCompleted { name: String, success: bool }, ///
/// Use [`StatusUpdate::tool_completed`] to construct this variant — it
/// handles redaction of sensitive parameters and keeps the 9-line pattern
/// in one place.
ToolCompleted {
name: String,
success: bool,
/// Error message when success is false.
error: Option<String>,
/// Tool input parameters (JSON string) for display on failure.
/// Only populated when `success` is `false`. Values listed in the
/// tool's `sensitive_params()` are replaced with `"[REDACTED]"`.
parameters: Option<String>,
},
/// Brief preview of tool execution output. /// Brief preview of tool execution output.
ToolResult { name: String, preview: String }, ToolResult { name: String, preview: String },
/// Streaming text chunk. /// Streaming text chunk.
@@ -152,6 +165,38 @@ pub enum StatusUpdate {
}, },
} }
impl StatusUpdate {
/// Build a `ToolCompleted` status with redacted parameters.
///
/// On failure, serializes the tool's input parameters as pretty JSON after
/// replacing any keys listed in the tool's `sensitive_params()` with
/// `"[REDACTED]"`. On success, no parameters or error are included.
///
/// Pass the resolved `Tool` reference (if available) so this method can
/// query `sensitive_params()` directly — callers don't need to manage the
/// borrow lifetime of the sensitive slice.
pub fn tool_completed(
name: String,
result: &Result<String, crate::error::Error>,
params: &serde_json::Value,
tool: Option<&dyn crate::tools::Tool>,
) -> Self {
let success = result.is_ok();
let sensitive = tool.map(|t| t.sensitive_params()).unwrap_or(&[]);
Self::ToolCompleted {
name,
success,
error: result.as_ref().err().map(|e| e.to_string()),
parameters: if !success {
let safe = crate::tools::redact_params(params, sensitive);
Some(serde_json::to_string_pretty(&safe).unwrap_or_else(|_| safe.to_string()))
} else {
None
},
}
}
}
/// Trait for message channels. /// Trait for message channels.
/// ///
/// Channels receive messages from external sources and convert them to /// Channels receive messages from external sources and convert them to
@@ -223,3 +268,131 @@ pub trait Channel: Send + Sync {
Ok(()) Ok(())
} }
} }
#[cfg(test)]
mod tests {
use super::*;
/// Stub tool that marks `"value"` as sensitive.
struct SecretTool;
#[async_trait]
impl crate::tools::Tool for SecretTool {
fn name(&self) -> &str {
"secret_save"
}
fn description(&self) -> &str {
"stub"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({"type": "object", "properties": {}})
}
async fn execute(
&self,
_params: serde_json::Value,
_ctx: &crate::context::JobContext,
) -> Result<crate::tools::ToolOutput, crate::tools::ToolError> {
unreachable!()
}
fn sensitive_params(&self) -> &[&str] {
&["value"]
}
}
#[test]
fn tool_completed_redacts_sensitive_params_on_failure() {
let params = serde_json::json!({"name": "api_key", "value": "sk-secret-123"});
let err: Result<String, crate::error::Error> =
Err(crate::error::ToolError::ExecutionFailed {
name: "secret_save".into(),
reason: "db error".into(),
}
.into());
let tool = SecretTool;
let status = StatusUpdate::tool_completed(
"secret_save".into(),
&err,
&params,
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, &params, 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, &params, 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");
}
}
}
+1 -1
View File
@@ -466,7 +466,7 @@ impl Channel for ReplChannel {
StatusUpdate::ToolStarted { name } => { StatusUpdate::ToolStarted { name } => {
eprintln!(" \x1b[33m\u{25CB} {name}\x1b[0m"); eprintln!(" \x1b[33m\u{25CB} {name}\x1b[0m");
} }
StatusUpdate::ToolCompleted { name, success } => { StatusUpdate::ToolCompleted { name, success, .. } => {
if success { if success {
eprintln!(" \x1b[32m\u{25CF} {name}\x1b[0m"); eprintln!(" \x1b[32m\u{25CF} {name}\x1b[0m");
} else { } else {
+1 -1
View File
@@ -974,7 +974,7 @@ impl Channel for SignalChannel {
// Send tool completed notification (debug mode only) // Send tool completed notification (debug mode only)
if self.is_debug() if self.is_debug()
&& let StatusUpdate::ToolCompleted { name, success } = &status && let StatusUpdate::ToolCompleted { name, success, .. } = &status
&& let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str()) && let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str())
{ {
let (icon, color) = if *success { let (icon, color) = if *success {
+1
View File
@@ -81,6 +81,7 @@ impl WasmChannelLoader {
let cap_bytes = fs::read(cap_path).await?; let cap_bytes = fs::read(cap_path).await?;
let cap_file = ChannelCapabilitiesFile::from_bytes(&cap_bytes) let cap_file = ChannelCapabilitiesFile::from_bytes(&cap_bytes)
.map_err(|e| WasmChannelError::InvalidCapabilities(e.to_string()))?; .map_err(|e| WasmChannelError::InvalidCapabilities(e.to_string()))?;
cap_file.validate();
// Debug: log raw capabilities // Debug: log raw capabilities
tracing::debug!( tracing::debug!(
+94
View File
@@ -90,6 +90,37 @@ impl ChannelCapabilitiesFile {
serde_json::from_slice(bytes) serde_json::from_slice(bytes)
} }
/// Validate the capabilities file and emit warnings for common misconfigurations.
///
/// Called once at load time to catch issues early. Warnings are emitted via
/// `tracing::warn` so they show up in startup logs without blocking loading.
pub fn validate(&self) {
const MIN_PROMPT_LENGTH: usize = 30;
// Check for short prompts in required_secrets
for secret in &self.setup.required_secrets {
if secret.prompt.len() < MIN_PROMPT_LENGTH {
tracing::warn!(
channel = self.name,
secret = secret.name,
prompt = secret.prompt,
"setup.required_secrets prompt is shorter than {} chars — \
consider a more descriptive prompt that tells the user where to find this value",
MIN_PROMPT_LENGTH
);
}
}
// Has required_secrets but no setup_url
if !self.setup.required_secrets.is_empty() && self.setup.setup_url.is_none() {
tracing::warn!(
channel = self.name,
"setup.required_secrets defined but no setup.setup_url — \
user has no link to obtain credentials"
);
}
}
/// Convert to runtime ChannelCapabilities. /// Convert to runtime ChannelCapabilities.
pub fn to_capabilities(&self) -> ChannelCapabilities { pub fn to_capabilities(&self) -> ChannelCapabilities {
self.capabilities.to_channel_capabilities(&self.name) self.capabilities.to_channel_capabilities(&self.name)
@@ -262,6 +293,10 @@ pub struct SetupSchema {
/// Placeholders like {secret_name} are replaced with actual values. /// Placeholders like {secret_name} are replaced with actual values.
#[serde(default)] #[serde(default)]
pub validation_endpoint: Option<String>, pub validation_endpoint: Option<String>,
/// User-facing URL where they can create/manage credentials.
#[serde(default)]
pub setup_url: Option<String>,
} }
/// Configuration for a secret required during setup. /// Configuration for a secret required during setup.
@@ -605,6 +640,65 @@ mod tests {
// ── Category 5: Discord Capabilities Setup & Configuration ────────── // ── Category 5: Discord Capabilities Setup & Configuration ──────────
#[test]
fn test_validate_channel_short_prompt() {
// prompt < 30 chars — should not panic
let json = r#"{
"name": "test-channel",
"setup": {
"required_secrets": [
{ "name": "bot_token", "prompt": "Bot token" }
],
"setup_url": "https://example.com"
}
}"#;
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
// Should not panic; warning emitted for short prompt
file.validate();
}
#[test]
fn test_validate_channel_missing_setup_url() {
// required_secrets without setup_url — should not panic
let json = r#"{
"name": "test-channel",
"setup": {
"required_secrets": [
{
"name": "bot_token",
"prompt": "Enter your bot token from the developer portal settings"
}
]
}
}"#;
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
// Should not panic; warning emitted for missing setup_url
file.validate();
}
#[test]
fn test_validate_clean_channel() {
// Well-configured channel — should not panic or warn
let json = r#"{
"name": "good-channel",
"setup": {
"required_secrets": [
{
"name": "bot_token",
"prompt": "Enter your bot token from https://example.com/bot-settings"
}
],
"setup_url": "https://example.com/bot-settings"
}
}"#;
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
// Should not panic and emits no warnings
file.validate();
}
#[test] #[test]
fn test_discord_capabilities_has_public_key_secret() { fn test_discord_capabilities_has_public_key_secret() {
let json = include_str!("../../../channels-src/discord/discord.capabilities.json"); let json = include_str!("../../../channels-src/discord/discord.capabilities.json");
+5 -1
View File
@@ -2479,7 +2479,7 @@ fn status_to_wit(status: &StatusUpdate, metadata: &serde_json::Value) -> wit_cha
message: format!("Tool started: {}", name), message: format!("Tool started: {}", name),
metadata_json, metadata_json,
}, },
StatusUpdate::ToolCompleted { name, success } => wit_channel::StatusUpdate { StatusUpdate::ToolCompleted { name, success, .. } => wit_channel::StatusUpdate {
status: wit_channel::StatusType::ToolCompleted, status: wit_channel::StatusType::ToolCompleted,
message: format!( message: format!(
"Tool completed: {} ({})", "Tool completed: {} ({})",
@@ -3387,6 +3387,8 @@ mod tests {
&crate::channels::StatusUpdate::ToolCompleted { &crate::channels::StatusUpdate::ToolCompleted {
name: "http_request".to_string(), name: "http_request".to_string(),
success: true, success: true,
error: None,
parameters: None,
}, },
&metadata, &metadata,
); );
@@ -3407,6 +3409,8 @@ mod tests {
&crate::channels::StatusUpdate::ToolCompleted { &crate::channels::StatusUpdate::ToolCompleted {
name: "http_request".to_string(), name: "http_request".to_string(),
success: false, success: false,
error: Some("connection refused".to_string()),
parameters: None,
}, },
&metadata, &metadata,
); );
+148 -30
View File
@@ -2,7 +2,7 @@
use axum::{ use axum::{
extract::{Request, State}, extract::{Request, State},
http::{HeaderMap, StatusCode}, http::{HeaderMap, Method, StatusCode},
middleware::Next, middleware::Next,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
@@ -14,10 +14,44 @@ pub struct AuthState {
pub token: String, pub token: String,
} }
/// Whether query-string token auth is allowed for this request.
///
/// Only GET requests to streaming endpoints may use `?token=xxx`. This
/// minimizes token-in-URL exposure on state-changing routes, where the token
/// would leak via server logs, Referer headers, and browser history.
///
/// Allowed endpoints:
/// - SSE: `/api/chat/events`, `/api/logs/events` (EventSource can't set headers)
/// - WebSocket: `/api/chat/ws` (WS upgrade can't set custom headers)
///
/// If you add a new SSE or WebSocket endpoint, add its path here.
fn allows_query_token_auth(request: &Request) -> bool {
if request.method() != Method::GET {
return false;
}
matches!(
request.uri().path(),
"/api/chat/events" | "/api/logs/events" | "/api/chat/ws"
)
}
/// Extract the `token` query parameter value, URL-decoded.
fn query_token(request: &Request) -> Option<String> {
let query = request.uri().query()?;
url::form_urlencoded::parse(query.as_bytes()).find_map(|(k, v)| {
if k == "token" {
Some(v.into_owned())
} else {
None
}
})
}
/// Auth middleware that validates bearer token from header or query param. /// Auth middleware that validates bearer token from header or query param.
/// ///
/// SSE connections can't set headers from `EventSource`, so we also accept /// SSE connections can't set headers from `EventSource`, so we also accept
/// `?token=xxx` as a query parameter. /// `?token=xxx` as a query parameter, but only on SSE endpoints.
pub async fn auth_middleware( pub async fn auth_middleware(
State(auth): State<AuthState>, State(auth): State<AuthState>,
headers: HeaderMap, headers: HeaderMap,
@@ -35,15 +69,12 @@ pub async fn auth_middleware(
return next.run(request).await; return next.run(request).await;
} }
// Fall back to query parameter for SSE EventSource (constant-time comparison) // Fall back to query parameter, but only for SSE endpoints (constant-time comparison).
if let Some(query) = request.uri().query() { if allows_query_token_auth(&request)
for pair in query.split('&') { && let Some(token) = query_token(&request)
if let Some(token) = pair.strip_prefix("token=") && bool::from(token.as_bytes().ct_eq(auth.token.as_bytes()))
&& bool::from(token.as_bytes().ct_eq(auth.token.as_bytes())) {
{ return next.run(request).await;
return next.run(request).await;
}
}
} }
(StatusCode::UNAUTHORIZED, "Invalid or missing auth token").into_response() (StatusCode::UNAUTHORIZED, "Invalid or missing auth token").into_response()
@@ -62,24 +93,28 @@ mod tests {
assert_eq!(cloned.token, "test-token"); assert_eq!(cloned.token, "test-token");
} }
// === QA Plan - Web gateway auth tests ===
use axum::Router; use axum::Router;
use axum::body::Body; use axum::body::Body;
use axum::middleware; use axum::middleware;
use axum::routing::get; use axum::routing::{get, post};
use tower::ServiceExt; use tower::ServiceExt;
async fn dummy_handler() -> &'static str { async fn dummy_handler() -> &'static str {
"ok" "ok"
} }
/// Router with streaming endpoints (query auth allowed) and regular
/// endpoints (query auth rejected).
fn test_app(token: &str) -> Router { fn test_app(token: &str) -> Router {
let state = AuthState { let state = AuthState {
token: token.to_string(), token: token.to_string(),
}; };
Router::new() Router::new()
.route("/test", get(dummy_handler)) .route("/api/chat/events", get(dummy_handler))
.route("/api/logs/events", get(dummy_handler))
.route("/api/chat/ws", get(dummy_handler))
.route("/api/chat/history", get(dummy_handler))
.route("/api/chat/send", post(dummy_handler))
.layer(middleware::from_fn_with_state(state, auth_middleware)) .layer(middleware::from_fn_with_state(state, auth_middleware))
} }
@@ -87,7 +122,7 @@ mod tests {
async fn test_valid_bearer_token_passes() { async fn test_valid_bearer_token_passes() {
let app = test_app("secret-token"); let app = test_app("secret-token");
let req = Request::builder() let req = Request::builder()
.uri("/test") .uri("/api/chat/events")
.header("Authorization", "Bearer secret-token") .header("Authorization", "Bearer secret-token")
.body(Body::empty()) .body(Body::empty())
.unwrap(); .unwrap();
@@ -99,7 +134,7 @@ mod tests {
async fn test_invalid_bearer_token_rejected() { async fn test_invalid_bearer_token_rejected() {
let app = test_app("secret-token"); let app = test_app("secret-token");
let req = Request::builder() let req = Request::builder()
.uri("/test") .uri("/api/chat/events")
.header("Authorization", "Bearer wrong-token") .header("Authorization", "Bearer wrong-token")
.body(Body::empty()) .body(Body::empty())
.unwrap(); .unwrap();
@@ -108,10 +143,10 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn test_missing_auth_header_falls_through_to_query() { async fn test_query_token_allowed_for_chat_events() {
let app = test_app("secret-token"); let app = test_app("secret-token");
let req = Request::builder() let req = Request::builder()
.uri("/test?token=secret-token") .uri("/api/chat/events?token=secret-token")
.body(Body::empty()) .body(Body::empty())
.unwrap(); .unwrap();
let resp = app.oneshot(req).await.unwrap(); let resp = app.oneshot(req).await.unwrap();
@@ -119,10 +154,80 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn test_query_param_invalid_token_rejected() { async fn test_query_token_allowed_for_logs_events() {
let app = test_app("secret-token"); let app = test_app("secret-token");
let req = Request::builder() let req = Request::builder()
.uri("/test?token=wrong-token") .uri("/api/logs/events?token=secret-token")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_query_token_allowed_for_ws_upgrade() {
let app = test_app("secret-token");
let req = Request::builder()
.uri("/api/chat/ws?token=secret-token")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_query_token_url_encoded() {
// Token with characters that get percent-encoded in URLs.
let raw_token = "tok+en/with spaces";
let app = test_app(raw_token);
let req = Request::builder()
.uri("/api/chat/events?token=tok%2Ben%2Fwith%20spaces")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_query_token_url_encoded_mismatch() {
let app = test_app("real-token");
// Encoded value decodes to "wrong-token", not "real-token".
let req = Request::builder()
.uri("/api/chat/events?token=wrong%2Dtoken")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_query_token_rejected_for_non_sse_get() {
let app = test_app("secret-token");
let req = Request::builder()
.uri("/api/chat/history?token=secret-token")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_query_token_rejected_for_post() {
let app = test_app("secret-token");
let req = Request::builder()
.method(Method::POST)
.uri("/api/chat/send?token=secret-token")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_query_token_invalid_rejected() {
let app = test_app("secret-token");
let req = Request::builder()
.uri("/api/chat/events?token=wrong-token")
.body(Body::empty()) .body(Body::empty())
.unwrap(); .unwrap();
let resp = app.oneshot(req).await.unwrap(); let resp = app.oneshot(req).await.unwrap();
@@ -132,17 +237,32 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_no_auth_at_all_rejected() { async fn test_no_auth_at_all_rejected() {
let app = test_app("secret-token"); let app = test_app("secret-token");
let req = Request::builder().uri("/test").body(Body::empty()).unwrap(); let req = Request::builder()
.uri("/api/chat/events")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap(); let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
} }
#[tokio::test] #[tokio::test]
async fn test_bearer_prefix_case_insensitive() { async fn test_bearer_header_works_for_post() {
// RFC 6750 Section 2.1: auth-scheme comparison must be case-insensitive.
let app = test_app("secret-token"); let app = test_app("secret-token");
let req = Request::builder() let req = Request::builder()
.uri("/test") .method(Method::POST)
.uri("/api/chat/send")
.header("Authorization", "Bearer secret-token")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_bearer_prefix_case_insensitive() {
let app = test_app("secret-token");
let req = Request::builder()
.uri("/api/chat/events")
.header("Authorization", "bearer secret-token") .header("Authorization", "bearer secret-token")
.body(Body::empty()) .body(Body::empty())
.unwrap(); .unwrap();
@@ -154,7 +274,7 @@ mod tests {
async fn test_bearer_prefix_mixed_case() { async fn test_bearer_prefix_mixed_case() {
let app = test_app("secret-token"); let app = test_app("secret-token");
let req = Request::builder() let req = Request::builder()
.uri("/test") .uri("/api/chat/events")
.header("Authorization", "BEARER secret-token") .header("Authorization", "BEARER secret-token")
.body(Body::empty()) .body(Body::empty())
.unwrap(); .unwrap();
@@ -166,7 +286,7 @@ mod tests {
async fn test_empty_bearer_token_rejected() { async fn test_empty_bearer_token_rejected() {
let app = test_app("secret-token"); let app = test_app("secret-token");
let req = Request::builder() let req = Request::builder()
.uri("/test") .uri("/api/chat/events")
.header("Authorization", "Bearer ") .header("Authorization", "Bearer ")
.body(Body::empty()) .body(Body::empty())
.unwrap(); .unwrap();
@@ -176,11 +296,9 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_token_with_whitespace_rejected() { async fn test_token_with_whitespace_rejected() {
// Extra space after "Bearer " means the token value starts with a space,
// which should not match the expected token.
let app = test_app("secret-token"); let app = test_app("secret-token");
let req = Request::builder() let req = Request::builder()
.uri("/test") .uri("/api/chat/events")
.header("Authorization", "Bearer secret-token") .header("Authorization", "Bearer secret-token")
.body(Body::empty()) .body(Body::empty())
.unwrap(); .unwrap();
+6 -5
View File
@@ -142,7 +142,7 @@ pub async fn chat_auth_token_handler(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.status == "authenticated" { if result.is_authenticated() {
// Auto-activate so tools are available immediately // Auto-activate so tools are available immediately
let msg = match ext_mgr.activate(&req.extension_name).await { let msg = match ext_mgr.activate(&req.extension_name).await {
Ok(r) => format!( Ok(r) => format!(
@@ -170,13 +170,14 @@ pub async fn chat_auth_token_handler(
// Re-emit auth_required for retry // Re-emit auth_required for retry
state.sse.broadcast(SseEvent::AuthRequired { state.sse.broadcast(SseEvent::AuthRequired {
extension_name: req.extension_name.clone(), extension_name: req.extension_name.clone(),
instructions: result.instructions.clone(), instructions: result.instructions().map(String::from),
auth_url: result.auth_url.clone(), auth_url: result.auth_url().map(String::from),
setup_url: result.setup_url.clone(), setup_url: result.setup_url().map(String::from),
}); });
Ok(Json(ActionResponse::fail( Ok(Json(ActionResponse::fail(
result result
.instructions .instructions()
.map(String::from)
.unwrap_or_else(|| "Invalid token".to_string()), .unwrap_or_else(|| "Invalid token".to_string()),
))) )))
} }
+6 -6
View File
@@ -141,7 +141,7 @@ pub async fn extensions_activate_handler(
// Activation failed due to auth; try authenticating first. // Activation failed due to auth; try authenticating first.
match ext_mgr.auth(&name, None).await { match ext_mgr.auth(&name, None).await {
Ok(auth_result) if auth_result.status == "authenticated" => { Ok(auth_result) if auth_result.is_authenticated() => {
// Auth succeeded, retry activation. // Auth succeeded, retry activation.
match ext_mgr.activate(&name).await { match ext_mgr.activate(&name).await {
Ok(result) => Ok(Json(ActionResponse::ok(result.message))), Ok(result) => Ok(Json(ActionResponse::ok(result.message))),
@@ -152,13 +152,13 @@ pub async fn extensions_activate_handler(
// Auth in progress (OAuth URL or awaiting manual token). // Auth in progress (OAuth URL or awaiting manual token).
let mut resp = ActionResponse::fail( let mut resp = ActionResponse::fail(
auth_result auth_result
.instructions .instructions()
.clone() .map(String::from)
.unwrap_or_else(|| format!("'{}' requires authentication.", name)), .unwrap_or_else(|| format!("'{}' requires authentication.", name)),
); );
resp.auth_url = auth_result.auth_url; resp.auth_url = auth_result.auth_url().map(String::from);
resp.awaiting_token = Some(auth_result.awaiting_token); resp.awaiting_token = Some(auth_result.is_awaiting_token());
resp.instructions = auth_result.instructions; resp.instructions = auth_result.instructions().map(String::from);
Ok(Json(resp)) Ok(Json(resp))
} }
Err(auth_err) => Ok(Json(ActionResponse::fail(format!( Err(auth_err) => Ok(Json(ActionResponse::fail(format!(
+8 -1
View File
@@ -304,9 +304,16 @@ impl Channel for GatewayChannel {
name, name,
thread_id: thread_id.clone(), thread_id: thread_id.clone(),
}, },
StatusUpdate::ToolCompleted { name, success } => SseEvent::ToolCompleted { StatusUpdate::ToolCompleted {
name, name,
success, success,
error,
parameters,
} => SseEvent::ToolCompleted {
name,
success,
error,
parameters,
thread_id: thread_id.clone(), thread_id: thread_id.clone(),
}, },
StatusUpdate::ToolResult { name, preview } => SseEvent::ToolResult { StatusUpdate::ToolResult { name, preview } => SseEvent::ToolResult {
+539 -20
View File
@@ -192,7 +192,9 @@ pub async fn start_server(
})?; })?;
// Public routes (no auth) // Public routes (no auth)
let public = Router::new().route("/api/health", get(health_handler)); let public = Router::new()
.route("/api/health", get(health_handler))
.route("/oauth/callback", get(oauth_callback_handler));
// Protected routes (require auth) // Protected routes (require auth)
let auth_state = AuthState { token: auth_token }; let auth_state = AuthState { token: auth_token };
@@ -424,6 +426,180 @@ async fn health_handler() -> Json<HealthResponse> {
}) })
} }
/// Return an OAuth error landing page response.
fn oauth_error_page(label: &str) -> axum::response::Response {
let html = crate::cli::oauth_defaults::landing_html(label, false);
axum::response::Html(html).into_response()
}
/// OAuth callback handler for the web gateway.
///
/// This is a PUBLIC route (no Bearer token required) because OAuth providers
/// redirect the user's browser here. The `state` query parameter correlates
/// the callback with a pending OAuth flow registered by `start_wasm_oauth()`.
///
/// Used on hosted instances where `IRONCLAW_OAUTH_CALLBACK_URL` points to
/// the gateway (e.g., `https://kind-deer.agent1.near.ai/oauth/callback`).
/// Local/desktop mode continues to use the TCP listener on port 9876.
async fn oauth_callback_handler(
State(state): State<Arc<GatewayState>>,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> impl IntoResponse {
use crate::cli::oauth_defaults;
// Check for error from OAuth provider (e.g., user denied consent)
if let Some(error) = params.get("error") {
let description = params
.get("error_description")
.cloned()
.unwrap_or_else(|| error.clone());
return oauth_error_page(&description);
}
let state_param = match params.get("state") {
Some(s) if !s.is_empty() => s.clone(),
_ => return oauth_error_page("IronClaw"),
};
let code = match params.get("code") {
Some(c) if !c.is_empty() => c.clone(),
_ => return oauth_error_page("IronClaw"),
};
// Look up the pending flow by CSRF state (atomic remove prevents replay)
let ext_mgr = match state.extension_manager.as_ref() {
Some(mgr) => mgr,
None => return oauth_error_page("IronClaw"),
};
// Strip instance prefix from state for registry lookup.
// Platform nginx sends `state=instance:nonce` but flows are keyed by nonce only.
let lookup_key = oauth_defaults::strip_instance_prefix(&state_param);
let flow = ext_mgr
.pending_oauth_flows()
.write()
.await
.remove(lookup_key);
let flow = match flow {
Some(f) => f,
None => {
tracing::warn!(
state = %state_param,
lookup_key = %lookup_key,
"OAuth callback received with unknown or expired state"
);
return oauth_error_page("IronClaw");
}
};
// Check flow expiry (5 minutes, matching TCP listener timeout)
if flow.created_at.elapsed() > oauth_defaults::OAUTH_FLOW_EXPIRY {
tracing::warn!(
extension = %flow.extension_name,
"OAuth flow expired"
);
return oauth_error_page(&flow.display_name);
}
// Exchange the authorization code for tokens.
// Use the platform exchange proxy when configured (keeps client_secret off container),
// otherwise call the provider's token URL directly.
let exchange_proxy_url = std::env::var("IRONCLAW_OAUTH_EXCHANGE_URL").ok();
let result: Result<(), String> = async {
let token_response = if let Some(ref proxy_url) = exchange_proxy_url {
let gateway_token = flow.gateway_token.as_deref().unwrap_or_default();
oauth_defaults::exchange_via_proxy(
proxy_url,
gateway_token,
&code,
&flow.redirect_uri,
flow.code_verifier.as_deref(),
&flow.access_token_field,
)
.await
.map_err(|e| e.to_string())?
} else {
oauth_defaults::exchange_oauth_code(
&flow.token_url,
&flow.client_id,
flow.client_secret.as_deref(),
&code,
&flow.redirect_uri,
flow.code_verifier.as_deref(),
&flow.access_token_field,
)
.await
.map_err(|e| e.to_string())?
};
// Validate the token before storing (catches wrong account, etc.)
if let Some(ref validation) = flow.validation_endpoint {
oauth_defaults::validate_oauth_token(&token_response.access_token, validation)
.await
.map_err(|e| e.to_string())?;
}
// Store tokens encrypted in the secrets store
oauth_defaults::store_oauth_tokens(
flow.secrets.as_ref(),
&flow.user_id,
&flow.secret_name,
flow.provider.as_deref(),
&token_response.access_token,
token_response.refresh_token.as_deref(),
token_response.expires_in,
&flow.scopes,
)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
.await;
let (success, message) = match &result {
Ok(()) => (
true,
format!("{} authenticated successfully", flow.display_name),
),
Err(e) => (
false,
format!("{} authentication failed: {}", flow.display_name, e),
),
};
match &result {
Ok(()) => {
tracing::info!(
extension = %flow.extension_name,
"OAuth completed successfully via gateway callback"
);
}
Err(e) => {
tracing::warn!(
extension = %flow.extension_name,
error = %e,
"OAuth failed via gateway callback"
);
}
}
// Broadcast SSE event to notify the web UI
if let Some(ref sender) = flow.sse_sender {
let _ = sender.send(SseEvent::AuthCompleted {
extension_name: flow.extension_name,
success,
message,
});
}
let html = oauth_defaults::landing_html(&flow.display_name, success);
axum::response::Html(html).into_response()
}
// --- Chat handlers --- // --- Chat handlers ---
async fn chat_send_handler( async fn chat_send_handler(
@@ -552,7 +728,7 @@ async fn chat_auth_token_handler(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.status == "authenticated" { if result.is_authenticated() {
// Auto-activate so tools are available immediately // Auto-activate so tools are available immediately
let msg = match ext_mgr.activate(&req.extension_name).await { let msg = match ext_mgr.activate(&req.extension_name).await {
Ok(r) => format!( Ok(r) => format!(
@@ -580,13 +756,14 @@ async fn chat_auth_token_handler(
// Re-emit auth_required for retry // Re-emit auth_required for retry
state.sse.broadcast(SseEvent::AuthRequired { state.sse.broadcast(SseEvent::AuthRequired {
extension_name: req.extension_name.clone(), extension_name: req.extension_name.clone(),
instructions: result.instructions.clone(), instructions: result.instructions().map(String::from),
auth_url: result.auth_url.clone(), auth_url: result.auth_url().map(String::from),
setup_url: result.setup_url.clone(), setup_url: result.setup_url().map(String::from),
}); });
Ok(Json(ActionResponse::fail( Ok(Json(ActionResponse::fail(
result result
.instructions .instructions()
.map(String::from)
.unwrap_or_else(|| "Invalid token".to_string()), .unwrap_or_else(|| "Invalid token".to_string()),
))) )))
} }
@@ -1332,12 +1509,9 @@ async fn extensions_install_handler(
// configured (e.g., built-in providers). We only surface an auth_url // configured (e.g., built-in providers). We only surface an auth_url
// when the extension reports it is awaiting authorization. // when the extension reports it is awaiting authorization.
match ext_mgr.auth(&req.name, None).await { match ext_mgr.auth(&req.name, None).await {
Ok(auth_result) Ok(auth_result) if auth_result.auth_url().is_some() => {
if auth_result.auth_url.is_some()
&& auth_result.status == "awaiting_authorization" =>
{
// Scope expansion or initial OAuth: user needs to authorize // Scope expansion or initial OAuth: user needs to authorize
resp.auth_url = auth_result.auth_url; resp.auth_url = auth_result.auth_url().map(String::from);
} }
_ => {} _ => {}
} }
@@ -1366,10 +1540,9 @@ async fn extensions_activate_handler(
// Initial OAuth setup is triggered via save_setup_secrets. // Initial OAuth setup is triggered via save_setup_secrets.
let mut resp = ActionResponse::ok(result.message); let mut resp = ActionResponse::ok(result.message);
if let Ok(auth_result) = ext_mgr.auth(&name, None).await if let Ok(auth_result) = ext_mgr.auth(&name, None).await
&& auth_result.auth_url.is_some() && auth_result.auth_url().is_some()
&& auth_result.status == "awaiting_authorization"
{ {
resp.auth_url = auth_result.auth_url; resp.auth_url = auth_result.auth_url().map(String::from);
} }
Ok(Json(resp)) Ok(Json(resp))
} }
@@ -1385,7 +1558,7 @@ async fn extensions_activate_handler(
// Activation failed due to auth; try authenticating first. // Activation failed due to auth; try authenticating first.
match ext_mgr.auth(&name, None).await { match ext_mgr.auth(&name, None).await {
Ok(auth_result) if auth_result.status == "authenticated" => { Ok(auth_result) if auth_result.is_authenticated() => {
// Auth succeeded, retry activation. // Auth succeeded, retry activation.
match ext_mgr.activate(&name).await { match ext_mgr.activate(&name).await {
Ok(result) => Ok(Json(ActionResponse::ok(result.message))), Ok(result) => Ok(Json(ActionResponse::ok(result.message))),
@@ -1396,13 +1569,13 @@ async fn extensions_activate_handler(
// Auth in progress (OAuth URL or awaiting manual token). // Auth in progress (OAuth URL or awaiting manual token).
let mut resp = ActionResponse::fail( let mut resp = ActionResponse::fail(
auth_result auth_result
.instructions .instructions()
.clone() .map(String::from)
.unwrap_or_else(|| format!("'{}' requires authentication.", name)), .unwrap_or_else(|| format!("'{}' requires authentication.", name)),
); );
resp.auth_url = auth_result.auth_url; resp.auth_url = auth_result.auth_url().map(String::from);
resp.awaiting_token = Some(auth_result.awaiting_token); resp.awaiting_token = Some(auth_result.is_awaiting_token());
resp.instructions = auth_result.instructions; resp.instructions = auth_result.instructions().map(String::from);
Ok(Json(resp)) Ok(Json(resp))
} }
Err(auth_err) => Ok(Json(ActionResponse::fail(format!( Err(auth_err) => Ok(Json(ActionResponse::fail(format!(
@@ -1592,6 +1765,13 @@ async fn extensions_setup_submit_handler(
match ext_mgr.save_setup_secrets(&name, &req.secrets).await { match ext_mgr.save_setup_secrets(&name, &req.secrets).await {
Ok(result) => { Ok(result) => {
// Broadcast auth_completed so the chat UI can dismiss any in-progress
// auth card or setup modal that was triggered by tool_auth/tool_activate.
state.sse.broadcast(SseEvent::AuthCompleted {
extension_name: name.clone(),
success: true,
message: result.message.clone(),
});
let mut resp = ActionResponse::ok(result.message); let mut resp = ActionResponse::ok(result.message);
resp.activated = Some(result.activated); resp.activated = Some(result.activated);
resp.auth_url = result.auth_url; resp.auth_url = result.auth_url;
@@ -2232,4 +2412,343 @@ mod tests {
let turns = build_turns_from_db_messages(&[]); let turns = build_turns_from_db_messages(&[]);
assert!(turns.is_empty()); assert!(turns.is_empty());
} }
// --- OAuth callback handler tests ---
/// Build a minimal `GatewayState` for testing the OAuth callback handler.
fn test_gateway_state(ext_mgr: Option<Arc<ExtensionManager>>) -> Arc<GatewayState> {
Arc::new(GatewayState {
msg_tx: tokio::sync::RwLock::new(None),
sse: SseManager::new(),
workspace: None,
session_manager: None,
log_broadcaster: None,
log_level_handle: None,
extension_manager: ext_mgr,
tool_registry: None,
store: None,
job_manager: None,
prompt_queue: None,
user_id: "test".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: None,
llm_provider: None,
skill_registry: None,
skill_catalog: None,
scheduler: None,
chat_rate_limiter: RateLimiter::new(30, 60),
registry_entries: vec![],
cost_guard: None,
startup_time: std::time::Instant::now(),
})
}
/// Build a test router with just the OAuth callback route.
fn test_oauth_router(state: Arc<GatewayState>) -> Router {
Router::new()
.route("/oauth/callback", get(oauth_callback_handler))
.with_state(state)
}
#[tokio::test]
async fn test_oauth_callback_missing_params() {
use axum::body::Body;
use tower::ServiceExt;
let state = test_gateway_state(None);
let app = test_oauth_router(state);
let req = axum::http::Request::builder()
.uri("/oauth/callback")
.body(Body::empty())
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
.await
.expect("body");
let html = String::from_utf8_lossy(&body);
assert!(html.contains("Authorization Failed"));
}
#[tokio::test]
async fn test_oauth_callback_error_from_provider() {
use axum::body::Body;
use tower::ServiceExt;
let state = test_gateway_state(None);
let app = test_oauth_router(state);
let req = axum::http::Request::builder()
.uri("/oauth/callback?error=access_denied&error_description=access_denied")
.body(Body::empty())
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
.await
.expect("body");
let html = String::from_utf8_lossy(&body);
assert!(html.contains("Authorization Failed"));
}
#[tokio::test]
async fn test_oauth_callback_unknown_state() {
use axum::body::Body;
use tower::ServiceExt;
// Build an ExtensionManager so the handler can look up flows
let secrets = Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
"test-key-at-least-32-chars-long!!".to_string(),
))
.expect("crypto"),
)));
let tool_registry = Arc::new(ToolRegistry::new());
let mcp_sm = Arc::new(crate::tools::mcp::session::McpSessionManager::new());
let ext_mgr = Arc::new(ExtensionManager::new(
mcp_sm,
secrets,
tool_registry,
None,
None,
std::path::PathBuf::from("/tmp/wasm_tools"),
std::path::PathBuf::from("/tmp/wasm_channels"),
None,
"test".to_string(),
None,
vec![],
));
let state = test_gateway_state(Some(ext_mgr));
let app = test_oauth_router(state);
let req = axum::http::Request::builder()
.uri("/oauth/callback?code=test_code&state=unknown_state_value")
.body(Body::empty())
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
.await
.expect("body");
let html = String::from_utf8_lossy(&body);
assert!(html.contains("Authorization Failed"));
}
#[tokio::test]
async fn test_oauth_callback_expired_flow() {
use axum::body::Body;
use tower::ServiceExt;
let secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync> =
Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
"test-key-at-least-32-chars-long!!".to_string(),
))
.expect("crypto"),
)));
let tool_registry = Arc::new(ToolRegistry::new());
let mcp_sm = Arc::new(crate::tools::mcp::session::McpSessionManager::new());
let ext_mgr = Arc::new(ExtensionManager::new(
mcp_sm,
secrets.clone(),
tool_registry,
None,
None,
std::path::PathBuf::from("/tmp/wasm_tools"),
std::path::PathBuf::from("/tmp/wasm_channels"),
None,
"test".to_string(),
None,
vec![],
));
// Insert an expired flow (created 10 minutes ago)
let flow = crate::cli::oauth_defaults::PendingOAuthFlow {
extension_name: "test_tool".to_string(),
display_name: "Test Tool".to_string(),
token_url: "https://example.com/token".to_string(),
client_id: "client123".to_string(),
client_secret: None,
redirect_uri: "https://example.com/oauth/callback".to_string(),
code_verifier: None,
access_token_field: "access_token".to_string(),
secret_name: "test_token".to_string(),
provider: None,
validation_endpoint: None,
scopes: vec![],
user_id: "test".to_string(),
secrets,
sse_sender: None,
gateway_token: None,
created_at: std::time::Instant::now() - std::time::Duration::from_secs(600),
};
ext_mgr
.pending_oauth_flows()
.write()
.await
.insert("expired_state".to_string(), flow);
let state = test_gateway_state(Some(ext_mgr));
let app = test_oauth_router(state);
let req = axum::http::Request::builder()
.uri("/oauth/callback?code=test_code&state=expired_state")
.body(Body::empty())
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
.await
.expect("body");
let html = String::from_utf8_lossy(&body);
// Expired flow → error landing page
assert!(html.contains("Authorization Failed"));
}
#[tokio::test]
async fn test_oauth_callback_no_extension_manager() {
use axum::body::Body;
use tower::ServiceExt;
// No extension manager set → graceful error
let state = test_gateway_state(None);
let app = test_oauth_router(state);
let req = axum::http::Request::builder()
.uri("/oauth/callback?code=test_code&state=some_state")
.body(Body::empty())
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
.await
.expect("body");
let html = String::from_utf8_lossy(&body);
assert!(html.contains("Authorization Failed"));
}
#[tokio::test]
async fn test_oauth_callback_strips_instance_prefix() {
use axum::body::Body;
use tower::ServiceExt;
let secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync> =
Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
"test-key-at-least-32-chars-long!!".to_string(),
))
.expect("crypto"),
)));
let tool_registry = Arc::new(ToolRegistry::new());
let mcp_sm = Arc::new(crate::tools::mcp::session::McpSessionManager::new());
let ext_mgr = Arc::new(ExtensionManager::new(
mcp_sm,
secrets.clone(),
tool_registry,
None,
None,
std::path::PathBuf::from("/tmp/wasm_tools"),
std::path::PathBuf::from("/tmp/wasm_channels"),
None,
"test".to_string(),
None,
vec![],
));
// Insert a flow keyed by raw nonce "test_nonce" (without instance prefix).
// Use an expired flow so the handler exits before attempting a real HTTP
// token exchange — we only need to verify that the instance prefix was
// stripped and the flow was found by the raw nonce.
let flow = crate::cli::oauth_defaults::PendingOAuthFlow {
extension_name: "test_tool".to_string(),
display_name: "Test Tool".to_string(),
token_url: "https://example.com/token".to_string(),
client_id: "client123".to_string(),
client_secret: None,
redirect_uri: "https://example.com/oauth/callback".to_string(),
code_verifier: None,
access_token_field: "access_token".to_string(),
secret_name: "test_token".to_string(),
provider: None,
validation_endpoint: None,
scopes: vec![],
user_id: "test".to_string(),
secrets,
sse_sender: None,
gateway_token: None,
// Expired — handler will reject after lookup (no network I/O)
created_at: std::time::Instant::now() - std::time::Duration::from_secs(600),
};
ext_mgr
.pending_oauth_flows()
.write()
.await
.insert("test_nonce".to_string(), flow);
let state = test_gateway_state(Some(ext_mgr.clone()));
let app = test_oauth_router(state);
// Send callback with instance prefix: "myinstance:test_nonce"
// The handler should strip "myinstance:" and find the flow keyed by "test_nonce"
let req = axum::http::Request::builder()
.uri("/oauth/callback?code=fake_code&state=myinstance:test_nonce")
.body(Body::empty())
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
.await
.expect("body");
let html = String::from_utf8_lossy(&body);
// The flow was found (stripped prefix matched) but is expired, so the
// handler returns an error landing page. The flow being consumed from
// the registry (checked below) proves the prefix was stripped correctly.
assert!(
html.contains("Authorization Failed"),
"Expected error page, html was: {}",
&html[..html.len().min(500)]
);
// Verify the flow was consumed (removed from registry)
assert!(
ext_mgr
.pending_oauth_flows()
.read()
.await
.get("test_nonce")
.is_none()
);
}
} }
+49 -17
View File
@@ -180,7 +180,7 @@ function connectSSE() {
eventSource.addEventListener('tool_completed', (e) => { eventSource.addEventListener('tool_completed', (e) => {
const data = JSON.parse(e.data); const data = JSON.parse(e.data);
if (!isCurrentThread(data.thread_id)) return; if (!isCurrentThread(data.thread_id)) return;
completeToolCard(data.name, data.success); completeToolCard(data.name, data.success, data.error, data.parameters);
}); });
eventSource.addEventListener('tool_result', (e) => { eventSource.addEventListener('tool_result', (e) => {
@@ -222,17 +222,22 @@ function connectSSE() {
eventSource.addEventListener('auth_required', (e) => { eventSource.addEventListener('auth_required', (e) => {
const data = JSON.parse(e.data); const data = JSON.parse(e.data);
showAuthCard(data); if (data.auth_url) {
// OAuth flow: show the auth card with an OAuth button + optional token paste field.
showAuthCard(data);
} else {
// Setup flow: fetch the extension's credential schema and show the multi-field
// configure modal (the same UI used by the Extensions tab "Setup" button).
showConfigureModal(data.extension_name);
}
}); });
eventSource.addEventListener('auth_completed', (e) => { eventSource.addEventListener('auth_completed', (e) => {
const data = JSON.parse(e.data); const data = JSON.parse(e.data);
// Dismiss whichever UI path was active: auth card (OAuth) or configure modal (setup).
removeAuthCard(data.extension_name); removeAuthCard(data.extension_name);
if (data.success) { closeConfigureModal();
showToast(data.message, 'success'); showToast(data.message, data.success ? 'success' : 'error');
} else {
showToast(data.message, 'error');
}
// Refresh extensions list so status indicators update // Refresh extensions list so status indicators update
if (currentTab === 'extensions') loadExtensions(); if (currentTab === 'extensions') loadExtensions();
enableChatInput(); enableChatInput();
@@ -590,7 +595,7 @@ function addToolCard(name) {
container.scrollTop = container.scrollHeight; container.scrollTop = container.scrollHeight;
} }
function completeToolCard(name, success) { function completeToolCard(name, success, error, parameters) {
const entries = _activeToolCards[name]; const entries = _activeToolCards[name];
if (!entries || entries.length === 0) return; if (!entries || entries.length === 0) return;
// Find first running card // Find first running card
@@ -611,6 +616,27 @@ function completeToolCard(name, success) {
? '<span class="activity-icon-success">&#10003;</span>' ? '<span class="activity-icon-success">&#10003;</span>'
: '<span class="activity-icon-fail">&#10007;</span>'; : '<span class="activity-icon-fail">&#10007;</span>';
entry.card.setAttribute('data-status', success ? 'success' : 'fail'); entry.card.setAttribute('data-status', success ? 'success' : 'fail');
// For failed tools, populate the body with error details and auto-expand
if (!success && (error || parameters)) {
const output = entry.card.querySelector('.activity-tool-output');
if (output) {
let detail = '';
if (parameters) {
detail += 'Input:\n' + parameters + '\n\n';
}
if (error) {
detail += 'Error:\n' + error;
}
output.textContent = detail;
// Auto-expand so the error is immediately visible
const body = entry.card.querySelector('.activity-tool-body');
const chevron = entry.card.querySelector('.activity-tool-chevron');
if (body) body.style.display = 'block';
if (chevron) chevron.classList.add('expanded');
}
}
} }
function setToolCardOutput(name, preview) { function setToolCardOutput(name, preview) {
@@ -874,7 +900,7 @@ function showAuthCard(data) {
const tokenInput = document.createElement('input'); const tokenInput = document.createElement('input');
tokenInput.type = 'password'; tokenInput.type = 'password';
tokenInput.placeholder = 'Paste your API key or token'; tokenInput.placeholder = data.instructions || 'Paste your API key or token';
tokenInput.addEventListener('keydown', (e) => { tokenInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') submitAuthToken(data.extension_name, tokenInput.value); if (e.key === 'Enter') submitAuthToken(data.extension_name, tokenInput.value);
}); });
@@ -1987,7 +2013,11 @@ function renderExtensionCard(ext) {
actions.appendChild(activateBtn); actions.appendChild(activateBtn);
} }
if (ext.needs_setup || ext.has_auth) { // Show Configure/Reconfigure button when there are secrets to enter.
// Skip when has_auth is true but needs_setup is false and not yet authenticated —
// this means OAuth credentials resolve automatically (builtin/env) and the user
// just needs to complete the OAuth flow, not fill in a config form.
if (ext.needs_setup || (ext.has_auth && ext.authenticated)) {
const configBtn = document.createElement('button'); const configBtn = document.createElement('button');
configBtn.className = 'btn-ext configure'; configBtn.className = 'btn-ext configure';
configBtn.textContent = ext.authenticated ? 'Reconfigure' : 'Configure'; configBtn.textContent = ext.authenticated ? 'Reconfigure' : 'Configure';
@@ -2176,18 +2206,18 @@ function submitConfigureModal(name, fields) {
closeConfigureModal(); closeConfigureModal();
if (res.success) { if (res.success) {
if (res.auth_url) { if (res.auth_url) {
// OAuth flow started — open consent popup // OAuth flow started — open consent popup. The auth_completed SSE will
// not arrive immediately (it fires after OAuth callback), so show a toast now.
showToast('Opening OAuth authorization for ' + name, 'info'); showToast('Opening OAuth authorization for ' + name, 'info');
window.open(res.auth_url, '_blank', 'width=600,height=700'); window.open(res.auth_url, '_blank', 'width=600,height=700');
} else if (res.activated) { loadExtensions();
showToast('Configured and activated ' + name, 'success');
} else {
showToast(res.message || 'Configuration saved but activation failed', 'warning');
} }
// For non-OAuth success: the server always broadcasts auth_completed SSE,
// which will show the toast and refresh extensions — no need to do it here too.
} else { } else {
showToast(res.message || 'Configuration failed', 'error'); showToast(res.message || 'Configuration failed', 'error');
loadExtensions();
} }
loadExtensions();
}) })
.catch((err) => { .catch((err) => {
btns.forEach(function(b) { b.disabled = false; }); btns.forEach(function(b) { b.disabled = false; });
@@ -2427,10 +2457,12 @@ function restartJob(jobId) {
apiFetch('/api/jobs/' + jobId + '/restart', { method: 'POST' }) apiFetch('/api/jobs/' + jobId + '/restart', { method: 'POST' })
.then((res) => { .then((res) => {
showToast('Job restarted as ' + (res.new_job_id || '').substring(0, 8), 'success'); showToast('Job restarted as ' + (res.new_job_id || '').substring(0, 8), 'success');
loadJobs();
}) })
.catch((err) => { .catch((err) => {
showToast('Failed to restart job: ' + err.message, 'error'); showToast('Failed to restart job: ' + err.message, 'error');
})
.finally(() => {
loadJobs();
}); });
} }
+4
View File
@@ -553,6 +553,10 @@ body {
border-color: rgba(230, 76, 76, 0.3); border-color: rgba(230, 76, 76, 0.3);
} }
.activity-tool-card[data-status="fail"] .activity-tool-name {
color: var(--danger);
}
.activity-tool-header { .activity-tool-header {
display: flex; display: flex;
align-items: center; align-items: center;
+4
View File
@@ -123,6 +123,10 @@ pub enum SseEvent {
name: String, name: String,
success: bool, success: bool,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
parameters: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>, thread_id: Option<String>,
}, },
#[serde(rename = "tool_result")] #[serde(rename = "tool_result")]
+4 -4
View File
@@ -242,7 +242,7 @@ async fn handle_client_message(
} => { } => {
if let Some(ref ext_mgr) = state.extension_manager { if let Some(ref ext_mgr) = state.extension_manager {
match ext_mgr.auth(&extension_name, Some(&token)).await { match ext_mgr.auth(&extension_name, Some(&token)).await {
Ok(result) if result.status == "authenticated" => { Ok(result) if result.is_authenticated() => {
let msg = match ext_mgr.activate(&extension_name).await { let msg = match ext_mgr.activate(&extension_name).await {
Ok(r) => format!( Ok(r) => format!(
"{} authenticated ({} tools loaded)", "{} authenticated ({} tools loaded)",
@@ -268,9 +268,9 @@ async fn handle_client_message(
.sse .sse
.broadcast(crate::channels::web::types::SseEvent::AuthRequired { .broadcast(crate::channels::web::types::SseEvent::AuthRequired {
extension_name, extension_name,
instructions: result.instructions, instructions: result.instructions().map(String::from),
auth_url: result.auth_url, auth_url: result.auth_url().map(String::from),
setup_url: result.setup_url, setup_url: result.setup_url().map(String::from),
}); });
} }
Err(e) => { Err(e) => {
+372
View File
@@ -18,6 +18,7 @@
//! env vars, which take priority over built-in defaults. //! env vars, which take priority over built-in defaults.
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
@@ -25,6 +26,7 @@ use rand::RngCore;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::sync::RwLock;
use crate::secrets::{CreateSecretParams, SecretsStore}; use crate::secrets::{CreateSecretParams, SecretsStore};
@@ -683,6 +685,219 @@ pub fn landing_html(provider_name: &str, success: bool) -> String {
) )
} }
// ── Gateway callback support ─────────────────────────────────────────
/// State for an in-progress OAuth flow, keyed by CSRF `state` parameter.
///
/// Created by `start_wasm_oauth()` and consumed by the web gateway's
/// `/oauth/callback` handler when running in hosted mode.
pub struct PendingOAuthFlow {
/// Extension name (e.g., "google_calendar").
pub extension_name: String,
/// Human-readable display name (e.g., "Google Calendar").
pub display_name: String,
/// OAuth token exchange URL.
pub token_url: String,
/// OAuth client ID.
pub client_id: String,
/// OAuth client secret (optional for PKCE-only flows).
pub client_secret: Option<String>,
/// The redirect_uri used in the authorization request.
pub redirect_uri: String,
/// PKCE code verifier (must match the code_challenge sent in the auth URL).
pub code_verifier: Option<String>,
/// Field name in token response containing the access token.
pub access_token_field: String,
/// Secret name for storage (e.g., "google_oauth_token").
pub secret_name: String,
/// Provider hint (e.g., "google").
pub provider: Option<String>,
/// Token validation endpoint (optional).
pub validation_endpoint: Option<crate::tools::wasm::ValidationEndpointSchema>,
/// Scopes that were requested.
pub scopes: Vec<String>,
/// User ID for secret storage.
pub user_id: String,
/// Secrets store reference for token persistence.
pub secrets: Arc<dyn SecretsStore + Send + Sync>,
/// SSE broadcast sender for notifying the web UI.
pub sse_sender: Option<tokio::sync::broadcast::Sender<crate::channels::web::types::SseEvent>>,
/// Gateway auth token for authenticating with the platform token exchange proxy.
pub gateway_token: Option<String>,
/// When this flow was created (for expiry).
pub created_at: std::time::Instant,
}
impl std::fmt::Debug for PendingOAuthFlow {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PendingOAuthFlow")
.field("extension_name", &self.extension_name)
.field("display_name", &self.display_name)
.field("secret_name", &self.secret_name)
.field("created_at", &self.created_at)
.finish_non_exhaustive()
}
}
/// Thread-safe registry of pending OAuth flows, keyed by CSRF `state` parameter.
pub type PendingOAuthRegistry = Arc<RwLock<HashMap<String, PendingOAuthFlow>>>;
/// Create a new empty pending OAuth flow registry.
pub fn new_pending_oauth_registry() -> PendingOAuthRegistry {
Arc::new(RwLock::new(HashMap::new()))
}
/// Returns `true` if OAuth callbacks should be routed through the web gateway
/// instead of the local TCP listener.
///
/// This is the case when `IRONCLAW_OAUTH_CALLBACK_URL` is set to a non-loopback
/// URL, meaning the user's browser will redirect to a hosted gateway rather than
/// localhost.
pub fn use_gateway_callback() -> bool {
std::env::var("IRONCLAW_OAUTH_CALLBACK_URL")
.ok()
.filter(|v| !v.is_empty())
.map(|raw| {
url::Url::parse(&raw)
.ok()
.and_then(|u| u.host_str().map(String::from))
.map(|host| !is_loopback_host(&host))
.unwrap_or(false)
})
.unwrap_or(false)
}
/// Maximum age for pending OAuth flows (5 minutes, matching TCP listener timeout).
pub const OAUTH_FLOW_EXPIRY: Duration = Duration::from_secs(300);
/// Remove expired flows from the registry.
///
/// Called when inserting new flows to prevent accumulation from abandoned
/// OAuth attempts.
pub async fn sweep_expired_flows(registry: &PendingOAuthRegistry) {
let mut flows = registry.write().await;
flows.retain(|_, flow| flow.created_at.elapsed() < OAUTH_FLOW_EXPIRY);
}
// ── Platform routing helpers ────────────────────────────────────────
/// Prepend instance name to CSRF state for platform routing.
///
/// The NEAR AI platform nginx proxy at `auth.DOMAIN` parses the instance name
/// from the `state` query parameter (format: `instance:nonce`) to route the
/// OAuth callback to the correct container.
///
/// Returns the nonce unchanged when `IRONCLAW_INSTANCE_NAME` is not set
/// (local/non-platform mode).
pub fn build_platform_state(nonce: &str) -> String {
let instance = std::env::var("IRONCLAW_INSTANCE_NAME")
.or_else(|_| std::env::var("OPENCLAW_INSTANCE_NAME"))
.ok()
.filter(|v| !v.is_empty());
match instance {
Some(name) => format!("{}:{}", name, nonce),
None => nonce.to_string(),
}
}
/// Strip the instance prefix from a state parameter to recover the lookup nonce.
///
/// `"myinstance:abc123"` → `"abc123"`, `"abc123"` → `"abc123"` (no prefix).
///
/// Safe because nonces are base64url-encoded (`[A-Za-z0-9_-]`, no colons).
pub fn strip_instance_prefix(state: &str) -> &str {
state
.split_once(':')
.map(|(_, nonce)| nonce)
.unwrap_or(state)
}
/// Exchange an OAuth authorization code via the platform's token exchange proxy.
///
/// The proxy holds `client_secret` server-side so the container never sees it.
/// Authenticated via the gateway auth token (Bearer header).
///
/// The proxy expects form params `{code, redirect_uri, code_verifier}` and
/// returns a standard Google token response `{access_token, refresh_token, expires_in}`.
pub async fn exchange_via_proxy(
proxy_url: &str,
gateway_token: &str,
code: &str,
redirect_uri: &str,
code_verifier: Option<&str>,
access_token_field: &str,
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
if gateway_token.is_empty() {
return Err(OAuthCallbackError::Io(
"Gateway auth token is required for proxy token exchange".to_string(),
));
}
let exchange_url = format!("{}/oauth/exchange", proxy_url.trim_end_matches('/'));
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(60))
.build()
.map_err(|e| OAuthCallbackError::Io(format!("Failed to build HTTP client: {}", e)))?;
let mut params = vec![
("code", code.to_string()),
("redirect_uri", redirect_uri.to_string()),
];
if let Some(verifier) = code_verifier {
params.push(("code_verifier", verifier.to_string()));
}
let response = client
.post(&exchange_url)
.bearer_auth(gateway_token)
.form(&params)
.send()
.await
.map_err(|e| {
OAuthCallbackError::Io(format!("Token exchange proxy request failed: {}", e))
})?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(OAuthCallbackError::Io(format!(
"Token exchange proxy failed: {} - {}",
status, body
)));
}
let token_data: serde_json::Value = response
.json()
.await
.map_err(|e| OAuthCallbackError::Io(format!("Failed to parse proxy response: {}", e)))?;
let access_token = token_data
.get(access_token_field)
.and_then(|v| v.as_str())
.ok_or_else(|| {
let fields: Vec<&str> = token_data
.as_object()
.map(|o| o.keys().map(|k| k.as_str()).collect())
.unwrap_or_default();
OAuthCallbackError::Io(format!(
"No '{}' field in proxy response (fields present: {:?})",
access_token_field, fields
))
})?
.to_string();
let refresh_token = token_data
.get("refresh_token")
.and_then(|v| v.as_str())
.map(String::from);
let expires_in = token_data.get("expires_in").and_then(|v| v.as_u64());
Ok(OAuthTokenResponse {
access_token,
refresh_token,
expires_in,
})
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::sync::Mutex; use std::sync::Mutex;
@@ -939,4 +1154,161 @@ mod tests {
// State should be different each time (random) // State should be different each time (random)
assert_ne!(result1.state, result2.state); assert_ne!(result1.state, result2.state);
} }
#[test]
fn test_use_gateway_callback_false_by_default() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL");
}
assert!(!crate::cli::oauth_defaults::use_gateway_callback());
unsafe {
if let Some(val) = original {
std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", val);
}
}
}
#[test]
fn test_use_gateway_callback_true_for_hosted() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
std::env::set_var(
"IRONCLAW_OAUTH_CALLBACK_URL",
"https://kind-deer.agent1.near.ai",
);
}
assert!(crate::cli::oauth_defaults::use_gateway_callback());
unsafe {
if let Some(val) = original {
std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", val);
} else {
std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL");
}
}
}
#[test]
fn test_use_gateway_callback_false_for_localhost() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", "http://127.0.0.1:3001");
}
assert!(!crate::cli::oauth_defaults::use_gateway_callback());
unsafe {
if let Some(val) = original {
std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", val);
} else {
std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL");
}
}
}
#[test]
fn test_use_gateway_callback_false_for_empty() {
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", "");
}
assert!(!crate::cli::oauth_defaults::use_gateway_callback());
unsafe {
if let Some(val) = original {
std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", val);
} else {
std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL");
}
}
}
#[test]
fn test_build_platform_state_with_instance() {
use crate::cli::oauth_defaults::build_platform_state;
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let original = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
std::env::set_var("IRONCLAW_INSTANCE_NAME", "kind-deer");
}
assert_eq!(build_platform_state("abc123"), "kind-deer:abc123");
unsafe {
if let Some(val) = original {
std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
} else {
std::env::remove_var("IRONCLAW_INSTANCE_NAME");
}
}
}
#[test]
fn test_build_platform_state_without_instance() {
use crate::cli::oauth_defaults::build_platform_state;
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let original = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
let original_oc = std::env::var("OPENCLAW_INSTANCE_NAME").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
std::env::remove_var("IRONCLAW_INSTANCE_NAME");
std::env::remove_var("OPENCLAW_INSTANCE_NAME");
}
assert_eq!(build_platform_state("abc123"), "abc123");
unsafe {
if let Some(val) = original {
std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
}
if let Some(val) = original_oc {
std::env::set_var("OPENCLAW_INSTANCE_NAME", val);
}
}
}
#[test]
fn test_build_platform_state_with_openclaw_instance() {
use crate::cli::oauth_defaults::build_platform_state;
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
let original_ic = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
let original_oc = std::env::var("OPENCLAW_INSTANCE_NAME").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
std::env::remove_var("IRONCLAW_INSTANCE_NAME");
std::env::set_var("OPENCLAW_INSTANCE_NAME", "quiet-lion");
}
assert_eq!(build_platform_state("xyz789"), "quiet-lion:xyz789");
unsafe {
if let Some(val) = original_ic {
std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
}
if let Some(val) = original_oc {
std::env::set_var("OPENCLAW_INSTANCE_NAME", val);
} else {
std::env::remove_var("OPENCLAW_INSTANCE_NAME");
}
}
}
#[test]
fn test_strip_instance_prefix_with_colon() {
use crate::cli::oauth_defaults::strip_instance_prefix;
assert_eq!(strip_instance_prefix("kind-deer:abc123"), "abc123");
assert_eq!(strip_instance_prefix("my-instance:xyz"), "xyz");
}
#[test]
fn test_strip_instance_prefix_without_colon() {
use crate::cli::oauth_defaults::strip_instance_prefix;
assert_eq!(strip_instance_prefix("abc123"), "abc123");
assert_eq!(strip_instance_prefix(""), "");
}
} }
+440 -403
View File
File diff suppressed because it is too large Load Diff
+379 -18
View File
@@ -24,6 +24,7 @@ pub use discovery::OnlineDiscovery;
pub use manager::ExtensionManager; pub use manager::ExtensionManager;
pub use registry::ExtensionRegistry; pub use registry::ExtensionRegistry;
use serde::ser::SerializeMap;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// The kind of extension, determining how it's installed, authenticated, and activated. /// The kind of extension, determining how it's installed, authenticated, and activated.
@@ -145,28 +146,267 @@ pub struct InstallResult {
pub message: String, pub message: String,
} }
/// Auth readiness state for the extensions list UI.
///
/// Used by `check_tool_auth_status` and `check_channel_auth_status` to
/// communicate a tool's credential state to the list handler without
/// ambiguous `(bool, bool)` tuples.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolAuthState {
/// Token/credentials are present — ready to use.
Ready,
/// Auth section exists but the access token is missing (OAuth not completed).
NeedsAuth,
/// Setup credentials (client_id/secret) must be configured before OAuth can start.
NeedsSetup,
/// No auth configuration at all (no capabilities or auth section).
NoAuth,
}
/// The typed auth status, carrying only the data relevant to each state.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthStatus {
/// Authentication is complete; no further action needed.
Authenticated,
/// No authentication is required for this extension.
NoAuthRequired,
/// OAuth flow started — user must open `auth_url` in their browser.
AwaitingAuthorization {
auth_url: String,
callback_type: String,
},
/// Waiting for user to provide a token/key manually.
AwaitingToken {
instructions: String,
setup_url: Option<String>,
},
/// OAuth client credentials need to be configured before auth can proceed.
NeedsSetup {
instructions: String,
setup_url: Option<String>,
},
}
impl AuthStatus {
/// The wire-format status string (backward-compatible with JS consumers).
pub fn as_str(&self) -> &'static str {
match self {
AuthStatus::Authenticated => "authenticated",
AuthStatus::NoAuthRequired => "no_auth_required",
AuthStatus::AwaitingAuthorization { .. } => "awaiting_authorization",
AuthStatus::AwaitingToken { .. } => "awaiting_token",
AuthStatus::NeedsSetup { .. } => "needs_setup",
}
}
}
/// Result of authenticating an extension. /// Result of authenticating an extension.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone)]
pub struct AuthResult { pub struct AuthResult {
pub name: String, pub name: String,
pub kind: ExtensionKind, pub kind: ExtensionKind,
/// OAuth URL to open (for OAuth flows). pub status: AuthStatus,
#[serde(skip_serializing_if = "Option::is_none")] }
pub auth_url: Option<String>,
/// Whether using local or remote callback. impl AuthResult {
#[serde(skip_serializing_if = "Option::is_none")] // ── Constructors ──────────────────────────────────────────────────
pub callback_type: Option<String>,
/// Instructions for manual token entry (for WASM tools). pub fn authenticated(name: impl Into<String>, kind: ExtensionKind) -> Self {
#[serde(skip_serializing_if = "Option::is_none")] Self {
pub instructions: Option<String>, name: name.into(),
/// URL for manual token setup. kind,
#[serde(skip_serializing_if = "Option::is_none")] status: AuthStatus::Authenticated,
pub setup_url: Option<String>, }
/// Whether the tool is waiting for a token from the user. }
#[serde(default)]
pub awaiting_token: bool, pub fn no_auth_required(name: impl Into<String>, kind: ExtensionKind) -> Self {
/// Current auth status. Self {
pub status: String, name: name.into(),
kind,
status: AuthStatus::NoAuthRequired,
}
}
pub fn awaiting_authorization(
name: impl Into<String>,
kind: ExtensionKind,
auth_url: String,
callback_type: String,
) -> Self {
Self {
name: name.into(),
kind,
status: AuthStatus::AwaitingAuthorization {
auth_url,
callback_type,
},
}
}
pub fn awaiting_token(
name: impl Into<String>,
kind: ExtensionKind,
instructions: String,
setup_url: Option<String>,
) -> Self {
Self {
name: name.into(),
kind,
status: AuthStatus::AwaitingToken {
instructions,
setup_url,
},
}
}
pub fn needs_setup(
name: impl Into<String>,
kind: ExtensionKind,
instructions: String,
setup_url: Option<String>,
) -> Self {
Self {
name: name.into(),
kind,
status: AuthStatus::NeedsSetup {
instructions,
setup_url,
},
}
}
// ── Accessors ─────────────────────────────────────────────────────
pub fn is_authenticated(&self) -> bool {
matches!(self.status, AuthStatus::Authenticated)
}
pub fn auth_url(&self) -> Option<&str> {
match &self.status {
AuthStatus::AwaitingAuthorization { auth_url, .. } => Some(auth_url),
_ => None,
}
}
pub fn callback_type(&self) -> Option<&str> {
match &self.status {
AuthStatus::AwaitingAuthorization { callback_type, .. } => Some(callback_type),
_ => None,
}
}
pub fn instructions(&self) -> Option<&str> {
match &self.status {
AuthStatus::AwaitingToken { instructions, .. }
| AuthStatus::NeedsSetup { instructions, .. } => Some(instructions),
_ => None,
}
}
pub fn setup_url(&self) -> Option<&str> {
match &self.status {
AuthStatus::AwaitingToken { setup_url, .. }
| AuthStatus::NeedsSetup { setup_url, .. } => setup_url.as_deref(),
_ => None,
}
}
pub fn is_awaiting_token(&self) -> bool {
matches!(self.status, AuthStatus::AwaitingToken { .. })
}
pub fn status_str(&self) -> &'static str {
self.status.as_str()
}
}
/// Serialize `AuthResult` to the same flat JSON shape the JS frontend expects.
impl Serialize for AuthResult {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
// Count fields: name + kind + status + optional fields
let optional_count = self.auth_url().is_some() as usize
+ self.callback_type().is_some() as usize
+ self.instructions().is_some() as usize
+ self.setup_url().is_some() as usize;
let mut map = serializer.serialize_map(Some(4 + optional_count))?;
map.serialize_entry("name", &self.name)?;
map.serialize_entry("kind", &self.kind)?;
if let Some(url) = self.auth_url() {
map.serialize_entry("auth_url", url)?;
}
if let Some(cb) = self.callback_type() {
map.serialize_entry("callback_type", cb)?;
}
if let Some(inst) = self.instructions() {
map.serialize_entry("instructions", inst)?;
}
if let Some(url) = self.setup_url() {
map.serialize_entry("setup_url", url)?;
}
map.serialize_entry("awaiting_token", &self.is_awaiting_token())?;
map.serialize_entry("status", self.status_str())?;
map.end()
}
}
/// Deserialize from the flat JSON shape back into the typed enum.
impl<'de> Deserialize<'de> for AuthResult {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
/// Flat helper matching the old JSON shape.
#[derive(Deserialize)]
#[allow(dead_code)]
struct Raw {
name: String,
kind: ExtensionKind,
#[serde(default)]
auth_url: Option<String>,
#[serde(default)]
callback_type: Option<String>,
#[serde(default)]
instructions: Option<String>,
#[serde(default)]
setup_url: Option<String>,
#[serde(default)]
awaiting_token: bool,
status: String,
}
let raw = Raw::deserialize(deserializer)?;
let status = match raw.status.as_str() {
"authenticated" => AuthStatus::Authenticated,
"no_auth_required" => AuthStatus::NoAuthRequired,
"awaiting_authorization" => AuthStatus::AwaitingAuthorization {
auth_url: raw.auth_url.unwrap_or_default(),
callback_type: raw.callback_type.unwrap_or_default(),
},
"awaiting_token" => AuthStatus::AwaitingToken {
instructions: raw.instructions.unwrap_or_default(),
setup_url: raw.setup_url,
},
"needs_setup" => AuthStatus::NeedsSetup {
instructions: raw.instructions.unwrap_or_default(),
setup_url: raw.setup_url,
},
other => {
return Err(serde::de::Error::unknown_variant(
other,
&[
"authenticated",
"no_auth_required",
"awaiting_authorization",
"awaiting_token",
"needs_setup",
],
));
}
};
Ok(AuthResult {
name: raw.name,
kind: raw.kind,
status,
})
}
} }
/// Result of activating an extension. /// Result of activating an extension.
@@ -257,3 +497,124 @@ pub enum ExtensionError {
#[error("{0}")] #[error("{0}")]
Other(String), Other(String),
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn auth_result_authenticated_round_trip() {
let result = AuthResult::authenticated("gmail", ExtensionKind::WasmTool);
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["status"], "authenticated");
assert_eq!(json["name"], "gmail");
assert_eq!(json["kind"], "wasm_tool");
assert_eq!(json["awaiting_token"], false);
assert!(json.get("auth_url").is_none());
assert!(json.get("instructions").is_none());
let back: AuthResult = serde_json::from_value(json).unwrap();
assert!(back.is_authenticated());
assert!(back.auth_url().is_none());
}
#[test]
fn auth_result_awaiting_authorization_round_trip() {
let result = AuthResult::awaiting_authorization(
"google-drive",
ExtensionKind::WasmTool,
"https://accounts.google.com/o/oauth2/v2/auth?state=abc".to_string(),
"local".to_string(),
);
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["status"], "awaiting_authorization");
assert_eq!(
json["auth_url"],
"https://accounts.google.com/o/oauth2/v2/auth?state=abc"
);
assert_eq!(json["callback_type"], "local");
assert_eq!(json["awaiting_token"], false);
let back: AuthResult = serde_json::from_value(json).unwrap();
assert_eq!(
back.auth_url(),
Some("https://accounts.google.com/o/oauth2/v2/auth?state=abc")
);
assert_eq!(back.callback_type(), Some("local"));
assert!(!back.is_authenticated());
}
#[test]
fn auth_result_awaiting_token_round_trip() {
let result = AuthResult::awaiting_token(
"telegram",
ExtensionKind::WasmChannel,
"Enter your bot token".to_string(),
None,
);
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["status"], "awaiting_token");
assert_eq!(json["instructions"], "Enter your bot token");
assert_eq!(json["awaiting_token"], true);
assert!(json.get("auth_url").is_none());
let back: AuthResult = serde_json::from_value(json).unwrap();
assert!(back.is_awaiting_token());
assert_eq!(back.instructions(), Some("Enter your bot token"));
}
#[test]
fn auth_result_needs_setup_round_trip() {
let result = AuthResult::needs_setup(
"custom-tool",
ExtensionKind::WasmTool,
"Configure OAuth credentials in the Setup tab.".to_string(),
Some("https://console.cloud.google.com".to_string()),
);
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["status"], "needs_setup");
assert_eq!(json["setup_url"], "https://console.cloud.google.com");
assert_eq!(json["awaiting_token"], false);
let back: AuthResult = serde_json::from_value(json).unwrap();
assert!(!back.is_authenticated());
assert!(!back.is_awaiting_token());
assert_eq!(back.setup_url(), Some("https://console.cloud.google.com"));
}
#[test]
fn auth_result_no_auth_required_round_trip() {
let result = AuthResult::no_auth_required("echo", ExtensionKind::WasmTool);
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["status"], "no_auth_required");
assert_eq!(json["awaiting_token"], false);
let back: AuthResult = serde_json::from_value(json).unwrap();
assert!(!back.is_authenticated());
assert_eq!(back.status, AuthStatus::NoAuthRequired);
}
#[test]
fn auth_status_type_safety() {
// AwaitingAuthorization always has auth_url
let result = AuthResult::awaiting_authorization(
"test",
ExtensionKind::WasmTool,
"https://example.com".to_string(),
"local".to_string(),
);
assert!(result.auth_url().is_some());
assert!(!result.is_awaiting_token());
// Authenticated never has auth_url
let result = AuthResult::authenticated("test", ExtensionKind::WasmTool);
assert!(result.auth_url().is_none());
assert!(result.instructions().is_none());
assert!(result.setup_url().is_none());
}
}
+15 -3
View File
@@ -30,6 +30,9 @@ pub enum PairingStoreError {
#[error("Invalid channel: {0}")] #[error("Invalid channel: {0}")]
InvalidChannel(String), InvalidChannel(String),
#[error("Invalid path: {0}")]
InvalidPath(String),
#[error("IO error: {0}")] #[error("IO error: {0}")]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
@@ -224,7 +227,10 @@ impl PairingStore {
meta: Option<serde_json::Value>, meta: Option<serde_json::Value>,
) -> Result<UpsertResult, PairingStoreError> { ) -> Result<UpsertResult, PairingStoreError> {
let path = pairing_path(&self.base_dir, channel)?; let path = pairing_path(&self.base_dir, channel)?;
fs::create_dir_all(path.parent().unwrap())?; let parent = path.parent().ok_or_else(|| {
PairingStoreError::InvalidPath(format!("path has no parent: {}", path.display()))
})?;
fs::create_dir_all(parent)?;
let mut file = fs::OpenOptions::new() let mut file = fs::OpenOptions::new()
.read(true) .read(true)
@@ -319,7 +325,10 @@ impl PairingStore {
fn record_failed_approve(&self, channel: &str) -> Result<(), PairingStoreError> { fn record_failed_approve(&self, channel: &str) -> Result<(), PairingStoreError> {
let path = approve_attempts_path(&self.base_dir, channel)?; let path = approve_attempts_path(&self.base_dir, channel)?;
fs::create_dir_all(path.parent().unwrap())?; let parent = path.parent().ok_or_else(|| {
PairingStoreError::InvalidPath(format!("path has no parent: {}", path.display()))
})?;
fs::create_dir_all(parent)?;
// Open (or create) and lock before reading so concurrent callers // Open (or create) and lock before reading so concurrent callers
// don't clobber each other's writes. // don't clobber each other's writes.
@@ -462,7 +471,10 @@ impl PairingStore {
} }
let path = allow_from_path(&self.base_dir, channel)?; let path = allow_from_path(&self.base_dir, channel)?;
fs::create_dir_all(path.parent().unwrap())?; let parent = path.parent().ok_or_else(|| {
PairingStoreError::InvalidPath(format!("path has no parent: {}", path.display()))
})?;
fs::create_dir_all(parent)?;
let file = fs::OpenOptions::new() let file = fs::OpenOptions::new()
.read(true) .read(true)
+3
View File
@@ -47,6 +47,9 @@ pub enum RegistryError {
actual_sha256: String, actual_sha256: String,
}, },
#[error("Missing SHA256 checksum for '{name}' artifact. Use --build to build from source.")]
MissingChecksum { name: String },
#[error( #[error(
"Source fallback unavailable for '{name}' after artifact install failed. Retry artifact download or run from a repository checkout." "Source fallback unavailable for '{name}' after artifact install failed. Retry artifact download or run from a repository checkout."
)] )]
+17 -8
View File
@@ -20,6 +20,10 @@ const ALLOWED_ARTIFACT_HOSTS: &[&str] = &[
]; ];
fn should_attempt_source_fallback(err: &RegistryError) -> bool { fn should_attempt_source_fallback(err: &RegistryError) -> bool {
// MissingChecksum is intentionally allowed here — it's a bootstrapping issue
// (no release has populated checksums yet), not a security concern. Source
// builds use local trusted code. ChecksumMismatch (tampered artifact) and
// InvalidManifest (structural problem) remain blocked.
!matches!( !matches!(
err, err,
RegistryError::AlreadyInstalled { .. } RegistryError::AlreadyInstalled { .. }
@@ -367,15 +371,15 @@ impl RegistryInstaller {
// Require SHA256 — refuse to install unverified binaries. Check before // Require SHA256 — refuse to install unverified binaries. Check before
// downloading to avoid wasting bandwidth on manifests that are missing // downloading to avoid wasting bandwidth on manifests that are missing
// checksums. // checksums. Uses MissingChecksum (not InvalidManifest) so that
// install_with_source_fallback can fall back to building from source
// when checksums haven't been populated yet (bootstrapping).
let expected_sha = let expected_sha =
artifact artifact
.sha256 .sha256
.as_ref() .as_ref()
.ok_or_else(|| RegistryError::InvalidManifest { .ok_or_else(|| RegistryError::MissingChecksum {
name: manifest.name.clone(), name: manifest.name.clone(),
field: "artifacts.wasm32-wasip2.sha256",
reason: "sha256 is required for artifact downloads".to_string(),
})?; })?;
let target_dir = match manifest.kind { let target_dir = match manifest.kind {
@@ -500,7 +504,7 @@ impl RegistryInstaller {
if prefer_build || !has_artifact { if prefer_build || !has_artifact {
self.install_from_source(manifest, force).await self.install_from_source(manifest, force).await
} else { } else {
self.install_from_artifact(manifest, force).await self.install_with_source_fallback(manifest, force).await
} }
} }
@@ -905,9 +909,8 @@ mod tests {
let result = installer.install_from_artifact(&manifest, false).await; let result = installer.install_from_artifact(&manifest, false).await;
match result { match result {
Err(RegistryError::InvalidManifest { field, reason, .. }) => { Err(RegistryError::MissingChecksum { name }) => {
assert_eq!(field, "artifacts.wasm32-wasip2.sha256"); assert_eq!(name, "demo");
assert!(reason.contains("required"), "reason: {}", reason);
} }
other => panic!("unexpected result: {:?}", other), other => panic!("unexpected result: {:?}", other),
} }
@@ -942,6 +945,12 @@ mod tests {
reason: "host not allowed".to_string(), reason: "host not allowed".to_string(),
}; };
assert!(!should_attempt_source_fallback(&invalid)); assert!(!should_attempt_source_fallback(&invalid));
// MissingChecksum SHOULD allow source fallback (bootstrapping)
let missing = RegistryError::MissingChecksum {
name: "demo".to_string(),
};
assert!(should_attempt_source_fallback(&missing));
} }
#[test] #[test]
+51 -28
View File
@@ -63,6 +63,29 @@ pub struct ContainerRunner {
proxy_port: u16, proxy_port: u16,
} }
/// Append `text` into `buffer` up to `limit` bytes without breaking UTF-8.
///
/// Returns `true` when truncation occurred.
fn append_with_limit(buffer: &mut String, text: &str, limit: usize) -> bool {
if text.is_empty() {
return false;
}
if buffer.len() >= limit {
return true;
}
let remaining = limit - buffer.len();
if text.len() <= remaining {
buffer.push_str(text);
return false;
}
let end = crate::util::floor_char_boundary(text, remaining);
buffer.push_str(&text[..end]);
true
}
impl ContainerRunner { impl ContainerRunner {
/// Create a new container runner. /// Create a new container runner.
pub fn new(docker: Docker, image: String, proxy_port: u16) -> Self { pub fn new(docker: Docker, image: String, proxy_port: u16) -> Self {
@@ -393,23 +416,11 @@ impl ContainerRunner {
match result { match result {
Ok(LogOutput::StdOut { message }) => { Ok(LogOutput::StdOut { message }) => {
let text = String::from_utf8_lossy(&message); let text = String::from_utf8_lossy(&message);
if stdout.len() + text.len() > half_max { truncated |= append_with_limit(&mut stdout, &text, half_max);
truncated = true;
let remaining = half_max.saturating_sub(stdout.len());
stdout.push_str(&text[..remaining.min(text.len())]);
} else {
stdout.push_str(&text);
}
} }
Ok(LogOutput::StdErr { message }) => { Ok(LogOutput::StdErr { message }) => {
let text = String::from_utf8_lossy(&message); let text = String::from_utf8_lossy(&message);
if stderr.len() + text.len() > half_max { truncated |= append_with_limit(&mut stderr, &text, half_max);
truncated = true;
let remaining = half_max.saturating_sub(stderr.len());
stderr.push_str(&text[..remaining.min(text.len())]);
} else {
stderr.push_str(&text);
}
} }
Ok(_) => {} Ok(_) => {}
Err(e) => { Err(e) => {
@@ -439,23 +450,11 @@ impl ContainerRunner {
match result { match result {
Ok(LogOutput::StdOut { message }) => { Ok(LogOutput::StdOut { message }) => {
let text = String::from_utf8_lossy(&message); let text = String::from_utf8_lossy(&message);
if stdout.len() < half_max { truncated |= append_with_limit(&mut stdout, &text, half_max);
let remaining = half_max.saturating_sub(stdout.len());
stdout.push_str(&text[..remaining.min(text.len())]);
if text.len() > remaining {
truncated = true;
}
}
} }
Ok(LogOutput::StdErr { message }) => { Ok(LogOutput::StdErr { message }) => {
let text = String::from_utf8_lossy(&message); let text = String::from_utf8_lossy(&message);
if stderr.len() < half_max { truncated |= append_with_limit(&mut stderr, &text, half_max);
let remaining = half_max.saturating_sub(stderr.len());
stderr.push_str(&text[..remaining.min(text.len())]);
if text.len() > remaining {
truncated = true;
}
}
} }
Ok(_) => {} Ok(_) => {}
Err(e) => { Err(e) => {
@@ -577,6 +576,30 @@ fn unix_socket_candidates_from_env(
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn append_with_limit_truncates_on_utf8_boundary() {
let mut out = String::new();
let truncated = append_with_limit(&mut out, "ab🙂cd", 5);
assert!(truncated);
assert_eq!(out, "ab");
}
#[test]
fn append_with_limit_marks_truncated_when_full() {
let mut out = "abc".to_string();
let truncated = append_with_limit(&mut out, "z", 3);
assert!(truncated);
assert_eq!(out, "abc");
}
#[test]
fn append_with_limit_appends_without_truncation() {
let mut out = String::new();
let truncated = append_with_limit(&mut out, "hello", 10);
assert!(!truncated);
assert_eq!(out, "hello");
}
#[cfg(unix)] #[cfg(unix)]
#[test] #[test]
fn test_unix_socket_candidates_include_rootless_paths() { fn test_unix_socket_candidates_include_rootless_paths() {
+2 -2
View File
@@ -218,7 +218,7 @@ impl Tool for ToolAuthTool {
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?; .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
// Auto-activate after successful auth so tools are available immediately // Auto-activate after successful auth so tools are available immediately
if result.status == "authenticated" { if result.is_authenticated() {
match self.manager.activate(name).await { match self.manager.activate(name).await {
Ok(activate_result) => { Ok(activate_result) => {
let output = serde_json::json!({ let output = serde_json::json!({
@@ -324,7 +324,7 @@ impl Tool for ToolActivateTool {
// Activation failed due to missing auth; initiate auth flow // Activation failed due to missing auth; initiate auth flow
// so the agent loop can show the auth card. // so the agent loop can show the auth card.
match self.manager.auth(name, None).await { match self.manager.auth(name, None).await {
Ok(auth_result) if auth_result.status == "authenticated" => { Ok(auth_result) if auth_result.is_authenticated() => {
// Auth succeeded (e.g. env var was set); retry activation. // Auth succeeded (e.g. env var was set); retry activation.
let result = self let result = self
.manager .manager
+2
View File
@@ -10,6 +10,7 @@ mod memory;
mod message; mod message;
pub mod path_utils; pub mod path_utils;
pub mod routine; pub mod routine;
pub mod secrets_tools;
pub(crate) mod shell; pub(crate) mod shell;
pub mod skill_tools; pub mod skill_tools;
mod time; mod time;
@@ -31,6 +32,7 @@ pub use message::MessageTool;
pub use routine::{ pub use routine::{
RoutineCreateTool, RoutineDeleteTool, RoutineHistoryTool, RoutineListTool, RoutineUpdateTool, RoutineCreateTool, RoutineDeleteTool, RoutineHistoryTool, RoutineListTool, RoutineUpdateTool,
}; };
pub use secrets_tools::{SecretDeleteTool, SecretListTool};
pub use shell::ShellTool; pub use shell::ShellTool;
pub use skill_tools::{SkillInstallTool, SkillListTool, SkillRemoveTool, SkillSearchTool}; pub use skill_tools::{SkillInstallTool, SkillListTool, SkillRemoveTool, SkillSearchTool};
pub use time::TimeTool; pub use time::TimeTool;
+222
View File
@@ -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(&params, "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
View File
@@ -26,5 +26,5 @@ pub use rate_limiter::RateLimiter;
pub use registry::ToolRegistry; pub use registry::ToolRegistry;
pub use tool::{ pub use tool::{
ApprovalRequirement, Tool, ToolDomain, ToolError, ToolOutput, ToolRateLimitConfig, ApprovalRequirement, Tool, ToolDomain, ToolError, ToolOutput, ToolRateLimitConfig,
validate_tool_schema, redact_params, validate_tool_schema,
}; };
+14
View File
@@ -346,6 +346,20 @@ impl ToolRegistry {
tracing::info!("Registered {} job management tools", job_tool_count); tracing::info!("Registered {} job management tools", job_tool_count);
} }
/// Register secret management tools (list, delete).
///
/// These allow the LLM to persist API keys and tokens encrypted in the database.
/// Values are never returned to the LLM; only names and metadata are exposed.
pub fn register_secrets_tools(
&self,
store: Arc<dyn crate::secrets::SecretsStore + Send + Sync>,
) {
use crate::tools::builtin::{SecretDeleteTool, SecretListTool};
self.register_sync(Arc::new(SecretListTool::new(Arc::clone(&store))));
self.register_sync(Arc::new(SecretDeleteTool::new(store)));
tracing::info!("Registered 2 secret management tools (list, delete)");
}
/// Register extension management tools (search, install, auth, activate, list, remove). /// Register extension management tools (search, install, auth, activate, list, remove).
/// ///
/// These allow the LLM to manage MCP servers and WASM tools through conversation. /// These allow the LLM to manage MCP servers and WASM tools through conversation.
+75
View File
@@ -239,6 +239,23 @@ pub trait Tool: Send + Sync {
ToolDomain::Orchestrator ToolDomain::Orchestrator
} }
/// Parameter names whose values must be redacted before logging, hooks, and approvals.
///
/// The agent framework replaces these parameter values with `"[REDACTED]"` before:
/// - Writing to debug logs
/// - Storing in `ActionRecord` (in-memory job history)
/// - Recording in `TurnToolCall` (session state)
/// - Sending to `BeforeToolCall` hooks
/// - Displaying in the approval UI
///
/// **The `execute()` method still receives the original, unredacted parameters.**
/// Redaction only applies to the observability and audit paths, not execution.
///
/// Use this for tools that accept plaintext secrets as parameters (e.g. `secret_save`).
fn sensitive_params(&self) -> &[&str] {
&[]
}
/// Per-invocation rate limit for this tool. /// Per-invocation rate limit for this tool.
/// ///
/// Return `Some(config)` to throttle how often this tool can be called per user. /// Return `Some(config)` to throttle how often this tool can be called per user.
@@ -287,6 +304,33 @@ pub fn require_param<'a>(
.ok_or_else(|| ToolError::InvalidParameters(format!("missing '{}' parameter", name))) .ok_or_else(|| ToolError::InvalidParameters(format!("missing '{}' parameter", name)))
} }
/// Replace sensitive parameter values with `"[REDACTED]"`.
///
/// Returns a new JSON value with the specified keys replaced. Non-object params
/// and unknown keys are passed through unchanged. The original value is cloned
/// only if there are sensitive params to redact; otherwise it is cloned once
/// (cheap — callers own the result).
///
/// Used by the agent framework before logging, hook dispatch, approval display,
/// and `ActionRecord` storage so plaintext secrets never reach those paths.
pub fn redact_params(params: &serde_json::Value, sensitive: &[&str]) -> serde_json::Value {
if sensitive.is_empty() {
return params.clone();
}
let mut redacted = params.clone();
if let Some(obj) = redacted.as_object_mut() {
for key in sensitive {
if obj.contains_key(*key) {
obj.insert(
(*key).to_string(),
serde_json::Value::String("[REDACTED]".into()),
);
}
}
}
redacted
}
/// Lenient runtime validation of a tool's `parameters_schema()`. /// Lenient runtime validation of a tool's `parameters_schema()`.
/// ///
/// Use this function at tool-registration time to catch structural mistakes /// Use this function at tool-registration time to catch structural mistakes
@@ -500,6 +544,37 @@ mod tests {
assert!(ApprovalRequirement::Always.is_required()); assert!(ApprovalRequirement::Always.is_required());
} }
#[test]
fn test_redact_params_replaces_sensitive_key() {
let params = serde_json::json!({"name": "openai_key", "value": "sk-secret"});
let redacted = redact_params(&params, &["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(&params, &[]);
assert_eq!(redacted, params);
}
#[test]
fn test_redact_params_missing_key_is_noop() {
let params = serde_json::json!({"name": "key"});
let redacted = redact_params(&params, &["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(&params, &["value"]);
assert_eq!(redacted, params);
}
#[test] #[test]
fn test_validate_schema_valid() { fn test_validate_schema_valid() {
let schema = serde_json::json!({ let schema = serde_json::json!({
+107
View File
@@ -105,6 +105,59 @@ impl CapabilitiesFile {
self self
} }
/// Validate the capabilities file and emit warnings for common misconfigurations.
///
/// Called once at load time to catch issues early. Warnings are emitted via
/// `tracing::warn` so they show up in startup logs without blocking loading.
pub fn validate(&self, name: &str) {
const MIN_PROMPT_LENGTH: usize = 30;
// setup.required_secrets present but no auth section → auth card won't display
if let Some(setup) = &self.setup {
if !setup.required_secrets.is_empty() && self.auth.is_none() {
tracing::warn!(
tool = name,
"setup.required_secrets defined but no 'auth' section — \
chat-based auth card will not display for this tool"
);
}
// Check for short prompts
for secret in &setup.required_secrets {
if secret.prompt.len() < MIN_PROMPT_LENGTH {
tracing::warn!(
tool = name,
secret = secret.name,
prompt = secret.prompt,
"setup.required_secrets prompt is shorter than {} chars — \
consider a more descriptive prompt that tells the user where to find this value",
MIN_PROMPT_LENGTH
);
}
}
}
// Manual auth (no OAuth) checks
if let Some(auth) = &self.auth
&& auth.oauth.is_none()
{
if auth.setup_url.is_none() {
tracing::warn!(
tool = name,
"auth section has no OAuth and no setup_url — \
user has no link to obtain credentials"
);
}
if auth.instructions.is_none() {
tracing::warn!(
tool = name,
"auth section has no OAuth and no instructions — \
user has no guidance on how to obtain credentials"
);
}
}
}
/// Convert to runtime Capabilities. /// Convert to runtime Capabilities.
pub fn to_capabilities(&self) -> Capabilities { pub fn to_capabilities(&self) -> Capabilities {
let mut caps = Capabilities::default(); let mut caps = Capabilities::default();
@@ -1056,6 +1109,60 @@ mod tests {
assert_eq!(caps.setup.unwrap().required_secrets[0].name, "my_secret"); assert_eq!(caps.setup.unwrap().required_secrets[0].name, "my_secret");
} }
#[test]
fn test_validate_setup_without_auth_warns() {
// setup.required_secrets with no auth section — should not panic
let json = r#"{
"setup": {
"required_secrets": [
{ "name": "api_key", "prompt": "Enter your API key from the provider dashboard settings page" }
]
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
// Should not panic; warning is emitted via tracing
caps.validate("test-tool");
}
#[test]
fn test_validate_manual_auth_missing_fields() {
// auth without OAuth, missing setup_url and instructions
let json = r#"{
"auth": {
"secret_name": "my_api_key"
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
// Should not panic; warnings emitted for missing setup_url and instructions
caps.validate("test-tool");
}
#[test]
fn test_validate_clean_tool() {
// Well-configured tool with auth, setup_url, instructions, and good prompts
let json = r#"{
"auth": {
"secret_name": "my_api_key",
"setup_url": "https://example.com/api-keys",
"instructions": "Go to example.com/api-keys and create a new key"
},
"setup": {
"required_secrets": [
{
"name": "my_api_key",
"prompt": "Enter your API key from https://example.com/api-keys"
}
]
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
// Should not panic and emits no warnings (has auth, setup_url, instructions, long prompt)
caps.validate("clean-tool");
}
#[test] #[test]
fn test_resolve_nested_empty_capabilities_noop() { fn test_resolve_nested_empty_capabilities_noop() {
// Empty inner capabilities should not clobber outer http // Empty inner capabilities should not clobber outer http
+1
View File
@@ -126,6 +126,7 @@ impl WasmToolLoader {
let cap_bytes = fs::read(cap_path).await?; let cap_bytes = fs::read(cap_path).await?;
let cap_file = CapabilitiesFile::from_bytes(&cap_bytes) let cap_file = CapabilitiesFile::from_bytes(&cap_bytes)
.map_err(|e| WasmLoadError::InvalidCapabilities(e.to_string()))?; .map_err(|e| WasmLoadError::InvalidCapabilities(e.to_string()))?;
cap_file.validate(name);
let caps = cap_file.to_capabilities(); let caps = cap_file.to_capabilities();
let oauth = resolve_oauth_refresh_config(&cap_file); let oauth = resolve_oauth_refresh_config(&cap_file);
(caps, oauth) (caps, oauth)
+146 -1
View File
@@ -592,6 +592,10 @@ impl WasmToolWrapper {
let instance = SandboxedTool::instantiate(&mut store, &component, &linker) let instance = SandboxedTool::instantiate(&mut store, &component, &linker)
.map_err(|e| WasmError::InstantiationFailed(e.to_string()))?; .map_err(|e| WasmError::InstantiationFailed(e.to_string()))?;
// Coerce string-encoded values to their schema-declared types.
// LLMs frequently pass numeric values as strings (e.g. "5" instead of 5).
let params = coerce_params_to_schema(params, &self.schema);
// Prepare the request // Prepare the request
let params_json = serde_json::to_string(&params) let params_json = serde_json::to_string(&params)
.map_err(|e| WasmError::InvalidResponseJson(e.to_string()))?; .map_err(|e| WasmError::InvalidResponseJson(e.to_string()))?;
@@ -652,10 +656,17 @@ impl Tool for WasmToolWrapper {
// Pre-resolve host credentials from secrets store (async, before blocking task). // Pre-resolve host credentials from secrets store (async, before blocking task).
// This decrypts the secrets once so the sync http_request() host function // This decrypts the secrets once so the sync http_request() host function
// can inject them without needing async access. // can inject them without needing async access.
//
// BUG FIX: ExtensionManager stores OAuth tokens under user_id "default"
// (hardcoded at construction in app.rs), but this was previously looking
// them up under ctx.user_id — which could be a Telegram user ID, web
// gateway user, etc. — causing credential resolution to silently fail.
// Must match the storage key until per-user credential isolation is added.
let credential_user_id = "default";
let host_credentials = resolve_host_credentials( let host_credentials = resolve_host_credentials(
&self.capabilities, &self.capabilities,
self.secrets_store.as_deref(), self.secrets_store.as_deref(),
&ctx.user_id, credential_user_id,
self.oauth_refresh.as_ref(), self.oauth_refresh.as_ref(),
) )
.await; .await;
@@ -1083,6 +1094,61 @@ fn is_private_ip(ip: std::net::IpAddr) -> bool {
} }
} }
/// Coerce parameter values to match their JSON Schema-declared types.
///
/// LLMs frequently send numeric values as strings (e.g. `"5"` instead of `5`)
/// or booleans as strings (`"true"` instead of `true`). This walks the params
/// object and converts string values where the schema expects a different type.
fn coerce_params_to_schema(
mut params: serde_json::Value,
schema: &serde_json::Value,
) -> serde_json::Value {
let properties = schema.get("properties").and_then(|p| p.as_object());
let properties = match properties {
Some(p) => p,
None => return params,
};
let obj = match params.as_object_mut() {
Some(o) => o,
None => return params,
};
for (key, prop_schema) in properties {
let declared_type = prop_schema.get("type").and_then(|t| t.as_str());
let declared_type = match declared_type {
Some(t) => t,
None => continue,
};
if let Some(current_value) = obj.get_mut(key)
&& let Some(s) = current_value.as_str()
{
if declared_type == "string" {
continue;
}
let coerced = match declared_type {
"number" => s.parse::<f64>().ok().map(serde_json::Value::from),
"integer" => s.parse::<i64>().ok().map(serde_json::Value::from),
"boolean" => match s.to_lowercase().as_str() {
"true" => Some(serde_json::json!(true)),
"false" => Some(serde_json::json!(false)),
_ => None,
},
_ => None,
};
if let Some(new_val) = coerced {
*current_value = new_val;
}
}
}
params
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::sync::Arc; use std::sync::Arc;
@@ -1588,4 +1654,83 @@ mod tests {
let result = super::reject_private_ip("https://8.8.8.8/dns-query"); let result = super::reject_private_ip("https://8.8.8.8/dns-query");
assert!(result.is_ok()); assert!(result.is_ok());
} }
#[test]
fn test_coerce_params_string_to_number() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"count": { "type": "number" },
"name": { "type": "string" }
}
});
let params = serde_json::json!({"count": "5", "name": "test"});
let result = super::coerce_params_to_schema(params, &schema);
assert_eq!(result["count"], serde_json::json!(5.0));
assert_eq!(result["name"], serde_json::json!("test"));
}
#[test]
fn test_coerce_params_string_to_integer() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"limit": { "type": "integer" }
}
});
let params = serde_json::json!({"limit": "10"});
let result = super::coerce_params_to_schema(params, &schema);
assert_eq!(result["limit"], serde_json::json!(10));
}
#[test]
fn test_coerce_params_string_to_boolean() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"a": { "type": "boolean" },
"b": { "type": "boolean" },
"c": { "type": "boolean" },
"d": { "type": "boolean" }
}
});
let params = serde_json::json!({
"a": "true",
"b": "false",
"c": "True",
"d": "FALSE"
});
let result = super::coerce_params_to_schema(params, &schema);
assert_eq!(result["a"], serde_json::json!(true));
assert_eq!(result["b"], serde_json::json!(false));
assert_eq!(result["c"], serde_json::json!(true));
assert_eq!(result["d"], serde_json::json!(false));
}
#[test]
fn test_coerce_params_already_correct_type() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"count": { "type": "number" }
}
});
let params = serde_json::json!({"count": 5});
let result = super::coerce_params_to_schema(params, &schema);
assert_eq!(result["count"], serde_json::json!(5));
}
#[test]
fn test_coerce_params_invalid_string_not_coerced() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"count": { "type": "number" }
}
});
let params = serde_json::json!({"count": "not-a-number"});
let result = super::coerce_params_to_schema(params, &schema);
// Should remain as string since it can't be parsed
assert_eq!(result["count"], serde_json::json!("not-a-number"));
}
} }
+12 -2
View File
@@ -98,6 +98,13 @@ async def ironclaw_server(ironclaw_binary, mock_llm_server):
# Prevent onboarding wizard from triggering # Prevent onboarding wizard from triggering
"ONBOARD_COMPLETED": "true", "ONBOARD_COMPLETED": "true",
} }
# Forward LLVM coverage instrumentation env vars when present
# (allows cargo-llvm-cov to collect profraw data from E2E runs)
for key in ("LLVM_PROFILE_FILE", "CARGO_LLVM_COV", "CARGO_LLVM_COV_SHOW_ENV",
"CARGO_LLVM_COV_TARGET_DIR"):
val = os.environ.get(key)
if val is not None:
env[key] = val
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
ironclaw_binary, "--no-onboard", ironclaw_binary, "--no-onboard",
stdin=asyncio.subprocess.DEVNULL, stdin=asyncio.subprocess.DEVNULL,
@@ -126,9 +133,12 @@ async def ironclaw_server(ironclaw_binary, mock_llm_server):
) )
finally: finally:
if proc.returncode is None: if proc.returncode is None:
proc.send_signal(signal.SIGTERM) # Use SIGINT (not SIGTERM) so tokio's ctrl_c handler triggers a
# graceful shutdown. This lets the LLVM coverage runtime run its
# atexit handler and flush .profraw files for cargo-llvm-cov.
proc.send_signal(signal.SIGINT)
try: try:
await asyncio.wait_for(proc.wait(), timeout=5) await asyncio.wait_for(proc.wait(), timeout=10)
except asyncio.TimeoutError: except asyncio.TimeoutError:
proc.kill() proc.kill()
+2
View File
@@ -312,6 +312,8 @@ async fn test_ws_multiple_events_in_sequence() {
state.sse.broadcast(SseEvent::ToolCompleted { state.sse.broadcast(SseEvent::ToolCompleted {
name: "shell".to_string(), name: "shell".to_string(),
success: true, success: true,
error: None,
parameters: None,
thread_id: None, thread_id: None,
}); });
state.sse.broadcast(SseEvent::Response { state.sse.broadcast(SseEvent::Response {
@@ -34,11 +34,19 @@
] ]
} }
}, },
"auth": {
"secret_name": "github_token",
"display_name": "GitHub",
"instructions": "Create a Personal Access Token at github.com/settings/tokens with repo scope, then paste it here.",
"setup_url": "https://github.com/settings/tokens",
"token_hint": "Starts with 'ghp_' or 'github_pat_'",
"env_var": "GITHUB_TOKEN"
},
"setup": { "setup": {
"required_secrets": [ "required_secrets": [
{ {
"name": "github_token", "name": "github_token",
"prompt": "GitHub Personal Access Token (from github.com/settings/tokens)" "prompt": "GitHub Personal Access Token (create one at github.com/settings/tokens with 'repo' scope)"
} }
] ]
}, },