mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c47237b9c7 | ||
|
|
a89cf37993 | ||
|
|
3c619b6272 | ||
|
|
15c5d3e2e2 | ||
|
|
cd1245afc0 | ||
|
|
9fbdd42988 | ||
|
|
8a60fa2d37 | ||
|
|
c7dec64b2d | ||
|
|
c54f739354 | ||
|
|
1ba6a83ca4 | ||
|
|
d8bcfe15cf | ||
|
|
6f00490900 | ||
|
|
e522d33a53 | ||
|
|
7a9cbb3b50 | ||
|
|
442a42d996 |
@@ -0,0 +1,59 @@
|
||||
#!/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
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/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
|
||||
@@ -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"
|
||||
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"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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
|
||||
@@ -58,10 +58,16 @@ 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: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }}
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||
|
||||
@@ -25,9 +25,35 @@ 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 }}
|
||||
@@ -44,7 +70,7 @@ jobs:
|
||||
id: check
|
||||
env:
|
||||
FORCE_RUN: ${{ inputs.force }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
PROMOTION_BASE: ${{ needs.resolve-promotion-base.outputs.promotion_base }}
|
||||
run: |
|
||||
CURRENT_HEAD=$(git rev-parse HEAD)
|
||||
echo "current_head=${CURRENT_HEAD}" >> "$GITHUB_OUTPUT"
|
||||
@@ -66,9 +92,9 @@ jobs:
|
||||
echo "Found ${COMMIT_COUNT} new commit(s) since last tested"
|
||||
DIFF_RANGE="${LAST_TESTED}..${CURRENT_HEAD}"
|
||||
else
|
||||
git fetch origin "${DEFAULT_BRANCH}"
|
||||
MERGE_BASE=$(git merge-base "origin/${DEFAULT_BRANCH}" HEAD)
|
||||
echo "First run -- reviewing from merge-base ${MERGE_BASE}"
|
||||
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}"
|
||||
DIFF_RANGE="${MERGE_BASE}..${CURRENT_HEAD}"
|
||||
fi
|
||||
fi
|
||||
@@ -102,7 +128,7 @@ jobs:
|
||||
# ── Create promotion PR (triggers claude-review.yml on the PR) ──
|
||||
create-promotion-pr:
|
||||
name: Create Promotion PR
|
||||
needs: check-changes
|
||||
needs: [resolve-promotion-base, check-changes]
|
||||
if: needs.check-changes.outputs.has_changes == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
@@ -134,15 +160,15 @@ jobs:
|
||||
id: ahead-check
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.token.outputs.token }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
PROMOTION_BASE: ${{ needs.resolve-promotion-base.outputs.promotion_base }}
|
||||
run: |
|
||||
git fetch origin "${DEFAULT_BRANCH}"
|
||||
AHEAD=$(git rev-list --count "origin/${DEFAULT_BRANCH}..origin/staging")
|
||||
git fetch origin "${PROMOTION_BASE}"
|
||||
AHEAD=$(git rev-list --count "origin/${PROMOTION_BASE}..origin/staging")
|
||||
echo "commits_ahead=${AHEAD}" >> "$GITHUB_OUTPUT"
|
||||
if [ "$AHEAD" -eq 0 ]; then
|
||||
echo "Staging is not ahead of ${DEFAULT_BRANCH}. Nothing to promote."
|
||||
echo "Staging is not ahead of ${PROMOTION_BASE}. Nothing to promote."
|
||||
else
|
||||
echo "Staging is ${AHEAD} commits ahead of ${DEFAULT_BRANCH}."
|
||||
echo "Staging is ${AHEAD} commits ahead of ${PROMOTION_BASE}."
|
||||
fi
|
||||
|
||||
- name: Create promotion branch
|
||||
@@ -156,54 +182,53 @@ 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="${{ steps.find-base.outputs.base }}"
|
||||
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*"
|
||||
|
||||
PR_URL=$(gh pr create \
|
||||
--base "$BASE" \
|
||||
--head "$BRANCH" \
|
||||
--title "chore: promote staging to ${BASE} (${TIMESTAMP})" \
|
||||
--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*" \
|
||||
--body "$PR_BODY" \
|
||||
--label "staging-promotion")
|
||||
|
||||
PR_NUM=$(echo "$PR_URL" | grep -oE '[0-9]+$')
|
||||
@@ -228,7 +253,8 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: staging
|
||||
fetch-depth: 1
|
||||
# Need full history to recompute the final promoted range before merge.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
@@ -327,8 +353,10 @@ 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=$(echo "$TAG" | sed 's/\[\(.*\):\(.*\)\]/\1/')
|
||||
CONFIDENCE=$(echo "$TAG" | sed 's/\[\(.*\):\(.*\)\]/\2/')
|
||||
SEVERITY="${TAG#\[}"
|
||||
SEVERITY="${SEVERITY%%:*}"
|
||||
CONFIDENCE="${TAG##*:}"
|
||||
CONFIDENCE="${CONFIDENCE%\]}"
|
||||
DESC=$(echo "$line" | sed "s/\[${SEVERITY}:${CONFIDENCE}\] *//" | head -1)
|
||||
|
||||
echo "Found: [${SEVERITY}:${CONFIDENCE}] ${DESC}"
|
||||
@@ -420,11 +448,29 @@ 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)"
|
||||
gh pr merge "$PR_NUMBER" --merge
|
||||
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
|
||||
echo "merged=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "PR #${PR_NUMBER} targets '${BASE}' (not main) — leaving open for chain resolution"
|
||||
@@ -464,18 +510,20 @@ jobs:
|
||||
steps:
|
||||
- name: Summary
|
||||
run: |
|
||||
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
|
||||
{
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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
|
||||
@@ -19,6 +19,7 @@ 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
|
||||
|
||||
@@ -20,6 +20,7 @@ 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/
|
||||
|
||||
Generated
+1
-1
@@ -121,7 +121,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "discord-channel"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"ed25519-dalek",
|
||||
"hex",
|
||||
|
||||
@@ -642,6 +642,7 @@ 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,7 +2,7 @@
|
||||
"name": "telegram",
|
||||
"display_name": "Telegram Channel",
|
||||
"kind": "channel",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.3",
|
||||
"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.2-wasm32-wasip2.tar.gz",
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-0.2.3-wasm32-wasip2.tar.gz",
|
||||
"sha256": "b9a83d5a2d1285ce0ec116b354336a1f245f893291ccb01dffbcaccf89d72aed"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1150,9 +1150,11 @@ 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;
|
||||
@@ -1358,4 +1360,11 @@ 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...");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,6 +290,7 @@ 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));
|
||||
|
||||
+112
-76
@@ -269,95 +269,105 @@ async fn webhook_handler(
|
||||
let mut fallback_req = None;
|
||||
{
|
||||
let webhook_secret = state.webhook_secret.read().await;
|
||||
if let Some(expected_secret) = webhook_secret.as_ref() {
|
||||
let expected_secret = expected_secret.expose_secret();
|
||||
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();
|
||||
|
||||
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("Invalid signature header encoding".to_string()),
|
||||
response: Some(
|
||||
"Webhook authentication required. Provide X-IronClaw-Signature header \
|
||||
(preferred) or 'secret' field in body (deprecated)."
|
||||
.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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1052,6 +1062,32 @@ 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,6 +10,7 @@ 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;
|
||||
@@ -182,12 +183,21 @@ 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
|
||||
|
||||
+130
-72
@@ -26,6 +26,7 @@ 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;
|
||||
@@ -572,6 +573,14 @@ 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);
|
||||
}
|
||||
|
||||
@@ -2416,12 +2425,21 @@ 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
|
||||
@@ -2696,6 +2714,7 @@ struct GatewayStatusResponse {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cli::oauth_defaults;
|
||||
use crate::testing::credentials::TEST_GATEWAY_CRYPTO_KEY;
|
||||
|
||||
#[test]
|
||||
@@ -2813,6 +2832,11 @@ 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;
|
||||
@@ -2919,29 +2943,14 @@ mod tests {
|
||||
use tower::ServiceExt;
|
||||
|
||||
// Build an ExtensionManager so the handler can look up flows
|
||||
let secrets = Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
|
||||
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
|
||||
TEST_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 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 state = test_gateway_state(Some(ext_mgr));
|
||||
let app = test_oauth_router(state);
|
||||
@@ -2975,25 +2984,13 @@ mod tests {
|
||||
))
|
||||
.expect("crypto"),
|
||||
)));
|
||||
let tool_registry = Arc::new(ToolRegistry::new());
|
||||
let mcp_sm = Arc::new(crate::tools::mcp::session::McpSessionManager::new());
|
||||
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 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)
|
||||
// Insert an expired flow.
|
||||
let flow = crate::cli::oauth_defaults::PendingOAuthFlow {
|
||||
extension_name: "test_tool".to_string(),
|
||||
display_name: "Test Tool".to_string(),
|
||||
@@ -3013,9 +3010,7 @@ mod tests {
|
||||
gateway_token: None,
|
||||
resource: None,
|
||||
client_id_secret_name: None,
|
||||
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"),
|
||||
created_at,
|
||||
};
|
||||
|
||||
ext_mgr
|
||||
@@ -3045,6 +3040,80 @@ 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;
|
||||
@@ -3083,28 +3152,16 @@ mod tests {
|
||||
))
|
||||
.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.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![],
|
||||
));
|
||||
let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(secrets.clone());
|
||||
|
||||
// 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(),
|
||||
@@ -3125,9 +3182,7 @@ mod tests {
|
||||
resource: None,
|
||||
client_id_secret_name: None,
|
||||
// Expired — handler will reject after lookup (no network I/O)
|
||||
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"),
|
||||
created_at,
|
||||
};
|
||||
|
||||
ext_mgr
|
||||
@@ -3198,24 +3253,27 @@ mod tests {
|
||||
|
||||
fn test_ext_mgr(
|
||||
secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync>,
|
||||
) -> Arc<ExtensionManager> {
|
||||
) -> (Arc<ExtensionManager>, tempfile::TempDir, tempfile::TempDir) {
|
||||
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());
|
||||
Arc::new(ExtensionManager::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(
|
||||
mcp_sm,
|
||||
mcp_pm,
|
||||
secrets,
|
||||
tool_registry,
|
||||
None,
|
||||
None,
|
||||
std::path::PathBuf::from("/tmp/wasm_tools"),
|
||||
std::path::PathBuf::from("/tmp/wasm_channels"),
|
||||
wasm_tools_dir.path().to_path_buf(),
|
||||
wasm_channels_dir.path().to_path_buf(),
|
||||
None,
|
||||
"test".to_string(),
|
||||
None,
|
||||
vec![],
|
||||
))
|
||||
));
|
||||
(ext_mgr, wasm_tools_dir, wasm_channels_dir)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3224,7 +3282,7 @@ mod tests {
|
||||
use tower::ServiceExt;
|
||||
|
||||
let secrets = test_secrets_store();
|
||||
let ext_mgr = test_ext_mgr(secrets);
|
||||
let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(secrets);
|
||||
let state = test_gateway_state(Some(ext_mgr));
|
||||
let app = test_relay_oauth_router(state);
|
||||
|
||||
@@ -3268,7 +3326,7 @@ mod tests {
|
||||
.await
|
||||
.expect("store nonce");
|
||||
|
||||
let ext_mgr = test_ext_mgr(secrets);
|
||||
let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(secrets);
|
||||
let state = test_gateway_state(Some(ext_mgr));
|
||||
let app = test_relay_oauth_router(state);
|
||||
|
||||
@@ -3313,7 +3371,7 @@ mod tests {
|
||||
.await
|
||||
.expect("store nonce");
|
||||
|
||||
let ext_mgr = test_ext_mgr(secrets.clone());
|
||||
let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(secrets.clone());
|
||||
let state = test_gateway_state(Some(ext_mgr));
|
||||
let app = test_relay_oauth_router(state);
|
||||
|
||||
|
||||
+137
-36
@@ -342,31 +342,27 @@ function connectSSE() {
|
||||
|
||||
eventSource.addEventListener('approval_needed', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
if (!isCurrentThread(data.thread_id)) return;
|
||||
showApproval(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();
|
||||
});
|
||||
|
||||
eventSource.addEventListener('auth_required', (e) => {
|
||||
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);
|
||||
}
|
||||
handleAuthRequired(JSON.parse(e.data));
|
||||
});
|
||||
|
||||
eventSource.addEventListener('auth_completed', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
// Dismiss whichever UI path was active: auth card (OAuth) or configure modal (setup).
|
||||
removeAuthCard(data.extension_name);
|
||||
closeConfigureModal();
|
||||
showToast(data.message, data.success ? 'success' : 'error');
|
||||
// Refresh extensions list so status indicators update
|
||||
if (currentTab === 'extensions') loadExtensions();
|
||||
enableChatInput();
|
||||
handleAuthCompleted(JSON.parse(e.data));
|
||||
});
|
||||
|
||||
eventSource.addEventListener('extension_status', (e) => {
|
||||
@@ -990,7 +986,26 @@ 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';
|
||||
@@ -1003,7 +1018,7 @@ function showApproval(data) {
|
||||
|
||||
const toolName = document.createElement('div');
|
||||
toolName.className = 'approval-tool-name';
|
||||
toolName.textContent = data.tool_name;
|
||||
toolName.textContent = humanizeToolName(data.tool_name);
|
||||
card.appendChild(toolName);
|
||||
|
||||
if (data.description) {
|
||||
@@ -1106,13 +1121,71 @@ 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) {
|
||||
// Remove any existing card for this extension first
|
||||
removeAuthCard(data.extension_name);
|
||||
// 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);
|
||||
});
|
||||
|
||||
const container = document.getElementById('chat-messages');
|
||||
const card = document.createElement('div');
|
||||
card.className = 'auth-card';
|
||||
card.className = 'auth-card auth-modal';
|
||||
card.setAttribute('data-extension-name', data.extension_name);
|
||||
|
||||
const header = document.createElement('div');
|
||||
@@ -1191,21 +1264,30 @@ function showAuthCard(data) {
|
||||
actions.appendChild(cancelBtn);
|
||||
card.appendChild(actions);
|
||||
|
||||
container.appendChild(card);
|
||||
container.scrollTop = container.scrollHeight;
|
||||
overlay.appendChild(card);
|
||||
document.body.appendChild(overlay);
|
||||
tokenInput.focus();
|
||||
}
|
||||
|
||||
function removeAuthCard(extensionName) {
|
||||
const card = document.querySelector('.auth-card[data-extension-name="' + extensionName + '"]');
|
||||
if (card) card.remove();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
function submitAuthToken(extensionName, tokenValue) {
|
||||
if (!tokenValue || !tokenValue.trim()) return;
|
||||
|
||||
// Disable submit button while in flight
|
||||
const card = document.querySelector('.auth-card[data-extension-name="' + extensionName + '"]');
|
||||
const card = getAuthCard(extensionName);
|
||||
if (card) {
|
||||
const btns = card.querySelectorAll('button');
|
||||
btns.forEach((b) => { b.disabled = true; });
|
||||
@@ -1216,8 +1298,10 @@ 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);
|
||||
addMessage('system', result.message);
|
||||
enableChatInput();
|
||||
} else {
|
||||
showAuthCardError(extensionName, result.message);
|
||||
}
|
||||
@@ -1236,7 +1320,7 @@ function cancelAuth(extensionName) {
|
||||
}
|
||||
|
||||
function showAuthCardError(extensionName, message) {
|
||||
const card = document.querySelector('.auth-card[data-extension-name="' + extensionName + '"]');
|
||||
const card = getAuthCard(extensionName);
|
||||
if (!card) return;
|
||||
// Re-enable buttons
|
||||
const btns = card.querySelectorAll('button');
|
||||
@@ -2166,6 +2250,10 @@ 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);
|
||||
}
|
||||
@@ -2431,6 +2519,10 @@ 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);
|
||||
}
|
||||
@@ -2439,6 +2531,10 @@ 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) {
|
||||
@@ -2481,6 +2577,7 @@ 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();
|
||||
});
|
||||
@@ -2574,7 +2671,8 @@ function submitConfigureModal(name, fields) {
|
||||
}
|
||||
|
||||
// Disable buttons to prevent double-submit
|
||||
var btns = document.querySelectorAll('.configure-actions button');
|
||||
const overlay = getConfigureOverlay(name) || document.querySelector('.configure-overlay');
|
||||
var btns = overlay ? overlay.querySelectorAll('.configure-actions button') : [];
|
||||
btns.forEach(function(b) { b.disabled = true; });
|
||||
|
||||
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/setup', {
|
||||
@@ -2585,8 +2683,10 @@ function submitConfigureModal(name, fields) {
|
||||
if (res.success) {
|
||||
closeConfigureModal();
|
||||
if (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.
|
||||
showAuthCard({
|
||||
extension_name: name,
|
||||
auth_url: res.auth_url,
|
||||
});
|
||||
showToast('Opening OAuth authorization for ' + name, 'info');
|
||||
openOAuthUrl(res.auth_url);
|
||||
loadExtensions();
|
||||
@@ -2605,8 +2705,9 @@ function submitConfigureModal(name, fields) {
|
||||
});
|
||||
}
|
||||
|
||||
function closeConfigureModal() {
|
||||
const existing = document.querySelector('.configure-overlay');
|
||||
function closeConfigureModal(extensionName) {
|
||||
if (typeof extensionName !== 'string') extensionName = null;
|
||||
const existing = getConfigureOverlay(extensionName);
|
||||
if (existing) existing.remove();
|
||||
}
|
||||
|
||||
|
||||
@@ -1219,7 +1219,21 @@ body {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Auth card (inline in chat) */
|
||||
/* 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 {
|
||||
align-self: flex-start;
|
||||
max-width: 80%;
|
||||
@@ -1234,6 +1248,16 @@ 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);
|
||||
|
||||
+14
-26
@@ -16,6 +16,7 @@ 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};
|
||||
@@ -32,6 +33,8 @@ 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, \
|
||||
@@ -163,24 +166,27 @@ 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") {
|
||||
tracing::debug!(
|
||||
timestamp = %s,
|
||||
"parsed naive timestamp without timezone; assuming UTC for backward compatibility"
|
||||
);
|
||||
log_naive_timestamp_once();
|
||||
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") {
|
||||
tracing::debug!(
|
||||
timestamp = %s,
|
||||
"parsed naive timestamp without timezone; assuming UTC for backward compatibility"
|
||||
);
|
||||
log_naive_timestamp_once();
|
||||
return Ok(ndt.and_utc());
|
||||
}
|
||||
Err(format!("unparseable timestamp: {:?}", s))
|
||||
@@ -326,24 +332,6 @@ 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 ====================
|
||||
|
||||
@@ -523,13 +523,6 @@ 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)]
|
||||
|
||||
@@ -61,11 +61,6 @@ 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 ====================
|
||||
|
||||
+189
-6
@@ -786,6 +786,19 @@ 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
|
||||
@@ -819,6 +832,14 @@ 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
|
||||
@@ -859,6 +880,9 @@ 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
|
||||
@@ -2860,6 +2884,17 @@ 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())
|
||||
})?;
|
||||
@@ -4495,14 +4530,18 @@ mod tests {
|
||||
// available" because the ExtensionManager had `wasm_tool_runtime: None`.
|
||||
|
||||
/// Build a minimal ExtensionManager suitable for unit tests.
|
||||
fn make_test_manager(
|
||||
fn make_test_manager_with_dirs(
|
||||
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> =
|
||||
@@ -4517,15 +4556,22 @@ mod tests {
|
||||
tools,
|
||||
None, // hooks
|
||||
wasm_runtime,
|
||||
tools_dir.clone(),
|
||||
tools_dir, // channels dir (unused here)
|
||||
None, // tunnel_url
|
||||
tools_dir,
|
||||
channels_dir,
|
||||
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
|
||||
@@ -4878,6 +4924,145 @@ 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";
|
||||
@@ -5153,7 +5338,6 @@ mod tests {
|
||||
Some("https://my-gateway.example.com/oauth/callback".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Regression tests for PR #677 (unify-extension-lifecycle) ─────────
|
||||
|
||||
#[tokio::test]
|
||||
@@ -5303,7 +5487,6 @@ 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
|
||||
|
||||
@@ -672,8 +672,6 @@ 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,
|
||||
@@ -932,12 +930,6 @@ 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(())
|
||||
|
||||
+30
-10
@@ -65,7 +65,20 @@ fn install_macos() -> Result<()> {
|
||||
let stdout = logs_dir.join("daemon.stdout.log");
|
||||
let stderr = logs_dir.join("daemon.stderr.log");
|
||||
|
||||
let plist = format!(
|
||||
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!(
|
||||
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">
|
||||
@@ -81,6 +94,11 @@ fn install_macos() -> Result<()> {
|
||||
<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>
|
||||
@@ -89,15 +107,10 @@ fn install_macos() -> Result<()> {
|
||||
</plist>
|
||||
"#,
|
||||
label = SERVICE_LABEL,
|
||||
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(())
|
||||
exe = xml_escape(exe),
|
||||
stdout = xml_escape(stdout),
|
||||
stderr = xml_escape(stderr),
|
||||
)
|
||||
}
|
||||
|
||||
fn install_linux() -> Result<()> {
|
||||
@@ -356,4 +369,11 @@ 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>"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,10 +397,6 @@ 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
|
||||
}
|
||||
|
||||
+34
-32
@@ -398,7 +398,7 @@ impl Tool for HttpTool {
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": ["GET", "POST", "PUT", "DELETE", "PATCH"],
|
||||
"description": "HTTP method"
|
||||
"description": "HTTP method (default: GET)"
|
||||
},
|
||||
"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": ["method", "url"]
|
||||
"required": ["url"]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -440,7 +440,7 @@ impl Tool for HttpTool {
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let method = require_str(¶ms, "method")?;
|
||||
let method = params["method"].as_str().unwrap_or("GET");
|
||||
let method_upper = method.to_uppercase();
|
||||
|
||||
let url = require_str(¶ms, "url")?;
|
||||
@@ -829,18 +829,22 @@ impl Tool for HttpTool {
|
||||
}
|
||||
|
||||
fn requires_approval(&self, params: &serde_json::Value) -> ApprovalRequirement {
|
||||
// 1. Manual auth headers/query params in LLM params
|
||||
if crate::safety::params_contain_manual_credentials(params) {
|
||||
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 {
|
||||
return ApprovalRequirement::Always;
|
||||
}
|
||||
// 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;
|
||||
|
||||
// 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;
|
||||
}
|
||||
// Default: outbound HTTP still needs approval unless auto-approved
|
||||
|
||||
ApprovalRequirement::UnlessAutoApproved
|
||||
}
|
||||
|
||||
@@ -1063,12 +1067,22 @@ mod tests {
|
||||
// ── Approval requirement tests ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_no_auth_headers_returns_unless_auto_approved() {
|
||||
fn test_get_no_auth_headers_returns_never() {
|
||||
let tool = HttpTool::new();
|
||||
let params = serde_json::json!({
|
||||
"method": "GET",
|
||||
"url": "https://api.example.com/data"
|
||||
});
|
||||
assert_eq!(tool.requires_approval(¶ms), 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(¶ms),
|
||||
ApprovalRequirement::UnlessAutoApproved
|
||||
@@ -1152,21 +1166,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_auth_headers_return_unless_auto_approved() {
|
||||
fn test_get_non_auth_headers_return_never() {
|
||||
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(¶ms),
|
||||
ApprovalRequirement::UnlessAutoApproved
|
||||
);
|
||||
assert_eq!(tool.requires_approval(¶ms), ApprovalRequirement::Never);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_headers_return_unless_auto_approved() {
|
||||
fn test_get_empty_headers_return_never() {
|
||||
let tool = HttpTool::new();
|
||||
|
||||
// Empty object
|
||||
@@ -1175,10 +1186,7 @@ mod tests {
|
||||
"url": "https://example.com",
|
||||
"headers": {}
|
||||
});
|
||||
assert_eq!(
|
||||
tool.requires_approval(¶ms),
|
||||
ApprovalRequirement::UnlessAutoApproved
|
||||
);
|
||||
assert_eq!(tool.requires_approval(¶ms), ApprovalRequirement::Never);
|
||||
|
||||
// Empty array
|
||||
let params = serde_json::json!({
|
||||
@@ -1186,10 +1194,7 @@ mod tests {
|
||||
"url": "https://example.com",
|
||||
"headers": []
|
||||
});
|
||||
assert_eq!(
|
||||
tool.requires_approval(¶ms),
|
||||
ApprovalRequirement::UnlessAutoApproved
|
||||
);
|
||||
assert_eq!(tool.requires_approval(¶ms), ApprovalRequirement::Never);
|
||||
}
|
||||
|
||||
// ── Credential registry approval tests ─────────────────────────────
|
||||
@@ -1219,7 +1224,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_host_without_credential_mapping_returns_unless_auto_approved() {
|
||||
fn test_get_host_without_credential_mapping_returns_never() {
|
||||
use crate::tools::wasm::SharedCredentialRegistry;
|
||||
|
||||
let registry = Arc::new(SharedCredentialRegistry::new());
|
||||
@@ -1231,10 +1236,7 @@ mod tests {
|
||||
"method": "GET",
|
||||
"url": "https://api.example.com/data"
|
||||
});
|
||||
assert_eq!(
|
||||
tool.requires_approval(¶ms),
|
||||
ApprovalRequirement::UnlessAutoApproved
|
||||
);
|
||||
assert_eq!(tool.requires_approval(¶ms), ApprovalRequirement::Never);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -8,7 +8,7 @@ use secrecy::{ExposeSecret, SecretString};
|
||||
|
||||
use crate::context::JobContext;
|
||||
use crate::tools::builtin::path_utils::validate_path;
|
||||
use crate::tools::tool::{ApprovalRequirement, Tool, ToolError, ToolOutput};
|
||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
||||
|
||||
/// Tool for analyzing images using a vision-capable model.
|
||||
pub struct ImageAnalyzeTool {
|
||||
@@ -86,10 +86,6 @@ impl Tool for ImageAnalyzeTool {
|
||||
})
|
||||
}
|
||||
|
||||
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
|
||||
ApprovalRequirement::UnlessAutoApproved
|
||||
}
|
||||
|
||||
fn requires_sanitization(&self) -> bool {
|
||||
true
|
||||
}
|
||||
@@ -185,6 +181,7 @@ impl Tool for ImageAnalyzeTool {
|
||||
mod tests {
|
||||
use super::super::media_type_from_path;
|
||||
use super::*;
|
||||
use crate::tools::tool::ApprovalRequirement;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
@@ -199,7 +196,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_requires_approval_returns_unless_auto_approved() {
|
||||
fn test_requires_approval_returns_never() {
|
||||
let tool = ImageAnalyzeTool::new(
|
||||
"https://api.example.com".to_string(),
|
||||
"test-key".to_string(),
|
||||
@@ -208,7 +205,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
tool.requires_approval(&serde_json::json!({})),
|
||||
ApprovalRequirement::UnlessAutoApproved
|
||||
ApprovalRequirement::Never
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ use secrecy::{ExposeSecret, SecretString};
|
||||
|
||||
use crate::context::JobContext;
|
||||
use crate::tools::builtin::path_utils::validate_path;
|
||||
use crate::tools::tool::{ApprovalRequirement, Tool, ToolError, ToolOutput};
|
||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
||||
|
||||
/// Tool for editing images using an AI image editing API.
|
||||
pub struct ImageEditTool {
|
||||
@@ -85,10 +85,6 @@ impl Tool for ImageEditTool {
|
||||
})
|
||||
}
|
||||
|
||||
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
|
||||
ApprovalRequirement::UnlessAutoApproved
|
||||
}
|
||||
|
||||
fn requires_sanitization(&self) -> bool {
|
||||
false
|
||||
}
|
||||
@@ -266,6 +262,7 @@ impl ImageEditTool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tools::tool::ApprovalRequirement;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
@@ -280,7 +277,7 @@ mod tests {
|
||||
assert!(!tool.requires_sanitization());
|
||||
assert_eq!(
|
||||
tool.requires_approval(&serde_json::json!({})),
|
||||
ApprovalRequirement::UnlessAutoApproved
|
||||
ApprovalRequirement::Never
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ 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.
|
||||
@@ -87,10 +86,6 @@ impl Tool for ImageGenerateTool {
|
||||
})
|
||||
}
|
||||
|
||||
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
|
||||
ApprovalRequirement::UnlessAutoApproved
|
||||
}
|
||||
|
||||
fn requires_sanitization(&self) -> bool {
|
||||
false
|
||||
}
|
||||
@@ -186,6 +181,7 @@ impl Tool for ImageGenerateTool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tools::tool::ApprovalRequirement;
|
||||
|
||||
#[test]
|
||||
fn test_tool_metadata() {
|
||||
@@ -197,7 +193,7 @@ mod tests {
|
||||
assert_eq!(tool.name(), "image_generate");
|
||||
assert_eq!(
|
||||
tool.requires_approval(&serde_json::json!({})),
|
||||
ApprovalRequirement::UnlessAutoApproved
|
||||
ApprovalRequirement::Never
|
||||
);
|
||||
|
||||
let schema = tool.parameters_schema();
|
||||
|
||||
+64
-64
@@ -540,8 +540,8 @@ impl Tool for MemoryTreeTool {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod path_routing_tests {
|
||||
use super::looks_like_filesystem_path;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detects_filesystem_paths() {
|
||||
@@ -557,82 +557,82 @@ mod path_routing_tests {
|
||||
assert!(!looks_like_filesystem_path("daily/2026-03-11.md"));
|
||||
assert!(!looks_like_filesystem_path("projects/alpha/notes.md"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "postgres"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[cfg(feature = "postgres")]
|
||||
mod postgres_schema_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,
|
||||
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(),
|
||||
))
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ 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::{
|
||||
@@ -39,6 +40,7 @@ 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;
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
//! 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(¶ms, "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(®istry));
|
||||
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(®istry));
|
||||
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(®istry));
|
||||
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(®istry));
|
||||
drop(registry);
|
||||
|
||||
let ctx = JobContext::default();
|
||||
let result = tool
|
||||
.execute(serde_json::json!({"name": "echo"}), &ctx)
|
||||
.await;
|
||||
assert!(matches!(result, Err(ToolError::ExecutionFailed(_))));
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,7 @@ const PROTECTED_TOOL_NAMES: &[&str] = &[
|
||||
"image_generate",
|
||||
"image_edit",
|
||||
"image_analyze",
|
||||
"tool_info",
|
||||
];
|
||||
|
||||
/// Registry of available tools.
|
||||
@@ -245,6 +246,17 @@ 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:
|
||||
|
||||
@@ -336,6 +336,17 @@ 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 {
|
||||
|
||||
+6
-84
@@ -1,7 +1,5 @@
|
||||
//! WASM sandbox error types.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors that can occur during WASM tool execution.
|
||||
@@ -68,13 +66,13 @@ pub enum WasmError {
|
||||
Timeout(std::time::Duration),
|
||||
|
||||
/// Component returned an error response.
|
||||
/// When `hint` is non-empty it carries the tool's description and parameter
|
||||
/// schema so the LLM can retry with correct arguments.
|
||||
/// When `hint` is non-empty it points the LLM to `tool_info` so it can
|
||||
/// fetch the tool's full parameter schema on demand.
|
||||
#[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 description + schema hint (empty when unavailable).
|
||||
/// Optional retry hint (empty when unavailable).
|
||||
hint: String,
|
||||
},
|
||||
|
||||
@@ -99,73 +97,9 @@ 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::{TrapCode, TrapInfo, WasmError};
|
||||
use crate::tools::wasm::error::WasmError;
|
||||
|
||||
#[test]
|
||||
fn test_error_display() {
|
||||
@@ -180,17 +114,6 @@ 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());
|
||||
@@ -218,12 +141,11 @@ mod tests {
|
||||
fn test_tool_returned_error_with_hint() {
|
||||
let err = WasmError::ToolReturnedError {
|
||||
message: "unknown action: foobar".to_string(),
|
||||
hint: "Description: Gmail tool\nParameters schema: {\"type\":\"object\"}".to_string(),
|
||||
hint: "Tip: call tool_info(name: \"gmail\", include_schema: true) for the full parameter schema.".to_string(),
|
||||
};
|
||||
let display = err.to_string();
|
||||
assert!(display.contains("unknown action: foobar"));
|
||||
assert!(display.contains("Tool usage hint"));
|
||||
assert!(display.contains("Gmail tool"));
|
||||
assert!(display.contains("Parameters schema"));
|
||||
assert!(display.contains("tool_info"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,14 +67,8 @@ 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 {
|
||||
@@ -87,9 +81,7 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ pub(crate) mod storage;
|
||||
mod wrapper;
|
||||
|
||||
// Core types
|
||||
pub use error::{TrapCode, TrapInfo, WasmError};
|
||||
pub use error::WasmError;
|
||||
pub use host::{HostState, LogEntry, LogLevel};
|
||||
pub use limits::{
|
||||
DEFAULT_FUEL_LIMIT, DEFAULT_MEMORY_LIMIT, DEFAULT_TIMEOUT, FuelConfig, ResourceLimits,
|
||||
|
||||
+26
-41
@@ -123,7 +123,9 @@ pub struct PreparedModule {
|
||||
pub name: String,
|
||||
/// Tool description (cached from component).
|
||||
pub description: String,
|
||||
/// Parameter schema JSON (cached from component).
|
||||
/// 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.
|
||||
pub schema: serde_json::Value,
|
||||
/// Pre-compiled component (cheaply cloneable via internal Arc).
|
||||
component: wasmtime::component::Component,
|
||||
@@ -265,11 +267,29 @@ impl WasmToolRuntime {
|
||||
let component = wasmtime::component::Component::new(&engine, &wasm_bytes)
|
||||
.map_err(|e| WasmError::CompilationFailed(e.to_string()))?;
|
||||
|
||||
// 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)?;
|
||||
// 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
|
||||
}),
|
||||
)
|
||||
});
|
||||
|
||||
Ok::<_, WasmError>(PreparedModule {
|
||||
name: name.clone(),
|
||||
@@ -321,41 +341,6 @@ 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")
|
||||
|
||||
+271
-53
@@ -464,9 +464,10 @@ 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,
|
||||
/// Cached schema (from PreparedModule or override).
|
||||
schema: serde_json::Value,
|
||||
/// Compact and discovery schemas for this tool.
|
||||
schemas: WasmToolSchemas,
|
||||
/// Injected credentials for HTTP requests (e.g., OAuth tokens).
|
||||
/// Keys are placeholder names like "GOOGLE_ACCESS_TOKEN".
|
||||
credentials: HashMap<String, String>,
|
||||
@@ -477,6 +478,84 @@ 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(
|
||||
@@ -486,7 +565,7 @@ impl WasmToolWrapper {
|
||||
) -> Self {
|
||||
Self {
|
||||
description: prepared.description.clone(),
|
||||
schema: prepared.schema.clone(),
|
||||
schemas: WasmToolSchemas::new(prepared.schema.clone()),
|
||||
runtime,
|
||||
prepared,
|
||||
capabilities,
|
||||
@@ -504,7 +583,7 @@ impl WasmToolWrapper {
|
||||
|
||||
/// Override the parameter schema.
|
||||
pub fn with_schema(mut self, schema: serde_json::Value) -> Self {
|
||||
self.schema = schema;
|
||||
self.schemas = self.schemas.with_override(schema);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -615,9 +694,18 @@ 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, &self.schema);
|
||||
let params = coerce_params_to_schema(params, &effective_schema);
|
||||
|
||||
// Prepare the request
|
||||
let params_json = serde_json::to_string(¶ms)
|
||||
@@ -629,7 +717,6 @@ 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") {
|
||||
@@ -644,12 +731,13 @@ impl WasmToolWrapper {
|
||||
// Get logs from host state
|
||||
let logs = store.data_mut().host_state.take_logs();
|
||||
|
||||
// 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.
|
||||
// Check for tool-level error — point the LLM to tool_info for the
|
||||
// full schema instead of dumping ~3.5KB inline.
|
||||
if let Some(err) = response.error {
|
||||
let hint = build_tool_hint(tool_iface, &mut store);
|
||||
let hint = format!(
|
||||
"Tip: call tool_info(name: \"{}\", include_schema: true) for the full parameter schema.",
|
||||
self.prepared.name
|
||||
);
|
||||
return Err(WasmError::ToolReturnedError { message: err, hint });
|
||||
}
|
||||
|
||||
@@ -658,47 +746,55 @@ impl WasmToolWrapper {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
/// 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);
|
||||
|
||||
/// 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)
|
||||
// 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)
|
||||
.ok()
|
||||
.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
|
||||
.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))
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -712,7 +808,33 @@ impl Tool for WasmToolWrapper {
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
self.schema.clone()
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
@@ -749,7 +871,7 @@ impl Tool for WasmToolWrapper {
|
||||
let prepared = Arc::clone(&self.prepared);
|
||||
let capabilities = self.capabilities.clone();
|
||||
let description = self.description.clone();
|
||||
let schema = self.schema.clone();
|
||||
let schemas = self.schemas.clone();
|
||||
let credentials = self.credentials.clone();
|
||||
|
||||
// Execute in blocking task with timeout
|
||||
@@ -759,7 +881,7 @@ impl Tool for WasmToolWrapper {
|
||||
prepared,
|
||||
capabilities,
|
||||
description,
|
||||
schema,
|
||||
schemas,
|
||||
credentials,
|
||||
secrets_store: None, // Not needed in blocking task
|
||||
oauth_refresh: None, // Already used above for pre-refresh
|
||||
@@ -1232,6 +1354,7 @@ 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};
|
||||
|
||||
@@ -1246,6 +1369,84 @@ 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();
|
||||
@@ -1788,6 +1989,23 @@ 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
|
||||
|
||||
+77
-1
@@ -20,9 +20,31 @@ 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."""
|
||||
@@ -70,7 +92,53 @@ async def mock_llm_server():
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def ironclaw_server(ironclaw_binary, mock_llm_server):
|
||||
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):
|
||||
"""Start the ironclaw gateway. Yields the base URL."""
|
||||
gateway_port = _find_free_port()
|
||||
env = {
|
||||
@@ -95,8 +163,16 @@ async def ironclaw_server(ironclaw_binary, mock_llm_server):
|
||||
"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).
|
||||
|
||||
@@ -133,3 +133,32 @@ 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,
|
||||
)
|
||||
|
||||
+181
-56
@@ -1,11 +1,16 @@
|
||||
"""Mock OpenAI-compatible LLM server for E2E tests."""
|
||||
"""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.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
CANNED_RESPONSES = [
|
||||
@@ -13,85 +18,207 @@ 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 match_response(messages: list[dict]) -> str:
|
||||
"""Find canned response for the last user message."""
|
||||
|
||||
def _last_user_content(messages: list[dict]) -> str:
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user":
|
||||
content = msg.get("content", "")
|
||||
# Handle content that may be a list (multi-modal)
|
||||
if isinstance(content, list):
|
||||
content = " ".join(
|
||||
part.get("text", "") for part in content if part.get("type") == "text"
|
||||
p.get("text", "") for p in content if p.get("type") == "text"
|
||||
)
|
||||
for pattern, response in CANNED_RESPONSES:
|
||||
if pattern.search(content):
|
||||
return response
|
||||
return DEFAULT_RESPONSE
|
||||
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
|
||||
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."""
|
||||
"""Handle POST /v1/chat/completions and /chat/completions."""
|
||||
body = await request.json()
|
||||
messages = body.get("messages", [])
|
||||
stream = body.get("stream", False)
|
||||
response_text = match_response(messages)
|
||||
completion_id = f"mock-{uuid.uuid4().hex[:8]}"
|
||||
has_tools = bool(body.get("tools"))
|
||||
cid = 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 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},
|
||||
})
|
||||
return _text_response(cid, text)
|
||||
return await _stream_text(request, cid, text)
|
||||
|
||||
# Streaming response: split into word-boundary chunks
|
||||
resp = web.StreamResponse(
|
||||
status=200,
|
||||
headers={"Content-Type": "text/event-stream", "Cache-Control": "no-cache"},
|
||||
)
|
||||
await resp.prepare(request)
|
||||
|
||||
# First chunk: role
|
||||
chunk = {
|
||||
"id": completion_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": int(time.time()),
|
||||
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, "delta": {"role": "assistant", "content": ""}, "finish_reason": None}],
|
||||
}
|
||||
await resp.write(f"data: {json.dumps(chunk)}\n\n".encode())
|
||||
"choices": [{"index": 0, "message": {"role": "assistant", "content": text},
|
||||
"finish_reason": "stop"}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": len(text.split()), "total_tokens": 15},
|
||||
})
|
||||
|
||||
# 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
|
||||
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"})
|
||||
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)
|
||||
chunk["choices"][0]["delta"] = {}
|
||||
chunk["choices"][0]["finish_reason"] = "stop"
|
||||
await resp.write(f"data: {json.dumps(chunk)}\n\n".encode())
|
||||
await _send_sse(resp, chunk)
|
||||
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"}],
|
||||
@@ -102,23 +229,21 @@ 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)
|
||||
|
||||
# Use aiohttp's runner to get the actual bound port
|
||||
import asyncio
|
||||
app.router.add_get("/models", models)
|
||||
app.router.add_post("/oauth/exchange", oauth_exchange)
|
||||
|
||||
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())
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
"""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"
|
||||
@@ -458,6 +458,37 @@ 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):
|
||||
@@ -612,7 +643,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 via window.open."""
|
||||
"""Save response with auth_url opens a popup and shows the global auth prompt."""
|
||||
await page.evaluate("window.open = (url) => { window._lastOpenedUrl = url; }")
|
||||
|
||||
async def handle_setup(route):
|
||||
@@ -639,6 +670,9 @@ 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):
|
||||
@@ -699,7 +733,7 @@ async def test_configure_modal_enter_key_submits(page):
|
||||
# ─── Group H: Auth card (SSE-triggered) ───────────────────────────────────────
|
||||
|
||||
async def _show_auth_card(page, **kwargs):
|
||||
"""Inject an auth card via JS and wait for it to appear."""
|
||||
"""Inject the global auth prompt 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)
|
||||
@@ -812,12 +846,43 @@ 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_multiple_extensions_coexist(page):
|
||||
"""Auth cards for different extensions can coexist."""
|
||||
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."""
|
||||
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"]).nth(1).wait_for(state="visible", timeout=3000)
|
||||
assert await page.locator(SEL["auth_card"]).count() == 2
|
||||
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
|
||||
|
||||
|
||||
async def test_auth_completed_sse_dismisses_card(page):
|
||||
@@ -826,13 +891,95 @@ async def test_auth_completed_sse_dismisses_card(page):
|
||||
|
||||
# Simulate the auth_completed SSE event being fired
|
||||
await page.evaluate("""
|
||||
// Call the handler the same way the SSE listener does
|
||||
removeAuthCard('myext');
|
||||
handleAuthCompleted({
|
||||
extension_name: 'myext',
|
||||
success: true,
|
||||
message: 'Authenticated!',
|
||||
});
|
||||
""")
|
||||
|
||||
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):
|
||||
@@ -902,8 +1049,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(page):
|
||||
"""Activate response with auth_url calls window.open."""
|
||||
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."""
|
||||
await page.evaluate("window.open = (url) => { window._lastOpenedUrl = url; }")
|
||||
await mock_ext_apis(page, installed=[_MCP_INACTIVE])
|
||||
|
||||
@@ -921,6 +1068,9 @@ async def test_activate_with_auth_url_opens_popup(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 ────────────────────────────────────────────
|
||||
@@ -947,9 +1097,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)
|
||||
@@ -990,19 +1140,20 @@ 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 by calling loadExtensions directly (as the SSE handler does)
|
||||
# Simulate auth_completed via the shared handler.
|
||||
await page.evaluate("""
|
||||
// Simulate what the auth_completed SSE handler does when currentTab === 'extensions'
|
||||
if (typeof loadExtensions === 'function') {
|
||||
loadExtensions();
|
||||
}
|
||||
handleAuthCompleted({
|
||||
extension_name: 'reload-ext',
|
||||
success: true,
|
||||
message: 'Reloaded.',
|
||||
});
|
||||
""")
|
||||
|
||||
await page.wait_for_timeout(600)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""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
|
||||
@@ -0,0 +1,94 @@
|
||||
"""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}"
|
||||
)
|
||||
@@ -0,0 +1,517 @@
|
||||
"""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)
|
||||
@@ -457,4 +457,90 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -13,6 +13,8 @@ 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,
|
||||
@@ -147,4 +149,115 @@ 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,6 +1,41 @@
|
||||
{
|
||||
"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": [
|
||||
|
||||
Reference in New Issue
Block a user