mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 23:16:26 +00:00
Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a5ed3d961 | ||
|
|
579c4fdbca | ||
|
|
1770663279 | ||
|
|
8fb2f70258 | ||
|
|
c916069dd2 | ||
|
|
757d24bd90 | ||
|
|
f9b880c2e9 | ||
|
|
2b625ef3df | ||
|
|
7d745d5479 | ||
|
|
1bc10fe4ca | ||
|
|
f53c1bb10b | ||
|
|
bc6725205a | ||
|
|
275bcfb658 | ||
|
|
7776d267f8 | ||
|
|
e805ec61aa | ||
|
|
1e00b1fed5 | ||
|
+12 |
5e7758598f | ||
|
|
c47237b9c7 | ||
|
|
a89cf37993 | ||
|
|
3c619b6272 | ||
|
|
15c5d3e2e2 | ||
|
|
cd1245afc0 | ||
|
|
9fbdd42988 | ||
|
|
8a60fa2d37 | ||
|
|
c7dec64b2d | ||
|
|
c54f739354 | ||
|
|
1ba6a83ca4 | ||
|
|
d8bcfe15cf | ||
|
|
6f00490900 | ||
|
|
e522d33a53 | ||
|
|
7a9cbb3b50 | ||
|
|
442a42d996 | ||
|
|
ee849d391a | ||
|
|
9aefa98139 | ||
|
|
ce4dec73fc | ||
|
|
bbb5321f34 | ||
|
|
c7f6fbc161 | ||
|
|
6b3fcabad2 | ||
|
|
476372bbb1 | ||
|
|
6fc821864e | ||
|
|
ad81f25238 | ||
|
|
34643fc168 | ||
|
|
ed5f110742 | ||
|
|
2a05dd2d13 |
@@ -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
|
||||
@@ -78,15 +78,70 @@ jobs:
|
||||
- name: Check lints
|
||||
run: cargo clippy --all --benches --tests --examples ${{ matrix.flags }} -- -D warnings
|
||||
|
||||
no-panics:
|
||||
name: No panics in production code
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Check for .unwrap(), .expect(), assert!() in production code
|
||||
run: |
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
# Get the full diff for .rs files (production only, exclude tests/ directory)
|
||||
DIFF=$(git diff "$BASE"...HEAD -- 'src/**/*.rs' 'crates/**/*.rs' || true)
|
||||
|
||||
if [ -z "$DIFF" ]; then
|
||||
echo "No production Rust changes detected."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract added lines, skipping those inside test modules.
|
||||
# Track whether we're inside a test module by watching hunk headers
|
||||
# (lines starting with @@) whose context contains "mod tests" or "#[cfg(test)]".
|
||||
ADDED=$(echo "$DIFF" | awk '
|
||||
/^@@/ {
|
||||
# Hunk context (after the second @@) tells us the function/module scope
|
||||
in_test = (tolower($0) ~ /mod tests/ || $0 ~ /#\[cfg\(test\)\]/ || $0 ~ /#\[test\]/)
|
||||
}
|
||||
/^\+[^+]/ && !in_test { print }
|
||||
' || true)
|
||||
|
||||
if [ -z "$ADDED" ]; then
|
||||
echo "No production Rust changes detected (test-only changes excluded)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Match panic-inducing patterns, excluding safety suppressions
|
||||
VIOLATIONS=$(echo "$ADDED" \
|
||||
| grep -E '\.(unwrap|expect)\(|[^_]assert(_eq|_ne)?!' \
|
||||
| grep -Ev 'debug_assert|// safety:' \
|
||||
|| true)
|
||||
|
||||
if [ -n "$VIOLATIONS" ]; then
|
||||
echo "::error::Found .unwrap(), .expect(), or assert!() in production code."
|
||||
echo "Production code must use proper error handling instead of panicking."
|
||||
echo "Suppress false positives with an inline '// safety: <reason>' comment."
|
||||
echo ""
|
||||
echo "$VIOLATIONS" | head -20
|
||||
echo ""
|
||||
COUNT=$(echo "$VIOLATIONS" | wc -l | tr -d ' ')
|
||||
echo "Total: $COUNT violation(s)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "OK: No panic-inducing calls in changed production code."
|
||||
|
||||
# Roll-up job for branch protection
|
||||
code-style:
|
||||
name: Code Style (fmt + clippy + deny)
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
needs: [format, clippy, clippy-windows, deny-check]
|
||||
needs: [format, clippy, clippy-windows, deny-check, no-panics]
|
||||
steps:
|
||||
- run: |
|
||||
if [[ "${{ needs.format.result }}" != "success" || "${{ needs.clippy.result }}" != "success" || "${{ needs.deny-check.result }}" != "success" ]]; then
|
||||
if [[ "${{ needs.format.result }}" != "success" || "${{ needs.clippy.result }}" != "success" || "${{ needs.deny-check.result }}" != "success" || "${{ needs.no-panics.result }}" != "success" ]]; then
|
||||
echo "One or more jobs failed"
|
||||
exit 1
|
||||
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 tests/e2e/scenarios/test_oauth_credential_fallback.py tests/e2e/scenarios/test_routine_oauth_credential_injection.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,78 @@
|
||||
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 workflow source
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# For chained promotion PRs, the script lives on the trusted PR head,
|
||||
# not necessarily on the older promotion branch used as the PR base.
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && 'main' || github.event.pull_request.head.sha }}
|
||||
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
|
||||
@@ -14,6 +14,10 @@
|
||||
|
||||
target/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Benchmark results (local runs, not committed)
|
||||
bench-results/
|
||||
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -132,7 +132,7 @@ fn embed_registry_catalog(root: &Path) {
|
||||
// No registry dir: write empty catalog
|
||||
fs::write(
|
||||
&out_path,
|
||||
r#"{"tools":[],"channels":[],"bundles":{"bundles":{}}}"#,
|
||||
r#"{"tools":[],"channels":[],"mcp_servers":[],"bundles":{"bundles":{}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
return;
|
||||
@@ -140,6 +140,7 @@ fn embed_registry_catalog(root: &Path) {
|
||||
|
||||
let mut tools = Vec::new();
|
||||
let mut channels = Vec::new();
|
||||
let mut mcp_servers = Vec::new();
|
||||
|
||||
// Collect tool manifests
|
||||
let tools_dir = registry_dir.join("tools");
|
||||
@@ -153,6 +154,12 @@ fn embed_registry_catalog(root: &Path) {
|
||||
collect_json_files(&channels_dir, &mut channels);
|
||||
}
|
||||
|
||||
// Collect MCP server manifests
|
||||
let mcp_servers_dir = registry_dir.join("mcp-servers");
|
||||
if mcp_servers_dir.is_dir() {
|
||||
collect_json_files(&mcp_servers_dir, &mut mcp_servers);
|
||||
}
|
||||
|
||||
// Read bundles
|
||||
let bundles_path = registry_dir.join("_bundles.json");
|
||||
let bundles_raw = if bundles_path.is_file() {
|
||||
@@ -163,9 +170,10 @@ fn embed_registry_catalog(root: &Path) {
|
||||
|
||||
// Build the combined JSON
|
||||
let catalog = format!(
|
||||
r#"{{"tools":[{}],"channels":[{}],"bundles":{}}}"#,
|
||||
r#"{{"tools":[{}],"channels":[{}],"mcp_servers":[{}],"bundles":{}}}"#,
|
||||
tools.join(","),
|
||||
channels.join(","),
|
||||
mcp_servers.join(","),
|
||||
bundles_raw,
|
||||
);
|
||||
|
||||
|
||||
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);
|
||||
|
||||
@@ -6,6 +6,12 @@ rust-version = "1.92"
|
||||
description = "Prompt injection defense, input validation, secret leak detection, and safety policy enforcement"
|
||||
authors = ["NEAR AI <[email protected]>"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
homepage = "https://github.com/nearai/ironclaw"
|
||||
repository = "https://github.com/nearai/ironclaw"
|
||||
publish = false
|
||||
|
||||
[package.metadata.dist]
|
||||
dist = false
|
||||
|
||||
[dependencies]
|
||||
aho-corasick = "1"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "discord",
|
||||
"display_name": "Discord Channel",
|
||||
"kind": "channel",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"wit_version": "0.3.0",
|
||||
"description": "Talk to your agent in Discord",
|
||||
"keywords": [
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/discord-0.2.0-wasm32-wasip2.tar.gz",
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/discord-0.2.0-wasm32-wasip2.tar.gz",
|
||||
"sha256": "efa1b9019fa33e243f8db1e1fcc732731d45836336bdd26ca19b6fe227ca8b69"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-0.2.1-wasm32-wasip2.tar.gz",
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/slack-0.2.1-wasm32-wasip2.tar.gz",
|
||||
"sha256": "d4667e35126986509d862bc3a0088777305d8f41c75de83c1e223b42312ede48"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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/download/v0.18.0/telegram-0.2.3-wasm32-wasip2.tar.gz",
|
||||
"sha256": "b9a83d5a2d1285ce0ec116b354336a1f245f893291ccb01dffbcaccf89d72aed"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/whatsapp-0.2.0-wasm32-wasip2.tar.gz",
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/whatsapp-0.2.0-wasm32-wasip2.tar.gz",
|
||||
"sha256": "feb9194719d9bed796b070ab4dc30348dbfb5d3dec56f9f21e02d14137abab01"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "asana",
|
||||
"display_name": "Asana",
|
||||
"kind": "mcp_server",
|
||||
"description": "Connect to Asana for task management, projects, and team coordination",
|
||||
"keywords": ["tasks", "projects", "management", "team"],
|
||||
"url": "https://mcp.asana.com/v2/mcp",
|
||||
"auth": "dcr"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "cloudflare",
|
||||
"display_name": "Cloudflare",
|
||||
"kind": "mcp_server",
|
||||
"description": "Connect to Cloudflare for DNS, Workers, KV, and infrastructure management",
|
||||
"keywords": ["cdn", "dns", "workers", "hosting", "infrastructure"],
|
||||
"url": "https://mcp.cloudflare.com/mcp",
|
||||
"auth": "dcr"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "intercom",
|
||||
"display_name": "Intercom",
|
||||
"kind": "mcp_server",
|
||||
"description": "Connect to Intercom for customer messaging, support, and engagement",
|
||||
"keywords": ["support", "customers", "messaging", "chat", "helpdesk"],
|
||||
"url": "https://mcp.intercom.com/mcp",
|
||||
"auth": "dcr"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "linear",
|
||||
"display_name": "Linear",
|
||||
"kind": "mcp_server",
|
||||
"description": "Connect to Linear for issue tracking, project management, and team workflows",
|
||||
"keywords": ["issues", "tickets", "project", "tracking", "bugs"],
|
||||
"url": "https://mcp.linear.app/sse",
|
||||
"auth": "dcr"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "notion",
|
||||
"display_name": "Notion",
|
||||
"kind": "mcp_server",
|
||||
"description": "Connect to Notion for reading and writing pages, databases, and comments",
|
||||
"keywords": ["notes", "wiki", "docs", "pages", "database"],
|
||||
"url": "https://mcp.notion.com/mcp",
|
||||
"auth": "dcr"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "sentry",
|
||||
"display_name": "Sentry",
|
||||
"kind": "mcp_server",
|
||||
"description": "Connect to Sentry for error tracking, performance monitoring, and debugging",
|
||||
"keywords": ["errors", "monitoring", "debugging", "crashes", "performance"],
|
||||
"url": "https://mcp.sentry.dev/mcp",
|
||||
"auth": "dcr"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "stripe",
|
||||
"display_name": "Stripe",
|
||||
"kind": "mcp_server",
|
||||
"description": "Connect to Stripe for payment processing, subscriptions, and financial data",
|
||||
"keywords": ["payments", "billing", "subscriptions", "invoices", "finance"],
|
||||
"url": "https://mcp.stripe.com",
|
||||
"auth": "dcr"
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/github-0.2.0-wasm32-wasip2.tar.gz",
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/github-0.2.0-wasm32-wasip2.tar.gz",
|
||||
"sha256": "da9fac56b6f20197a415489bbaec9fefb085a5cf6324cab79ea48a47eb19c13b"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/gmail-0.2.0-wasm32-wasip2.tar.gz",
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/gmail-0.2.0-wasm32-wasip2.tar.gz",
|
||||
"sha256": "ee9574e02e92bc1d481f1310eb88afd99ee52bf6971074ab33bd76bf99b34b1d"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-calendar-0.2.0-wasm32-wasip2.tar.gz",
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/google-calendar-0.2.0-wasm32-wasip2.tar.gz",
|
||||
"sha256": "2fa47150ea222e787c122182ad6f4dfa2ffaf5fe490d05e8de887a76445f8d2d"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-docs-0.2.0-wasm32-wasip2.tar.gz",
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/google-docs-0.2.0-wasm32-wasip2.tar.gz",
|
||||
"sha256": "40e134a1c1564f832ca861c3396895d4e33ec67b99313fc1f97baf8d971423a9"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-drive-0.2.0-wasm32-wasip2.tar.gz",
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/google-drive-0.2.0-wasm32-wasip2.tar.gz",
|
||||
"sha256": "002a341a1d58125563a7c69561b26fbc2629b04ea723cade744102bdc0fbb71f"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-sheets-0.2.0-wasm32-wasip2.tar.gz",
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/google-sheets-0.2.0-wasm32-wasip2.tar.gz",
|
||||
"sha256": "8aa2c9d52f033edea3a6c2311b0ec694ccb6d0a54ef07e94d72bf8be1ce8009a"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-slides-0.2.0-wasm32-wasip2.tar.gz",
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/google-slides-0.2.0-wasm32-wasip2.tar.gz",
|
||||
"sha256": "e931a97d4fd0b0b938e464dc7c7f2be6ea6b4d1508f5ea3cd931d44db23f05f5"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
},
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-0.2.0-wasm32-wasip2.tar.gz",
|
||||
"sha256": "8af3f884240de8413d272845fad2164a347d7d2a502a0d148aa38425b93f62ed"
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/slack-0.2.1-wasm32-wasip2.tar.gz",
|
||||
"sha256": "d4667e35126986509d862bc3a0088777305d8f41c75de83c1e223b42312ede48"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
},
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-0.2.0-wasm32-wasip2.tar.gz",
|
||||
"sha256": "2c66245913854be4294021fc6bb479e43f7d65830c5cec25cf6c60a71d1af468"
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/telegram-0.2.2-wasm32-wasip2.tar.gz",
|
||||
"sha256": "b9a83d5a2d1285ce0ec116b354336a1f245f893291ccb01dffbcaccf89d72aed"
|
||||
}
|
||||
},
|
||||
"auth_summary": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "web-search",
|
||||
"display_name": "Web Search",
|
||||
"kind": "tool",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"wit_version": "0.3.0",
|
||||
"description": "Search the web using Brave Search API",
|
||||
"keywords": [
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/web-search-0.2.0-wasm32-wasip2.tar.gz",
|
||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/web-search-0.2.0-wasm32-wasip2.tar.gz",
|
||||
"sha256": "56834573c54ea2a33cea1eb0f04bbdf59f1ef8d8702995cf431b0921302eeccc"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
[workspace]
|
||||
git_release_enable = false
|
||||
|
||||
[[package]]
|
||||
name = "ironclaw_safety"
|
||||
release = false
|
||||
|
||||
@@ -70,19 +70,21 @@ echo
|
||||
# This is a WARNING, not a hard violation.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
echo "--- Check 2: .unwrap() / .expect() in production code ---"
|
||||
echo "--- Check 2: .unwrap() / .expect() / assert!() in production code ---"
|
||||
|
||||
# Collect raw matches excluding obvious test-only files and lines
|
||||
raw_results=$(grep -rn '\.unwrap()\|\.expect(' src/ \
|
||||
# Collect raw matches excluding obvious test-only files and lines.
|
||||
# Also catches assert!(), assert_eq!(), assert_ne!() but NOT debug_assert variants.
|
||||
raw_results=$(grep -rnE '\.(unwrap|expect)\(|[^_]assert(_eq|_ne)?!' src/ \
|
||||
--include='*.rs' \
|
||||
| grep -v 'src/main.rs' \
|
||||
| grep -v 'src/testing.rs' \
|
||||
| grep -v 'src/setup/' \
|
||||
| grep -Ev 'debug_assert|// safety:' \
|
||||
|| true)
|
||||
|
||||
if [ -n "$raw_results" ]; then
|
||||
total=$(echo "$raw_results" | wc -l | tr -d ' ')
|
||||
echo "WARNING: ~$total .unwrap()/.expect() calls found in src/ (excluding main/testing/setup)."
|
||||
echo "WARNING: ~$total .unwrap()/.expect()/assert!() calls found in src/ (excluding main/testing/setup)."
|
||||
echo "Many are in test modules; a per-file breakdown helps triage:"
|
||||
echo
|
||||
# Show per-file counts, sorted by count descending, top 15
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
# 3. Hardcoded /tmp paths in tests (flaky in parallel runs)
|
||||
# 4. Tool parameters logged without redaction (secret leaks)
|
||||
# 5. Multi-step DB operations without transaction wrapping
|
||||
# 6. .unwrap(), .expect(), assert!() in production code (panics)
|
||||
#
|
||||
# Suppress individual lines with an inline "// safety: <reason>" comment.
|
||||
|
||||
@@ -128,6 +129,24 @@ if [ -n "$DIFF_W_OUTPUT" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# 6. .unwrap(), .expect(), assert!() in production code
|
||||
# Matches added lines containing panic-inducing calls.
|
||||
# Excludes test files, test modules, and debug_assert (compiled out in release).
|
||||
# Suppress with "// safety: <reason>".
|
||||
PROD_DIFF="$DIFF_OUTPUT"
|
||||
# Strip hunks from test-only files (tests/ directory, *_test.rs, test_*.rs)
|
||||
PROD_DIFF=$(echo "$PROD_DIFF" | grep -v '^+++ b/tests/' || true)
|
||||
if echo "$PROD_DIFF" | grep -nE '^\+' \
|
||||
| grep -E '\.(unwrap|expect)\(|[^_]assert(_eq|_ne)?!' \
|
||||
| grep -vE 'debug_assert|// safety:|#\[cfg\(test\)\]|#\[test\]|mod tests' \
|
||||
| head -5 | grep -q .; then
|
||||
warn "PANIC" "Production code must not use .unwrap(), .expect(), or assert!(). Use proper error handling."
|
||||
echo "$PROD_DIFF" | grep -nE '^\+' \
|
||||
| grep -E '\.(unwrap|expect)\(|[^_]assert(_eq|_ne)?!' \
|
||||
| grep -vE 'debug_assert|// safety:|#\[cfg\(test\)\]|#\[test\]|mod tests' \
|
||||
| head -5 | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
if [ "$WARNINGS" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "Found $WARNINGS potential issue(s). Fix them or add '// safety: <reason>' to suppress."
|
||||
|
||||
@@ -152,6 +152,30 @@ pub async fn run_agentic_loop(
|
||||
// Call LLM
|
||||
let output = delegate.call_llm(reasoning, reason_ctx, iteration).await?;
|
||||
|
||||
match &output.result {
|
||||
RespondResult::Text(text) => {
|
||||
tracing::debug!(
|
||||
iteration,
|
||||
len = text.len(),
|
||||
has_suggestions = text.contains("<suggestions>"),
|
||||
response = %text,
|
||||
"LLM text response"
|
||||
);
|
||||
}
|
||||
RespondResult::ToolCalls {
|
||||
tool_calls,
|
||||
content,
|
||||
} => {
|
||||
let names: Vec<&str> = tool_calls.iter().map(|tc| tc.name.as_str()).collect();
|
||||
tracing::debug!(
|
||||
iteration,
|
||||
tools = ?names,
|
||||
has_content = content.is_some(),
|
||||
"LLM tool_calls response"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
match output.result {
|
||||
RespondResult::Text(text) => {
|
||||
// Tool intent nudge: if the LLM says "let me search..." without
|
||||
|
||||
@@ -1051,6 +1051,54 @@ fn strip_internal_tool_call_text(text: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract `<suggestions>["...","..."]</suggestions>` from a response string.
|
||||
///
|
||||
/// Returns `(cleaned_text, suggestions)`. The `<suggestions>` block is stripped
|
||||
/// from the text regardless of whether the JSON inside parses successfully.
|
||||
/// Only the **last** `<suggestions>` block is used (closest to end of response).
|
||||
/// Blocks inside markdown code fences are ignored.
|
||||
pub(crate) fn extract_suggestions(text: &str) -> (String, Vec<String>) {
|
||||
use regex::Regex;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
static RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?s)<suggestions>\s*(.*?)\s*</suggestions>").expect("valid regex") // safety: constant pattern
|
||||
});
|
||||
|
||||
// Find the position of the last closing code fence to avoid matching inside code blocks
|
||||
let last_code_fence = text.rfind("```").unwrap_or(0);
|
||||
|
||||
// Find all matches, take the last one that's after the last code fence
|
||||
let mut best_match: Option<regex::Match<'_>> = None;
|
||||
let mut best_capture: Option<String> = None;
|
||||
for caps in RE.captures_iter(text) {
|
||||
if let (Some(full), Some(inner)) = (caps.get(0), caps.get(1))
|
||||
&& full.start() >= last_code_fence
|
||||
{
|
||||
best_match = Some(full);
|
||||
best_capture = Some(inner.as_str().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let Some(full) = best_match else {
|
||||
return (text.to_string(), Vec::new());
|
||||
};
|
||||
|
||||
let cleaned = format!("{}{}", &text[..full.start()], &text[full.end()..]); // safety: regex match boundaries are valid UTF-8
|
||||
let cleaned = cleaned.trim().to_string();
|
||||
|
||||
// Parse the JSON array
|
||||
let suggestions = best_capture
|
||||
.and_then(|json| serde_json::from_str::<Vec<String>>(&json).ok())
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|s| !s.trim().is_empty() && s.len() <= 80)
|
||||
.take(3)
|
||||
.collect();
|
||||
|
||||
(cleaned, suggestions)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
@@ -2197,6 +2245,55 @@ mod tests {
|
||||
assert_eq!(result, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_suggestions_basic() {
|
||||
let input = "Here is my answer.\n<suggestions>[\"Check logs\", \"Deploy\"]</suggestions>";
|
||||
let (text, suggestions) = super::extract_suggestions(input);
|
||||
assert_eq!(text, "Here is my answer."); // safety: test
|
||||
assert_eq!(suggestions, vec!["Check logs", "Deploy"]); // safety: test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_suggestions_no_tag() {
|
||||
let input = "Just a plain response.";
|
||||
let (text, suggestions) = super::extract_suggestions(input);
|
||||
assert_eq!(text, "Just a plain response."); // safety: test
|
||||
assert!(suggestions.is_empty()); // safety: test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_suggestions_malformed_json() {
|
||||
let input = "Answer.\n<suggestions>not json</suggestions>";
|
||||
let (text, suggestions) = super::extract_suggestions(input);
|
||||
assert_eq!(text, "Answer."); // safety: test
|
||||
assert!(suggestions.is_empty()); // safety: test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_suggestions_inside_code_fence() {
|
||||
let input = "```\n<suggestions>[\"foo\"]</suggestions>\n```";
|
||||
let (text, suggestions) = super::extract_suggestions(input);
|
||||
// The tag is inside a code fence, so it should not be extracted
|
||||
assert_eq!(text, input); // safety: test
|
||||
assert!(suggestions.is_empty()); // safety: test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_suggestions_after_code_fence() {
|
||||
let input = "```\ncode\n```\nAnswer.\n<suggestions>[\"foo\"]</suggestions>";
|
||||
let (text, suggestions) = super::extract_suggestions(input);
|
||||
assert_eq!(text, "```\ncode\n```\nAnswer."); // safety: test
|
||||
assert_eq!(suggestions, vec!["foo"]); // safety: test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_suggestions_filters_long() {
|
||||
let long = "x".repeat(81);
|
||||
let input = format!("Answer.\n<suggestions>[\"{}\", \"ok\"]</suggestions>", long);
|
||||
let (_, suggestions) = super::extract_suggestions(&input);
|
||||
assert_eq!(suggestions, vec!["ok"]); // safety: test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_error_format_includes_tool_name() {
|
||||
// Regression test for issue #487: tool errors sent to the LLM should
|
||||
|
||||
+42
-14
@@ -93,19 +93,26 @@ impl RoutineEngine {
|
||||
let mut cache = Vec::new();
|
||||
for routine in routines {
|
||||
match &routine.trigger {
|
||||
Trigger::Event { pattern, .. } => match Regex::new(pattern) {
|
||||
Ok(re) => cache.push(EventMatcher::Message {
|
||||
routine: routine.clone(),
|
||||
regex: re,
|
||||
}),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
routine = %routine.name,
|
||||
"Invalid event regex '{}': {}",
|
||||
pattern, e
|
||||
);
|
||||
Trigger::Event { pattern, .. } => {
|
||||
// Use RegexBuilder with size limit to prevent ReDoS
|
||||
// from user-supplied patterns (issue #825).
|
||||
match regex::RegexBuilder::new(pattern)
|
||||
.size_limit(64 * 1024) // 64KB compiled size limit
|
||||
.build()
|
||||
{
|
||||
Ok(re) => cache.push(EventMatcher::Message {
|
||||
routine: routine.clone(),
|
||||
regex: re,
|
||||
}),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
routine = %routine.name,
|
||||
"Invalid or too complex event regex '{}': {}",
|
||||
pattern, e
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
Trigger::SystemEvent { .. } => {
|
||||
cache.push(EventMatcher::System {
|
||||
routine: routine.clone(),
|
||||
@@ -973,6 +980,18 @@ async fn execute_lightweight_with_tools(
|
||||
}
|
||||
};
|
||||
|
||||
// Truncate oversized tool output to prevent unbounded context growth.
|
||||
// Routine tool loops are lightweight and should not accumulate
|
||||
// large payloads across iterations.
|
||||
const MAX_TOOL_OUTPUT_CHARS: usize = 8192;
|
||||
let result_content = if result_content.len() > MAX_TOOL_OUTPUT_CHARS {
|
||||
let truncated = &result_content
|
||||
[..result_content.floor_char_boundary(MAX_TOOL_OUTPUT_CHARS)];
|
||||
format!("{truncated}\n... [output truncated to {MAX_TOOL_OUTPUT_CHARS} chars]")
|
||||
} else {
|
||||
result_content
|
||||
};
|
||||
|
||||
// Add tool result to context
|
||||
messages.push(ChatMessage::tool_result(&tc.id, &tc.name, &result_content));
|
||||
}
|
||||
@@ -1150,9 +1169,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 +1379,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...");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,6 +420,10 @@ impl Agent {
|
||||
// Complete, fail, or request approval
|
||||
match result {
|
||||
Ok(AgenticLoopResult::Response(response)) => {
|
||||
// Extract <suggestions> from response text before user sees it
|
||||
let (response, suggestions) =
|
||||
crate::agent::dispatcher::extract_suggestions(&response);
|
||||
|
||||
// Hook: TransformResponse — allow hooks to modify or reject the final response
|
||||
let response = {
|
||||
let event = crate::hooks::HookEvent::ResponseTransform {
|
||||
@@ -473,6 +477,18 @@ impl Agent {
|
||||
)
|
||||
.await;
|
||||
|
||||
// Send suggestions after response (best-effort, rendered by web gateway)
|
||||
if !suggestions.is_empty() {
|
||||
let _ = self
|
||||
.channels
|
||||
.send_status(
|
||||
&message.channel,
|
||||
StatusUpdate::Suggestions { suggestions },
|
||||
&message.metadata,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(SubmissionResult::response(response))
|
||||
}
|
||||
Ok(AgenticLoopResult::NeedApproval { pending }) => {
|
||||
@@ -1334,6 +1350,8 @@ impl Agent {
|
||||
|
||||
match result {
|
||||
Ok(AgenticLoopResult::Response(response)) => {
|
||||
let (response, suggestions) =
|
||||
crate::agent::dispatcher::extract_suggestions(&response);
|
||||
thread.complete_turn(&response);
|
||||
let (turn_number, tool_calls) = thread
|
||||
.turns
|
||||
@@ -1364,6 +1382,16 @@ impl Agent {
|
||||
&message.metadata,
|
||||
)
|
||||
.await;
|
||||
if !suggestions.is_empty() {
|
||||
let _ = self
|
||||
.channels
|
||||
.send_status(
|
||||
&message.channel,
|
||||
StatusUpdate::Suggestions { suggestions },
|
||||
&message.metadata,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(SubmissionResult::response(response))
|
||||
}
|
||||
Ok(AgenticLoopResult::NeedApproval {
|
||||
|
||||
+2
-1
@@ -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));
|
||||
@@ -593,7 +594,7 @@ impl AppBuilder {
|
||||
let entries: Vec<_> = catalog
|
||||
.all()
|
||||
.iter()
|
||||
.map(|m| m.to_registry_entry())
|
||||
.filter_map(|m| m.to_registry_entry())
|
||||
.collect();
|
||||
tracing::debug!(
|
||||
count = entries.len(),
|
||||
|
||||
@@ -238,6 +238,8 @@ pub enum StatusUpdate {
|
||||
/// Optional workspace path where the image was saved.
|
||||
path: Option<String>,
|
||||
},
|
||||
/// Suggested follow-up messages for the user.
|
||||
Suggestions { suggestions: Vec<String> },
|
||||
}
|
||||
|
||||
impl StatusUpdate {
|
||||
|
||||
+153
-84
@@ -140,7 +140,7 @@ struct WebhookRequest {
|
||||
content: String,
|
||||
/// Optional thread ID for conversation tracking.
|
||||
thread_id: Option<String>,
|
||||
/// Deprecated: webhook secret in request body. Use X-IronClaw-Signature header instead.
|
||||
/// Deprecated: webhook secret in request body. Use X-Hub-Signature-256 header instead.
|
||||
/// This field is accepted for backward compatibility but will be removed in a future release.
|
||||
secret: Option<String>,
|
||||
/// Whether to wait for a synchronous response.
|
||||
@@ -269,95 +269,108 @@ 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 expected_secret = match webhook_secret.as_ref() {
|
||||
Some(secret) => secret.expose_secret(),
|
||||
None => {
|
||||
// No secret configured — reject all requests. This guards against
|
||||
// the secret being cleared at runtime via update_secret(None).
|
||||
// The start() method also prevents startup without a secret, but
|
||||
// this is defense-in-depth for the SIGHUP hot-swap path.
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(WebhookResponse {
|
||||
message_id: Uuid::nil(),
|
||||
status: "error".to_string(),
|
||||
response: Some("Webhook authentication not configured".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();
|
||||
}
|
||||
match headers.get("x-hub-signature-256") {
|
||||
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-Hub-Signature-256 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-Hub-Signature-256 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-Hub-Signature-256 header \
|
||||
(preferred) or 'secret' field in body (deprecated)."
|
||||
.to_string(),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -713,7 +726,7 @@ mod tests {
|
||||
.method("POST")
|
||||
.uri("/webhook")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-ironclaw-signature", signature)
|
||||
.header("x-hub-signature-256", signature)
|
||||
.body(Body::from(body_bytes))
|
||||
.unwrap();
|
||||
|
||||
@@ -736,7 +749,7 @@ mod tests {
|
||||
.method("POST")
|
||||
.uri("/webhook")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-ironclaw-signature", signature)
|
||||
.header("x-hub-signature-256", signature)
|
||||
.body(Body::from(body_bytes))
|
||||
.unwrap();
|
||||
|
||||
@@ -757,7 +770,7 @@ mod tests {
|
||||
.method("POST")
|
||||
.uri("/webhook")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-ironclaw-signature", "not-a-valid-signature")
|
||||
.header("x-hub-signature-256", "not-a-valid-signature")
|
||||
.body(Body::from(serde_json::to_vec(&body).unwrap()))
|
||||
.unwrap();
|
||||
|
||||
@@ -906,7 +919,7 @@ mod tests {
|
||||
.method("POST")
|
||||
.uri("/webhook")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-ironclaw-signature", signature)
|
||||
.header("x-hub-signature-256", signature)
|
||||
.body(Body::from(body_bytes))
|
||||
.unwrap();
|
||||
|
||||
@@ -928,7 +941,7 @@ mod tests {
|
||||
.method("POST")
|
||||
.uri("/webhook")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-ironclaw-signature", signature)
|
||||
.header("x-hub-signature-256", signature)
|
||||
.body(Body::from(body))
|
||||
.unwrap();
|
||||
|
||||
@@ -953,7 +966,7 @@ mod tests {
|
||||
.method("POST")
|
||||
.uri("/webhook")
|
||||
.header("content-type", "text/plain")
|
||||
.header("x-ironclaw-signature", signature)
|
||||
.header("x-hub-signature-256", signature)
|
||||
.body(Body::from(body_bytes))
|
||||
.unwrap();
|
||||
|
||||
@@ -978,7 +991,7 @@ mod tests {
|
||||
.body(Body::from(serde_json::to_vec(&body).unwrap()))
|
||||
.unwrap();
|
||||
req.headers_mut().insert(
|
||||
"x-ironclaw-signature",
|
||||
"x-hub-signature-256",
|
||||
HeaderValue::from_bytes(b"\xFF").unwrap(),
|
||||
);
|
||||
|
||||
@@ -1052,6 +1065,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-hub-signature-256", signature)
|
||||
.body(Body::from(body_bytes))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); // safety: test assertion
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_concurrent_requests_during_secret_update() {
|
||||
use std::sync::Arc as StdArc;
|
||||
@@ -1170,4 +1209,34 @@ mod tests {
|
||||
let body = b"test body content";
|
||||
assert!(!verify_hmac_signature(secret, body, "sha256=not-hex!"));
|
||||
}
|
||||
|
||||
/// Regression test for issue #1033: when the webhook secret is cleared at
|
||||
/// runtime via update_secret(None), subsequent requests must be rejected
|
||||
/// instead of being processed without authentication.
|
||||
#[tokio::test]
|
||||
async fn webhook_rejects_when_secret_cleared_at_runtime() {
|
||||
let channel = test_channel(Some("initial-secret"));
|
||||
let _stream = channel.start().await.unwrap();
|
||||
|
||||
// Clear the secret at runtime (simulates a bad SIGHUP config reload)
|
||||
channel.update_secret(None).await;
|
||||
|
||||
let app = channel.routes();
|
||||
let body = serde_json::json!({
|
||||
"content": "hello"
|
||||
});
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/webhook")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&body).unwrap()))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"requests must be rejected when webhook secret is cleared at runtime"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,6 +294,8 @@ impl Channel for RelayChannel {
|
||||
match client.connect_stream(&token, stream_timeout_secs).await {
|
||||
Ok((new_stream, new_parser)) => {
|
||||
tracing::info!("Relay SSE stream reconnected");
|
||||
consecutive_failures = 0;
|
||||
backoff_ms = backoff_initial_ms;
|
||||
current_stream = new_stream;
|
||||
// Abort old parser before replacing
|
||||
if let Some(old) = parser_handle.write().await.take() {
|
||||
@@ -312,6 +314,8 @@ impl Channel for RelayChannel {
|
||||
tracing::info!(
|
||||
"Relay SSE stream reconnected with new token"
|
||||
);
|
||||
consecutive_failures = 0;
|
||||
backoff_ms = backoff_initial_ms;
|
||||
current_stream = new_stream;
|
||||
if let Some(old) = parser_handle.write().await.take() {
|
||||
old.abort();
|
||||
|
||||
@@ -607,6 +607,9 @@ impl Channel for ReplChannel {
|
||||
eprintln!("\x1b[36m [image generated]\x1b[0m");
|
||||
}
|
||||
}
|
||||
StatusUpdate::Suggestions { .. } => {
|
||||
// Suggestions are only rendered by the web gateway
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1664,7 +1664,9 @@ impl WasmChannel {
|
||||
.await;
|
||||
let pairing_store = self.pairing_store.clone();
|
||||
|
||||
let wit_update = status_to_wit(status, metadata);
|
||||
let Some(wit_update) = status_to_wit(status, metadata) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let result = tokio::time::timeout(timeout, async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
@@ -1833,7 +1835,9 @@ impl WasmChannel {
|
||||
.await;
|
||||
let pairing_store = self.pairing_store.clone();
|
||||
let callback_timeout = self.runtime.config().callback_timeout;
|
||||
let wit_update = status_to_wit(&status, metadata);
|
||||
let Some(wit_update) = status_to_wit(&status, metadata) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(4));
|
||||
@@ -2704,10 +2708,13 @@ fn truncate_status_text(input: &str, max_chars: usize) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn status_to_wit(status: &StatusUpdate, metadata: &serde_json::Value) -> wit_channel::StatusUpdate {
|
||||
fn status_to_wit(
|
||||
status: &StatusUpdate,
|
||||
metadata: &serde_json::Value,
|
||||
) -> Option<wit_channel::StatusUpdate> {
|
||||
let metadata_json = serde_json::to_string(metadata).unwrap_or_default();
|
||||
|
||||
match status {
|
||||
Some(match status {
|
||||
StatusUpdate::Thinking(msg) => wit_channel::StatusUpdate {
|
||||
status: wit_channel::StatusType::Thinking,
|
||||
message: msg.clone(),
|
||||
@@ -2827,7 +2834,9 @@ fn status_to_wit(status: &StatusUpdate, metadata: &serde_json::Value) -> wit_cha
|
||||
},
|
||||
metadata_json,
|
||||
},
|
||||
}
|
||||
// Suggestions are web-gateway-only; skip for WASM channels
|
||||
StatusUpdate::Suggestions { .. } => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Clone a WIT StatusUpdate (the generated type doesn't derive Clone).
|
||||
@@ -3556,7 +3565,8 @@ mod tests {
|
||||
let wit = status_to_wit(
|
||||
&crate::channels::StatusUpdate::Thinking("Processing...".into()),
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
@@ -3574,7 +3584,8 @@ mod tests {
|
||||
let wit = status_to_wit(
|
||||
&crate::channels::StatusUpdate::Status("Done".into()),
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
|
||||
assert!(matches!(wit.status, super::wit_channel::StatusType::Done));
|
||||
}
|
||||
@@ -3589,14 +3600,16 @@ mod tests {
|
||||
let wit = status_to_wit(
|
||||
&crate::channels::StatusUpdate::Status("done".into()),
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
assert!(matches!(wit.status, super::wit_channel::StatusType::Done));
|
||||
|
||||
// with whitespace
|
||||
let wit = status_to_wit(
|
||||
&crate::channels::StatusUpdate::Status(" Done ".into()),
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
assert!(matches!(wit.status, super::wit_channel::StatusType::Done));
|
||||
}
|
||||
|
||||
@@ -3608,7 +3621,8 @@ mod tests {
|
||||
let wit = status_to_wit(
|
||||
&crate::channels::StatusUpdate::Status("Interrupted".into()),
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
@@ -3626,7 +3640,8 @@ mod tests {
|
||||
let wit = status_to_wit(
|
||||
&crate::channels::StatusUpdate::Status("interrupted".into()),
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
super::wit_channel::StatusType::Interrupted
|
||||
@@ -3636,7 +3651,8 @@ mod tests {
|
||||
let wit = status_to_wit(
|
||||
&crate::channels::StatusUpdate::Status(" Interrupted ".into()),
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
super::wit_channel::StatusType::Interrupted
|
||||
@@ -3651,7 +3667,8 @@ mod tests {
|
||||
let wit = status_to_wit(
|
||||
&crate::channels::StatusUpdate::Status("Awaiting approval".into()),
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
|
||||
assert!(matches!(wit.status, super::wit_channel::StatusType::Status));
|
||||
assert_eq!(wit.message, "Awaiting approval");
|
||||
@@ -3670,7 +3687,8 @@ mod tests {
|
||||
setup_url: None,
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
@@ -3690,7 +3708,8 @@ mod tests {
|
||||
name: "http_request".to_string(),
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
@@ -3712,7 +3731,8 @@ mod tests {
|
||||
parameters: None,
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
@@ -3734,7 +3754,8 @@ mod tests {
|
||||
parameters: None,
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
@@ -3754,7 +3775,8 @@ mod tests {
|
||||
preview: "{".to_string() + "\"temperature\": 22}",
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
@@ -3775,7 +3797,8 @@ mod tests {
|
||||
preview: long_preview,
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
@@ -3796,7 +3819,8 @@ mod tests {
|
||||
browse_url: "https://example.com/jobs/job-1".to_string(),
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
@@ -3818,7 +3842,8 @@ mod tests {
|
||||
message: "Token saved".to_string(),
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
@@ -3840,7 +3865,8 @@ mod tests {
|
||||
message: "Invalid token".to_string(),
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
@@ -3863,7 +3889,8 @@ mod tests {
|
||||
parameters: serde_json::json!({"url": "https://api.weather.test"}),
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
@@ -3887,7 +3914,8 @@ mod tests {
|
||||
parameters: serde_json::json!({"url": "https://api.weather.test"}),
|
||||
},
|
||||
&metadata,
|
||||
);
|
||||
)
|
||||
.unwrap(); // safety: test
|
||||
|
||||
assert!(matches!(
|
||||
wit.status,
|
||||
|
||||
@@ -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,17 +183,41 @@ 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,
|
||||
};
|
||||
|
||||
// When re-enabling a cron routine, recompute next_fire_at so the cron
|
||||
// ticker can pick it up. Mirrors the CLI behavior (issue #1077).
|
||||
if routine.enabled
|
||||
&& !was_enabled
|
||||
&& let Trigger::Cron {
|
||||
ref schedule,
|
||||
ref timezone,
|
||||
} = routine.trigger
|
||||
{
|
||||
routine.next_fire_at = next_cron_fire(schedule, timezone.as_deref()).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to compute next fire: {e}"),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
store
|
||||
.update_routine(&routine)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Refresh the in-memory event trigger cache so event/system_event
|
||||
// routines reflect the new enabled state immediately (issue #1076).
|
||||
if let Some(engine) = state.routine_engine.read().await.as_ref() {
|
||||
engine.refresh_event_cache().await;
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"status": if routine.enabled { "enabled" } else { "disabled" },
|
||||
"routine_id": routine_id,
|
||||
@@ -217,6 +242,12 @@ pub async fn routines_delete_handler(
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if deleted {
|
||||
// Refresh the in-memory event trigger cache so deleted event/system_event
|
||||
// routines stop firing immediately (issue #1076).
|
||||
if let Some(engine) = state.routine_engine.read().await.as_ref() {
|
||||
engine.refresh_event_cache().await;
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"status": "deleted",
|
||||
"routine_id": routine_id,
|
||||
|
||||
@@ -397,6 +397,10 @@ impl Channel for GatewayChannel {
|
||||
StatusUpdate::ImageGenerated { data_url, path } => SseEvent::ImageGenerated {
|
||||
data_url,
|
||||
path,
|
||||
thread_id: thread_id.clone(),
|
||||
},
|
||||
StatusUpdate::Suggestions { suggestions } => SseEvent::Suggestions {
|
||||
suggestions,
|
||||
thread_id,
|
||||
},
|
||||
};
|
||||
|
||||
+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);
|
||||
|
||||
|
||||
@@ -143,6 +143,7 @@ impl SseManager {
|
||||
SseEvent::JobResult { .. } => "job_result",
|
||||
SseEvent::Heartbeat => "heartbeat",
|
||||
SseEvent::ImageGenerated { .. } => "image_generated",
|
||||
SseEvent::Suggestions { .. } => "suggestions",
|
||||
SseEvent::ExtensionStatus { .. } => "extension_status",
|
||||
};
|
||||
Ok(Event::default().event(event_type).data(data))
|
||||
|
||||
+218
-36
@@ -19,6 +19,7 @@ let _loadThreadsTimer = null;
|
||||
const JOB_EVENTS_CAP = 500;
|
||||
const MEMORY_SEARCH_QUERY_MAX_LENGTH = 100;
|
||||
let stagedImages = [];
|
||||
let _ghostSuggestion = '';
|
||||
|
||||
// --- Slash Commands ---
|
||||
|
||||
@@ -286,9 +287,18 @@ function connectSSE() {
|
||||
if (data.thread_id) debouncedLoadThreads();
|
||||
return;
|
||||
}
|
||||
clearSuggestionChips();
|
||||
showActivityThinking(data.message);
|
||||
});
|
||||
|
||||
eventSource.addEventListener('suggestions', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
if (!isCurrentThread(data.thread_id)) return;
|
||||
if (data.suggestions && data.suggestions.length > 0) {
|
||||
showSuggestionChips(data.suggestions);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener('tool_started', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
if (!isCurrentThread(data.thread_id)) return;
|
||||
@@ -342,31 +352,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) => {
|
||||
@@ -427,9 +433,59 @@ function isCurrentThread(threadId) {
|
||||
return threadId === currentThreadId;
|
||||
}
|
||||
|
||||
// --- Suggestion Chips ---
|
||||
|
||||
function showSuggestionChips(suggestions) {
|
||||
// Clear previous chips/ghost without restoring placeholder (we'll set it below)
|
||||
_ghostSuggestion = '';
|
||||
const container = document.getElementById('suggestion-chips');
|
||||
container.innerHTML = '';
|
||||
const ghost = document.getElementById('ghost-text');
|
||||
ghost.style.display = 'none';
|
||||
const wrapper = document.querySelector('.chat-input-wrapper');
|
||||
if (wrapper) wrapper.classList.remove('has-ghost');
|
||||
|
||||
_ghostSuggestion = suggestions[0] || '';
|
||||
const input = document.getElementById('chat-input');
|
||||
suggestions.forEach(text => {
|
||||
const chip = document.createElement('button');
|
||||
chip.className = 'suggestion-chip';
|
||||
chip.textContent = text;
|
||||
chip.addEventListener('click', () => {
|
||||
input.value = text;
|
||||
clearSuggestionChips();
|
||||
autoResizeTextarea(input);
|
||||
input.focus();
|
||||
sendMessage();
|
||||
});
|
||||
container.appendChild(chip);
|
||||
});
|
||||
container.style.display = 'flex';
|
||||
// Show first suggestion as ghost text in the input so user knows Tab works
|
||||
if (_ghostSuggestion && input.value === '') {
|
||||
ghost.textContent = _ghostSuggestion;
|
||||
ghost.style.display = 'block';
|
||||
input.closest('.chat-input-wrapper').classList.add('has-ghost');
|
||||
}
|
||||
}
|
||||
|
||||
function clearSuggestionChips() {
|
||||
_ghostSuggestion = '';
|
||||
const container = document.getElementById('suggestion-chips');
|
||||
if (container) {
|
||||
container.innerHTML = '';
|
||||
container.style.display = 'none';
|
||||
}
|
||||
const ghost = document.getElementById('ghost-text');
|
||||
if (ghost) ghost.style.display = 'none';
|
||||
const wrapper = document.querySelector('.chat-input-wrapper');
|
||||
if (wrapper) wrapper.classList.remove('has-ghost');
|
||||
}
|
||||
|
||||
// --- Chat ---
|
||||
|
||||
function sendMessage() {
|
||||
clearSuggestionChips();
|
||||
const input = document.getElementById('chat-input');
|
||||
if (!currentThreadId) {
|
||||
console.warn('sendMessage: no thread selected, ignoring');
|
||||
@@ -990,7 +1046,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 +1078,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 +1181,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 +1324,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 +1358,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 +1380,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');
|
||||
@@ -1250,6 +1394,7 @@ function showAuthCardError(extensionName, message) {
|
||||
}
|
||||
|
||||
function loadHistory(before) {
|
||||
clearSuggestionChips();
|
||||
let historyUrl = '/api/chat/history?limit=50';
|
||||
if (currentThreadId) {
|
||||
historyUrl += '&thread_id=' + encodeURIComponent(currentThreadId);
|
||||
@@ -1545,6 +1690,7 @@ function switchToAssistant() {
|
||||
}
|
||||
|
||||
function switchThread(threadId) {
|
||||
clearSuggestionChips();
|
||||
finalizeActivityGroup();
|
||||
currentThreadId = threadId;
|
||||
unreadThreads.delete(threadId);
|
||||
@@ -1577,6 +1723,15 @@ chatInput.addEventListener('keydown', (e) => {
|
||||
const acEl = document.getElementById('slash-autocomplete');
|
||||
const acVisible = acEl && acEl.style.display !== 'none';
|
||||
|
||||
// Accept first suggestion with Tab (plain Tab only, not Shift+Tab)
|
||||
if (e.key === 'Tab' && !e.shiftKey && !acVisible && _ghostSuggestion && chatInput.value === '') {
|
||||
e.preventDefault();
|
||||
chatInput.value = _ghostSuggestion;
|
||||
clearSuggestionChips();
|
||||
autoResizeTextarea(chatInput);
|
||||
return;
|
||||
}
|
||||
|
||||
if (acVisible) {
|
||||
const items = acEl.querySelectorAll('.slash-ac-item');
|
||||
if (e.key === 'ArrowDown') {
|
||||
@@ -1613,6 +1768,16 @@ chatInput.addEventListener('keydown', (e) => {
|
||||
chatInput.addEventListener('input', () => {
|
||||
autoResizeTextarea(chatInput);
|
||||
filterSlashCommands(chatInput.value);
|
||||
const ghost = document.getElementById('ghost-text');
|
||||
const wrapper = chatInput.closest('.chat-input-wrapper');
|
||||
if (chatInput.value !== '') {
|
||||
ghost.style.display = 'none';
|
||||
wrapper.classList.remove('has-ghost');
|
||||
} else if (_ghostSuggestion) {
|
||||
ghost.textContent = _ghostSuggestion;
|
||||
ghost.style.display = 'block';
|
||||
wrapper.classList.add('has-ghost');
|
||||
}
|
||||
});
|
||||
chatInput.addEventListener('blur', () => {
|
||||
// Small delay so mousedown on autocomplete item fires first
|
||||
@@ -2166,6 +2331,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 +2600,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 +2612,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 +2658,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 +2752,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 +2764,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 +2786,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();
|
||||
}
|
||||
|
||||
|
||||
@@ -155,9 +155,13 @@
|
||||
<div class="chat-container">
|
||||
<div class="chat-messages" id="chat-messages"></div>
|
||||
<div id="slash-autocomplete" class="slash-autocomplete" style="display:none"></div>
|
||||
<div id="suggestion-chips" class="suggestion-chips" style="display:none"></div>
|
||||
<div class="chat-input">
|
||||
<div id="image-preview-strip" class="image-preview-strip"></div>
|
||||
<textarea id="chat-input" data-i18n="chat.inputPlaceholder" data-i18n-attr="placeholder" placeholder="Message or / for commands..." rows="1"></textarea>
|
||||
<div class="chat-input-wrapper">
|
||||
<textarea id="chat-input" data-i18n="chat.inputPlaceholder" data-i18n-attr="placeholder" placeholder="Message or / for commands..." rows="1"></textarea>
|
||||
<div id="ghost-text" class="ghost-text"></div>
|
||||
</div>
|
||||
<input type="file" id="image-file-input" accept="image/*" multiple style="display:none">
|
||||
<button id="attach-btn" class="attach-btn" data-i18n="chat.attachImages" data-i18n-attr="title" title="Attach images"
|
||||
aria-label="Attach images">📎</button>
|
||||
|
||||
@@ -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);
|
||||
@@ -1338,8 +1362,14 @@ body {
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.chat-input textarea {
|
||||
.chat-input-wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.chat-input-wrapper textarea {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
@@ -1352,17 +1382,66 @@ body {
|
||||
max-height: 120px;
|
||||
}
|
||||
|
||||
.chat-input textarea:focus {
|
||||
.ghost-text {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
white-space: pre-wrap;
|
||||
overflow: hidden;
|
||||
display: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Hide native placeholder when ghost text is visible */
|
||||
.chat-input-wrapper.has-ghost textarea::placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.chat-input-wrapper textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
|
||||
}
|
||||
|
||||
.chat-input textarea:disabled {
|
||||
.chat-input-wrapper textarea:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.suggestion-chips {
|
||||
display: none;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.suggestion-chip {
|
||||
padding: 6px 14px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.suggestion-chip:hover {
|
||||
background: var(--accent);
|
||||
color: #09090b;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.chat-input button {
|
||||
padding: 8px 20px;
|
||||
background: var(--accent);
|
||||
@@ -1392,7 +1471,7 @@ body {
|
||||
}
|
||||
|
||||
/* Keyboard accessibility focus rings */
|
||||
.chat-input textarea:focus-visible,
|
||||
.chat-input-wrapper textarea:focus-visible,
|
||||
.chat-input button:focus-visible,
|
||||
.tab-bar button:focus-visible,
|
||||
.tree-row:focus-visible {
|
||||
@@ -3800,7 +3879,7 @@ mark {
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
.chat-input textarea {
|
||||
.chat-input-wrapper textarea {
|
||||
min-height: 36px;
|
||||
max-height: 100px;
|
||||
}
|
||||
|
||||
@@ -242,6 +242,14 @@ pub enum SseEvent {
|
||||
thread_id: Option<String>,
|
||||
},
|
||||
|
||||
/// Suggested follow-up messages for the user.
|
||||
#[serde(rename = "suggestions")]
|
||||
Suggestions {
|
||||
suggestions: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thread_id: Option<String>,
|
||||
},
|
||||
|
||||
/// Extension activation status change (WASM channels).
|
||||
#[serde(rename = "extension_status")]
|
||||
ExtensionStatus {
|
||||
@@ -707,6 +715,7 @@ impl WsServerMessage {
|
||||
SseEvent::JobStatus { .. } => "job_status",
|
||||
SseEvent::JobResult { .. } => "job_result",
|
||||
SseEvent::ImageGenerated { .. } => "image_generated",
|
||||
SseEvent::Suggestions { .. } => "suggestions",
|
||||
SseEvent::ExtensionStatus { .. } => "extension_status",
|
||||
};
|
||||
let data = serde_json::to_value(event).unwrap_or(serde_json::Value::Null);
|
||||
|
||||
+18
-6
@@ -127,7 +127,11 @@ fn cmd_list(
|
||||
.unwrap_or("none");
|
||||
println!(
|
||||
"{:<20} {:<8} {:<8} {:<10} {}",
|
||||
m.name, m.kind, m.version, auth, m.description
|
||||
m.name,
|
||||
m.kind,
|
||||
m.version.as_deref().unwrap_or("-"),
|
||||
auth,
|
||||
m.description
|
||||
);
|
||||
} else {
|
||||
println!("{:<20} {:<8} {}", m.name, m.kind, m.description);
|
||||
@@ -173,17 +177,25 @@ fn cmd_info(catalog: &RegistryCatalog, name: &str) -> anyhow::Result<()> {
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
println!("{} ({})", manifest.display_name, manifest.kind);
|
||||
println!(" Version: {}", manifest.version);
|
||||
if let Some(ref version) = manifest.version {
|
||||
println!(" Version: {}", version);
|
||||
}
|
||||
println!(" {}", manifest.description);
|
||||
|
||||
if !manifest.keywords.is_empty() {
|
||||
println!(" Keywords: {}", manifest.keywords.join(", "));
|
||||
}
|
||||
|
||||
println!("\nSource:");
|
||||
println!(" Directory: {}", manifest.source.dir);
|
||||
println!(" Crate: {}", manifest.source.crate_name);
|
||||
println!(" Capabilities: {}", manifest.source.capabilities);
|
||||
if let Some(ref source) = manifest.source {
|
||||
println!("\nSource:");
|
||||
println!(" Directory: {}", source.dir);
|
||||
println!(" Crate: {}", source.crate_name);
|
||||
println!(" Capabilities: {}", source.capabilities);
|
||||
}
|
||||
|
||||
if let Some(ref url) = manifest.url {
|
||||
println!("\nMCP Server URL: {}", url);
|
||||
}
|
||||
|
||||
if let Some(artifact) = manifest.artifacts.get("wasm32-wasip2") {
|
||||
println!("\nArtifact (wasm32-wasip2):");
|
||||
|
||||
@@ -23,6 +23,9 @@ pub struct EmbeddingsConfig {
|
||||
pub ollama_base_url: String,
|
||||
/// Embedding vector dimension. Inferred from the model name when not set explicitly.
|
||||
pub dimension: usize,
|
||||
/// Custom base URL for OpenAI-compatible embedding providers.
|
||||
/// When set, overrides the default `https://api.openai.com`.
|
||||
pub openai_base_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for EmbeddingsConfig {
|
||||
@@ -36,6 +39,7 @@ impl Default for EmbeddingsConfig {
|
||||
model,
|
||||
ollama_base_url: "http://localhost:11434".to_string(),
|
||||
dimension,
|
||||
openai_base_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,6 +78,8 @@ impl EmbeddingsConfig {
|
||||
|
||||
let enabled = parse_bool_env("EMBEDDING_ENABLED", settings.embeddings.enabled)?;
|
||||
|
||||
let openai_base_url = optional_env("EMBEDDING_BASE_URL")?;
|
||||
|
||||
Ok(Self {
|
||||
enabled,
|
||||
provider,
|
||||
@@ -81,6 +87,7 @@ impl EmbeddingsConfig {
|
||||
model,
|
||||
ollama_base_url,
|
||||
dimension,
|
||||
openai_base_url,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -130,16 +137,27 @@ impl EmbeddingsConfig {
|
||||
}
|
||||
_ => {
|
||||
if let Some(api_key) = self.openai_api_key() {
|
||||
tracing::debug!(
|
||||
"Embeddings enabled via OpenAI (model: {}, dim: {})",
|
||||
self.model,
|
||||
self.dimension,
|
||||
);
|
||||
Some(Arc::new(crate::workspace::OpenAiEmbeddings::with_model(
|
||||
let mut provider = crate::workspace::OpenAiEmbeddings::with_model(
|
||||
api_key,
|
||||
&self.model,
|
||||
self.dimension,
|
||||
)))
|
||||
);
|
||||
if let Some(ref base_url) = self.openai_base_url {
|
||||
tracing::debug!(
|
||||
"Embeddings enabled via OpenAI (model: {}, base_url: {}, dim: {})",
|
||||
self.model,
|
||||
base_url,
|
||||
self.dimension,
|
||||
);
|
||||
provider = provider.with_base_url(base_url);
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"Embeddings enabled via OpenAI (model: {}, dim: {})",
|
||||
self.model,
|
||||
self.dimension,
|
||||
);
|
||||
}
|
||||
Some(Arc::new(provider))
|
||||
} else {
|
||||
tracing::warn!("Embeddings configured but OPENAI_API_KEY not set");
|
||||
None
|
||||
@@ -164,6 +182,7 @@ mod tests {
|
||||
std::env::remove_var("EMBEDDING_PROVIDER");
|
||||
std::env::remove_var("EMBEDDING_MODEL");
|
||||
std::env::remove_var("OPENAI_API_KEY");
|
||||
std::env::remove_var("EMBEDDING_BASE_URL");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,4 +266,41 @@ mod tests {
|
||||
std::env::remove_var("EMBEDDING_ENABLED");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedding_base_url_parsed_from_env() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
clear_embedding_env();
|
||||
|
||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||
unsafe {
|
||||
std::env::set_var("EMBEDDING_BASE_URL", "https://custom.example.com");
|
||||
}
|
||||
|
||||
let settings = Settings::default();
|
||||
let config = EmbeddingsConfig::resolve(&settings).expect("resolve should succeed");
|
||||
assert_eq!(
|
||||
config.openai_base_url.as_deref(),
|
||||
Some("https://custom.example.com"),
|
||||
"EMBEDDING_BASE_URL env var should be parsed into openai_base_url"
|
||||
);
|
||||
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::remove_var("EMBEDDING_BASE_URL");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedding_base_url_defaults_to_none() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
clear_embedding_env();
|
||||
|
||||
let settings = Settings::default();
|
||||
let config = EmbeddingsConfig::resolve(&settings).expect("resolve should succeed");
|
||||
assert!(
|
||||
config.openai_base_url.is_none(),
|
||||
"openai_base_url should be None when EMBEDDING_BASE_URL is not set"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
+79
-226
@@ -232,198 +232,11 @@ pub fn builtin_entries() -> Vec<RegistryEntry> {
|
||||
}
|
||||
|
||||
/// Well-known extensions, with an optional relay URL for the channel-relay entry.
|
||||
///
|
||||
/// MCP server entries are loaded from `registry/mcp-servers/*.json` via the catalog
|
||||
/// system. Only runtime-dependent entries (like channel-relay) remain here.
|
||||
pub fn builtin_entries_with_relay(relay_url: Option<String>) -> Vec<RegistryEntry> {
|
||||
let mut entries = vec![
|
||||
// -- MCP Servers --
|
||||
RegistryEntry {
|
||||
name: "notion".to_string(),
|
||||
display_name: "Notion".to_string(),
|
||||
kind: ExtensionKind::McpServer,
|
||||
description: "Connect to Notion for reading and writing pages, databases, and comments"
|
||||
.to_string(),
|
||||
keywords: vec![
|
||||
"notes".into(),
|
||||
"wiki".into(),
|
||||
"docs".into(),
|
||||
"pages".into(),
|
||||
"database".into(),
|
||||
],
|
||||
source: ExtensionSource::McpUrl {
|
||||
url: "https://mcp.notion.com/mcp".to_string(),
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
},
|
||||
RegistryEntry {
|
||||
name: "linear".to_string(),
|
||||
display_name: "Linear".to_string(),
|
||||
kind: ExtensionKind::McpServer,
|
||||
description:
|
||||
"Connect to Linear for issue tracking, project management, and team workflows"
|
||||
.to_string(),
|
||||
keywords: vec![
|
||||
"issues".into(),
|
||||
"tickets".into(),
|
||||
"project".into(),
|
||||
"tracking".into(),
|
||||
"bugs".into(),
|
||||
],
|
||||
source: ExtensionSource::McpUrl {
|
||||
url: "https://mcp.linear.app/sse".to_string(),
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
},
|
||||
RegistryEntry {
|
||||
name: "github".to_string(),
|
||||
display_name: "GitHub".to_string(),
|
||||
kind: ExtensionKind::McpServer,
|
||||
description:
|
||||
"Connect to GitHub for repository management, issues, PRs, and code search"
|
||||
.to_string(),
|
||||
keywords: vec![
|
||||
"git".into(),
|
||||
"repos".into(),
|
||||
"code".into(),
|
||||
"pull-request".into(),
|
||||
"issues".into(),
|
||||
],
|
||||
source: ExtensionSource::McpUrl {
|
||||
url: "https://api.githubcopilot.com/mcp/".to_string(),
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
},
|
||||
RegistryEntry {
|
||||
name: "slack-mcp".to_string(),
|
||||
display_name: "Slack MCP".to_string(),
|
||||
kind: ExtensionKind::McpServer,
|
||||
description:
|
||||
"Connect to Slack via MCP for messaging, channel management, and team communication"
|
||||
.to_string(),
|
||||
keywords: vec![
|
||||
"messaging".into(),
|
||||
"chat".into(),
|
||||
"channels".into(),
|
||||
"team".into(),
|
||||
"communication".into(),
|
||||
],
|
||||
source: ExtensionSource::McpUrl {
|
||||
url: "https://mcp.slack.com".to_string(),
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
},
|
||||
RegistryEntry {
|
||||
name: "sentry".to_string(),
|
||||
display_name: "Sentry".to_string(),
|
||||
kind: ExtensionKind::McpServer,
|
||||
description:
|
||||
"Connect to Sentry for error tracking, performance monitoring, and debugging"
|
||||
.to_string(),
|
||||
keywords: vec![
|
||||
"errors".into(),
|
||||
"monitoring".into(),
|
||||
"debugging".into(),
|
||||
"crashes".into(),
|
||||
"performance".into(),
|
||||
],
|
||||
source: ExtensionSource::McpUrl {
|
||||
url: "https://mcp.sentry.dev/mcp".to_string(),
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
},
|
||||
RegistryEntry {
|
||||
name: "stripe".to_string(),
|
||||
display_name: "Stripe".to_string(),
|
||||
kind: ExtensionKind::McpServer,
|
||||
description:
|
||||
"Connect to Stripe for payment processing, subscriptions, and financial data"
|
||||
.to_string(),
|
||||
keywords: vec![
|
||||
"payments".into(),
|
||||
"billing".into(),
|
||||
"subscriptions".into(),
|
||||
"invoices".into(),
|
||||
"finance".into(),
|
||||
],
|
||||
source: ExtensionSource::McpUrl {
|
||||
url: "https://mcp.stripe.com".to_string(),
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
},
|
||||
RegistryEntry {
|
||||
name: "cloudflare".to_string(),
|
||||
display_name: "Cloudflare".to_string(),
|
||||
kind: ExtensionKind::McpServer,
|
||||
description:
|
||||
"Connect to Cloudflare for DNS, Workers, KV, and infrastructure management"
|
||||
.to_string(),
|
||||
keywords: vec![
|
||||
"cdn".into(),
|
||||
"dns".into(),
|
||||
"workers".into(),
|
||||
"hosting".into(),
|
||||
"infrastructure".into(),
|
||||
],
|
||||
source: ExtensionSource::McpUrl {
|
||||
url: "https://mcp.cloudflare.com/mcp".to_string(),
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
},
|
||||
RegistryEntry {
|
||||
name: "asana".to_string(),
|
||||
display_name: "Asana".to_string(),
|
||||
kind: ExtensionKind::McpServer,
|
||||
description: "Connect to Asana for task management, projects, and team coordination"
|
||||
.to_string(),
|
||||
keywords: vec![
|
||||
"tasks".into(),
|
||||
"projects".into(),
|
||||
"management".into(),
|
||||
"team".into(),
|
||||
],
|
||||
source: ExtensionSource::McpUrl {
|
||||
url: "https://mcp.asana.com/v2/mcp".to_string(),
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
},
|
||||
RegistryEntry {
|
||||
name: "intercom".to_string(),
|
||||
display_name: "Intercom".to_string(),
|
||||
kind: ExtensionKind::McpServer,
|
||||
description: "Connect to Intercom for customer messaging, support, and engagement"
|
||||
.to_string(),
|
||||
keywords: vec![
|
||||
"support".into(),
|
||||
"customers".into(),
|
||||
"messaging".into(),
|
||||
"chat".into(),
|
||||
"helpdesk".into(),
|
||||
],
|
||||
source: ExtensionSource::McpUrl {
|
||||
url: "https://mcp.intercom.com/mcp".to_string(),
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
},
|
||||
// WASM channels (telegram, slack, discord, whatsapp) come from the embedded
|
||||
// registry catalog (registry/channels/*.json) with WasmDownload URLs pointing
|
||||
// to GitHub release artifacts. See new_with_catalog() for merging.
|
||||
];
|
||||
let mut entries = vec![];
|
||||
|
||||
// Conditionally add channel-relay entries when relay URL is configured
|
||||
if let Some(relay_url) = relay_url {
|
||||
@@ -545,9 +358,21 @@ mod tests {
|
||||
assert_eq!(score, 0, "No match should score 0");
|
||||
}
|
||||
|
||||
/// Helper to create a registry with catalog entries (MCP servers come from catalog now).
|
||||
fn registry_with_catalog() -> ExtensionRegistry {
|
||||
let catalog = crate::registry::catalog::RegistryCatalog::load_or_embedded()
|
||||
.expect("catalog should load");
|
||||
let catalog_entries: Vec<RegistryEntry> = catalog
|
||||
.all()
|
||||
.iter()
|
||||
.filter_map(|m| m.to_registry_entry())
|
||||
.collect();
|
||||
ExtensionRegistry::new_with_catalog(catalog_entries)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_returns_sorted() {
|
||||
let registry = ExtensionRegistry::new();
|
||||
let registry = registry_with_catalog();
|
||||
let results = registry.search("notion").await;
|
||||
|
||||
assert!(!results.is_empty(), "Should find notion in registry");
|
||||
@@ -556,7 +381,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_empty_query_returns_all() {
|
||||
let registry = ExtensionRegistry::new();
|
||||
let registry = registry_with_catalog();
|
||||
let results = registry.search("").await;
|
||||
|
||||
assert!(results.len() > 5, "Empty query should return all entries");
|
||||
@@ -564,7 +389,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_by_keyword() {
|
||||
let registry = ExtensionRegistry::new();
|
||||
let registry = registry_with_catalog();
|
||||
let results = registry.search("issues tickets").await;
|
||||
|
||||
assert!(
|
||||
@@ -578,7 +403,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_exact_name() {
|
||||
let registry = ExtensionRegistry::new();
|
||||
let registry = registry_with_catalog();
|
||||
|
||||
let entry = registry.get("notion").await;
|
||||
assert!(entry.is_some());
|
||||
@@ -658,17 +483,30 @@ mod tests {
|
||||
auth_hint: AuthHint::CapabilitiesAuth,
|
||||
version: None,
|
||||
},
|
||||
// This shares a name with the builtin slack-mcp but has a different kind, so both should appear
|
||||
// Two entries with same name but different kinds should coexist
|
||||
RegistryEntry {
|
||||
name: "slack-mcp".to_string(),
|
||||
display_name: "Slack MCP WASM".to_string(),
|
||||
name: "dual-ext".to_string(),
|
||||
display_name: "Dual MCP".to_string(),
|
||||
kind: ExtensionKind::McpServer,
|
||||
description: "Dual extension MCP server".to_string(),
|
||||
keywords: vec!["messaging".into()],
|
||||
source: ExtensionSource::McpUrl {
|
||||
url: "https://mcp.example.com".to_string(),
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
},
|
||||
RegistryEntry {
|
||||
name: "dual-ext".to_string(),
|
||||
display_name: "Dual WASM".to_string(),
|
||||
kind: ExtensionKind::WasmTool,
|
||||
description: "Slack WASM tool".to_string(),
|
||||
description: "Dual extension WASM tool".to_string(),
|
||||
keywords: vec!["messaging".into()],
|
||||
source: ExtensionSource::WasmBuildable {
|
||||
source_dir: "tools-src/slack".to_string(),
|
||||
build_dir: Some("tools-src/slack".to_string()),
|
||||
crate_name: Some("slack-tool".to_string()),
|
||||
source_dir: "tools-src/dual".to_string(),
|
||||
build_dir: Some("tools-src/dual".to_string()),
|
||||
crate_name: Some("dual-tool".to_string()),
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::CapabilitiesAuth,
|
||||
@@ -683,41 +521,56 @@ mod tests {
|
||||
assert!(!results.is_empty(), "Should find telegram from catalog");
|
||||
assert_eq!(results[0].entry.name, "telegram");
|
||||
|
||||
// Should have both builtin MCP slack-mcp and catalog WASM slack-mcp
|
||||
let results = registry.search("slack").await;
|
||||
let slack_mcp = results
|
||||
// Should have both MCP and WASM entries with the same name
|
||||
let results = registry.search("dual-ext").await;
|
||||
let has_mcp = results
|
||||
.iter()
|
||||
.any(|r| r.entry.name == "slack-mcp" && r.entry.kind == ExtensionKind::McpServer);
|
||||
let slack_wasm = results
|
||||
.any(|r| r.entry.name == "dual-ext" && r.entry.kind == ExtensionKind::McpServer);
|
||||
let has_wasm = results
|
||||
.iter()
|
||||
.any(|r| r.entry.name == "slack-mcp" && r.entry.kind == ExtensionKind::WasmTool);
|
||||
assert!(slack_mcp, "Should have builtin MCP slack-mcp");
|
||||
assert!(slack_wasm, "Should have catalog WASM slack-mcp");
|
||||
.any(|r| r.entry.name == "dual-ext" && r.entry.kind == ExtensionKind::WasmTool);
|
||||
assert!(has_mcp, "Should have MCP dual-ext");
|
||||
assert!(has_wasm, "Should have WASM dual-ext");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_new_with_catalog_dedup_same_kind() {
|
||||
// A catalog entry with same name AND kind as a builtin should be skipped
|
||||
let catalog_entries = vec![RegistryEntry {
|
||||
name: "slack-mcp".to_string(),
|
||||
display_name: "Slack MCP Override".to_string(),
|
||||
kind: ExtensionKind::McpServer, // same kind as builtin slack-mcp
|
||||
description: "Should be skipped".to_string(),
|
||||
keywords: vec![],
|
||||
source: ExtensionSource::McpUrl {
|
||||
url: "https://other.slack.com".to_string(),
|
||||
// When two catalog entries share name AND kind, only the first should be kept
|
||||
let catalog_entries = vec![
|
||||
RegistryEntry {
|
||||
name: "test-ext".to_string(),
|
||||
display_name: "Test First".to_string(),
|
||||
kind: ExtensionKind::McpServer,
|
||||
description: "First entry".to_string(),
|
||||
keywords: vec![],
|
||||
source: ExtensionSource::McpUrl {
|
||||
url: "https://first.example.com".to_string(),
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
}];
|
||||
RegistryEntry {
|
||||
name: "test-ext".to_string(),
|
||||
display_name: "Test Duplicate".to_string(),
|
||||
kind: ExtensionKind::McpServer, // same kind
|
||||
description: "Should be skipped".to_string(),
|
||||
keywords: vec![],
|
||||
source: ExtensionSource::McpUrl {
|
||||
url: "https://second.example.com".to_string(),
|
||||
},
|
||||
fallback_source: None,
|
||||
auth_hint: AuthHint::Dcr,
|
||||
version: None,
|
||||
},
|
||||
];
|
||||
|
||||
let registry = ExtensionRegistry::new_with_catalog(catalog_entries);
|
||||
|
||||
let entry = registry.get("slack-mcp").await;
|
||||
let entry = registry.get("test-ext").await;
|
||||
assert!(entry.is_some());
|
||||
// Should still be the builtin, not the override
|
||||
assert_eq!(entry.unwrap().display_name, "Slack MCP");
|
||||
// Should be the first entry, not the duplicate
|
||||
assert_eq!(entry.unwrap().display_name, "Test First");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -270,10 +270,6 @@ impl NearAiChatProvider {
|
||||
reason: format!("Failed to read response body: {}", e),
|
||||
})?;
|
||||
|
||||
if tracing::enabled!(tracing::Level::DEBUG) {
|
||||
tracing::debug!("NEAR AI Chat response status: {}", status);
|
||||
}
|
||||
|
||||
// Log response body only at TRACE level to avoid exposing sensitive content
|
||||
// (user-generated data, tool outputs, leaked secrets) in DEBUG logs
|
||||
if tracing::enabled!(tracing::Level::TRACE) {
|
||||
|
||||
@@ -902,7 +902,8 @@ Example:
|
||||
## Guidelines
|
||||
- Be concise and direct
|
||||
- Use markdown formatting where helpful
|
||||
- For code, use appropriate code blocks with language tags{}
|
||||
- For code, use appropriate code blocks with language tags
|
||||
- ALWAYS end your response with a <suggestions> tag containing a JSON array of 1-3 short follow-up commands. Each suggestion must read as something the USER would type to instruct YOU. Write them in the user's voice as direct commands, not as requests FROM you TO the user. Do NOT repeat or rephrase content already in your response. Example: <suggestions>["Suggest dinner spots in my area", "Find a quick recipe for pasta"]</suggestions> Keep each under 80 characters.{}
|
||||
|
||||
## Safety
|
||||
- You have no independent goals. Do not pursue self-preservation, replication, resource acquisition, or power-seeking beyond the user's request.
|
||||
|
||||
@@ -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(())
|
||||
|
||||
+86
-31
@@ -192,6 +192,12 @@ impl RegistryCatalog {
|
||||
Self::load_manifests_from_dir(&channels_dir, "channels", &mut manifests)?;
|
||||
}
|
||||
|
||||
// Load MCP servers
|
||||
let mcp_servers_dir = registry_dir.join("mcp-servers");
|
||||
if mcp_servers_dir.is_dir() {
|
||||
Self::load_manifests_from_dir(&mcp_servers_dir, "mcp-servers", &mut manifests)?;
|
||||
}
|
||||
|
||||
// Load bundles
|
||||
let bundles_path = registry_dir.join("_bundles.json");
|
||||
let bundles = if bundles_path.is_file() {
|
||||
@@ -280,8 +286,9 @@ impl RegistryCatalog {
|
||||
/// Get a manifest by name. Tries exact key match first ("tools/github"),
|
||||
/// then searches by bare name ("github").
|
||||
///
|
||||
/// If a bare name matches both a tool and a channel, returns `None`.
|
||||
/// Use a qualified key ("tools/github" or "channels/telegram") to disambiguate.
|
||||
/// If a bare name matches more than one prefix, returns `None`.
|
||||
/// Use a qualified key ("tools/github", "channels/telegram", or
|
||||
/// "mcp-servers/notion") to disambiguate.
|
||||
pub fn get(&self, name: &str) -> Option<&ExtensionManifest> {
|
||||
// Try exact key first
|
||||
if let Some(m) = self.manifests.get(name) {
|
||||
@@ -289,14 +296,15 @@ impl RegistryCatalog {
|
||||
}
|
||||
|
||||
// Try with kind prefix, detecting collisions
|
||||
let tool = self.manifests.get(&format!("tools/{}", name));
|
||||
let channel = self.manifests.get(&format!("channels/{}", name));
|
||||
let candidates: Vec<_> = ["tools", "channels", "mcp-servers"]
|
||||
.iter()
|
||||
.filter_map(|prefix| self.manifests.get(&format!("{}/{}", prefix, name)))
|
||||
.collect();
|
||||
|
||||
match (tool, channel) {
|
||||
(Some(_), Some(_)) => None, // ambiguous
|
||||
(Some(m), None) => Some(m),
|
||||
(None, Some(m)) => Some(m),
|
||||
(None, None) => None,
|
||||
if candidates.len() == 1 {
|
||||
Some(candidates[0])
|
||||
} else {
|
||||
None // ambiguous or not found
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,37 +316,63 @@ impl RegistryCatalog {
|
||||
return Ok(m);
|
||||
}
|
||||
|
||||
let has_tool = self.manifests.contains_key(&format!("tools/{}", name));
|
||||
let has_channel = self.manifests.contains_key(&format!("channels/{}", name));
|
||||
let prefixes: &[(&str, &str)] = &[
|
||||
("tools", "tool"),
|
||||
("channels", "channel"),
|
||||
("mcp-servers", "mcp_server"),
|
||||
];
|
||||
|
||||
match (has_tool, has_channel) {
|
||||
(true, true) => Err(RegistryError::AmbiguousName {
|
||||
name: name.to_string(),
|
||||
kind_a: "tool",
|
||||
prefix_a: "tools",
|
||||
kind_b: "channel",
|
||||
prefix_b: "channels",
|
||||
}),
|
||||
(true, false) => Ok(self.manifests.get(&format!("tools/{}", name)).unwrap()),
|
||||
(false, true) => Ok(self.manifests.get(&format!("channels/{}", name)).unwrap()),
|
||||
(false, false) => Err(RegistryError::ExtensionNotFound(name.to_string())),
|
||||
let matches: Vec<_> = prefixes
|
||||
.iter()
|
||||
.filter(|(prefix, _)| self.manifests.contains_key(&format!("{}/{}", prefix, name)))
|
||||
.collect();
|
||||
|
||||
match matches.len() {
|
||||
0 => Err(RegistryError::ExtensionNotFound(name.to_string())),
|
||||
1 => {
|
||||
let (prefix, _) = matches[0];
|
||||
let key = format!("{}/{}", prefix, name);
|
||||
self.manifests
|
||||
.get(&key)
|
||||
.ok_or_else(|| RegistryError::ExtensionNotFound(name.to_string()))
|
||||
}
|
||||
_ => {
|
||||
let (prefix_a, kind_a) = matches[0];
|
||||
let (prefix_b, kind_b) = matches[1];
|
||||
Err(RegistryError::AmbiguousName {
|
||||
name: name.to_string(),
|
||||
kind_a,
|
||||
prefix_a,
|
||||
kind_b,
|
||||
prefix_b,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the full key ("tools/github" or "channels/telegram") for a manifest.
|
||||
/// Get the full key ("tools/github", "channels/telegram", or
|
||||
/// "mcp-servers/notion") for a manifest.
|
||||
pub fn key_for(&self, name: &str) -> Option<String> {
|
||||
if self.manifests.contains_key(name) {
|
||||
return Some(name.to_string());
|
||||
}
|
||||
|
||||
let has_tool = self.manifests.contains_key(&format!("tools/{}", name));
|
||||
let has_channel = self.manifests.contains_key(&format!("channels/{}", name));
|
||||
let matches: Vec<String> = ["tools", "channels", "mcp-servers"]
|
||||
.iter()
|
||||
.filter_map(|prefix| {
|
||||
let key = format!("{}/{}", prefix, name);
|
||||
if self.manifests.contains_key(&key) {
|
||||
Some(key)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
match (has_tool, has_channel) {
|
||||
(true, true) => None, // ambiguous
|
||||
(true, false) => Some(format!("tools/{}", name)),
|
||||
(false, true) => Some(format!("channels/{}", name)),
|
||||
(false, false) => None,
|
||||
if matches.len() == 1 {
|
||||
matches.into_iter().next()
|
||||
} else {
|
||||
None // ambiguous or not found
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,8 +510,10 @@ mod tests {
|
||||
fn create_test_registry(dir: &Path) {
|
||||
let tools_dir = dir.join("tools");
|
||||
let channels_dir = dir.join("channels");
|
||||
let mcp_dir = dir.join("mcp-servers");
|
||||
fs::create_dir_all(&tools_dir).unwrap();
|
||||
fs::create_dir_all(&channels_dir).unwrap();
|
||||
fs::create_dir_all(&mcp_dir).unwrap();
|
||||
|
||||
fs::write(
|
||||
tools_dir.join("slack.json"),
|
||||
@@ -540,6 +576,20 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
fs::write(
|
||||
mcp_dir.join("notion.json"),
|
||||
r#"{
|
||||
"name": "notion",
|
||||
"display_name": "Notion",
|
||||
"kind": "mcp_server",
|
||||
"description": "Connect to Notion for pages and databases",
|
||||
"keywords": ["notes", "wiki"],
|
||||
"url": "https://mcp.notion.com/mcp",
|
||||
"auth": "dcr"
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
fs::write(
|
||||
dir.join("_bundles.json"),
|
||||
r#"{
|
||||
@@ -565,7 +615,7 @@ mod tests {
|
||||
create_test_registry(tmp.path());
|
||||
|
||||
let catalog = RegistryCatalog::load(tmp.path()).unwrap();
|
||||
assert_eq!(catalog.all().len(), 3);
|
||||
assert_eq!(catalog.all().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -579,6 +629,9 @@ mod tests {
|
||||
|
||||
let channels = catalog.list(Some(ManifestKind::Channel), None);
|
||||
assert_eq!(channels.len(), 1);
|
||||
|
||||
let mcp_servers = catalog.list(Some(ManifestKind::McpServer), None);
|
||||
assert_eq!(mcp_servers.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -603,10 +656,12 @@ mod tests {
|
||||
|
||||
// Full key
|
||||
assert!(catalog.get("tools/slack").is_some());
|
||||
assert!(catalog.get("mcp-servers/notion").is_some());
|
||||
|
||||
// Bare name
|
||||
assert!(catalog.get("slack").is_some());
|
||||
assert!(catalog.get("telegram").is_some());
|
||||
assert!(catalog.get("notion").is_some());
|
||||
|
||||
// Missing
|
||||
assert!(catalog.get("nonexistent").is_none());
|
||||
|
||||
@@ -20,6 +20,8 @@ struct EmbeddedCatalogRaw {
|
||||
#[serde(default)]
|
||||
channels: Vec<ExtensionManifest>,
|
||||
#[serde(default)]
|
||||
mcp_servers: Vec<ExtensionManifest>,
|
||||
#[serde(default)]
|
||||
bundles: BundlesFile,
|
||||
}
|
||||
|
||||
@@ -52,6 +54,10 @@ fn parsed_catalog() -> &'static ParsedCatalog {
|
||||
let key = format!("channels/{}", m.name);
|
||||
manifests.insert(key, m);
|
||||
}
|
||||
for m in raw.mcp_servers {
|
||||
let key = format!("mcp-servers/{}", m.name);
|
||||
manifests.insert(key, m);
|
||||
}
|
||||
|
||||
ParsedCatalog {
|
||||
manifests,
|
||||
|
||||
+76
-18
@@ -7,7 +7,7 @@ use tokio::fs;
|
||||
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
use crate::registry::catalog::RegistryError;
|
||||
use crate::registry::manifest::{BundleDefinition, ExtensionManifest, ManifestKind};
|
||||
use crate::registry::manifest::{BundleDefinition, ExtensionManifest, ManifestKind, SourceSpec};
|
||||
|
||||
// GitHub-only by design. New trusted hosts (e.g. a NEAR AI CDN) must be
|
||||
// explicitly added here; unknown hosts fall back to source build with a
|
||||
@@ -98,12 +98,29 @@ fn validate_manifest_install_inputs(manifest: &ExtensionManifest) -> Result<(),
|
||||
});
|
||||
}
|
||||
|
||||
// MCP servers are not installed via this path
|
||||
if manifest.kind == ManifestKind::McpServer {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let source = match &manifest.source {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return Err(RegistryError::InvalidManifest {
|
||||
name: manifest.name.clone(),
|
||||
field: "source",
|
||||
reason: "WASM extensions must have a source spec".to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let expected_prefix = match manifest.kind {
|
||||
ManifestKind::Tool => "tools-src/",
|
||||
ManifestKind::Channel => "channels-src/",
|
||||
ManifestKind::McpServer => unreachable!(),
|
||||
};
|
||||
|
||||
if !manifest.source.dir.starts_with(expected_prefix) {
|
||||
if !source.dir.starts_with(expected_prefix) {
|
||||
return Err(RegistryError::InvalidManifest {
|
||||
name: manifest.name.clone(),
|
||||
field: "source.dir",
|
||||
@@ -111,7 +128,7 @@ fn validate_manifest_install_inputs(manifest: &ExtensionManifest) -> Result<(),
|
||||
});
|
||||
}
|
||||
|
||||
let source_path = Path::new(&manifest.source.dir);
|
||||
let source_path = Path::new(&source.dir);
|
||||
let has_unsafe_component = source_path.components().any(|component| {
|
||||
matches!(
|
||||
component,
|
||||
@@ -127,9 +144,9 @@ fn validate_manifest_install_inputs(manifest: &ExtensionManifest) -> Result<(),
|
||||
});
|
||||
}
|
||||
|
||||
let has_path_separator = manifest.source.capabilities.contains('/')
|
||||
|| manifest.source.capabilities.contains('\\')
|
||||
|| manifest.source.capabilities.contains("..");
|
||||
let has_path_separator = source.capabilities.contains('/')
|
||||
|| source.capabilities.contains('\\')
|
||||
|| source.capabilities.contains("..");
|
||||
|
||||
if has_path_separator {
|
||||
return Err(RegistryError::InvalidManifest {
|
||||
@@ -142,6 +159,18 @@ fn validate_manifest_install_inputs(manifest: &ExtensionManifest) -> Result<(),
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract the source spec from a manifest, returning an error if absent.
|
||||
fn require_source(manifest: &ExtensionManifest) -> Result<&SourceSpec, RegistryError> {
|
||||
manifest
|
||||
.source
|
||||
.as_ref()
|
||||
.ok_or_else(|| RegistryError::InvalidManifest {
|
||||
name: manifest.name.clone(),
|
||||
field: "source",
|
||||
reason: "WASM extensions must have a source spec".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn download_failure_reason(error: &reqwest::Error) -> String {
|
||||
if error.is_timeout() {
|
||||
"request timed out".to_string()
|
||||
@@ -206,7 +235,17 @@ impl RegistryInstaller {
|
||||
) -> Result<InstallOutcome, RegistryError> {
|
||||
validate_manifest_install_inputs(manifest)?;
|
||||
|
||||
let source_dir = self.repo_root.join(&manifest.source.dir);
|
||||
if manifest.kind == ManifestKind::McpServer {
|
||||
return Err(RegistryError::InvalidManifest {
|
||||
name: manifest.name.clone(),
|
||||
field: "kind",
|
||||
reason: "MCP servers cannot be installed from source".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let source = require_source(manifest)?;
|
||||
|
||||
let source_dir = self.repo_root.join(&source.dir);
|
||||
if !source_dir.exists() {
|
||||
return Err(RegistryError::ManifestRead {
|
||||
path: source_dir.clone(),
|
||||
@@ -217,6 +256,7 @@ impl RegistryInstaller {
|
||||
let target_dir = match manifest.kind {
|
||||
ManifestKind::Tool => &self.tools_dir,
|
||||
ManifestKind::Channel => &self.channels_dir,
|
||||
ManifestKind::McpServer => unreachable!(),
|
||||
};
|
||||
|
||||
fs::create_dir_all(target_dir)
|
||||
@@ -242,7 +282,7 @@ impl RegistryInstaller {
|
||||
manifest.display_name,
|
||||
source_dir.display()
|
||||
);
|
||||
let crate_name = &manifest.source.crate_name;
|
||||
let crate_name = &source.crate_name;
|
||||
let wasm_path =
|
||||
crate::registry::artifacts::build_wasm_component(&source_dir, crate_name, true)
|
||||
.await
|
||||
@@ -258,7 +298,7 @@ impl RegistryInstaller {
|
||||
.map_err(RegistryError::Io)?;
|
||||
|
||||
// Copy capabilities file
|
||||
let caps_source = source_dir.join(&manifest.source.capabilities);
|
||||
let caps_source = source_dir.join(&source.capabilities);
|
||||
let target_caps = target_dir.join(format!("{}.capabilities.json", manifest.name));
|
||||
let has_capabilities = if caps_source.exists() {
|
||||
fs::copy(&caps_source, &target_caps)
|
||||
@@ -296,6 +336,16 @@ impl RegistryInstaller {
|
||||
// catch it first.
|
||||
validate_manifest_install_inputs(manifest)?;
|
||||
|
||||
if manifest.kind == ManifestKind::McpServer {
|
||||
return Err(RegistryError::InvalidManifest {
|
||||
name: manifest.name.clone(),
|
||||
field: "kind",
|
||||
reason: "MCP servers cannot be installed via the WASM installer".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let source = require_source(manifest)?;
|
||||
|
||||
let has_artifact = manifest
|
||||
.artifacts
|
||||
.get("wasm32-wasip2")
|
||||
@@ -306,7 +356,7 @@ impl RegistryInstaller {
|
||||
return self.install_from_source(manifest, force).await;
|
||||
}
|
||||
|
||||
let source_dir = self.repo_root.join(&manifest.source.dir);
|
||||
let source_dir = self.repo_root.join(&source.dir);
|
||||
|
||||
match self.install_from_artifact(manifest, force).await {
|
||||
Ok(outcome) => Ok(outcome),
|
||||
@@ -391,6 +441,13 @@ impl RegistryInstaller {
|
||||
let target_dir = match manifest.kind {
|
||||
ManifestKind::Tool => &self.tools_dir,
|
||||
ManifestKind::Channel => &self.channels_dir,
|
||||
ManifestKind::McpServer => {
|
||||
return Err(RegistryError::InvalidManifest {
|
||||
name: manifest.name.clone(),
|
||||
field: "kind",
|
||||
reason: "MCP servers cannot be installed as artifacts".to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
fs::create_dir_all(target_dir)
|
||||
@@ -458,12 +515,9 @@ impl RegistryInstaller {
|
||||
false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} else if let Some(ref source) = manifest.source {
|
||||
// Legacy fallback: try source tree
|
||||
let caps_source = self
|
||||
.repo_root
|
||||
.join(&manifest.source.dir)
|
||||
.join(&manifest.source.capabilities);
|
||||
let caps_source = self.repo_root.join(&source.dir).join(&source.capabilities);
|
||||
if caps_source.exists() {
|
||||
fs::copy(&caps_source, &target_caps)
|
||||
.await
|
||||
@@ -472,6 +526,8 @@ impl RegistryInstaller {
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
@@ -775,17 +831,19 @@ mod tests {
|
||||
name: name.to_string(),
|
||||
display_name: name.to_string(),
|
||||
kind,
|
||||
version: "0.1.0".to_string(),
|
||||
version: Some("0.1.0".to_string()),
|
||||
description: "test manifest".to_string(),
|
||||
keywords: Vec::new(),
|
||||
source: SourceSpec {
|
||||
source: Some(SourceSpec {
|
||||
dir: source_dir.to_string(),
|
||||
capabilities: format!("{}.capabilities.json", name),
|
||||
crate_name: name.to_string(),
|
||||
},
|
||||
}),
|
||||
artifacts,
|
||||
auth_summary: None,
|
||||
tags: Vec::new(),
|
||||
url: None,
|
||||
auth: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+192
-21
@@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::extensions::{AuthHint, ExtensionKind, ExtensionSource, RegistryEntry};
|
||||
|
||||
/// A single extension manifest loaded from `registry/{tools,channels}/<name>.json`.
|
||||
/// A single extension manifest loaded from `registry/{tools,channels,mcp-servers}/<name>.json`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExtensionManifest {
|
||||
/// Unique identifier (matches crate name stem, e.g. "slack").
|
||||
@@ -16,11 +16,12 @@ pub struct ExtensionManifest {
|
||||
/// Human-readable name (e.g. "Slack").
|
||||
pub display_name: String,
|
||||
|
||||
/// Whether this is a tool or channel.
|
||||
/// Whether this is a tool, channel, or MCP server.
|
||||
pub kind: ManifestKind,
|
||||
|
||||
/// Semver version from Cargo.toml.
|
||||
pub version: String,
|
||||
/// Semver version from Cargo.toml. Optional for MCP server manifests.
|
||||
#[serde(default)]
|
||||
pub version: Option<String>,
|
||||
|
||||
/// One-line description.
|
||||
pub description: String,
|
||||
@@ -29,8 +30,9 @@ pub struct ExtensionManifest {
|
||||
#[serde(default)]
|
||||
pub keywords: Vec<String>,
|
||||
|
||||
/// Source code location and build info.
|
||||
pub source: SourceSpec,
|
||||
/// Source code location and build info. Absent for MCP server manifests.
|
||||
#[serde(default)]
|
||||
pub source: Option<SourceSpec>,
|
||||
|
||||
/// Pre-built binary artifacts keyed by target triple.
|
||||
#[serde(default)]
|
||||
@@ -43,6 +45,15 @@ pub struct ExtensionManifest {
|
||||
/// Tags for filtering (e.g. "default", "messaging", "google").
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
|
||||
/// MCP server URL. Only present for `McpServer` manifests.
|
||||
#[serde(default)]
|
||||
pub url: Option<String>,
|
||||
|
||||
/// MCP auth method: "dcr", "oauth_pre_configured:<setup_url>", or "none".
|
||||
/// Only present for `McpServer` manifests.
|
||||
#[serde(default)]
|
||||
pub auth: Option<String>,
|
||||
}
|
||||
|
||||
/// Extension kind as declared in manifests.
|
||||
@@ -51,6 +62,7 @@ pub struct ExtensionManifest {
|
||||
pub enum ManifestKind {
|
||||
Tool,
|
||||
Channel,
|
||||
McpServer,
|
||||
}
|
||||
|
||||
impl From<ManifestKind> for ExtensionKind {
|
||||
@@ -58,6 +70,7 @@ impl From<ManifestKind> for ExtensionKind {
|
||||
match kind {
|
||||
ManifestKind::Tool => ExtensionKind::WasmTool,
|
||||
ManifestKind::Channel => ExtensionKind::WasmChannel,
|
||||
ManifestKind::McpServer => ExtensionKind::McpServer,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,6 +80,7 @@ impl std::fmt::Display for ManifestKind {
|
||||
match self {
|
||||
ManifestKind::Tool => write!(f, "tool"),
|
||||
ManifestKind::Channel => write!(f, "channel"),
|
||||
ManifestKind::McpServer => write!(f, "mcp_server"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,12 +167,64 @@ pub struct BundlesFile {
|
||||
impl ExtensionManifest {
|
||||
/// Convert this manifest into a [`RegistryEntry`] for use with the in-chat
|
||||
/// extension discovery system.
|
||||
pub fn to_registry_entry(&self) -> RegistryEntry {
|
||||
let buildable = ExtensionSource::WasmBuildable {
|
||||
source_dir: self.source.dir.clone(),
|
||||
build_dir: Some(self.source.dir.clone()),
|
||||
crate_name: Some(self.source.crate_name.clone()),
|
||||
///
|
||||
/// Returns `None` for MCP server manifests missing a `url` field.
|
||||
pub fn to_registry_entry(&self) -> Option<RegistryEntry> {
|
||||
if self.kind == ManifestKind::McpServer {
|
||||
return self.to_mcp_registry_entry();
|
||||
}
|
||||
|
||||
Some(self.to_wasm_registry_entry())
|
||||
}
|
||||
|
||||
/// Build a [`RegistryEntry`] for an MCP server manifest.
|
||||
fn to_mcp_registry_entry(&self) -> Option<RegistryEntry> {
|
||||
let url = match &self.url {
|
||||
Some(u) => u.clone(),
|
||||
None => {
|
||||
tracing::warn!(
|
||||
"MCP server manifest '{}' is missing 'url' field, skipping",
|
||||
self.name
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let auth_hint = match self.auth.as_deref() {
|
||||
Some("dcr") | None => AuthHint::Dcr,
|
||||
Some("none") => AuthHint::None,
|
||||
Some(other) if other.starts_with("oauth_pre_configured:") => {
|
||||
AuthHint::OAuthPreConfigured {
|
||||
setup_url: other
|
||||
.strip_prefix("oauth_pre_configured:")
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
_ => AuthHint::Dcr,
|
||||
};
|
||||
|
||||
Some(RegistryEntry {
|
||||
name: self.name.clone(),
|
||||
display_name: self.display_name.clone(),
|
||||
kind: ExtensionKind::McpServer,
|
||||
description: self.description.clone(),
|
||||
keywords: self.keywords.clone(),
|
||||
source: ExtensionSource::McpUrl { url },
|
||||
fallback_source: None,
|
||||
auth_hint,
|
||||
version: self.version.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a [`RegistryEntry`] for a WASM tool or channel manifest.
|
||||
fn to_wasm_registry_entry(&self) -> RegistryEntry {
|
||||
let source_spec = self.source.as_ref();
|
||||
|
||||
let buildable = source_spec.map(|s| ExtensionSource::WasmBuildable {
|
||||
source_dir: s.dir.clone(),
|
||||
build_dir: Some(s.dir.clone()),
|
||||
crate_name: Some(s.crate_name.clone()),
|
||||
});
|
||||
|
||||
// Prefer pre-built artifact download when a URL is available,
|
||||
// with build-from-source as fallback in case the download fails (e.g., 404).
|
||||
@@ -170,13 +236,32 @@ impl ExtensionManifest {
|
||||
wasm_url: url.clone(),
|
||||
capabilities_url: artifact.capabilities_url.clone(),
|
||||
},
|
||||
Some(Box::new(buildable)),
|
||||
buildable.map(Box::new),
|
||||
)
|
||||
} else if let Some(b) = buildable {
|
||||
(b, None)
|
||||
} else {
|
||||
(buildable, None)
|
||||
// No source spec and no download URL — use a placeholder
|
||||
(
|
||||
ExtensionSource::WasmBuildable {
|
||||
source_dir: String::new(),
|
||||
build_dir: None,
|
||||
crate_name: None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
}
|
||||
} else if let Some(b) = buildable {
|
||||
(b, None)
|
||||
} else {
|
||||
(buildable, None)
|
||||
(
|
||||
ExtensionSource::WasmBuildable {
|
||||
source_dir: String::new(),
|
||||
build_dir: None,
|
||||
crate_name: None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
};
|
||||
|
||||
let auth_hint = match self.auth_summary.as_ref().and_then(|a| a.method.as_deref()) {
|
||||
@@ -195,7 +280,7 @@ impl ExtensionManifest {
|
||||
source,
|
||||
fallback_source,
|
||||
auth_hint,
|
||||
version: Some(self.version.clone()),
|
||||
version: self.version.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -234,10 +319,10 @@ mod tests {
|
||||
let manifest: ExtensionManifest = serde_json::from_str(json).expect("parse manifest");
|
||||
assert_eq!(manifest.name, "slack");
|
||||
assert_eq!(manifest.kind, ManifestKind::Tool);
|
||||
assert_eq!(manifest.version, "0.1.0");
|
||||
assert_eq!(manifest.version.as_deref(), Some("0.1.0"));
|
||||
assert!(manifest.tags.contains(&"default".to_string()));
|
||||
|
||||
let entry = manifest.to_registry_entry();
|
||||
let entry = manifest.to_registry_entry().unwrap();
|
||||
assert_eq!(entry.kind, ExtensionKind::WasmTool);
|
||||
}
|
||||
|
||||
@@ -262,7 +347,7 @@ mod tests {
|
||||
assert!(manifest.auth_summary.is_none());
|
||||
assert!(manifest.artifacts.is_empty());
|
||||
|
||||
let entry = manifest.to_registry_entry();
|
||||
let entry = manifest.to_registry_entry().unwrap();
|
||||
assert_eq!(entry.kind, ExtensionKind::WasmChannel);
|
||||
}
|
||||
|
||||
@@ -296,6 +381,7 @@ mod tests {
|
||||
fn test_manifest_kind_display() {
|
||||
assert_eq!(ManifestKind::Tool.to_string(), "tool");
|
||||
assert_eq!(ManifestKind::Channel.to_string(), "channel");
|
||||
assert_eq!(ManifestKind::McpServer.to_string(), "mcp_server");
|
||||
}
|
||||
|
||||
/// When a manifest has a download URL in artifacts, to_registry_entry()
|
||||
@@ -324,7 +410,7 @@ mod tests {
|
||||
}"#;
|
||||
|
||||
let manifest: ExtensionManifest = serde_json::from_str(json).expect("parse manifest");
|
||||
let entry = manifest.to_registry_entry();
|
||||
let entry = manifest.to_registry_entry().unwrap();
|
||||
|
||||
// Primary source should be WasmDownload
|
||||
assert!(
|
||||
@@ -374,7 +460,7 @@ mod tests {
|
||||
}"#;
|
||||
|
||||
let manifest: ExtensionManifest = serde_json::from_str(json).expect("parse manifest");
|
||||
let entry = manifest.to_registry_entry();
|
||||
let entry = manifest.to_registry_entry().unwrap();
|
||||
|
||||
assert!(
|
||||
matches!(&entry.source, ExtensionSource::WasmBuildable { .. }),
|
||||
@@ -405,7 +491,7 @@ mod tests {
|
||||
}"#;
|
||||
|
||||
let manifest: ExtensionManifest = serde_json::from_str(json).expect("parse manifest");
|
||||
let entry = manifest.to_registry_entry();
|
||||
let entry = manifest.to_registry_entry().unwrap();
|
||||
|
||||
assert!(
|
||||
matches!(&entry.source, ExtensionSource::WasmBuildable { .. }),
|
||||
@@ -416,4 +502,89 @@ mod tests {
|
||||
"Should have no fallback when already using WasmBuildable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mcp_server_manifest() {
|
||||
let json = r#"{
|
||||
"name": "notion",
|
||||
"display_name": "Notion",
|
||||
"kind": "mcp_server",
|
||||
"description": "Connect to Notion for reading and writing pages, databases, and comments",
|
||||
"keywords": ["notes", "wiki", "docs", "pages", "database"],
|
||||
"url": "https://mcp.notion.com/mcp",
|
||||
"auth": "dcr"
|
||||
}"#;
|
||||
|
||||
let manifest: ExtensionManifest = serde_json::from_str(json).expect("parse manifest");
|
||||
assert_eq!(manifest.name, "notion");
|
||||
assert_eq!(manifest.kind, ManifestKind::McpServer);
|
||||
assert!(manifest.version.is_none());
|
||||
assert!(manifest.source.is_none());
|
||||
assert_eq!(manifest.url.as_deref(), Some("https://mcp.notion.com/mcp"));
|
||||
assert_eq!(manifest.auth.as_deref(), Some("dcr"));
|
||||
|
||||
let entry = manifest.to_registry_entry().unwrap();
|
||||
assert_eq!(entry.kind, ExtensionKind::McpServer);
|
||||
assert!(
|
||||
matches!(&entry.source, ExtensionSource::McpUrl { url } if url == "https://mcp.notion.com/mcp")
|
||||
);
|
||||
assert!(matches!(&entry.auth_hint, AuthHint::Dcr));
|
||||
assert!(entry.fallback_source.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_server_oauth_pre_configured() {
|
||||
let json = r#"{
|
||||
"name": "custom-mcp",
|
||||
"display_name": "Custom MCP",
|
||||
"kind": "mcp_server",
|
||||
"description": "Custom MCP server",
|
||||
"keywords": [],
|
||||
"url": "https://mcp.example.com",
|
||||
"auth": "oauth_pre_configured:https://example.com/setup"
|
||||
}"#;
|
||||
|
||||
let manifest: ExtensionManifest = serde_json::from_str(json).expect("parse manifest");
|
||||
let entry = manifest.to_registry_entry().unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
&entry.auth_hint,
|
||||
AuthHint::OAuthPreConfigured { setup_url } if setup_url == "https://example.com/setup"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_server_auth_none() {
|
||||
let json = r#"{
|
||||
"name": "local-mcp",
|
||||
"display_name": "Local MCP",
|
||||
"kind": "mcp_server",
|
||||
"description": "Local MCP server",
|
||||
"keywords": [],
|
||||
"url": "http://localhost:8080/mcp",
|
||||
"auth": "none"
|
||||
}"#;
|
||||
|
||||
let manifest: ExtensionManifest = serde_json::from_str(json).expect("parse manifest");
|
||||
let entry = manifest.to_registry_entry().unwrap();
|
||||
|
||||
assert!(matches!(&entry.auth_hint, AuthHint::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_server_missing_url_returns_none() {
|
||||
let json = r#"{
|
||||
"name": "broken-mcp",
|
||||
"display_name": "Broken MCP",
|
||||
"kind": "mcp_server",
|
||||
"description": "MCP server with no URL",
|
||||
"keywords": []
|
||||
}"#;
|
||||
|
||||
let manifest: ExtensionManifest = serde_json::from_str(json).expect("parse manifest");
|
||||
assert!(
|
||||
manifest.to_registry_entry().is_none(),
|
||||
"MCP manifest without url should return None"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+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
|
||||
}
|
||||
|
||||
+37
-32
@@ -214,6 +214,7 @@ fn is_disallowed_ipv4(v4: &Ipv4Addr) -> bool {
|
||||
|| v4.is_multicast()
|
||||
|| v4.is_unspecified()
|
||||
|| *v4 == Ipv4Addr::new(169, 254, 169, 254)
|
||||
|| (v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64)
|
||||
}
|
||||
|
||||
fn is_disallowed_ip(ip: &IpAddr) -> bool {
|
||||
@@ -398,7 +399,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 +430,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 +441,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 +830,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
|
||||
}
|
||||
|
||||
@@ -909,6 +914,8 @@ mod tests {
|
||||
assert!(is_disallowed_ip(&IpAddr::V4(Ipv4Addr::new(
|
||||
169, 254, 169, 254
|
||||
))));
|
||||
// Carrier-grade NAT
|
||||
assert!(is_disallowed_ip(&IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1))));
|
||||
// Public
|
||||
assert!(!is_disallowed_ip(&IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
|
||||
}
|
||||
@@ -1063,12 +1070,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 +1169,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 +1189,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 +1197,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 +1227,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 +1239,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;
|
||||
|
||||
+252
-121
@@ -24,6 +24,132 @@ use crate::context::JobContext;
|
||||
use crate::db::Database;
|
||||
use crate::tools::tool::{ApprovalRequirement, Tool, ToolError, ToolOutput, require_str};
|
||||
|
||||
pub(crate) fn routine_create_parameters_schema() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Unique routine name, for example 'daily-pr-review'."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Short summary of what the routine is for."
|
||||
},
|
||||
"trigger_type": {
|
||||
"type": "string",
|
||||
"enum": ["cron", "event", "system_event", "manual"],
|
||||
"description": "When the routine fires: 'cron' for schedules, 'event' for incoming messages, 'system_event' for structured emitted events, or 'manual' for explicit runs."
|
||||
},
|
||||
"schedule": {
|
||||
"type": "string",
|
||||
"description": "Cron schedule for 'cron' triggers. Uses 6 fields: second minute hour day month weekday."
|
||||
},
|
||||
"event_pattern": {
|
||||
"type": "string",
|
||||
"description": "Regex matched against incoming message text for 'event' triggers, for example '^bug\\\\b'."
|
||||
},
|
||||
"event_channel": {
|
||||
"type": "string",
|
||||
"description": "Optional platform filter for 'event' triggers, for example 'telegram'. Omit to match any channel. Not a chat or thread ID."
|
||||
},
|
||||
"event_source": {
|
||||
"type": "string",
|
||||
"description": "Structured event source for 'system_event' triggers, for example 'github'."
|
||||
},
|
||||
"event_type": {
|
||||
"type": "string",
|
||||
"description": "Structured event type for 'system_event' triggers, for example 'issue.opened'."
|
||||
},
|
||||
"event_filters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": {
|
||||
"type": ["string", "number", "boolean"]
|
||||
},
|
||||
"description": "Optional exact-match payload filters for 'system_event' triggers. Values can be strings, numbers, or booleans."
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "Instructions for what the routine should do after it fires."
|
||||
},
|
||||
"context_paths": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Workspace paths to load as extra context before running the routine."
|
||||
},
|
||||
"action_type": {
|
||||
"type": "string",
|
||||
"enum": ["lightweight", "full_job"],
|
||||
"description": "Execution mode: 'lightweight' for one LLM turn or 'full_job' for a multi-step job with tools."
|
||||
},
|
||||
"use_tools": {
|
||||
"type": "boolean",
|
||||
"description": "Enable safe tool use in 'lightweight' mode. Ignored for 'full_job'."
|
||||
},
|
||||
"max_tool_rounds": {
|
||||
"type": "integer",
|
||||
"description": "Maximum tool-call rounds in 'lightweight' mode when 'use_tools' is true."
|
||||
},
|
||||
"cooldown_secs": {
|
||||
"type": "integer",
|
||||
"description": "Minimum seconds between fires."
|
||||
},
|
||||
"tool_permissions": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Pre-authorized tool names for 'full_job' routines."
|
||||
},
|
||||
"notify_channel": {
|
||||
"type": "string",
|
||||
"description": "Where routine output should be sent, for example 'telegram' or 'slack'. This does not control what triggers the routine."
|
||||
},
|
||||
"notify_user": {
|
||||
"type": "string",
|
||||
"description": "User or destination to notify, for example a username or chat ID."
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"description": "IANA timezone used to evaluate 'cron' schedules, for example 'America/New_York'."
|
||||
}
|
||||
},
|
||||
"required": ["name", "trigger_type", "prompt"]
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn routine_update_parameters_schema() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the routine to update."
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Set to true to enable the routine or false to disable it."
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "Replace the routine instructions for what it should do after it fires."
|
||||
},
|
||||
"schedule": {
|
||||
"type": "string",
|
||||
"description": "New cron schedule for existing 'cron' routines only. This does not convert other trigger types."
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"description": "New IANA timezone for existing 'cron' routines only, for example 'America/New_York'."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Replace the routine summary."
|
||||
}
|
||||
},
|
||||
"required": ["name"]
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== routine_create ====================
|
||||
|
||||
pub struct RoutineCreateTool {
|
||||
@@ -50,92 +176,7 @@ impl Tool for RoutineCreateTool {
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Unique name for the routine (e.g. 'daily-pr-review')"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "What this routine does"
|
||||
},
|
||||
"trigger_type": {
|
||||
"type": "string",
|
||||
"enum": ["cron", "event", "system_event", "manual"],
|
||||
"description": "When the routine fires"
|
||||
},
|
||||
"schedule": {
|
||||
"type": "string",
|
||||
"description": "Cron expression (for cron trigger). E.g. '0 9 * * MON-FRI' for weekdays at 9am. Uses 6-field cron (sec min hour day month weekday)."
|
||||
},
|
||||
"event_pattern": {
|
||||
"type": "string",
|
||||
"description": "Regex pattern to match messages (for event trigger)"
|
||||
},
|
||||
"event_channel": {
|
||||
"type": "string",
|
||||
"description": "Optional channel filter for event trigger (e.g. 'telegram')"
|
||||
},
|
||||
"event_source": {
|
||||
"type": "string",
|
||||
"description": "Event source for system_event triggers (e.g. 'github')"
|
||||
},
|
||||
"event_type": {
|
||||
"type": "string",
|
||||
"description": "Event type for system_event triggers (e.g. 'issue.opened')"
|
||||
},
|
||||
"event_filters": {
|
||||
"type": "object",
|
||||
"description": "Optional exact-match filters against payload fields for system_event triggers. Values can be strings, numbers, or booleans."
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "The prompt/instructions for the routine"
|
||||
},
|
||||
"context_paths": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Workspace paths to load as context (e.g. ['context/priorities.md'])"
|
||||
},
|
||||
"action_type": {
|
||||
"type": "string",
|
||||
"enum": ["lightweight", "full_job"],
|
||||
"description": "Execution mode: 'lightweight' (single LLM call, default) or 'full_job' (multi-turn with tools)"
|
||||
},
|
||||
"use_tools": {
|
||||
"type": "boolean",
|
||||
"description": "Enable tool access in lightweight mode (default: false). Only safe tools (no approval required) are available. Ignored for full_job mode."
|
||||
},
|
||||
"max_tool_rounds": {
|
||||
"type": "integer",
|
||||
"description": "Max tool call rounds in lightweight mode (default: 3). Only used when use_tools is true."
|
||||
},
|
||||
"cooldown_secs": {
|
||||
"type": "integer",
|
||||
"description": "Minimum seconds between fires (default: 300)"
|
||||
},
|
||||
"tool_permissions": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Tool names pre-authorized for Always-approval tools in full_job mode (e.g. ['shell']). UnlessAutoApproved tools are automatically permitted in routines."
|
||||
},
|
||||
"notify_channel": {
|
||||
"type": "string",
|
||||
"description": "Channel to send results to (e.g. 'telegram', 'slack', 'tui'). Sets the default channel for message tool calls in routine jobs."
|
||||
},
|
||||
"notify_user": {
|
||||
"type": "string",
|
||||
"description": "User/target to notify (e.g. username, chat ID). Defaults to 'default'."
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"description": "IANA timezone for cron schedule evaluation (e.g. 'America/New_York'). Defaults to UTC."
|
||||
}
|
||||
},
|
||||
"required": ["name", "trigger_type", "prompt"]
|
||||
})
|
||||
routine_create_parameters_schema()
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
@@ -199,9 +240,13 @@ impl Tool for RoutineCreateTool {
|
||||
"event trigger requires 'event_pattern'".to_string(),
|
||||
)
|
||||
})?;
|
||||
// Validate regex
|
||||
regex::Regex::new(pattern)
|
||||
.map_err(|e| ToolError::InvalidParameters(format!("invalid regex: {e}")))?;
|
||||
// Validate regex with size limit to prevent ReDoS (issue #825)
|
||||
regex::RegexBuilder::new(pattern)
|
||||
.size_limit(64 * 1024)
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
ToolError::InvalidParameters(format!("invalid or too complex regex: {e}"))
|
||||
})?;
|
||||
let channel = params
|
||||
.get("event_channel")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -478,41 +523,13 @@ impl Tool for RoutineUpdateTool {
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Update an existing routine. Can modify trigger, prompt, schedule, or toggle enabled state. \
|
||||
Pass the routine name and only the fields you want to change."
|
||||
"Update an existing routine. Can change prompt, description, enabled state, or cron timing. \
|
||||
Pass the routine name and only the fields you want to change. \
|
||||
This does not convert one trigger type into another."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the routine to update"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable or disable the routine"
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "New prompt/instructions"
|
||||
},
|
||||
"schedule": {
|
||||
"type": "string",
|
||||
"description": "New cron schedule (for cron triggers)"
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"description": "IANA timezone for cron schedule (e.g. 'America/New_York'). Only valid for cron triggers."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "New description"
|
||||
}
|
||||
},
|
||||
"required": ["name"]
|
||||
})
|
||||
routine_update_parameters_schema()
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
@@ -953,3 +970,117 @@ impl Tool for EventEmitTool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{routine_create_parameters_schema, routine_update_parameters_schema};
|
||||
use crate::tools::validate_tool_schema;
|
||||
|
||||
fn property<'a>(schema: &'a serde_json::Value, name: &str) -> &'a serde_json::Value {
|
||||
schema
|
||||
.get("properties")
|
||||
.and_then(|props| props.get(name))
|
||||
.unwrap_or_else(|| panic!("missing schema property {name}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routine_create_schema_exposes_all_trigger_and_delivery_fields() {
|
||||
let schema = routine_create_parameters_schema();
|
||||
let errors = validate_tool_schema(&schema, "routine_create");
|
||||
assert!(
|
||||
errors.is_empty(),
|
||||
"routine_create schema should validate cleanly: {errors:?}"
|
||||
);
|
||||
|
||||
for field in [
|
||||
"trigger_type",
|
||||
"schedule",
|
||||
"event_pattern",
|
||||
"event_channel",
|
||||
"event_source",
|
||||
"event_type",
|
||||
"event_filters",
|
||||
"action_type",
|
||||
"use_tools",
|
||||
"max_tool_rounds",
|
||||
"tool_permissions",
|
||||
"notify_channel",
|
||||
"notify_user",
|
||||
"timezone",
|
||||
] {
|
||||
let _ = property(&schema, field);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routine_create_schema_descriptions_cover_event_trigger_gotchas() {
|
||||
let schema = routine_create_parameters_schema();
|
||||
|
||||
let trigger_type = property(&schema, "trigger_type")
|
||||
.get("description")
|
||||
.and_then(|value| value.as_str())
|
||||
.expect("trigger_type description");
|
||||
assert!(trigger_type.contains("incoming messages"));
|
||||
assert!(trigger_type.contains("structured emitted events"));
|
||||
|
||||
let event_pattern = property(&schema, "event_pattern")
|
||||
.get("description")
|
||||
.and_then(|value| value.as_str())
|
||||
.expect("event_pattern description");
|
||||
assert!(event_pattern.contains("incoming message text"));
|
||||
assert!(event_pattern.contains("^bug\\\\b"));
|
||||
|
||||
let event_channel = property(&schema, "event_channel")
|
||||
.get("description")
|
||||
.and_then(|value| value.as_str())
|
||||
.expect("event_channel description");
|
||||
assert!(event_channel.contains("Omit to match any channel"));
|
||||
assert!(event_channel.contains("Not a chat or thread ID"));
|
||||
|
||||
let notify_channel = property(&schema, "notify_channel")
|
||||
.get("description")
|
||||
.and_then(|value| value.as_str())
|
||||
.expect("notify_channel description");
|
||||
assert!(notify_channel.contains("does not control what triggers"));
|
||||
|
||||
let prompt = property(&schema, "prompt")
|
||||
.get("description")
|
||||
.and_then(|value| value.as_str())
|
||||
.expect("prompt description");
|
||||
assert!(prompt.contains("after it fires"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routine_update_schema_exposes_supported_fields_and_limits() {
|
||||
let schema = routine_update_parameters_schema();
|
||||
let errors = validate_tool_schema(&schema, "routine_update");
|
||||
assert!(
|
||||
errors.is_empty(),
|
||||
"routine_update schema should validate cleanly: {errors:?}"
|
||||
);
|
||||
|
||||
for field in [
|
||||
"name",
|
||||
"enabled",
|
||||
"prompt",
|
||||
"schedule",
|
||||
"timezone",
|
||||
"description",
|
||||
] {
|
||||
let _ = property(&schema, field);
|
||||
}
|
||||
|
||||
let schedule = property(&schema, "schedule")
|
||||
.get("description")
|
||||
.and_then(|value| value.as_str())
|
||||
.expect("schedule description");
|
||||
assert!(schedule.contains("existing 'cron' routines only"));
|
||||
assert!(schedule.contains("does not convert other trigger types"));
|
||||
|
||||
let timezone = property(&schema, "timezone")
|
||||
.get("description")
|
||||
.and_then(|value| value.as_str())
|
||||
.expect("timezone description");
|
||||
assert!(timezone.contains("existing 'cron' routines only"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,7 +247,11 @@ fn resolve_timezone_for_output(
|
||||
params: &serde_json::Value,
|
||||
ctx: &JobContext,
|
||||
) -> Result<Option<(Tz, String)>, ToolError> {
|
||||
if let Some(name) = params.get("timezone").and_then(|v| v.as_str()) {
|
||||
if let Some(name) = params
|
||||
.get("timezone")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
let tz = parse_timezone(name)?;
|
||||
return Ok(Some((tz, tz.to_string())));
|
||||
}
|
||||
@@ -286,7 +290,11 @@ fn context_timezone(ctx: &JobContext) -> Result<Option<(Tz, String)>, ToolError>
|
||||
|
||||
fn optional_timezone(params: &serde_json::Value, keys: &[&str]) -> Result<Option<Tz>, ToolError> {
|
||||
for key in keys {
|
||||
if let Some(value) = params.get(*key).and_then(|v| v.as_str()) {
|
||||
if let Some(value) = params
|
||||
.get(*key)
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
return parse_timezone(value).map(Some);
|
||||
}
|
||||
}
|
||||
@@ -534,4 +542,48 @@ mod tests {
|
||||
|
||||
assert_eq!(dt.to_rfc3339(), "2026-03-08T07:30:00+00:00");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_now_with_empty_timezone_string_does_not_error() {
|
||||
// LLMs sometimes pass "" for optional fields instead of omitting them.
|
||||
// Empty timezone should be treated as absent and fall back to UTC.
|
||||
let tool = TimeTool;
|
||||
let ctx = JobContext::with_user("test", "chat", "test");
|
||||
|
||||
let output = tool
|
||||
.execute(
|
||||
serde_json::json!({
|
||||
"operation": "now",
|
||||
"timezone": ""
|
||||
}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.expect("empty timezone string should not error");
|
||||
|
||||
assert!(output.result.get("iso").is_some(), "should have iso");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_convert_with_empty_from_timezone_string_does_not_error() {
|
||||
// LLMs sometimes pass "" for optional fields instead of omitting them.
|
||||
// Empty from_timezone should be treated as absent.
|
||||
let tool = TimeTool;
|
||||
let ctx = JobContext::with_user("test", "chat", "test");
|
||||
|
||||
let output = tool
|
||||
.execute(
|
||||
serde_json::json!({
|
||||
"operation": "convert",
|
||||
"timestamp": "2026-03-08T12:00:00Z",
|
||||
"to_timezone": "America/New_York",
|
||||
"from_timezone": ""
|
||||
}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.expect("empty from_timezone string should not error");
|
||||
|
||||
assert!(output.result.get("output").is_some(), "should have output");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(_))));
|
||||
}
|
||||
}
|
||||
+60
-40
@@ -18,6 +18,44 @@ use crate::cli::oauth_defaults::{self, OAUTH_CALLBACK_PORT};
|
||||
use crate::secrets::{CreateSecretParams, SecretsStore};
|
||||
use crate::tools::mcp::config::McpServerConfig;
|
||||
|
||||
/// Shared HTTP client for all OAuth/discovery requests.
|
||||
///
|
||||
/// Redirects are disabled for security (prevents redirect-based SSRF).
|
||||
/// Per-request timeouts can override the default via `.timeout()` on
|
||||
/// the request builder.
|
||||
fn oauth_http_client() -> Result<&'static reqwest::Client, AuthError> {
|
||||
static CLIENT: std::sync::OnceLock<Result<reqwest::Client, String>> =
|
||||
std::sync::OnceLock::new();
|
||||
CLIENT
|
||||
.get_or_init(|| {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.as_ref()
|
||||
.map_err(|e| AuthError::Http(e.clone()))
|
||||
}
|
||||
|
||||
/// Log a debug message when a discovery/auth response is a redirect.
|
||||
/// Helps users diagnose configuration issues when legitimate servers
|
||||
/// redirect and our no-redirect policy causes a failure.
|
||||
fn log_redirect_if_applicable(url: &str, response: &reqwest::Response) {
|
||||
if response.status().is_redirection() {
|
||||
let location = response
|
||||
.headers()
|
||||
.get("location")
|
||||
.and_then(|v| v.to_str().ok());
|
||||
tracing::debug!(
|
||||
"OAuth request to '{}' returned redirect {} -> {:?} (redirects disabled for security)",
|
||||
url,
|
||||
response.status(),
|
||||
location
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// OAuth authorization error.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AuthError {
|
||||
@@ -287,10 +325,8 @@ async fn validate_url_safe(url: &str) -> Result<(), AuthError> {
|
||||
)));
|
||||
}
|
||||
if scheme == "http" {
|
||||
let host = parsed.host_str().unwrap_or("");
|
||||
let is_localhost =
|
||||
host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "[::1]";
|
||||
if !is_localhost {
|
||||
if !crate::tools::mcp::config::is_localhost_url(url) {
|
||||
let host = parsed.host_str().unwrap_or("");
|
||||
return Err(AuthError::DiscoveryFailed(format!(
|
||||
"HTTP is only allowed for localhost; use HTTPS for '{}'",
|
||||
host
|
||||
@@ -382,18 +418,17 @@ fn parse_resource_metadata_url(www_authenticate: &str) -> Option<String> {
|
||||
async fn fetch_resource_metadata(url: &str) -> Result<ProtectedResourceMetadata, AuthError> {
|
||||
validate_url_safe(url).await?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|e| AuthError::Http(e.to_string()))?;
|
||||
let client = oauth_http_client()?;
|
||||
|
||||
let response = client
|
||||
.get(url)
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AuthError::DiscoveryFailed(e.to_string()))?;
|
||||
|
||||
log_redirect_if_applicable(url, &response);
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(AuthError::DiscoveryFailed(format!(
|
||||
"HTTP {}",
|
||||
@@ -411,20 +446,19 @@ async fn fetch_resource_metadata(url: &str) -> Result<ProtectedResourceMetadata,
|
||||
async fn discover_via_401(server_url: &str) -> Result<AuthorizationServerMetadata, AuthError> {
|
||||
validate_url_safe(server_url).await?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|e| AuthError::Http(e.to_string()))?;
|
||||
let client = oauth_http_client()?;
|
||||
|
||||
let response = client
|
||||
.post(server_url)
|
||||
.timeout(Duration::from_secs(10))
|
||||
.header("Content-Type", "application/json")
|
||||
.body("{}")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AuthError::DiscoveryFailed(e.to_string()))?;
|
||||
|
||||
log_redirect_if_applicable(server_url, &response);
|
||||
|
||||
if response.status().as_u16() != 401 {
|
||||
return Err(AuthError::DiscoveryFailed(format!(
|
||||
"Expected 401, got {}",
|
||||
@@ -472,20 +506,19 @@ pub async fn discover_protected_resource(
|
||||
) -> Result<ProtectedResourceMetadata, AuthError> {
|
||||
validate_url_safe(server_url).await?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|e| AuthError::Http(e.to_string()))?;
|
||||
let client = oauth_http_client()?;
|
||||
|
||||
let well_known_url = build_well_known_uri(server_url, "oauth-protected-resource")?;
|
||||
|
||||
let response = client
|
||||
.get(&well_known_url)
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AuthError::DiscoveryFailed(e.to_string()))?;
|
||||
|
||||
log_redirect_if_applicable(&well_known_url, &response);
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(AuthError::NotSupported);
|
||||
}
|
||||
@@ -502,20 +535,19 @@ pub async fn discover_authorization_server(
|
||||
) -> Result<AuthorizationServerMetadata, AuthError> {
|
||||
validate_url_safe(auth_server_url).await?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|e| AuthError::Http(e.to_string()))?;
|
||||
let client = oauth_http_client()?;
|
||||
|
||||
let well_known_url = build_well_known_uri(auth_server_url, "oauth-authorization-server")?;
|
||||
|
||||
let response = client
|
||||
.get(&well_known_url)
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AuthError::DiscoveryFailed(e.to_string()))?;
|
||||
|
||||
log_redirect_if_applicable(&well_known_url, &response);
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(AuthError::DiscoveryFailed(format!(
|
||||
"HTTP {}",
|
||||
@@ -595,11 +627,7 @@ pub async fn register_client(
|
||||
) -> Result<ClientRegistrationResponse, AuthError> {
|
||||
validate_url_safe(registration_endpoint).await?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|e| AuthError::Http(e.to_string()))?;
|
||||
let client = oauth_http_client()?;
|
||||
|
||||
let request = ClientRegistrationRequest {
|
||||
client_name: "IronClaw".to_string(),
|
||||
@@ -813,7 +841,7 @@ pub fn build_authorization_url(
|
||||
if let Some(pkce) = pkce {
|
||||
url.push_str(&format!(
|
||||
"&code_challenge={}&code_challenge_method=S256",
|
||||
pkce.challenge
|
||||
urlencoding::encode(&pkce.challenge)
|
||||
));
|
||||
}
|
||||
|
||||
@@ -863,11 +891,7 @@ pub async fn exchange_code_for_token(
|
||||
) -> Result<AccessToken, AuthError> {
|
||||
validate_url_safe(token_url).await?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|e| AuthError::Http(e.to_string()))?;
|
||||
let client = oauth_http_client()?;
|
||||
|
||||
let mut params = vec![
|
||||
("grant_type", "authorization_code".to_string()),
|
||||
@@ -1054,11 +1078,7 @@ pub async fn refresh_access_token(
|
||||
|
||||
validate_url_safe(&token_url).await?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|e| AuthError::Http(e.to_string()))?;
|
||||
let client = oauth_http_client()?;
|
||||
|
||||
// Compute canonical resource URI for RFC 8707
|
||||
let resource = canonical_resource_uri(&server_config.url);
|
||||
|
||||
+205
-63
@@ -5,7 +5,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::RwLock;
|
||||
@@ -58,9 +58,10 @@ pub struct McpClient {
|
||||
/// Custom headers to include in every request.
|
||||
custom_headers: HashMap<String, String>,
|
||||
|
||||
/// Whether the MCP initialize handshake has completed.
|
||||
/// Used as a local idempotency guard when no session_manager is present.
|
||||
initialized: AtomicBool,
|
||||
/// Ensures the MCP initialize handshake runs exactly once.
|
||||
/// Uses `OnceCell` to serialize concurrent callers so only one
|
||||
/// actually sends the request; subsequent calls return immediately.
|
||||
initialized: tokio::sync::OnceCell<InitializeResult>,
|
||||
}
|
||||
|
||||
impl McpClient {
|
||||
@@ -83,7 +84,7 @@ impl McpClient {
|
||||
user_id: "default".to_string(),
|
||||
server_config: None,
|
||||
custom_headers: HashMap::new(),
|
||||
initialized: AtomicBool::new(false),
|
||||
initialized: tokio::sync::OnceCell::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +107,7 @@ impl McpClient {
|
||||
user_id: "default".to_string(),
|
||||
server_config: None,
|
||||
custom_headers: HashMap::new(),
|
||||
initialized: AtomicBool::new(false),
|
||||
initialized: tokio::sync::OnceCell::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,20 +115,24 @@ impl McpClient {
|
||||
///
|
||||
/// Use this when you have an `McpServerConfig` with custom headers but no OAuth.
|
||||
/// The config must use HTTP transport (the default); for stdio/UDS use `new_with_transport`.
|
||||
pub fn new_with_config(config: McpServerConfig) -> Self {
|
||||
assert!(
|
||||
matches!(
|
||||
config.effective_transport(),
|
||||
crate::tools::mcp::config::EffectiveTransport::Http
|
||||
),
|
||||
"new_with_config only supports HTTP transport; use new_with_transport for stdio/UDS"
|
||||
);
|
||||
///
|
||||
/// Returns an error if the config uses a non-HTTP transport.
|
||||
pub fn new_with_config(config: McpServerConfig) -> Result<Self, ToolError> {
|
||||
if !matches!(
|
||||
config.effective_transport(),
|
||||
crate::tools::mcp::config::EffectiveTransport::Http
|
||||
) {
|
||||
return Err(ToolError::InvalidParameters(
|
||||
"new_with_config only supports HTTP transport; use new_with_transport for stdio/UDS"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let transport = Arc::new(HttpMcpTransport::new(
|
||||
config.url.clone(),
|
||||
config.name.clone(),
|
||||
));
|
||||
|
||||
Self {
|
||||
Ok(Self {
|
||||
transport,
|
||||
server_url: config.url.clone(),
|
||||
server_name: config.name.clone(),
|
||||
@@ -137,9 +142,9 @@ impl McpClient {
|
||||
secrets: None,
|
||||
user_id: "default".to_string(),
|
||||
custom_headers: config.headers.clone(),
|
||||
initialized: AtomicBool::new(false),
|
||||
initialized: tokio::sync::OnceCell::new(),
|
||||
server_config: Some(config),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new authenticated MCP client.
|
||||
@@ -169,7 +174,7 @@ impl McpClient {
|
||||
user_id: user_id.into(),
|
||||
server_config: Some(config),
|
||||
custom_headers,
|
||||
initialized: AtomicBool::new(false),
|
||||
initialized: tokio::sync::OnceCell::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,7 +210,7 @@ impl McpClient {
|
||||
user_id: user_id.into(),
|
||||
server_config,
|
||||
custom_headers,
|
||||
initialized: AtomicBool::new(false),
|
||||
initialized: tokio::sync::OnceCell::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,53 +341,64 @@ impl McpClient {
|
||||
}
|
||||
|
||||
/// Initialize the connection to the MCP server.
|
||||
///
|
||||
/// Uses `OnceCell` to guarantee that exactly one caller performs the
|
||||
/// handshake, even under concurrent access. Subsequent calls return
|
||||
/// immediately.
|
||||
pub async fn initialize(&self) -> Result<InitializeResult, ToolError> {
|
||||
// Fast path: already initialized (local flag or session manager)
|
||||
if self.initialized.load(Ordering::Relaxed) {
|
||||
return Ok(InitializeResult::default());
|
||||
}
|
||||
if let Some(ref session_manager) = self.session_manager
|
||||
&& session_manager.is_initialized(&self.server_name).await
|
||||
{
|
||||
self.initialized.store(true, Ordering::Relaxed);
|
||||
return Ok(InitializeResult::default());
|
||||
}
|
||||
if let Some(ref session_manager) = self.session_manager {
|
||||
session_manager
|
||||
.get_or_create(&self.server_name, &self.server_url)
|
||||
.await;
|
||||
}
|
||||
let result = self
|
||||
.initialized
|
||||
.get_or_try_init(|| async {
|
||||
if let Some(ref session_manager) = self.session_manager
|
||||
&& session_manager.is_initialized(&self.server_name).await
|
||||
{
|
||||
return Ok(InitializeResult::default());
|
||||
}
|
||||
if let Some(ref session_manager) = self.session_manager {
|
||||
session_manager
|
||||
.get_or_create(&self.server_name, &self.server_url)
|
||||
.await;
|
||||
}
|
||||
|
||||
let request = McpRequest::initialize(self.next_request_id());
|
||||
let response = self.send_request(request).await?;
|
||||
let request = McpRequest::initialize(self.next_request_id());
|
||||
let response = self.send_request(request).await?;
|
||||
|
||||
if let Some(error) = response.error {
|
||||
return Err(ToolError::ExternalService(format!(
|
||||
"MCP initialization error: {} (code {})",
|
||||
error.message, error.code
|
||||
)));
|
||||
}
|
||||
if let Some(error) = response.error {
|
||||
return Err(ToolError::ExternalService(format!(
|
||||
"MCP initialization error: {} (code {})",
|
||||
error.message, error.code
|
||||
)));
|
||||
}
|
||||
|
||||
let result: InitializeResult = response
|
||||
.result
|
||||
.ok_or_else(|| {
|
||||
ToolError::ExternalService("No result in initialize response".to_string())
|
||||
let init_result: InitializeResult = response
|
||||
.result
|
||||
.ok_or_else(|| {
|
||||
ToolError::ExternalService("No result in initialize response".to_string())
|
||||
})
|
||||
.and_then(|r| {
|
||||
serde_json::from_value(r).map_err(|e| {
|
||||
ToolError::ExternalService(format!("Invalid initialize result: {}", e))
|
||||
})
|
||||
})?;
|
||||
|
||||
if let Some(ref session_manager) = self.session_manager {
|
||||
session_manager.mark_initialized(&self.server_name).await;
|
||||
}
|
||||
|
||||
let notification = McpRequest::initialized_notification();
|
||||
if let Err(e) = self.send_request(notification).await {
|
||||
tracing::debug!(
|
||||
"Failed to send initialized notification to '{}': {}",
|
||||
self.server_name,
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
Ok(init_result)
|
||||
})
|
||||
.and_then(|r| {
|
||||
serde_json::from_value(r).map_err(|e| {
|
||||
ToolError::ExternalService(format!("Invalid initialize result: {}", e))
|
||||
})
|
||||
})?;
|
||||
.await?;
|
||||
|
||||
if let Some(ref session_manager) = self.session_manager {
|
||||
session_manager.mark_initialized(&self.server_name).await;
|
||||
}
|
||||
self.initialized.store(true, Ordering::Relaxed);
|
||||
|
||||
let notification = McpRequest::initialized_notification();
|
||||
let _ = self.send_request(notification).await;
|
||||
|
||||
Ok(result)
|
||||
Ok(result.clone())
|
||||
}
|
||||
|
||||
/// List available tools from the MCP server.
|
||||
@@ -471,6 +487,11 @@ impl McpClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clone the client, resetting the tools cache and initialization state.
|
||||
/// The cloned client shares the same transport and session manager, so
|
||||
/// re-initialization will short-circuit via the session manager check if
|
||||
/// the source was already initialized. The `next_id` counter is copied
|
||||
/// so that cloned clients continue with monotonically increasing IDs.
|
||||
impl Clone for McpClient {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
@@ -484,7 +505,7 @@ impl Clone for McpClient {
|
||||
user_id: self.user_id.clone(),
|
||||
server_config: self.server_config.clone(),
|
||||
custom_headers: self.custom_headers.clone(),
|
||||
initialized: AtomicBool::new(self.initialized.load(Ordering::Relaxed)),
|
||||
initialized: tokio::sync::OnceCell::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -707,7 +728,7 @@ mod tests {
|
||||
headers.insert("X-Custom".to_string(), "value".to_string());
|
||||
|
||||
let config = McpServerConfig::new("test", "http://localhost:8080").with_headers(headers);
|
||||
let client = McpClient::new_with_config(config.clone());
|
||||
let client = McpClient::new_with_config(config.clone()).expect("HTTP config should work");
|
||||
|
||||
assert_eq!(client.server_name(), "test");
|
||||
assert_eq!(client.server_url(), "http://localhost:8080");
|
||||
@@ -719,7 +740,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_new_with_config_no_headers() {
|
||||
let config = McpServerConfig::new("bare", "http://localhost:9090");
|
||||
let client = McpClient::new_with_config(config);
|
||||
let client = McpClient::new_with_config(config).expect("HTTP config should work");
|
||||
|
||||
assert_eq!(client.server_name(), "bare");
|
||||
assert!(client.custom_headers.is_empty());
|
||||
@@ -971,4 +992,125 @@ mod tests {
|
||||
assert_eq!(obj.len(), 1);
|
||||
assert!(obj["outer"]["inner"].is_null());
|
||||
}
|
||||
|
||||
// --- Issue 1 regression: new_with_config rejects non-HTTP transport ---
|
||||
|
||||
#[test]
|
||||
fn test_new_with_config_rejects_stdio_transport() {
|
||||
let config = McpServerConfig::new_stdio(
|
||||
"stdio-server",
|
||||
"echo",
|
||||
vec!["hello".to_string()],
|
||||
HashMap::new(),
|
||||
);
|
||||
let result = McpClient::new_with_config(config);
|
||||
let err = result
|
||||
.err()
|
||||
.expect("stdio config must be rejected")
|
||||
.to_string();
|
||||
assert!(
|
||||
err.contains("new_with_config only supports HTTP"),
|
||||
"error should explain the restriction: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
// --- Issue 13: McpToolWrapper unit tests ---
|
||||
|
||||
fn make_test_mcp_tool(destructive: bool) -> McpTool {
|
||||
use crate::tools::mcp::protocol::McpToolAnnotations;
|
||||
McpTool {
|
||||
name: "do_thing".to_string(),
|
||||
description: "Does a thing".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input": {"type": "string"}
|
||||
}
|
||||
}),
|
||||
annotations: if destructive {
|
||||
Some(McpToolAnnotations {
|
||||
destructive_hint: true,
|
||||
side_effects_hint: false,
|
||||
read_only_hint: false,
|
||||
execution_time_hint: None,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_tool_wrapper_name_is_prefixed() {
|
||||
let client = Arc::new(McpClient::new("http://localhost:8080"));
|
||||
let wrapper = McpToolWrapper {
|
||||
tool: make_test_mcp_tool(false),
|
||||
prefixed_name: "mcp__myserver__do_thing".to_string(),
|
||||
client,
|
||||
};
|
||||
assert_eq!(wrapper.name(), "mcp__myserver__do_thing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_tool_wrapper_description() {
|
||||
let client = Arc::new(McpClient::new("http://localhost:8080"));
|
||||
let wrapper = McpToolWrapper {
|
||||
tool: make_test_mcp_tool(false),
|
||||
prefixed_name: "mcp__s__do_thing".to_string(),
|
||||
client,
|
||||
};
|
||||
assert_eq!(wrapper.description(), "Does a thing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_tool_wrapper_parameters_schema() {
|
||||
let client = Arc::new(McpClient::new("http://localhost:8080"));
|
||||
let wrapper = McpToolWrapper {
|
||||
tool: make_test_mcp_tool(false),
|
||||
prefixed_name: "mcp__s__do_thing".to_string(),
|
||||
client,
|
||||
};
|
||||
let schema = wrapper.parameters_schema();
|
||||
assert_eq!(schema["type"], "object");
|
||||
assert!(schema["properties"]["input"].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_tool_wrapper_requires_sanitization() {
|
||||
let client = Arc::new(McpClient::new("http://localhost:8080"));
|
||||
let wrapper = McpToolWrapper {
|
||||
tool: make_test_mcp_tool(false),
|
||||
prefixed_name: "mcp__s__do_thing".to_string(),
|
||||
client,
|
||||
};
|
||||
assert!(
|
||||
wrapper.requires_sanitization(),
|
||||
"MCP tools should always require sanitization"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_tool_wrapper_approval_destructive() {
|
||||
let client = Arc::new(McpClient::new("http://localhost:8080"));
|
||||
let wrapper = McpToolWrapper {
|
||||
tool: make_test_mcp_tool(true),
|
||||
prefixed_name: "mcp__s__do_thing".to_string(),
|
||||
client,
|
||||
};
|
||||
let approval = wrapper.requires_approval(&serde_json::json!({}));
|
||||
assert_eq!(approval, ApprovalRequirement::UnlessAutoApproved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_tool_wrapper_approval_non_destructive() {
|
||||
let client = Arc::new(McpClient::new("http://localhost:8080"));
|
||||
let wrapper = McpToolWrapper {
|
||||
tool: make_test_mcp_tool(false),
|
||||
prefixed_name: "mcp__s__do_thing".to_string(),
|
||||
client,
|
||||
};
|
||||
let approval = wrapper.requires_approval(&serde_json::json!({}));
|
||||
assert_eq!(approval, ApprovalRequirement::Never);
|
||||
}
|
||||
}
|
||||
|
||||
+38
-6
@@ -163,10 +163,8 @@ impl McpServerConfig {
|
||||
}
|
||||
|
||||
// Remote servers must use HTTPS (localhost is allowed for development)
|
||||
let url_lower = self.url.to_lowercase();
|
||||
let is_localhost =
|
||||
url_lower.contains("localhost") || url_lower.contains("127.0.0.1");
|
||||
if !is_localhost && !url_lower.starts_with("https://") {
|
||||
let is_localhost = is_localhost_url(&self.url);
|
||||
if !is_localhost && !self.url.to_lowercase().starts_with("https://") {
|
||||
return Err(ConfigError::InvalidConfig {
|
||||
reason: "Remote MCP servers must use HTTPS".to_string(),
|
||||
});
|
||||
@@ -442,7 +440,12 @@ pub async fn save_mcp_servers_to(
|
||||
}
|
||||
|
||||
let content = serde_json::to_string_pretty(config)?;
|
||||
fs::write(path, content).await?;
|
||||
|
||||
// Write to a temporary file first, then atomically rename to avoid
|
||||
// corrupting the config if the process crashes during the write.
|
||||
let tmp_path = path.with_extension("json.tmp");
|
||||
fs::write(&tmp_path, content).await?;
|
||||
fs::rename(&tmp_path, path).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -570,7 +573,7 @@ pub async fn remove_mcp_server_db(
|
||||
///
|
||||
/// Uses `url::Url` for proper parsing so edge cases (IPv6, userinfo, ports)
|
||||
/// are handled correctly without manual string splitting.
|
||||
fn is_localhost_url(url: &str) -> bool {
|
||||
pub(crate) fn is_localhost_url(url: &str) -> bool {
|
||||
let Ok(parsed) = url::Url::parse(url) else {
|
||||
return false;
|
||||
};
|
||||
@@ -1125,4 +1128,33 @@ mod tests {
|
||||
assert!(parsed.transport.is_none());
|
||||
assert_eq!(parsed.headers.get("X-Custom").unwrap(), "value");
|
||||
}
|
||||
|
||||
// --- Issue 3 regression: is_localhost_url rejects attacker subdomains ---
|
||||
|
||||
#[test]
|
||||
fn test_is_localhost_url_rejects_attacker_subdomain() {
|
||||
// Before the fix, url.contains("localhost") matched this.
|
||||
assert!(
|
||||
!is_localhost_url("http://evil.localhost.attacker.com:8080/mcp"),
|
||||
"attacker subdomain containing 'localhost' must not be treated as local"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_localhost_url_accepts_real_localhost() {
|
||||
assert!(is_localhost_url("http://localhost:8080/mcp"));
|
||||
assert!(is_localhost_url("https://localhost/path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_localhost_url_accepts_loopback_ip() {
|
||||
assert!(is_localhost_url("http://127.0.0.1:3000"));
|
||||
assert!(is_localhost_url("http://[::1]:3000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_localhost_url_rejects_remote() {
|
||||
assert!(!is_localhost_url("https://mcp.example.com"));
|
||||
assert!(!is_localhost_url("http://192.168.1.1:8080"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ pub enum McpFactoryError {
|
||||
UnixConnect { name: String, reason: String },
|
||||
#[error("Unix socket transport is not supported on this platform (server '{name}')")]
|
||||
UnixNotSupported { name: String },
|
||||
#[error("Invalid configuration for MCP server '{name}': {reason}")]
|
||||
InvalidConfig { name: String, reason: String },
|
||||
}
|
||||
|
||||
/// Create an `McpClient` from a server configuration, dispatching on the
|
||||
@@ -89,10 +91,18 @@ pub async fn create_client_from_config(
|
||||
))
|
||||
} else {
|
||||
Ok(McpClient::new_with_config(server)
|
||||
.map_err(|e| McpFactoryError::InvalidConfig {
|
||||
name: server_name.clone(),
|
||||
reason: e.to_string(),
|
||||
})?
|
||||
.with_session_manager(Arc::clone(session_manager)))
|
||||
}
|
||||
} else {
|
||||
Ok(McpClient::new_with_config(server)
|
||||
.map_err(|e| McpFactoryError::InvalidConfig {
|
||||
name: server_name,
|
||||
reason: e.to_string(),
|
||||
})?
|
||||
.with_session_manager(Arc::clone(session_manager)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ impl McpTransport for HttpMcpTransport {
|
||||
.to_string();
|
||||
|
||||
if content_type.contains("text/event-stream") {
|
||||
self.parse_sse_response(response).await
|
||||
self.parse_sse_response(response, request.id).await
|
||||
} else {
|
||||
response.json().await.map_err(|e| {
|
||||
ToolError::ExternalService(format!(
|
||||
@@ -161,11 +161,14 @@ impl McpTransport for HttpMcpTransport {
|
||||
}
|
||||
|
||||
impl HttpMcpTransport {
|
||||
/// Parse a Server-Sent Events response, returning the first valid JSON-RPC
|
||||
/// `data:` line as an [`McpResponse`].
|
||||
/// Parse a Server-Sent Events response, returning the JSON-RPC response
|
||||
/// whose `id` matches `request_id`. Non-matching events (e.g. server
|
||||
/// notifications or progress updates) are skipped so that the caller
|
||||
/// receives the actual result for its request.
|
||||
async fn parse_sse_response(
|
||||
&self,
|
||||
response: reqwest::Response,
|
||||
request_id: Option<u64>,
|
||||
) -> Result<McpResponse, ToolError> {
|
||||
use futures::StreamExt;
|
||||
|
||||
@@ -202,9 +205,10 @@ impl HttpMcpTransport {
|
||||
remaining_start = i + 1;
|
||||
|
||||
if let Some(json_str) = line.strip_prefix("data: ")
|
||||
&& let Ok(response) = serde_json::from_str::<McpResponse>(json_str)
|
||||
&& let Ok(resp) = serde_json::from_str::<McpResponse>(json_str)
|
||||
&& resp.id == request_id
|
||||
{
|
||||
return Ok(response);
|
||||
return Ok(resp);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,14 +220,15 @@ impl HttpMcpTransport {
|
||||
|
||||
// Process any remaining data without a trailing newline.
|
||||
if let Some(json_str) = buffer.strip_prefix("data: ")
|
||||
&& let Ok(response) = serde_json::from_str::<McpResponse>(json_str.trim())
|
||||
&& let Ok(resp) = serde_json::from_str::<McpResponse>(json_str.trim())
|
||||
&& resp.id == request_id
|
||||
{
|
||||
return Ok(response);
|
||||
return Ok(resp);
|
||||
}
|
||||
|
||||
Err(ToolError::ExternalService(format!(
|
||||
"[{}] No valid data in SSE response: {}",
|
||||
self.server_name, buffer
|
||||
"[{}] No matching response (id={:?}) in SSE stream",
|
||||
self.server_name, request_id
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ use tokio::sync::{Mutex, oneshot};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::tools::mcp::protocol::{McpRequest, McpResponse};
|
||||
use crate::tools::mcp::transport::{McpTransport, spawn_jsonrpc_reader, write_jsonrpc_line};
|
||||
use crate::tools::mcp::transport::{McpTransport, spawn_jsonrpc_reader, stream_transport_send};
|
||||
use crate::tools::tool::ToolError;
|
||||
|
||||
/// MCP transport that communicates with a child process over stdin/stdout.
|
||||
@@ -118,63 +118,14 @@ impl McpTransport for StdioMcpTransport {
|
||||
request: &McpRequest,
|
||||
_headers: &HashMap<String, String>,
|
||||
) -> Result<McpResponse, ToolError> {
|
||||
// JSON-RPC notifications (no id) are fire-and-forget: the server
|
||||
// will not send a response, so we must not wait for one.
|
||||
if request.id.is_none() {
|
||||
let mut stdin = self.stdin.lock().await;
|
||||
write_jsonrpc_line(&mut *stdin, request).await?;
|
||||
return Ok(McpResponse {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: None,
|
||||
result: None,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
|
||||
let id = request.id.unwrap_or(0);
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
// Register the pending response handler before writing the request,
|
||||
// so we don't miss a fast response from the child.
|
||||
{
|
||||
let mut pending = self.pending.lock().await;
|
||||
pending.insert(id, tx);
|
||||
}
|
||||
|
||||
// Write the request to stdin.
|
||||
{
|
||||
let mut stdin = self.stdin.lock().await;
|
||||
if let Err(e) = write_jsonrpc_line(&mut *stdin, request).await {
|
||||
// Remove the pending entry on write failure.
|
||||
let mut pending = self.pending.lock().await;
|
||||
pending.remove(&id);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for the response with a timeout.
|
||||
let timeout = Duration::from_secs(30);
|
||||
match tokio::time::timeout(timeout, rx).await {
|
||||
Ok(Ok(response)) => Ok(response),
|
||||
Ok(Err(_)) => {
|
||||
// Sender was dropped (reader task ended). Clean up pending entry.
|
||||
let mut pending = self.pending.lock().await;
|
||||
pending.remove(&id);
|
||||
Err(ToolError::ExternalService(format!(
|
||||
"[{}] MCP server closed connection before responding to request {:?}",
|
||||
self.server_name, request.id
|
||||
)))
|
||||
}
|
||||
Err(_) => {
|
||||
// Timeout: remove the pending entry.
|
||||
let mut pending = self.pending.lock().await;
|
||||
pending.remove(&id);
|
||||
Err(ToolError::ExternalService(format!(
|
||||
"[{}] Timeout waiting for response to request {:?} after {:?}",
|
||||
self.server_name, request.id, timeout
|
||||
)))
|
||||
}
|
||||
}
|
||||
stream_transport_send(
|
||||
&self.stdin,
|
||||
&self.pending,
|
||||
request,
|
||||
&self.server_name,
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn shutdown(&self) -> Result<(), ToolError> {
|
||||
|
||||
+105
-1
@@ -97,7 +97,13 @@ pub fn spawn_jsonrpc_reader<R: AsyncBufRead + Unpin + Send + 'static>(
|
||||
}
|
||||
};
|
||||
|
||||
let id = response.id.unwrap_or(0);
|
||||
let Some(id) = response.id else {
|
||||
tracing::debug!(
|
||||
"[{}] Received JSON-RPC notification (no id), skipping dispatch",
|
||||
server_name
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let mut map = pending.lock().await;
|
||||
if let Some(tx) = map.remove(&id) {
|
||||
// Ignore send error — the receiver may have been dropped (timeout).
|
||||
@@ -115,6 +121,76 @@ pub fn spawn_jsonrpc_reader<R: AsyncBufRead + Unpin + Send + 'static>(
|
||||
})
|
||||
}
|
||||
|
||||
/// Send a JSON-RPC request over a stream-based transport (stdio / unix socket).
|
||||
///
|
||||
/// Handles notification fire-and-forget, pending response registration,
|
||||
/// write, timeout, and cleanup. Used by both [`StdioMcpTransport`] and
|
||||
/// [`UnixMcpTransport`] to avoid duplicating the send logic.
|
||||
pub(crate) async fn stream_transport_send<W: AsyncWrite + Unpin>(
|
||||
writer: &Mutex<W>,
|
||||
pending: &Mutex<HashMap<u64, oneshot::Sender<McpResponse>>>,
|
||||
request: &McpRequest,
|
||||
server_name: &str,
|
||||
timeout_duration: std::time::Duration,
|
||||
) -> Result<McpResponse, ToolError> {
|
||||
// JSON-RPC notifications (no id) are fire-and-forget: the server
|
||||
// will not send a response, so we must not wait for one.
|
||||
if request.id.is_none() {
|
||||
let mut w = writer.lock().await;
|
||||
write_jsonrpc_line(&mut *w, request).await?;
|
||||
return Ok(McpResponse {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: None,
|
||||
result: None,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
|
||||
let id = request.id.unwrap_or(0);
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
// Register the pending response handler before writing the request,
|
||||
// so we don't miss a fast response from the server.
|
||||
{
|
||||
let mut map = pending.lock().await;
|
||||
map.insert(id, tx);
|
||||
}
|
||||
|
||||
// Write the request.
|
||||
{
|
||||
let mut w = writer.lock().await;
|
||||
if let Err(e) = write_jsonrpc_line(&mut *w, request).await {
|
||||
// Remove the pending entry on write failure.
|
||||
let mut map = pending.lock().await;
|
||||
map.remove(&id);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for the response with a timeout.
|
||||
match tokio::time::timeout(timeout_duration, rx).await {
|
||||
Ok(Ok(response)) => Ok(response),
|
||||
Ok(Err(_)) => {
|
||||
// Sender was dropped (reader task ended). Clean up pending entry.
|
||||
let mut map = pending.lock().await;
|
||||
map.remove(&id);
|
||||
Err(ToolError::ExternalService(format!(
|
||||
"[{}] MCP server closed connection before responding to request {:?}",
|
||||
server_name, request.id
|
||||
)))
|
||||
}
|
||||
Err(_) => {
|
||||
// Timeout: remove the pending entry.
|
||||
let mut map = pending.lock().await;
|
||||
map.remove(&id);
|
||||
Err(ToolError::ExternalService(format!(
|
||||
"[{}] Timeout waiting for response to request {:?} after {:?}",
|
||||
server_name, request.id, timeout_duration
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -193,4 +269,32 @@ mod tests {
|
||||
|
||||
handle.await.expect("reader task should finish");
|
||||
}
|
||||
|
||||
/// Issue 9 regression: a JSON-RPC notification (no id) must not resolve
|
||||
/// a pending request keyed by id 0 (the old `unwrap_or(0)` default).
|
||||
#[tokio::test]
|
||||
async fn test_notification_does_not_resolve_pending_id_zero() {
|
||||
// A notification response (no id), followed by a proper response for id 0.
|
||||
let notification = r#"{"jsonrpc":"2.0","method":"notifications/progress","params":{}}"#;
|
||||
let real_response = r#"{"jsonrpc":"2.0","id":0,"result":{"ok":true}}"#;
|
||||
let input = format!("{notification}\n{real_response}\n");
|
||||
|
||||
let reader = std::io::Cursor::new(input.into_bytes());
|
||||
let pending: Arc<Mutex<HashMap<u64, oneshot::Sender<McpResponse>>>> =
|
||||
Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
let (tx, rx) = oneshot::channel();
|
||||
{
|
||||
let mut map = pending.lock().await;
|
||||
map.insert(0, tx);
|
||||
}
|
||||
|
||||
let handle = spawn_jsonrpc_reader(reader, pending.clone(), "test".into());
|
||||
|
||||
let resp = rx.await.expect("should receive the real id=0 response");
|
||||
assert_eq!(resp.id, Some(0));
|
||||
assert!(resp.result.is_some());
|
||||
|
||||
handle.await.expect("reader task should finish");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ use tokio::sync::{Mutex, oneshot};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::tools::mcp::protocol::{McpRequest, McpResponse};
|
||||
use crate::tools::mcp::transport::{McpTransport, spawn_jsonrpc_reader, write_jsonrpc_line};
|
||||
use crate::tools::mcp::transport::{McpTransport, spawn_jsonrpc_reader, stream_transport_send};
|
||||
use crate::tools::tool::ToolError;
|
||||
|
||||
/// MCP transport that communicates over a Unix domain socket.
|
||||
@@ -91,63 +91,14 @@ impl McpTransport for UnixMcpTransport {
|
||||
request: &McpRequest,
|
||||
_headers: &HashMap<String, String>,
|
||||
) -> Result<McpResponse, ToolError> {
|
||||
// JSON-RPC notifications (no id) are fire-and-forget: the server
|
||||
// will not send a response, so we must not wait for one.
|
||||
if request.id.is_none() {
|
||||
let mut writer = self.writer.lock().await;
|
||||
write_jsonrpc_line(&mut *writer, request).await?;
|
||||
return Ok(McpResponse {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: None,
|
||||
result: None,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
|
||||
let id = request.id.unwrap_or(0);
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
// Register the pending response handler before writing the request,
|
||||
// so we don't miss a fast response from the server.
|
||||
{
|
||||
let mut pending = self.pending.lock().await;
|
||||
pending.insert(id, tx);
|
||||
}
|
||||
|
||||
// Write the request to the socket.
|
||||
{
|
||||
let mut writer = self.writer.lock().await;
|
||||
if let Err(e) = write_jsonrpc_line(&mut *writer, request).await {
|
||||
// Remove the pending entry on write failure.
|
||||
let mut pending = self.pending.lock().await;
|
||||
pending.remove(&id);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for the response with a timeout.
|
||||
let timeout = Duration::from_secs(30);
|
||||
match tokio::time::timeout(timeout, rx).await {
|
||||
Ok(Ok(response)) => Ok(response),
|
||||
Ok(Err(_)) => {
|
||||
// Sender was dropped (reader task ended). Clean up pending entry.
|
||||
let mut pending = self.pending.lock().await;
|
||||
pending.remove(&id);
|
||||
Err(ToolError::ExternalService(format!(
|
||||
"[{}] MCP server closed connection before responding to request {:?}",
|
||||
self.server_name, request.id
|
||||
)))
|
||||
}
|
||||
Err(_) => {
|
||||
// Timeout: remove the pending entry.
|
||||
let mut pending = self.pending.lock().await;
|
||||
pending.remove(&id);
|
||||
Err(ToolError::ExternalService(format!(
|
||||
"[{}] Timeout waiting for response to request {:?} after {:?}",
|
||||
self.server_name, request.id, timeout
|
||||
)))
|
||||
}
|
||||
}
|
||||
stream_transport_send(
|
||||
&self.writer,
|
||||
&self.pending,
|
||||
request,
|
||||
&self.server_name,
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn shutdown(&self) -> Result<(), ToolError> {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -558,48 +558,7 @@ mod tests {
|
||||
// Routine tools
|
||||
(
|
||||
"routine_create",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string", "description": "Routine name" },
|
||||
"description": { "type": "string", "description": "What it does" },
|
||||
"trigger_type": {
|
||||
"type": "string",
|
||||
"enum": ["cron", "event", "system_event", "manual"],
|
||||
"description": "When the routine fires"
|
||||
},
|
||||
"schedule": { "type": "string", "description": "Cron expression" },
|
||||
"event_pattern": { "type": "string", "description": "Regex pattern" },
|
||||
"event_channel": { "type": "string", "description": "Channel filter" },
|
||||
"event_source": { "type": "string", "description": "System event source" },
|
||||
"event_type": { "type": "string", "description": "System event type" },
|
||||
"event_filters": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" },
|
||||
"description": "Exact-match payload filters"
|
||||
},
|
||||
"prompt": { "type": "string", "description": "Instructions" },
|
||||
"context_paths": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Workspace paths to load"
|
||||
},
|
||||
"action_type": {
|
||||
"type": "string",
|
||||
"enum": ["lightweight", "full_job"],
|
||||
"description": "Execution mode"
|
||||
},
|
||||
"cooldown_secs": { "type": "integer", "description": "Min seconds between fires" },
|
||||
"tool_permissions": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Pre-authorized tools for full_job mode"
|
||||
},
|
||||
"notify_channel": { "type": "string", "description": "Channel for message tool" },
|
||||
"notify_user": { "type": "string", "description": "User/target to notify" }
|
||||
},
|
||||
"required": ["name", "trigger_type", "prompt"]
|
||||
}),
|
||||
crate::tools::builtin::routine::routine_create_parameters_schema(),
|
||||
),
|
||||
(
|
||||
"routine_list",
|
||||
@@ -611,17 +570,7 @@ mod tests {
|
||||
),
|
||||
(
|
||||
"routine_update",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string", "description": "Name" },
|
||||
"enabled": { "type": "boolean", "description": "Toggle" },
|
||||
"prompt": { "type": "string", "description": "New prompt" },
|
||||
"schedule": { "type": "string", "description": "New cron schedule" },
|
||||
"description": { "type": "string", "description": "New description" }
|
||||
},
|
||||
"required": ["name"]
|
||||
}),
|
||||
crate::tools::builtin::routine::routine_update_parameters_schema(),
|
||||
),
|
||||
(
|
||||
"routine_delete",
|
||||
|
||||
+59
-3
@@ -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 {
|
||||
@@ -419,9 +430,24 @@ pub fn redact_params(params: &serde_json::Value, sensitive: &[&str]) -> serde_js
|
||||
/// Properties without a `"type"` field are allowed (freeform/any-type).
|
||||
/// This is an intentional pattern used by tools like `json` and `http` for
|
||||
/// OpenAI compatibility, since union types with arrays require `items`.
|
||||
/// Maximum nesting depth for tool schema validation to prevent stack overflow
|
||||
/// on maliciously crafted schemas.
|
||||
const MAX_SCHEMA_DEPTH: usize = 16;
|
||||
|
||||
pub fn validate_tool_schema(schema: &serde_json::Value, path: &str) -> Vec<String> {
|
||||
validate_tool_schema_inner(schema, path, 0)
|
||||
}
|
||||
|
||||
fn validate_tool_schema_inner(schema: &serde_json::Value, path: &str, depth: usize) -> Vec<String> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
if depth > MAX_SCHEMA_DEPTH {
|
||||
errors.push(format!(
|
||||
"{path}: schema nesting exceeds maximum depth of {MAX_SCHEMA_DEPTH}"
|
||||
));
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Rule 1: must have "type": "object" at this level
|
||||
match schema.get("type").and_then(|t| t.as_str()) {
|
||||
Some("object") => {}
|
||||
@@ -463,14 +489,17 @@ pub fn validate_tool_schema(schema: &serde_json::Value, path: &str) -> Vec<Strin
|
||||
if let Some(prop_type) = prop.get("type").and_then(|t| t.as_str()) {
|
||||
match prop_type {
|
||||
"object" => {
|
||||
errors.extend(validate_tool_schema(prop, &prop_path));
|
||||
errors.extend(validate_tool_schema_inner(prop, &prop_path, depth + 1));
|
||||
}
|
||||
"array" => {
|
||||
if let Some(items) = prop.get("items") {
|
||||
// If items is an object type, recurse
|
||||
if items.get("type").and_then(|t| t.as_str()) == Some("object") {
|
||||
errors
|
||||
.extend(validate_tool_schema(items, &format!("{prop_path}.items")));
|
||||
errors.extend(validate_tool_schema_inner(
|
||||
items,
|
||||
&format!("{prop_path}.items"),
|
||||
depth + 1,
|
||||
));
|
||||
}
|
||||
} else {
|
||||
errors.push(format!("{prop_path}: array property missing \"items\""));
|
||||
@@ -799,6 +828,33 @@ mod tests {
|
||||
assert!(errors[0].contains("\"missing_field\""));
|
||||
}
|
||||
|
||||
/// Regression test for issue #975: deeply nested schemas must not cause
|
||||
/// stack overflow. The validator should stop at MAX_SCHEMA_DEPTH and
|
||||
/// report an error instead of recursing infinitely.
|
||||
#[test]
|
||||
fn test_validate_schema_depth_limit() {
|
||||
// Build a schema nested 20 levels deep (exceeds MAX_SCHEMA_DEPTH=16)
|
||||
let mut schema = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"leaf": { "type": "string" }
|
||||
}
|
||||
});
|
||||
for _ in 0..20 {
|
||||
schema = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"nested": schema
|
||||
}
|
||||
});
|
||||
}
|
||||
let errors = validate_tool_schema(&schema, "test");
|
||||
assert!(
|
||||
errors.iter().any(|e| e.contains("maximum depth")),
|
||||
"expected depth limit error, got: {errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approval_context_autonomous_allows_unless_auto_approved() {
|
||||
let ctx = ApprovalContext::autonomous();
|
||||
|
||||
@@ -101,24 +101,75 @@ pub struct CapabilitiesFile {
|
||||
pub capabilities: Option<Box<CapabilitiesFile>>,
|
||||
}
|
||||
|
||||
/// Maximum length for the description field to prevent memory abuse.
|
||||
const MAX_DESCRIPTION_CHARS: usize = 4096;
|
||||
/// Maximum serialized size of the parameters schema JSON.
|
||||
const MAX_PARAMETERS_SCHEMA_BYTES: usize = 64 * 1024;
|
||||
|
||||
impl CapabilitiesFile {
|
||||
/// Parse from JSON string.
|
||||
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
|
||||
serde_json::from_str::<Self>(json).map(Self::resolve_nested)
|
||||
let mut caps = serde_json::from_str::<Self>(json).map(Self::resolve_nested)?;
|
||||
caps.enforce_limits();
|
||||
Ok(caps)
|
||||
}
|
||||
|
||||
/// Parse from JSON bytes.
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
|
||||
serde_json::from_slice::<Self>(bytes).map(Self::resolve_nested)
|
||||
let mut caps = serde_json::from_slice::<Self>(bytes).map(Self::resolve_nested)?;
|
||||
caps.enforce_limits();
|
||||
Ok(caps)
|
||||
}
|
||||
|
||||
/// Truncate oversized fields to prevent unbounded memory usage.
|
||||
fn enforce_limits(&mut self) {
|
||||
// Truncate oversized description (issue #976)
|
||||
if let Some(ref desc) = self.description
|
||||
&& desc.len() > MAX_DESCRIPTION_CHARS
|
||||
{
|
||||
let truncated = &desc[..desc.floor_char_boundary(MAX_DESCRIPTION_CHARS)];
|
||||
tracing::warn!(
|
||||
"Capabilities description truncated from {} to {} chars",
|
||||
desc.len(),
|
||||
MAX_DESCRIPTION_CHARS,
|
||||
);
|
||||
self.description = Some(truncated.to_string());
|
||||
}
|
||||
// Drop oversized parameters schema (issue #977)
|
||||
if let Some(ref params) = self.parameters {
|
||||
let size = params.to_string().len();
|
||||
if size > MAX_PARAMETERS_SCHEMA_BYTES {
|
||||
tracing::warn!(
|
||||
"Capabilities parameters schema dropped ({} bytes exceeds {} limit)",
|
||||
size,
|
||||
MAX_PARAMETERS_SCHEMA_BYTES,
|
||||
);
|
||||
self.parameters = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge nested `capabilities` wrapper into top-level fields.
|
||||
///
|
||||
/// Channel-level JSON nests tool capabilities under `"capabilities"`.
|
||||
/// This promotes the inner fields so callers can access them uniformly.
|
||||
fn resolve_nested(mut self) -> Self {
|
||||
/// Maximum nesting depth for capabilities resolution.
|
||||
const MAX_NESTED_DEPTH: usize = 8;
|
||||
|
||||
fn resolve_nested(self) -> Self {
|
||||
self.resolve_nested_inner(0)
|
||||
}
|
||||
|
||||
fn resolve_nested_inner(mut self, depth: usize) -> Self {
|
||||
if depth > Self::MAX_NESTED_DEPTH {
|
||||
tracing::warn!(
|
||||
"Capabilities nesting exceeds maximum depth of {}, stopping resolution",
|
||||
Self::MAX_NESTED_DEPTH
|
||||
);
|
||||
return self;
|
||||
}
|
||||
if let Some(inner) = self.capabilities.take() {
|
||||
let inner = inner.resolve_nested();
|
||||
let inner = inner.resolve_nested_inner(depth + 1);
|
||||
self.description = self.description.or(inner.description);
|
||||
self.parameters = self.parameters.or(inner.parameters);
|
||||
self.http = self.http.or(inner.http);
|
||||
@@ -1383,4 +1434,63 @@ mod tests {
|
||||
"Outer description should take precedence over inner"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression test for issue #974: deeply nested capabilities wrappers
|
||||
/// must not cause stack overflow. resolve_nested should stop at
|
||||
/// MAX_NESTED_DEPTH and return gracefully.
|
||||
#[test]
|
||||
fn test_resolve_nested_depth_limit() {
|
||||
// Build a capabilities file nested beyond MAX_NESTED_DEPTH (8).
|
||||
// The description is at the innermost level which is beyond the limit,
|
||||
// so it won't be resolved — the key assertion is no stack overflow.
|
||||
let mut json = r#"{ "description": "leaf" }"#.to_string();
|
||||
for _ in 0..20 {
|
||||
json = format!(r#"{{ "capabilities": {json} }}"#);
|
||||
}
|
||||
// Should not stack overflow — this is the primary assertion.
|
||||
let _caps = CapabilitiesFile::from_json(&json).unwrap();
|
||||
}
|
||||
|
||||
/// Regression test for issue #976: oversized description strings are truncated.
|
||||
#[test]
|
||||
fn test_description_truncated_at_limit() {
|
||||
let long_desc = "x".repeat(10_000);
|
||||
let json = format!(r#"{{ "description": "{long_desc}" }}"#);
|
||||
let caps = CapabilitiesFile::from_json(&json).unwrap();
|
||||
let desc = caps.description.unwrap();
|
||||
assert!(
|
||||
desc.len() <= super::MAX_DESCRIPTION_CHARS + 50, // allow for minor overhead
|
||||
"description should be truncated to ~{} chars, got {}",
|
||||
super::MAX_DESCRIPTION_CHARS,
|
||||
desc.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression test for issue #977: oversized parameters schema is dropped.
|
||||
#[test]
|
||||
fn test_oversized_parameters_schema_dropped() {
|
||||
// Build a parameters schema larger than MAX_PARAMETERS_SCHEMA_BYTES
|
||||
let mut properties = serde_json::Map::new();
|
||||
for i in 0..2000 {
|
||||
properties.insert(
|
||||
format!("field_{i}"),
|
||||
serde_json::json!({
|
||||
"type": "string",
|
||||
"description": "x".repeat(50)
|
||||
}),
|
||||
);
|
||||
}
|
||||
let schema = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
});
|
||||
let json = serde_json::json!({
|
||||
"parameters": schema,
|
||||
});
|
||||
let caps = CapabilitiesFile::from_json(&json.to_string()).unwrap();
|
||||
assert!(
|
||||
caps.parameters.is_none(),
|
||||
"oversized parameters schema should be dropped"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+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")
|
||||
|
||||
+468
-58
@@ -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
|
||||
@@ -982,7 +1104,18 @@ async fn resolve_host_credentials(
|
||||
) -> Vec<ResolvedHostCredential> {
|
||||
let store = match store {
|
||||
Some(s) => s,
|
||||
None => return Vec::new(),
|
||||
None => {
|
||||
// If tool requires credentials but has no secrets store, this is a configuration error
|
||||
if let Some(http_cap) = &capabilities.http
|
||||
&& !http_cap.credentials.is_empty()
|
||||
{
|
||||
tracing::warn!(
|
||||
user_id = %user_id,
|
||||
"WASM tool requires credentials but secrets_store is not configured - authentication will fail"
|
||||
);
|
||||
}
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
// Check if the access token needs refreshing before resolving credentials.
|
||||
@@ -1033,13 +1166,37 @@ async fn resolve_host_credentials(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to get credential under the provided user_id first.
|
||||
// If not found and user_id != "default", fallback to "default" (global credentials).
|
||||
// This handles OAuth tokens stored globally under "default" but accessed from routine contexts.
|
||||
let secret = match store.get_decrypted(user_id, &mapping.secret_name).await {
|
||||
Ok(s) => s,
|
||||
Ok(s) => Some(s),
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
// If lookup fails and we're not already looking up "default", try "default" as fallback
|
||||
if user_id != "default" {
|
||||
tracing::debug!(
|
||||
secret_name = %mapping.secret_name,
|
||||
user_id = %user_id,
|
||||
error = %e,
|
||||
"Credential not found for user, trying default global credentials"
|
||||
);
|
||||
store
|
||||
.get_decrypted("default", &mapping.secret_name)
|
||||
.await
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let secret = match secret {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
secret_name = %mapping.secret_name,
|
||||
error = %e,
|
||||
"Could not resolve credential for WASM tool (auth may not be configured)"
|
||||
user_id = %user_id,
|
||||
"Could not resolve credential for WASM tool (not found in user context or default)"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -1232,6 +1389,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 +1404,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 +2024,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
|
||||
@@ -1840,4 +2093,161 @@ mod tests {
|
||||
"Leak scan on post-injection headers should block the Slack token"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_host_credentials_fallback_to_default_user() {
|
||||
use crate::secrets::{CredentialLocation, CredentialMapping, SecretsStore};
|
||||
use crate::tools::wasm::capabilities::HttpCapability;
|
||||
use crate::tools::wasm::wrapper::resolve_host_credentials;
|
||||
|
||||
let store = test_secrets_store();
|
||||
|
||||
// Store a token under the "default" global user
|
||||
store
|
||||
.create(
|
||||
"default",
|
||||
crate::secrets::CreateSecretParams::new("google_oauth_token", "global_token_value"),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to store global token"); // safety: test code only
|
||||
|
||||
// Create capabilities requiring this credential
|
||||
let mut creds = std::collections::HashMap::new();
|
||||
creds.insert(
|
||||
"google_oauth_token".to_string(),
|
||||
CredentialMapping {
|
||||
secret_name: "google_oauth_token".to_string(),
|
||||
location: CredentialLocation::AuthorizationBearer,
|
||||
host_patterns: vec!["sheets.googleapis.com".to_string()],
|
||||
},
|
||||
);
|
||||
let caps = Capabilities {
|
||||
http: Some(HttpCapability {
|
||||
allowlist: vec![],
|
||||
credentials: creds,
|
||||
rate_limit: crate::tools::wasm::capabilities::RateLimitConfig::default(),
|
||||
max_request_bytes: 1024 * 1024,
|
||||
max_response_bytes: 10 * 1024 * 1024,
|
||||
timeout: std::time::Duration::from_secs(30),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Resolve credentials for a different user (routine context)
|
||||
// Should fallback to "default" and find the token
|
||||
let result = resolve_host_credentials(&caps, Some(&store), "routine_user_123", None).await;
|
||||
|
||||
assert!(!result.is_empty(), "fallback to default"); // safety: test code only
|
||||
assert_eq!(result[0].secret_value, "global_token_value"); // safety: test code only
|
||||
}
|
||||
|
||||
fn test_capabilities_with_google_oauth() -> Capabilities {
|
||||
use crate::secrets::{CredentialLocation, CredentialMapping};
|
||||
use crate::tools::wasm::capabilities::HttpCapability;
|
||||
|
||||
let mut creds = std::collections::HashMap::new();
|
||||
creds.insert(
|
||||
"google_oauth_token".to_string(),
|
||||
CredentialMapping {
|
||||
secret_name: "google_oauth_token".to_string(),
|
||||
location: CredentialLocation::AuthorizationBearer,
|
||||
host_patterns: vec!["sheets.googleapis.com".to_string()],
|
||||
},
|
||||
);
|
||||
Capabilities {
|
||||
http: Some(HttpCapability {
|
||||
allowlist: vec![],
|
||||
credentials: creds,
|
||||
rate_limit: crate::tools::wasm::capabilities::RateLimitConfig::default(),
|
||||
max_request_bytes: 1024 * 1024,
|
||||
max_response_bytes: 10 * 1024 * 1024,
|
||||
timeout: std::time::Duration::from_secs(30),
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_host_credentials_prefers_user_specific_over_default() {
|
||||
use crate::secrets::SecretsStore;
|
||||
use crate::tools::wasm::wrapper::resolve_host_credentials;
|
||||
|
||||
let store = test_secrets_store();
|
||||
|
||||
// Store token under "default" (global)
|
||||
store
|
||||
.create(
|
||||
"default",
|
||||
crate::secrets::CreateSecretParams::new("google_oauth_token", "global_token"),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to store global token"); // safety: test code only
|
||||
|
||||
// Store token under user_123 (user-specific)
|
||||
store
|
||||
.create(
|
||||
"user_123",
|
||||
crate::secrets::CreateSecretParams::new(
|
||||
"google_oauth_token",
|
||||
"user_specific_token",
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to store user token"); // safety: test code only
|
||||
|
||||
// Create capabilities
|
||||
let caps = test_capabilities_with_google_oauth();
|
||||
|
||||
// Resolve credentials for user_123
|
||||
// Should prefer user_123's token over default
|
||||
let result = resolve_host_credentials(&caps, Some(&store), "user_123", None).await;
|
||||
|
||||
assert!(!result.is_empty(), "has user credentials"); // safety: test code only
|
||||
assert_eq!(result[0].secret_value, "user_specific_token", "user token"); // safety: test code only
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_host_credentials_no_fallback_when_already_default() {
|
||||
use crate::secrets::SecretsStore;
|
||||
use crate::tools::wasm::wrapper::resolve_host_credentials;
|
||||
|
||||
let store = test_secrets_store();
|
||||
|
||||
// Only store token under "default" (not a duplicate)
|
||||
store
|
||||
.create(
|
||||
"default",
|
||||
crate::secrets::CreateSecretParams::new("google_oauth_token", "default_token"),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to store default token"); // safety: test code only
|
||||
|
||||
// Create capabilities
|
||||
let caps = test_capabilities_with_google_oauth();
|
||||
|
||||
// Resolve credentials for "default" user
|
||||
// Should NOT attempt fallback (already looking up default)
|
||||
let result = resolve_host_credentials(&caps, Some(&store), "default", None).await;
|
||||
|
||||
assert!(!result.is_empty(), "Should find default token"); // safety: test code only
|
||||
assert_eq!(result[0].secret_value, "default_token"); // safety: test code only
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_host_credentials_missing_secret_warns() {
|
||||
use crate::tools::wasm::wrapper::resolve_host_credentials;
|
||||
|
||||
let store = test_secrets_store();
|
||||
|
||||
// Don't store any token
|
||||
|
||||
// Create capabilities expecting a credential
|
||||
let caps = test_capabilities_with_google_oauth();
|
||||
|
||||
// Resolve credentials when neither user nor default has the token
|
||||
let result = resolve_host_credentials(&caps, Some(&store), "user_456", None).await;
|
||||
|
||||
// Should return empty since credential can't be found anywhere
|
||||
assert!(result.is_empty(), "no credentials found"); // safety: test code only
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user