Compare commits

..
Author SHA1 Message Date
mackabyandZaki 5677e5e955 fix(db): invoke shutdown during runtime teardown
Call Database::shutdown() from async_main graceful shutdown after webhook/tunnel stop.

Add debug logs for libSQL no-op shutdown paths (no replicator and sync-not-supported).
2026-03-12 15:08:08 -07:00
mackabyandZaki 91e0c2ee62 feat(db): add backend shutdown hook for graceful runtime shutdown
Add Database::shutdown() with a default no-op implementation for backward compatibility.

Implement libSQL shutdown via flush_replicator(), treating SyncNotSupported as non-fatal.

Implement Postgres shutdown by closing the pool.
2026-03-12 15:06:12 -07:00
53 changed files with 700 additions and 3432 deletions
-59
View File
@@ -1,59 +0,0 @@
#!/usr/bin/env bash
load_commit_summary() {
local range="$1"
local max_commits="${2:-50}"
local commit_list overflow
commit_list="$(git log --oneline --no-merges --reverse "${range}" 2>/dev/null || echo "")"
if [ -n "${commit_list}" ]; then
COMMIT_COUNT="$(printf '%s\n' "${commit_list}" | wc -l | tr -d ' ')"
if [ "${COMMIT_COUNT}" -gt "${max_commits}" ]; then
COMMIT_MD="$(printf '%s\n' "${commit_list}" | head -n "${max_commits}" | sed 's/^/- /')"
overflow=$((COMMIT_COUNT - max_commits))
COMMIT_MD+=$'\n'"- ... and ${overflow} more (see compare view)"
else
COMMIT_MD="$(printf '%s\n' "${commit_list}" | sed 's/^/- /')"
fi
else
COMMIT_COUNT=0
COMMIT_MD="- (no non-merge commits in range)"
fi
}
replace_marked_section() {
local body_file="$1"
local section_file="$2"
local section_start="$3"
local section_end="$4"
local output_file="$5"
if grep -qF "${section_start}" "${body_file}" && grep -qF "${section_end}" "${body_file}"; then
awk -v start="${section_start}" -v end="${section_end}" -v replacement_file="${section_file}" '
BEGIN {
while ((getline line < replacement_file) > 0) {
replacement = replacement line ORS
}
in_block = 0
}
$0 == start {
printf "%s", replacement
in_block = 1
next
}
$0 == end {
in_block = 0
next
}
!in_block {
print
}
' "${body_file}" > "${output_file}"
else
cp "${body_file}" "${output_file}"
if [ -s "${output_file}" ]; then
printf '\n\n' >> "${output_file}"
fi
cat "${section_file}" >> "${output_file}"
fi
}
-101
View File
@@ -1,101 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
: "${PR_NUMBER:?PR_NUMBER is required}"
: "${REPO:?REPO is required}"
MAIN_BRANCH="${MAIN_BRANCH:-main}"
DRY_RUN="${DRY_RUN:-false}"
SECTION_START="<!-- staging-promotion-release-summary:start -->"
SECTION_END="<!-- staging-promotion-release-summary:end -->"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "${TMP_DIR}"' EXIT
# shellcheck source=.github/scripts/pr-body-utils.sh
source "$(dirname "$0")/pr-body-utils.sh"
gh pr view "${PR_NUMBER}" --repo "${REPO}" --json body > "${TMP_DIR}/pr.json"
jq -r '.body // ""' < "${TMP_DIR}/pr.json" > "${TMP_DIR}/body.md"
git fetch origin "${MAIN_BRANCH}"
git fetch origin "+refs/tags/v*:refs/tags/v*"
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 "origin/${MAIN_BRANCH}" 2>/dev/null || true)"
if [ -n "${LAST_TAG}" ]; then
RANGE="${LAST_TAG}..origin/${MAIN_BRANCH}"
HEADER="## Staging promotion batches since ${LAST_TAG}"
EMPTY_MESSAGE="_No structured staging promotion merges found since ${LAST_TAG}._"
else
RANGE="origin/${MAIN_BRANCH}"
HEADER="## Staging promotion batches on ${MAIN_BRANCH}"
EMPTY_MESSAGE="_No structured staging promotion merges found on ${MAIN_BRANCH}._"
fi
{
echo "${SECTION_START}"
echo "${HEADER}"
echo
} > "${TMP_DIR}/section.md"
FOUND_SUMMARY=false
while IFS= read -r sha; do
[ -n "${sha}" ] || continue
BODY="$(git show -s --format=%b "${sha}")"
if ! printf '%s\n' "${BODY}" | grep -q '^staging-promotion-summary-v1$'; then
continue
fi
FOUND_SUMMARY=true
SUBJECT="$(git show -s --format=%s "${sha}")"
PR_REF="$(printf '%s\n' "${BODY}" | sed -n 's/^promotion-pr: //p' | head -n 1)"
COMMIT_COUNT="$(printf '%s\n' "${BODY}" | sed -n 's/^current-commit-count: //p' | head -n 1)"
CURRENT_RANGE="$(printf '%s\n' "${BODY}" | sed -n 's/^current-range: //p' | head -n 1)"
COMMIT_BLOCK="$(printf '%s\n' "${BODY}" | awk 'capture { print } /^Current commits in this promotion \([0-9]+\):$/ { capture = 1 }')"
{
echo "### ${SUBJECT}"
echo
if [ -n "${PR_REF}" ]; then
echo "**Promotion PR:** ${PR_REF}"
fi
if [ -n "${COMMIT_COUNT}" ]; then
echo "**Commit count:** ${COMMIT_COUNT}"
fi
if [ -n "${CURRENT_RANGE}" ]; then
echo "**Range:** \`${CURRENT_RANGE}\`"
fi
echo
if [ -n "${COMMIT_BLOCK}" ]; then
echo "${COMMIT_BLOCK}"
else
echo "- (no commit summary found)"
fi
echo
} >> "${TMP_DIR}/section.md"
done < <(git log --merges --reverse --format='%H' "${RANGE}")
if [ "${FOUND_SUMMARY}" = false ]; then
{
echo "${EMPTY_MESSAGE}"
echo
} >> "${TMP_DIR}/section.md"
fi
{
echo "*Auto-updated from structured staging promotion merge bodies on ${MAIN_BRANCH}.*"
echo "${SECTION_END}"
} >> "${TMP_DIR}/section.md"
replace_marked_section \
"${TMP_DIR}/body.md" \
"${TMP_DIR}/section.md" \
"${SECTION_START}" \
"${SECTION_END}" \
"${TMP_DIR}/new-body.md"
if [ "${DRY_RUN}" = "true" ]; then
echo "Dry run enabled. Computed PR body for #${PR_NUMBER}:"
cat "${TMP_DIR}/new-body.md"
else
gh pr edit "${PR_NUMBER}" --repo "${REPO}" --body-file "${TMP_DIR}/new-body.md"
fi
@@ -1,53 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
: "${PR_NUMBER:?PR_NUMBER is required}"
: "${REPO:?REPO is required}"
MAX_COMMITS="${MAX_COMMITS:-50}"
DRY_RUN="${DRY_RUN:-false}"
SECTION_START="<!-- staging-ci-current:start -->"
SECTION_END="<!-- staging-ci-current:end -->"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "${TMP_DIR}"' EXIT
# shellcheck source=.github/scripts/pr-body-utils.sh
source "$(dirname "$0")/pr-body-utils.sh"
gh pr view "${PR_NUMBER}" --repo "${REPO}" --json body,baseRefName,headRefName > "${TMP_DIR}/pr.json"
jq -r '.body // ""' < "${TMP_DIR}/pr.json" > "${TMP_DIR}/body.md"
BASE="$(jq -r '.baseRefName' < "${TMP_DIR}/pr.json")"
HEAD="$(jq -r '.headRefName' < "${TMP_DIR}/pr.json")"
RANGE="origin/${BASE}..origin/${HEAD}"
git fetch origin "${BASE}" "${HEAD}"
load_commit_summary "${RANGE}" "${MAX_COMMITS}"
{
echo "${SECTION_START}"
echo "### Current commits in this promotion (${COMMIT_COUNT})"
echo
echo "**Current base:** \`${BASE}\`"
echo "**Current head:** \`${HEAD}\`"
echo "**Current range:** \`${RANGE}\`"
echo
echo "${COMMIT_MD}"
echo
echo "*Auto-updated by staging promotion metadata workflow*"
echo "${SECTION_END}"
} > "${TMP_DIR}/section.md"
replace_marked_section \
"${TMP_DIR}/body.md" \
"${TMP_DIR}/section.md" \
"${SECTION_START}" \
"${SECTION_END}" \
"${TMP_DIR}/new-body.md"
if [ "${DRY_RUN}" = "true" ]; then
echo "Dry run enabled. Computed PR body for #${PR_NUMBER}:"
cat "${TMP_DIR}/new-body.md"
else
gh pr edit "${PR_NUMBER}" --repo "${REPO}" --body-file "${TMP_DIR}/new-body.md"
fi
+1 -1
View File
@@ -52,7 +52,7 @@ jobs:
- group: features
files: "tests/e2e/scenarios/test_skills.py tests/e2e/scenarios/test_tool_approval.py"
- group: extensions
files: "tests/e2e/scenarios/test_extensions.py tests/e2e/scenarios/test_extension_oauth.py tests/e2e/scenarios/test_wasm_lifecycle.py tests/e2e/scenarios/test_tool_execution.py tests/e2e/scenarios/test_pairing.py"
files: "tests/e2e/scenarios/test_extensions.py"
steps:
- uses: actions/checkout@v6
@@ -1,44 +0,0 @@
name: Release-plz Batch Summary
on:
workflow_dispatch:
inputs:
pr_number:
description: "release-plz PR number to refresh"
required: true
type: string
dry_run:
description: "Compute the body update without editing the PR"
required: false
type: boolean
default: true
pull_request_target:
types: [opened, synchronize, reopened]
permissions:
contents: read
pull-requests: write
jobs:
update-release-pr:
if: >
(github.event_name == 'pull_request_target' &&
github.event.pull_request.head.repo.full_name == github.repository &&
startsWith(github.event.pull_request.head.ref, 'release-plz-')) ||
github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- name: Checkout base branch
uses: actions/checkout@v6
with:
ref: ${{ github.event_name == 'workflow_dispatch' && 'main' || github.event.pull_request.base.ref }}
fetch-depth: 0
fetch-tags: true
- name: Update release-plz PR body with staging batch summary
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }}
REPO: ${{ github.repository }}
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || 'false' }}
run: bash .github/scripts/update-release-plz-body.sh
+1 -7
View File
@@ -58,16 +58,10 @@ jobs:
- *checkout
- *install-rust
- uses: Swatinem/rust-cache@v2
- name: Generate GitHub token
uses: actions/create-github-app-token@v2
id: generate-token
with:
app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
private-key: ${{ secrets.GH_RELEASES_MANAGER_APP_PRIVATE_KEY }}
- name: Run release-plz
uses: release-plz/[email protected]
with:
command: release-pr
env:
GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
+63 -111
View File
@@ -25,35 +25,9 @@ concurrency:
cancel-in-progress: false # Let running suites finish
jobs:
# ── Resolve promotion base branch ───────────────────────────────
resolve-promotion-base:
name: Resolve promotion base
runs-on: ubuntu-latest
outputs:
promotion_base: ${{ steps.resolve.outputs.promotion_base }}
steps:
- name: Resolve promotion base
id: resolve
env:
GH_TOKEN: ${{ github.token }}
FALLBACK_BRANCH: main
REPO: ${{ github.repository }}
run: |
LATEST=$(gh pr list --repo "${REPO}" --label staging-promotion --state open \
--json headRefName,createdAt \
--jq '[.[] | select(.headRefName | startswith("staging-promote/"))] | sort_by(.createdAt) | last | .headRefName // empty')
if [ -n "$LATEST" ]; then
echo "promotion_base=${LATEST}" >> "$GITHUB_OUTPUT"
echo "Using open promotion branch as base: ${LATEST}"
else
echo "promotion_base=${FALLBACK_BRANCH}" >> "$GITHUB_OUTPUT"
echo "No open promotion branch found. Using ${FALLBACK_BRANCH}."
fi
# ── Check for new commits ──────────────────────────────────────
check-changes:
name: Check for new commits
needs: resolve-promotion-base
runs-on: ubuntu-latest
outputs:
has_changes: ${{ steps.check.outputs.has_changes }}
@@ -70,7 +44,7 @@ jobs:
id: check
env:
FORCE_RUN: ${{ inputs.force }}
PROMOTION_BASE: ${{ needs.resolve-promotion-base.outputs.promotion_base }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
CURRENT_HEAD=$(git rev-parse HEAD)
echo "current_head=${CURRENT_HEAD}" >> "$GITHUB_OUTPUT"
@@ -92,9 +66,9 @@ jobs:
echo "Found ${COMMIT_COUNT} new commit(s) since last tested"
DIFF_RANGE="${LAST_TESTED}..${CURRENT_HEAD}"
else
git fetch origin "${PROMOTION_BASE}"
MERGE_BASE=$(git merge-base "origin/${PROMOTION_BASE}" HEAD)
echo "First run -- reviewing from merge-base ${MERGE_BASE} against ${PROMOTION_BASE}"
git fetch origin "${DEFAULT_BRANCH}"
MERGE_BASE=$(git merge-base "origin/${DEFAULT_BRANCH}" HEAD)
echo "First run -- reviewing from merge-base ${MERGE_BASE}"
DIFF_RANGE="${MERGE_BASE}..${CURRENT_HEAD}"
fi
fi
@@ -128,7 +102,7 @@ jobs:
# ── Create promotion PR (triggers claude-review.yml on the PR) ──
create-promotion-pr:
name: Create Promotion PR
needs: [resolve-promotion-base, check-changes]
needs: check-changes
if: needs.check-changes.outputs.has_changes == 'true'
runs-on: ubuntu-latest
outputs:
@@ -160,15 +134,15 @@ jobs:
id: ahead-check
env:
GH_TOKEN: ${{ steps.token.outputs.token }}
PROMOTION_BASE: ${{ needs.resolve-promotion-base.outputs.promotion_base }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
git fetch origin "${PROMOTION_BASE}"
AHEAD=$(git rev-list --count "origin/${PROMOTION_BASE}..origin/staging")
git fetch origin "${DEFAULT_BRANCH}"
AHEAD=$(git rev-list --count "origin/${DEFAULT_BRANCH}..origin/staging")
echo "commits_ahead=${AHEAD}" >> "$GITHUB_OUTPUT"
if [ "$AHEAD" -eq 0 ]; then
echo "Staging is not ahead of ${PROMOTION_BASE}. Nothing to promote."
echo "Staging is not ahead of ${DEFAULT_BRANCH}. Nothing to promote."
else
echo "Staging is ${AHEAD} commits ahead of ${PROMOTION_BASE}."
echo "Staging is ${AHEAD} commits ahead of ${DEFAULT_BRANCH}."
fi
- name: Create promotion branch
@@ -182,53 +156,54 @@ jobs:
echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT"
echo "Created promotion branch: ${BRANCH}"
- name: Find base branch
id: find-base
if: steps.ahead-check.outputs.commits_ahead != '0'
env:
GH_TOKEN: ${{ steps.token.outputs.token }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
# Find the newest open promotion PR with a staging-promote/* head branch
LATEST=$(gh pr list --label staging-promotion --state open \
--json headRefName,createdAt \
--jq '[.[] | select(.headRefName | startswith("staging-promote/"))] | sort_by(.createdAt) | last | .headRefName // empty')
if [ -n "$LATEST" ]; then
echo "base=${LATEST}" >> "$GITHUB_OUTPUT"
echo "Chaining onto existing promotion branch: ${LATEST}"
else
echo "base=${DEFAULT_BRANCH}" >> "$GITHUB_OUTPUT"
echo "No existing promotion PR — targeting ${DEFAULT_BRANCH}"
fi
- name: Create promotion PR
id: create-pr
if: steps.ahead-check.outputs.commits_ahead != '0'
env:
GH_TOKEN: ${{ steps.token.outputs.token }}
run: |
source .github/scripts/pr-body-utils.sh
RANGE="${{ needs.check-changes.outputs.diff_range }}"
TIMESTAMP=$(date -u +"%Y-%m-%d %H:%M UTC")
BRANCH="${{ steps.branch.outputs.branch }}"
BASE="${{ needs.resolve-promotion-base.outputs.promotion_base }}"
MAX_COMMITS=50
load_commit_summary "${RANGE}" "${MAX_COMMITS}"
# Build PR body via concatenation to avoid heredoc shell expansion
# (commit messages in COMMIT_MD may contain $, backticks, or backslashes)
PR_BODY="## Auto-promotion from staging CI"
PR_BODY+=$'\n\n'"**Batch range:** \`${RANGE}\`"
PR_BODY+=$'\n'"**Promotion branch:** \`${BRANCH}\`"
PR_BODY+=$'\n'"**Base:** \`${BASE}\`"
PR_BODY+=$'\n'"**Triggered by:** Staging CI batch at ${TIMESTAMP}"
PR_BODY+=$'\n\n'"### Commits in this batch (${COMMIT_COUNT}):"
PR_BODY+=$'\n'"${COMMIT_MD}"
PR_BODY+=$'\n\n'"<!-- staging-ci-current:start -->"
PR_BODY+=$'\n'"### Current commits in this promotion (${COMMIT_COUNT})"
PR_BODY+=$'\n'
PR_BODY+=$'\n'"**Current base:** \`${BASE}\`"
PR_BODY+=$'\n'"**Current head:** \`${BRANCH}\`"
PR_BODY+=$'\n'"**Current range:** \`origin/${BASE}..origin/${BRANCH}\`"
PR_BODY+=$'\n'
PR_BODY+=$'\n'"${COMMIT_MD}"
PR_BODY+=$'\n'
PR_BODY+=$'\n'"*Auto-updated by staging promotion metadata workflow*"
PR_BODY+=$'\n'"<!-- staging-ci-current:end -->"
PR_BODY+=$'\n\n'"Waiting for gates:"
PR_BODY+=$'\n'"- Tests: pending"
PR_BODY+=$'\n'"- E2E: pending"
PR_BODY+=$'\n'"- Claude Code review: pending (will post comments on this PR)"
PR_BODY+=$'\n\n'"---"
PR_BODY+=$'\n'"*Auto-created by staging-ci workflow*"
BASE="${{ steps.find-base.outputs.base }}"
PR_URL=$(gh pr create \
--base "$BASE" \
--head "$BRANCH" \
--title "chore: promote staging to ${BASE} (${TIMESTAMP})" \
--body "$PR_BODY" \
--body "## Auto-promotion from staging CI
**Batch range:** \`${RANGE}\`
**Promotion branch:** \`${BRANCH}\`
**Base:** \`${BASE}\`
**Triggered by:** Staging CI batch at ${TIMESTAMP}
Waiting for gates:
- Tests: pending
- E2E: pending
- Claude Code review: pending (will post comments on this PR)
---
*Auto-created by staging-ci workflow*" \
--label "staging-promotion")
PR_NUM=$(echo "$PR_URL" | grep -oE '[0-9]+$')
@@ -253,8 +228,7 @@ jobs:
- uses: actions/checkout@v6
with:
ref: staging
# Need full history to recompute the final promoted range before merge.
fetch-depth: 0
fetch-depth: 1
- name: Generate GitHub App token
id: app-token
@@ -353,10 +327,8 @@ jobs:
# Use process substitution so variables propagate to parent shell
while read -r line; do
TAG=$(echo "$line" | grep -oE '^\[(CRITICAL|HIGH|MEDIUM|LOW):[0-9]+\]')
SEVERITY="${TAG#\[}"
SEVERITY="${SEVERITY%%:*}"
CONFIDENCE="${TAG##*:}"
CONFIDENCE="${CONFIDENCE%\]}"
SEVERITY=$(echo "$TAG" | sed 's/\[\(.*\):\(.*\)\]/\1/')
CONFIDENCE=$(echo "$TAG" | sed 's/\[\(.*\):\(.*\)\]/\2/')
DESC=$(echo "$line" | sed "s/\[${SEVERITY}:${CONFIDENCE}\] *//" | head -1)
echo "Found: [${SEVERITY}:${CONFIDENCE}] ${DESC}"
@@ -448,29 +420,11 @@ jobs:
GH_TOKEN: ${{ steps.token.outputs.token }}
PR_NUMBER: ${{ needs.create-promotion-pr.outputs.pr_number }}
run: |
source .github/scripts/pr-body-utils.sh
if [ -n "$PR_NUMBER" ]; then
BASE=$(gh pr view "$PR_NUMBER" --json baseRefName --jq '.baseRefName')
if [ "$BASE" = "main" ]; then
echo "Merging promotion PR #${PR_NUMBER} (targets main)"
TITLE=$(gh pr view "$PR_NUMBER" --json title --jq '.title')
HEAD_BRANCH=$(gh pr view "$PR_NUMBER" --json headRefName --jq '.headRefName')
git fetch origin "${BASE}" "${HEAD_BRANCH}"
CURRENT_RANGE="origin/${BASE}..origin/${HEAD_BRANCH}"
MAX_COMMITS=50
load_commit_summary "${CURRENT_RANGE}" "${MAX_COMMITS}"
{
echo "staging-promotion-summary-v1"
echo "promotion-pr: #${PR_NUMBER}"
echo "base: ${BASE}"
echo "head: ${HEAD_BRANCH}"
echo "current-range: ${CURRENT_RANGE}"
echo "current-commit-count: ${COMMIT_COUNT}"
echo ""
echo "Current commits in this promotion (${COMMIT_COUNT}):"
echo "${COMMIT_MD}"
} > /tmp/staging-promotion-merge-body.md
gh pr merge "$PR_NUMBER" --merge --subject "#${PR_NUMBER} $TITLE" --body-file /tmp/staging-promotion-merge-body.md
gh pr merge "$PR_NUMBER" --merge
echo "merged=true" >> "$GITHUB_OUTPUT"
else
echo "PR #${PR_NUMBER} targets '${BASE}' (not main) — leaving open for chain resolution"
@@ -510,20 +464,18 @@ jobs:
steps:
- name: Summary
run: |
{
echo "## Staging CI Batch Results"
echo ""
echo "| Check | Result |"
echo "|-------|--------|"
echo "| Tests | ${{ needs.tests.result }} |"
echo "| E2E | ${{ needs.e2e.result }} |"
echo "| Promotion PR | ${{ needs.create-promotion-pr.result }} |"
echo "| Gate | ${{ needs.gate.result }} |"
echo "| Tag Updated | ${{ needs.update-tag.result }} |"
echo ""
echo "Range: ${{ needs.check-changes.outputs.diff_range }}"
PR_NUM="${{ needs.create-promotion-pr.outputs.pr_number }}"
if [ -n "$PR_NUM" ]; then
echo "Promotion PR: #${PR_NUM}"
fi
} >> "$GITHUB_STEP_SUMMARY"
echo "## Staging CI Batch Results" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Check | Result |" >> "$GITHUB_STEP_SUMMARY"
echo "|-------|--------|" >> "$GITHUB_STEP_SUMMARY"
echo "| Tests | ${{ needs.tests.result }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| E2E | ${{ needs.e2e.result }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Promotion PR | ${{ needs.create-promotion-pr.result }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Gate | ${{ needs.gate.result }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Tag Updated | ${{ needs.update-tag.result }} |" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Range: ${{ needs.check-changes.outputs.diff_range }}" >> "$GITHUB_STEP_SUMMARY"
PR_NUM="${{ needs.create-promotion-pr.outputs.pr_number }}"
if [ -n "$PR_NUM" ]; then
echo "Promotion PR: #${PR_NUM}" >> "$GITHUB_STEP_SUMMARY"
fi
@@ -1,76 +0,0 @@
name: Staging Promotion Metadata
on:
workflow_dispatch:
inputs:
pr_number:
description: "Staging promotion PR number to refresh"
required: true
type: string
dry_run:
description: "Compute the body update without editing the PR"
required: false
type: boolean
default: true
pull_request_target:
types: [opened, synchronize, reopened]
push:
branches:
- main
permissions:
contents: read
pull-requests: write
jobs:
refresh-single-pr:
if: >
(github.event_name == 'pull_request_target' &&
github.event.pull_request.head.repo.full_name == github.repository &&
startsWith(github.event.pull_request.head.ref, 'staging-promote/')) ||
github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- name: Checkout base branch
uses: actions/checkout@v6
with:
ref: ${{ github.event_name == 'workflow_dispatch' && 'main' || github.event.pull_request.base.ref }}
fetch-depth: 0
fetch-tags: true
- name: Refresh staging promotion PR body
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }}
REPO: ${{ github.repository }}
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || 'false' }}
run: bash .github/scripts/update-staging-promotion-body.sh
refresh-open-prs-after-main-push:
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- name: Checkout main
uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
fetch-tags: true
- name: Refresh all open staging promotion PR bodies
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: |
# ubuntu-latest uses bash 5.x, so mapfile is available here.
mapfile -t prs < <(gh pr list --repo "${REPO}" --label staging-promotion --state open \
--json number,headRefName \
--jq '.[] | select(.headRefName | startswith("staging-promote/")) | .number')
if [ "${#prs[@]}" -eq 0 ]; then
echo "No open staging promotion PRs to refresh."
exit 0
fi
for pr in "${prs[@]}"; do
echo "Refreshing staging promotion PR #${pr}"
PR_NUMBER="${pr}" bash .github/scripts/update-staging-promotion-body.sh
done
-1
View File
@@ -19,7 +19,6 @@ WORKDIR /app
# Copy manifests first for layer caching
COPY Cargo.toml Cargo.lock ./
COPY crates/ crates/
# Copy source, build script, tests, and supporting directories
COPY build.rs build.rs
-1
View File
@@ -20,7 +20,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY crates/ crates/
COPY build.rs build.rs
COPY src/ src/
COPY tests/ tests/
+1 -1
View File
@@ -121,7 +121,7 @@ dependencies = [
[[package]]
name = "discord-channel"
version = "0.2.0"
version = "0.1.0"
dependencies = [
"ed25519-dalek",
"hex",
-1
View File
@@ -642,7 +642,6 @@ fn poll_channel_mentions(channel_id: &str, bot_id: &str) {
},
thread_id: None,
metadata_json,
attachments: vec![],
});
remember_processed_id(&mut recent_ids, &msg.id);
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "telegram",
"display_name": "Telegram Channel",
"kind": "channel",
"version": "0.2.3",
"version": "0.2.2",
"wit_version": "0.3.0",
"description": "Talk to your agent through a Telegram bot",
"keywords": [
@@ -18,7 +18,7 @@
},
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-0.2.3-wasm32-wasip2.tar.gz",
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-0.2.2-wasm32-wasip2.tar.gz",
"sha256": "b9a83d5a2d1285ce0ec116b354336a1f245f893291ccb01dffbcaccf89d72aed"
}
},
+2 -11
View File
@@ -1150,11 +1150,9 @@ pub fn spawn_cron_ticker(
interval: Duration,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
// Run one check immediately so routines due at startup don't wait
// an extra full polling interval.
engine.check_cron_triggers().await;
let mut ticker = tokio::time::interval(interval);
// Skip immediate first tick
ticker.tick().await;
loop {
ticker.tick().await;
@@ -1360,11 +1358,4 @@ mod tests {
assert_eq!(finish_reason_length, crate::llm::FinishReason::Length);
assert_eq!(finish_reason_stop, crate::llm::FinishReason::Stop);
}
#[test]
fn test_truncate_adds_ellipsis_when_over_limit() {
let input = "abcdefghijk";
let out = super::truncate(input, 5);
assert_eq!(out, "abcde...");
}
}
-1
View File
@@ -290,7 +290,6 @@ impl AppBuilder {
Arc::new(ToolRegistry::new())
};
tools.register_builtin_tools();
tools.register_tool_info();
if let Some(ref ss) = self.secrets_store {
tools.register_secrets_tools(Arc::clone(ss));
+76 -112
View File
@@ -269,105 +269,95 @@ async fn webhook_handler(
let mut fallback_req = None;
{
let webhook_secret = state.webhook_secret.read().await;
let Some(expected_secret) = webhook_secret.as_ref() else {
return (
StatusCode::UNAUTHORIZED,
Json(WebhookResponse {
message_id: Uuid::nil(),
status: "error".to_string(),
response: Some(
"Webhook authentication required: HTTP webhook secret is not configured."
.to_string(),
),
}),
)
.into_response();
};
let expected_secret = expected_secret.expose_secret();
if let Some(expected_secret) = webhook_secret.as_ref() {
let expected_secret = expected_secret.expose_secret();
match headers.get("x-ironclaw-signature") {
Some(raw_signature) => match raw_signature.to_str() {
Ok(signature) => {
if !verify_hmac_signature(expected_secret, &body, signature) {
return (
StatusCode::UNAUTHORIZED,
Json(WebhookResponse {
message_id: Uuid::nil(),
status: "error".to_string(),
response: Some("Invalid webhook signature".to_string()),
}),
)
.into_response();
match headers.get("x-ironclaw-signature") {
Some(raw_signature) => match raw_signature.to_str() {
Ok(signature) => {
if !verify_hmac_signature(expected_secret, &body, signature) {
return (
StatusCode::UNAUTHORIZED,
Json(WebhookResponse {
message_id: Uuid::nil(),
status: "error".to_string(),
response: Some("Invalid webhook signature".to_string()),
}),
)
.into_response();
}
}
}
Err(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(WebhookResponse {
message_id: Uuid::nil(),
status: "error".to_string(),
response: Some("Invalid signature header encoding".to_string()),
}),
)
.into_response();
}
},
None => {
let req: WebhookRequest = match serde_json::from_slice(&body) {
Ok(req) => req,
Err(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(WebhookResponse {
message_id: Uuid::nil(),
status: "error".to_string(),
response: Some(
"Webhook authentication required. Provide X-IronClaw-Signature header \
(preferred) or 'secret' field in body (deprecated)."
.to_string(),
),
response: Some("Invalid signature header encoding".to_string()),
}),
)
.into_response();
}
};
},
None => {
let req: WebhookRequest = match serde_json::from_slice(&body) {
Ok(req) => req,
Err(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(WebhookResponse {
message_id: Uuid::nil(),
status: "error".to_string(),
response: Some(
"Webhook authentication required. Provide X-IronClaw-Signature header \
(preferred) or 'secret' field in body (deprecated)."
.to_string(),
),
}),
)
.into_response();
}
};
match &req.secret {
Some(provided)
if bool::from(provided.as_bytes().ct_eq(expected_secret.as_bytes())) =>
{
tracing::warn!(
"Webhook authenticated via deprecated 'secret' field in request body. \
Migrate to X-IronClaw-Signature header (HMAC-SHA256). \
Body secret support will be removed in a future release."
);
fallback_req = Some(req);
}
Some(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(WebhookResponse {
message_id: Uuid::nil(),
status: "error".to_string(),
response: Some("Invalid webhook secret".to_string()),
}),
)
.into_response();
}
None => {
return (
StatusCode::UNAUTHORIZED,
Json(WebhookResponse {
message_id: Uuid::nil(),
status: "error".to_string(),
response: Some(
"Webhook authentication required. Provide X-IronClaw-Signature header \
(preferred) or 'secret' field in body (deprecated)."
.to_string(),
),
}),
)
.into_response();
match &req.secret {
Some(provided)
if bool::from(
provided.as_bytes().ct_eq(expected_secret.as_bytes()),
) =>
{
tracing::warn!(
"Webhook authenticated via deprecated 'secret' field in request body. \
Migrate to X-IronClaw-Signature header (HMAC-SHA256). \
Body secret support will be removed in a future release."
);
fallback_req = Some(req);
}
Some(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(WebhookResponse {
message_id: Uuid::nil(),
status: "error".to_string(),
response: Some("Invalid webhook secret".to_string()),
}),
)
.into_response();
}
None => {
return (
StatusCode::UNAUTHORIZED,
Json(WebhookResponse {
message_id: Uuid::nil(),
status: "error".to_string(),
response: Some(
"Webhook authentication required. Provide X-IronClaw-Signature header \
(preferred) or 'secret' field in body (deprecated)."
.to_string(),
),
}),
)
.into_response();
}
}
}
}
@@ -1062,32 +1052,6 @@ mod tests {
);
}
#[tokio::test]
async fn webhook_rejects_requests_after_secret_is_cleared() {
let secret = "test-secret-123";
let channel = test_channel(Some(secret));
let _stream = channel.start().await.unwrap();
let app = channel.routes();
channel.update_secret(None).await;
let body = serde_json::json!({
"content": "hello"
});
let body_bytes = serde_json::to_vec(&body).unwrap();
let signature = compute_signature(secret, &body_bytes);
let req = Request::builder()
.method("POST")
.uri("/webhook")
.header("content-type", "application/json")
.header("x-ironclaw-signature", signature)
.body(Body::from(body_bytes))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_concurrent_requests_during_secret_update() {
use std::sync::Arc as StdArc;
-10
View File
@@ -10,7 +10,6 @@ use axum::{
use serde::Deserialize;
use uuid::Uuid;
use crate::agent::routine::{Trigger, next_cron_fire};
use crate::channels::web::server::GatewayState;
use crate::channels::web::types::*;
use crate::error::RoutineError;
@@ -183,21 +182,12 @@ pub async fn routines_toggle_handler(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?;
let was_enabled = routine.enabled;
// If a specific value was provided, use it; otherwise toggle.
routine.enabled = match body {
Some(Json(req)) => req.enabled.unwrap_or(!routine.enabled),
None => !routine.enabled,
};
if routine.enabled
&& !was_enabled
&& let Trigger::Cron { schedule, timezone } = &routine.trigger
{
routine.next_fire_at = next_cron_fire(schedule, timezone.as_deref())
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
}
store
.update_routine(&routine)
.await
+72 -130
View File
@@ -26,7 +26,6 @@ use tower_http::set_header::SetResponseHeaderLayer;
use uuid::Uuid;
use crate::agent::SessionManager;
use crate::agent::routine::{Trigger, next_cron_fire};
use crate::bootstrap::ironclaw_base_dir;
use crate::channels::IncomingMessage;
use crate::channels::relay::DEFAULT_RELAY_NAME;
@@ -573,14 +572,6 @@ async fn oauth_callback_handler(
extension = %flow.extension_name,
"OAuth flow expired"
);
// Notify UI so auth card can show error instead of staying stuck
if let Some(ref sender) = flow.sse_sender {
let _ = sender.send(SseEvent::AuthCompleted {
extension_name: flow.extension_name.clone(),
success: false,
message: "OAuth flow expired. Please try again.".to_string(),
});
}
return oauth_error_page(&flow.display_name);
}
@@ -2425,21 +2416,12 @@ async fn routines_toggle_handler(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?;
let was_enabled = routine.enabled;
// If a specific value was provided, use it; otherwise toggle.
routine.enabled = match body {
Some(Json(req)) => req.enabled.unwrap_or(!routine.enabled),
None => !routine.enabled,
};
if routine.enabled
&& !was_enabled
&& let Trigger::Cron { schedule, timezone } = &routine.trigger
{
routine.next_fire_at = next_cron_fire(schedule, timezone.as_deref())
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
}
store
.update_routine(&routine)
.await
@@ -2714,7 +2696,6 @@ struct GatewayStatusResponse {
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::oauth_defaults;
use crate::testing::credentials::TEST_GATEWAY_CRYPTO_KEY;
#[test]
@@ -2832,11 +2813,6 @@ mod tests {
.with_state(state)
}
fn expired_flow_created_at() -> Option<std::time::Instant> {
std::time::Instant::now()
.checked_sub(oauth_defaults::OAUTH_FLOW_EXPIRY + std::time::Duration::from_secs(1))
}
#[tokio::test]
async fn test_csp_header_present_on_responses() {
use std::net::SocketAddr;
@@ -2943,14 +2919,29 @@ mod tests {
use tower::ServiceExt;
// Build an ExtensionManager so the handler can look up flows
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_GATEWAY_CRYPTO_KEY.to_string(),
))
.expect("crypto"),
)));
let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(secrets);
let secrets = Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
TEST_GATEWAY_CRYPTO_KEY.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,
Arc::new(crate::tools::mcp::process::McpProcessManager::new()),
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);
@@ -2984,13 +2975,25 @@ mod tests {
))
.expect("crypto"),
)));
let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(secrets.clone());
let Some(created_at) = expired_flow_created_at() else {
eprintln!("Skipping expired OAuth flow test: monotonic uptime below expiry window");
return;
};
let tool_registry = Arc::new(ToolRegistry::new());
let mcp_sm = Arc::new(crate::tools::mcp::session::McpSessionManager::new());
// Insert an expired flow.
let ext_mgr = Arc::new(ExtensionManager::new(
mcp_sm,
Arc::new(crate::tools::mcp::process::McpProcessManager::new()),
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(),
@@ -3010,7 +3013,9 @@ mod tests {
gateway_token: None,
resource: None,
client_id_secret_name: None,
created_at,
created_at: std::time::Instant::now()
.checked_sub(std::time::Duration::from_secs(600))
.expect("System uptime is too low to run expired flow test"),
};
ext_mgr
@@ -3040,80 +3045,6 @@ mod tests {
assert!(html.contains("Authorization Failed"));
}
#[tokio::test]
async fn test_oauth_callback_expired_flow_broadcasts_auth_completed_failure() {
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_GATEWAY_CRYPTO_KEY.to_string(),
))
.expect("crypto"),
)));
let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(secrets.clone());
let (sender, mut receiver) = tokio::sync::broadcast::channel(4);
let Some(created_at) = expired_flow_created_at() else {
eprintln!("Skipping expired OAuth flow SSE test: monotonic uptime below expiry window");
return;
};
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: Some(sender),
gateway_token: None,
resource: None,
client_id_secret_name: None,
created_at,
};
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);
match receiver.recv().await.expect("auth_completed event") {
crate::channels::web::types::SseEvent::AuthCompleted {
extension_name,
success,
message,
} => {
assert_eq!(extension_name, "test_tool");
assert!(!success, "expired OAuth flow should broadcast failure");
assert_eq!(message, "OAuth flow expired. Please try again.");
}
event => panic!("expected AuthCompleted event, got {event:?}"),
}
}
#[tokio::test]
async fn test_oauth_callback_no_extension_manager() {
use axum::body::Body;
@@ -3152,16 +3083,28 @@ mod tests {
))
.expect("crypto"),
)));
let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(secrets.clone());
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,
Arc::new(crate::tools::mcp::process::McpProcessManager::new()),
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 Some(created_at) = expired_flow_created_at() else {
eprintln!("Skipping OAuth state-prefix test: monotonic uptime below expiry window");
return;
};
let flow = crate::cli::oauth_defaults::PendingOAuthFlow {
extension_name: "test_tool".to_string(),
display_name: "Test Tool".to_string(),
@@ -3182,7 +3125,9 @@ mod tests {
resource: None,
client_id_secret_name: None,
// Expired — handler will reject after lookup (no network I/O)
created_at,
created_at: std::time::Instant::now()
.checked_sub(std::time::Duration::from_secs(600))
.expect("System uptime is too low to run expired flow test"),
};
ext_mgr
@@ -3253,27 +3198,24 @@ mod tests {
fn test_ext_mgr(
secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync>,
) -> (Arc<ExtensionManager>, tempfile::TempDir, tempfile::TempDir) {
) -> Arc<ExtensionManager> {
let tool_registry = Arc::new(ToolRegistry::new());
let mcp_sm = Arc::new(crate::tools::mcp::session::McpSessionManager::new());
let mcp_pm = Arc::new(crate::tools::mcp::process::McpProcessManager::new());
let wasm_tools_dir = tempfile::tempdir().expect("temp wasm tools dir");
let wasm_channels_dir = tempfile::tempdir().expect("temp wasm channels dir");
let ext_mgr = Arc::new(ExtensionManager::new(
Arc::new(ExtensionManager::new(
mcp_sm,
mcp_pm,
secrets,
tool_registry,
None,
None,
wasm_tools_dir.path().to_path_buf(),
wasm_channels_dir.path().to_path_buf(),
std::path::PathBuf::from("/tmp/wasm_tools"),
std::path::PathBuf::from("/tmp/wasm_channels"),
None,
"test".to_string(),
None,
vec![],
));
(ext_mgr, wasm_tools_dir, wasm_channels_dir)
))
}
#[tokio::test]
@@ -3282,7 +3224,7 @@ mod tests {
use tower::ServiceExt;
let secrets = test_secrets_store();
let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(secrets);
let ext_mgr = test_ext_mgr(secrets);
let state = test_gateway_state(Some(ext_mgr));
let app = test_relay_oauth_router(state);
@@ -3326,7 +3268,7 @@ mod tests {
.await
.expect("store nonce");
let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(secrets);
let ext_mgr = test_ext_mgr(secrets);
let state = test_gateway_state(Some(ext_mgr));
let app = test_relay_oauth_router(state);
@@ -3371,7 +3313,7 @@ mod tests {
.await
.expect("store nonce");
let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(secrets.clone());
let ext_mgr = test_ext_mgr(secrets.clone());
let state = test_gateway_state(Some(ext_mgr));
let app = test_relay_oauth_router(state);
+36 -137
View File
@@ -342,27 +342,31 @@ function connectSSE() {
eventSource.addEventListener('approval_needed', (e) => {
const data = JSON.parse(e.data);
const hasThread = !!data.thread_id;
const forCurrentThread = !hasThread || isCurrentThread(data.thread_id);
if (forCurrentThread) {
showApproval(data);
} else {
// Keep thread list fresh when approval is requested in a background thread.
unreadThreads.set(data.thread_id, (unreadThreads.get(data.thread_id) || 0) + 1);
debouncedLoadThreads();
}
// Extension setup flows can surface approvals while user is on Extensions tab.
if (currentTab === 'extensions') loadExtensions();
if (!isCurrentThread(data.thread_id)) return;
showApproval(data);
});
eventSource.addEventListener('auth_required', (e) => {
handleAuthRequired(JSON.parse(e.data));
const data = JSON.parse(e.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) => {
handleAuthCompleted(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);
closeConfigureModal();
showToast(data.message, data.success ? 'success' : 'error');
// Refresh extensions list so status indicators update
if (currentTab === 'extensions') loadExtensions();
enableChatInput();
});
eventSource.addEventListener('extension_status', (e) => {
@@ -986,26 +990,7 @@ function finalizeActivityGroup() {
_activeToolCards = {};
}
function humanizeToolName(rawName) {
if (!rawName) return '';
return String(rawName)
.replace(/[_-]+/g, ' ')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/^tool([a-zA-Z])/, 'tool $1')
.replace(/\s+/g, ' ')
.trim();
}
function shouldShowChannelConnectedMessage(extensionName, success) {
if (!success || !extensionName) return false;
return String(extensionName).toLowerCase().includes('telegram');
}
function showApproval(data) {
// Avoid duplicate cards on reconnect/history refresh.
const existing = document.querySelector('.approval-card[data-request-id="' + CSS.escape(data.request_id) + '"]');
if (existing) return;
const container = document.getElementById('chat-messages');
const card = document.createElement('div');
card.className = 'approval-card';
@@ -1018,7 +1003,7 @@ function showApproval(data) {
const toolName = document.createElement('div');
toolName.className = 'approval-tool-name';
toolName.textContent = humanizeToolName(data.tool_name);
toolName.textContent = data.tool_name;
card.appendChild(toolName);
if (data.description) {
@@ -1121,71 +1106,13 @@ function showJobCard(data) {
// --- Auth card ---
function handleAuthRequired(data) {
if (data.auth_url) {
// OAuth flow: show the global auth prompt 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);
}
}
function handleAuthCompleted(data) {
// Dismiss only the matching extension's UI so unrelated setup work is not interrupted.
removeAuthCard(data.extension_name);
closeConfigureModal(data.extension_name);
showToast(data.message, data.success ? 'success' : 'error');
if (shouldShowChannelConnectedMessage(data.extension_name, data.success)) {
addMessage('system', 'Telegram is now connected. You can message me there and I can send you notifications.');
}
if (currentTab === 'extensions') loadExtensions();
enableChatInput();
}
function queryByDataAttribute(selector, attributeName, attributeValue) {
if (typeof attributeValue !== 'string') return document.querySelector(selector);
if (window.CSS && typeof window.CSS.escape === 'function') {
return document.querySelector(
selector + '[' + attributeName + '="' + window.CSS.escape(attributeValue) + '"]'
);
}
const candidates = document.querySelectorAll(selector);
for (const candidate of candidates) {
if (candidate.getAttribute(attributeName) === attributeValue) return candidate;
}
return null;
}
function getAuthOverlay(extensionName) {
return queryByDataAttribute('.auth-overlay', 'data-extension-name', extensionName);
}
function getAuthCard(extensionName) {
return queryByDataAttribute('.auth-card', 'data-extension-name', extensionName);
}
function getConfigureOverlay(extensionName) {
return queryByDataAttribute('.configure-overlay', 'data-extension-name', extensionName);
}
function showAuthCard(data) {
// Keep a single global auth prompt so the experience is consistent across tabs.
const existing = getAuthOverlay();
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.className = 'auth-overlay';
overlay.setAttribute('data-extension-name', data.extension_name);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) cancelAuth(data.extension_name);
});
// Remove any existing card for this extension first
removeAuthCard(data.extension_name);
const container = document.getElementById('chat-messages');
const card = document.createElement('div');
card.className = 'auth-card auth-modal';
card.className = 'auth-card';
card.setAttribute('data-extension-name', data.extension_name);
const header = document.createElement('div');
@@ -1264,30 +1191,21 @@ function showAuthCard(data) {
actions.appendChild(cancelBtn);
card.appendChild(actions);
overlay.appendChild(card);
document.body.appendChild(overlay);
container.appendChild(card);
container.scrollTop = container.scrollHeight;
tokenInput.focus();
}
function removeAuthCard(extensionName) {
const overlay = getAuthOverlay(extensionName);
if (overlay) {
overlay.remove();
return;
}
const card = getAuthCard(extensionName);
if (card) {
const parentOverlay = card.closest('.auth-overlay');
if (parentOverlay) parentOverlay.remove();
else card.remove();
}
const card = document.querySelector('.auth-card[data-extension-name="' + extensionName + '"]');
if (card) card.remove();
}
function submitAuthToken(extensionName, tokenValue) {
if (!tokenValue || !tokenValue.trim()) return;
// Disable submit button while in flight
const card = getAuthCard(extensionName);
const card = document.querySelector('.auth-card[data-extension-name="' + extensionName + '"]');
if (card) {
const btns = card.querySelectorAll('button');
btns.forEach((b) => { b.disabled = true; });
@@ -1298,10 +1216,8 @@ function submitAuthToken(extensionName, tokenValue) {
body: { extension_name: extensionName, token: tokenValue.trim() },
}).then((result) => {
if (result.success) {
// Close immediately for responsiveness; the authoritative success UX
// (toast + extensions refresh) still comes from auth_completed SSE.
removeAuthCard(extensionName);
enableChatInput();
addMessage('system', result.message);
} else {
showAuthCardError(extensionName, result.message);
}
@@ -1320,7 +1236,7 @@ function cancelAuth(extensionName) {
}
function showAuthCardError(extensionName, message) {
const card = getAuthCard(extensionName);
const card = document.querySelector('.auth-card[data-extension-name="' + extensionName + '"]');
if (!card) return;
// Re-enable buttons
const btns = card.querySelectorAll('button');
@@ -2250,10 +2166,6 @@ function renderAvailableExtensionCard(entry) {
showToast(I18n.t('extensions.installedSuccess', {name: entry.display_name}), 'success');
// OAuth popup if auth started during install (builtin creds)
if (res.auth_url) {
showAuthCard({
extension_name: entry.name,
auth_url: res.auth_url,
});
showToast('Opening authentication for ' + entry.display_name, 'info');
openOAuthUrl(res.auth_url);
}
@@ -2519,10 +2431,6 @@ function activateExtension(name) {
if (res.success) {
// Even on success, the tool may need OAuth (e.g., WASM loaded but no token yet)
if (res.auth_url) {
showAuthCard({
extension_name: name,
auth_url: res.auth_url,
});
showToast('Opening authentication for ' + name, 'info');
openOAuthUrl(res.auth_url);
}
@@ -2531,10 +2439,6 @@ function activateExtension(name) {
}
if (res.auth_url) {
showAuthCard({
extension_name: name,
auth_url: res.auth_url,
});
showToast('Opening authentication for ' + name, 'info');
openOAuthUrl(res.auth_url);
} else if (res.awaiting_token) {
@@ -2577,7 +2481,6 @@ function renderConfigureModal(name, secrets) {
closeConfigureModal();
const overlay = document.createElement('div');
overlay.className = 'configure-overlay';
overlay.setAttribute('data-extension-name', name);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closeConfigureModal();
});
@@ -2671,8 +2574,7 @@ function submitConfigureModal(name, fields) {
}
// Disable buttons to prevent double-submit
const overlay = getConfigureOverlay(name) || document.querySelector('.configure-overlay');
var btns = overlay ? overlay.querySelectorAll('.configure-actions button') : [];
var btns = document.querySelectorAll('.configure-actions button');
btns.forEach(function(b) { b.disabled = true; });
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/setup', {
@@ -2683,10 +2585,8 @@ function submitConfigureModal(name, fields) {
if (res.success) {
closeConfigureModal();
if (res.auth_url) {
showAuthCard({
extension_name: name,
auth_url: res.auth_url,
});
// 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');
openOAuthUrl(res.auth_url);
loadExtensions();
@@ -2705,9 +2605,8 @@ function submitConfigureModal(name, fields) {
});
}
function closeConfigureModal(extensionName) {
if (typeof extensionName !== 'string') extensionName = null;
const existing = getConfigureOverlay(extensionName);
function closeConfigureModal() {
const existing = document.querySelector('.configure-overlay');
if (existing) existing.remove();
}
+1 -25
View File
@@ -1219,21 +1219,7 @@ body {
color: var(--danger);
}
/* Auth prompt */
.auth-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
z-index: 1001;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
/* Auth card (inline in chat) */
.auth-card {
align-self: flex-start;
max-width: 80%;
@@ -1248,16 +1234,6 @@ body {
transition: border-color 0.2s;
}
.auth-overlay .auth-card {
width: 460px;
max-width: min(460px, 90vw);
margin: 0;
align-self: auto;
background: var(--bg);
border-color: rgba(52, 211, 153, 0.35);
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.35);
}
.auth-card .auth-header {
font-weight: 600;
color: var(--accent);
+26 -14
View File
@@ -16,7 +16,6 @@ mod workspace;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use async_trait::async_trait;
use chrono::{DateTime, NaiveDateTime, Utc};
@@ -33,8 +32,6 @@ use crate::workspace::MemoryDocument;
use crate::db::libsql_migrations;
static NAIVE_TIMESTAMP_LOGGED: AtomicBool = AtomicBool::new(false);
/// Explicit column list for routines table (matches positional access in `row_to_routine_libsql`).
pub(crate) const ROUTINE_COLUMNS: &str = "\
id, name, description, user_id, enabled, \
@@ -166,27 +163,24 @@ impl LibSqlBackend {
///
/// Returns an error if none of the formats match.
pub(crate) fn parse_timestamp(s: &str) -> Result<DateTime<Utc>, String> {
let log_naive_timestamp_once = || {
if !NAIVE_TIMESTAMP_LOGGED.swap(true, Ordering::Relaxed) {
tracing::debug!(
timestamp = %s,
"parsed naive timestamp without timezone; assuming UTC for backward compatibility"
);
}
};
// RFC 3339 (our canonical write format)
if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
return Ok(dt.with_timezone(&Utc));
}
// Naive with fractional seconds (legacy or SQLite datetime() output)
if let Ok(ndt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f") {
log_naive_timestamp_once();
tracing::debug!(
timestamp = %s,
"parsed naive timestamp without timezone; assuming UTC for backward compatibility"
);
return Ok(ndt.and_utc());
}
// Naive without fractional seconds (legacy format)
if let Ok(ndt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
log_naive_timestamp_once();
tracing::debug!(
timestamp = %s,
"parsed naive timestamp without timezone; assuming UTC for backward compatibility"
);
return Ok(ndt.and_utc());
}
Err(format!("unparseable timestamp: {:?}", s))
@@ -332,6 +326,24 @@ impl Database for LibSqlBackend {
libsql_migrations::run_incremental(&conn).await?;
Ok(())
}
async fn shutdown(&self) -> Result<(), DatabaseError> {
match self.db.flush_replicator().await {
Ok(Some(frame_no)) => {
tracing::debug!("libSQL replicator flushed at frame {}", frame_no);
Ok(())
}
Ok(None) => {
tracing::debug!("No libSQL replicator to flush, skipping shutdown sync");
Ok(())
}
Err(libsql::Error::SyncNotSupported(_)) => {
tracing::debug!("libSQL sync not supported, skipping flush on shutdown");
Ok(())
}
Err(error) => Err(DatabaseError::from(error)),
}
}
}
// ==================== Row conversion helpers ====================
+7
View File
@@ -523,6 +523,13 @@ pub trait Database:
{
/// Run schema migrations for this backend.
async fn run_migrations(&self) -> Result<(), DatabaseError>;
/// Shutdown hook for backend-specific drain/flush behavior.
///
/// Default implementation is a no-op so existing backends remain compatible.
async fn shutdown(&self) -> Result<(), DatabaseError> {
Ok(())
}
}
#[cfg(test)]
+5
View File
@@ -61,6 +61,11 @@ impl Database for PgBackend {
async fn run_migrations(&self) -> Result<(), DatabaseError> {
self.store.run_migrations().await
}
async fn shutdown(&self) -> Result<(), DatabaseError> {
self.store.pool().close();
Ok(())
}
}
// ==================== ConversationStore ====================
+6 -189
View File
@@ -786,19 +786,6 @@ impl ExtensionManager {
Self::validate_extension_name(name)?;
let kind = self.determine_installed_kind(name).await?;
// Clean up any in-progress OAuth flows for this extension.
// TCP mode: abort the listener task so port 9876 is freed immediately.
// Gateway mode: remove stale pending flow entries.
if let Some(pending) = self.pending_auth.write().await.remove(name)
&& let Some(handle) = pending.task_handle
{
handle.abort();
}
self.pending_oauth_flows
.write()
.await
.retain(|_, flow| flow.extension_name != name);
match kind {
ExtensionKind::McpServer => {
// Unregister tools with this server's prefix
@@ -832,14 +819,6 @@ impl ExtensionManager {
// Unregister from tool registry
self.tool_registry.unregister(name).await;
// Evict compiled module from runtime cache so reinstall uses fresh binary
if let Some(ref rt) = self.wasm_tool_runtime {
rt.remove(name).await;
}
// Clear stale activation errors so reinstall starts clean
self.activation_errors.write().await.remove(name);
// Revoke credential mappings from the shared registry
let cap_path = self
.wasm_tools_dir
@@ -880,9 +859,6 @@ impl ExtensionManager {
self.active_channel_names.write().await.remove(name);
self.persist_active_channels().await;
// Clear stale activation errors so reinstall starts clean
self.activation_errors.write().await.remove(name);
// Delete channel files
let wasm_path = self.wasm_channels_dir.join(format!("{}.wasm", name));
let cap_path = self
@@ -2884,17 +2860,6 @@ impl ExtensionManager {
});
}
// Check auth status — block activation if required secrets are missing.
// NeedsAuth (OAuth not yet completed) is allowed because configure() loads
// the tool first, then starts the OAuth flow to obtain the token.
let auth_state = self.check_tool_auth_status(name).await;
if auth_state == ToolAuthState::NeedsSetup {
return Err(ExtensionError::ActivationFailed(format!(
"Tool '{}' requires configuration. Use the setup form to provide credentials.",
name
)));
}
let runtime = self.wasm_tool_runtime.as_ref().ok_or_else(|| {
ExtensionError::ActivationFailed("WASM runtime not available".to_string())
})?;
@@ -4530,18 +4495,14 @@ mod tests {
// available" because the ExtensionManager had `wasm_tool_runtime: None`.
/// Build a minimal ExtensionManager suitable for unit tests.
fn make_test_manager_with_dirs(
fn make_test_manager(
wasm_runtime: Option<Arc<crate::tools::wasm::WasmToolRuntime>>,
tools_dir: std::path::PathBuf,
channels_dir: std::path::PathBuf,
) -> crate::extensions::manager::ExtensionManager {
use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
use crate::tools::mcp::process::McpProcessManager;
use crate::tools::mcp::session::McpSessionManager;
std::fs::create_dir_all(&tools_dir).ok();
std::fs::create_dir_all(&channels_dir).ok();
let key = secrecy::SecretString::from(crate::secrets::keychain::generate_master_key_hex());
let crypto = Arc::new(SecretsCrypto::new(key).expect("crypto"));
let secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync> =
@@ -4556,22 +4517,15 @@ mod tests {
tools,
None, // hooks
wasm_runtime,
tools_dir,
channels_dir,
None, // tunnel_url
tools_dir.clone(),
tools_dir, // channels dir (unused here)
None, // tunnel_url
"test".to_string(),
None, // db
vec![],
)
}
fn make_test_manager(
wasm_runtime: Option<Arc<crate::tools::wasm::WasmToolRuntime>>,
tools_dir: std::path::PathBuf,
) -> crate::extensions::manager::ExtensionManager {
make_test_manager_with_dirs(wasm_runtime, tools_dir.clone(), tools_dir)
}
#[tokio::test]
async fn test_activate_wasm_tool_with_runtime_passes_runtime_check() {
// When the ExtensionManager has a WASM runtime, activation should get
@@ -4924,145 +4878,6 @@ mod tests {
);
}
#[tokio::test]
async fn test_remove_wasm_tool_clears_pending_oauth_state_and_activation_error() {
let dir = tempfile::tempdir().expect("temp dir");
let mgr = make_test_manager(None, dir.path().to_path_buf());
std::fs::write(dir.path().join("gmail.wasm"), b"fake-tool").expect("write tool");
let listener = tokio::spawn(async {
std::future::pending::<()>().await;
});
let abort_handle = listener.abort_handle();
mgr.pending_auth.write().await.insert(
"gmail".to_string(),
super::PendingAuth {
_name: "gmail".to_string(),
_kind: ExtensionKind::WasmTool,
created_at: std::time::Instant::now(),
task_handle: Some(listener),
},
);
mgr.activation_errors
.write()
.await
.insert("gmail".to_string(), "cached failure".to_string());
let secrets = Arc::clone(&mgr.secrets);
mgr.pending_oauth_flows().write().await.insert(
"gmail-state".to_string(),
crate::cli::oauth_defaults::PendingOAuthFlow {
extension_name: "gmail".to_string(),
display_name: "Gmail".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: "google_oauth_token".to_string(),
provider: None,
validation_endpoint: None,
scopes: vec![],
user_id: "test".to_string(),
secrets: Arc::clone(&secrets),
sse_sender: None,
gateway_token: None,
resource: None,
client_id_secret_name: None,
created_at: std::time::Instant::now(),
},
);
mgr.pending_oauth_flows().write().await.insert(
"other-state".to_string(),
crate::cli::oauth_defaults::PendingOAuthFlow {
extension_name: "web-search".to_string(),
display_name: "Web Search".to_string(),
token_url: "https://example.com/token".to_string(),
client_id: "client456".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: "other_token".to_string(),
provider: None,
validation_endpoint: None,
scopes: vec![],
user_id: "test".to_string(),
secrets,
sse_sender: None,
gateway_token: None,
resource: None,
client_id_secret_name: None,
created_at: std::time::Instant::now(),
},
);
let result = mgr.remove("gmail").await;
assert!(result.is_ok(), "remove should succeed: {:?}", result.err());
tokio::task::yield_now().await;
assert!(
mgr.pending_auth.read().await.get("gmail").is_none(),
"pending auth entry should be removed"
);
assert!(
abort_handle.is_finished(),
"pending auth listener should be aborted"
);
assert!(
!mgr.activation_errors.read().await.contains_key("gmail"),
"stale activation error should be cleared"
);
let flows = mgr.pending_oauth_flows().read().await;
assert!(
!flows.contains_key("gmail-state"),
"gateway OAuth flow for removed extension should be cleared"
);
assert!(
flows.contains_key("other-state"),
"unrelated pending OAuth flows should be retained"
);
}
#[tokio::test]
async fn test_remove_wasm_channel_clears_activation_error_and_deletes_files() {
let dir = tempfile::tempdir().expect("temp dir");
let tools_dir = dir.path().join("tools");
let channels_dir = dir.path().join("channels");
let mgr = make_test_manager_with_dirs(None, tools_dir, channels_dir.clone());
let wasm_path = channels_dir.join("telegram.wasm");
let cap_path = channels_dir.join("telegram.capabilities.json");
std::fs::write(&wasm_path, b"fake-channel").expect("write channel");
std::fs::write(&cap_path, b"{}").expect("write capabilities");
mgr.activation_errors
.write()
.await
.insert("telegram".to_string(), "channel failed".to_string());
let result = mgr.remove("telegram").await;
assert!(result.is_ok(), "remove should succeed: {:?}", result.err());
assert!(
!mgr.activation_errors.read().await.contains_key("telegram"),
"channel activation error should be cleared on remove"
);
assert!(
!wasm_path.exists(),
"channel wasm file should be deleted on remove"
);
assert!(
!cap_path.exists(),
"channel capabilities file should be deleted on remove"
);
}
#[test]
fn test_sanitize_url_with_query_params() {
let url = "https://api.example.com/path?api_key=secret123&token=abc";
@@ -5338,6 +5153,7 @@ mod tests {
Some("https://my-gateway.example.com/oauth/callback".to_string()),
);
}
// ── Regression tests for PR #677 (unify-extension-lifecycle) ─────────
#[tokio::test]
@@ -5487,6 +5303,7 @@ mod tests {
"configure should have stored the relay stream token"
);
}
#[test]
fn test_validation_failed_is_distinct_error_variant() {
// Regression: ValidationFailed must be a distinct error variant so
+8
View File
@@ -672,6 +672,8 @@ async fn async_main() -> anyhow::Result<()> {
.as_ref()
.map(|db| Arc::clone(db) as Arc<dyn ironclaw::db::SettingsStore>);
let db_for_shutdown = components.db.clone();
let deps = AgentDeps {
store: components.db,
llm: components.llm,
@@ -930,6 +932,12 @@ async fn async_main() -> anyhow::Result<()> {
}
}
if let Some(db) = db_for_shutdown {
if let Err(e) = db.shutdown().await {
tracing::warn!("Failed to shutdown database cleanly: {}", e);
}
}
tracing::debug!("Agent shutdown complete");
Ok(())
+10 -30
View File
@@ -65,20 +65,7 @@ fn install_macos() -> Result<()> {
let stdout = logs_dir.join("daemon.stdout.log");
let stderr = logs_dir.join("daemon.stderr.log");
let plist = macos_plist_content(
&exe.display().to_string(),
&stdout.display().to_string(),
&stderr.display().to_string(),
);
std::fs::write(&file, plist)?;
println!("Installed launchd service: {}", file.display());
println!(" Start with: ironclaw service start");
Ok(())
}
fn macos_plist_content(exe: &str, stdout: &str, stderr: &str) -> String {
format!(
let plist = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
@@ -94,11 +81,6 @@ fn macos_plist_content(exe: &str, stdout: &str, stderr: &str) -> String {
<true/>
<key>KeepAlive</key>
<true/>
<key>EnvironmentVariables</key>
<dict>
<key>CLI_ENABLED</key>
<string>false</string>
</dict>
<key>StandardOutPath</key>
<string>{stdout}</string>
<key>StandardErrorPath</key>
@@ -107,10 +89,15 @@ fn macos_plist_content(exe: &str, stdout: &str, stderr: &str) -> String {
</plist>
"#,
label = SERVICE_LABEL,
exe = xml_escape(exe),
stdout = xml_escape(stdout),
stderr = xml_escape(stderr),
)
exe = xml_escape(&exe.display().to_string()),
stdout = xml_escape(&stdout.display().to_string()),
stderr = xml_escape(&stderr.display().to_string()),
);
std::fs::write(&file, plist)?;
println!("Installed launchd service: {}", file.display());
println!(" Start with: ironclaw service start");
Ok(())
}
fn install_linux() -> Result<()> {
@@ -369,11 +356,4 @@ mod tests {
let s = path.to_string_lossy();
assert!(s.ends_with(".ironclaw/logs"), "unexpected path: {s}");
}
#[test]
fn macos_plist_sets_cli_enabled_false() {
let plist = macos_plist_content("/tmp/ironclaw", "/tmp/stdout.log", "/tmp/stderr.log");
assert!(plist.contains("<key>EnvironmentVariables</key>"));
assert!(plist.contains(" <key>CLI_ENABLED</key>\n <string>false</string>"));
}
}
+4
View File
@@ -397,6 +397,10 @@ impl Tool for ListDirTool {
false // Directory listings are safe
}
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
ApprovalRequirement::UnlessAutoApproved
}
fn domain(&self) -> ToolDomain {
ToolDomain::Container
}
+32 -34
View File
@@ -398,7 +398,7 @@ impl Tool for HttpTool {
"method": {
"type": "string",
"enum": ["GET", "POST", "PUT", "DELETE", "PATCH"],
"description": "HTTP method (default: GET)"
"description": "HTTP method"
},
"url": {
"type": "string",
@@ -429,7 +429,7 @@ impl Tool for HttpTool {
"description": "Save response body as raw bytes to this file path instead of returning it. Use for binary downloads (images, PDFs, etc.). The path must be under /tmp/."
}
},
"required": ["url"]
"required": ["method", "url"]
})
}
@@ -440,7 +440,7 @@ impl Tool for HttpTool {
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let method = params["method"].as_str().unwrap_or("GET");
let method = require_str(&params, "method")?;
let method_upper = method.to_uppercase();
let url = require_str(&params, "url")?;
@@ -829,22 +829,18 @@ impl Tool for HttpTool {
}
fn requires_approval(&self, params: &serde_json::Value) -> ApprovalRequirement {
let has_credentials = crate::safety::params_contain_manual_credentials(params)
|| (self.credential_registry.as_ref().is_some_and(|registry| {
extract_host_from_params(params)
.is_some_and(|host| registry.has_credentials_for_host(&host))
}));
if has_credentials {
// 1. Manual auth headers/query params in LLM params
if crate::safety::params_contain_manual_credentials(params) {
return ApprovalRequirement::Always;
}
// GET requests (or missing method, since GET is the default) are low-risk
let method = params["method"].as_str().unwrap_or("GET");
if method.eq_ignore_ascii_case("GET") {
return ApprovalRequirement::Never;
// 2. Target host has credential mappings (will be auto-injected)
if let Some(ref registry) = self.credential_registry
&& let Some(host) = extract_host_from_params(params)
&& registry.has_credentials_for_host(&host)
{
return ApprovalRequirement::Always;
}
// Default: outbound HTTP still needs approval unless auto-approved
ApprovalRequirement::UnlessAutoApproved
}
@@ -1067,22 +1063,12 @@ mod tests {
// ── Approval requirement tests ──────────────────────────────────────
#[test]
fn test_get_no_auth_headers_returns_never() {
fn test_no_auth_headers_returns_unless_auto_approved() {
let tool = HttpTool::new();
let params = serde_json::json!({
"method": "GET",
"url": "https://api.example.com/data"
});
assert_eq!(tool.requires_approval(&params), ApprovalRequirement::Never);
}
#[test]
fn test_post_no_auth_headers_returns_unless_auto_approved() {
let tool = HttpTool::new();
let params = serde_json::json!({
"method": "POST",
"url": "https://api.example.com/data"
});
assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::UnlessAutoApproved
@@ -1166,18 +1152,21 @@ mod tests {
}
#[test]
fn test_get_non_auth_headers_return_never() {
fn test_non_auth_headers_return_unless_auto_approved() {
let tool = HttpTool::new();
let params = serde_json::json!({
"method": "GET",
"url": "https://example.com",
"headers": {"Content-Type": "application/json", "Accept": "text/html"}
});
assert_eq!(tool.requires_approval(&params), ApprovalRequirement::Never);
assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::UnlessAutoApproved
);
}
#[test]
fn test_get_empty_headers_return_never() {
fn test_empty_headers_return_unless_auto_approved() {
let tool = HttpTool::new();
// Empty object
@@ -1186,7 +1175,10 @@ mod tests {
"url": "https://example.com",
"headers": {}
});
assert_eq!(tool.requires_approval(&params), ApprovalRequirement::Never);
assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::UnlessAutoApproved
);
// Empty array
let params = serde_json::json!({
@@ -1194,7 +1186,10 @@ mod tests {
"url": "https://example.com",
"headers": []
});
assert_eq!(tool.requires_approval(&params), ApprovalRequirement::Never);
assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::UnlessAutoApproved
);
}
// ── Credential registry approval tests ─────────────────────────────
@@ -1224,7 +1219,7 @@ mod tests {
}
#[test]
fn test_get_host_without_credential_mapping_returns_never() {
fn test_host_without_credential_mapping_returns_unless_auto_approved() {
use crate::tools::wasm::SharedCredentialRegistry;
let registry = Arc::new(SharedCredentialRegistry::new());
@@ -1236,7 +1231,10 @@ mod tests {
"method": "GET",
"url": "https://api.example.com/data"
});
assert_eq!(tool.requires_approval(&params), ApprovalRequirement::Never);
assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::UnlessAutoApproved
);
}
#[test]
+7 -4
View File
@@ -8,7 +8,7 @@ use secrecy::{ExposeSecret, SecretString};
use crate::context::JobContext;
use crate::tools::builtin::path_utils::validate_path;
use crate::tools::tool::{Tool, ToolError, ToolOutput};
use crate::tools::tool::{ApprovalRequirement, Tool, ToolError, ToolOutput};
/// Tool for analyzing images using a vision-capable model.
pub struct ImageAnalyzeTool {
@@ -86,6 +86,10 @@ impl Tool for ImageAnalyzeTool {
})
}
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
ApprovalRequirement::UnlessAutoApproved
}
fn requires_sanitization(&self) -> bool {
true
}
@@ -181,7 +185,6 @@ impl Tool for ImageAnalyzeTool {
mod tests {
use super::super::media_type_from_path;
use super::*;
use crate::tools::tool::ApprovalRequirement;
use tempfile::TempDir;
#[test]
@@ -196,7 +199,7 @@ mod tests {
}
#[test]
fn test_requires_approval_returns_never() {
fn test_requires_approval_returns_unless_auto_approved() {
let tool = ImageAnalyzeTool::new(
"https://api.example.com".to_string(),
"test-key".to_string(),
@@ -205,7 +208,7 @@ mod tests {
);
assert_eq!(
tool.requires_approval(&serde_json::json!({})),
ApprovalRequirement::Never
ApprovalRequirement::UnlessAutoApproved
);
}
+6 -3
View File
@@ -7,7 +7,7 @@ use secrecy::{ExposeSecret, SecretString};
use crate::context::JobContext;
use crate::tools::builtin::path_utils::validate_path;
use crate::tools::tool::{Tool, ToolError, ToolOutput};
use crate::tools::tool::{ApprovalRequirement, Tool, ToolError, ToolOutput};
/// Tool for editing images using an AI image editing API.
pub struct ImageEditTool {
@@ -85,6 +85,10 @@ impl Tool for ImageEditTool {
})
}
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
ApprovalRequirement::UnlessAutoApproved
}
fn requires_sanitization(&self) -> bool {
false
}
@@ -262,7 +266,6 @@ impl ImageEditTool {
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::tool::ApprovalRequirement;
use tempfile::TempDir;
#[test]
@@ -277,7 +280,7 @@ mod tests {
assert!(!tool.requires_sanitization());
assert_eq!(
tool.requires_approval(&serde_json::json!({})),
ApprovalRequirement::Never
ApprovalRequirement::UnlessAutoApproved
);
}
+6 -2
View File
@@ -5,6 +5,7 @@ use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use crate::context::JobContext;
use crate::tools::tool::ApprovalRequirement;
use crate::tools::{Tool, ToolError, ToolOutput};
/// Tool for generating images using FLUX or compatible image generation APIs.
@@ -86,6 +87,10 @@ impl Tool for ImageGenerateTool {
})
}
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
ApprovalRequirement::UnlessAutoApproved
}
fn requires_sanitization(&self) -> bool {
false
}
@@ -181,7 +186,6 @@ impl Tool for ImageGenerateTool {
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::tool::ApprovalRequirement;
#[test]
fn test_tool_metadata() {
@@ -193,7 +197,7 @@ mod tests {
assert_eq!(tool.name(), "image_generate");
assert_eq!(
tool.requires_approval(&serde_json::json!({})),
ApprovalRequirement::Never
ApprovalRequirement::UnlessAutoApproved
);
let schema = tool.parameters_schema();
+64 -64
View File
@@ -540,8 +540,8 @@ impl Tool for MemoryTreeTool {
}
#[cfg(test)]
mod tests {
use super::*;
mod path_routing_tests {
use super::looks_like_filesystem_path;
#[test]
fn detects_filesystem_paths() {
@@ -557,82 +557,82 @@ mod tests {
assert!(!looks_like_filesystem_path("daily/2026-03-11.md"));
assert!(!looks_like_filesystem_path("projects/alpha/notes.md"));
}
}
#[cfg(feature = "postgres")]
mod postgres_schema_tests {
use super::*;
#[cfg(all(test, feature = "postgres"))]
mod tests {
use super::*;
fn make_test_workspace() -> Arc<Workspace> {
Arc::new(Workspace::new(
"test_user",
deadpool_postgres::Pool::builder(deadpool_postgres::Manager::new(
tokio_postgres::Config::new(),
tokio_postgres::NoTls,
))
.build()
.unwrap(),
fn make_test_workspace() -> Arc<Workspace> {
Arc::new(Workspace::new(
"test_user",
deadpool_postgres::Pool::builder(deadpool_postgres::Manager::new(
tokio_postgres::Config::new(),
tokio_postgres::NoTls,
))
}
.build()
.unwrap(),
))
}
#[test]
fn test_memory_search_schema() {
let workspace = make_test_workspace();
let tool = MemorySearchTool::new(workspace);
#[test]
fn test_memory_search_schema() {
let workspace = make_test_workspace();
let tool = MemorySearchTool::new(workspace);
assert_eq!(tool.name(), "memory_search");
assert!(!tool.requires_sanitization());
assert_eq!(tool.name(), "memory_search");
assert!(!tool.requires_sanitization());
let schema = tool.parameters_schema();
assert!(schema["properties"]["query"].is_object());
assert!(
schema["required"]
.as_array()
.unwrap()
.contains(&"query".into())
);
}
let schema = tool.parameters_schema();
assert!(schema["properties"]["query"].is_object());
assert!(
schema["required"]
.as_array()
.unwrap()
.contains(&"query".into())
);
}
#[test]
fn test_memory_write_schema() {
let workspace = make_test_workspace();
let tool = MemoryWriteTool::new(workspace);
#[test]
fn test_memory_write_schema() {
let workspace = make_test_workspace();
let tool = MemoryWriteTool::new(workspace);
assert_eq!(tool.name(), "memory_write");
assert_eq!(tool.name(), "memory_write");
let schema = tool.parameters_schema();
assert!(schema["properties"]["content"].is_object());
assert!(schema["properties"]["target"].is_object());
assert!(schema["properties"]["append"].is_object());
}
let schema = tool.parameters_schema();
assert!(schema["properties"]["content"].is_object());
assert!(schema["properties"]["target"].is_object());
assert!(schema["properties"]["append"].is_object());
}
#[test]
fn test_memory_read_schema() {
let workspace = make_test_workspace();
let tool = MemoryReadTool::new(workspace);
#[test]
fn test_memory_read_schema() {
let workspace = make_test_workspace();
let tool = MemoryReadTool::new(workspace);
assert_eq!(tool.name(), "memory_read");
assert_eq!(tool.name(), "memory_read");
let schema = tool.parameters_schema();
assert!(schema["properties"]["path"].is_object());
assert!(
schema["required"]
.as_array()
.unwrap()
.contains(&"path".into())
);
}
let schema = tool.parameters_schema();
assert!(schema["properties"]["path"].is_object());
assert!(
schema["required"]
.as_array()
.unwrap()
.contains(&"path".into())
);
}
#[test]
fn test_memory_tree_schema() {
let workspace = make_test_workspace();
let tool = MemoryTreeTool::new(workspace);
#[test]
fn test_memory_tree_schema() {
let workspace = make_test_workspace();
let tool = MemoryTreeTool::new(workspace);
assert_eq!(tool.name(), "memory_tree");
assert_eq!(tool.name(), "memory_tree");
let schema = tool.parameters_schema();
assert!(schema["properties"]["path"].is_object());
assert!(schema["properties"]["depth"].is_object());
assert_eq!(schema["properties"]["depth"]["default"], 1);
}
let schema = tool.parameters_schema();
assert!(schema["properties"]["path"].is_object());
assert!(schema["properties"]["depth"].is_object());
assert_eq!(schema["properties"]["depth"]["default"], 1);
}
}
-2
View File
@@ -15,7 +15,6 @@ pub mod secrets_tools;
pub(crate) mod shell;
pub mod skill_tools;
mod time;
mod tool_info;
pub use echo::EchoTool;
pub use extension_tools::{
@@ -40,7 +39,6 @@ pub use secrets_tools::{SecretDeleteTool, SecretListTool};
pub use shell::ShellTool;
pub use skill_tools::{SkillInstallTool, SkillListTool, SkillRemoveTool, SkillSearchTool};
pub use time::TimeTool;
pub use tool_info::ToolInfoTool;
mod html_converter;
pub mod image_analyze;
pub mod image_edit;
-183
View File
@@ -1,183 +0,0 @@
//! On-demand tool discovery (like CLI `--help`).
//!
//! Two levels of detail:
//! - Default: name, description, parameter names (compact ~150 bytes)
//! - `include_schema: true`: adds the full typed JSON Schema
//!
//! Keeps the tools array compact (WASM tools use permissive schemas)
//! while allowing precise discovery when needed.
use std::sync::Weak;
use async_trait::async_trait;
use crate::context::JobContext;
use crate::tools::registry::ToolRegistry;
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str};
pub struct ToolInfoTool {
registry: Weak<ToolRegistry>,
}
impl ToolInfoTool {
pub fn new(registry: Weak<ToolRegistry>) -> Self {
Self { registry }
}
}
#[async_trait]
impl Tool for ToolInfoTool {
fn name(&self) -> &str {
"tool_info"
}
fn description(&self) -> &str {
"Get info about any tool: description and parameter names. \
Set include_schema to true for the full typed parameter schema."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Name of the tool to get info about"
},
"include_schema": {
"type": "boolean",
"description": "If true, include the full typed JSON Schema for parameters (larger response). Default: false.",
"default": false
}
},
"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 include_schema = params
.get("include_schema")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let registry = self.registry.upgrade().ok_or_else(|| {
ToolError::ExecutionFailed(
"tool registry is no longer available for tool_info".to_string(),
)
})?;
let tool = registry.get(name).await.ok_or_else(|| {
ToolError::InvalidParameters(format!("No tool named '{name}' is registered"))
})?;
let schema = tool.discovery_schema();
// Extract just param names from the schema's "properties" keys
let param_names: Vec<&str> = schema
.get("properties")
.and_then(|p| p.as_object())
.map(|props| props.keys().map(|k| k.as_str()).collect())
.unwrap_or_default();
let mut info = serde_json::json!({
"name": tool.name(),
"description": tool.description(),
"parameters": param_names,
});
if include_schema {
info["schema"] = schema;
}
Ok(ToolOutput::success(info, start.elapsed()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::builtin::EchoTool;
use std::sync::Arc;
#[tokio::test]
async fn test_tool_info_default_returns_param_names() {
let registry = Arc::new(ToolRegistry::new());
registry.register(Arc::new(EchoTool)).await;
let tool = ToolInfoTool::new(Arc::downgrade(&registry));
let ctx = JobContext::default();
let result = tool
.execute(serde_json::json!({"name": "echo"}), &ctx)
.await
.unwrap();
let info = &result.result;
assert_eq!(info["name"], "echo");
assert!(!info["description"].as_str().unwrap().is_empty());
// Default: parameters is an array of names, not the full schema
assert!(info["parameters"].is_array());
assert!(
info["parameters"]
.as_array()
.unwrap()
.iter()
.any(|v| v.as_str() == Some("message")),
"echo tool should have 'message' parameter: {:?}",
info["parameters"]
);
// No schema field by default
assert!(info.get("schema").is_none());
}
#[tokio::test]
async fn test_tool_info_with_schema() {
let registry = Arc::new(ToolRegistry::new());
registry.register(Arc::new(EchoTool)).await;
let tool = ToolInfoTool::new(Arc::downgrade(&registry));
let ctx = JobContext::default();
let result = tool
.execute(
serde_json::json!({"name": "echo", "include_schema": true}),
&ctx,
)
.await
.unwrap();
let info = &result.result;
assert_eq!(info["name"], "echo");
// With include_schema: true, schema field should be present
assert!(info["schema"].is_object());
assert!(info["schema"]["properties"].is_object());
}
#[tokio::test]
async fn test_tool_info_unknown_tool() {
let registry = Arc::new(ToolRegistry::new());
let tool = ToolInfoTool::new(Arc::downgrade(&registry));
let ctx = JobContext::default();
let result = tool
.execute(serde_json::json!({"name": "nonexistent"}), &ctx)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_tool_info_registry_dropped() {
let registry = Arc::new(ToolRegistry::new());
let tool = ToolInfoTool::new(Arc::downgrade(&registry));
drop(registry);
let ctx = JobContext::default();
let result = tool
.execute(serde_json::json!({"name": "echo"}), &ctx)
.await;
assert!(matches!(result, Err(ToolError::ExecutionFailed(_))));
}
}
-12
View File
@@ -75,7 +75,6 @@ const PROTECTED_TOOL_NAMES: &[&str] = &[
"image_generate",
"image_edit",
"image_analyze",
"tool_info",
];
/// Registry of available tools.
@@ -246,17 +245,6 @@ impl ToolRegistry {
tracing::debug!("Registered {} built-in tools", self.count());
}
/// Register the `tool_info` discovery tool.
///
/// Requires `Arc<Self>` so the tool can query the registry for other tools'
/// schemas at runtime. Call after `register_builtin_tools()`.
pub fn register_tool_info(self: &Arc<Self>) {
use crate::tools::builtin::ToolInfoTool;
let tool = ToolInfoTool::new(Arc::downgrade(self));
self.register_sync(Arc::new(tool));
tracing::debug!("Registered tool_info discovery tool");
}
/// Register only orchestrator-domain tools (safe for the main process).
///
/// This registers tools that don't touch the filesystem or run shell commands:
-11
View File
@@ -336,17 +336,6 @@ pub trait Tool: Send + Sync {
None
}
/// Full parameter schema for discovery and coercion purposes.
///
/// Unlike `parameters_schema()` (which may be permissive to keep the tools
/// array compact), this returns the complete typed schema. Used by the
/// `tool_info` built-in and by WASM parameter coercion.
///
/// Default: delegates to `parameters_schema()`.
fn discovery_schema(&self) -> serde_json::Value {
self.parameters_schema()
}
/// Get the tool schema for LLM function calling.
fn schema(&self) -> ToolSchema {
ToolSchema {
+84 -6
View File
@@ -1,5 +1,7 @@
//! WASM sandbox error types.
use std::fmt;
use thiserror::Error;
/// Errors that can occur during WASM tool execution.
@@ -66,13 +68,13 @@ pub enum WasmError {
Timeout(std::time::Duration),
/// Component returned an error response.
/// When `hint` is non-empty it points the LLM to `tool_info` so it can
/// fetch the tool's full parameter schema on demand.
/// When `hint` is non-empty it carries the tool's description and parameter
/// schema so the LLM can retry with correct arguments.
#[error("Tool error: {message}{}", if hint.is_empty() { String::new() } else { format!("\n\nTool usage hint:\n{hint}") })]
ToolReturnedError {
/// The error message from the WASM tool.
message: String,
/// Optional retry hint (empty when unavailable).
/// Optional description + schema hint (empty when unavailable).
hint: String,
},
@@ -97,9 +99,73 @@ impl From<WasmError> for crate::tools::ToolError {
}
}
/// Details about a trap that occurred during execution.
#[derive(Debug, Clone)]
pub struct TrapInfo {
/// Human-readable trap message.
pub message: String,
/// Trap code if available.
pub code: Option<TrapCode>,
}
impl fmt::Display for TrapInfo {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.code {
Some(code) => write!(f, "{}: {}", code, self.message),
None => write!(f, "{}", self.message),
}
}
}
/// Known trap codes from Wasmtime.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrapCode {
/// Out of bounds memory access.
MemoryOutOfBounds,
/// Out of bounds table access.
TableOutOfBounds,
/// Indirect call type mismatch.
IndirectCallToNull,
/// Signature mismatch on indirect call.
BadSignature,
/// Integer overflow.
IntegerOverflow,
/// Integer division by zero.
IntegerDivisionByZero,
/// Invalid conversion to integer.
BadConversionToInteger,
/// Unreachable instruction executed.
UnreachableCodeReached,
/// Call stack exhausted.
StackOverflow,
/// Out of fuel.
OutOfFuel,
/// Unknown trap code.
Unknown,
}
impl fmt::Display for TrapCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
TrapCode::MemoryOutOfBounds => "memory out of bounds",
TrapCode::TableOutOfBounds => "table out of bounds",
TrapCode::IndirectCallToNull => "indirect call to null",
TrapCode::BadSignature => "bad signature",
TrapCode::IntegerOverflow => "integer overflow",
TrapCode::IntegerDivisionByZero => "integer division by zero",
TrapCode::BadConversionToInteger => "bad conversion to integer",
TrapCode::UnreachableCodeReached => "unreachable code reached",
TrapCode::StackOverflow => "stack overflow",
TrapCode::OutOfFuel => "out of fuel",
TrapCode::Unknown => "unknown trap",
};
write!(f, "{}", s)
}
}
#[cfg(test)]
mod tests {
use crate::tools::wasm::error::WasmError;
use crate::tools::wasm::error::{TrapCode, TrapInfo, WasmError};
#[test]
fn test_error_display() {
@@ -114,6 +180,17 @@ mod tests {
assert!(err.to_string().contains("10000000"));
}
#[test]
fn test_trap_info_display() {
let info = TrapInfo {
message: "access at offset 0x1000".to_string(),
code: Some(TrapCode::MemoryOutOfBounds),
};
let s = info.to_string();
assert!(s.contains("memory out of bounds"));
assert!(s.contains("access at offset"));
}
#[test]
fn test_conversion_to_tool_error() {
let wasm_err = WasmError::Trapped("test trap".to_string());
@@ -141,11 +218,12 @@ mod tests {
fn test_tool_returned_error_with_hint() {
let err = WasmError::ToolReturnedError {
message: "unknown action: foobar".to_string(),
hint: "Tip: call tool_info(name: \"gmail\", include_schema: true) for the full parameter schema.".to_string(),
hint: "Description: Gmail tool\nParameters schema: {\"type\":\"object\"}".to_string(),
};
let display = err.to_string();
assert!(display.contains("unknown action: foobar"));
assert!(display.contains("Tool usage hint"));
assert!(display.contains("tool_info"));
assert!(display.contains("Gmail tool"));
assert!(display.contains("Parameters schema"));
}
}
+8
View File
@@ -67,8 +67,14 @@ pub struct WasmResourceLimiter {
memory_used: u64,
/// Maximum tables allowed.
max_tables: u32,
/// Current table count.
#[allow(dead_code)] // Reserved for table limit enforcement
tables_created: u32,
/// Maximum instances allowed.
max_instances: u32,
/// Current instance count.
#[allow(dead_code)] // Reserved for instance limit enforcement
instances_created: u32,
}
impl WasmResourceLimiter {
@@ -81,7 +87,9 @@ impl WasmResourceLimiter {
memory_limit,
memory_used: 0,
max_tables: 10,
tables_created: 0,
max_instances: 10, // Component model needs multiple instances for WASI
instances_created: 0,
}
}
+1 -1
View File
@@ -96,7 +96,7 @@ pub(crate) mod storage;
mod wrapper;
// Core types
pub use error::WasmError;
pub use error::{TrapCode, TrapInfo, WasmError};
pub use host::{HostState, LogEntry, LogLevel};
pub use limits::{
DEFAULT_FUEL_LIMIT, DEFAULT_MEMORY_LIMIT, DEFAULT_TIMEOUT, FuelConfig, ResourceLimits,
+41 -26
View File
@@ -123,9 +123,7 @@ pub struct PreparedModule {
pub name: String,
/// Tool description (cached from component).
pub description: String,
/// Full parameter schema JSON extracted from the component.
/// Used for discovery and coercion, not necessarily for the compact
/// schema advertised in the main tools array.
/// Parameter schema JSON (cached from component).
pub schema: serde_json::Value,
/// Pre-compiled component (cheaply cloneable via internal Arc).
component: wasmtime::component::Component,
@@ -267,29 +265,11 @@ impl WasmToolRuntime {
let component = wasmtime::component::Component::new(&engine, &wasm_bytes)
.map_err(|e| WasmError::CompilationFailed(e.to_string()))?;
// Briefly instantiate to extract metadata (description + schema)
// from the tool's exports, analogous to MCP's list_tools().
let effective_limits = limits.clone().unwrap_or(default_limits.clone());
let (description, schema) = crate::tools::wasm::wrapper::extract_wasm_metadata(
&engine,
&component,
&effective_limits,
)
.unwrap_or_else(|e| {
tracing::warn!(
name = %name,
error = %e,
"WASM metadata extraction failed, using fallbacks"
);
(
"WASM sandboxed tool".to_string(),
serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": true
}),
)
});
// We need to instantiate briefly to extract metadata.
// In a full implementation, we'd use WIT bindgen to get typed access.
// For now, we extract what we can from the component.
let description = extract_tool_description(&engine, &component)?;
let schema = extract_tool_schema(&engine, &component)?;
Ok::<_, WasmError>(PreparedModule {
name: name.clone(),
@@ -341,6 +321,41 @@ impl WasmToolRuntime {
}
}
/// Extract tool description from a compiled component.
///
/// Returns a generic fallback. Callers should prefer loading the description
/// from the sidecar `*.capabilities.json` file and overriding via
/// `WasmToolWrapper::with_description()` or the `WasmToolRegistration::description` field.
fn extract_tool_description(
_engine: &Engine,
_component: &wasmtime::component::Component,
) -> Result<String, WasmError> {
// WIT bindgen extraction is not yet implemented (see TODO #4 in CLAUDE.md).
// Real descriptions come from the capabilities.json sidecar file, which is
// loaded by the WasmToolLoader and passed as an override at registration time.
Ok("WASM sandboxed tool".to_string())
}
/// Extract tool parameter schema from a compiled component.
///
/// Returns a permissive fallback that accepts any JSON object. Callers should
/// prefer loading the schema from the sidecar `*.capabilities.json` file and
/// overriding via `WasmToolWrapper::with_schema()` or the
/// `WasmToolRegistration::schema` field.
fn extract_tool_schema(
_engine: &Engine,
_component: &wasmtime::component::Component,
) -> Result<serde_json::Value, WasmError> {
// WIT bindgen extraction is not yet implemented (see TODO #4 in CLAUDE.md).
// Real schemas come from the capabilities.json sidecar file, which is
// loaded by the WasmToolLoader and passed as an override at registration time.
Ok(serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": true
}))
}
impl std::fmt::Debug for WasmToolRuntime {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WasmToolRuntime")
+53 -271
View File
@@ -464,10 +464,9 @@ pub struct WasmToolWrapper {
/// Capabilities to grant to this tool.
capabilities: Capabilities,
/// Cached description (from PreparedModule or override).
/// Stored without any tool_info hints — hints are composed at display time.
description: String,
/// Compact and discovery schemas for this tool.
schemas: WasmToolSchemas,
/// Cached schema (from PreparedModule or override).
schema: serde_json::Value,
/// Injected credentials for HTTP requests (e.g., OAuth tokens).
/// Keys are placeholder names like "GOOGLE_ACCESS_TOKEN".
credentials: HashMap<String, String>,
@@ -478,84 +477,6 @@ pub struct WasmToolWrapper {
oauth_refresh: Option<OAuthRefreshConfig>,
}
#[derive(Debug, Clone)]
struct WasmToolSchemas {
/// Compact schema advertised in the main tools array.
///
/// This stays permissive by default to avoid serializing full exported
/// WASM schemas on every LLM call. Sidecars can override it explicitly.
advertised: serde_json::Value,
/// Full schema available for discovery and coercion.
///
/// Seeded from the WASM `schema()` export at registration time, unless a
/// sidecar explicitly overrides it.
discovery: serde_json::Value,
}
impl WasmToolSchemas {
fn permissive_schema() -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": true
})
}
fn is_permissive_schema(schema: &serde_json::Value) -> bool {
schema
.get("properties")
.and_then(|p| p.as_object())
.is_none_or(|p| p.is_empty())
}
fn new(discovery: serde_json::Value) -> Self {
Self {
advertised: Self::permissive_schema(),
discovery,
}
}
fn with_override(&self, schema: serde_json::Value) -> Self {
Self {
advertised: schema.clone(),
discovery: schema,
}
}
fn is_advertised_permissive(&self) -> bool {
Self::is_permissive_schema(&self.advertised)
}
fn advertised(&self) -> serde_json::Value {
self.advertised.clone()
}
fn discovery(&self) -> serde_json::Value {
self.discovery.clone()
}
/// Return the best schema available for type coercion.
///
/// Prefers the discovery schema when it has typed properties. Falls back
/// to the `PreparedModule` schema extracted at load time rather than
/// re-calling the WASM `schema()` export mid-execution, which could
/// interact with mutable linear memory state.
fn effective_for_coercion(&self, prepared_schema: &serde_json::Value) -> serde_json::Value {
if !Self::is_permissive_schema(&self.discovery) {
return self.discovery.clone();
}
// Fall back to the load-time extracted schema from PreparedModule.
// This avoids calling schema() on the already-running WASM instance
// where mutable state could produce inconsistent results.
if !Self::is_permissive_schema(prepared_schema) {
return prepared_schema.clone();
}
self.discovery.clone()
}
}
impl WasmToolWrapper {
/// Create a new WASM tool wrapper.
pub fn new(
@@ -565,7 +486,7 @@ impl WasmToolWrapper {
) -> Self {
Self {
description: prepared.description.clone(),
schemas: WasmToolSchemas::new(prepared.schema.clone()),
schema: prepared.schema.clone(),
runtime,
prepared,
capabilities,
@@ -583,7 +504,7 @@ impl WasmToolWrapper {
/// Override the parameter schema.
pub fn with_schema(mut self, schema: serde_json::Value) -> Self {
self.schemas = self.schemas.with_override(schema);
self.schema = schema;
self
}
@@ -694,18 +615,9 @@ impl WasmToolWrapper {
}
})?;
// Get typed interface — used for execute.
let tool_iface = instance.near_agent_tool();
// Determine effective schema for type coercion.
// Prefer the discovery schema when typed; fall back to the load-time
// extracted schema from PreparedModule rather than re-calling the WASM
// export on the already-running instance.
let effective_schema = self.schemas.effective_for_coercion(&self.prepared.schema);
// 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, &effective_schema);
let params = coerce_params_to_schema(params, &self.schema);
// Prepare the request
let params_json = serde_json::to_string(&params)
@@ -717,6 +629,7 @@ impl WasmToolWrapper {
};
// Call execute using the generated typed interface
let tool_iface = instance.near_agent_tool();
let response = tool_iface.call_execute(&mut store, &request).map_err(|e| {
let error_str = e.to_string();
if error_str.contains("out of fuel") {
@@ -731,13 +644,12 @@ impl WasmToolWrapper {
// Get logs from host state
let logs = store.data_mut().host_state.take_logs();
// Check for tool-level error — point the LLM to tool_info for the
// full schema instead of dumping ~3.5KB inline.
// Check for tool-level error — on failure, call the WASM module's
// description() and schema() exports so the LLM can retry with the
// correct parameters without us having to include the (large) schema
// in every request's tools array.
if let Some(err) = response.error {
let hint = format!(
"Tip: call tool_info(name: \"{}\", include_schema: true) for the full parameter schema.",
self.prepared.name
);
let hint = build_tool_hint(tool_iface, &mut store);
return Err(WasmError::ToolReturnedError { message: err, hint });
}
@@ -746,55 +658,47 @@ impl WasmToolWrapper {
}
}
/// Extract metadata (description + schema) from a WASM tool by briefly
/// instantiating it and calling its `description()` and `schema()` exports.
/// Analogous to MCP's `list_tools()` — discovers tool capabilities at load time.
///
/// Falls back to generic description and permissive schema on failure.
pub(super) fn extract_wasm_metadata(
engine: &wasmtime::Engine,
component: &wasmtime::component::Component,
limits: &ResourceLimits,
) -> Result<(String, serde_json::Value), WasmError> {
let store_data = StoreData::new(
limits.memory_bytes,
Capabilities::default(),
HashMap::new(),
vec![],
);
let mut store = Store::new(engine, store_data);
/// Maximum characters for the description portion of a tool hint.
const HINT_DESC_MAX: usize = 500;
/// Maximum characters for the schema portion of a tool hint.
const HINT_SCHEMA_MAX: usize = 3000;
// Configure fuel + epoch deadline so extraction can't hang
if let Err(e) = store.set_fuel(limits.fuel) {
tracing::debug!("Fuel not enabled for metadata extraction: {e}");
}
store.epoch_deadline_trap();
let ticks = (limits.timeout.as_millis() / EPOCH_TICK_INTERVAL.as_millis()).max(1) as u64;
store.set_epoch_deadline(ticks);
store.limiter(|data| &mut data.limiter);
// Instantiate with minimal linker
let mut linker = Linker::new(engine);
WasmToolWrapper::add_host_functions(&mut linker)?;
let instance = SandboxedTool::instantiate(&mut store, component, &linker)
.map_err(|e| WasmError::InstantiationFailed(e.to_string()))?;
let tool_iface = instance.near_agent_tool();
// Extract description (fall back to generic)
let description = tool_iface
.call_description(&mut store)
.unwrap_or_else(|_| "WASM sandboxed tool".to_string());
// Extract and parse schema (fall back to permissive)
let schema = tool_iface
.call_schema(&mut store)
/// Call the WASM module's `description()` and `schema()` exports to build a
/// hint string. Returns an empty string if both calls fail or return empty.
/// Description is capped at [`HINT_DESC_MAX`] chars, schema at
/// [`HINT_SCHEMA_MAX`] chars.
fn build_tool_hint(tool_iface: &wit_tool::Guest, store: &mut Store<StoreData>) -> String {
let desc = tool_iface
.call_description(&mut *store)
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.unwrap_or_else(|| {
serde_json::json!({"type": "object", "properties": {}, "additionalProperties": true})
});
Ok((description, schema))
.unwrap_or_default();
let schema = tool_iface.call_schema(&mut *store).ok().unwrap_or_default();
if desc.is_empty() && schema.is_empty() {
return String::new();
}
let mut hint = String::new();
if !desc.is_empty() {
hint.push_str("Description: ");
if desc.len() > HINT_DESC_MAX {
let end = crate::util::floor_char_boundary(&desc, HINT_DESC_MAX);
hint.push_str(&desc[..end]);
hint.push('…');
} else {
hint.push_str(&desc);
}
hint.push('\n');
}
if !schema.is_empty() {
hint.push_str("Parameters schema: ");
if schema.len() > HINT_SCHEMA_MAX {
let end = crate::util::floor_char_boundary(&schema, HINT_SCHEMA_MAX);
hint.push_str(&schema[..end]);
hint.push('…');
} else {
hint.push_str(&schema);
}
}
hint
}
#[async_trait]
@@ -808,33 +712,7 @@ impl Tool for WasmToolWrapper {
}
fn parameters_schema(&self) -> serde_json::Value {
self.schemas.advertised()
}
fn discovery_schema(&self) -> serde_json::Value {
self.schemas.discovery()
}
/// Compose the tool schema for LLM function calling.
///
/// When the advertised schema is permissive (no typed properties), appends
/// a hint to the description directing the LLM to call `tool_info` for the
/// full parameter schema. This keeps the raw description clean while still
/// guiding the LLM.
fn schema(&self) -> crate::tools::tool::ToolSchema {
let description = if self.schemas.is_advertised_permissive() {
format!(
"{} (call tool_info(name: \"{}\", include_schema: true) for parameter schema)",
self.description, self.prepared.name
)
} else {
self.description.clone()
};
crate::tools::tool::ToolSchema {
name: self.prepared.name.clone(),
description,
parameters: self.schemas.advertised(),
}
self.schema.clone()
}
async fn execute(
@@ -871,7 +749,7 @@ impl Tool for WasmToolWrapper {
let prepared = Arc::clone(&self.prepared);
let capabilities = self.capabilities.clone();
let description = self.description.clone();
let schemas = self.schemas.clone();
let schema = self.schema.clone();
let credentials = self.credentials.clone();
// Execute in blocking task with timeout
@@ -881,7 +759,7 @@ impl Tool for WasmToolWrapper {
prepared,
capabilities,
description,
schemas,
schema,
credentials,
secrets_store: None, // Not needed in blocking task
oauth_refresh: None, // Already used above for pre-refresh
@@ -1354,7 +1232,6 @@ mod tests {
TEST_GOOGLE_OAUTH_TOKEN, TEST_OAUTH_CLIENT_ID, TEST_OAUTH_CLIENT_SECRET,
test_secrets_store,
};
use crate::tools::tool::Tool;
use crate::tools::wasm::capabilities::Capabilities;
use crate::tools::wasm::runtime::{WasmRuntimeConfig, WasmToolRuntime};
@@ -1369,84 +1246,6 @@ mod tests {
assert!(runtime.config().fuel_config.enabled);
}
#[tokio::test]
async fn test_advertised_schema_stays_permissive_until_sidecar_override() {
let discovery_schema = serde_json::json!({
"type": "object",
"properties": {
"query": { "type": "string" },
"limit": { "type": "integer" }
},
"required": ["query"]
});
let runtime = Arc::new(WasmToolRuntime::new(WasmRuntimeConfig::for_testing()).unwrap());
let prepared = runtime
.prepare("search", b"\0asm\x0d\0\x01\0", None)
.await
.unwrap();
let mut wrapper =
super::WasmToolWrapper::new(Arc::clone(&runtime), prepared, Capabilities::default());
wrapper.schemas = super::WasmToolSchemas::new(discovery_schema.clone());
wrapper.description = "Search documents".to_string();
// Advertised schema stays permissive; discovery holds the typed schema
assert_eq!(
wrapper.parameters_schema(),
serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": true
})
);
assert_eq!(wrapper.discovery_schema(), discovery_schema);
// Raw description is clean — no tool_info hint baked in
assert!(!wrapper.description().contains("tool_info"));
// But schema() composes the hint at display time when advertised is permissive
let schema = wrapper.schema();
assert!(
schema.description.contains("tool_info"),
"schema().description should contain tool_info hint: {}",
schema.description
);
assert!(
schema.description.contains("include_schema: true"),
"hint should mention include_schema: true: {}",
schema.description
);
// After sidecar override, both schemas match and hint disappears
let wrapper = wrapper.with_schema(serde_json::json!({
"type": "object",
"properties": {
"query": { "type": "string" }
},
"required": ["query"]
}));
assert_eq!(
wrapper.parameters_schema(),
serde_json::json!({
"type": "object",
"properties": {
"query": { "type": "string" }
},
"required": ["query"]
})
);
assert_eq!(wrapper.discovery_schema(), wrapper.parameters_schema());
// With typed schema, schema() should NOT include tool_info hint
let schema = wrapper.schema();
assert!(
!schema.description.contains("tool_info"),
"schema().description should not contain tool_info hint when typed: {}",
schema.description
);
}
#[test]
fn test_capabilities_default() {
let caps = Capabilities::default();
@@ -1989,23 +1788,6 @@ mod tests {
assert_eq!(result["count"], serde_json::json!("not-a-number"));
}
/// Regression: permissive fallback schema (empty properties) must NOT coerce.
/// This documents the bug where WASM tools with no sidecar `parameters` field
/// got the permissive fallback, causing coercion to be a no-op and LLM-provided
/// string integers to reach the WASM tool un-coerced.
#[test]
fn test_coerce_noop_with_permissive_schema() {
let permissive = serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": true
});
let params = serde_json::json!({"query": "test", "count": "10"});
let result = super::coerce_params_to_schema(params, &permissive);
// With empty properties, no coercion happens — string stays string
assert_eq!(result["count"], serde_json::json!("10"));
}
/// Regression test: leak scan must run on raw headers (before credential
/// injection), not after. If it ran post-injection, the host-injected
/// Slack bot token (`xoxb-...`) would trigger a Block and reject the
+1 -77
View File
@@ -20,31 +20,9 @@ from helpers import AUTH_TOKEN, wait_for_port_line, wait_for_ready
# Project root (two levels up from tests/e2e/)
ROOT = Path(__file__).resolve().parent.parent.parent
# Git main repo root (for worktree support — WASM build artifacts live
# in the main repo's tools-src/*/target/ and aren't shared across worktrees)
_MAIN_ROOT = None
try:
import subprocess as _sp
_common = _sp.check_output(
["git", "worktree", "list", "--porcelain"],
cwd=ROOT, text=True, stderr=_sp.DEVNULL,
)
for line in _common.splitlines():
if line.startswith("worktree "):
_MAIN_ROOT = Path(line.split(" ", 1)[1])
break # first entry is always the main worktree
except Exception:
pass
# Temp directory for the libSQL database file (cleaned up automatically)
_DB_TMPDIR = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-")
# Temp directories for WASM extensions. These start empty and are populated by
# the install pipeline during tests; fixtures do not pre-populate dev build
# artifacts into them.
_WASM_TOOLS_TMPDIR = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-wasm-tools-")
_WASM_CHANNELS_TMPDIR = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-wasm-channels-")
def _find_free_port() -> int:
"""Bind to port 0 and return the OS-assigned port."""
@@ -92,53 +70,7 @@ async def mock_llm_server():
@pytest.fixture(scope="session")
def wasm_tools_dir(_wasm_build_symlinks):
"""Empty temp dir for WASM tools.
Starts empty so the server has no pre-loaded extensions at boot.
The install API (POST /api/extensions/install) downloads and writes
WASM files here; tests exercise the full install pipeline.
NOTE on capabilities file naming: Cargo builds with underscored stems
(web_search_tool.wasm) but capabilities use hyphens (web-search-tool.
capabilities.json). The loader expects matching stems. If you pre-load
files, rename caps: web-search-tool web_search_tool.
"""
return str(Path(_WASM_TOOLS_TMPDIR.name))
@pytest.fixture(scope="session", autouse=True)
def _wasm_build_symlinks():
"""Symlink WASM build artifacts from the main repo into the worktree.
In a git worktree, tools-src/*/target/ directories don't exist because
Cargo build artifacts aren't shared. The install API's source fallback
checks these paths. Symlinking makes the fallback work without rebuilding.
"""
if _MAIN_ROOT is None or _MAIN_ROOT == ROOT:
yield
return
created = []
tools_src = ROOT / "tools-src"
main_tools_src = _MAIN_ROOT / "tools-src"
if tools_src.is_dir() and main_tools_src.is_dir():
for tool_dir in tools_src.iterdir():
if not tool_dir.is_dir():
continue
target = tool_dir / "target"
main_target = main_tools_src / tool_dir.name / "target"
if not target.exists() and main_target.is_dir():
target.symlink_to(main_target)
created.append(target)
yield
for link in created:
if link.is_symlink():
link.unlink()
@pytest.fixture(scope="session")
async def ironclaw_server(ironclaw_binary, mock_llm_server, wasm_tools_dir):
async def ironclaw_server(ironclaw_binary, mock_llm_server):
"""Start the ironclaw gateway. Yields the base URL."""
gateway_port = _find_free_port()
env = {
@@ -163,16 +95,8 @@ async def ironclaw_server(ironclaw_binary, mock_llm_server, wasm_tools_dir):
"ROUTINES_ENABLED": "false",
"HEARTBEAT_ENABLED": "false",
"EMBEDDING_ENABLED": "false",
# WASM tool/channel support
"WASM_ENABLED": "true",
"WASM_TOOLS_DIR": wasm_tools_dir,
"WASM_CHANNELS_DIR": _WASM_CHANNELS_TMPDIR.name,
# Prevent onboarding wizard from triggering
"ONBOARD_COMPLETED": "true",
# Force gateway OAuth callback mode (non-loopback URL) and point
# token exchange at mock_llm.py so OAuth tests work without Google.
"IRONCLAW_OAUTH_CALLBACK_URL": "https://oauth.test.example/oauth/callback",
"IRONCLAW_OAUTH_EXCHANGE_URL": mock_llm_server,
}
# Forward LLVM coverage instrumentation env vars when present
# (allows cargo-llvm-cov to collect profraw data from E2E runs).
-29
View File
@@ -133,32 +133,3 @@ async def wait_for_port_line(process, pattern: str, *, timeout: float = 60) -> i
if match := re.search(pattern, decoded):
return int(match.group(1))
raise TimeoutError(f"Port pattern '{pattern}' not found in stdout after {timeout}s")
# -- API helpers -----------------------------------------------------------
def auth_headers() -> dict[str, str]:
"""Return Authorization header dict for authenticated API calls."""
return {"Authorization": f"Bearer {AUTH_TOKEN}"}
async def api_get(base_url: str, path: str, **kwargs) -> httpx.Response:
"""Make an authenticated GET request to the ironclaw API."""
async with httpx.AsyncClient() as client:
return await client.get(
f"{base_url}{path}",
headers=auth_headers(),
timeout=kwargs.pop("timeout", 10),
**kwargs,
)
async def api_post(base_url: str, path: str, **kwargs) -> httpx.Response:
"""Make an authenticated POST request to the ironclaw API."""
async with httpx.AsyncClient() as client:
return await client.post(
f"{base_url}{path}",
headers=auth_headers(),
timeout=kwargs.pop("timeout", 10),
**kwargs,
)
+59 -184
View File
@@ -1,16 +1,11 @@
"""Mock OpenAI-compatible LLM server for E2E tests.
Serves OpenAI-compatible endpoints for chat completions and model listing.
Supports both streaming and non-streaming responses, plus function calling
via TOOL_CALL_PATTERNS.
"""
"""Mock OpenAI-compatible LLM server for E2E tests."""
import argparse
import asyncio
import json
import re
import time
import uuid
from aiohttp import web
CANNED_RESPONSES = [
@@ -18,207 +13,85 @@ CANNED_RESPONSES = [
(re.compile(r"2\s*\+\s*2|two plus two", re.IGNORECASE), "The answer is 4."),
(re.compile(r"skill|install", re.IGNORECASE), "I can help you with skills management."),
(re.compile(r"html.?test|injection.?test", re.IGNORECASE),
'Here is some content: <script>alert("xss")</script> and <img src=x onerror="alert(1)">'
' and <iframe src="javascript:alert(2)"></iframe> end of content.'),
'Here is some content: <script>alert("xss")</script> and <img src=x onerror="alert(1)"> and <iframe src="javascript:alert(2)"></iframe> end of content.'),
]
DEFAULT_RESPONSE = "I understand your request."
TOOL_CALL_PATTERNS = [
(re.compile(r"echo (.+)", re.IGNORECASE), "echo", lambda m: {"message": m.group(1)}),
(re.compile(r"what time|current time", re.IGNORECASE), "time", lambda _: {"operation": "now"}),
]
def _last_user_content(messages: list[dict]) -> str:
def match_response(messages: list[dict]) -> str:
"""Find canned response for the last user message."""
for msg in reversed(messages):
if msg.get("role") == "user":
content = msg.get("content", "")
# Handle content that may be a list (multi-modal)
if isinstance(content, list):
content = " ".join(
p.get("text", "") for p in content if p.get("type") == "text"
part.get("text", "") for part in content if part.get("type") == "text"
)
return content
return ""
def match_response(messages: list[dict]) -> str:
content = _last_user_content(messages)
for pattern, response in CANNED_RESPONSES:
if pattern.search(content):
return response
for pattern, response in CANNED_RESPONSES:
if pattern.search(content):
return response
return DEFAULT_RESPONSE
return DEFAULT_RESPONSE
def match_tool_call(messages: list[dict], has_tools: bool) -> dict | None:
if not has_tools:
return None
content = _last_user_content(messages)
for pattern, tool_name, args_fn in TOOL_CALL_PATTERNS:
m = pattern.search(content)
if m:
return {"tool_name": tool_name, "arguments": args_fn(m)}
return None
def _extract_tool_name(msg: dict) -> str:
"""Extract tool name from a message, checking both 'name' field and XML content."""
name = msg.get("name")
if name:
return name
# ironclaw wraps tool output as <tool_output name="...">
content = msg.get("content", "")
m = re.search(r'<tool_output\s+name="([^"]+)"', content)
if m:
return m.group(1)
return "unknown"
def _find_tool_result(messages: list[dict]) -> dict | None:
"""Find a pending tool result that appears after the last user message.
Only returns a tool result if it's a fresh result the agent is waiting
for the LLM to summarize (i.e., it follows the most recent user message).
This prevents stale tool results from earlier conversation turns from
being re-processed.
"""
# Find the position of the last user message
last_user_idx = -1
for i in range(len(messages) - 1, -1, -1):
if messages[i].get("role") == "user":
last_user_idx = i
break
# Only look for tool results after the last user message
for i in range(len(messages) - 1, last_user_idx, -1):
if messages[i].get("role") == "tool":
return {"name": _extract_tool_name(messages[i]),
"content": messages[i].get("content", "")}
return None
def _make_base(completion_id: str) -> dict:
return {"id": completion_id, "object": "chat.completion.chunk",
"created": int(time.time()), "model": "mock-model"}
async def _send_sse(resp: web.StreamResponse, data: dict):
await resp.write(f"data: {json.dumps(data)}\n\n".encode())
async def chat_completions(request: web.Request) -> web.StreamResponse:
"""Handle POST /v1/chat/completions and /chat/completions."""
"""Handle POST /v1/chat/completions."""
body = await request.json()
messages = body.get("messages", [])
stream = body.get("stream", False)
has_tools = bool(body.get("tools"))
cid = f"mock-{uuid.uuid4().hex[:8]}"
response_text = match_response(messages)
completion_id = f"mock-{uuid.uuid4().hex[:8]}"
# Tool result in messages -> text summary
tr = _find_tool_result(messages)
if tr:
text = f"The {tr['name']} tool returned: {tr['content']}"
if not stream:
return _text_response(cid, text)
return await _stream_text(request, cid, text)
# Tool-call pattern match
tc = match_tool_call(messages, has_tools)
if tc:
if not stream:
return _tool_call_response(cid, tc)
return await _stream_tool_call(request, cid, tc)
# Default text response
text = match_response(messages)
if not stream:
return _text_response(cid, text)
return await _stream_text(request, cid, text)
return web.json_response({
"id": completion_id,
"object": "chat.completion",
"created": int(time.time()),
"model": "mock-model",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": response_text},
"finish_reason": "stop",
}],
"usage": {"prompt_tokens": 10, "completion_tokens": len(response_text.split()), "total_tokens": 15},
})
def _text_response(cid: str, text: str) -> web.Response:
return web.json_response({
"id": cid, "object": "chat.completion", "created": int(time.time()),
"model": "mock-model",
"choices": [{"index": 0, "message": {"role": "assistant", "content": text},
"finish_reason": "stop"}],
"usage": {"prompt_tokens": 10, "completion_tokens": len(text.split()), "total_tokens": 15},
})
def _tool_call_response(cid: str, tc: dict) -> web.Response:
return web.json_response({
"id": cid, "object": "chat.completion", "created": int(time.time()),
"model": "mock-model",
"choices": [{"index": 0, "message": {
"role": "assistant", "content": None,
"tool_calls": [{"id": f"call_{uuid.uuid4().hex[:8]}", "type": "function",
"function": {"name": tc["tool_name"],
"arguments": json.dumps(tc["arguments"])}}],
}, "finish_reason": "tool_calls"}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
})
async def _stream_text(request: web.Request, cid: str, text: str) -> web.StreamResponse:
resp = web.StreamResponse(status=200, headers={
"Content-Type": "text/event-stream", "Cache-Control": "no-cache"})
# Streaming response: split into word-boundary chunks
resp = web.StreamResponse(
status=200,
headers={"Content-Type": "text/event-stream", "Cache-Control": "no-cache"},
)
await resp.prepare(request)
base = _make_base(cid)
chunk = {**base, "choices": [{"index": 0, "delta": {"role": "assistant", "content": ""},
"finish_reason": None}]}
await _send_sse(resp, chunk)
for i, word in enumerate(text.split(" ")):
chunk["choices"][0]["delta"] = {"content": word if i == 0 else f" {word}"}
await _send_sse(resp, chunk)
# First chunk: role
chunk = {
"id": completion_id,
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": "mock-model",
"choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}, "finish_reason": None}],
}
await resp.write(f"data: {json.dumps(chunk)}\n\n".encode())
# Content chunks: split on spaces
words = response_text.split(" ")
for i, word in enumerate(words):
text = word if i == 0 else f" {word}"
chunk["choices"][0]["delta"] = {"content": text}
await resp.write(f"data: {json.dumps(chunk)}\n\n".encode())
# Final chunk: finish_reason
chunk["choices"][0]["delta"] = {}
chunk["choices"][0]["finish_reason"] = "stop"
await _send_sse(resp, chunk)
await resp.write(f"data: {json.dumps(chunk)}\n\n".encode())
await resp.write(b"data: [DONE]\n\n")
return resp
async def _stream_tool_call(request: web.Request, cid: str, tc: dict) -> web.StreamResponse:
resp = web.StreamResponse(status=200, headers={
"Content-Type": "text/event-stream", "Cache-Control": "no-cache"})
await resp.prepare(request)
call_id = f"call_{uuid.uuid4().hex[:8]}"
base = _make_base(cid)
# First chunk: role + tool call header with empty arguments
chunk = {**base, "choices": [{"index": 0, "delta": {
"role": "assistant",
"tool_calls": [{"index": 0, "id": call_id, "type": "function",
"function": {"name": tc["tool_name"], "arguments": ""}}],
}, "finish_reason": None}]}
await _send_sse(resp, chunk)
# Second chunk: arguments payload
chunk["choices"][0]["delta"] = {
"tool_calls": [{"index": 0, "function": {"arguments": json.dumps(tc["arguments"])}}]}
await _send_sse(resp, chunk)
# Final chunk: finish reason
chunk["choices"][0]["delta"] = {}
chunk["choices"][0]["finish_reason"] = "tool_calls"
await _send_sse(resp, chunk)
await resp.write(b"data: [DONE]\n\n")
return resp
async def oauth_exchange(request: web.Request) -> web.Response:
"""Mock OAuth token exchange proxy for E2E tests.
Accepts form params (code, redirect_uri, code_verifier) and returns
a fake token response. Called by ironclaw's exchange_via_proxy() when
IRONCLAW_OAUTH_EXCHANGE_URL is set.
"""
data = await request.post()
code = data.get("code", "")
return web.json_response({
"access_token": f"mock-token-{code}",
"refresh_token": "mock-refresh-token",
"expires_in": 3600,
})
async def models(_request: web.Request) -> web.Response:
"""Handle GET /v1/models."""
return web.json_response({
"object": "list",
"data": [{"id": "mock-model", "object": "model", "owned_by": "test"}],
@@ -229,21 +102,23 @@ def main():
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=0)
args = parser.parse_args()
app = web.Application()
# Register both /v1/ and non-/v1/ paths (rig-core omits the /v1/ prefix)
app.router.add_post("/v1/chat/completions", chat_completions)
app.router.add_post("/chat/completions", chat_completions)
app.router.add_get("/v1/models", models)
app.router.add_get("/models", models)
app.router.add_post("/oauth/exchange", oauth_exchange)
# Use aiohttp's runner to get the actual bound port
import asyncio
async def start():
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "127.0.0.1", args.port)
await site.start()
# Extract the actual port from the bound socket
port = site._server.sockets[0].getsockname()[1]
print(f"MOCK_LLM_PORT={port}", flush=True)
# Block forever
await asyncio.Event().wait()
asyncio.run(start())
-264
View File
@@ -1,264 +0,0 @@
"""Extension OAuth round-trip e2e tests.
Tests the full internal OAuth callback pipeline: install gmail configure
(get auth_url) simulate OAuth callback verify token stored. Uses gateway
callback mode + mock token exchange (no real Google login).
The conftest sets IRONCLAW_OAUTH_CALLBACK_URL (non-loopback, forces gateway
mode) and IRONCLAW_OAUTH_EXCHANGE_URL (points to mock_llm.py's /oauth/exchange).
"""
from urllib.parse import parse_qs, urlparse
import httpx
import pytest
from helpers import api_get, api_post
# Module-level state
_gmail_installed = False
_auth_url = None
_csrf_state = None
def _extract_state(auth_url: str) -> str:
"""Extract the CSRF state parameter from an OAuth authorization URL."""
parsed = urlparse(auth_url)
qs = parse_qs(parsed.query)
assert "state" in qs, f"auth_url should contain state param: {auth_url}"
state = qs["state"][0]
assert len(state) > 0
return state
async def _get_extension(base_url, name):
"""Get a specific extension from the extensions list, or None."""
r = await api_get(base_url, "/api/extensions")
for ext in r.json().get("extensions", []):
if ext["name"] == name:
return ext
return None
async def _ensure_removed(base_url, name):
"""Remove extension if already installed."""
ext = await _get_extension(base_url, name)
if ext:
await api_post(base_url, f"/api/extensions/{name}/remove", timeout=30)
# ── Section A: Install + OAuth Initiation ────────────────────────────────
async def test_oauth_install_gmail(ironclaw_server):
"""Install gmail from registry for OAuth testing."""
global _gmail_installed
await _ensure_removed(ironclaw_server, "gmail")
r = await api_post(
ironclaw_server,
"/api/extensions/install",
json={"name": "gmail"},
timeout=180,
)
assert r.status_code == 200
data = r.json()
assert data.get("success") is True, f"Install failed: {data.get('message', '')}"
_gmail_installed = True
async def test_oauth_configure_returns_auth_url(ironclaw_server):
"""Configure with empty secrets returns an OAuth auth_url."""
global _auth_url, _csrf_state
if not _gmail_installed:
pytest.skip("gmail not installed")
r = await api_post(
ironclaw_server,
"/api/extensions/gmail/setup",
json={"secrets": {}},
timeout=30,
)
assert r.status_code == 200
data = r.json()
assert data.get("success") is True, f"Configure failed: {data.get('message', '')}"
_auth_url = data.get("auth_url")
assert _auth_url is not None, f"Expected auth_url in response: {data}"
assert "accounts.google.com" in _auth_url, (
f"auth_url should point to Google: {_auth_url}"
)
_csrf_state = _extract_state(_auth_url)
async def test_oauth_activate_returns_auth_url(ironclaw_server):
"""Activate on un-authenticated gmail returns auth_url."""
if not _gmail_installed:
pytest.skip("gmail not installed")
r = await api_post(
ironclaw_server, "/api/extensions/gmail/activate", timeout=30
)
assert r.status_code == 200
data = r.json()
# Activation may fail with auth_url or succeed with auth_url
auth_url = data.get("auth_url")
assert auth_url is not None, f"Expected auth_url in activate response: {data}"
# ── Section B: Internal OAuth Round-Trip ─────────────────────────────────
async def test_oauth_callback_exchanges_token(ironclaw_server):
"""Simulate OAuth callback with mock code — verifies token exchange."""
global _csrf_state
if not _csrf_state:
pytest.skip("No CSRF state from configure step")
# Re-configure to get a fresh pending flow (previous configure may have
# been consumed by the activate test above)
r = await api_post(
ironclaw_server,
"/api/extensions/gmail/setup",
json={"secrets": {}},
timeout=30,
)
data = r.json()
auth_url = data.get("auth_url")
if auth_url:
_csrf_state = _extract_state(auth_url)
# Hit the OAuth callback endpoint directly (public route, no auth header).
# The callback handler looks up the pending flow by state, calls
# exchange_via_proxy() which hits mock_llm.py's /oauth/exchange, and
# stores the returned fake token.
async with httpx.AsyncClient() as client:
r = await client.get(
f"{ironclaw_server}/oauth/callback",
params={"code": "mock_auth_code", "state": _csrf_state},
timeout=30,
follow_redirects=True,
)
assert r.status_code == 200, f"Callback returned {r.status_code}: {r.text[:300]}"
body = r.text.lower()
# The landing page says "<name> Connected" on success, "failed" on error
assert "connected" in body or "success" in body, (
f"Callback HTML should indicate success: {r.text[:500]}"
)
async def test_oauth_callback_replay_rejected(ironclaw_server):
"""Replaying the same callback is rejected (flow consumed on first use)."""
if not _csrf_state:
pytest.skip("No CSRF state")
async with httpx.AsyncClient() as client:
r = await client.get(
f"{ironclaw_server}/oauth/callback",
params={"code": "mock_auth_code", "state": _csrf_state},
timeout=10,
follow_redirects=True,
)
# Should fail — the flow was already consumed
body = r.text.lower()
assert "error" in body or "fail" in body or "expired" in body or r.status_code >= 400, (
f"Replay should be rejected, got status={r.status_code}: {r.text[:500]}"
)
async def test_oauth_callback_invalid_state(ironclaw_server):
"""Callback with bogus state is rejected."""
async with httpx.AsyncClient() as client:
r = await client.get(
f"{ironclaw_server}/oauth/callback",
params={"code": "x", "state": "totally-bogus-state-value"},
timeout=10,
follow_redirects=True,
)
body = r.text.lower()
assert "error" in body or "fail" in body or "expired" in body or r.status_code >= 400, (
f"Invalid state should be rejected, got status={r.status_code}: {r.text[:500]}"
)
async def test_oauth_extension_authenticated(ironclaw_server):
"""After OAuth callback, gmail shows authenticated=True."""
if not _gmail_installed:
pytest.skip("gmail not installed")
ext = await _get_extension(ironclaw_server, "gmail")
assert ext is not None, "gmail not in extensions list"
assert ext["authenticated"] is True, (
f"gmail should be authenticated after OAuth callback: {ext}"
)
async def test_oauth_tools_registered(ironclaw_server):
"""After OAuth authentication, gmail tools appear in tools endpoint."""
if not _gmail_installed:
pytest.skip("gmail not installed")
ext = await _get_extension(ironclaw_server, "gmail")
assert ext is not None
# Check the extension's tools array
tools = ext.get("tools", [])
assert len(tools) > 0, (
f"gmail should have tools registered after auth: {ext}"
)
async def test_remove_during_pending_oauth_invalidates_callback(ironclaw_server):
"""Removing an extension while OAuth is pending invalidates the callback state."""
if not _gmail_installed:
pytest.skip("gmail not installed")
r = await api_post(
ironclaw_server,
"/api/extensions/gmail/setup",
json={"secrets": {}},
timeout=30,
)
assert r.status_code == 200
data = r.json()
auth_url = data.get("auth_url")
assert auth_url is not None, f"Expected auth_url in response: {data}"
callback_state = _extract_state(auth_url)
remove_r = await api_post(
ironclaw_server, "/api/extensions/gmail/remove", timeout=30
)
assert remove_r.status_code == 200
assert remove_r.json().get("success") is True, (
f"Removing gmail during pending OAuth should succeed: {remove_r.text[:300]}"
)
async with httpx.AsyncClient() as client:
callback_r = await client.get(
f"{ironclaw_server}/oauth/callback",
params={"code": "mock_auth_code", "state": callback_state},
timeout=30,
follow_redirects=True,
)
assert callback_r.status_code == 200
body = callback_r.text.lower()
assert "error" in body or "fail" in body or "expired" in body, (
f"Callback after removal should fail: {callback_r.text[:500]}"
)
ext = await _get_extension(ironclaw_server, "gmail")
assert ext is None, "gmail should remain removed after invalidated callback"
# ── Section C: Cleanup ──────────────────────────────────────────────────
async def test_cleanup_gmail(ironclaw_server):
"""Remove gmail (cleanup for other test files)."""
await _ensure_removed(ironclaw_server, "gmail")
ext = await _get_extension(ironclaw_server, "gmail")
assert ext is None, "gmail should be removed"
+17 -168
View File
@@ -458,37 +458,6 @@ async def test_install_wasm_channel_triggers_configure(page):
assert await modal.is_visible()
async def test_install_with_auth_url_opens_popup_and_shows_auth_prompt(page):
"""Install responses with auth_url should surface the same auth prompt used elsewhere."""
await page.evaluate("window.open = (url) => { window._lastOpenedUrl = url; }")
await mock_ext_apis(page, registry=[_REGISTRY_WASM])
async def handle_install(route):
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"success": True, "auth_url": "https://example.com/oauth"}),
)
await page.route("**/api/extensions/install", handle_install)
await go_to_extensions(page)
install_btn = page.locator(SEL["available_wasm_list"]).locator(SEL["ext_install_btn"]).first
await install_btn.wait_for(state="visible", timeout=5000)
await install_btn.click()
await page.wait_for_function(
"() => window._lastOpenedUrl !== null && window._lastOpenedUrl !== undefined",
timeout=5000,
)
opened = await page.evaluate("window._lastOpenedUrl")
assert opened is not None, "window.open was not called"
assert "example.com" in opened
await page.locator(SEL["auth_card"] + '[data-extension-name="registry-tool"]').wait_for(
state="visible", timeout=5000
)
# ─── Group F: Remove flow ─────────────────────────────────────────────────────
async def test_remove_installed_extension_confirmed(page):
@@ -643,7 +612,7 @@ async def test_configure_modal_save_success(page):
async def test_configure_modal_save_oauth(page):
"""Save response with auth_url opens a popup and shows the global auth prompt."""
"""Save response with auth_url opens a popup via window.open."""
await page.evaluate("window.open = (url) => { window._lastOpenedUrl = url; }")
async def handle_setup(route):
@@ -670,9 +639,6 @@ async def test_configure_modal_save_oauth(page):
opened = await page.evaluate("window._lastOpenedUrl")
assert opened is not None, "window.open was not called"
assert "oauth" in opened or "example.com" in opened
await page.locator(SEL["auth_card"] + '[data-extension-name="test-ext"]').wait_for(
state="visible", timeout=5000
)
async def test_configure_modal_save_failure(page):
@@ -733,7 +699,7 @@ async def test_configure_modal_enter_key_submits(page):
# ─── Group H: Auth card (SSE-triggered) ───────────────────────────────────────
async def _show_auth_card(page, **kwargs):
"""Inject the global auth prompt via JS and wait for it to appear."""
"""Inject an auth card via JS and wait for it to appear."""
payload = json.dumps(kwargs)
await page.evaluate(f"showAuthCard({payload})")
await page.locator(SEL["auth_card"]).wait_for(state="visible", timeout=5000)
@@ -846,43 +812,12 @@ async def test_auth_card_replaces_existing_same_extension(page):
assert "Second" in await page.locator(SEL["auth_instructions"]).text_content()
async def test_auth_card_for_different_extension_replaces_existing_prompt(page):
"""A new auth prompt replaces the previous one to keep the UX modal and global."""
async def test_auth_card_multiple_extensions_coexist(page):
"""Auth cards for different extensions can coexist."""
await page.evaluate('showAuthCard({extension_name: "ext-a", instructions: "Token A"})')
await page.evaluate('showAuthCard({extension_name: "ext-b", instructions: "Token B"})')
await page.locator(SEL["auth_card"]).wait_for(state="visible", timeout=3000)
assert await page.locator(SEL["auth_card"]).count() == 1
assert await page.locator(SEL["auth_card"] + '[data-extension-name="ext-a"]').count() == 0
assert await page.locator(SEL["auth_card"] + '[data-extension-name="ext-b"]').count() == 1
async def test_auth_and_configure_helpers_escape_selector_sensitive_extension_names(page):
"""Quoted extension names should not break auth/configure modal helpers."""
result = await page.evaluate(
"""({ name }) => {
showAuthCard({ extension_name: name, instructions: 'Paste token' });
showAuthCardError(name, 'Bad token');
const errorText = document.querySelector('.auth-error')?.textContent || '';
removeAuthCard(name);
const authStillPresent = Array.from(document.querySelectorAll('.auth-card'))
.some((card) => card.getAttribute('data-extension-name') === name);
const overlay = document.createElement('div');
overlay.className = 'configure-overlay';
overlay.setAttribute('data-extension-name', name);
document.body.appendChild(overlay);
closeConfigureModal(name);
const configureStillPresent = Array.from(document.querySelectorAll('.configure-overlay'))
.some((node) => node.getAttribute('data-extension-name') === name);
return { errorText, authStillPresent, configureStillPresent };
}""",
{"name": 'quoted "ext" name'},
)
assert result["errorText"] == "Bad token"
assert result["authStillPresent"] is False
assert result["configureStillPresent"] is False
await page.locator(SEL["auth_card"]).nth(1).wait_for(state="visible", timeout=3000)
assert await page.locator(SEL["auth_card"]).count() == 2
async def test_auth_completed_sse_dismisses_card(page):
@@ -891,95 +826,13 @@ async def test_auth_completed_sse_dismisses_card(page):
# Simulate the auth_completed SSE event being fired
await page.evaluate("""
handleAuthCompleted({
extension_name: 'myext',
success: true,
message: 'Authenticated!',
});
// Call the handler the same way the SSE listener does
removeAuthCard('myext');
""")
assert await page.locator(SEL["auth_card"] + '[data-extension-name="myext"]').count() == 0
async def test_auth_completed_for_other_extension_keeps_configure_modal_open(page):
"""Auth completion should not close a different extension's configure modal."""
async def handle_setup(route):
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"secrets": [{"name": "token", "prompt": "Token", "provided": False, "optional": False, "auto_generate": False}]}),
)
await page.route("**/api/extensions/test-ext/setup", handle_setup)
await page.evaluate("showConfigureModal('test-ext')")
await page.locator(SEL["configure_modal"]).wait_for(state="visible", timeout=5000)
await page.evaluate("""
handleAuthCompleted({
extension_name: 'other-ext',
success: true,
message: 'Other extension connected.',
});
""")
assert await page.locator(SEL["configure_overlay"]).is_visible(), (
"Configure modal should remain open when another extension finishes auth"
)
async def test_auth_completed_failure_sse_shows_error_toast_and_reloads_extensions(page):
"""Failed auth_completed handling should clear stale UI and refresh extensions."""
reload_count = []
async def counting_handler(route):
path = route.request.url.split("?")[0]
if path.endswith("/api/extensions"):
reload_count.append(1)
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"extensions": []}),
)
else:
await route.continue_()
async def handle_tools(route):
await route.fulfill(status=200, content_type="application/json", body='{"tools":[]}')
async def handle_registry(route):
await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}')
await page.route("**/api/extensions*", counting_handler)
await page.route("**/api/extensions/tools", handle_tools)
await page.route("**/api/extensions/registry", handle_registry)
await go_to_extensions(page)
count_before = len(reload_count)
await _show_auth_card(page, extension_name="gmail", auth_url="https://example.com/oauth")
assert await page.locator(SEL["auth_card"] + '[data-extension-name="gmail"]').count() == 1
await page.evaluate("""
handleAuthCompleted({
extension_name: 'gmail',
success: false,
message: 'OAuth flow expired. Please try again.',
});
""")
await wait_for_toast(page, "OAuth flow expired. Please try again.")
assert await page.locator(SEL["auth_card"] + '[data-extension-name="gmail"]').count() == 0
assert (
await page.locator(
SEL["toast_error"], has_text="OAuth flow expired. Please try again."
).count()
>= 1
)
await page.wait_for_timeout(600)
assert len(reload_count) > count_before, "Extensions list did not reload after auth failure"
# ─── Group I: Activate flow ────────────────────────────────────────────────────
async def test_activate_mcp_server_success(page):
@@ -1049,8 +902,8 @@ async def test_activate_failure_shows_error_toast(page):
await wait_for_toast(page, "Config missing")
async def test_activate_with_auth_url_opens_popup_and_shows_auth_prompt(page):
"""Activate response with auth_url calls window.open and shows the auth prompt."""
async def test_activate_with_auth_url_opens_popup(page):
"""Activate response with auth_url calls window.open."""
await page.evaluate("window.open = (url) => { window._lastOpenedUrl = url; }")
await mock_ext_apis(page, installed=[_MCP_INACTIVE])
@@ -1068,9 +921,6 @@ async def test_activate_with_auth_url_opens_popup_and_shows_auth_prompt(page):
opened = await page.evaluate("window._lastOpenedUrl")
assert opened is not None, "window.open was not called"
assert "example.com" in opened
await page.locator(
SEL["auth_card"] + '[data-extension-name="test-mcp-inactive"]'
).wait_for(state="visible", timeout=5000)
# ─── Group J: Tab reload behaviour ────────────────────────────────────────────
@@ -1097,9 +947,9 @@ async def test_extensions_tab_reloads_on_revisit(page):
async def handle_registry(route):
await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}')
await page.route("**/api/extensions*", counting_handler)
await page.route("**/api/extensions/tools", handle_tools)
await page.route("**/api/extensions/registry", handle_registry)
await page.route("**/api/extensions*", counting_handler)
# First visit
await go_to_extensions(page)
@@ -1140,20 +990,19 @@ async def test_auth_completed_sse_triggers_extensions_reload(page):
async def handle_registry(route):
await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}')
await page.route("**/api/extensions*", counting_handler)
await page.route("**/api/extensions/tools", handle_tools)
await page.route("**/api/extensions/registry", handle_registry)
await page.route("**/api/extensions*", counting_handler)
await go_to_extensions(page)
count_before = len(reload_count)
# Simulate auth_completed via the shared handler.
# Simulate auth_completed by calling loadExtensions directly (as the SSE handler does)
await page.evaluate("""
handleAuthCompleted({
extension_name: 'reload-ext',
success: true,
message: 'Reloaded.',
});
// Simulate what the auth_completed SSE handler does when currentTab === 'extensions'
if (typeof loadExtensions === 'function') {
loadExtensions();
}
""")
await page.wait_for_timeout(600)
-79
View File
@@ -1,79 +0,0 @@
"""DM pairing flow e2e tests.
Tests the pairing security gate for WASM channels: listing pending requests,
approving codes, and error handling.
"""
import httpx
from helpers import AUTH_TOKEN
def _headers():
return {"Authorization": f"Bearer {AUTH_TOKEN}"}
async def test_pairing_list_returns_empty_for_unknown_channel(ironclaw_server):
"""GET /api/pairing/{channel} returns empty list or 404 for non-existent channel."""
async with httpx.AsyncClient() as client:
r = await client.get(
f"{ironclaw_server}/api/pairing/nonexistent-channel",
headers=_headers(),
timeout=10,
)
# Either empty list or error is acceptable
if r.status_code == 200:
data = r.json()
assert isinstance(data, (dict, list))
if isinstance(data, dict):
assert "requests" in data
assert isinstance(data["requests"], list)
assert data["requests"] == []
else:
assert data == []
else:
# 404 or similar is fine for non-existent channel
assert r.status_code in (404, 400)
async def test_approve_invalid_code_rejected(ironclaw_server):
"""POST /api/pairing/{channel}/approve with bad code returns error."""
async with httpx.AsyncClient() as client:
r = await client.post(
f"{ironclaw_server}/api/pairing/test-channel/approve",
json={"code": "INVALID0"},
headers=_headers(),
timeout=10,
)
# Should fail — no pending request with this code
if r.status_code == 200:
data = r.json()
assert data.get("success") is False or data.get("ok") is False or "error" in str(data).lower()
else:
assert r.status_code >= 400
async def test_approve_empty_code_rejected(ironclaw_server):
"""POST /api/pairing/{channel}/approve with empty code returns error."""
async with httpx.AsyncClient() as client:
r = await client.post(
f"{ironclaw_server}/api/pairing/test-channel/approve",
json={"code": ""},
headers=_headers(),
timeout=10,
)
if r.status_code == 200:
data = r.json()
assert data.get("success") is False or data.get("ok") is False
else:
assert r.status_code >= 400
async def test_pairing_approve_requires_auth(ironclaw_server):
"""POST /api/pairing/{channel}/approve without auth token is rejected."""
async with httpx.AsyncClient() as client:
r = await client.post(
f"{ironclaw_server}/api/pairing/test-channel/approve",
json={"code": "ABCD1234"},
timeout=10,
)
assert r.status_code == 401 or r.status_code == 403
@@ -1,94 +0,0 @@
"""Tool execution e2e tests.
Tests the agent loop: user message -> mock LLM returns tool_calls -> tool
executes -> result displayed in chat. Requires the enhanced mock_llm.py
with TOOL_CALL_PATTERNS support.
"""
from helpers import SEL
async def _send_and_get_response(
page,
message: str,
*,
expected_fragment: str,
timeout: int = 30000,
) -> str:
"""Send a message and return the text of the newest assistant response.
Counts existing assistant messages before sending, then waits for a new
one to appear and contain the expected final text fragment. This avoids
reading partial streamed content before the assistant response is complete.
"""
chat_input = page.locator(SEL["chat_input"])
await chat_input.wait_for(state="visible", timeout=5000)
# Count existing assistant messages before sending
assistant_sel = SEL["message_assistant"]
before_count = await page.locator(assistant_sel).count()
await chat_input.fill(message)
await chat_input.press("Enter")
# Wait for the final assistant message to exist and include the expected
# text fragment rather than returning on the first streamed chunk.
expected = before_count + 1
await page.wait_for_function(
"""({ assistantSelector, expectedCount, expectedFragment }) => {
const messages = document.querySelectorAll(assistantSelector);
if (messages.length < expectedCount) return false;
const text = (messages[messages.length - 1].innerText || '').trim().toLowerCase();
return text.includes(expectedFragment.toLowerCase());
}""",
arg={
"assistantSelector": assistant_sel,
"expectedCount": expected,
"expectedFragment": expected_fragment,
},
timeout=timeout,
)
return await page.locator(assistant_sel).last.inner_text()
async def test_builtin_echo_tool(page):
"""Send a message that triggers the echo tool via mock LLM function calling."""
text = await _send_and_get_response(
page,
"echo hello world",
expected_fragment="hello world",
)
# The mock LLM returns "The echo tool returned: <result>"
assert "echo" in text.lower() or "hello world" in text.lower(), (
f"Expected echo result in response, got: {text}"
)
async def test_builtin_time_tool(page):
"""Send a message that triggers the time tool via mock LLM function calling."""
text = await _send_and_get_response(
page,
"what time is it",
expected_fragment="time",
)
# The mock LLM returns "The time tool returned: <json with iso/unix>"
assert "time" in text.lower(), (
f"Expected time result in response, got: {text}"
)
async def test_non_tool_message_still_works(page):
"""Messages that don't match tool patterns still get text responses."""
text = await _send_and_get_response(
page,
"What is 2+2?",
expected_fragment="4",
timeout=15000,
)
assert "4" in text, (
f"Expected '4' in response, got: {text}"
)
-517
View File
@@ -1,517 +0,0 @@
"""Comprehensive WASM extension lifecycle e2e tests.
Tests the full extension pipeline: registry install fields configure
activate tools remove reinstall. Validates response fields, not just
status codes, to catch production bugs like missing capabilities, wrong
activation state, and stale registry flags.
Lifecycle stages are expressed as scoped fixtures so each test requests the
state it needs explicitly rather than relying on module-global flags.
"""
from pathlib import Path
import pytest
from helpers import SEL, api_get, api_post
async def _get_extension(base_url, name):
"""Get a specific extension from the extensions list, or None."""
r = await api_get(base_url, "/api/extensions")
for ext in r.json().get("extensions", []):
if ext["name"] == name:
return ext
return None
async def _ensure_removed(base_url, name):
"""Remove extension if already installed (idempotent cleanup)."""
ext = await _get_extension(base_url, name)
if ext:
await api_post(base_url, f"/api/extensions/{name}/remove", timeout=30)
async def _install_extension(base_url, name):
"""Install an extension and assert success."""
r = await api_post(
base_url,
"/api/extensions/install",
json={"name": name},
timeout=180,
)
assert r.status_code == 200, f"Install HTTP error: {r.status_code} {r.text[:300]}"
data = r.json()
assert data.get("success") is True, f"Install failed: {data.get('message', '')}"
return data
@pytest.fixture(scope="module", autouse=True)
async def extension_lifecycle_cleanup(ironclaw_server):
"""Start and end the module with a clean extension set."""
await _ensure_removed(ironclaw_server, "web-search")
await _ensure_removed(ironclaw_server, "gmail")
yield
await _ensure_removed(ironclaw_server, "web-search")
await _ensure_removed(ironclaw_server, "gmail")
@pytest.fixture(scope="module")
async def web_search_installed(ironclaw_server, extension_lifecycle_cleanup):
"""Install web-search once for tests that require the pre-configure state."""
data = await _install_extension(ironclaw_server, "web-search")
return {"name": "web-search", "install": data}
@pytest.fixture(scope="module")
async def web_search_configured(ironclaw_server, web_search_installed):
"""Configure web-search once for tests that require the active state."""
r = await api_post(
ironclaw_server,
"/api/extensions/web-search/setup",
json={"secrets": {"brave_api_key": "test-key-123"}},
timeout=30,
)
assert r.status_code == 200
data = r.json()
assert data.get("success") is True, f"Configure failed: {data.get('message', '')}"
assert data.get("activated") is True, "Should auto-activate after configure"
return {"name": "web-search", "configure": data}
@pytest.fixture(scope="module")
async def gmail_installed(ironclaw_server, extension_lifecycle_cleanup):
"""Install gmail once for multi-extension and OAuth setup assertions."""
data = await _install_extension(ironclaw_server, "gmail")
return {"name": "gmail", "install": data}
@pytest.fixture(scope="module")
async def web_search_removed(ironclaw_server, web_search_configured):
"""Remove web-search once for post-uninstall assertions."""
r = await api_post(
ironclaw_server, "/api/extensions/web-search/remove", timeout=30
)
assert r.status_code == 200
data = r.json()
assert data.get("success") is True, f"Remove failed: {data.get('message', '')}"
return {"name": "web-search", "remove": data}
@pytest.fixture(scope="module")
async def web_search_reinstalled(ironclaw_server, web_search_removed):
"""Reinstall web-search after removal to verify saved-secret recovery."""
await _ensure_removed(ironclaw_server, "web-search")
data = await _install_extension(ironclaw_server, "web-search")
return {"name": "web-search", "install": data}
# ── Section A: Registry Validation ──────────────────────────────────────
async def test_registry_lists_extensions(ironclaw_server):
"""Registry endpoint returns entries from the embedded catalog."""
r = await api_get(ironclaw_server, "/api/extensions/registry")
assert r.status_code == 200
data = r.json()
assert "entries" in data
names = [e["name"] for e in data["entries"]]
assert "web-search" in names
assert "gmail" in names
async def test_registry_entry_fields(ironclaw_server):
"""Every registry entry has all required fields with correct types."""
r = await api_get(ironclaw_server, "/api/extensions/registry")
entries = r.json()["entries"]
assert len(entries) > 0, "Registry should have entries"
for entry in entries:
assert "name" in entry and isinstance(entry["name"], str) and entry["name"]
assert "display_name" in entry and isinstance(entry["display_name"], str)
assert "kind" in entry and isinstance(entry["kind"], str)
assert "description" in entry and isinstance(entry["description"], str)
assert "installed" in entry and isinstance(entry["installed"], bool)
assert "keywords" in entry and isinstance(entry["keywords"], list)
async def test_registry_installed_flag_false_initially(ironclaw_server):
"""Before any install, all registry entries have installed=False."""
# Clean up in case previous test run left extensions installed
await _ensure_removed(ironclaw_server, "web-search")
await _ensure_removed(ironclaw_server, "gmail")
r = await api_get(ironclaw_server, "/api/extensions/registry")
entries = r.json()["entries"]
for entry in entries:
if entry["name"] in ("web-search", "gmail"):
assert entry["installed"] is False, (
f"{entry['name']} should not be installed yet"
)
async def test_registry_search_filters(ironclaw_server):
"""Search query filters registry results."""
r = await api_get(
ironclaw_server, "/api/extensions/registry", params={"query": "search"}
)
assert r.status_code == 200
entries = r.json()["entries"]
names = [e["name"] for e in entries]
assert "web-search" in names
async def test_registry_search_no_match(ironclaw_server):
"""Nonsense query returns empty results."""
r = await api_get(
ironclaw_server,
"/api/extensions/registry",
params={"query": "xyznonexistent999"},
)
assert r.status_code == 200
assert len(r.json()["entries"]) == 0
# ── Section B: Install Lifecycle (web-search) ───────────────────────────
async def test_install_web_search(web_search_installed):
"""Install web-search from registry. Asserts success — failure here means
the registry/download/build pipeline is broken."""
assert "message" in web_search_installed["install"]
async def test_installed_extension_fields(ironclaw_server, web_search_installed):
"""After install, extension list shows correct fields."""
ext = await _get_extension(ironclaw_server, "web-search")
assert ext is not None, "web-search not in extensions list after install"
assert ext["kind"] == "wasm_tool"
assert ext["needs_setup"] is True, "Should need setup (has brave_api_key secret)"
assert ext["authenticated"] is False, "Should not be authenticated before configure"
async def test_installed_in_registry(ironclaw_server, web_search_installed):
"""Registry marks installed extension with installed=True."""
r = await api_get(ironclaw_server, "/api/extensions/registry")
entries = r.json()["entries"]
ws_entry = next((e for e in entries if e["name"] == "web-search"), None)
assert ws_entry is not None
assert ws_entry["installed"] is True, "Registry should show installed=True"
async def test_setup_schema_has_secrets(ironclaw_server, web_search_installed):
"""Setup schema returns brave_api_key with correct field info."""
r = await api_get(ironclaw_server, "/api/extensions/web-search/setup")
assert r.status_code == 200
data = r.json()
assert "secrets" in data
secrets = {s["name"]: s for s in data["secrets"]}
assert "brave_api_key" in secrets, (
f"brave_api_key not in setup schema secrets: {list(secrets.keys())}"
)
key_info = secrets["brave_api_key"]
assert key_info["provided"] is False, "Should not be provided yet"
async def test_extension_not_authenticated_before_configure(
ironclaw_server, web_search_installed
):
"""Installed but not configured extension is not authenticated."""
ext = await _get_extension(ironclaw_server, "web-search")
assert ext is not None
# Before configuring secrets, extension shouldn't be fully authenticated
assert ext["needs_setup"] is True, "Should still need setup before configure"
async def test_activate_before_configure_rejected(ironclaw_server, web_search_installed):
"""Activating a tool that needs setup secrets is rejected."""
r = await api_post(
ironclaw_server, "/api/extensions/web-search/activate", timeout=30
)
assert r.status_code == 200
data = r.json()
assert data.get("success") is False, (
f"Activate should fail before configure: {data}"
)
msg = data.get("message", "").lower()
assert "requires configuration" in msg or "setup" in msg, (
f"Error should mention configuration: {data.get('message')}"
)
# ── Section C: Configure + Activate (web-search) ────────────────────────
async def test_configure_rejects_unknown_secret(ironclaw_server, web_search_installed):
"""Submitting an unknown secret name is rejected."""
r = await api_post(
ironclaw_server,
"/api/extensions/web-search/setup",
json={"secrets": {"fake_unknown_key": "value"}},
)
assert r.status_code == 200
data = r.json()
assert data.get("success") is False, f"Should reject unknown secret: {data}"
assert "unknown" in data.get("message", "").lower() or "not found" in data.get(
"message", ""
).lower(), f"Error should mention unknown secret: {data.get('message')}"
async def test_configure_with_valid_secret(web_search_configured):
"""Configure with valid brave_api_key succeeds and auto-activates."""
assert web_search_configured["configure"].get("activated") is True
async def test_extension_active_after_configure(ironclaw_server, web_search_configured):
"""After configure, extension shows authenticated=True and active=True."""
ext = await _get_extension(ironclaw_server, "web-search")
assert ext is not None
assert ext["authenticated"] is True, "Should be authenticated after configure"
assert ext["active"] is True, "Should be active after auto-activation"
assert len(ext.get("tools", [])) > 0, "Should have tools registered"
async def test_setup_shows_provided(ironclaw_server, web_search_configured):
"""After configure, setup schema shows secret as provided."""
r = await api_get(ironclaw_server, "/api/extensions/web-search/setup")
assert r.status_code == 200
secrets = {s["name"]: s for s in r.json()["secrets"]}
assert "brave_api_key" in secrets
assert secrets["brave_api_key"]["provided"] is True
async def test_tools_registered_after_activate(
ironclaw_server, web_search_configured
):
"""After activation, extension tools appear in the tools endpoint."""
r = await api_get(ironclaw_server, "/api/extensions/tools")
assert r.status_code == 200
tool_names = [t["name"] for t in r.json()["tools"]]
assert "web-search" in tool_names, (
f"web-search tool not found in tools list: {tool_names}"
)
async def test_activate_already_active_idempotent(
ironclaw_server, web_search_configured
):
"""Activating an already-active extension succeeds (idempotent)."""
r = await api_post(
ironclaw_server, "/api/extensions/web-search/activate", timeout=30
)
assert r.status_code == 200
data = r.json()
assert data.get("success") is True, (
f"Re-activation should succeed: {data.get('message', '')}"
)
async def test_configure_empty_secret_skipped(ironclaw_server, web_search_configured):
"""Submitting an empty string for a secret skips it (doesn't overwrite)."""
r = await api_post(
ironclaw_server,
"/api/extensions/web-search/setup",
json={"secrets": {"brave_api_key": ""}},
timeout=30,
)
assert r.status_code == 200
data = r.json()
assert data.get("success") is True
# Verify the secret is still provided (not cleared)
r2 = await api_get(ironclaw_server, "/api/extensions/web-search/setup")
secrets = {s["name"]: s for s in r2.json()["secrets"]}
assert secrets["brave_api_key"]["provided"] is True, (
"Empty value should not clear existing secret"
)
# ── Section D: Install gmail (multi-extension) ──────────────────────────
async def test_install_gmail(gmail_installed):
"""Install gmail from registry (second extension, tests isolation)."""
assert "message" in gmail_installed["install"]
async def test_gmail_fields(ironclaw_server, gmail_installed):
"""Gmail extension has correct field values (OAuth-based auth)."""
ext = await _get_extension(ironclaw_server, "gmail")
assert ext is not None, "gmail not in extensions list"
assert ext["kind"] == "wasm_tool"
assert ext["has_auth"] is True, "Gmail should have OAuth auth"
async def test_both_extensions_listed(
ironclaw_server, web_search_configured, gmail_installed
):
"""Both web-search and gmail appear in extensions list (no clobbering)."""
r = await api_get(ironclaw_server, "/api/extensions")
names = [e["name"] for e in r.json()["extensions"]]
assert "web-search" in names, f"web-search missing from: {names}"
assert "gmail" in names, f"gmail missing from: {names}"
async def test_gmail_setup_schema_auto_resolves(ironclaw_server, gmail_installed):
"""Gmail setup schema returns empty secrets (builtin creds auto-resolve)."""
r = await api_get(ironclaw_server, "/api/extensions/gmail/setup")
assert r.status_code == 200
data = r.json()
secrets = data.get("secrets", [])
# Builtin Google credentials auto-resolve client_id/client_secret via
# is_auto_resolved_oauth_field(), so the setup schema should have no
# user-facing secrets (or only auto-generated ones).
user_facing = [s for s in secrets if not s.get("auto_generate", False)]
assert len(user_facing) == 0, (
f"Gmail should have no user-facing secrets (auto-resolved), got: "
f"{[s['name'] for s in user_facing]}"
)
# ── Section E: Remove + Cleanup ─────────────────────────────────────────
async def test_remove_web_search(web_search_removed):
"""Remove web-search succeeds."""
assert web_search_removed["remove"].get("success") is True
async def test_removed_not_in_extensions(ironclaw_server, web_search_removed):
"""Removed extension no longer appears in extensions list."""
ext = await _get_extension(ironclaw_server, "web-search")
assert ext is None, "web-search should not be in extensions list after removal"
async def test_removed_extension_not_listed(ironclaw_server, web_search_removed):
"""Removed extension should not appear in the extension tools list."""
r = await api_get(ironclaw_server, "/api/extensions/tools")
assert r.status_code == 200
tool_names = [t["name"] for t in r.json()["tools"]]
assert "web-search" not in tool_names, (
f"Removed web-search tool should not remain registered: {tool_names}"
)
async def test_removed_not_in_registry_installed(ironclaw_server, web_search_removed):
"""Registry shows removed extension as installed=False."""
r = await api_get(ironclaw_server, "/api/extensions/registry")
ws_entry = next(
(e for e in r.json()["entries"] if e["name"] == "web-search"), None
)
assert ws_entry is not None
assert ws_entry["installed"] is False, "Registry should show installed=False"
async def test_activate_after_remove_uses_replacement_bytes_not_cached_module(
ironclaw_server, wasm_tools_dir, web_search_removed
):
"""After removal, activation must use the replacement bytes rather than a stale cache."""
wasm_path = Path(wasm_tools_dir) / "web-search.wasm"
wasm_path.write_bytes(b"not-a-valid-wasm-component")
r = await api_post(
ironclaw_server, "/api/extensions/web-search/activate", timeout=30
)
assert r.status_code == 200
data = r.json()
assert data.get("success") is False, (
f"Activation should fail against replacement bytes, got: {data}"
)
async def test_reinstall_after_remove(ironclaw_server, web_search_reinstalled):
"""Extension can be reinstalled after removal without stale activation errors."""
ext = await _get_extension(ironclaw_server, "web-search")
assert ext is not None, "web-search not found after reinstall"
assert ext["active"] is True, "Reinstalled tool should auto-activate via saved secrets"
assert ext["authenticated"] is True, "Saved secret should still authenticate on reinstall"
# Verify no stale activation error from previous install
assert ext.get("activation_error") is None or ext.get("activation_error") == "", (
f"Reinstalled extension should have no stale activation error: {ext}"
)
# ── Section F: Error Paths ──────────────────────────────────────────────
async def test_install_nonexistent(ironclaw_server):
"""Installing a nonexistent extension returns an error."""
r = await api_post(
ironclaw_server,
"/api/extensions/install",
json={"name": "nonexistent-tool-xyz-999"},
timeout=30,
)
if r.status_code == 200:
assert r.json().get("success") is False
else:
assert r.status_code >= 400
async def test_install_empty_name(ironclaw_server):
"""Installing with empty name returns an error."""
r = await api_post(
ironclaw_server,
"/api/extensions/install",
json={"name": ""},
timeout=10,
)
if r.status_code == 200:
assert r.json().get("success") is False
else:
assert r.status_code >= 400
async def test_remove_noninstalled(ironclaw_server):
"""Removing a non-installed extension returns an error."""
r = await api_post(
ironclaw_server, "/api/extensions/nonexistent-xyz/remove", timeout=10
)
if r.status_code == 200:
assert r.json().get("success") is False
else:
assert r.status_code >= 400
async def test_activate_noninstalled(ironclaw_server):
"""Activating a non-installed extension returns an error."""
r = await api_post(
ironclaw_server, "/api/extensions/nonexistent-xyz/activate", timeout=10
)
if r.status_code == 200:
assert r.json().get("success") is False
else:
assert r.status_code >= 400
async def test_setup_noninstalled(ironclaw_server):
"""Setup for non-installed extension returns an error."""
r = await api_get(ironclaw_server, "/api/extensions/nonexistent-xyz/setup")
# May return 500 or a JSON error
assert r.status_code >= 400 or r.json().get("success") is False
async def test_configure_noninstalled(ironclaw_server):
"""Configure for non-installed extension returns an error."""
r = await api_post(
ironclaw_server,
"/api/extensions/nonexistent-xyz/setup",
json={"secrets": {}},
timeout=10,
)
if r.status_code == 200:
assert r.json().get("success") is False
else:
assert r.status_code >= 400
# ── Section G: Browser UI ──────────────────────────────────────────────
async def test_extensions_tab_shows_registry(page):
"""Extensions tab loads and shows available extensions from registry."""
tab_btn = page.locator(SEL["tab_button"].format(tab="extensions"))
await tab_btn.click()
panel = page.locator(SEL["tab_panel"].format(tab="extensions"))
await panel.wait_for(state="visible", timeout=5000)
available_section = page.locator(SEL["available_wasm_list"])
await available_section.wait_for(state="visible", timeout=10000)
-86
View File
@@ -457,90 +457,4 @@ mod tests {
rig.shutdown();
}
// -----------------------------------------------------------------------
// Test: tool_info_discovery (two-level detail)
// -----------------------------------------------------------------------
// Verifies the tool_info built-in returns:
// - Default (no include_schema): name, description, parameter names array
// - With include_schema: true: adds full typed JSON Schema
#[tokio::test]
async fn tool_info_discovery() {
let trace = LlmTrace::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/tools/tool_info_discovery.json"
))
.expect("failed to load tool_info_discovery.json");
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.build()
.await;
rig.send_message("What is the schema for the echo and time tools?")
.await;
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
rig.verify_trace_expects(&trace, &responses);
// tool_info should have been called twice (echo + time), both succeeding.
let completed = rig.tool_calls_completed();
let tool_info_calls: Vec<_> = completed.iter().filter(|(n, _)| n == "tool_info").collect();
assert_eq!(
tool_info_calls.len(),
2,
"Expected 2 tool_info calls, got {tool_info_calls:?}"
);
assert!(
tool_info_calls.iter().all(|(_, ok)| *ok),
"All tool_info calls should succeed: {tool_info_calls:?}"
);
// Verify the results contain expected fields.
let results = rig.tool_results();
let info_results: Vec<_> = results.iter().filter(|(n, _)| n == "tool_info").collect();
// First call was for "echo" (default, no include_schema) — result should
// contain "echo" and "parameters" as an array of names (not full schema).
let echo_result = info_results
.iter()
.find(|(_, preview)| preview.contains("echo"))
.expect("tool_info result should contain 'echo'");
assert!(
echo_result.1.contains("message"),
"echo default result should list 'message' parameter name: {:?}",
echo_result.1
);
// Default mode should NOT include the full "schema" key
let echo_json: serde_json::Value = serde_json::from_str(&echo_result.1)
.expect("echo tool_info result should be valid JSON");
assert!(
echo_json.get("schema").is_none(),
"Default tool_info should not include schema field: {:?}",
echo_result.1
);
// Second call was for "time" with include_schema: true — result should
// contain "time", "schema" field with full object.
let time_result = info_results
.iter()
.find(|(_, preview)| preview.contains("time"))
.expect("tool_info result should contain 'time'");
let time_json: serde_json::Value = serde_json::from_str(&time_result.1)
.expect("time tool_info result should be valid JSON");
assert!(
time_json.get("schema").is_some(),
"include_schema: true should include schema field: {:?}",
time_result.1
);
assert!(
time_json["schema"]["properties"].is_object(),
"schema should have properties: {:?}",
time_result.1
);
rig.shutdown();
}
}
@@ -1,50 +0,0 @@
{
"model_name": "test-tool-info-discovery",
"expects": {
"tools_used": ["tool_info"],
"all_tools_succeeded": true,
"min_responses": 1,
"tool_results_contain": {
"tool_info": "echo"
}
},
"steps": [
{
"request_hint": { "last_user_message_contains": "schema" },
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_tool_info_echo",
"name": "tool_info",
"arguments": { "name": "echo" }
}
],
"input_tokens": 100,
"output_tokens": 20
}
},
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_tool_info_time",
"name": "tool_info",
"arguments": { "name": "time", "include_schema": true }
}
],
"input_tokens": 200,
"output_tokens": 20
}
},
{
"response": {
"type": "text",
"content": "I found the info for both tools. The echo tool has a 'message' parameter. The time tool accepts an 'operation' parameter with options like 'now', 'parse', and 'diff'.",
"input_tokens": 400,
"output_tokens": 40
}
}
]
}
-113
View File
@@ -13,8 +13,6 @@ mod support;
mod tests {
use std::time::Duration;
use uuid::Uuid;
use crate::support::gateway_workflow_harness::GatewayWorkflowHarness;
use crate::support::mock_openai_server::{
MockOpenAiResponse, MockOpenAiRule, MockOpenAiServerBuilder, MockToolCall,
@@ -149,115 +147,4 @@ mod tests {
harness.shutdown().await;
mock.shutdown().await;
}
#[tokio::test]
async fn routines_toggle_reenable_cron_recomputes_next_fire_at() {
let mock = MockOpenAiServerBuilder::new()
.with_rule(MockOpenAiRule::on_user_contains(
"create cron routine",
MockOpenAiResponse::ToolCalls(vec![MockToolCall::new(
"call_create_cron_1",
"routine_create",
serde_json::json!({
"name": "wf-cron-toggle-reenable",
"description": "Cron toggle regression test",
"trigger_type": "cron",
"schedule": "0 */5 * * * *",
"timezone": "UTC",
"action_type": "lightweight",
"prompt": "noop"
}),
)]),
))
.with_default_response(MockOpenAiResponse::Text("ack".to_string()))
.start()
.await;
let harness =
GatewayWorkflowHarness::start_openai_compatible(&mock.openai_base_url(), "mock-model")
.await;
let thread_id = harness.create_thread().await;
harness.send_chat(&thread_id, "create cron routine").await;
harness
.wait_for_turns(&thread_id, 1, Duration::from_secs(10))
.await;
let routine = harness
.routine_by_name("wf-cron-toggle-reenable")
.await
.expect("routine should exist");
let routine_id = routine
.get("id")
.and_then(|v| v.as_str())
.expect("routine id missing");
let routine_uuid = Uuid::parse_str(routine_id).expect("valid routine uuid");
// Disable through the web toggle endpoint.
harness
.client
.post(format!(
"{}/api/routines/{routine_id}/toggle",
harness.base_url()
))
.bearer_auth(&harness.auth_token)
.json(&serde_json::json!({ "enabled": false }))
.send()
.await
.expect("disable toggle request failed")
.error_for_status()
.expect("disable toggle non-2xx");
// Simulate an unscheduled disabled cron routine (next_fire_at missing).
let mut stored = harness
.db
.get_routine(routine_uuid)
.await
.expect("db get_routine")
.expect("routine should still exist");
stored.next_fire_at = None;
harness
.db
.update_routine(&stored)
.await
.expect("db update_routine");
// Re-enable through the web toggle endpoint.
harness
.client
.post(format!(
"{}/api/routines/{routine_id}/toggle",
harness.base_url()
))
.bearer_auth(&harness.auth_token)
.json(&serde_json::json!({ "enabled": true }))
.send()
.await
.expect("enable toggle request failed")
.error_for_status()
.expect("enable toggle non-2xx");
let detail = harness
.client
.get(format!("{}/api/routines/{routine_id}", harness.base_url()))
.bearer_auth(&harness.auth_token)
.send()
.await
.expect("detail request failed")
.error_for_status()
.expect("detail non-2xx")
.json::<serde_json::Value>()
.await
.expect("invalid detail response");
assert_eq!(detail["enabled"].as_bool(), Some(true));
assert!(
detail["next_fire_at"].as_str().is_some(),
"expected next_fire_at to be recomputed when re-enabling cron routine, got {detail}"
);
harness.shutdown().await;
mock.shutdown().await;
}
}
@@ -1,41 +1,6 @@
{
"version": "0.2.0",
"wit_version": "0.3.0",
"description": "Search the web using Brave Search. Returns titles, URLs, descriptions, and publication dates for matching web pages. Supports filtering by country, language, and freshness. Authentication is handled via the 'brave_api_key' secret injected by the host.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query to look up on the web"
},
"count": {
"type": "integer",
"description": "Number of results to return (1-20, default 5)",
"minimum": 1,
"maximum": 20,
"default": 5
},
"country": {
"type": "string",
"description": "2-letter uppercase country code to bias results (e.g. 'US', 'DE', 'JP')"
},
"search_lang": {
"type": "string",
"description": "2-letter lowercase language code for search results (e.g. 'en', 'de', 'fr')"
},
"ui_lang": {
"type": "string",
"description": "Locale in language-region format (e.g. 'en-US', 'de-DE')"
},
"freshness": {
"type": "string",
"description": "Filter by discovery time: 'pd' (past day), 'pw' (past week), 'pm' (past month), 'py' (past year), or date range 'YYYY-MM-DDtoYYYY-MM-DD'"
}
},
"required": ["query"],
"additionalProperties": false
},
"capabilities": {
"http": {
"allowlist": [