mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5677e5e955 | ||
|
|
91e0c2ee62 | ||
|
|
d5828b271d | ||
|
|
e1691a8d42 | ||
|
|
8ac24e775b | ||
|
|
bcda73c2e0 | ||
|
|
5dfa666691 | ||
|
|
fd574b2859 | ||
|
|
c592c50dad | ||
|
|
0b122cb28f | ||
|
|
c94ecf19db | ||
|
|
8df51c04ae | ||
|
|
6bbf87ba3a | ||
|
|
006c15e79c | ||
|
|
d420abfa6a | ||
|
|
863702a87a | ||
|
+7 |
f776d96395 | ||
|
|
4faf81ab61 | ||
|
|
8a26cfae73 | ||
|
|
0b81342b5c | ||
|
|
c26f116a98 | ||
|
|
ef34943c14 | ||
|
|
c937dfa315 |
@@ -70,6 +70,12 @@ NEARAI_AUTH_URL=https://private.near.ai
|
|||||||
# LLM_BASE_URL=https://api.fireworks.ai/inference/v1
|
# LLM_BASE_URL=https://api.fireworks.ai/inference/v1
|
||||||
# LLM_API_KEY=fw_...
|
# LLM_API_KEY=fw_...
|
||||||
|
|
||||||
|
# === MiniMax ===
|
||||||
|
# LLM_BACKEND=minimax
|
||||||
|
# MINIMAX_API_KEY=...
|
||||||
|
# MINIMAX_MODEL=MiniMax-M2.5
|
||||||
|
# MINIMAX_BASE_URL=https://api.minimax.io/v1 # default (global); use https://api.minimaxi.com/v1 for China
|
||||||
|
|
||||||
# === Anthropic Direct ===
|
# === Anthropic Direct ===
|
||||||
# LLM_BACKEND=anthropic
|
# LLM_BACKEND=anthropic
|
||||||
# ANTHROPIC_MODEL=claude-sonnet-4-6
|
# ANTHROPIC_MODEL=claude-sonnet-4-6
|
||||||
|
|||||||
Executable
+23
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Pre-push hook: run clippy and tests before pushing.
|
||||||
|
# Install: git config core.hooksPath .githooks
|
||||||
|
|
||||||
|
echo "pre-push: running clippy..."
|
||||||
|
if ! cargo clippy --all --benches --tests --examples --all-features -- -D warnings; then
|
||||||
|
echo ""
|
||||||
|
echo "Push blocked: clippy warnings found."
|
||||||
|
echo "To bypass: git push --no-verify"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "pre-push: running tests..."
|
||||||
|
if ! cargo test; then
|
||||||
|
echo ""
|
||||||
|
echo "Push blocked: tests failed."
|
||||||
|
echo "To bypass: git push --no-verify"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "pre-push: all checks passed."
|
||||||
@@ -48,7 +48,7 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- group: core
|
- group: core
|
||||||
files: "tests/e2e/scenarios/test_connection.py tests/e2e/scenarios/test_chat.py tests/e2e/scenarios/test_sse_reconnect.py tests/e2e/scenarios/test_html_injection.py"
|
files: "tests/e2e/scenarios/test_connection.py tests/e2e/scenarios/test_chat.py tests/e2e/scenarios/test_sse_reconnect.py tests/e2e/scenarios/test_html_injection.py tests/e2e/scenarios/test_csp.py"
|
||||||
- group: features
|
- group: features
|
||||||
files: "tests/e2e/scenarios/test_skills.py tests/e2e/scenarios/test_tool_approval.py"
|
files: "tests/e2e/scenarios/test_skills.py tests/e2e/scenarios/test_tool_approval.py"
|
||||||
- group: extensions
|
- group: extensions
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Fetch PR head and base
|
||||||
|
run: |
|
||||||
|
git fetch origin ${{ github.event.pull_request.base.ref }}
|
||||||
|
git fetch origin pull/${{ github.event.pull_request.number }}/head:pr-head
|
||||||
|
|
||||||
- name: Check for regression tests
|
- name: Check for regression tests
|
||||||
env:
|
env:
|
||||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||||
@@ -21,6 +26,8 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
BASE_REF="origin/${{ github.event.pull_request.base.ref }}"
|
BASE_REF="origin/${{ github.event.pull_request.base.ref }}"
|
||||||
|
# Use the actual PR head, not the merge commit that actions/checkout checks out
|
||||||
|
HEAD_REF="pr-head"
|
||||||
|
|
||||||
# --- 1. Is this a fix PR? Check title first, then commit messages ---
|
# --- 1. Is this a fix PR? Check title first, then commit messages ---
|
||||||
IS_FIX=false
|
IS_FIX=false
|
||||||
@@ -30,7 +37,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$IS_FIX" = false ]; then
|
if [ "$IS_FIX" = false ]; then
|
||||||
COMMITS=$(git log --format='%s' "${BASE_REF}..HEAD")
|
COMMITS=$(git log --format='%s' "${BASE_REF}..${HEAD_REF}")
|
||||||
if grep -qiE '^(fix(\(.*\))?|hotfix|bugfix):' <<< "$COMMITS"; then
|
if grep -qiE '^(fix(\(.*\))?|hotfix|bugfix):' <<< "$COMMITS"; then
|
||||||
IS_FIX=true
|
IS_FIX=true
|
||||||
fi
|
fi
|
||||||
@@ -49,14 +56,14 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
COMMIT_BODIES=$(git log --format='%B' "${BASE_REF}..HEAD")
|
COMMIT_BODIES=$(git log --format='%B' "${BASE_REF}..${HEAD_REF}")
|
||||||
if grep -qF '[skip-regression-check]' <<< "$COMMIT_BODIES"; then
|
if grep -qF '[skip-regression-check]' <<< "$COMMIT_BODIES"; then
|
||||||
echo "[skip-regression-check] found in commit message — skipping."
|
echo "[skip-regression-check] found in commit message — skipping."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- 3. Exempt static-only / docs-only changes ---
|
# --- 3. Exempt static-only / docs-only changes ---
|
||||||
CHANGED_FILES=$(git diff --name-only "${BASE_REF}...HEAD")
|
CHANGED_FILES=$(git diff --name-only "${BASE_REF}...${HEAD_REF}")
|
||||||
|
|
||||||
if [ -z "$CHANGED_FILES" ]; then
|
if [ -z "$CHANGED_FILES" ]; then
|
||||||
echo "No changed files — skipping."
|
echo "No changed files — skipping."
|
||||||
@@ -80,13 +87,13 @@ jobs:
|
|||||||
# --- 4. Look for test changes ---
|
# --- 4. Look for test changes ---
|
||||||
|
|
||||||
# Fast path: new test attributes or test modules in added lines.
|
# Fast path: new test attributes or test modules in added lines.
|
||||||
if git diff "${BASE_REF}...HEAD" -U0 -- '*.rs' | grep -qE '^\+.*(#\[test\]|#\[tokio::test\]|#\[cfg\(test\)\]|mod tests)'; then
|
if git diff "${BASE_REF}...${HEAD_REF}" -U0 -- '*.rs' | grep -qE '^\+.*(#\[test\]|#\[tokio::test\]|#\[cfg\(test\)\]|mod tests)'; then
|
||||||
echo "Test changes found in .rs files."
|
echo "Test changes found in .rs files."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Whole-function context: detect edits inside existing test functions.
|
# Whole-function context: detect edits inside existing test functions.
|
||||||
if git diff "${BASE_REF}...HEAD" -W -- '*.rs' | awk '
|
if git diff "${BASE_REF}...${HEAD_REF}" -W -- '*.rs' | awk '
|
||||||
/^@@/ { if (has_test && has_add) { found=1; exit } has_test=0; has_add=0 }
|
/^@@/ { if (has_test && has_add) { found=1; exit } has_test=0; has_add=0 }
|
||||||
/^ .*#\[test\]/ || /^ .*#\[tokio::test\]/ || /^ .*#\[cfg\(test\)\]/ || /^ .*mod tests/ { has_test=1 }
|
/^ .*#\[test\]/ || /^ .*#\[tokio::test\]/ || /^ .*#\[cfg\(test\)\]/ || /^ .*mod tests/ { has_test=1 }
|
||||||
/^\+.*#\[test\]/ || /^\+.*#\[tokio::test\]/ || /^\+.*#\[cfg\(test\)\]/ || /^\+.*mod tests/ { has_test=1 }
|
/^\+.*#\[test\]/ || /^\+.*#\[tokio::test\]/ || /^\+.*#\[cfg\(test\)\]/ || /^\+.*mod tests/ { has_test=1 }
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ jobs:
|
|||||||
id: check
|
id: check
|
||||||
env:
|
env:
|
||||||
FORCE_RUN: ${{ inputs.force }}
|
FORCE_RUN: ${{ inputs.force }}
|
||||||
|
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||||
run: |
|
run: |
|
||||||
CURRENT_HEAD=$(git rev-parse HEAD)
|
CURRENT_HEAD=$(git rev-parse HEAD)
|
||||||
echo "current_head=${CURRENT_HEAD}" >> "$GITHUB_OUTPUT"
|
echo "current_head=${CURRENT_HEAD}" >> "$GITHUB_OUTPUT"
|
||||||
@@ -65,8 +66,8 @@ jobs:
|
|||||||
echo "Found ${COMMIT_COUNT} new commit(s) since last tested"
|
echo "Found ${COMMIT_COUNT} new commit(s) since last tested"
|
||||||
DIFF_RANGE="${LAST_TESTED}..${CURRENT_HEAD}"
|
DIFF_RANGE="${LAST_TESTED}..${CURRENT_HEAD}"
|
||||||
else
|
else
|
||||||
git fetch origin main
|
git fetch origin "${DEFAULT_BRANCH}"
|
||||||
MERGE_BASE=$(git merge-base origin/main HEAD)
|
MERGE_BASE=$(git merge-base "origin/${DEFAULT_BRANCH}" HEAD)
|
||||||
echo "First run -- reviewing from merge-base ${MERGE_BASE}"
|
echo "First run -- reviewing from merge-base ${MERGE_BASE}"
|
||||||
DIFF_RANGE="${MERGE_BASE}..${CURRENT_HEAD}"
|
DIFF_RANGE="${MERGE_BASE}..${CURRENT_HEAD}"
|
||||||
fi
|
fi
|
||||||
@@ -129,18 +130,19 @@ jobs:
|
|||||||
echo "token=${{ github.token }}" >> "$GITHUB_OUTPUT"
|
echo "token=${{ github.token }}" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Check if staging is ahead of main
|
- name: Check if staging is ahead of target branch
|
||||||
id: ahead-check
|
id: ahead-check
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ steps.token.outputs.token }}
|
GH_TOKEN: ${{ steps.token.outputs.token }}
|
||||||
|
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||||
run: |
|
run: |
|
||||||
git fetch origin main
|
git fetch origin "${DEFAULT_BRANCH}"
|
||||||
AHEAD=$(git rev-list --count origin/main..origin/staging)
|
AHEAD=$(git rev-list --count "origin/${DEFAULT_BRANCH}..origin/staging")
|
||||||
echo "commits_ahead=${AHEAD}" >> "$GITHUB_OUTPUT"
|
echo "commits_ahead=${AHEAD}" >> "$GITHUB_OUTPUT"
|
||||||
if [ "$AHEAD" -eq 0 ]; then
|
if [ "$AHEAD" -eq 0 ]; then
|
||||||
echo "Staging is not ahead of main. Nothing to promote."
|
echo "Staging is not ahead of ${DEFAULT_BRANCH}. Nothing to promote."
|
||||||
else
|
else
|
||||||
echo "Staging is ${AHEAD} commits ahead of main."
|
echo "Staging is ${AHEAD} commits ahead of ${DEFAULT_BRANCH}."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Create promotion branch
|
- name: Create promotion branch
|
||||||
@@ -159,6 +161,7 @@ jobs:
|
|||||||
if: steps.ahead-check.outputs.commits_ahead != '0'
|
if: steps.ahead-check.outputs.commits_ahead != '0'
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ steps.token.outputs.token }}
|
GH_TOKEN: ${{ steps.token.outputs.token }}
|
||||||
|
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||||
run: |
|
run: |
|
||||||
# Find the newest open promotion PR with a staging-promote/* head branch
|
# Find the newest open promotion PR with a staging-promote/* head branch
|
||||||
LATEST=$(gh pr list --label staging-promotion --state open \
|
LATEST=$(gh pr list --label staging-promotion --state open \
|
||||||
@@ -168,8 +171,8 @@ jobs:
|
|||||||
echo "base=${LATEST}" >> "$GITHUB_OUTPUT"
|
echo "base=${LATEST}" >> "$GITHUB_OUTPUT"
|
||||||
echo "Chaining onto existing promotion branch: ${LATEST}"
|
echo "Chaining onto existing promotion branch: ${LATEST}"
|
||||||
else
|
else
|
||||||
echo "base=main" >> "$GITHUB_OUTPUT"
|
echo "base=${DEFAULT_BRANCH}" >> "$GITHUB_OUTPUT"
|
||||||
echo "No existing promotion PR — targeting main"
|
echo "No existing promotion PR — targeting ${DEFAULT_BRANCH}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Create promotion PR
|
- name: Create promotion PR
|
||||||
@@ -186,7 +189,7 @@ jobs:
|
|||||||
PR_URL=$(gh pr create \
|
PR_URL=$(gh pr create \
|
||||||
--base "$BASE" \
|
--base "$BASE" \
|
||||||
--head "$BRANCH" \
|
--head "$BRANCH" \
|
||||||
--title "chore: promote staging to main (${TIMESTAMP})" \
|
--title "chore: promote staging to ${BASE} (${TIMESTAMP})" \
|
||||||
--body "## Auto-promotion from staging CI
|
--body "## Auto-promotion from staging CI
|
||||||
|
|
||||||
**Batch range:** \`${RANGE}\`
|
**Batch range:** \`${RANGE}\`
|
||||||
|
|||||||
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.18.0](https://github.com/nearai/ironclaw/compare/v0.17.0...v0.18.0) - 2026-03-11
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- Merge pull request #907 from nearai/staging-promote/b0214fef-22930316561
|
||||||
|
- promote staging to main (2026-03-10 15:19 UTC) ([#865](https://github.com/nearai/ironclaw/pull/865))
|
||||||
|
- Merge pull request #830 from nearai/staging-promote/3a2989d0-22888378864
|
||||||
|
- update WASM artifact SHA256 checksums [skip ci] ([#876](https://github.com/nearai/ironclaw/pull/876))
|
||||||
|
|
||||||
## [0.17.0](https://github.com/nearai/ironclaw/compare/v0.16.1...v0.17.0) - 2026-03-10
|
## [0.17.0](https://github.com/nearai/ironclaw/compare/v0.16.1...v0.17.0) - 2026-03-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
Generated
+1
-1
@@ -3350,7 +3350,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ironclaw"
|
name = "ironclaw"
|
||||||
version = "0.17.0"
|
version = "0.18.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ exclude = [
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "ironclaw"
|
name = "ironclaw"
|
||||||
version = "0.17.0"
|
version = "0.18.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.92"
|
rust-version = "1.92"
|
||||||
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
||||||
|
|||||||
+1
-1
@@ -170,7 +170,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
| `nodes` | ✅ | ❌ | P3 | Device management, remove/clear flows |
|
| `nodes` | ✅ | ❌ | P3 | Device management, remove/clear flows |
|
||||||
| `plugins` | ✅ | ❌ | P3 | Plugin management |
|
| `plugins` | ✅ | ❌ | P3 | Plugin management |
|
||||||
| `hooks` | ✅ | ✅ | P2 | Lifecycle hooks |
|
| `hooks` | ✅ | ✅ | P2 | Lifecycle hooks |
|
||||||
| `cron` | ✅ | ❌ | P2 | Scheduled jobs (model/thinking fields in edit) |
|
| `cron` | ✅ | 🚧 | P2 | list/create/edit/enable/disable/delete/history; TODO: `cron run`, model/thinking fields |
|
||||||
| `webhooks` | ✅ | ❌ | P3 | Webhook config |
|
| `webhooks` | ✅ | ❌ | P3 | Webhook config |
|
||||||
| `message send` | ✅ | ❌ | P2 | Send to channels |
|
| `message send` | ✅ | ❌ | P2 | Send to channels |
|
||||||
| `browser` | ✅ | ❌ | P3 | Browser automation |
|
| `browser` | ✅ | ❌ | P3 | Browser automation |
|
||||||
|
|||||||
Generated
+205
@@ -20,33 +20,162 @@ version = "1.0.102"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64ct"
|
||||||
|
version = "1.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.11.0"
|
version = "2.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.10.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.9.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "curve25519-dalek"
|
||||||
|
version = "4.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"curve25519-dalek-derive",
|
||||||
|
"digest",
|
||||||
|
"fiat-crypto",
|
||||||
|
"rustc_version",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "curve25519-dalek-derive"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "der"
|
||||||
|
version = "0.7.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||||
|
dependencies = [
|
||||||
|
"const-oid",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer",
|
||||||
|
"crypto-common",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "discord-channel"
|
name = "discord-channel"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"ed25519-dalek",
|
||||||
|
"hex",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"wit-bindgen",
|
"wit-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ed25519"
|
||||||
|
version = "2.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||||
|
dependencies = [
|
||||||
|
"pkcs8",
|
||||||
|
"signature",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ed25519-dalek"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||||
|
dependencies = [
|
||||||
|
"curve25519-dalek",
|
||||||
|
"ed25519",
|
||||||
|
"serde",
|
||||||
|
"sha2",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fiat-crypto"
|
||||||
|
version = "0.2.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "generic-array"
|
||||||
|
version = "0.14.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.14.5"
|
version = "0.14.5"
|
||||||
@@ -68,6 +197,12 @@ version = "0.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hex"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "id-arena"
|
name = "id-arena"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
@@ -98,6 +233,12 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67"
|
checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.182"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.29"
|
version = "0.4.29"
|
||||||
@@ -116,6 +257,16 @@ version = "1.21.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pkcs8"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||||
|
dependencies = [
|
||||||
|
"der",
|
||||||
|
"spki",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "prettyplease"
|
name = "prettyplease"
|
||||||
version = "0.2.37"
|
version = "0.2.37"
|
||||||
@@ -144,6 +295,15 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc_version"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||||
|
dependencies = [
|
||||||
|
"semver",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "semver"
|
name = "semver"
|
||||||
version = "1.0.27"
|
version = "1.0.27"
|
||||||
@@ -193,6 +353,23 @@ dependencies = [
|
|||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha2"
|
||||||
|
version = "0.10.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signature"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smallvec"
|
name = "smallvec"
|
||||||
version = "1.15.1"
|
version = "1.15.1"
|
||||||
@@ -208,6 +385,22 @@ dependencies = [
|
|||||||
"smallvec",
|
"smallvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "spki"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"der",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.117"
|
version = "2.0.117"
|
||||||
@@ -219,6 +412,12 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.19.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
@@ -394,6 +593,12 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ publish = false
|
|||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
wit-bindgen = "0.36"
|
wit-bindgen = "0.36"
|
||||||
|
ed25519-dalek = { version = "2", default-features = false, features = ["alloc", "fast", "zeroize"] }
|
||||||
|
hex = "0.4"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
crate-type = ["cdylib"]
|
crate-type = ["cdylib"]
|
||||||
|
|||||||
@@ -21,11 +21,10 @@ WASM channel for Discord integration - handle slash commands and button interact
|
|||||||
ironclaw secret set discord_bot_token YOUR_BOT_TOKEN
|
ironclaw secret set discord_bot_token YOUR_BOT_TOKEN
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** The `discord_bot_token` secret is the only value read directly by this
|
**Note:** The `discord_bot_token` secret is used for Discord REST API calls.
|
||||||
Discord channel WASM component. The `discord_app_id` and `discord_public_key`
|
Interaction signature verification is performed inside the Discord channel
|
||||||
secrets are used by the IronClaw host (for example, to verify Discord
|
module and uses the channel config field `webhook_secret` (set this to your
|
||||||
interaction signatures and manage slash command registration) and are not
|
Discord app public key hex).
|
||||||
accessed from the WASM module itself.
|
|
||||||
|
|
||||||
## Discord Configuration
|
## Discord Configuration
|
||||||
|
|
||||||
@@ -87,6 +86,30 @@ If an internal error occurs (e.g., metadata serialization failure), the tool att
|
|||||||
Check the host logs for detailed error information.
|
Check the host logs for detailed error information.
|
||||||
|
|
||||||
## Advanced Usage
|
## Advanced Usage
|
||||||
|
### Mention Polling
|
||||||
|
|
||||||
|
The Discord channel can also poll configured channels for `@bot` mentions.
|
||||||
|
|
||||||
|
Example channel config:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"require_signature_verification": true,
|
||||||
|
"webhook_secret": "YOUR_DISCORD_PUBLIC_KEY_HEX",
|
||||||
|
"polling_enabled": true,
|
||||||
|
"poll_interval_ms": 30000,
|
||||||
|
"mention_channel_ids": ["123456789012345678"],
|
||||||
|
"owner_id": null,
|
||||||
|
"dm_policy": "pairing",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Access Control
|
||||||
|
|
||||||
|
- `owner_id`: when set, only that Discord user can interact with the bot.
|
||||||
|
- `dm_policy`: `open` allows all DMs; `pairing` requires approval.
|
||||||
|
- `allow_from`: allowlist entries for DM pairing checks (`*`, user id, or username).
|
||||||
|
|
||||||
### Embeds
|
### Embeds
|
||||||
|
|
||||||
@@ -96,8 +119,11 @@ To send embeds, include an `embeds` array in the `metadata_json` field of the ag
|
|||||||
|
|
||||||
### "Invalid Signature"
|
### "Invalid Signature"
|
||||||
|
|
||||||
- Check that `discord_public_key` is set correctly in IronClaw secrets.
|
- Check that `webhook_secret` is set to your Discord app public key hex in the
|
||||||
- This validation happens on the host before reaching the WASM.
|
Discord channel config.
|
||||||
|
- Validation happens inside the Discord WASM channel.
|
||||||
|
- If `require_signature_verification` is `true` and `webhook_secret` is empty,
|
||||||
|
the channel returns HTTP `500` with a configuration error.
|
||||||
|
|
||||||
### "401 Unauthorized"
|
### "401 Unauthorized"
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"wit_version": "0.3.0",
|
"wit_version": "0.3.0",
|
||||||
"type": "channel",
|
"type": "channel",
|
||||||
"name": "discord",
|
"name": "discord",
|
||||||
"description": "Discord Gateway/Webhook channel for handling slash commands, buttons, and messages",
|
"description": "Discord webhook channel for slash commands, components, and optional mention polling",
|
||||||
"setup": {
|
"setup": {
|
||||||
"required_secrets": [
|
"required_secrets": [
|
||||||
{
|
{
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
},
|
},
|
||||||
"channel": {
|
"channel": {
|
||||||
"allowed_paths": ["/webhook/discord"],
|
"allowed_paths": ["/webhook/discord"],
|
||||||
"allow_polling": false,
|
"allow_polling": true,
|
||||||
"callback_timeout_secs": 45,
|
"callback_timeout_secs": 45,
|
||||||
"workspace_prefix": "channels/discord/",
|
"workspace_prefix": "channels/discord/",
|
||||||
"emit_rate_limit": {
|
"emit_rate_limit": {
|
||||||
@@ -55,6 +55,10 @@
|
|||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"require_signature_verification": true,
|
"require_signature_verification": true,
|
||||||
|
"webhook_secret": null,
|
||||||
|
"polling_enabled": false,
|
||||||
|
"poll_interval_ms": 30000,
|
||||||
|
"mention_channel_ids": [],
|
||||||
"owner_id": null,
|
"owner_id": null,
|
||||||
"dm_policy": "pairing",
|
"dm_policy": "pairing",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
|||||||
+974
-101
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,10 @@
|
|||||||
# WARNING: Replace all CHANGE_ME values before deploying.
|
# WARNING: Replace all CHANGE_ME values before deploying.
|
||||||
# Do not use placeholder passwords in production.
|
# Do not use placeholder passwords in production.
|
||||||
|
|
||||||
|
# Pin the Docker image version for deterministic deployments.
|
||||||
|
# Update this value when deploying a new release.
|
||||||
|
# IRONCLAW_VERSION=v1.0.0
|
||||||
|
|
||||||
DATABASE_URL=postgres://ironclaw:CHANGE_ME@localhost:5432/ironclaw
|
DATABASE_URL=postgres://ironclaw:CHANGE_ME@localhost:5432/ironclaw
|
||||||
|
|
||||||
# NEAR AI Cloud (API key auth, Chat Completions API)
|
# NEAR AI Cloud (API key auth, Chat Completions API)
|
||||||
|
|||||||
@@ -5,13 +5,17 @@ Requires=cloud-sql-proxy.service
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
ExecStartPre=/usr/bin/docker pull us-central1-docker.pkg.dev/ironclaw-prod/ironclaw/agent:latest
|
EnvironmentFile=/opt/ironclaw/.env
|
||||||
ExecStart=/usr/bin/docker run --rm \
|
# Pin to a specific version tag or digest instead of :latest to prevent
|
||||||
|
# uncontrolled deployments. Update IRONCLAW_VERSION in /opt/ironclaw/.env
|
||||||
|
# or replace the tag below when deploying a new release.
|
||||||
|
ExecStartPre=/bin/bash -c 'docker pull us-central1-docker.pkg.dev/ironclaw-prod/ironclaw/agent:${IRONCLAW_VERSION:-latest}'
|
||||||
|
ExecStart=/bin/bash -c 'docker run --rm \
|
||||||
--name ironclaw \
|
--name ironclaw \
|
||||||
--env-file /opt/ironclaw/.env \
|
--env-file /opt/ironclaw/.env \
|
||||||
--network=host \
|
-p 3000:3000 \
|
||||||
us-central1-docker.pkg.dev/ironclaw-prod/ironclaw/agent:latest \
|
us-central1-docker.pkg.dev/ironclaw-prod/ironclaw/agent:${IRONCLAW_VERSION:-latest} \
|
||||||
--no-onboard
|
--no-onboard'
|
||||||
ExecStop=/usr/bin/docker stop ironclaw
|
ExecStop=/usr/bin/docker stop ironclaw
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=10
|
RestartSec=10
|
||||||
|
|||||||
+8
-1
@@ -24,8 +24,15 @@ systemctl enable docker
|
|||||||
systemctl start docker
|
systemctl start docker
|
||||||
|
|
||||||
echo "==> Installing Cloud SQL Auth Proxy"
|
echo "==> Installing Cloud SQL Auth Proxy"
|
||||||
|
CLOUD_SQL_PROXY_VERSION="v2.14.3"
|
||||||
|
CLOUD_SQL_PROXY_SHA256="75e7cc1f158ab6f97b7810e9d8419c55735cff40bc56d4f19673adfdf2406a59"
|
||||||
curl -fsSL -o /usr/local/bin/cloud-sql-proxy \
|
curl -fsSL -o /usr/local/bin/cloud-sql-proxy \
|
||||||
https://storage.googleapis.com/cloud-sql-connectors/cloud-sql-proxy/v2.14.3/cloud-sql-proxy.linux.amd64
|
"https://storage.googleapis.com/cloud-sql-connectors/cloud-sql-proxy/${CLOUD_SQL_PROXY_VERSION}/cloud-sql-proxy.linux.amd64"
|
||||||
|
echo "${CLOUD_SQL_PROXY_SHA256} /usr/local/bin/cloud-sql-proxy" | sha256sum -c - || {
|
||||||
|
echo "ERROR: Cloud SQL Auth Proxy checksum verification failed -- aborting"
|
||||||
|
rm -f /usr/local/bin/cloud-sql-proxy
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
chmod +x /usr/local/bin/cloud-sql-proxy
|
chmod +x /usr/local/bin/cloud-sql-proxy
|
||||||
|
|
||||||
echo "==> Installing systemd services"
|
echo "==> Installing systemd services"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ configurations.
|
|||||||
| io.net | `ionet` | `IONET_API_KEY` | Intelligence API |
|
| io.net | `ionet` | `IONET_API_KEY` | Intelligence API |
|
||||||
| Mistral | `mistral` | `MISTRAL_API_KEY` | Mistral models |
|
| Mistral | `mistral` | `MISTRAL_API_KEY` | Mistral models |
|
||||||
| Yandex AI Studio | `yandex` | `YANDEX_API_KEY` | YandexGPT models |
|
| Yandex AI Studio | `yandex` | `YANDEX_API_KEY` | YandexGPT models |
|
||||||
|
| MiniMax | `minimax` | `MINIMAX_API_KEY` | MiniMax-M2.5 models |
|
||||||
| Cloudflare Workers AI | `cloudflare` | `CLOUDFLARE_API_KEY` | Access to Workers AI |
|
| Cloudflare Workers AI | `cloudflare` | `CLOUDFLARE_API_KEY` | Access to Workers AI |
|
||||||
| Ollama | `ollama` | No | Local inference |
|
| Ollama | `ollama` | No | Local inference |
|
||||||
| AWS Bedrock | `bedrock` | AWS credentials | Native Converse API |
|
| AWS Bedrock | `bedrock` | AWS credentials | Native Converse API |
|
||||||
@@ -74,6 +75,25 @@ Pull a model first: `ollama pull llama3.2`
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## MiniMax
|
||||||
|
|
||||||
|
[MiniMax](https://platform.minimax.io) provides high-performance language models with 204,800 token context windows.
|
||||||
|
|
||||||
|
```env
|
||||||
|
LLM_BACKEND=minimax
|
||||||
|
MINIMAX_API_KEY=...
|
||||||
|
```
|
||||||
|
|
||||||
|
Available models: `MiniMax-M2.5` (default), `MiniMax-M2.5-highspeed`
|
||||||
|
|
||||||
|
To use the China mainland endpoint, set:
|
||||||
|
|
||||||
|
```env
|
||||||
|
MINIMAX_BASE_URL=https://api.minimaxi.com/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## AWS Bedrock (requires `--features bedrock`)
|
## AWS Bedrock (requires `--features bedrock`)
|
||||||
|
|
||||||
Uses the native AWS Converse API via `aws-sdk-bedrockruntime`. Supports standard AWS
|
Uses the native AWS Converse API via `aws-sdk-bedrockruntime`. Supports standard AWS
|
||||||
|
|||||||
@@ -382,6 +382,27 @@
|
|||||||
"can_list_models": true
|
"can_list_models": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "minimax",
|
||||||
|
"aliases": [
|
||||||
|
"mini_max"
|
||||||
|
],
|
||||||
|
"protocol": "open_ai_completions",
|
||||||
|
"default_base_url": "https://api.minimax.io/v1",
|
||||||
|
"api_key_env": "MINIMAX_API_KEY",
|
||||||
|
"api_key_required": true,
|
||||||
|
"base_url_env": "MINIMAX_BASE_URL",
|
||||||
|
"model_env": "MINIMAX_MODEL",
|
||||||
|
"default_model": "MiniMax-M2.5",
|
||||||
|
"description": "MiniMax API (MiniMax-M2.5 and MiniMax-M2.5-highspeed models)",
|
||||||
|
"setup": {
|
||||||
|
"kind": "api_key",
|
||||||
|
"secret_name": "llm_minimax_api_key",
|
||||||
|
"key_url": "https://platform.minimax.io",
|
||||||
|
"display_name": "MiniMax",
|
||||||
|
"can_list_models": false
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "cloudflare",
|
"id": "cloudflare",
|
||||||
"aliases": [
|
"aliases": [
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/discord-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/latest/download/discord-0.2.0-wasm32-wasip2.tar.gz",
|
||||||
"sha256": null
|
"sha256": "efa1b9019fa33e243f8db1e1fcc732731d45836336bdd26ca19b6fe227ca8b69"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-0.2.1-wasm32-wasip2.tar.gz",
|
||||||
"sha256": null
|
"sha256": "d4667e35126986509d862bc3a0088777305d8f41c75de83c1e223b42312ede48"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-0.2.2-wasm32-wasip2.tar.gz",
|
||||||
"sha256": null
|
"sha256": "b9a83d5a2d1285ce0ec116b354336a1f245f893291ccb01dffbcaccf89d72aed"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/whatsapp-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/latest/download/whatsapp-0.2.0-wasm32-wasip2.tar.gz",
|
||||||
"sha256": null
|
"sha256": "feb9194719d9bed796b070ab4dc30348dbfb5d3dec56f9f21e02d14137abab01"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -19,8 +19,8 @@
|
|||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/github-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/latest/download/github-0.2.0-wasm32-wasip2.tar.gz",
|
||||||
"sha256": null
|
"sha256": "da9fac56b6f20197a415489bbaec9fefb085a5cf6324cab79ea48a47eb19c13b"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/gmail-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/latest/download/gmail-0.2.0-wasm32-wasip2.tar.gz",
|
||||||
"sha256": null
|
"sha256": "ee9574e02e92bc1d481f1310eb88afd99ee52bf6971074ab33bd76bf99b34b1d"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-calendar-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-calendar-0.2.0-wasm32-wasip2.tar.gz",
|
||||||
"sha256": null
|
"sha256": "2fa47150ea222e787c122182ad6f4dfa2ffaf5fe490d05e8de887a76445f8d2d"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-docs-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-docs-0.2.0-wasm32-wasip2.tar.gz",
|
||||||
"sha256": null
|
"sha256": "40e134a1c1564f832ca861c3396895d4e33ec67b99313fc1f97baf8d971423a9"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-drive-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-drive-0.2.0-wasm32-wasip2.tar.gz",
|
||||||
"sha256": null
|
"sha256": "002a341a1d58125563a7c69561b26fbc2629b04ea723cade744102bdc0fbb71f"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-sheets-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-sheets-0.2.0-wasm32-wasip2.tar.gz",
|
||||||
"sha256": null
|
"sha256": "8aa2c9d52f033edea3a6c2311b0ec694ccb6d0a54ef07e94d72bf8be1ce8009a"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -17,8 +17,8 @@
|
|||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-slides-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-slides-0.2.0-wasm32-wasip2.tar.gz",
|
||||||
"sha256": null
|
"sha256": "e931a97d4fd0b0b938e464dc7c7f2be6ea6b4d1508f5ea3cd931d44db23f05f5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "llm-context",
|
||||||
|
"display_name": "LLM Context",
|
||||||
|
"kind": "tool",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"wit_version": "0.3.0",
|
||||||
|
"description": "Fetch pre-extracted web content from Brave Search for grounding LLM answers (RAG, fact-checking)",
|
||||||
|
"keywords": [
|
||||||
|
"search",
|
||||||
|
"web",
|
||||||
|
"brave",
|
||||||
|
"rag",
|
||||||
|
"grounding",
|
||||||
|
"llm",
|
||||||
|
"context"
|
||||||
|
],
|
||||||
|
"source": {
|
||||||
|
"dir": "tools-src/llm-context",
|
||||||
|
"capabilities": "llm-context-tool.capabilities.json",
|
||||||
|
"crate_name": "llm-context-tool"
|
||||||
|
},
|
||||||
|
"artifacts": {
|
||||||
|
"wasm32-wasip2": {
|
||||||
|
"url": "https://github.com/nearai/ironclaw/releases/latest/download/llm-context-wasm32-wasip2.tar.gz",
|
||||||
|
"sha256": "581cc5867ef3b75116b7ddc8161e63dd92befe2b53e6ad8213c007639aa243c3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"auth_summary": {
|
||||||
|
"method": "manual",
|
||||||
|
"provider": "Brave",
|
||||||
|
"secrets": [
|
||||||
|
"brave_api_key"
|
||||||
|
],
|
||||||
|
"shared_auth": "Same API key as Web Search tool (brave_api_key)",
|
||||||
|
"setup_url": "https://brave.com/search/api/"
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"default",
|
||||||
|
"search"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -17,8 +17,8 @@
|
|||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-tool-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-0.2.0-wasm32-wasip2.tar.gz",
|
||||||
"sha256": null
|
"sha256": "8af3f884240de8413d272845fad2164a347d7d2a502a0d148aa38425b93f62ed"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-mtproto-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-0.2.0-wasm32-wasip2.tar.gz",
|
||||||
"sha256": null
|
"sha256": "2c66245913854be4294021fc6bb479e43f7d65830c5cec25cf6c60a71d1af468"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/web-search-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/latest/download/web-search-0.2.0-wasm32-wasip2.tar.gz",
|
||||||
"sha256": null
|
"sha256": "56834573c54ea2a33cea1eb0f04bbdf59f1ef8d8702995cf431b0921302eeccc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
+5
-24
@@ -18,7 +18,7 @@ use crate::agent::self_repair::{DefaultSelfRepair, RepairResult, SelfRepair};
|
|||||||
use crate::agent::session_manager::SessionManager;
|
use crate::agent::session_manager::SessionManager;
|
||||||
use crate::agent::submission::{Submission, SubmissionParser, SubmissionResult};
|
use crate::agent::submission::{Submission, SubmissionParser, SubmissionResult};
|
||||||
use crate::agent::{HeartbeatConfig as AgentHeartbeatConfig, Router, Scheduler};
|
use crate::agent::{HeartbeatConfig as AgentHeartbeatConfig, Router, Scheduler};
|
||||||
use crate::channels::{ChannelManager, IncomingMessage, OutgoingResponse, StatusUpdate};
|
use crate::channels::{ChannelManager, IncomingMessage, OutgoingResponse};
|
||||||
use crate::config::{AgentConfig, HeartbeatConfig, RoutineConfig, SkillsConfig};
|
use crate::config::{AgentConfig, HeartbeatConfig, RoutineConfig, SkillsConfig};
|
||||||
use crate::context::ContextManager;
|
use crate::context::ContextManager;
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
@@ -936,29 +936,10 @@ impl Agent {
|
|||||||
SubmissionResult::Ok { message } => Ok(message),
|
SubmissionResult::Ok { message } => Ok(message),
|
||||||
SubmissionResult::Error { message } => Ok(Some(format!("Error: {}", message))),
|
SubmissionResult::Error { message } => Ok(Some(format!("Error: {}", message))),
|
||||||
SubmissionResult::Interrupted => Ok(Some("Interrupted.".into())),
|
SubmissionResult::Interrupted => Ok(Some("Interrupted.".into())),
|
||||||
SubmissionResult::NeedApproval {
|
SubmissionResult::NeedApproval { .. } => {
|
||||||
request_id,
|
// ApprovalNeeded status was already sent by thread_ops.rs before
|
||||||
tool_name,
|
// returning this result. Empty string signals the caller to skip
|
||||||
description,
|
// respond() (no duplicate text).
|
||||||
parameters,
|
|
||||||
} => {
|
|
||||||
// Each channel renders the approval prompt via send_status.
|
|
||||||
// Web gateway shows an inline card, REPL prints a formatted prompt, etc.
|
|
||||||
let _ = self
|
|
||||||
.channels
|
|
||||||
.send_status(
|
|
||||||
&message.channel,
|
|
||||||
StatusUpdate::ApprovalNeeded {
|
|
||||||
request_id: request_id.to_string(),
|
|
||||||
tool_name,
|
|
||||||
description,
|
|
||||||
parameters,
|
|
||||||
},
|
|
||||||
&message.metadata,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// Empty string signals the caller to skip respond() (no duplicate text)
|
|
||||||
Ok(Some(String::new()))
|
Ok(Some(String::new()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -554,6 +554,31 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if needs_approval {
|
if needs_approval {
|
||||||
|
// In non-DM relay channels, auto-deny approval-
|
||||||
|
// requiring tools to prevent stuck AwaitingApproval
|
||||||
|
// state and prompt injection from other users.
|
||||||
|
let is_relay = self.message.channel.ends_with("-relay");
|
||||||
|
let is_dm = self
|
||||||
|
.message
|
||||||
|
.metadata
|
||||||
|
.get("event_type")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
== Some("direct_message");
|
||||||
|
if is_relay && !is_dm {
|
||||||
|
tracing::info!(
|
||||||
|
tool = %tc.name,
|
||||||
|
channel = %self.message.channel,
|
||||||
|
"Auto-denying approval-requiring tool in non-DM relay channel"
|
||||||
|
);
|
||||||
|
let reject_msg = format!(
|
||||||
|
"Tool '{}' requires approval and cannot run in shared channels. \
|
||||||
|
Ask the user to message me directly (DM) to use this tool.",
|
||||||
|
tc.name
|
||||||
|
);
|
||||||
|
preflight.push((tc, PreflightOutcome::Rejected(reject_msg)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
approval_needed = Some((idx, tc, tool));
|
approval_needed = Some((idx, tc, tool));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -2235,4 +2260,51 @@ mod tests {
|
|||||||
"Present 'data' field should produce non-empty string"
|
"Present 'data' field should produce non-empty string"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Test the relay channel auto-deny decision logic:
|
||||||
|
/// approval-requiring tools in non-DM relay channels must be rejected.
|
||||||
|
#[test]
|
||||||
|
fn test_relay_non_dm_auto_deny_decision() {
|
||||||
|
use crate::channels::IncomingMessage;
|
||||||
|
|
||||||
|
// Case 1: relay channel + non-DM → should auto-deny
|
||||||
|
let msg = IncomingMessage::new("slack-relay", "u1", "hello")
|
||||||
|
.with_metadata(serde_json::json!({ "event_type": "message" }));
|
||||||
|
let is_relay = msg.channel.ends_with("-relay");
|
||||||
|
let is_dm =
|
||||||
|
msg.metadata.get("event_type").and_then(|v| v.as_str()) == Some("direct_message");
|
||||||
|
assert!(is_relay && !is_dm, "Should auto-deny in relay non-DM");
|
||||||
|
|
||||||
|
// Case 2: relay channel + DM → should NOT auto-deny
|
||||||
|
let msg_dm = IncomingMessage::new("slack-relay", "u1", "hello")
|
||||||
|
.with_metadata(serde_json::json!({ "event_type": "direct_message" }));
|
||||||
|
let is_dm_2 =
|
||||||
|
msg_dm.metadata.get("event_type").and_then(|v| v.as_str()) == Some("direct_message");
|
||||||
|
assert!(
|
||||||
|
!msg_dm.channel.ends_with("-relay") || is_dm_2,
|
||||||
|
"Should NOT auto-deny in relay DM"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Case 3: non-relay channel → should NOT auto-deny
|
||||||
|
let msg_web = IncomingMessage::new("web", "u1", "hello")
|
||||||
|
.with_metadata(serde_json::json!({ "event_type": "message" }));
|
||||||
|
assert!(
|
||||||
|
!msg_web.channel.ends_with("-relay"),
|
||||||
|
"Non-relay channel should not trigger auto-deny"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that the auto-deny produces a PreflightOutcome::Rejected-style message.
|
||||||
|
#[test]
|
||||||
|
fn test_relay_auto_deny_message_format() {
|
||||||
|
let tool_name = "shell";
|
||||||
|
let result_msg = format!(
|
||||||
|
"Tool '{}' requires approval and cannot run in shared channels. \
|
||||||
|
Ask the user to message me directly (DM) to use this tool.",
|
||||||
|
tool_name
|
||||||
|
);
|
||||||
|
assert!(result_msg.contains("shell"));
|
||||||
|
assert!(result_msg.contains("approval"));
|
||||||
|
assert!(result_msg.contains("DM"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ pub mod task;
|
|||||||
mod thread_ops;
|
mod thread_ops;
|
||||||
pub mod undo;
|
pub mod undo;
|
||||||
|
|
||||||
pub use crate::worker::{Worker, WorkerDeps};
|
|
||||||
pub(crate) use agent_loop::truncate_for_preview;
|
pub(crate) use agent_loop::truncate_for_preview;
|
||||||
pub use agent_loop::{Agent, AgentDeps};
|
pub use agent_loop::{Agent, AgentDeps};
|
||||||
pub use compaction::{CompactionResult, ContextCompactor};
|
pub use compaction::{CompactionResult, ContextCompactor};
|
||||||
|
|||||||
+116
-3
@@ -207,7 +207,7 @@ impl Trigger {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum RoutineAction {
|
pub enum RoutineAction {
|
||||||
/// Single LLM call, no tools. Cheap and fast.
|
/// Single LLM call (optionally with tools). Cheap and fast.
|
||||||
Lightweight {
|
Lightweight {
|
||||||
/// The prompt sent to the LLM.
|
/// The prompt sent to the LLM.
|
||||||
prompt: String,
|
prompt: String,
|
||||||
@@ -217,6 +217,14 @@ pub enum RoutineAction {
|
|||||||
/// Max output tokens (default: 4096).
|
/// Max output tokens (default: 4096).
|
||||||
#[serde(default = "default_max_tokens")]
|
#[serde(default = "default_max_tokens")]
|
||||||
max_tokens: u32,
|
max_tokens: u32,
|
||||||
|
/// Enable tool access (default: false for backward compatibility).
|
||||||
|
/// When true, the LLM can call tools during execution.
|
||||||
|
/// Tools requiring approval are automatically filtered out.
|
||||||
|
#[serde(default)]
|
||||||
|
use_tools: bool,
|
||||||
|
/// Max tool call rounds (default: 3). Only used when use_tools is true.
|
||||||
|
#[serde(default = "default_max_tool_rounds")]
|
||||||
|
max_tool_rounds: u32,
|
||||||
},
|
},
|
||||||
/// Full multi-turn worker job with tool access.
|
/// Full multi-turn worker job with tool access.
|
||||||
FullJob {
|
FullJob {
|
||||||
@@ -243,6 +251,19 @@ fn default_max_iterations() -> u32 {
|
|||||||
10
|
10
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_max_tool_rounds() -> u32 {
|
||||||
|
3
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hard upper bound for max_tool_rounds to prevent runaway loops and cost explosion.
|
||||||
|
pub(crate) const MAX_TOOL_ROUNDS_LIMIT: u32 = 20;
|
||||||
|
|
||||||
|
/// Clamp max_tool_rounds to [1, MAX_TOOL_ROUNDS_LIMIT].
|
||||||
|
/// Accepts u64 to avoid truncation before clamping.
|
||||||
|
fn clamp_max_tool_rounds(value: u64) -> u32 {
|
||||||
|
value.clamp(1, MAX_TOOL_ROUNDS_LIMIT as u64) as u32
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse a `tool_permissions` JSON array into a `Vec<String>`.
|
/// Parse a `tool_permissions` JSON array into a `Vec<String>`.
|
||||||
pub fn parse_tool_permissions(value: &serde_json::Value) -> Vec<String> {
|
pub fn parse_tool_permissions(value: &serde_json::Value) -> Vec<String> {
|
||||||
value
|
value
|
||||||
@@ -290,10 +311,22 @@ impl RoutineAction {
|
|||||||
.get("max_tokens")
|
.get("max_tokens")
|
||||||
.and_then(|v| v.as_u64())
|
.and_then(|v| v.as_u64())
|
||||||
.unwrap_or(default_max_tokens() as u64) as u32;
|
.unwrap_or(default_max_tokens() as u64) as u32;
|
||||||
|
let use_tools = config
|
||||||
|
.get("use_tools")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
let max_tool_rounds = clamp_max_tool_rounds(
|
||||||
|
config
|
||||||
|
.get("max_tool_rounds")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(default_max_tool_rounds() as u64),
|
||||||
|
);
|
||||||
Ok(RoutineAction::Lightweight {
|
Ok(RoutineAction::Lightweight {
|
||||||
prompt,
|
prompt,
|
||||||
context_paths,
|
context_paths,
|
||||||
max_tokens,
|
max_tokens,
|
||||||
|
use_tools,
|
||||||
|
max_tool_rounds,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
"full_job" => {
|
"full_job" => {
|
||||||
@@ -339,10 +372,14 @@ impl RoutineAction {
|
|||||||
prompt,
|
prompt,
|
||||||
context_paths,
|
context_paths,
|
||||||
max_tokens,
|
max_tokens,
|
||||||
|
use_tools,
|
||||||
|
max_tool_rounds,
|
||||||
} => serde_json::json!({
|
} => serde_json::json!({
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"context_paths": context_paths,
|
"context_paths": context_paths,
|
||||||
"max_tokens": max_tokens,
|
"max_tokens": max_tokens,
|
||||||
|
"use_tools": use_tools,
|
||||||
|
"max_tool_rounds": max_tool_rounds,
|
||||||
}),
|
}),
|
||||||
RoutineAction::FullJob {
|
RoutineAction::FullJob {
|
||||||
title,
|
title,
|
||||||
@@ -504,7 +541,8 @@ pub fn next_cron_fire(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::agent::routine::{
|
use crate::agent::routine::{
|
||||||
RoutineAction, RoutineGuardrails, RunStatus, Trigger, content_hash, next_cron_fire,
|
MAX_TOOL_ROUNDS_LIMIT, RoutineAction, RoutineGuardrails, RunStatus, Trigger, content_hash,
|
||||||
|
next_cron_fire,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -554,11 +592,13 @@ mod tests {
|
|||||||
prompt: "Check PRs".to_string(),
|
prompt: "Check PRs".to_string(),
|
||||||
context_paths: vec!["context/priorities.md".to_string()],
|
context_paths: vec!["context/priorities.md".to_string()],
|
||||||
max_tokens: 2048,
|
max_tokens: 2048,
|
||||||
|
use_tools: false,
|
||||||
|
max_tool_rounds: 3,
|
||||||
};
|
};
|
||||||
let json = action.to_config_json();
|
let json = action.to_config_json();
|
||||||
let parsed = RoutineAction::from_db("lightweight", json).expect("parse lightweight");
|
let parsed = RoutineAction::from_db("lightweight", json).expect("parse lightweight");
|
||||||
assert!(
|
assert!(
|
||||||
matches!(parsed, RoutineAction::Lightweight { prompt, context_paths, max_tokens }
|
matches!(parsed, RoutineAction::Lightweight { prompt, context_paths, max_tokens, .. }
|
||||||
if prompt == "Check PRs" && context_paths.len() == 1 && max_tokens == 2048)
|
if prompt == "Check PRs" && context_paths.len() == 1 && max_tokens == 2048)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -695,4 +735,77 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(Trigger::Manual.type_tag(), "manual");
|
assert_eq!(Trigger::Manual.type_tag(), "manual");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_action_lightweight_backward_compat_no_use_tools() {
|
||||||
|
// Simulate old DB record without use_tools field
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"prompt": "old routine",
|
||||||
|
"context_paths": [],
|
||||||
|
"max_tokens": 4096
|
||||||
|
});
|
||||||
|
let parsed = RoutineAction::from_db("lightweight", json).expect("parse lightweight");
|
||||||
|
assert!(
|
||||||
|
matches!(parsed, RoutineAction::Lightweight { use_tools, max_tool_rounds, .. }
|
||||||
|
if !use_tools && max_tool_rounds == 3),
|
||||||
|
"missing use_tools should default to false, max_tool_rounds to 3"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_max_tool_rounds_clamped_to_upper_bound() {
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"prompt": "test",
|
||||||
|
"use_tools": true,
|
||||||
|
"max_tool_rounds": 9999
|
||||||
|
});
|
||||||
|
let parsed = RoutineAction::from_db("lightweight", json).expect("parse");
|
||||||
|
match parsed {
|
||||||
|
RoutineAction::Lightweight {
|
||||||
|
max_tool_rounds, ..
|
||||||
|
} => {
|
||||||
|
assert_eq!(
|
||||||
|
max_tool_rounds, MAX_TOOL_ROUNDS_LIMIT,
|
||||||
|
"should clamp to MAX_TOOL_ROUNDS_LIMIT"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => panic!("expected Lightweight"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_max_tool_rounds_clamped_to_lower_bound() {
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"prompt": "test",
|
||||||
|
"use_tools": true,
|
||||||
|
"max_tool_rounds": 0
|
||||||
|
});
|
||||||
|
let parsed = RoutineAction::from_db("lightweight", json).expect("parse");
|
||||||
|
match parsed {
|
||||||
|
RoutineAction::Lightweight {
|
||||||
|
max_tool_rounds, ..
|
||||||
|
} => {
|
||||||
|
assert_eq!(max_tool_rounds, 1, "should clamp 0 to 1");
|
||||||
|
}
|
||||||
|
_ => panic!("expected Lightweight"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_max_tool_rounds_normal_value_passes_through() {
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"prompt": "test",
|
||||||
|
"use_tools": true,
|
||||||
|
"max_tool_rounds": 10
|
||||||
|
});
|
||||||
|
let parsed = RoutineAction::from_db("lightweight", json).expect("parse");
|
||||||
|
match parsed {
|
||||||
|
RoutineAction::Lightweight {
|
||||||
|
max_tool_rounds, ..
|
||||||
|
} => {
|
||||||
|
assert_eq!(max_tool_rounds, 10, "normal value should pass through");
|
||||||
|
}
|
||||||
|
_ => panic!("expected Lightweight"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+84
-23
@@ -459,7 +459,20 @@ async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun)
|
|||||||
prompt,
|
prompt,
|
||||||
context_paths,
|
context_paths,
|
||||||
max_tokens,
|
max_tokens,
|
||||||
} => execute_lightweight(&ctx, &routine, prompt, context_paths, *max_tokens).await,
|
use_tools,
|
||||||
|
max_tool_rounds,
|
||||||
|
} => {
|
||||||
|
execute_lightweight(
|
||||||
|
&ctx,
|
||||||
|
&routine,
|
||||||
|
prompt,
|
||||||
|
context_paths,
|
||||||
|
*max_tokens,
|
||||||
|
*use_tools,
|
||||||
|
*max_tool_rounds,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
RoutineAction::FullJob {
|
RoutineAction::FullJob {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -670,6 +683,8 @@ async fn execute_lightweight(
|
|||||||
prompt: &str,
|
prompt: &str,
|
||||||
context_paths: &[String],
|
context_paths: &[String],
|
||||||
max_tokens: u32,
|
max_tokens: u32,
|
||||||
|
use_tools: bool,
|
||||||
|
max_tool_rounds: u32,
|
||||||
) -> Result<(RunStatus, Option<String>, Option<i32>), RoutineError> {
|
) -> Result<(RunStatus, Option<String>, Option<i32>), RoutineError> {
|
||||||
// Load context from workspace
|
// Load context from workspace
|
||||||
let mut context_parts = Vec::new();
|
let mut context_parts = Vec::new();
|
||||||
@@ -732,14 +747,15 @@ async fn execute_lightweight(
|
|||||||
Err(_) => max_tokens,
|
Err(_) => max_tokens,
|
||||||
};
|
};
|
||||||
|
|
||||||
// If tools are enabled, use the tool execution loop; otherwise, single LLM call
|
// If tools are enabled (both globally and per-routine), use the tool execution loop
|
||||||
if ctx.config.lightweight_tools_enabled {
|
if use_tools && ctx.config.lightweight_tools_enabled {
|
||||||
execute_lightweight_with_tools(
|
execute_lightweight_with_tools(
|
||||||
ctx,
|
ctx,
|
||||||
routine,
|
routine,
|
||||||
&system_prompt,
|
&system_prompt,
|
||||||
&full_prompt,
|
&full_prompt,
|
||||||
effective_max_tokens,
|
effective_max_tokens,
|
||||||
|
max_tool_rounds,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
} else {
|
} else {
|
||||||
@@ -783,24 +799,12 @@ async fn execute_lightweight_no_tools(
|
|||||||
reason: e.to_string(),
|
reason: e.to_string(),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let content = response.content.trim();
|
handle_text_response(
|
||||||
let tokens_used = Some((response.input_tokens + response.output_tokens) as i32);
|
&response.content,
|
||||||
|
response.finish_reason,
|
||||||
// Empty content guard
|
response.input_tokens,
|
||||||
if content.is_empty() {
|
response.output_tokens,
|
||||||
return if response.finish_reason == FinishReason::Length {
|
)
|
||||||
Err(RoutineError::TruncatedResponse)
|
|
||||||
} else {
|
|
||||||
Err(RoutineError::EmptyResponse)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for the "nothing to do" sentinel
|
|
||||||
if content == "ROUTINE_OK" || content.contains("ROUTINE_OK") {
|
|
||||||
return Ok((RunStatus::Ok, None, tokens_used));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((RunStatus::Attention, Some(content.to_string()), tokens_used))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle a text-only LLM response in lightweight routine execution.
|
/// Handle a text-only LLM response in lightweight routine execution.
|
||||||
@@ -850,6 +854,7 @@ async fn execute_lightweight_with_tools(
|
|||||||
system_prompt: &str,
|
system_prompt: &str,
|
||||||
full_prompt: &str,
|
full_prompt: &str,
|
||||||
effective_max_tokens: u32,
|
effective_max_tokens: u32,
|
||||||
|
max_tool_rounds: u32,
|
||||||
) -> Result<(RunStatus, Option<String>, Option<i32>), RoutineError> {
|
) -> Result<(RunStatus, Option<String>, Option<i32>), RoutineError> {
|
||||||
let mut messages = if system_prompt.is_empty() {
|
let mut messages = if system_prompt.is_empty() {
|
||||||
vec![ChatMessage::user(full_prompt)]
|
vec![ChatMessage::user(full_prompt)]
|
||||||
@@ -860,7 +865,9 @@ async fn execute_lightweight_with_tools(
|
|||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
let max_iterations = ctx.config.lightweight_max_iterations.min(5);
|
let max_iterations = max_tool_rounds
|
||||||
|
.min(ctx.config.lightweight_max_iterations)
|
||||||
|
.min(5);
|
||||||
let mut iteration = 0;
|
let mut iteration = 0;
|
||||||
let mut total_input_tokens = 0;
|
let mut total_input_tokens = 0;
|
||||||
let mut total_output_tokens = 0;
|
let mut total_output_tokens = 0;
|
||||||
@@ -906,7 +913,10 @@ async fn execute_lightweight_with_tools(
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Tool-enabled iteration
|
// Tool-enabled iteration
|
||||||
let tool_defs = ctx.tools.tool_definitions().await;
|
let tool_defs = ctx
|
||||||
|
.tools
|
||||||
|
.tool_definitions_excluding(ROUTINE_TOOL_DENYLIST)
|
||||||
|
.await;
|
||||||
|
|
||||||
let request = ToolCompletionRequest::new(messages.clone(), tool_defs)
|
let request = ToolCompletionRequest::new(messages.clone(), tool_defs)
|
||||||
.with_max_tokens(effective_max_tokens)
|
.with_max_tokens(effective_max_tokens)
|
||||||
@@ -972,12 +982,33 @@ async fn execute_lightweight_with_tools(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tools that must never be callable from lightweight routines.
|
||||||
|
///
|
||||||
|
/// These tools pose autonomy-escalation risks: a routine could self-replicate,
|
||||||
|
/// modify its own triggers/prompts, delete other routines, or restart the agent.
|
||||||
|
const ROUTINE_TOOL_DENYLIST: &[&str] = &[
|
||||||
|
"routine_create",
|
||||||
|
"routine_update",
|
||||||
|
"routine_delete",
|
||||||
|
"routine_fire",
|
||||||
|
"restart",
|
||||||
|
];
|
||||||
|
|
||||||
/// Execute a single tool for a lightweight routine.
|
/// Execute a single tool for a lightweight routine.
|
||||||
async fn execute_routine_tool(
|
async fn execute_routine_tool(
|
||||||
ctx: &EngineContext,
|
ctx: &EngineContext,
|
||||||
job_ctx: &JobContext,
|
job_ctx: &JobContext,
|
||||||
tc: &ToolCall,
|
tc: &ToolCall,
|
||||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
// Block tools that pose autonomy-escalation risks
|
||||||
|
if ROUTINE_TOOL_DENYLIST.contains(&tc.name.as_str()) {
|
||||||
|
return Err(format!(
|
||||||
|
"Tool '{}' is not available in lightweight routines",
|
||||||
|
tc.name
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
// Check if tool exists
|
// Check if tool exists
|
||||||
let tool = ctx
|
let tool = ctx
|
||||||
.tools
|
.tools
|
||||||
@@ -1283,6 +1314,36 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_routine_tool_denylist_blocks_self_management_tools() {
|
||||||
|
let denylisted = vec![
|
||||||
|
"routine_create",
|
||||||
|
"routine_update",
|
||||||
|
"routine_delete",
|
||||||
|
"routine_fire",
|
||||||
|
"restart",
|
||||||
|
];
|
||||||
|
for tool in &denylisted {
|
||||||
|
assert!(
|
||||||
|
super::ROUTINE_TOOL_DENYLIST.contains(tool),
|
||||||
|
"Tool '{}' should be in ROUTINE_TOOL_DENYLIST",
|
||||||
|
tool
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_routine_tool_denylist_allows_safe_tools() {
|
||||||
|
let allowed = vec!["echo", "time", "json", "http", "memory_search", "shell"];
|
||||||
|
for tool in &allowed {
|
||||||
|
assert!(
|
||||||
|
!super::ROUTINE_TOOL_DENYLIST.contains(tool),
|
||||||
|
"Tool '{}' should NOT be in ROUTINE_TOOL_DENYLIST",
|
||||||
|
tool
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_empty_response_handling() {
|
fn test_empty_response_handling() {
|
||||||
// Simulate the empty content guard logic
|
// Simulate the empty content guard logic
|
||||||
|
|||||||
+18
-3
@@ -486,7 +486,12 @@ impl Agent {
|
|||||||
.channels
|
.channels
|
||||||
.send_status(
|
.send_status(
|
||||||
&message.channel,
|
&message.channel,
|
||||||
StatusUpdate::Status("Awaiting approval".into()),
|
StatusUpdate::ApprovalNeeded {
|
||||||
|
request_id: request_id.to_string(),
|
||||||
|
tool_name: tool_name.clone(),
|
||||||
|
description: description.clone(),
|
||||||
|
parameters: parameters.clone(),
|
||||||
|
},
|
||||||
&message.metadata,
|
&message.metadata,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -1297,7 +1302,12 @@ impl Agent {
|
|||||||
.channels
|
.channels
|
||||||
.send_status(
|
.send_status(
|
||||||
&message.channel,
|
&message.channel,
|
||||||
StatusUpdate::Status("Awaiting approval".into()),
|
StatusUpdate::ApprovalNeeded {
|
||||||
|
request_id: request_id.to_string(),
|
||||||
|
tool_name: tool_name.clone(),
|
||||||
|
description: description.clone(),
|
||||||
|
parameters: parameters.clone(),
|
||||||
|
},
|
||||||
&message.metadata,
|
&message.metadata,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -1368,7 +1378,12 @@ impl Agent {
|
|||||||
.channels
|
.channels
|
||||||
.send_status(
|
.send_status(
|
||||||
&message.channel,
|
&message.channel,
|
||||||
StatusUpdate::Status("Awaiting approval".into()),
|
StatusUpdate::ApprovalNeeded {
|
||||||
|
request_id: request_id.to_string(),
|
||||||
|
tool_name: tool_name.clone(),
|
||||||
|
description: description.clone(),
|
||||||
|
parameters: parameters.clone(),
|
||||||
|
},
|
||||||
&message.metadata,
|
&message.metadata,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
+74
-1
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::agent::SessionManager as AgentSessionManager;
|
||||||
use crate::channels::web::log_layer::LogBroadcaster;
|
use crate::channels::web::log_layer::LogBroadcaster;
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::context::ContextManager;
|
use crate::context::ContextManager;
|
||||||
@@ -46,6 +47,8 @@ pub struct AppComponents {
|
|||||||
pub log_broadcaster: Arc<LogBroadcaster>,
|
pub log_broadcaster: Arc<LogBroadcaster>,
|
||||||
pub context_manager: Arc<ContextManager>,
|
pub context_manager: Arc<ContextManager>,
|
||||||
pub hooks: Arc<HookRegistry>,
|
pub hooks: Arc<HookRegistry>,
|
||||||
|
/// Shared thread/session manager used by the standard agent runtime.
|
||||||
|
pub agent_session_manager: Arc<AgentSessionManager>,
|
||||||
pub skill_registry: Option<Arc<std::sync::RwLock<SkillRegistry>>>,
|
pub skill_registry: Option<Arc<std::sync::RwLock<SkillRegistry>>>,
|
||||||
pub skill_catalog: Option<Arc<SkillCatalog>>,
|
pub skill_catalog: Option<Arc<SkillCatalog>>,
|
||||||
pub cost_guard: Arc<crate::agent::cost_guard::CostGuard>,
|
pub cost_guard: Arc<crate::agent::cost_guard::CostGuard>,
|
||||||
@@ -300,7 +303,8 @@ impl AppBuilder {
|
|||||||
|
|
||||||
// Register memory tools if database is available
|
// Register memory tools if database is available
|
||||||
let workspace = if let Some(ref db) = self.db {
|
let workspace = if let Some(ref db) = self.db {
|
||||||
let mut ws = Workspace::new_with_db("default", db.clone());
|
let mut ws = Workspace::new_with_db("default", db.clone())
|
||||||
|
.with_search_config(&self.config.search);
|
||||||
if let Some(ref emb) = embeddings {
|
if let Some(ref emb) = embeddings {
|
||||||
ws = ws.with_embeddings(emb.clone());
|
ws = ws.with_embeddings(emb.clone());
|
||||||
}
|
}
|
||||||
@@ -689,6 +693,8 @@ impl AppBuilder {
|
|||||||
|
|
||||||
// Create hook registry early so runtime extension activation can register hooks.
|
// Create hook registry early so runtime extension activation can register hooks.
|
||||||
let hooks = Arc::new(HookRegistry::new());
|
let hooks = Arc::new(HookRegistry::new());
|
||||||
|
let agent_session_manager =
|
||||||
|
Arc::new(AgentSessionManager::new().with_hooks(Arc::clone(&hooks)));
|
||||||
|
|
||||||
let (
|
let (
|
||||||
mcp_session_manager,
|
mcp_session_manager,
|
||||||
@@ -795,6 +801,7 @@ impl AppBuilder {
|
|||||||
log_broadcaster: self.log_broadcaster,
|
log_broadcaster: self.log_broadcaster,
|
||||||
context_manager,
|
context_manager,
|
||||||
hooks,
|
hooks,
|
||||||
|
agent_session_manager,
|
||||||
skill_registry,
|
skill_registry,
|
||||||
skill_catalog,
|
skill_catalog,
|
||||||
cost_guard,
|
cost_guard,
|
||||||
@@ -805,3 +812,69 @@ impl AppBuilder {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
use crate::agent::SessionManager as AgentSessionManager;
|
||||||
|
use crate::hooks::{
|
||||||
|
Hook, HookContext, HookError, HookEvent, HookOutcome, HookPoint, HookRegistry,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct SessionStartHook {
|
||||||
|
tx: mpsc::UnboundedSender<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Hook for SessionStartHook {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"session-start-test"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hook_points(&self) -> &[HookPoint] {
|
||||||
|
&[HookPoint::OnSessionStart]
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
event: &HookEvent,
|
||||||
|
_ctx: &HookContext,
|
||||||
|
) -> Result<HookOutcome, HookError> {
|
||||||
|
if let HookEvent::SessionStart {
|
||||||
|
user_id,
|
||||||
|
session_id,
|
||||||
|
} = event
|
||||||
|
{
|
||||||
|
self.tx
|
||||||
|
.send((user_id.clone(), session_id.clone()))
|
||||||
|
.expect("test channel receiver should be alive");
|
||||||
|
} else {
|
||||||
|
panic!("SessionStartHook received an unexpected event: {event:?}");
|
||||||
|
}
|
||||||
|
Ok(HookOutcome::ok())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn agent_session_manager_runs_session_start_hooks() {
|
||||||
|
let hooks = Arc::new(HookRegistry::new());
|
||||||
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
|
hooks.register(Arc::new(SessionStartHook { tx })).await;
|
||||||
|
|
||||||
|
let manager = AgentSessionManager::new().with_hooks(Arc::clone(&hooks));
|
||||||
|
manager.get_or_create_session("user-123").await;
|
||||||
|
|
||||||
|
let (user_id, session_id) =
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
|
||||||
|
.await
|
||||||
|
.expect("session start hook should fire")
|
||||||
|
.expect("session start payload should be present");
|
||||||
|
|
||||||
|
assert_eq!(user_id, "user-123");
|
||||||
|
assert!(!session_id.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -807,6 +807,67 @@ mod tests {
|
|||||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression test for issue #869: RwLock read guard was held across
|
||||||
|
/// tx.send(msg).await in `process_message()`, blocking shutdown() from
|
||||||
|
/// acquiring the write lock when the channel buffer was full.
|
||||||
|
///
|
||||||
|
/// This test exercises the actual production code path (`process_message`)
|
||||||
|
/// with a full channel buffer, then verifies shutdown() can still complete.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn shutdown_completes_while_process_message_blocked() {
|
||||||
|
let channel = Arc::new(test_channel(Some("secret")));
|
||||||
|
let stream = channel.start().await.unwrap();
|
||||||
|
|
||||||
|
// Fill all 256 slots in the channel buffer
|
||||||
|
{
|
||||||
|
let tx = {
|
||||||
|
let guard = channel.state.tx.read().await;
|
||||||
|
guard.as_ref().unwrap().clone()
|
||||||
|
};
|
||||||
|
for i in 0..256 {
|
||||||
|
let msg = IncomingMessage::new("http", "user", format!("fill-{}", i));
|
||||||
|
tx.send(msg).await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signal so we know the spawned task has started and is about to
|
||||||
|
// call process_message (which will block on the full channel).
|
||||||
|
let started = Arc::new(tokio::sync::Notify::new());
|
||||||
|
let started_clone = started.clone();
|
||||||
|
|
||||||
|
// Spawn a task that calls the actual production code path.
|
||||||
|
// process_message() internally acquires the RwLock read guard and
|
||||||
|
// sends on the channel. With the fix, the guard is released before
|
||||||
|
// send().await; without the fix, shutdown() would deadlock.
|
||||||
|
let state = channel.state.clone();
|
||||||
|
let blocked_send = tokio::spawn(async move {
|
||||||
|
started_clone.notify_one();
|
||||||
|
let msg = IncomingMessage::new("http", "user", "blocked-257th");
|
||||||
|
let _ = process_message(state, msg, false).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for the spawned task to start, then give it time to reach
|
||||||
|
// the send().await and verify that it is still pending (i.e., blocked).
|
||||||
|
started.notified().await;
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||||
|
assert!(
|
||||||
|
!blocked_send.is_finished(),
|
||||||
|
"process_message task should still be pending before shutdown()"
|
||||||
|
);
|
||||||
|
|
||||||
|
// shutdown() must complete even though process_message is blocked on
|
||||||
|
// send(). Before the fix, the read guard held across send().await
|
||||||
|
// would prevent shutdown() from acquiring the write lock.
|
||||||
|
let result =
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(2), channel.shutdown()).await;
|
||||||
|
assert!(result.is_ok(), "shutdown() must not deadlock");
|
||||||
|
assert!(result.unwrap().is_ok());
|
||||||
|
|
||||||
|
// Drop the stream (receiver) so the blocked send task can complete
|
||||||
|
drop(stream);
|
||||||
|
let _ = blocked_send.await;
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn webhook_missing_all_auth_returns_unauthorized() {
|
async fn webhook_missing_all_auth_returns_unauthorized() {
|
||||||
let channel = test_channel(Some("correct-secret"));
|
let channel = test_channel(Some("correct-secret"));
|
||||||
|
|||||||
@@ -408,12 +408,120 @@ impl Channel for RelayChannel {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Status updates are not forwarded to messaging providers to avoid noise.
|
|
||||||
async fn send_status(
|
async fn send_status(
|
||||||
&self,
|
&self,
|
||||||
_status: StatusUpdate,
|
status: StatusUpdate,
|
||||||
_metadata: &serde_json::Value,
|
metadata: &serde_json::Value,
|
||||||
) -> Result<(), ChannelError> {
|
) -> Result<(), ChannelError> {
|
||||||
|
// Only handle ApprovalNeeded — all other variants are no-ops
|
||||||
|
let StatusUpdate::ApprovalNeeded {
|
||||||
|
request_id,
|
||||||
|
tool_name,
|
||||||
|
description,
|
||||||
|
parameters,
|
||||||
|
} = status
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only send buttons in DMs (dispatcher gates upstream, but guard here too)
|
||||||
|
let event_type = metadata
|
||||||
|
.get("event_type")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
if event_type != "direct_message" {
|
||||||
|
tracing::warn!(
|
||||||
|
tool = %tool_name,
|
||||||
|
event_type,
|
||||||
|
"Approval requested in non-DM, skipping buttons"
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract required metadata — error if missing
|
||||||
|
let channel_id = metadata
|
||||||
|
.get("channel_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| ChannelError::SendFailed {
|
||||||
|
name: self.name().to_string(),
|
||||||
|
reason: "Missing channel_id for approval buttons".into(),
|
||||||
|
})?;
|
||||||
|
let sender_id = metadata
|
||||||
|
.get("sender_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| ChannelError::SendFailed {
|
||||||
|
name: self.name().to_string(),
|
||||||
|
reason: "Missing sender_id for approval buttons".into(),
|
||||||
|
})?;
|
||||||
|
let thread_id = metadata.get("thread_id").and_then(|v| v.as_str());
|
||||||
|
let team_id = metadata
|
||||||
|
.get("team_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or(&self.team_id);
|
||||||
|
|
||||||
|
// Button value payload (Slack limits button values to 2000 chars;
|
||||||
|
// safe with typical UUIDs but documented here as a constraint)
|
||||||
|
let value_payload = serde_json::json!({
|
||||||
|
"instance_id": self.instance_id,
|
||||||
|
"team_id": team_id,
|
||||||
|
"channel_id": channel_id,
|
||||||
|
"thread_ts": thread_id,
|
||||||
|
"request_id": request_id,
|
||||||
|
"sender_id": sender_id,
|
||||||
|
});
|
||||||
|
let value_str = value_payload.to_string();
|
||||||
|
|
||||||
|
// Parameters are already redacted via redact_params() in dispatcher.rs
|
||||||
|
let params_display =
|
||||||
|
serde_json::to_string_pretty(¶meters).unwrap_or_else(|_| parameters.to_string());
|
||||||
|
|
||||||
|
let blocks = serde_json::json!([
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"text": {
|
||||||
|
"type": "mrkdwn",
|
||||||
|
"text": format!(
|
||||||
|
"*Tool approval required*\n`{tool_name}`: {description}\n```{params_display}```"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "actions",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"type": "button",
|
||||||
|
"text": { "type": "plain_text", "text": "Approve" },
|
||||||
|
"style": "primary",
|
||||||
|
"action_id": "approve_tool",
|
||||||
|
"value": value_str,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "button",
|
||||||
|
"text": { "type": "plain_text", "text": "Deny" },
|
||||||
|
"style": "danger",
|
||||||
|
"action_id": "deny_tool",
|
||||||
|
"value": value_str,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
let mut body = serde_json::json!({
|
||||||
|
"channel": channel_id,
|
||||||
|
"text": format!("Tool approval required: {tool_name} - {description}"),
|
||||||
|
"blocks": blocks,
|
||||||
|
});
|
||||||
|
if let Some(tid) = thread_id {
|
||||||
|
body["thread_ts"] = serde_json::Value::String(tid.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.proxy_send(team_id, "chat.postMessage", body)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ChannelError::SendFailed {
|
||||||
|
name: self.name().to_string(),
|
||||||
|
reason: e.to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -639,4 +747,118 @@ mod tests {
|
|||||||
// The reconnect loop now skips team validation when team_id is empty,
|
// The reconnect loop now skips team validation when team_id is empty,
|
||||||
// so the channel remains alive.
|
// so the channel remains alive.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_send_status_non_approval_is_noop() {
|
||||||
|
let channel = RelayChannel::new(
|
||||||
|
test_client(),
|
||||||
|
"token".into(),
|
||||||
|
"T123".into(),
|
||||||
|
"inst1".into(),
|
||||||
|
"user1".into(),
|
||||||
|
);
|
||||||
|
let metadata = serde_json::json!({});
|
||||||
|
let result = channel
|
||||||
|
.send_status(
|
||||||
|
StatusUpdate::ToolStarted {
|
||||||
|
name: "echo".into(),
|
||||||
|
},
|
||||||
|
&metadata,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_send_status_approval_non_dm_skips() {
|
||||||
|
let channel = RelayChannel::new(
|
||||||
|
test_client(),
|
||||||
|
"token".into(),
|
||||||
|
"T123".into(),
|
||||||
|
"inst1".into(),
|
||||||
|
"user1".into(),
|
||||||
|
);
|
||||||
|
let metadata = serde_json::json!({
|
||||||
|
"event_type": "message",
|
||||||
|
"channel_id": "C456",
|
||||||
|
"sender_id": "U789",
|
||||||
|
});
|
||||||
|
let result = channel
|
||||||
|
.send_status(
|
||||||
|
StatusUpdate::ApprovalNeeded {
|
||||||
|
request_id: "req1".into(),
|
||||||
|
tool_name: "shell".into(),
|
||||||
|
description: "run command".into(),
|
||||||
|
parameters: serde_json::json!({}),
|
||||||
|
},
|
||||||
|
&metadata,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
// Non-DM approval requests are silently skipped (no HTTP call)
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_send_status_approval_dm_missing_channel_id_errors() {
|
||||||
|
let channel = RelayChannel::new(
|
||||||
|
test_client(),
|
||||||
|
"token".into(),
|
||||||
|
"T123".into(),
|
||||||
|
"inst1".into(),
|
||||||
|
"user1".into(),
|
||||||
|
);
|
||||||
|
let metadata = serde_json::json!({
|
||||||
|
"event_type": "direct_message",
|
||||||
|
"sender_id": "U789",
|
||||||
|
});
|
||||||
|
let result = channel
|
||||||
|
.send_status(
|
||||||
|
StatusUpdate::ApprovalNeeded {
|
||||||
|
request_id: "req1".into(),
|
||||||
|
tool_name: "shell".into(),
|
||||||
|
description: "run command".into(),
|
||||||
|
parameters: serde_json::json!({}),
|
||||||
|
},
|
||||||
|
&metadata,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err().to_string();
|
||||||
|
assert!(
|
||||||
|
err.contains("channel_id"),
|
||||||
|
"expected channel_id error, got: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_send_status_approval_dm_missing_sender_id_errors() {
|
||||||
|
let channel = RelayChannel::new(
|
||||||
|
test_client(),
|
||||||
|
"token".into(),
|
||||||
|
"T123".into(),
|
||||||
|
"inst1".into(),
|
||||||
|
"user1".into(),
|
||||||
|
);
|
||||||
|
let metadata = serde_json::json!({
|
||||||
|
"event_type": "direct_message",
|
||||||
|
"channel_id": "C456",
|
||||||
|
});
|
||||||
|
let result = channel
|
||||||
|
.send_status(
|
||||||
|
StatusUpdate::ApprovalNeeded {
|
||||||
|
request_id: "req1".into(),
|
||||||
|
tool_name: "shell".into(),
|
||||||
|
description: "run command".into(),
|
||||||
|
parameters: serde_json::json!({}),
|
||||||
|
},
|
||||||
|
&metadata,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err().to_string();
|
||||||
|
assert!(
|
||||||
|
err.contains("sender_id"),
|
||||||
|
"expected sender_id error, got: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,11 @@ const ALLOWED_MIME_PREFIXES: &[&str] = &[
|
|||||||
"application/x-tar",
|
"application/x-tar",
|
||||||
"application/octet-stream",
|
"application/octet-stream",
|
||||||
];
|
];
|
||||||
|
/// Truncate a string to at most `max_bytes` without splitting UTF-8 code points.
|
||||||
|
fn truncate_utf8(s: &str, max_bytes: usize) -> &str {
|
||||||
|
let end = crate::util::floor_char_boundary(s, max_bytes);
|
||||||
|
&s[..end]
|
||||||
|
}
|
||||||
/// A message emitted by a WASM channel to be sent to the agent.
|
/// A message emitted by a WASM channel to be sent to the agent.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct EmittedMessage {
|
pub struct EmittedMessage {
|
||||||
@@ -264,7 +268,7 @@ impl ChannelHostState {
|
|||||||
max = MAX_MESSAGE_CONTENT_SIZE,
|
max = MAX_MESSAGE_CONTENT_SIZE,
|
||||||
"Message content too large, truncating"
|
"Message content too large, truncating"
|
||||||
);
|
);
|
||||||
let mut truncated = msg.content[..MAX_MESSAGE_CONTENT_SIZE].to_string();
|
let mut truncated = truncate_utf8(&msg.content, MAX_MESSAGE_CONTENT_SIZE).to_string();
|
||||||
truncated.push_str("... (truncated)");
|
truncated.push_str("... (truncated)");
|
||||||
let msg = EmittedMessage {
|
let msg = EmittedMessage {
|
||||||
content: truncated,
|
content: truncated,
|
||||||
@@ -631,6 +635,7 @@ mod tests {
|
|||||||
use crate::channels::wasm::host::{
|
use crate::channels::wasm::host::{
|
||||||
Attachment, ChannelEmitRateLimiter, ChannelHostState, EmittedMessage,
|
Attachment, ChannelEmitRateLimiter, ChannelHostState, EmittedMessage,
|
||||||
MAX_ATTACHMENT_TOTAL_SIZE, MAX_ATTACHMENTS_PER_MESSAGE, MAX_EMITS_PER_EXECUTION,
|
MAX_ATTACHMENT_TOTAL_SIZE, MAX_ATTACHMENTS_PER_MESSAGE, MAX_EMITS_PER_EXECUTION,
|
||||||
|
MAX_MESSAGE_CONTENT_SIZE,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -689,6 +694,25 @@ mod tests {
|
|||||||
assert_eq!(state.emits_dropped(), 1);
|
assert_eq!(state.emits_dropped(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_emit_message_truncates_utf8_safely() {
|
||||||
|
let caps = ChannelCapabilities::for_channel("test");
|
||||||
|
let mut state = ChannelHostState::new("test", caps);
|
||||||
|
|
||||||
|
let prefix = "a".repeat(MAX_MESSAGE_CONTENT_SIZE - 1);
|
||||||
|
let content = format!("{}🙂suffix", prefix);
|
||||||
|
let msg = EmittedMessage::new("user123", content);
|
||||||
|
|
||||||
|
state.emit_message(msg).unwrap();
|
||||||
|
let messages = state.take_emitted_messages();
|
||||||
|
assert_eq!(messages.len(), 1);
|
||||||
|
|
||||||
|
let emitted = &messages[0].content;
|
||||||
|
assert!(emitted.starts_with(&prefix));
|
||||||
|
assert!(emitted.ends_with("... (truncated)"));
|
||||||
|
assert!(!emitted.contains("🙂"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_workspace_write_prefixing() {
|
fn test_workspace_write_prefixing() {
|
||||||
let caps = ChannelCapabilities::for_channel("slack");
|
let caps = ChannelCapabilities::for_channel("slack");
|
||||||
|
|||||||
@@ -1994,6 +1994,8 @@ impl WasmChannel {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clone sender to avoid holding RwLock read guard across send().await in the loop
|
||||||
|
let tx = {
|
||||||
let tx_guard = self.message_tx.read().await;
|
let tx_guard = self.message_tx.read().await;
|
||||||
let Some(tx) = tx_guard.as_ref() else {
|
let Some(tx) = tx_guard.as_ref() else {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
@@ -2003,11 +2005,13 @@ impl WasmChannel {
|
|||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
tx.clone()
|
||||||
let mut rate_limiter = self.rate_limiter.write().await;
|
};
|
||||||
|
|
||||||
for emitted in messages {
|
for emitted in messages {
|
||||||
// Check rate limit
|
// Check rate limit — acquire and release the write lock before send().await
|
||||||
|
{
|
||||||
|
let mut rate_limiter = self.rate_limiter.write().await;
|
||||||
if !rate_limiter.check_and_record() {
|
if !rate_limiter.check_and_record() {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
channel = %self.name,
|
channel = %self.name,
|
||||||
@@ -2017,6 +2021,7 @@ impl WasmChannel {
|
|||||||
name: self.name.clone(),
|
name: self.name.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Convert to IncomingMessage
|
// Convert to IncomingMessage
|
||||||
let mut msg = IncomingMessage::new(&self.name, &emitted.user_id, &emitted.content);
|
let mut msg = IncomingMessage::new(&self.name, &emitted.user_id, &emitted.content);
|
||||||
@@ -2057,7 +2062,7 @@ impl WasmChannel {
|
|||||||
self.update_broadcast_metadata(&emitted.metadata_json).await;
|
self.update_broadcast_metadata(&emitted.metadata_json).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send to stream
|
// Send to stream — no locks held across this await
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
channel = %self.name,
|
channel = %self.name,
|
||||||
user_id = %emitted.user_id,
|
user_id = %emitted.user_id,
|
||||||
@@ -2281,6 +2286,8 @@ impl WasmChannel {
|
|||||||
"Processing emitted messages from polling callback"
|
"Processing emitted messages from polling callback"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Clone sender to avoid holding RwLock read guard across send().await in the loop
|
||||||
|
let tx = {
|
||||||
let tx_guard = message_tx.read().await;
|
let tx_guard = message_tx.read().await;
|
||||||
let Some(tx) = tx_guard.as_ref() else {
|
let Some(tx) = tx_guard.as_ref() else {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
@@ -2290,11 +2297,13 @@ impl WasmChannel {
|
|||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
tx.clone()
|
||||||
let mut limiter = rate_limiter.write().await;
|
};
|
||||||
|
|
||||||
for emitted in messages {
|
for emitted in messages {
|
||||||
// Check rate limit
|
// Check rate limit — acquire and release the write lock before send().await
|
||||||
|
{
|
||||||
|
let mut limiter = rate_limiter.write().await;
|
||||||
if !limiter.check_and_record() {
|
if !limiter.check_and_record() {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
channel = %channel_name,
|
channel = %channel_name,
|
||||||
@@ -2304,6 +2313,7 @@ impl WasmChannel {
|
|||||||
name: channel_name.to_string(),
|
name: channel_name.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Convert to IncomingMessage
|
// Convert to IncomingMessage
|
||||||
let mut msg = IncomingMessage::new(channel_name, &emitted.user_id, &emitted.content);
|
let mut msg = IncomingMessage::new(channel_name, &emitted.user_id, &emitted.content);
|
||||||
@@ -2350,7 +2360,7 @@ impl WasmChannel {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send to stream
|
// Send to stream — no locks held across this await
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
channel = %channel_name,
|
channel = %channel_name,
|
||||||
user_id = %emitted.user_id,
|
user_id = %emitted.user_id,
|
||||||
|
|||||||
@@ -37,11 +37,17 @@ pub async fn chat_send_handler(
|
|||||||
let msg_id = msg.id;
|
let msg_id = msg.id;
|
||||||
let thread_id = msg.thread_id.clone();
|
let thread_id = msg.thread_id.clone();
|
||||||
|
|
||||||
|
// Clone sender to avoid holding RwLock read guard across send().await
|
||||||
|
let tx = {
|
||||||
let tx_guard = state.msg_tx.read().await;
|
let tx_guard = state.msg_tx.read().await;
|
||||||
let tx = tx_guard.as_ref().ok_or((
|
tx_guard
|
||||||
|
.as_ref()
|
||||||
|
.ok_or((
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
"Channel not started".to_string(),
|
"Channel not started".to_string(),
|
||||||
))?;
|
))?
|
||||||
|
.clone()
|
||||||
|
};
|
||||||
|
|
||||||
tx.send(msg).await.map_err(|_| {
|
tx.send(msg).await.map_err(|_| {
|
||||||
(
|
(
|
||||||
@@ -111,11 +117,17 @@ pub async fn chat_approval_handler(
|
|||||||
|
|
||||||
let msg_id = msg.id;
|
let msg_id = msg.id;
|
||||||
|
|
||||||
|
// Clone sender to avoid holding RwLock read guard across send().await
|
||||||
|
let tx = {
|
||||||
let tx_guard = state.msg_tx.read().await;
|
let tx_guard = state.msg_tx.read().await;
|
||||||
let tx = tx_guard.as_ref().ok_or((
|
tx_guard
|
||||||
|
.as_ref()
|
||||||
|
.ok_or((
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
"Channel not started".to_string(),
|
"Channel not started".to_string(),
|
||||||
))?;
|
))?
|
||||||
|
.clone()
|
||||||
|
};
|
||||||
|
|
||||||
tx.send(msg).await.map_err(|_| {
|
tx.send(msg).await.map_err(|_| {
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -581,7 +581,12 @@ async fn oauth_callback_handler(
|
|||||||
let exchange_proxy_url = std::env::var("IRONCLAW_OAUTH_EXCHANGE_URL").ok();
|
let exchange_proxy_url = std::env::var("IRONCLAW_OAUTH_EXCHANGE_URL").ok();
|
||||||
|
|
||||||
let result: Result<(), String> = async {
|
let result: Result<(), String> = async {
|
||||||
let token_response = if let Some(ref proxy_url) = exchange_proxy_url {
|
let token_response = if let (Some(proxy_url), None) = (&exchange_proxy_url, &flow.resource)
|
||||||
|
{
|
||||||
|
// Use the platform exchange proxy when configured and no resource
|
||||||
|
// parameter is needed. The proxy holds client_secret server-side so
|
||||||
|
// the container never sees it. MCP flows (resource.is_some()) bypass
|
||||||
|
// the proxy because it doesn't forward the RFC 8707 resource param.
|
||||||
let gateway_token = flow.gateway_token.as_deref().unwrap_or_default();
|
let gateway_token = flow.gateway_token.as_deref().unwrap_or_default();
|
||||||
oauth_defaults::exchange_via_proxy(
|
oauth_defaults::exchange_via_proxy(
|
||||||
proxy_url,
|
proxy_url,
|
||||||
@@ -594,7 +599,10 @@ async fn oauth_callback_handler(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
} else {
|
} else {
|
||||||
oauth_defaults::exchange_oauth_code(
|
// Direct token exchange: uses exchange_oauth_code_with_resource so MCP
|
||||||
|
// flows can include the RFC 8707 `resource` parameter to scope the
|
||||||
|
// issued token to the specific MCP server.
|
||||||
|
oauth_defaults::exchange_oauth_code_with_resource(
|
||||||
&flow.token_url,
|
&flow.token_url,
|
||||||
&flow.client_id,
|
&flow.client_id,
|
||||||
flow.client_secret.as_deref(),
|
flow.client_secret.as_deref(),
|
||||||
@@ -602,6 +610,7 @@ async fn oauth_callback_handler(
|
|||||||
&flow.redirect_uri,
|
&flow.redirect_uri,
|
||||||
flow.code_verifier.as_deref(),
|
flow.code_verifier.as_deref(),
|
||||||
&flow.access_token_field,
|
&flow.access_token_field,
|
||||||
|
flow.resource.as_deref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
@@ -628,6 +637,19 @@ async fn oauth_callback_handler(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// For MCP OAuth flows (identified by resource field), persist the
|
||||||
|
// client_id so token refresh works without re-authentication.
|
||||||
|
// The CLI flow stores this in authorize_mcp_server(); the gateway
|
||||||
|
// callback must do the same.
|
||||||
|
if let Some(ref client_id_secret) = flow.client_id_secret_name {
|
||||||
|
let params = crate::secrets::CreateSecretParams::new(client_id_secret, &flow.client_id)
|
||||||
|
.with_provider(flow.provider.as_ref().cloned().unwrap_or_default());
|
||||||
|
flow.secrets
|
||||||
|
.create(&flow.user_id, params)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
.await;
|
.await;
|
||||||
@@ -659,12 +681,35 @@ async fn oauth_callback_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After successful OAuth, auto-activate the extension so it moves
|
||||||
|
// from "Installed (Authenticate)" → "Active" without a second click.
|
||||||
|
// OAuth success is independent of activation — tokens are already stored.
|
||||||
|
// Report auth as successful and attempt activation as a bonus step.
|
||||||
|
let final_message = if success {
|
||||||
|
match ext_mgr.activate(&flow.extension_name).await {
|
||||||
|
Ok(result) => result.message,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
extension = %flow.extension_name,
|
||||||
|
error = %e,
|
||||||
|
"Auto-activation after OAuth failed"
|
||||||
|
);
|
||||||
|
format!(
|
||||||
|
"{} authenticated successfully. Activation failed: {}. Try activating manually.",
|
||||||
|
flow.display_name, e
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
message
|
||||||
|
};
|
||||||
|
|
||||||
// Broadcast SSE event to notify the web UI
|
// Broadcast SSE event to notify the web UI
|
||||||
if let Some(ref sender) = flow.sse_sender {
|
if let Some(ref sender) = flow.sse_sender {
|
||||||
let _ = sender.send(SseEvent::AuthCompleted {
|
let _ = sender.send(SseEvent::AuthCompleted {
|
||||||
extension_name: flow.extension_name,
|
extension_name: flow.extension_name,
|
||||||
success,
|
success,
|
||||||
message,
|
message: final_message.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -973,11 +1018,17 @@ async fn chat_send_handler(
|
|||||||
req.images.len()
|
req.images.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Clone sender to avoid holding RwLock read guard across send().await
|
||||||
|
let tx = {
|
||||||
let tx_guard = state.msg_tx.read().await;
|
let tx_guard = state.msg_tx.read().await;
|
||||||
let tx = tx_guard.as_ref().ok_or((
|
tx_guard
|
||||||
|
.as_ref()
|
||||||
|
.ok_or((
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
"Channel not started".to_string(),
|
"Channel not started".to_string(),
|
||||||
))?;
|
))?
|
||||||
|
.clone()
|
||||||
|
};
|
||||||
|
|
||||||
tracing::debug!("[chat_send_handler] Sending message through channel");
|
tracing::debug!("[chat_send_handler] Sending message through channel");
|
||||||
tx.send(msg).await.map_err(|_| {
|
tx.send(msg).await.map_err(|_| {
|
||||||
@@ -1043,11 +1094,17 @@ async fn chat_approval_handler(
|
|||||||
|
|
||||||
let msg_id = msg.id;
|
let msg_id = msg.id;
|
||||||
|
|
||||||
|
// Clone sender to avoid holding RwLock read guard across send().await
|
||||||
|
let tx = {
|
||||||
let tx_guard = state.msg_tx.read().await;
|
let tx_guard = state.msg_tx.read().await;
|
||||||
let tx = tx_guard.as_ref().ok_or((
|
tx_guard
|
||||||
|
.as_ref()
|
||||||
|
.ok_or((
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
"Channel not started".to_string(),
|
"Channel not started".to_string(),
|
||||||
))?;
|
))?
|
||||||
|
.clone()
|
||||||
|
};
|
||||||
|
|
||||||
tx.send(msg).await.map_err(|_| {
|
tx.send(msg).await.map_err(|_| {
|
||||||
(
|
(
|
||||||
@@ -2954,6 +3011,8 @@ mod tests {
|
|||||||
secrets,
|
secrets,
|
||||||
sse_sender: None,
|
sse_sender: None,
|
||||||
gateway_token: None,
|
gateway_token: None,
|
||||||
|
resource: None,
|
||||||
|
client_id_secret_name: None,
|
||||||
created_at: std::time::Instant::now()
|
created_at: std::time::Instant::now()
|
||||||
.checked_sub(std::time::Duration::from_secs(600))
|
.checked_sub(std::time::Duration::from_secs(600))
|
||||||
.expect("System uptime is too low to run expired flow test"),
|
.expect("System uptime is too low to run expired flow test"),
|
||||||
@@ -3063,6 +3122,8 @@ mod tests {
|
|||||||
secrets,
|
secrets,
|
||||||
sse_sender: None,
|
sse_sender: None,
|
||||||
gateway_token: None,
|
gateway_token: None,
|
||||||
|
resource: None,
|
||||||
|
client_id_secret_name: None,
|
||||||
// Expired — handler will reject after lookup (no network I/O)
|
// Expired — handler will reject after lookup (no network I/O)
|
||||||
created_at: std::time::Instant::now()
|
created_at: std::time::Instant::now()
|
||||||
.checked_sub(std::time::Duration::from_secs(600))
|
.checked_sub(std::time::Duration::from_secs(600))
|
||||||
|
|||||||
+149
-28
@@ -670,7 +670,7 @@ function renderMarkdown(text) {
|
|||||||
// Sanitize HTML output to prevent XSS from tool output or LLM responses.
|
// Sanitize HTML output to prevent XSS from tool output or LLM responses.
|
||||||
html = sanitizeRenderedHtml(html);
|
html = sanitizeRenderedHtml(html);
|
||||||
// Inject copy buttons into <pre> blocks
|
// Inject copy buttons into <pre> blocks
|
||||||
html = html.replace(/<pre>/g, '<pre class="code-block-wrapper"><button class="copy-btn" onclick="copyCodeBlock(this)">Copy</button>');
|
html = html.replace(/<pre>/g, '<pre class="code-block-wrapper"><button class="copy-btn" data-action="copy-code">Copy</button>');
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
return escapeHtml(text);
|
return escapeHtml(text);
|
||||||
@@ -702,16 +702,25 @@ function copyCodeBlock(btn) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function copyMessage(btn) {
|
||||||
|
const message = btn.closest('.message');
|
||||||
|
if (!message) return;
|
||||||
|
const text = message.getAttribute('data-copy-text')
|
||||||
|
|| message.getAttribute('data-raw')
|
||||||
|
|| message.textContent
|
||||||
|
|| '';
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
btn.textContent = 'Copied';
|
||||||
|
setTimeout(() => { btn.textContent = 'Copy'; }, 1200);
|
||||||
|
}).catch(() => {
|
||||||
|
btn.textContent = 'Failed';
|
||||||
|
setTimeout(() => { btn.textContent = 'Copy'; }, 1200);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function addMessage(role, content) {
|
function addMessage(role, content) {
|
||||||
const container = document.getElementById('chat-messages');
|
const container = document.getElementById('chat-messages');
|
||||||
const div = document.createElement('div');
|
const div = createMessageElement(role, content);
|
||||||
div.className = 'message ' + role;
|
|
||||||
if (role === 'user') {
|
|
||||||
div.textContent = content;
|
|
||||||
} else {
|
|
||||||
div.setAttribute('data-raw', content);
|
|
||||||
div.innerHTML = renderMarkdown(content);
|
|
||||||
}
|
|
||||||
container.appendChild(div);
|
container.appendChild(div);
|
||||||
container.scrollTop = container.scrollHeight;
|
container.scrollTop = container.scrollHeight;
|
||||||
}
|
}
|
||||||
@@ -723,7 +732,11 @@ function appendToLastAssistant(chunk) {
|
|||||||
const last = messages[messages.length - 1];
|
const last = messages[messages.length - 1];
|
||||||
const raw = (last.getAttribute('data-raw') || '') + chunk;
|
const raw = (last.getAttribute('data-raw') || '') + chunk;
|
||||||
last.setAttribute('data-raw', raw);
|
last.setAttribute('data-raw', raw);
|
||||||
last.innerHTML = renderMarkdown(raw);
|
last.setAttribute('data-copy-text', raw);
|
||||||
|
const content = last.querySelector('.message-content');
|
||||||
|
if (content) {
|
||||||
|
content.innerHTML = renderMarkdown(raw);
|
||||||
|
}
|
||||||
container.scrollTop = container.scrollHeight;
|
container.scrollTop = container.scrollHeight;
|
||||||
} else {
|
} else {
|
||||||
addMessage('assistant', chunk);
|
addMessage('assistant', chunk);
|
||||||
@@ -1310,12 +1323,31 @@ function loadHistory(before) {
|
|||||||
function createMessageElement(role, content) {
|
function createMessageElement(role, content) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'message ' + role;
|
div.className = 'message ' + role;
|
||||||
if (role === 'user') {
|
|
||||||
div.textContent = content;
|
if (role === 'assistant' || role === 'user') {
|
||||||
|
div.classList.add('has-copy');
|
||||||
|
div.setAttribute('data-copy-text', content);
|
||||||
|
const copyBtn = document.createElement('button');
|
||||||
|
copyBtn.className = 'message-copy-btn';
|
||||||
|
copyBtn.type = 'button';
|
||||||
|
copyBtn.setAttribute('aria-label', 'Copy message');
|
||||||
|
copyBtn.textContent = 'Copy';
|
||||||
|
copyBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
copyMessage(copyBtn);
|
||||||
|
});
|
||||||
|
div.appendChild(copyBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'message-content';
|
||||||
|
if (role === 'user' || role === 'system') {
|
||||||
|
body.textContent = content;
|
||||||
} else {
|
} else {
|
||||||
div.setAttribute('data-raw', content);
|
div.setAttribute('data-raw', content);
|
||||||
div.innerHTML = renderMarkdown(content);
|
body.innerHTML = renderMarkdown(content);
|
||||||
}
|
}
|
||||||
|
div.appendChild(body);
|
||||||
return div;
|
return div;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1819,13 +1851,11 @@ function saveMemoryEdit() {
|
|||||||
|
|
||||||
function buildBreadcrumb(path) {
|
function buildBreadcrumb(path) {
|
||||||
const parts = path.split('/');
|
const parts = path.split('/');
|
||||||
let html = '<a onclick="loadMemoryTree()">workspace</a>';
|
let html = '<a data-action="breadcrumb-root" href="#">workspace</a>';
|
||||||
let current = '';
|
let current = '';
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
current += (current ? '/' : '') + part;
|
current += (current ? '/' : '') + part;
|
||||||
// Store the path in data-path (HTML-escaped) and read it back via this.dataset.path
|
html += ' / <a data-action="breadcrumb-file" data-path="' + escapeHtml(current) + '" href="#">' + escapeHtml(part) + '</a>';
|
||||||
// to avoid single-quote injection in inline JS string literals.
|
|
||||||
html += ' / <a onclick="readMemoryFile(this.dataset.path)" data-path="' + escapeHtml(current) + '">' + escapeHtml(part) + '</a>';
|
|
||||||
}
|
}
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
@@ -2795,11 +2825,11 @@ function renderJobsList(jobs) {
|
|||||||
|
|
||||||
let actionBtns = '';
|
let actionBtns = '';
|
||||||
if (job.state === 'pending' || job.state === 'in_progress') {
|
if (job.state === 'pending' || job.state === 'in_progress') {
|
||||||
actionBtns = '<button class="btn-cancel" onclick="event.stopPropagation(); cancelJob(\'' + job.id + '\')">Cancel</button>';
|
actionBtns = '<button class="btn-cancel" data-action="cancel-job" data-id="' + escapeHtml(job.id) + '">Cancel</button>';
|
||||||
}
|
}
|
||||||
// Retry is only shown in the detail view where can_restart is available.
|
// Retry is only shown in the detail view where can_restart is available.
|
||||||
|
|
||||||
return '<tr class="job-row" onclick="openJobDetail(\'' + job.id + '\')">'
|
return '<tr class="job-row" data-action="open-job" data-id="' + escapeHtml(job.id) + '">'
|
||||||
+ '<td title="' + escapeHtml(job.id) + '">' + shortId + '</td>'
|
+ '<td title="' + escapeHtml(job.id) + '">' + shortId + '</td>'
|
||||||
+ '<td>' + escapeHtml(job.title) + '</td>'
|
+ '<td>' + escapeHtml(job.title) + '</td>'
|
||||||
+ '<td><span class="badge ' + stateClass + '">' + escapeHtml(job.state) + '</span></td>'
|
+ '<td><span class="badge ' + stateClass + '">' + escapeHtml(job.state) + '</span></td>'
|
||||||
@@ -2862,12 +2892,12 @@ function renderJobDetail(job) {
|
|||||||
const header = document.createElement('div');
|
const header = document.createElement('div');
|
||||||
header.className = 'job-detail-header';
|
header.className = 'job-detail-header';
|
||||||
|
|
||||||
let headerHtml = '<button class="btn-back" onclick="closeJobDetail()">← Back</button>'
|
let headerHtml = '<button class="btn-back" data-action="close-job-detail">← Back</button>'
|
||||||
+ '<h2>' + escapeHtml(job.title) + '</h2>'
|
+ '<h2>' + escapeHtml(job.title) + '</h2>'
|
||||||
+ '<span class="badge ' + stateClass + '">' + escapeHtml(job.state) + '</span>';
|
+ '<span class="badge ' + stateClass + '">' + escapeHtml(job.state) + '</span>';
|
||||||
|
|
||||||
if ((job.state === 'failed' || job.state === 'interrupted') && job.can_restart === true) {
|
if ((job.state === 'failed' || job.state === 'interrupted') && job.can_restart === true) {
|
||||||
headerHtml += '<button class="btn-restart" onclick="restartJob(\'' + job.id + '\')">Retry</button>';
|
headerHtml += '<button class="btn-restart" data-action="restart-job" data-id="' + escapeHtml(job.id) + '">Retry</button>';
|
||||||
}
|
}
|
||||||
if (job.browse_url) {
|
if (job.browse_url) {
|
||||||
headerHtml += '<a class="btn-browse" href="' + escapeHtml(job.browse_url) + '" target="_blank">Browse Files</a>';
|
headerHtml += '<a class="btn-browse" href="' + escapeHtml(job.browse_url) + '" target="_blank">Browse Files</a>';
|
||||||
@@ -3324,7 +3354,7 @@ function renderRoutinesList(routines) {
|
|||||||
const toggleLabel = r.enabled ? 'Disable' : 'Enable';
|
const toggleLabel = r.enabled ? 'Disable' : 'Enable';
|
||||||
const toggleClass = r.enabled ? 'btn-cancel' : 'btn-restart';
|
const toggleClass = r.enabled ? 'btn-cancel' : 'btn-restart';
|
||||||
|
|
||||||
return '<tr class="routine-row" onclick="openRoutineDetail(\'' + r.id + '\')">'
|
return '<tr class="routine-row" data-action="open-routine" data-id="' + escapeHtml(r.id) + '">'
|
||||||
+ '<td>' + escapeHtml(r.name) + '</td>'
|
+ '<td>' + escapeHtml(r.name) + '</td>'
|
||||||
+ '<td>' + escapeHtml(r.trigger_summary) + '</td>'
|
+ '<td>' + escapeHtml(r.trigger_summary) + '</td>'
|
||||||
+ '<td>' + escapeHtml(r.action_type) + '</td>'
|
+ '<td>' + escapeHtml(r.action_type) + '</td>'
|
||||||
@@ -3333,9 +3363,9 @@ function renderRoutinesList(routines) {
|
|||||||
+ '<td>' + r.run_count + '</td>'
|
+ '<td>' + r.run_count + '</td>'
|
||||||
+ '<td><span class="badge ' + statusClass + '">' + escapeHtml(r.status) + '</span></td>'
|
+ '<td><span class="badge ' + statusClass + '">' + escapeHtml(r.status) + '</span></td>'
|
||||||
+ '<td>'
|
+ '<td>'
|
||||||
+ '<button class="' + toggleClass + '" onclick="event.stopPropagation(); toggleRoutine(\'' + r.id + '\')">' + toggleLabel + '</button> '
|
+ '<button class="' + toggleClass + '" data-action="toggle-routine" data-id="' + escapeHtml(r.id) + '">' + toggleLabel + '</button> '
|
||||||
+ '<button class="btn-restart" onclick="event.stopPropagation(); triggerRoutine(\'' + r.id + '\')">Run</button> '
|
+ '<button class="btn-restart" data-action="trigger-routine" data-id="' + escapeHtml(r.id) + '">Run</button> '
|
||||||
+ '<button class="btn-cancel" onclick="event.stopPropagation(); deleteRoutine(\'' + r.id + '\', \'' + escapeHtml(r.name) + '\')">Delete</button>'
|
+ '<button class="btn-cancel" data-action="delete-routine" data-id="' + escapeHtml(r.id) + '" data-name="' + escapeHtml(r.name) + '">Delete</button>'
|
||||||
+ '</td>'
|
+ '</td>'
|
||||||
+ '</tr>';
|
+ '</tr>';
|
||||||
}).join('');
|
}).join('');
|
||||||
@@ -3371,7 +3401,7 @@ function renderRoutineDetail(routine) {
|
|||||||
: 'active';
|
: 'active';
|
||||||
|
|
||||||
let html = '<div class="job-detail-header">'
|
let html = '<div class="job-detail-header">'
|
||||||
+ '<button class="btn-back" onclick="closeRoutineDetail()">← Back</button>'
|
+ '<button class="btn-back" data-action="close-routine-detail">← Back</button>'
|
||||||
+ '<h2>' + escapeHtml(routine.name) + '</h2>'
|
+ '<h2>' + escapeHtml(routine.name) + '</h2>'
|
||||||
+ '<span class="badge ' + statusClass + '">' + escapeHtml(statusLabel) + '</span>'
|
+ '<span class="badge ' + statusClass + '">' + escapeHtml(statusLabel) + '</span>'
|
||||||
+ '</div>';
|
+ '</div>';
|
||||||
@@ -3418,7 +3448,7 @@ function renderRoutineDetail(routine) {
|
|||||||
+ '<td>' + formatDate(run.completed_at) + '</td>'
|
+ '<td>' + formatDate(run.completed_at) + '</td>'
|
||||||
+ '<td><span class="badge ' + runStatusClass + '">' + escapeHtml(run.status) + '</span></td>'
|
+ '<td><span class="badge ' + runStatusClass + '">' + escapeHtml(run.status) + '</span></td>'
|
||||||
+ '<td>' + escapeHtml(run.result_summary || '-')
|
+ '<td>' + escapeHtml(run.result_summary || '-')
|
||||||
+ (run.job_id ? ' <a href="#" onclick="event.preventDefault(); switchTab(\'jobs\'); openJobDetail(\'' + run.job_id + '\')">[view job]</a>' : '')
|
+ (run.job_id ? ' <a href="#" data-action="view-run-job" data-id="' + escapeHtml(run.job_id) + '">[view job]</a>' : '')
|
||||||
+ '</td>'
|
+ '</td>'
|
||||||
+ '<td>' + (run.tokens_used != null ? run.tokens_used : '-') + '</td>'
|
+ '<td>' + (run.tokens_used != null ? run.tokens_used : '-') + '</td>'
|
||||||
+ '</tr>';
|
+ '</tr>';
|
||||||
@@ -3661,7 +3691,7 @@ function renderTeePopover(report) {
|
|||||||
+ '<div class="tee-field"><div class="tee-field-label">VM Config</div>'
|
+ '<div class="tee-field"><div class="tee-field-label">VM Config</div>'
|
||||||
+ '<div class="tee-field-value">' + escapeHtml(vmConfig) + '</div></div>'
|
+ '<div class="tee-field-value">' + escapeHtml(vmConfig) + '</div></div>'
|
||||||
+ '<div class="tee-popover-actions">'
|
+ '<div class="tee-popover-actions">'
|
||||||
+ '<button class="tee-btn-copy" onclick="copyTeeReport()">Copy Full Report</button></div>';
|
+ '<button class="tee-btn-copy" data-action="copy-tee-report">Copy Full Report</button></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyTeeReport() {
|
function copyTeeReport() {
|
||||||
@@ -4143,3 +4173,94 @@ function formatDate(isoString) {
|
|||||||
const d = new Date(isoString);
|
const d = new Date(isoString);
|
||||||
return d.toLocaleString();
|
return d.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Event Listener Registration (CSP-safe, no inline handlers) ---
|
||||||
|
|
||||||
|
document.getElementById('auth-connect-btn').addEventListener('click', () => authenticate());
|
||||||
|
document.getElementById('restart-overlay').addEventListener('click', () => cancelRestart());
|
||||||
|
document.getElementById('restart-close-btn').addEventListener('click', () => cancelRestart());
|
||||||
|
document.getElementById('restart-cancel-btn').addEventListener('click', () => cancelRestart());
|
||||||
|
document.getElementById('restart-confirm-btn').addEventListener('click', () => confirmRestart());
|
||||||
|
document.getElementById('restart-btn').addEventListener('click', () => triggerRestart());
|
||||||
|
document.getElementById('thread-new-btn').addEventListener('click', () => createNewThread());
|
||||||
|
document.getElementById('thread-toggle-btn').addEventListener('click', () => toggleThreadSidebar());
|
||||||
|
document.getElementById('assistant-thread').addEventListener('click', () => switchToAssistant());
|
||||||
|
document.getElementById('send-btn').addEventListener('click', () => sendMessage());
|
||||||
|
document.getElementById('memory-edit-btn').addEventListener('click', () => startMemoryEdit());
|
||||||
|
document.getElementById('memory-save-btn').addEventListener('click', () => saveMemoryEdit());
|
||||||
|
document.getElementById('memory-cancel-btn').addEventListener('click', () => cancelMemoryEdit());
|
||||||
|
document.getElementById('logs-server-level').addEventListener('change', (e) => setServerLogLevel(e.target.value));
|
||||||
|
document.getElementById('logs-pause-btn').addEventListener('click', () => toggleLogsPause());
|
||||||
|
document.getElementById('logs-clear-btn').addEventListener('click', () => clearLogs());
|
||||||
|
document.getElementById('wasm-install-btn').addEventListener('click', () => installWasmExtension());
|
||||||
|
document.getElementById('mcp-add-btn').addEventListener('click', () => addMcpServer());
|
||||||
|
document.getElementById('skill-search-btn').addEventListener('click', () => searchClawHub());
|
||||||
|
document.getElementById('skill-install-btn').addEventListener('click', () => installSkillFromForm());
|
||||||
|
|
||||||
|
// --- Delegated Event Handlers (for dynamically generated HTML) ---
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
const el = e.target.closest('[data-action]');
|
||||||
|
if (!el) return;
|
||||||
|
const action = el.dataset.action;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'copy-code':
|
||||||
|
copyCodeBlock(el);
|
||||||
|
break;
|
||||||
|
case 'breadcrumb-root':
|
||||||
|
e.preventDefault();
|
||||||
|
loadMemoryTree();
|
||||||
|
break;
|
||||||
|
case 'breadcrumb-file':
|
||||||
|
e.preventDefault();
|
||||||
|
readMemoryFile(el.dataset.path);
|
||||||
|
break;
|
||||||
|
case 'cancel-job':
|
||||||
|
e.stopPropagation();
|
||||||
|
cancelJob(el.dataset.id);
|
||||||
|
break;
|
||||||
|
case 'open-job':
|
||||||
|
openJobDetail(el.dataset.id);
|
||||||
|
break;
|
||||||
|
case 'close-job-detail':
|
||||||
|
closeJobDetail();
|
||||||
|
break;
|
||||||
|
case 'restart-job':
|
||||||
|
restartJob(el.dataset.id);
|
||||||
|
break;
|
||||||
|
case 'open-routine':
|
||||||
|
openRoutineDetail(el.dataset.id);
|
||||||
|
break;
|
||||||
|
case 'toggle-routine':
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleRoutine(el.dataset.id);
|
||||||
|
break;
|
||||||
|
case 'trigger-routine':
|
||||||
|
e.stopPropagation();
|
||||||
|
triggerRoutine(el.dataset.id);
|
||||||
|
break;
|
||||||
|
case 'delete-routine':
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteRoutine(el.dataset.id, el.dataset.name);
|
||||||
|
break;
|
||||||
|
case 'close-routine-detail':
|
||||||
|
closeRoutineDetail();
|
||||||
|
break;
|
||||||
|
case 'view-run-job':
|
||||||
|
e.preventDefault();
|
||||||
|
switchTab('jobs');
|
||||||
|
openJobDetail(el.dataset.id);
|
||||||
|
break;
|
||||||
|
case 'copy-tee-report':
|
||||||
|
copyTeeReport();
|
||||||
|
break;
|
||||||
|
case 'switch-language':
|
||||||
|
if (typeof switchLanguage === 'function') switchLanguage(el.dataset.lang);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('language-btn').addEventListener('click', function() {
|
||||||
|
if (typeof toggleLanguageMenu === 'function') toggleLanguageMenu();
|
||||||
|
});
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
<div class="auth-form">
|
<div class="auth-form">
|
||||||
<label for="token-input" data-i18n="auth.tokenLabel">Gateway Token</label>
|
<label for="token-input" data-i18n="auth.tokenLabel">Gateway Token</label>
|
||||||
<input type="password" id="token-input" data-i18n="auth.tokenPlaceholder" data-i18n-attr="placeholder" placeholder="Paste your auth token" autofocus>
|
<input type="password" id="token-input" data-i18n="auth.tokenPlaceholder" data-i18n-attr="placeholder" placeholder="Paste your auth token" autofocus>
|
||||||
<button onclick="authenticate()" data-i18n="auth.connect">Connect</button>
|
<button id="auth-connect-btn" data-i18n="auth.connect">Connect</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="auth-error"></div>
|
<div id="auth-error"></div>
|
||||||
<p class="auth-hint" data-i18n="auth.hint">Enter the GATEWAY_AUTH_TOKEN from your .env configuration.</p>
|
<p class="auth-hint" data-i18n="auth.hint">Enter the GATEWAY_AUTH_TOKEN from your .env configuration.</p>
|
||||||
@@ -46,11 +46,11 @@
|
|||||||
|
|
||||||
<!-- Restart Confirmation Modal -->
|
<!-- Restart Confirmation Modal -->
|
||||||
<div id="restart-confirm-modal" class="restart-modal" style="display: none;">
|
<div id="restart-confirm-modal" class="restart-modal" style="display: none;">
|
||||||
<div class="restart-modal-overlay" onclick="cancelRestart()"></div>
|
<div class="restart-modal-overlay" id="restart-overlay"></div>
|
||||||
<div class="restart-modal-content">
|
<div class="restart-modal-content">
|
||||||
<div class="restart-modal-header">
|
<div class="restart-modal-header">
|
||||||
<h2 data-i18n="restart.title">Restart IronClaw Instance</h2>
|
<h2 data-i18n="restart.title">Restart IronClaw Instance</h2>
|
||||||
<button class="restart-modal-close" onclick="cancelRestart()" data-i18n="restart.closeTooltip" data-i18n-attr="title"
|
<button class="restart-modal-close" id="restart-close-btn" data-i18n="restart.closeTooltip" data-i18n-attr="title"
|
||||||
title="Close">×</button>
|
title="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="restart-modal-body">
|
<div class="restart-modal-body">
|
||||||
@@ -63,8 +63,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="restart-modal-footer">
|
<div class="restart-modal-footer">
|
||||||
<button class="restart-modal-btn cancel" onclick="cancelRestart()" data-i18n="restart.cancel">Cancel</button>
|
<button class="restart-modal-btn cancel" id="restart-cancel-btn" data-i18n="restart.cancel">Cancel</button>
|
||||||
<button class="restart-modal-btn confirm" onclick="confirmRestart()" data-i18n="restart.confirm">Confirm Restart</button>
|
<button class="restart-modal-btn confirm" id="restart-confirm-btn" data-i18n="restart.confirm">Confirm Restart</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,11 +101,11 @@
|
|||||||
|
|
||||||
<!-- Language Switcher -->
|
<!-- Language Switcher -->
|
||||||
<div class="language-switcher">
|
<div class="language-switcher">
|
||||||
<button class="language-btn" id="language-btn" type="button" onclick="toggleLanguageMenu()" title="Switch Language"
|
<button class="language-btn" id="language-btn" type="button" title="Switch Language"
|
||||||
aria-label="Switch language" aria-haspopup="true" aria-expanded="false" aria-controls="language-menu">🌐</button>
|
aria-label="Switch language" aria-haspopup="true" aria-expanded="false" aria-controls="language-menu">🌐</button>
|
||||||
<div class="language-menu" id="language-menu" style="display: none;">
|
<div class="language-menu" id="language-menu" style="display: none;">
|
||||||
<button type="button" class="language-option" onclick="switchLanguage('en')" data-lang="en">English</button>
|
<button type="button" class="language-option" data-action="switch-language" data-lang="en">English</button>
|
||||||
<button type="button" class="language-option" onclick="switchLanguage('zh-CN')" data-lang="zh-CN">简体中文</button>
|
<button type="button" class="language-option" data-action="switch-language" data-lang="zh-CN">简体中文</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@
|
|||||||
<span id="sse-status" data-i18n="status.connected">Connected</span>
|
<span id="sse-status" data-i18n="status.connected">Connected</span>
|
||||||
<div class="gateway-popover" id="gateway-popover"></div>
|
<div class="gateway-popover" id="gateway-popover"></div>
|
||||||
</div>
|
</div>
|
||||||
<button class="restart-btn" id="restart-btn" onclick="triggerRestart()" data-i18n="status.restartTooltip"
|
<button class="restart-btn" id="restart-btn" data-i18n="status.restartTooltip"
|
||||||
data-i18n-attr="title" title="Gracefully restart the process" style="display: none;">
|
data-i18n-attr="title" title="Gracefully restart the process" style="display: none;">
|
||||||
<svg id="restart-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg id="restart-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M23 4v6h-6"></path>
|
<path d="M23 4v6h-6"></path>
|
||||||
@@ -137,13 +137,13 @@
|
|||||||
<div class="tab-panel active" id="tab-chat">
|
<div class="tab-panel active" id="tab-chat">
|
||||||
<div class="thread-sidebar" id="thread-sidebar">
|
<div class="thread-sidebar" id="thread-sidebar">
|
||||||
<div class="thread-sidebar-header">
|
<div class="thread-sidebar-header">
|
||||||
<button class="thread-new-btn" onclick="createNewThread()" data-i18n="chat.newThread" data-i18n-attr="title"
|
<button class="thread-new-btn" id="thread-new-btn" data-i18n="chat.newThread" data-i18n-attr="title"
|
||||||
title="New thread (Ctrl/Cmd+N)">+</button>
|
title="New thread (Ctrl/Cmd+N)">+</button>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<button class="thread-toggle-btn" id="thread-toggle-btn" onclick="toggleThreadSidebar()" data-i18n="chat.toggleSidebar"
|
<button class="thread-toggle-btn" id="thread-toggle-btn" data-i18n="chat.toggleSidebar"
|
||||||
data-i18n-attr="title" title="Toggle sidebar">«</button>
|
data-i18n-attr="title" title="Toggle sidebar">«</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="assistant-item" id="assistant-thread" onclick="switchToAssistant()">
|
<div class="assistant-item" id="assistant-thread">
|
||||||
<span class="assistant-label" id="assistant-label" data-i18n="chat.assistant">Assistant</span>
|
<span class="assistant-label" id="assistant-label" data-i18n="chat.assistant">Assistant</span>
|
||||||
<span class="assistant-meta" id="assistant-meta"></span>
|
<span class="assistant-meta" id="assistant-meta"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -161,7 +161,7 @@
|
|||||||
<input type="file" id="image-file-input" accept="image/*" multiple style="display:none">
|
<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"
|
<button id="attach-btn" class="attach-btn" data-i18n="chat.attachImages" data-i18n-attr="title" title="Attach images"
|
||||||
aria-label="Attach images">📎</button>
|
aria-label="Attach images">📎</button>
|
||||||
<button id="send-btn" onclick="sendMessage()" data-i18n="chat.send">Send</button>
|
<button id="send-btn" data-i18n="chat.send">Send</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,7 +178,7 @@
|
|||||||
<div class="memory-content">
|
<div class="memory-content">
|
||||||
<div class="memory-breadcrumb" id="memory-breadcrumb">
|
<div class="memory-breadcrumb" id="memory-breadcrumb">
|
||||||
<span id="memory-breadcrumb-path">workspace /</span>
|
<span id="memory-breadcrumb-path">workspace /</span>
|
||||||
<button class="memory-edit-btn" id="memory-edit-btn" style="display:none" onclick="startMemoryEdit()" data-i18n="memory.edit">Edit</button>
|
<button class="memory-edit-btn" id="memory-edit-btn" style="display:none" data-i18n="memory.edit">Edit</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="memory-viewer" id="memory-viewer">
|
<div class="memory-viewer" id="memory-viewer">
|
||||||
<div class="empty" data-i18n="memory.selectFile">Select a file to view its contents</div>
|
<div class="empty" data-i18n="memory.selectFile">Select a file to view its contents</div>
|
||||||
@@ -186,8 +186,8 @@
|
|||||||
<div class="memory-editor" id="memory-editor" style="display:none">
|
<div class="memory-editor" id="memory-editor" style="display:none">
|
||||||
<textarea id="memory-edit-textarea"></textarea>
|
<textarea id="memory-edit-textarea"></textarea>
|
||||||
<div class="memory-editor-actions">
|
<div class="memory-editor-actions">
|
||||||
<button class="btn-save" onclick="saveMemoryEdit()" data-i18n="memory.save">Save</button>
|
<button class="btn-save" id="memory-save-btn" data-i18n="memory.save">Save</button>
|
||||||
<button class="btn-cancel-edit" onclick="cancelMemoryEdit()" data-i18n="memory.cancel">Cancel</button>
|
<button class="btn-cancel-edit" id="memory-cancel-btn" data-i18n="memory.cancel">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,7 +219,7 @@
|
|||||||
<div class="tab-panel" id="tab-logs">
|
<div class="tab-panel" id="tab-logs">
|
||||||
<div class="logs-container">
|
<div class="logs-container">
|
||||||
<div class="logs-toolbar">
|
<div class="logs-toolbar">
|
||||||
<select id="logs-server-level" onchange="setServerLogLevel(this.value)" title="Server-side log level (changes what the server emits)">
|
<select id="logs-server-level" title="Server-side log level (changes what the server emits)">
|
||||||
<option value="error">Server: ERROR</option>
|
<option value="error">Server: ERROR</option>
|
||||||
<option value="warn">Server: WARN</option>
|
<option value="warn">Server: WARN</option>
|
||||||
<option value="info" selected>Server: INFO</option>
|
<option value="info" selected>Server: INFO</option>
|
||||||
@@ -234,8 +234,8 @@
|
|||||||
</select>
|
</select>
|
||||||
<input type="text" id="logs-target-filter" placeholder="Filter by target...">
|
<input type="text" id="logs-target-filter" placeholder="Filter by target...">
|
||||||
<label class="logs-checkbox"><input type="checkbox" id="logs-autoscroll" checked> <span data-i18n="logs.autoScroll">Auto-scroll</span></label>
|
<label class="logs-checkbox"><input type="checkbox" id="logs-autoscroll" checked> <span data-i18n="logs.autoScroll">Auto-scroll</span></label>
|
||||||
<button id="logs-pause-btn" onclick="toggleLogsPause()" data-i18n="logs.pause">Pause</button>
|
<button id="logs-pause-btn" data-i18n="logs.pause">Pause</button>
|
||||||
<button onclick="clearLogs()" data-i18n="logs.clear">Clear</button>
|
<button id="logs-clear-btn" data-i18n="logs.clear">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="logs-output" id="logs-output"></div>
|
<div class="logs-output" id="logs-output"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -287,7 +287,7 @@
|
|||||||
<div class="ext-install-form">
|
<div class="ext-install-form">
|
||||||
<input type="text" id="wasm-install-name" data-i18n-placeholder="common.name" placeholder="Extension name">
|
<input type="text" id="wasm-install-name" data-i18n-placeholder="common.name" placeholder="Extension name">
|
||||||
<input type="text" id="wasm-install-url" placeholder="URL to .tar.gz bundle">
|
<input type="text" id="wasm-install-url" placeholder="URL to .tar.gz bundle">
|
||||||
<button onclick="installWasmExtension()" data-i18n="extensions.install">Install</button>
|
<button id="wasm-install-btn" data-i18n="extensions.install">Install</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="extensions-section">
|
<div class="extensions-section">
|
||||||
@@ -299,7 +299,7 @@
|
|||||||
<div class="ext-install-form">
|
<div class="ext-install-form">
|
||||||
<input type="text" id="mcp-install-name" data-i18n-placeholder="common.name" placeholder="Server name">
|
<input type="text" id="mcp-install-name" data-i18n-placeholder="common.name" placeholder="Server name">
|
||||||
<input type="text" id="mcp-install-url" placeholder="MCP server URL (https://...)">
|
<input type="text" id="mcp-install-url" placeholder="MCP server URL (https://...)">
|
||||||
<button onclick="addMcpServer()" data-i18n="mcp.add">Add</button>
|
<button id="mcp-add-btn" data-i18n="mcp.add">Add</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="extensions-section">
|
<div class="extensions-section">
|
||||||
@@ -320,7 +320,7 @@
|
|||||||
<h3 data-i18n="skills.searchClawHub">Search ClawHub</h3>
|
<h3 data-i18n="skills.searchClawHub">Search ClawHub</h3>
|
||||||
<div class="skill-search-box">
|
<div class="skill-search-box">
|
||||||
<input type="text" id="skill-search-input" data-i18n-placeholder="skills.searchPlaceholder" placeholder="Search...">
|
<input type="text" id="skill-search-input" data-i18n-placeholder="skills.searchPlaceholder" placeholder="Search...">
|
||||||
<button onclick="searchClawHub()" data-i18n="skills.search">Search</button>
|
<button id="skill-search-btn" data-i18n="skills.search">Search</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="extensions-list" id="skill-search-results"></div>
|
<div class="extensions-list" id="skill-search-results"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -335,7 +335,7 @@
|
|||||||
<div class="ext-install-form">
|
<div class="ext-install-form">
|
||||||
<input type="text" id="skill-install-name" data-i18n-placeholder="skills.namePlaceholder" placeholder="Skill name or slug">
|
<input type="text" id="skill-install-name" data-i18n-placeholder="skills.namePlaceholder" placeholder="Skill name or slug">
|
||||||
<input type="text" id="skill-install-url" data-i18n-placeholder="skills.urlPlaceholder" placeholder="HTTPS URL to SKILL.md (optional)">
|
<input type="text" id="skill-install-url" data-i18n-placeholder="skills.urlPlaceholder" placeholder="HTTPS URL to SKILL.md (optional)">
|
||||||
<button onclick="installSkillFromForm()" data-i18n="extensions.install">Install</button>
|
<button id="skill-install-btn" data-i18n="extensions.install">Install</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -666,6 +666,7 @@ body {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message.user {
|
.message.user {
|
||||||
@@ -686,6 +687,58 @@ body {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message.has-copy {
|
||||||
|
padding-right: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-copy-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
z-index: 2;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.user:hover .message-copy-btn,
|
||||||
|
.message.assistant:hover .message-copy-btn,
|
||||||
|
.message.user:focus-within .message-copy-btn,
|
||||||
|
.message.assistant:focus-within .message-copy-btn {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-copy-btn:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-copy-btn:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.message.user .message-copy-btn,
|
||||||
|
.message.assistant .message-copy-btn {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.message.system {
|
.message.system {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
|
|||||||
+10
-2
@@ -176,8 +176,12 @@ async fn handle_client_message(
|
|||||||
incoming = incoming.with_attachments(attachments);
|
incoming = incoming.with_attachments(attachments);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clone sender to avoid holding RwLock read guard across send().await
|
||||||
|
let tx = {
|
||||||
let tx_guard = state.msg_tx.read().await;
|
let tx_guard = state.msg_tx.read().await;
|
||||||
if let Some(ref tx) = *tx_guard {
|
tx_guard.as_ref().cloned()
|
||||||
|
};
|
||||||
|
if let Some(tx) = tx {
|
||||||
if tx.send(incoming).await.is_err() {
|
if tx.send(incoming).await.is_err() {
|
||||||
let _ = direct_tx
|
let _ = direct_tx
|
||||||
.send(WsServerMessage::Error {
|
.send(WsServerMessage::Error {
|
||||||
@@ -245,8 +249,12 @@ async fn handle_client_message(
|
|||||||
if let Some(ref tid) = thread_id {
|
if let Some(ref tid) = thread_id {
|
||||||
msg = msg.with_thread(tid);
|
msg = msg.with_thread(tid);
|
||||||
}
|
}
|
||||||
|
// Clone sender to avoid holding RwLock read guard across send().await
|
||||||
|
let tx = {
|
||||||
let tx_guard = state.msg_tx.read().await;
|
let tx_guard = state.msg_tx.read().await;
|
||||||
if let Some(ref tx) = *tx_guard {
|
tx_guard.as_ref().cloned()
|
||||||
|
};
|
||||||
|
if let Some(tx) = tx {
|
||||||
let _ = tx.send(msg).await;
|
let _ = tx.send(msg).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//! - Managing WASM tools (`tool install`, `tool list`, `tool remove`)
|
//! - Managing WASM tools (`tool install`, `tool list`, `tool remove`)
|
||||||
//! - Managing MCP servers (`mcp add`, `mcp auth`, `mcp list`, `mcp test`)
|
//! - Managing MCP servers (`mcp add`, `mcp auth`, `mcp list`, `mcp test`)
|
||||||
//! - Querying workspace memory (`memory search`, `memory read`, `memory write`)
|
//! - Querying workspace memory (`memory search`, `memory read`, `memory write`)
|
||||||
|
//! - Managing routines (`routines list`, `routines create`, `routines edit`, ...)
|
||||||
//! - Managing OS service (`service install`, `service start`, `service stop`)
|
//! - Managing OS service (`service install`, `service start`, `service stop`)
|
||||||
//! - Listing configured channels (`channels list`)
|
//! - Listing configured channels (`channels list`)
|
||||||
//! - Active health diagnostics (`doctor`)
|
//! - Active health diagnostics (`doctor`)
|
||||||
@@ -23,6 +24,7 @@ pub mod memory;
|
|||||||
pub mod oauth_defaults;
|
pub mod oauth_defaults;
|
||||||
mod pairing;
|
mod pairing;
|
||||||
mod registry;
|
mod registry;
|
||||||
|
mod routines;
|
||||||
mod service;
|
mod service;
|
||||||
mod skills;
|
mod skills;
|
||||||
pub mod status;
|
pub mod status;
|
||||||
@@ -39,6 +41,7 @@ pub use memory::MemoryCommand;
|
|||||||
pub use memory::run_memory_command_with_db;
|
pub use memory::run_memory_command_with_db;
|
||||||
pub use pairing::{PairingCommand, run_pairing_command, run_pairing_command_with_store};
|
pub use pairing::{PairingCommand, run_pairing_command, run_pairing_command_with_store};
|
||||||
pub use registry::{RegistryCommand, run_registry_command};
|
pub use registry::{RegistryCommand, run_registry_command};
|
||||||
|
pub use routines::{RoutinesCommand, run_routines_command};
|
||||||
pub use service::{ServiceCommand, run_service_command};
|
pub use service::{ServiceCommand, run_service_command};
|
||||||
pub use skills::{SkillsCommand, run_skills_command};
|
pub use skills::{SkillsCommand, run_skills_command};
|
||||||
pub use status::run_status_command;
|
pub use status::run_status_command;
|
||||||
@@ -147,6 +150,15 @@ pub enum Command {
|
|||||||
)]
|
)]
|
||||||
Channels(ChannelsCommand),
|
Channels(ChannelsCommand),
|
||||||
|
|
||||||
|
/// Manage routines (scheduled, event-driven, webhook, manual)
|
||||||
|
#[command(
|
||||||
|
subcommand,
|
||||||
|
alias = "cron",
|
||||||
|
about = "Manage routines",
|
||||||
|
long_about = "List, create, edit, enable/disable, delete, and view history of routines.\nExamples:\n ironclaw routines list\n ironclaw routines create --name daily-digest --schedule '0 0 9 * * *' --prompt 'Summarize today'"
|
||||||
|
)]
|
||||||
|
Routines(RoutinesCommand),
|
||||||
|
|
||||||
/// Manage MCP servers (hosted tool providers)
|
/// Manage MCP servers (hosted tool providers)
|
||||||
#[command(
|
#[command(
|
||||||
subcommand,
|
subcommand,
|
||||||
@@ -281,6 +293,23 @@ pub async fn init_secrets_store()
|
|||||||
Ok(crate::db::create_secrets_store(&config.database, crypto).await?)
|
Ok(crate::db::create_secrets_store(&config.database, crypto).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run the Routines CLI subcommand.
|
||||||
|
pub async fn run_routines_cli(
|
||||||
|
routines_cmd: &RoutinesCommand,
|
||||||
|
config_path: Option<&std::path::Path>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let config = crate::config::Config::from_env_with_toml(config_path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e:#}"))?;
|
||||||
|
|
||||||
|
let db: Arc<dyn crate::db::Database> = crate::db::connect_from_config(&config.database)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e:#}"))?;
|
||||||
|
|
||||||
|
let user_id = std::env::var("GATEWAY_USER_ID").unwrap_or_else(|_| "default".to_string());
|
||||||
|
run_routines_command(routines_cmd.clone(), db, &user_id).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Run the Memory CLI subcommand.
|
/// Run the Memory CLI subcommand.
|
||||||
pub async fn run_memory_command(mem_cmd: &MemoryCommand) -> anyhow::Result<()> {
|
pub async fn run_memory_command(mem_cmd: &MemoryCommand) -> anyhow::Result<()> {
|
||||||
let config = crate::config::Config::from_env()
|
let config = crate::config::Config::from_env()
|
||||||
|
|||||||
@@ -172,6 +172,35 @@ pub async fn exchange_oauth_code(
|
|||||||
redirect_uri: &str,
|
redirect_uri: &str,
|
||||||
code_verifier: Option<&str>,
|
code_verifier: Option<&str>,
|
||||||
access_token_field: &str,
|
access_token_field: &str,
|
||||||
|
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
|
||||||
|
// Delegates to exchange_oauth_code_with_resource with resource=None.
|
||||||
|
// Non-MCP OAuth flows don't need the RFC 8707 resource parameter.
|
||||||
|
exchange_oauth_code_with_resource(
|
||||||
|
token_url,
|
||||||
|
client_id,
|
||||||
|
client_secret,
|
||||||
|
code,
|
||||||
|
redirect_uri,
|
||||||
|
code_verifier,
|
||||||
|
access_token_field,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exchange an OAuth authorization code for tokens, with optional RFC 8707 `resource` parameter.
|
||||||
|
///
|
||||||
|
/// The `resource` parameter scopes the issued token to a specific server (used by MCP OAuth).
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub async fn exchange_oauth_code_with_resource(
|
||||||
|
token_url: &str,
|
||||||
|
client_id: &str,
|
||||||
|
client_secret: Option<&str>,
|
||||||
|
code: &str,
|
||||||
|
redirect_uri: &str,
|
||||||
|
code_verifier: Option<&str>,
|
||||||
|
access_token_field: &str,
|
||||||
|
resource: Option<&str>,
|
||||||
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
|
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let mut token_params = vec![
|
let mut token_params = vec![
|
||||||
@@ -184,6 +213,12 @@ pub async fn exchange_oauth_code(
|
|||||||
token_params.push(("code_verifier", verifier.to_string()));
|
token_params.push(("code_verifier", verifier.to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RFC 8707: include the `resource` parameter so the authorization server
|
||||||
|
// scopes the issued token to the specific MCP server (protected resource).
|
||||||
|
if let Some(resource) = resource {
|
||||||
|
token_params.push(("resource", resource.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
let mut request = client.post(token_url);
|
let mut request = client.post(token_url);
|
||||||
|
|
||||||
if let Some(secret) = client_secret {
|
if let Some(secret) = client_secret {
|
||||||
@@ -388,6 +423,12 @@ pub struct PendingOAuthFlow {
|
|||||||
pub sse_sender: Option<tokio::sync::broadcast::Sender<crate::channels::web::types::SseEvent>>,
|
pub sse_sender: Option<tokio::sync::broadcast::Sender<crate::channels::web::types::SseEvent>>,
|
||||||
/// Gateway auth token for authenticating with the platform token exchange proxy.
|
/// Gateway auth token for authenticating with the platform token exchange proxy.
|
||||||
pub gateway_token: Option<String>,
|
pub gateway_token: Option<String>,
|
||||||
|
/// RFC 8707 resource parameter (MCP OAuth only).
|
||||||
|
/// Sent during token exchange to scope the token to a specific MCP server.
|
||||||
|
pub resource: Option<String>,
|
||||||
|
/// Secret name for persisting the client ID (MCP OAuth only).
|
||||||
|
/// Needed so token refresh can find the client_id after the session ends.
|
||||||
|
pub client_id_secret_name: Option<String>,
|
||||||
/// When this flow was created (for expiry).
|
/// When this flow was created (for expiry).
|
||||||
pub created_at: std::time::Instant,
|
pub created_at: std::time::Instant,
|
||||||
}
|
}
|
||||||
@@ -975,4 +1016,42 @@ mod tests {
|
|||||||
assert_eq!(strip_instance_prefix("abc123"), "abc123");
|
assert_eq!(strip_instance_prefix("abc123"), "abc123");
|
||||||
assert_eq!(strip_instance_prefix(""), "");
|
assert_eq!(strip_instance_prefix(""), "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Verify that `build_oauth_url` includes the RFC 8707 `resource` parameter
|
||||||
|
/// when passed through `extra_params`, which is how MCP OAuth gateway mode
|
||||||
|
/// scopes tokens to a specific MCP server.
|
||||||
|
#[test]
|
||||||
|
fn test_build_oauth_url_includes_resource_via_extra_params() {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::cli::oauth_defaults::build_oauth_url;
|
||||||
|
|
||||||
|
let mut extra = HashMap::new();
|
||||||
|
extra.insert(
|
||||||
|
"resource".to_string(),
|
||||||
|
"https://mcp.example.com".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = build_oauth_url(
|
||||||
|
"https://auth.example.com/authorize",
|
||||||
|
"client-123",
|
||||||
|
"https://gateway.example.com/oauth/callback",
|
||||||
|
&["read".to_string()],
|
||||||
|
true,
|
||||||
|
&extra,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The resource parameter should be URL-encoded in the auth URL
|
||||||
|
assert!(
|
||||||
|
result
|
||||||
|
.url
|
||||||
|
.contains("resource=https%3A%2F%2Fmcp.example.com"),
|
||||||
|
"Expected resource param in URL: {}",
|
||||||
|
result.url
|
||||||
|
);
|
||||||
|
// State and PKCE should be present
|
||||||
|
assert!(result.url.contains("state="));
|
||||||
|
assert!(result.url.contains("code_challenge="));
|
||||||
|
assert!(result.code_verifier.is_some());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,732 @@
|
|||||||
|
//! `ironclaw routines` — manage scheduled routines from the CLI.
|
||||||
|
//!
|
||||||
|
//! Provides subcommands for listing, creating, editing, enabling/disabling,
|
||||||
|
//! deleting, and viewing run history of routines without starting the full agent.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use clap::Subcommand;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::agent::routine::{
|
||||||
|
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger, next_cron_fire,
|
||||||
|
};
|
||||||
|
use crate::db::Database;
|
||||||
|
|
||||||
|
/// Routines subcommands.
|
||||||
|
#[derive(Subcommand, Debug, Clone)]
|
||||||
|
pub enum RoutinesCommand {
|
||||||
|
/// List routines
|
||||||
|
List {
|
||||||
|
/// Filter by trigger type (e.g. "cron", "webhook", "event")
|
||||||
|
#[arg(long)]
|
||||||
|
trigger: Option<String>,
|
||||||
|
|
||||||
|
/// Include disabled routines
|
||||||
|
#[arg(long)]
|
||||||
|
disabled: bool,
|
||||||
|
|
||||||
|
/// Output as JSON (for scripting)
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Create a new cron routine
|
||||||
|
#[command(alias = "add")]
|
||||||
|
Create {
|
||||||
|
/// Routine name (must be unique per user)
|
||||||
|
#[arg(long)]
|
||||||
|
name: String,
|
||||||
|
|
||||||
|
/// Cron schedule (6-field: "sec min hour day month weekday")
|
||||||
|
#[arg(long)]
|
||||||
|
schedule: String,
|
||||||
|
|
||||||
|
/// Prompt for the LLM
|
||||||
|
#[arg(long)]
|
||||||
|
prompt: String,
|
||||||
|
|
||||||
|
/// Optional description
|
||||||
|
#[arg(long, default_value = "")]
|
||||||
|
description: String,
|
||||||
|
|
||||||
|
/// IANA timezone (e.g. "America/New_York")
|
||||||
|
#[arg(long)]
|
||||||
|
timezone: Option<String>,
|
||||||
|
|
||||||
|
/// Cooldown between fires in seconds
|
||||||
|
#[arg(long, default_value = "300")]
|
||||||
|
cooldown: u64,
|
||||||
|
|
||||||
|
/// Notification channel
|
||||||
|
#[arg(long)]
|
||||||
|
notify_channel: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Edit an existing routine
|
||||||
|
#[command(alias = "update")]
|
||||||
|
Edit {
|
||||||
|
/// Routine name
|
||||||
|
#[arg(long)]
|
||||||
|
name: String,
|
||||||
|
|
||||||
|
/// New schedule
|
||||||
|
#[arg(long)]
|
||||||
|
schedule: Option<String>,
|
||||||
|
|
||||||
|
/// New prompt
|
||||||
|
#[arg(long)]
|
||||||
|
prompt: Option<String>,
|
||||||
|
|
||||||
|
/// New description
|
||||||
|
#[arg(long)]
|
||||||
|
description: Option<String>,
|
||||||
|
|
||||||
|
/// New timezone
|
||||||
|
#[arg(long)]
|
||||||
|
timezone: Option<String>,
|
||||||
|
|
||||||
|
/// New cooldown in seconds
|
||||||
|
#[arg(long)]
|
||||||
|
cooldown: Option<u64>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Enable a routine
|
||||||
|
Enable {
|
||||||
|
/// Routine name
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Disable a routine
|
||||||
|
Disable {
|
||||||
|
/// Routine name
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Delete a routine
|
||||||
|
#[command(alias = "rm")]
|
||||||
|
Delete {
|
||||||
|
/// Routine name
|
||||||
|
name: String,
|
||||||
|
|
||||||
|
/// Skip confirmation prompt
|
||||||
|
#[arg(short, long)]
|
||||||
|
yes: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Show run history for a routine
|
||||||
|
#[command(alias = "runs")]
|
||||||
|
History {
|
||||||
|
/// Routine name
|
||||||
|
name: String,
|
||||||
|
|
||||||
|
/// Maximum number of runs to show
|
||||||
|
#[arg(short, long, default_value = "10")]
|
||||||
|
limit: i64,
|
||||||
|
|
||||||
|
/// Output as JSON (for scripting)
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a routines CLI command against the database.
|
||||||
|
pub async fn run_routines_command(
|
||||||
|
cmd: RoutinesCommand,
|
||||||
|
db: Arc<dyn Database>,
|
||||||
|
user_id: &str,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
match cmd {
|
||||||
|
RoutinesCommand::List {
|
||||||
|
trigger,
|
||||||
|
disabled,
|
||||||
|
json,
|
||||||
|
} => list(&db, user_id, trigger.as_deref(), disabled, json).await,
|
||||||
|
RoutinesCommand::Create {
|
||||||
|
name,
|
||||||
|
schedule,
|
||||||
|
prompt,
|
||||||
|
description,
|
||||||
|
timezone,
|
||||||
|
cooldown,
|
||||||
|
notify_channel,
|
||||||
|
} => {
|
||||||
|
create(
|
||||||
|
&db,
|
||||||
|
user_id,
|
||||||
|
&name,
|
||||||
|
&schedule,
|
||||||
|
&prompt,
|
||||||
|
&description,
|
||||||
|
timezone.as_deref(),
|
||||||
|
cooldown,
|
||||||
|
notify_channel,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
RoutinesCommand::Edit {
|
||||||
|
name,
|
||||||
|
schedule,
|
||||||
|
prompt,
|
||||||
|
description,
|
||||||
|
timezone,
|
||||||
|
cooldown,
|
||||||
|
} => {
|
||||||
|
edit(
|
||||||
|
&db,
|
||||||
|
user_id,
|
||||||
|
&name,
|
||||||
|
schedule.as_deref(),
|
||||||
|
prompt.as_deref(),
|
||||||
|
description.as_deref(),
|
||||||
|
timezone.as_deref(),
|
||||||
|
cooldown,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
RoutinesCommand::Enable { name } => set_enabled(&db, user_id, &name, true).await,
|
||||||
|
RoutinesCommand::Disable { name } => set_enabled(&db, user_id, &name, false).await,
|
||||||
|
RoutinesCommand::Delete { name, yes } => delete(&db, user_id, &name, yes).await,
|
||||||
|
RoutinesCommand::History { name, limit, json } => {
|
||||||
|
history(&db, user_id, &name, limit, json).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── List ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn list(
|
||||||
|
db: &Arc<dyn Database>,
|
||||||
|
user_id: &str,
|
||||||
|
trigger_filter: Option<&str>,
|
||||||
|
show_disabled: bool,
|
||||||
|
json: bool,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let routines = db.list_routines(user_id).await?;
|
||||||
|
|
||||||
|
let filtered: Vec<&Routine> = routines
|
||||||
|
.iter()
|
||||||
|
.filter(|r| {
|
||||||
|
trigger_filter
|
||||||
|
.map(|t| r.trigger.type_tag() == t)
|
||||||
|
.unwrap_or(true)
|
||||||
|
})
|
||||||
|
.filter(|r| show_disabled || r.enabled)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if json {
|
||||||
|
let items: Vec<serde_json::Value> = filtered
|
||||||
|
.iter()
|
||||||
|
.map(|r| {
|
||||||
|
serde_json::json!({
|
||||||
|
"id": r.id.to_string(),
|
||||||
|
"name": r.name,
|
||||||
|
"trigger": r.trigger.type_tag(),
|
||||||
|
"enabled": r.enabled,
|
||||||
|
"next_fire_at": r.next_fire_at,
|
||||||
|
"last_run_at": r.last_run_at,
|
||||||
|
"run_count": r.run_count,
|
||||||
|
"consecutive_failures": r.consecutive_failures,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
println!("{}", serde_json::to_string_pretty(&items)?);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if filtered.is_empty() {
|
||||||
|
if let Some(t) = trigger_filter {
|
||||||
|
println!("No {t} routines found.");
|
||||||
|
} else {
|
||||||
|
println!("No routines found.");
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header
|
||||||
|
println!(
|
||||||
|
"{:<36} {:<20} {:<8} {:<8} {:<22} {:<22} {:>5}",
|
||||||
|
"ID", "NAME", "TRIGGER", "STATUS", "NEXT FIRE", "LAST RUN", "RUNS"
|
||||||
|
);
|
||||||
|
println!("{}", "-".repeat(130));
|
||||||
|
|
||||||
|
for r in &filtered {
|
||||||
|
let status = if r.enabled {
|
||||||
|
if r.consecutive_failures > 0 {
|
||||||
|
format!("err({})", r.consecutive_failures)
|
||||||
|
} else {
|
||||||
|
"active".to_string()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"disabled".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let next_fire = r
|
||||||
|
.next_fire_at
|
||||||
|
.map(format_relative)
|
||||||
|
.unwrap_or_else(|| "-".to_string());
|
||||||
|
|
||||||
|
let last_run = r
|
||||||
|
.last_run_at
|
||||||
|
.map(format_relative)
|
||||||
|
.unwrap_or_else(|| "-".to_string());
|
||||||
|
|
||||||
|
let name = truncate(&r.name, 20);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{:<36} {:<20} {:<8} {:<8} {:<22} {:<22} {:>5}",
|
||||||
|
r.id,
|
||||||
|
name,
|
||||||
|
r.trigger.type_tag(),
|
||||||
|
status,
|
||||||
|
next_fire,
|
||||||
|
last_run,
|
||||||
|
r.run_count,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n{} routine(s)", filtered.len());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn create(
|
||||||
|
db: &Arc<dyn Database>,
|
||||||
|
user_id: &str,
|
||||||
|
name: &str,
|
||||||
|
schedule: &str,
|
||||||
|
prompt: &str,
|
||||||
|
description: &str,
|
||||||
|
timezone: Option<&str>,
|
||||||
|
cooldown_secs: u64,
|
||||||
|
notify_channel: Option<String>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
validate_timezone_arg(timezone)?;
|
||||||
|
|
||||||
|
// Validate the cron expression by computing next fire.
|
||||||
|
let next_fire = next_cron_fire(schedule, timezone)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Invalid cron schedule: {e}"))?;
|
||||||
|
|
||||||
|
// Check for name conflict.
|
||||||
|
if db.get_routine_by_name(user_id, name).await?.is_some() {
|
||||||
|
anyhow::bail!("Routine '{}' already exists", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let routine = Routine {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
name: name.to_string(),
|
||||||
|
description: description.to_string(),
|
||||||
|
user_id: user_id.to_string(),
|
||||||
|
enabled: true,
|
||||||
|
trigger: Trigger::Cron {
|
||||||
|
schedule: schedule.to_string(),
|
||||||
|
timezone: timezone.map(String::from),
|
||||||
|
},
|
||||||
|
action: RoutineAction::Lightweight {
|
||||||
|
prompt: prompt.to_string(),
|
||||||
|
context_paths: Vec::new(),
|
||||||
|
max_tokens: 4096,
|
||||||
|
use_tools: false,
|
||||||
|
max_tool_rounds: 0,
|
||||||
|
},
|
||||||
|
guardrails: RoutineGuardrails {
|
||||||
|
cooldown: std::time::Duration::from_secs(cooldown_secs),
|
||||||
|
max_concurrent: 1,
|
||||||
|
dedup_window: None,
|
||||||
|
},
|
||||||
|
notify: NotifyConfig {
|
||||||
|
channel: notify_channel,
|
||||||
|
user: user_id.to_string(),
|
||||||
|
on_attention: true,
|
||||||
|
on_failure: true,
|
||||||
|
on_success: false,
|
||||||
|
},
|
||||||
|
last_run_at: None,
|
||||||
|
next_fire_at: next_fire,
|
||||||
|
run_count: 0,
|
||||||
|
consecutive_failures: 0,
|
||||||
|
state: serde_json::json!({}),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.create_routine(&routine).await?;
|
||||||
|
|
||||||
|
println!("Created routine '{}'", name);
|
||||||
|
println!(" ID: {}", routine.id);
|
||||||
|
println!(" Schedule: {}", schedule);
|
||||||
|
if let Some(tz) = timezone {
|
||||||
|
println!(" Timezone: {}", tz);
|
||||||
|
}
|
||||||
|
if let Some(nf) = next_fire {
|
||||||
|
println!(" Next fire: {}", format_relative(nf));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edit ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn edit(
|
||||||
|
db: &Arc<dyn Database>,
|
||||||
|
user_id: &str,
|
||||||
|
name: &str,
|
||||||
|
schedule: Option<&str>,
|
||||||
|
prompt: Option<&str>,
|
||||||
|
description: Option<&str>,
|
||||||
|
timezone: Option<&str>,
|
||||||
|
cooldown: Option<u64>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let mut routine = require_routine(db, user_id, name).await?;
|
||||||
|
validate_timezone_arg(timezone)?;
|
||||||
|
|
||||||
|
let mut changed = false;
|
||||||
|
|
||||||
|
// Update schedule if provided (only valid for cron routines).
|
||||||
|
if let Some(new_schedule) = schedule {
|
||||||
|
let tz = timezone.or(match &routine.trigger {
|
||||||
|
Trigger::Cron { timezone, .. } => timezone.as_deref(),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
let next_fire = next_cron_fire(new_schedule, tz)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Invalid cron schedule: {e}"))?;
|
||||||
|
routine.trigger = Trigger::Cron {
|
||||||
|
schedule: new_schedule.to_string(),
|
||||||
|
timezone: tz.map(String::from),
|
||||||
|
};
|
||||||
|
routine.next_fire_at = next_fire;
|
||||||
|
changed = true;
|
||||||
|
} else if let Some(tz) = timezone {
|
||||||
|
// Update only timezone, recompute next fire with existing schedule.
|
||||||
|
if let Trigger::Cron { ref schedule, .. } = routine.trigger {
|
||||||
|
let next_fire = next_cron_fire(schedule, Some(tz))
|
||||||
|
.map_err(|e| anyhow::anyhow!("Invalid cron schedule: {e}"))?;
|
||||||
|
routine.trigger = Trigger::Cron {
|
||||||
|
schedule: schedule.clone(),
|
||||||
|
timezone: Some(tz.to_string()),
|
||||||
|
};
|
||||||
|
routine.next_fire_at = next_fire;
|
||||||
|
changed = true;
|
||||||
|
} else {
|
||||||
|
anyhow::bail!("Cannot set timezone on non-cron trigger");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(new_prompt) = prompt {
|
||||||
|
match &mut routine.action {
|
||||||
|
RoutineAction::Lightweight { prompt: p, .. } => {
|
||||||
|
*p = new_prompt.to_string();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
RoutineAction::FullJob { description: d, .. } => {
|
||||||
|
*d = new_prompt.to_string();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(new_desc) = description {
|
||||||
|
routine.description = new_desc.to_string();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(cd) = cooldown {
|
||||||
|
routine.guardrails.cooldown = std::time::Duration::from_secs(cd);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !changed {
|
||||||
|
println!("No changes specified.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
routine.updated_at = Utc::now();
|
||||||
|
db.update_routine(&routine).await?;
|
||||||
|
println!("Updated routine '{}'", name);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Enable / Disable ────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn set_enabled(
|
||||||
|
db: &Arc<dyn Database>,
|
||||||
|
user_id: &str,
|
||||||
|
name: &str,
|
||||||
|
enabled: bool,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let mut routine = require_routine(db, user_id, name).await?;
|
||||||
|
|
||||||
|
if routine.enabled == enabled {
|
||||||
|
println!(
|
||||||
|
"Routine '{}' is already {}",
|
||||||
|
name,
|
||||||
|
if enabled { "enabled" } else { "disabled" }
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
routine.enabled = enabled;
|
||||||
|
|
||||||
|
// Recompute next fire when enabling a cron routine.
|
||||||
|
if enabled
|
||||||
|
&& let Trigger::Cron {
|
||||||
|
ref schedule,
|
||||||
|
ref timezone,
|
||||||
|
} = routine.trigger
|
||||||
|
{
|
||||||
|
routine.next_fire_at = next_cron_fire(schedule, timezone.as_deref())
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to compute next fire for stored schedule: {e}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
routine.updated_at = Utc::now();
|
||||||
|
db.update_routine(&routine).await?;
|
||||||
|
println!(
|
||||||
|
"{} routine '{}'",
|
||||||
|
if enabled { "Enabled" } else { "Disabled" },
|
||||||
|
name
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn delete(
|
||||||
|
db: &Arc<dyn Database>,
|
||||||
|
user_id: &str,
|
||||||
|
name: &str,
|
||||||
|
skip_confirm: bool,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let routine = require_routine(db, user_id, name).await?;
|
||||||
|
|
||||||
|
if !skip_confirm {
|
||||||
|
println!("Routine: {}", routine.name);
|
||||||
|
println!(" ID: {}", routine.id);
|
||||||
|
println!(" Trigger: {}", routine.trigger.type_tag());
|
||||||
|
if let Trigger::Cron { ref schedule, .. } = routine.trigger {
|
||||||
|
println!("Schedule: {}", schedule);
|
||||||
|
}
|
||||||
|
println!(" Runs: {}", routine.run_count);
|
||||||
|
print!("\nDelete this routine? [y/N] ");
|
||||||
|
std::io::Write::flush(&mut std::io::stdout())?;
|
||||||
|
|
||||||
|
let mut input = String::new();
|
||||||
|
std::io::stdin().read_line(&mut input)?;
|
||||||
|
if !matches!(input.trim().to_lowercase().as_str(), "y" | "yes") {
|
||||||
|
println!("Cancelled.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let deleted = db.delete_routine(routine.id).await?;
|
||||||
|
if deleted {
|
||||||
|
println!("Deleted routine '{}'", name);
|
||||||
|
} else {
|
||||||
|
anyhow::bail!("Failed to delete routine '{}'", name);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── History ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn history(
|
||||||
|
db: &Arc<dyn Database>,
|
||||||
|
user_id: &str,
|
||||||
|
name: &str,
|
||||||
|
limit: i64,
|
||||||
|
json: bool,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let routine = require_routine(db, user_id, name).await?;
|
||||||
|
|
||||||
|
let limit = limit.clamp(1, 50);
|
||||||
|
let runs = db.list_routine_runs(routine.id, limit).await?;
|
||||||
|
|
||||||
|
if json {
|
||||||
|
let items: Vec<serde_json::Value> = runs
|
||||||
|
.iter()
|
||||||
|
.map(|run| {
|
||||||
|
serde_json::json!({
|
||||||
|
"id": run.id.to_string(),
|
||||||
|
"status": run.status.to_string(),
|
||||||
|
"started_at": run.started_at,
|
||||||
|
"completed_at": run.completed_at,
|
||||||
|
"result_summary": run.result_summary,
|
||||||
|
"tokens_used": run.tokens_used,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
println!("{}", serde_json::to_string_pretty(&items)?);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if runs.is_empty() {
|
||||||
|
println!("No runs found for routine '{}'", name);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Run history for '{}' (last {}):\n", name, runs.len());
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{:<36} {:<8} {:<20} {:<12} SUMMARY",
|
||||||
|
"RUN ID", "STATUS", "STARTED", "DURATION"
|
||||||
|
);
|
||||||
|
println!("{}", "-".repeat(100));
|
||||||
|
|
||||||
|
for run in &runs {
|
||||||
|
let duration = run
|
||||||
|
.completed_at
|
||||||
|
.map(|end| {
|
||||||
|
let secs = (end - run.started_at).num_seconds();
|
||||||
|
if secs < 60 {
|
||||||
|
format!("{}s", secs)
|
||||||
|
} else {
|
||||||
|
format!("{}m{}s", secs / 60, secs % 60)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "running".to_string());
|
||||||
|
|
||||||
|
let summary = run
|
||||||
|
.result_summary
|
||||||
|
.as_deref()
|
||||||
|
.map(|s| truncate(s, 40))
|
||||||
|
.unwrap_or_else(|| "-".to_string());
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{:<36} {:<8} {:<20} {:<12} {}",
|
||||||
|
run.id,
|
||||||
|
run.status,
|
||||||
|
run.started_at.format("%Y-%m-%d %H:%M:%S"),
|
||||||
|
duration,
|
||||||
|
summary,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n{} run(s) shown", runs.len());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared lookup ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Look up a routine by name.
|
||||||
|
async fn require_routine(
|
||||||
|
db: &Arc<dyn Database>,
|
||||||
|
user_id: &str,
|
||||||
|
name: &str,
|
||||||
|
) -> anyhow::Result<Routine> {
|
||||||
|
db.get_routine_by_name(user_id, name)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Routine '{}' not found", name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_timezone_arg(timezone: Option<&str>) -> anyhow::Result<()> {
|
||||||
|
if let Some(tz) = timezone
|
||||||
|
&& crate::timezone::parse_timezone(tz).is_none()
|
||||||
|
{
|
||||||
|
anyhow::bail!("Invalid timezone: '{tz}' is not a valid IANA timezone");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Format a datetime relative to now (e.g. "in 2h", "3m ago").
|
||||||
|
fn format_relative(dt: DateTime<Utc>) -> String {
|
||||||
|
let now = Utc::now();
|
||||||
|
let diff = dt.signed_duration_since(now);
|
||||||
|
let secs = diff.num_seconds();
|
||||||
|
|
||||||
|
if secs.abs() < 60 {
|
||||||
|
if secs >= 0 {
|
||||||
|
"in <1m".to_string()
|
||||||
|
} else {
|
||||||
|
"<1m ago".to_string()
|
||||||
|
}
|
||||||
|
} else if secs.abs() < 3600 {
|
||||||
|
let mins = secs.abs() / 60;
|
||||||
|
if secs >= 0 {
|
||||||
|
format!("in {}m", mins)
|
||||||
|
} else {
|
||||||
|
format!("{}m ago", mins)
|
||||||
|
}
|
||||||
|
} else if secs.abs() < 86400 {
|
||||||
|
let hours = secs.abs() / 3600;
|
||||||
|
if secs >= 0 {
|
||||||
|
format!("in {}h", hours)
|
||||||
|
} else {
|
||||||
|
format!("{}h ago", hours)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let days = secs.abs() / 86400;
|
||||||
|
if secs >= 0 {
|
||||||
|
format!("in {}d", days)
|
||||||
|
} else {
|
||||||
|
format!("{}d ago", days)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Truncate a string to a maximum character length.
|
||||||
|
fn truncate(s: &str, max_chars: usize) -> String {
|
||||||
|
if s.chars().count() <= max_chars {
|
||||||
|
s.to_string()
|
||||||
|
} else {
|
||||||
|
let truncated: String = s.chars().take(max_chars.saturating_sub(2)).collect();
|
||||||
|
format!("{}..", truncated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_relative_future() {
|
||||||
|
let future = Utc::now() + chrono::Duration::hours(2);
|
||||||
|
let result = format_relative(future);
|
||||||
|
assert!(
|
||||||
|
result.starts_with("in "),
|
||||||
|
"expected 'in ...' for future time, got: {result}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_relative_past() {
|
||||||
|
let past = Utc::now() - chrono::Duration::minutes(30);
|
||||||
|
let result = format_relative(past);
|
||||||
|
assert!(
|
||||||
|
result.ends_with(" ago"),
|
||||||
|
"expected '... ago' for past time, got: {result}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_relative_days() {
|
||||||
|
let far_future = Utc::now() + chrono::Duration::days(3);
|
||||||
|
let result = format_relative(far_future);
|
||||||
|
assert!(result.contains('d'), "expected days in: {result}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_short_string() {
|
||||||
|
assert_eq!(truncate("hello", 10), "hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_long_string() {
|
||||||
|
let result = truncate("hello world", 7);
|
||||||
|
assert_eq!(result, "hello..");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_multibyte_safe() {
|
||||||
|
// Ensure no panic on multi-byte characters.
|
||||||
|
let cjk = "你好世界测试";
|
||||||
|
let result = truncate(cjk, 4);
|
||||||
|
assert!(result.ends_with(".."), "got: {result}");
|
||||||
|
// Must be valid UTF-8 (would have panicked otherwise).
|
||||||
|
assert!(result.is_char_boundary(result.len()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
---
|
|
||||||
source: src/cli/mod.rs
|
|
||||||
assertion_line: 302
|
|
||||||
expression: help
|
|
||||||
---
|
|
||||||
Secure personal AI assistant that protects your data and expands its capabilities
|
|
||||||
|
|
||||||
Usage: ironclaw [OPTIONS] [COMMAND]
|
|
||||||
|
|
||||||
Commands:
|
|
||||||
run Run the AI agent
|
|
||||||
onboard Run interactive setup wizard
|
|
||||||
config Manage app configs
|
|
||||||
tool Manage WASM tools
|
|
||||||
registry Browse/install extensions
|
|
||||||
mcp Manage MCP servers
|
|
||||||
memory Manage workspace memory
|
|
||||||
pairing Manage DM pairing
|
|
||||||
service Manage OS service
|
|
||||||
doctor Run diagnostics
|
|
||||||
status Show system status
|
|
||||||
completion Generate completions
|
|
||||||
import Import from other AI systems
|
|
||||||
help Print this message or the help of the given subcommand(s)
|
|
||||||
|
|
||||||
Options:
|
|
||||||
--cli-only Run in interactive CLI mode only (disable other channels)
|
|
||||||
--no-db Skip database connection (for testing)
|
|
||||||
-m, --message <MESSAGE> Single message mode - send one message and exit
|
|
||||||
-c, --config <CONFIG> Configuration file path (optional, uses env vars by default)
|
|
||||||
--no-onboard Skip first-run onboarding check
|
|
||||||
-h, --help Print help (see more with '--help')
|
|
||||||
-V, --version Print version
|
|
||||||
@@ -13,6 +13,7 @@ Commands:
|
|||||||
tool Manage WASM tools
|
tool Manage WASM tools
|
||||||
registry Browse/install extensions
|
registry Browse/install extensions
|
||||||
channels Manage channels
|
channels Manage channels
|
||||||
|
routines Manage routines
|
||||||
mcp Manage MCP servers
|
mcp Manage MCP servers
|
||||||
memory Manage workspace memory
|
memory Manage workspace memory
|
||||||
pairing Manage DM pairing
|
pairing Manage DM pairing
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
---
|
|
||||||
source: src/cli/mod.rs
|
|
||||||
assertion_line: 318
|
|
||||||
expression: help
|
|
||||||
---
|
|
||||||
IronClaw is a secure AI assistant. Use 'ironclaw <subcommand> --help' for details.
|
|
||||||
Examples:
|
|
||||||
ironclaw run # Start the agent
|
|
||||||
ironclaw config list # List configs
|
|
||||||
|
|
||||||
Usage: ironclaw [OPTIONS] [COMMAND]
|
|
||||||
|
|
||||||
Commands:
|
|
||||||
run Run the AI agent
|
|
||||||
onboard Run interactive setup wizard
|
|
||||||
config Manage app configs
|
|
||||||
tool Manage WASM tools
|
|
||||||
registry Browse/install extensions
|
|
||||||
mcp Manage MCP servers
|
|
||||||
memory Manage workspace memory
|
|
||||||
pairing Manage DM pairing
|
|
||||||
service Manage OS service
|
|
||||||
doctor Run diagnostics
|
|
||||||
status Show system status
|
|
||||||
completion Generate completions
|
|
||||||
import Import from other AI systems
|
|
||||||
help Print this message or the help of the given subcommand(s)
|
|
||||||
|
|
||||||
Options:
|
|
||||||
--cli-only
|
|
||||||
Run in interactive CLI mode only (disable other channels)
|
|
||||||
|
|
||||||
--no-db
|
|
||||||
Skip database connection (for testing)
|
|
||||||
|
|
||||||
-m, --message <MESSAGE>
|
|
||||||
Single message mode - send one message and exit
|
|
||||||
|
|
||||||
-c, --config <CONFIG>
|
|
||||||
Configuration file path (optional, uses env vars by default)
|
|
||||||
|
|
||||||
--no-onboard
|
|
||||||
Skip first-run onboarding check
|
|
||||||
|
|
||||||
-h, --help
|
|
||||||
Print help (see a summary with '-h')
|
|
||||||
|
|
||||||
-V, --version
|
|
||||||
Print version
|
|
||||||
@@ -16,6 +16,7 @@ Commands:
|
|||||||
tool Manage WASM tools
|
tool Manage WASM tools
|
||||||
registry Browse/install extensions
|
registry Browse/install extensions
|
||||||
channels Manage channels
|
channels Manage channels
|
||||||
|
routines Manage routines
|
||||||
mcp Manage MCP servers
|
mcp Manage MCP servers
|
||||||
memory Manage workspace memory
|
memory Manage workspace memory
|
||||||
pairing Manage DM pairing
|
pairing Manage DM pairing
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ pub mod relay;
|
|||||||
mod routines;
|
mod routines;
|
||||||
mod safety;
|
mod safety;
|
||||||
mod sandbox;
|
mod sandbox;
|
||||||
|
mod search;
|
||||||
mod secrets;
|
mod secrets;
|
||||||
mod skills;
|
mod skills;
|
||||||
mod transcription;
|
mod transcription;
|
||||||
@@ -44,6 +45,7 @@ pub use self::routines::RoutineConfig;
|
|||||||
pub use self::safety::SafetyConfig;
|
pub use self::safety::SafetyConfig;
|
||||||
use self::safety::resolve_safety_config;
|
use self::safety::resolve_safety_config;
|
||||||
pub use self::sandbox::{ClaudeCodeConfig, SandboxModeConfig};
|
pub use self::sandbox::{ClaudeCodeConfig, SandboxModeConfig};
|
||||||
|
pub use self::search::WorkspaceSearchConfig;
|
||||||
pub use self::secrets::SecretsConfig;
|
pub use self::secrets::SecretsConfig;
|
||||||
pub use self::skills::SkillsConfig;
|
pub use self::skills::SkillsConfig;
|
||||||
pub use self::transcription::TranscriptionConfig;
|
pub use self::transcription::TranscriptionConfig;
|
||||||
@@ -91,6 +93,7 @@ pub struct Config {
|
|||||||
pub claude_code: ClaudeCodeConfig,
|
pub claude_code: ClaudeCodeConfig,
|
||||||
pub skills: SkillsConfig,
|
pub skills: SkillsConfig,
|
||||||
pub transcription: TranscriptionConfig,
|
pub transcription: TranscriptionConfig,
|
||||||
|
pub search: WorkspaceSearchConfig,
|
||||||
pub observability: crate::observability::ObservabilityConfig,
|
pub observability: crate::observability::ObservabilityConfig,
|
||||||
/// Channel-relay integration (Slack via external relay service).
|
/// Channel-relay integration (Slack via external relay service).
|
||||||
/// Present only when both `CHANNEL_RELAY_URL` and `CHANNEL_RELAY_API_KEY` are set.
|
/// Present only when both `CHANNEL_RELAY_URL` and `CHANNEL_RELAY_API_KEY` are set.
|
||||||
@@ -166,6 +169,7 @@ impl Config {
|
|||||||
..SkillsConfig::default()
|
..SkillsConfig::default()
|
||||||
},
|
},
|
||||||
transcription: TranscriptionConfig::default(),
|
transcription: TranscriptionConfig::default(),
|
||||||
|
search: WorkspaceSearchConfig::default(),
|
||||||
observability: crate::observability::ObservabilityConfig::default(),
|
observability: crate::observability::ObservabilityConfig::default(),
|
||||||
relay: None,
|
relay: None,
|
||||||
}
|
}
|
||||||
@@ -318,6 +322,7 @@ impl Config {
|
|||||||
claude_code: ClaudeCodeConfig::resolve()?,
|
claude_code: ClaudeCodeConfig::resolve()?,
|
||||||
skills: SkillsConfig::resolve()?,
|
skills: SkillsConfig::resolve()?,
|
||||||
transcription: TranscriptionConfig::resolve(settings)?,
|
transcription: TranscriptionConfig::resolve(settings)?,
|
||||||
|
search: WorkspaceSearchConfig::resolve()?,
|
||||||
observability: crate::observability::ObservabilityConfig {
|
observability: crate::observability::ObservabilityConfig {
|
||||||
backend: std::env::var("OBSERVABILITY_BACKEND").unwrap_or_else(|_| "none".into()),
|
backend: std::env::var("OBSERVABILITY_BACKEND").unwrap_or_else(|_| "none".into()),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
use crate::config::helpers::{optional_env, parse_optional_env};
|
||||||
|
use crate::error::ConfigError;
|
||||||
|
use crate::workspace::FusionStrategy;
|
||||||
|
|
||||||
|
/// Workspace search configuration resolved from environment variables.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct WorkspaceSearchConfig {
|
||||||
|
/// Fusion strategy: "rrf" or "weighted".
|
||||||
|
pub fusion_strategy: FusionStrategy,
|
||||||
|
/// RRF constant k (default 60).
|
||||||
|
pub rrf_k: u32,
|
||||||
|
/// FTS weight for fusion.
|
||||||
|
///
|
||||||
|
/// [`Default`] uses 0.5. When the configuration is resolved, per-strategy
|
||||||
|
/// defaults are applied: 0.5 (RRF) or 0.3 (weighted).
|
||||||
|
pub fts_weight: f32,
|
||||||
|
/// Vector weight for fusion.
|
||||||
|
///
|
||||||
|
/// [`Default`] uses 0.5. When the configuration is resolved, per-strategy
|
||||||
|
/// defaults are applied: 0.5 (RRF) or 0.7 (weighted).
|
||||||
|
pub vector_weight: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WorkspaceSearchConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
fusion_strategy: FusionStrategy::default(),
|
||||||
|
rrf_k: 60,
|
||||||
|
fts_weight: 0.5,
|
||||||
|
vector_weight: 0.5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkspaceSearchConfig {
|
||||||
|
pub(crate) fn resolve() -> Result<Self, ConfigError> {
|
||||||
|
let fusion_strategy = match optional_env("SEARCH_FUSION_STRATEGY")? {
|
||||||
|
Some(s) => match s.to_lowercase().as_str() {
|
||||||
|
"rrf" => FusionStrategy::Rrf,
|
||||||
|
"weighted" => FusionStrategy::WeightedScore,
|
||||||
|
other => {
|
||||||
|
return Err(ConfigError::InvalidValue {
|
||||||
|
key: "SEARCH_FUSION_STRATEGY".to_string(),
|
||||||
|
message: format!("must be 'rrf' or 'weighted', got '{other}'"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => FusionStrategy::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let rrf_k = parse_optional_env("SEARCH_RRF_K", 60u32)?;
|
||||||
|
|
||||||
|
// Per-strategy weight defaults: RRF uses 0.5/0.5, weighted uses 0.3/0.7 (vector-biased).
|
||||||
|
let (default_fts, default_vec) = match fusion_strategy {
|
||||||
|
FusionStrategy::Rrf => (0.5f32, 0.5f32),
|
||||||
|
FusionStrategy::WeightedScore => (0.3f32, 0.7f32),
|
||||||
|
};
|
||||||
|
let fts_weight = parse_optional_env("SEARCH_FTS_WEIGHT", default_fts)?;
|
||||||
|
let vector_weight = parse_optional_env("SEARCH_VECTOR_WEIGHT", default_vec)?;
|
||||||
|
|
||||||
|
if !fts_weight.is_finite() || fts_weight < 0.0 {
|
||||||
|
return Err(ConfigError::InvalidValue {
|
||||||
|
key: "SEARCH_FTS_WEIGHT".to_string(),
|
||||||
|
message: "must be a finite, non-negative float".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !vector_weight.is_finite() || vector_weight < 0.0 {
|
||||||
|
return Err(ConfigError::InvalidValue {
|
||||||
|
key: "SEARCH_VECTOR_WEIGHT".to_string(),
|
||||||
|
message: "must be a finite, non-negative float".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if matches!(fusion_strategy, FusionStrategy::WeightedScore)
|
||||||
|
&& fts_weight == 0.0
|
||||||
|
&& vector_weight == 0.0
|
||||||
|
{
|
||||||
|
return Err(ConfigError::InvalidValue {
|
||||||
|
key: "SEARCH_FTS_WEIGHT/SEARCH_VECTOR_WEIGHT".to_string(),
|
||||||
|
message: "weighted fusion requires at least one non-zero weight".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
fusion_strategy,
|
||||||
|
rrf_k,
|
||||||
|
fts_weight,
|
||||||
|
vector_weight,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::config::helpers::ENV_MUTEX;
|
||||||
|
|
||||||
|
fn clear_search_env() {
|
||||||
|
// SAFETY: Only called under ENV_MUTEX in tests.
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var("SEARCH_FUSION_STRATEGY");
|
||||||
|
std::env::remove_var("SEARCH_RRF_K");
|
||||||
|
std::env::remove_var("SEARCH_FTS_WEIGHT");
|
||||||
|
std::env::remove_var("SEARCH_VECTOR_WEIGHT");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_when_no_env() {
|
||||||
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||||
|
clear_search_env();
|
||||||
|
|
||||||
|
let config = WorkspaceSearchConfig::resolve().expect("should resolve");
|
||||||
|
assert_eq!(config.fusion_strategy, FusionStrategy::Rrf);
|
||||||
|
assert_eq!(config.rrf_k, 60);
|
||||||
|
assert!((config.fts_weight - 0.5).abs() < 0.001);
|
||||||
|
assert!((config.vector_weight - 0.5).abs() < 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn env_overrides() {
|
||||||
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||||
|
clear_search_env();
|
||||||
|
|
||||||
|
// SAFETY: Under ENV_MUTEX.
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("SEARCH_FUSION_STRATEGY", "weighted");
|
||||||
|
std::env::set_var("SEARCH_RRF_K", "30");
|
||||||
|
std::env::set_var("SEARCH_FTS_WEIGHT", "0.9");
|
||||||
|
std::env::set_var("SEARCH_VECTOR_WEIGHT", "0.1");
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = WorkspaceSearchConfig::resolve().expect("should resolve");
|
||||||
|
assert_eq!(config.fusion_strategy, FusionStrategy::WeightedScore);
|
||||||
|
assert_eq!(config.rrf_k, 30);
|
||||||
|
assert!((config.fts_weight - 0.9).abs() < 0.001);
|
||||||
|
assert!((config.vector_weight - 0.1).abs() < 0.001);
|
||||||
|
|
||||||
|
clear_search_env();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_strategy_rejected() {
|
||||||
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||||
|
clear_search_env();
|
||||||
|
|
||||||
|
// SAFETY: Under ENV_MUTEX.
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("SEARCH_FUSION_STRATEGY", "bm25");
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = WorkspaceSearchConfig::resolve();
|
||||||
|
assert!(result.is_err());
|
||||||
|
|
||||||
|
clear_search_env();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn weighted_strategy_defaults() {
|
||||||
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||||
|
clear_search_env();
|
||||||
|
|
||||||
|
// SAFETY: Under ENV_MUTEX.
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("SEARCH_FUSION_STRATEGY", "weighted");
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = WorkspaceSearchConfig::resolve().expect("should resolve");
|
||||||
|
assert_eq!(config.fusion_strategy, FusionStrategy::WeightedScore);
|
||||||
|
// Weighted mode should default to 0.3 FTS / 0.7 vector
|
||||||
|
assert!((config.fts_weight - 0.3).abs() < 0.001);
|
||||||
|
assert!((config.vector_weight - 0.7).abs() < 0.001);
|
||||||
|
|
||||||
|
clear_search_env();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn weighted_both_zero_rejected() {
|
||||||
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||||
|
clear_search_env();
|
||||||
|
|
||||||
|
// SAFETY: Under ENV_MUTEX.
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("SEARCH_FUSION_STRATEGY", "weighted");
|
||||||
|
std::env::set_var("SEARCH_FTS_WEIGHT", "0.0");
|
||||||
|
std::env::set_var("SEARCH_VECTOR_WEIGHT", "0.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = WorkspaceSearchConfig::resolve();
|
||||||
|
assert!(result.is_err());
|
||||||
|
|
||||||
|
clear_search_env();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rrf_both_zero_allowed() {
|
||||||
|
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||||
|
clear_search_env();
|
||||||
|
|
||||||
|
// SAFETY: Under ENV_MUTEX.
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("SEARCH_FTS_WEIGHT", "0.0");
|
||||||
|
std::env::set_var("SEARCH_VECTOR_WEIGHT", "0.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
// RRF ignores weights, so both=0 is fine
|
||||||
|
let config = WorkspaceSearchConfig::resolve().expect("should resolve");
|
||||||
|
assert_eq!(config.fusion_strategy, FusionStrategy::Rrf);
|
||||||
|
|
||||||
|
clear_search_env();
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-2
@@ -169,7 +169,7 @@ pub(crate) fn parse_timestamp(s: &str) -> Result<DateTime<Utc>, String> {
|
|||||||
}
|
}
|
||||||
// Naive with fractional seconds (legacy or SQLite datetime() output)
|
// 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") {
|
if let Ok(ndt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f") {
|
||||||
tracing::warn!(
|
tracing::debug!(
|
||||||
timestamp = %s,
|
timestamp = %s,
|
||||||
"parsed naive timestamp without timezone; assuming UTC for backward compatibility"
|
"parsed naive timestamp without timezone; assuming UTC for backward compatibility"
|
||||||
);
|
);
|
||||||
@@ -177,7 +177,7 @@ pub(crate) fn parse_timestamp(s: &str) -> Result<DateTime<Utc>, String> {
|
|||||||
}
|
}
|
||||||
// Naive without fractional seconds (legacy format)
|
// Naive without fractional seconds (legacy format)
|
||||||
if let Ok(ndt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
|
if let Ok(ndt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
|
||||||
tracing::warn!(
|
tracing::debug!(
|
||||||
timestamp = %s,
|
timestamp = %s,
|
||||||
"parsed naive timestamp without timezone; assuming UTC for backward compatibility"
|
"parsed naive timestamp without timezone; assuming UTC for backward compatibility"
|
||||||
);
|
);
|
||||||
@@ -326,6 +326,24 @@ impl Database for LibSqlBackend {
|
|||||||
libsql_migrations::run_incremental(&conn).await?;
|
libsql_migrations::run_incremental(&conn).await?;
|
||||||
Ok(())
|
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 ====================
|
// ==================== Row conversion helpers ====================
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use crate::db::WorkspaceStore;
|
|||||||
use crate::error::WorkspaceError;
|
use crate::error::WorkspaceError;
|
||||||
use crate::workspace::{
|
use crate::workspace::{
|
||||||
MemoryChunk, MemoryDocument, RankedResult, SearchConfig, SearchResult, WorkspaceEntry,
|
MemoryChunk, MemoryDocument, RankedResult, SearchConfig, SearchResult, WorkspaceEntry,
|
||||||
reciprocal_rank_fusion,
|
fuse_results,
|
||||||
};
|
};
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
@@ -614,6 +614,6 @@ impl WorkspaceStore for LibSqlBackend {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(reciprocal_rank_fusion(fts_results, vector_results, config))
|
Ok(fuse_results(fts_results, vector_results, config))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -523,6 +523,13 @@ pub trait Database:
|
|||||||
{
|
{
|
||||||
/// Run schema migrations for this backend.
|
/// Run schema migrations for this backend.
|
||||||
async fn run_migrations(&self) -> Result<(), DatabaseError>;
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ impl Database for PgBackend {
|
|||||||
async fn run_migrations(&self) -> Result<(), DatabaseError> {
|
async fn run_migrations(&self) -> Result<(), DatabaseError> {
|
||||||
self.store.run_migrations().await
|
self.store.run_migrations().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn shutdown(&self) -> Result<(), DatabaseError> {
|
||||||
|
self.store.pool().close();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== ConversationStore ====================
|
// ==================== ConversationStore ====================
|
||||||
|
|||||||
+432
-44
@@ -27,7 +27,7 @@ use crate::secrets::{CreateSecretParams, SecretsStore};
|
|||||||
use crate::tools::ToolRegistry;
|
use crate::tools::ToolRegistry;
|
||||||
use crate::tools::mcp::McpClient;
|
use crate::tools::mcp::McpClient;
|
||||||
use crate::tools::mcp::auth::{
|
use crate::tools::mcp::auth::{
|
||||||
PkceChallenge, authorize_mcp_server, build_authorization_url, discover_full_oauth_metadata,
|
authorize_mcp_server, canonical_resource_uri, discover_full_oauth_metadata,
|
||||||
find_available_port, is_authenticated, register_client,
|
find_available_port, is_authenticated, register_client,
|
||||||
};
|
};
|
||||||
use crate::tools::mcp::config::McpServerConfig;
|
use crate::tools::mcp::config::McpServerConfig;
|
||||||
@@ -108,6 +108,13 @@ pub struct ExtensionManager {
|
|||||||
/// Relay config captured at startup. Used by `auth_channel_relay` and
|
/// Relay config captured at startup. Used by `auth_channel_relay` and
|
||||||
/// `activate_channel_relay` instead of re-reading env vars.
|
/// `activate_channel_relay` instead of re-reading env vars.
|
||||||
relay_config: Option<crate::config::RelayConfig>,
|
relay_config: Option<crate::config::RelayConfig>,
|
||||||
|
/// When `true`, OAuth flows always return an auth URL to the caller
|
||||||
|
/// instead of opening a browser on the server via `open::that()`.
|
||||||
|
/// Set by the web gateway at startup via `enable_gateway_mode()`.
|
||||||
|
gateway_mode: std::sync::atomic::AtomicBool,
|
||||||
|
/// The gateway's own base URL for building OAuth redirect URIs.
|
||||||
|
/// Set by the web gateway at startup via `enable_gateway_mode()`.
|
||||||
|
gateway_base_url: RwLock<Option<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sanitize a URL for logging by removing query parameters and credentials.
|
/// Sanitize a URL for logging by removing query parameters and credentials.
|
||||||
@@ -181,9 +188,75 @@ impl ExtensionManager {
|
|||||||
pending_oauth_flows: crate::cli::oauth_defaults::new_pending_oauth_registry(),
|
pending_oauth_flows: crate::cli::oauth_defaults::new_pending_oauth_registry(),
|
||||||
gateway_token: std::env::var("GATEWAY_AUTH_TOKEN").ok(),
|
gateway_token: std::env::var("GATEWAY_AUTH_TOKEN").ok(),
|
||||||
relay_config: crate::config::RelayConfig::from_env(),
|
relay_config: crate::config::RelayConfig::from_env(),
|
||||||
|
gateway_mode: std::sync::atomic::AtomicBool::new(false),
|
||||||
|
gateway_base_url: RwLock::new(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enable gateway mode so OAuth flows return auth URLs to the frontend
|
||||||
|
/// instead of calling `open::that()` on the server.
|
||||||
|
///
|
||||||
|
/// `base_url` is the gateway's own public URL (e.g. `https://my-gateway.example.com`),
|
||||||
|
/// used to build OAuth redirect URIs when `IRONCLAW_OAUTH_CALLBACK_URL` is not set.
|
||||||
|
pub async fn enable_gateway_mode(&self, base_url: String) {
|
||||||
|
self.gateway_mode
|
||||||
|
.store(true, std::sync::atomic::Ordering::Release);
|
||||||
|
*self.gateway_base_url.write().await = Some(base_url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if OAuth should use gateway mode (return auth URL to
|
||||||
|
/// frontend) rather than CLI mode (open browser on server via `open::that`).
|
||||||
|
///
|
||||||
|
/// Gateway mode is active when any of:
|
||||||
|
/// - `enable_gateway_mode()` was called (web gateway is running), OR
|
||||||
|
/// - `IRONCLAW_OAUTH_CALLBACK_URL` is set to a non-loopback URL, OR
|
||||||
|
/// - `self.tunnel_url` is set to a non-loopback URL
|
||||||
|
pub fn should_use_gateway_mode(&self) -> bool {
|
||||||
|
if self.gateway_mode.load(std::sync::atomic::Ordering::Acquire) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if crate::cli::oauth_defaults::use_gateway_callback() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
self.tunnel_url
|
||||||
|
.as_ref()
|
||||||
|
.filter(|u| !u.is_empty())
|
||||||
|
.and_then(|raw| url::Url::parse(raw).ok())
|
||||||
|
.and_then(|u| u.host_str().map(String::from))
|
||||||
|
.map(|host| !crate::cli::oauth_defaults::is_loopback_host(&host))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the OAuth redirect URI for gateway mode, or `None` for local mode.
|
||||||
|
///
|
||||||
|
/// Priority:
|
||||||
|
/// 1. `IRONCLAW_OAUTH_CALLBACK_URL` env var (via `callback_url()`)
|
||||||
|
/// 2. `gateway_base_url` (set by `enable_gateway_mode()`)
|
||||||
|
/// 3. `tunnel_url` (from config)
|
||||||
|
/// 4. `None` (local/CLI mode)
|
||||||
|
async fn gateway_callback_redirect_uri(&self) -> Option<String> {
|
||||||
|
use crate::cli::oauth_defaults;
|
||||||
|
if oauth_defaults::use_gateway_callback() {
|
||||||
|
return Some(format!("{}/oauth/callback", oauth_defaults::callback_url()));
|
||||||
|
}
|
||||||
|
// Use gateway_base_url from enable_gateway_mode()
|
||||||
|
if let Some(ref base) = *self.gateway_base_url.read().await {
|
||||||
|
let base = base.trim_end_matches('/');
|
||||||
|
return Some(format!("{}/oauth/callback", base));
|
||||||
|
}
|
||||||
|
// Fall back to tunnel_url
|
||||||
|
self.tunnel_url
|
||||||
|
.as_ref()
|
||||||
|
.filter(|u| !u.is_empty())
|
||||||
|
.and_then(|raw| url::Url::parse(raw).ok())
|
||||||
|
.and_then(|u| u.host_str().map(String::from))
|
||||||
|
.filter(|host| !oauth_defaults::is_loopback_host(host))
|
||||||
|
.map(|_| {
|
||||||
|
let base = self.tunnel_url.as_ref().unwrap().trim_end_matches('/');
|
||||||
|
format!("{}/oauth/callback", base)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the relay config stored at startup.
|
/// Get the relay config stored at startup.
|
||||||
fn relay_config(&self) -> Result<&crate::config::RelayConfig, ExtensionError> {
|
fn relay_config(&self) -> Result<&crate::config::RelayConfig, ExtensionError> {
|
||||||
self.relay_config.as_ref().ok_or_else(|| {
|
self.relay_config.as_ref().ok_or_else(|| {
|
||||||
@@ -193,6 +266,12 @@ impl ExtensionManager {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Inject a registry entry for testing. The entry is added to the discovery
|
||||||
|
/// cache so it appears in search results alongside built-in entries.
|
||||||
|
pub async fn inject_registry_entry(&self, entry: crate::extensions::RegistryEntry) {
|
||||||
|
self.registry.cache_discovered(vec![entry]).await;
|
||||||
|
}
|
||||||
|
|
||||||
/// Configure the channel runtime infrastructure for hot-activating WASM channels.
|
/// Configure the channel runtime infrastructure for hot-activating WASM channels.
|
||||||
///
|
///
|
||||||
/// Call after construction (and after wrapping in `Arc`) once the channel
|
/// Call after construction (and after wrapping in `Arc`) once the channel
|
||||||
@@ -1684,19 +1763,13 @@ impl ExtensionManager {
|
|||||||
return Ok(AuthResult::authenticated(name, ExtensionKind::McpServer));
|
return Ok(AuthResult::authenticated(name, ExtensionKind::McpServer));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run the full OAuth flow (opens browser, waits for callback)
|
// In gateway mode, build an auth URL and return it for the frontend to
|
||||||
match authorize_mcp_server(&server, &self.secrets, &self.user_id).await {
|
// open in the same browser. The gateway's /oauth/callback handler will
|
||||||
Ok(_token) => {
|
// complete the token exchange.
|
||||||
tracing::info!("MCP server '{}' authenticated via OAuth", name);
|
if self.should_use_gateway_mode() {
|
||||||
Ok(AuthResult::authenticated(name, ExtensionKind::McpServer))
|
return match self.auth_mcp_build_url(name, &server).await {
|
||||||
}
|
|
||||||
Err(crate::tools::mcp::auth::AuthError::NotSupported) => {
|
|
||||||
// Server doesn't support OAuth, try building a URL first
|
|
||||||
match self.auth_mcp_build_url(name, &server).await {
|
|
||||||
Ok(result) => Ok(result),
|
Ok(result) => Ok(result),
|
||||||
Err(_) => {
|
Err(ExtensionError::AuthNotSupported(_)) => Ok(AuthResult::awaiting_token(
|
||||||
// No OAuth, no DCR: fall back to manual token entry
|
|
||||||
Ok(AuthResult::awaiting_token(
|
|
||||||
name,
|
name,
|
||||||
ExtensionKind::McpServer,
|
ExtensionKind::McpServer,
|
||||||
format!(
|
format!(
|
||||||
@@ -1705,8 +1778,31 @@ impl ExtensionManager {
|
|||||||
name
|
name
|
||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
))
|
)),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CLI/local mode: run the full blocking OAuth flow (opens browser, waits for callback)
|
||||||
|
match authorize_mcp_server(&server, &self.secrets, &self.user_id).await {
|
||||||
|
Ok(_token) => {
|
||||||
|
tracing::info!("MCP server '{}' authenticated via OAuth", name);
|
||||||
|
Ok(AuthResult::authenticated(name, ExtensionKind::McpServer))
|
||||||
|
}
|
||||||
|
Err(crate::tools::mcp::auth::AuthError::NotSupported) => {
|
||||||
|
// Server doesn't support OAuth, try building a URL
|
||||||
|
match self.auth_mcp_build_url(name, &server).await {
|
||||||
|
Ok(result) => Ok(result),
|
||||||
|
Err(_) => Ok(AuthResult::awaiting_token(
|
||||||
|
name,
|
||||||
|
ExtensionKind::McpServer,
|
||||||
|
format!(
|
||||||
|
"Server '{}' does not support OAuth. \
|
||||||
|
Please provide an API token/key for this server.",
|
||||||
|
name
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -1725,8 +1821,12 @@ impl ExtensionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build an auth URL for cases where non-interactive auth is needed
|
/// Build an auth URL for MCP OAuth.
|
||||||
/// (e.g., running via Telegram where we can't open a browser).
|
///
|
||||||
|
/// In gateway mode, stores a `PendingOAuthFlow` so the web gateway's
|
||||||
|
/// `/oauth/callback` handler can complete the token exchange — the auth
|
||||||
|
/// URL is sent to the frontend which opens it in the same browser.
|
||||||
|
/// In local/CLI mode, builds the URL for the user to open manually.
|
||||||
async fn auth_mcp_build_url(
|
async fn auth_mcp_build_url(
|
||||||
&self,
|
&self,
|
||||||
name: &str,
|
name: &str,
|
||||||
@@ -1734,45 +1834,119 @@ impl ExtensionManager {
|
|||||||
) -> Result<AuthResult, ExtensionError> {
|
) -> Result<AuthResult, ExtensionError> {
|
||||||
// Try to discover OAuth metadata and build a URL the user can open manually
|
// Try to discover OAuth metadata and build a URL the user can open manually
|
||||||
let metadata = discover_full_oauth_metadata(&server.url)
|
let metadata = discover_full_oauth_metadata(&server.url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| match e {
|
||||||
|
crate::tools::mcp::auth::AuthError::NotSupported => {
|
||||||
|
ExtensionError::AuthNotSupported(e.to_string())
|
||||||
|
}
|
||||||
|
_ => ExtensionError::AuthFailed(e.to_string()),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
use crate::cli::oauth_defaults;
|
||||||
|
|
||||||
|
let is_gateway = self.should_use_gateway_mode();
|
||||||
|
|
||||||
|
// Build redirect URI: gateway uses the public callback URL,
|
||||||
|
// local mode binds a random port.
|
||||||
|
let redirect_uri = if let Some(uri) = self.gateway_callback_redirect_uri().await {
|
||||||
|
uri
|
||||||
|
} else {
|
||||||
|
let port = find_available_port()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
||||||
|
format!("http://localhost:{}/callback", port.1)
|
||||||
|
};
|
||||||
|
|
||||||
// Try DCR if no client_id configured
|
// Try DCR if no client_id configured
|
||||||
let (client_id, redirect_uri) = if let Some(ref oauth) = server.oauth {
|
let (client_id, client_secret) = if let Some(ref oauth) = server.oauth {
|
||||||
let port = find_available_port()
|
(oauth.client_id.clone(), None)
|
||||||
.await
|
|
||||||
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
|
||||||
let redirect = format!("http://localhost:{}/callback", port.1);
|
|
||||||
(oauth.client_id.clone(), redirect)
|
|
||||||
} else if let Some(ref reg_endpoint) = metadata.registration_endpoint {
|
} else if let Some(ref reg_endpoint) = metadata.registration_endpoint {
|
||||||
let port = find_available_port()
|
let registration = register_client(reg_endpoint, &redirect_uri)
|
||||||
.await
|
|
||||||
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
|
||||||
let redirect = format!("http://localhost:{}/callback", port.1);
|
|
||||||
|
|
||||||
let registration = register_client(reg_endpoint, &redirect)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
||||||
|
|
||||||
(registration.client_id, redirect)
|
(registration.client_id, None)
|
||||||
} else {
|
} else {
|
||||||
return Err(ExtensionError::AuthFailed(
|
return Err(ExtensionError::AuthNotSupported(
|
||||||
"Server doesn't support OAuth or Dynamic Client Registration".to_string(),
|
"Server doesn't support OAuth or Dynamic Client Registration".to_string(),
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
let pkce = PkceChallenge::generate();
|
// RFC 8707: resource parameter to scope the token to this MCP server
|
||||||
let auth_url = build_authorization_url(
|
let resource = canonical_resource_uri(&server.url);
|
||||||
|
|
||||||
|
// Build authorization URL with CSRF state using the shared oauth_defaults
|
||||||
|
// builder, which generates PKCE + state for us.
|
||||||
|
let mut extra_params = server
|
||||||
|
.oauth
|
||||||
|
.as_ref()
|
||||||
|
.map(|o| o.extra_params.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
extra_params.insert("resource".to_string(), resource.clone());
|
||||||
|
|
||||||
|
let scopes = server
|
||||||
|
.oauth
|
||||||
|
.as_ref()
|
||||||
|
.map(|o| o.scopes.clone())
|
||||||
|
.unwrap_or_else(|| metadata.scopes_supported.clone());
|
||||||
|
|
||||||
|
let oauth_result = oauth_defaults::build_oauth_url(
|
||||||
&metadata.authorization_endpoint,
|
&metadata.authorization_endpoint,
|
||||||
&client_id,
|
&client_id,
|
||||||
&redirect_uri,
|
&redirect_uri,
|
||||||
&metadata.scopes_supported,
|
&scopes,
|
||||||
Some(&pkce),
|
true, // Always use PKCE for MCP
|
||||||
&std::collections::HashMap::new(),
|
&extra_params,
|
||||||
None,
|
|
||||||
);
|
);
|
||||||
|
let expected_state = oauth_result.state;
|
||||||
|
let code_verifier = oauth_result.code_verifier;
|
||||||
|
|
||||||
|
if is_gateway {
|
||||||
|
// Gateway mode: store pending flow for the /oauth/callback handler.
|
||||||
|
oauth_defaults::sweep_expired_flows(&self.pending_oauth_flows).await;
|
||||||
|
|
||||||
|
// Platform routing: prepend instance name to state
|
||||||
|
let platform_state = oauth_defaults::build_platform_state(&expected_state);
|
||||||
|
let auth_url = if platform_state != expected_state {
|
||||||
|
oauth_result.url.replace(
|
||||||
|
&format!("state={}", urlencoding::encode(&expected_state)),
|
||||||
|
&format!("state={}", urlencoding::encode(&platform_state)),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
oauth_result.url
|
||||||
|
};
|
||||||
|
|
||||||
|
let flow = oauth_defaults::PendingOAuthFlow {
|
||||||
|
extension_name: name.to_string(),
|
||||||
|
display_name: server.name.clone(),
|
||||||
|
token_url: metadata.token_endpoint,
|
||||||
|
client_id,
|
||||||
|
client_secret,
|
||||||
|
redirect_uri,
|
||||||
|
code_verifier,
|
||||||
|
access_token_field: "access_token".to_string(),
|
||||||
|
secret_name: server.token_secret_name(),
|
||||||
|
provider: Some(format!("mcp:{}", name)),
|
||||||
|
validation_endpoint: None,
|
||||||
|
scopes,
|
||||||
|
user_id: self.user_id.clone(),
|
||||||
|
secrets: Arc::clone(&self.secrets),
|
||||||
|
sse_sender: self.sse_sender.read().await.clone(),
|
||||||
|
gateway_token: self.gateway_token.clone(),
|
||||||
|
resource: Some(resource),
|
||||||
|
client_id_secret_name: if server.oauth.is_none() {
|
||||||
|
Some(server.client_id_secret_name())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
created_at: std::time::Instant::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.pending_oauth_flows
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.insert(expected_state, flow);
|
||||||
|
|
||||||
// Store pending auth for later callback handling
|
|
||||||
self.pending_auth.write().await.insert(
|
self.pending_auth.write().await.insert(
|
||||||
name.to_string(),
|
name.to_string(),
|
||||||
PendingAuth {
|
PendingAuth {
|
||||||
@@ -1787,9 +1961,28 @@ impl ExtensionManager {
|
|||||||
name,
|
name,
|
||||||
ExtensionKind::McpServer,
|
ExtensionKind::McpServer,
|
||||||
auth_url,
|
auth_url,
|
||||||
|
"gateway".to_string(),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
// Local mode: return URL for manual opening
|
||||||
|
self.pending_auth.write().await.insert(
|
||||||
|
name.to_string(),
|
||||||
|
PendingAuth {
|
||||||
|
_name: name.to_string(),
|
||||||
|
_kind: ExtensionKind::McpServer,
|
||||||
|
created_at: std::time::Instant::now(),
|
||||||
|
task_handle: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(AuthResult::awaiting_authorization(
|
||||||
|
name,
|
||||||
|
ExtensionKind::McpServer,
|
||||||
|
oauth_result.url,
|
||||||
"local".to_string(),
|
"local".to_string(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn auth_wasm_tool(&self, name: &str) -> Result<AuthResult, ExtensionError> {
|
async fn auth_wasm_tool(&self, name: &str) -> Result<AuthResult, ExtensionError> {
|
||||||
// Read the capabilities file to get auth config
|
// Read the capabilities file to get auth config
|
||||||
@@ -2203,7 +2396,10 @@ impl ExtensionManager {
|
|||||||
flows.retain(|_, flow| flow.extension_name != name);
|
flows.retain(|_, flow| flow.extension_name != name);
|
||||||
}
|
}
|
||||||
|
|
||||||
let redirect_uri = format!("{}/callback", oauth_defaults::callback_url());
|
let redirect_uri = self
|
||||||
|
.gateway_callback_redirect_uri()
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|| format!("{}/callback", oauth_defaults::callback_url()));
|
||||||
|
|
||||||
// Merge scopes from all tools sharing this provider
|
// Merge scopes from all tools sharing this provider
|
||||||
let merged_scopes = self
|
let merged_scopes = self
|
||||||
@@ -2228,7 +2424,7 @@ impl ExtensionManager {
|
|||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| name.to_string());
|
.unwrap_or_else(|| name.to_string());
|
||||||
|
|
||||||
if oauth_defaults::use_gateway_callback() {
|
if self.should_use_gateway_mode() {
|
||||||
// Gateway mode: store pending flow state for the web gateway's
|
// Gateway mode: store pending flow state for the web gateway's
|
||||||
// `/oauth/callback` handler to complete the exchange. No TCP listener
|
// `/oauth/callback` handler to complete the exchange. No TCP listener
|
||||||
// needed — the OAuth provider redirects to the gateway URL.
|
// needed — the OAuth provider redirects to the gateway URL.
|
||||||
@@ -2264,6 +2460,8 @@ impl ExtensionManager {
|
|||||||
secrets: Arc::clone(&self.secrets),
|
secrets: Arc::clone(&self.secrets),
|
||||||
sse_sender: self.sse_sender.read().await.clone(),
|
sse_sender: self.sse_sender.read().await.clone(),
|
||||||
gateway_token: self.gateway_token.clone(),
|
gateway_token: self.gateway_token.clone(),
|
||||||
|
resource: None,
|
||||||
|
client_id_secret_name: None,
|
||||||
created_at: std::time::Instant::now(),
|
created_at: std::time::Instant::now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2605,11 +2803,17 @@ impl ExtensionManager {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?;
|
.map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?;
|
||||||
|
|
||||||
// Try to list and create tools
|
// Try to list and create tools.
|
||||||
let mcp_tools = client
|
// A 401/auth error means the server requires OAuth — surface as
|
||||||
.list_tools()
|
// AuthRequired so the activate handler triggers the OAuth flow.
|
||||||
.await
|
let mcp_tools = client.list_tools().await.map_err(|e| {
|
||||||
.map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?;
|
let msg = e.to_string();
|
||||||
|
if msg.contains("requires authentication") || msg.contains("401") {
|
||||||
|
ExtensionError::AuthRequired
|
||||||
|
} else {
|
||||||
|
ExtensionError::ActivationFailed(msg)
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
let tool_impls = client
|
let tool_impls = client
|
||||||
.create_tools()
|
.create_tools()
|
||||||
@@ -4766,6 +4970,190 @@ mod tests {
|
|||||||
assert!(result.contains("/v1/users/123/profile"));
|
assert!(result.contains("/v1/users/123/profile"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- gateway mode detection tests ----
|
||||||
|
// Regression tests for a bug where MCP OAuth called `open::that()` on the
|
||||||
|
// server machine instead of returning an auth URL to the gateway frontend.
|
||||||
|
// The root cause was that `should_use_gateway_mode()` only checked the
|
||||||
|
// `IRONCLAW_OAUTH_CALLBACK_URL` env var, ignoring `self.tunnel_url`.
|
||||||
|
|
||||||
|
/// Serializes env-mutating tests to prevent parallel races.
|
||||||
|
static GATEWAY_ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||||
|
|
||||||
|
/// Build a minimal ExtensionManager with a custom tunnel_url.
|
||||||
|
fn make_manager_with_tunnel(tunnel_url: Option<String>) -> ExtensionManager {
|
||||||
|
use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
|
||||||
|
use crate::tools::mcp::process::McpProcessManager;
|
||||||
|
use crate::tools::mcp::session::McpSessionManager;
|
||||||
|
|
||||||
|
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> =
|
||||||
|
Arc::new(InMemorySecretsStore::new(crypto));
|
||||||
|
let tools = Arc::new(crate::tools::ToolRegistry::new());
|
||||||
|
let mcp = Arc::new(McpSessionManager::new());
|
||||||
|
let dir = std::env::temp_dir().join("ironclaw-test-gateway-mode");
|
||||||
|
|
||||||
|
ExtensionManager::new(
|
||||||
|
mcp,
|
||||||
|
Arc::new(McpProcessManager::new()),
|
||||||
|
secrets,
|
||||||
|
tools,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
dir.clone(),
|
||||||
|
dir,
|
||||||
|
tunnel_url,
|
||||||
|
"test".to_string(),
|
||||||
|
None,
|
||||||
|
vec![],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_use_gateway_mode_true_for_tunnel_url() {
|
||||||
|
let _guard = GATEWAY_ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||||
|
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
|
||||||
|
// SAFETY: Under GATEWAY_ENV_MUTEX, no concurrent env access.
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mgr = make_manager_with_tunnel(Some("https://my-gateway.example.com".into()));
|
||||||
|
assert!(
|
||||||
|
mgr.should_use_gateway_mode(),
|
||||||
|
"should detect gateway mode from tunnel_url"
|
||||||
|
);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
if let Some(val) = original {
|
||||||
|
std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_use_gateway_mode_false_without_tunnel() {
|
||||||
|
let _guard = GATEWAY_ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||||
|
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mgr = make_manager_with_tunnel(None);
|
||||||
|
assert!(
|
||||||
|
!mgr.should_use_gateway_mode(),
|
||||||
|
"should not detect gateway mode without tunnel_url or env var"
|
||||||
|
);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
if let Some(val) = original {
|
||||||
|
std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_use_gateway_mode_false_for_loopback_tunnel() {
|
||||||
|
let _guard = GATEWAY_ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||||
|
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mgr = make_manager_with_tunnel(Some("http://127.0.0.1:3001".into()));
|
||||||
|
assert!(
|
||||||
|
!mgr.should_use_gateway_mode(),
|
||||||
|
"should not detect gateway mode for loopback tunnel_url"
|
||||||
|
);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
if let Some(val) = original {
|
||||||
|
std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to run an async test body while holding the env mutex.
|
||||||
|
/// Clears `IRONCLAW_OAUTH_CALLBACK_URL` for the duration, restoring on drop.
|
||||||
|
struct EnvGuard {
|
||||||
|
original: Option<String>,
|
||||||
|
_mutex: std::sync::MutexGuard<'static, ()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EnvGuard {
|
||||||
|
fn new() -> Self {
|
||||||
|
let guard = GATEWAY_ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||||
|
let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok();
|
||||||
|
// SAFETY: Under GATEWAY_ENV_MUTEX, no concurrent env access.
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL");
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
original,
|
||||||
|
_mutex: guard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for EnvGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: Under GATEWAY_ENV_MUTEX (still held by _mutex), no concurrent env access.
|
||||||
|
unsafe {
|
||||||
|
if let Some(ref val) = self.original {
|
||||||
|
std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", val);
|
||||||
|
} else {
|
||||||
|
std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_callback_redirect_uri_from_tunnel_url() {
|
||||||
|
let _env = EnvGuard::new();
|
||||||
|
|
||||||
|
let mgr = make_manager_with_tunnel(Some("https://my-gateway.example.com".into()));
|
||||||
|
assert_eq!(
|
||||||
|
mgr.gateway_callback_redirect_uri().await,
|
||||||
|
Some("https://my-gateway.example.com/oauth/callback".to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_callback_redirect_uri_none_without_tunnel() {
|
||||||
|
let _env = EnvGuard::new();
|
||||||
|
|
||||||
|
let mgr = make_manager_with_tunnel(None);
|
||||||
|
assert_eq!(mgr.gateway_callback_redirect_uri().await, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_callback_redirect_uri_trims_trailing_slash() {
|
||||||
|
let _env = EnvGuard::new();
|
||||||
|
|
||||||
|
let mgr = make_manager_with_tunnel(Some("https://my-gateway.example.com/".into()));
|
||||||
|
assert_eq!(
|
||||||
|
mgr.gateway_callback_redirect_uri().await,
|
||||||
|
Some("https://my-gateway.example.com/oauth/callback".to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_mode_enabled_explicitly() {
|
||||||
|
let _env = EnvGuard::new();
|
||||||
|
|
||||||
|
let mgr = make_manager_with_tunnel(None);
|
||||||
|
assert!(!mgr.should_use_gateway_mode());
|
||||||
|
|
||||||
|
mgr.enable_gateway_mode("https://my-gateway.example.com".into())
|
||||||
|
.await;
|
||||||
|
assert!(mgr.should_use_gateway_mode());
|
||||||
|
assert_eq!(
|
||||||
|
mgr.gateway_callback_redirect_uri().await,
|
||||||
|
Some("https://my-gateway.example.com/oauth/callback".to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Regression tests for PR #677 (unify-extension-lifecycle) ─────────
|
// ── Regression tests for PR #677 (unify-extension-lifecycle) ─────────
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -517,6 +517,9 @@ pub enum ExtensionError {
|
|||||||
#[error("Authentication failed: {0}")]
|
#[error("Authentication failed: {0}")]
|
||||||
AuthFailed(String),
|
AuthFailed(String),
|
||||||
|
|
||||||
|
#[error("Server does not support OAuth: {0}")]
|
||||||
|
AuthNotSupported(String),
|
||||||
|
|
||||||
#[error("Activation failed: {0}")]
|
#[error("Activation failed: {0}")]
|
||||||
ActivationFailed(String),
|
ActivationFailed(String),
|
||||||
|
|
||||||
|
|||||||
+25
-5
@@ -9,8 +9,8 @@ use ironclaw::{
|
|||||||
agent::{Agent, AgentDeps},
|
agent::{Agent, AgentDeps},
|
||||||
app::{AppBuilder, AppBuilderFlags},
|
app::{AppBuilder, AppBuilderFlags},
|
||||||
channels::{
|
channels::{
|
||||||
ChannelManager, ChannelSecretUpdater, GatewayChannel, HttpChannel, ReplChannel,
|
ChannelManager, GatewayChannel, HttpChannel, ReplChannel, SignalChannel, WebhookServer,
|
||||||
SignalChannel, WebhookServer, WebhookServerConfig,
|
WebhookServerConfig,
|
||||||
wasm::{WasmChannelRouter, WasmChannelRuntime},
|
wasm::{WasmChannelRouter, WasmChannelRuntime},
|
||||||
web::log_layer::LogBroadcaster,
|
web::log_layer::LogBroadcaster,
|
||||||
},
|
},
|
||||||
@@ -67,6 +67,10 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
Some(Command::Routines(routines_cmd)) => {
|
||||||
|
init_cli_tracing();
|
||||||
|
return ironclaw::cli::run_routines_cli(routines_cmd, cli.config.as_deref()).await;
|
||||||
|
}
|
||||||
Some(Command::Mcp(mcp_cmd)) => {
|
Some(Command::Mcp(mcp_cmd)) => {
|
||||||
init_cli_tracing();
|
init_cli_tracing();
|
||||||
return run_mcp_command(*mcp_cmd.clone()).await;
|
return run_mcp_command(*mcp_cmd.clone()).await;
|
||||||
@@ -429,9 +433,8 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
"Lifecycle hooks initialized"
|
"Lifecycle hooks initialized"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create session manager (shared between agent and web gateway)
|
// Reuse the shared agent session manager prepared by AppBuilder.
|
||||||
let session_manager =
|
let session_manager = Arc::clone(&components.agent_session_manager);
|
||||||
Arc::new(ironclaw::agent::SessionManager::new().with_hooks(components.hooks.clone()));
|
|
||||||
|
|
||||||
// Lazy scheduler slot — filled after Agent::new creates the Scheduler.
|
// Lazy scheduler slot — filled after Agent::new creates the Scheduler.
|
||||||
// Allows CreateJobTool to dispatch local jobs via the Scheduler even though
|
// Allows CreateJobTool to dispatch local jobs via the Scheduler even though
|
||||||
@@ -472,6 +475,14 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
gw = gw.with_log_level_handle(Arc::clone(&log_level_handle));
|
gw = gw.with_log_level_handle(Arc::clone(&log_level_handle));
|
||||||
gw = gw.with_tool_registry(Arc::clone(&components.tools));
|
gw = gw.with_tool_registry(Arc::clone(&components.tools));
|
||||||
if let Some(ref ext_mgr) = components.extension_manager {
|
if let Some(ref ext_mgr) = components.extension_manager {
|
||||||
|
// Enable gateway mode so MCP OAuth returns auth URLs to the frontend
|
||||||
|
// instead of calling open::that() on the server.
|
||||||
|
let gw_base = config
|
||||||
|
.tunnel
|
||||||
|
.public_url
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("http://{}:{}", gw_config.host, gw_config.port));
|
||||||
|
ext_mgr.enable_gateway_mode(gw_base).await;
|
||||||
gw = gw.with_extension_manager(Arc::clone(ext_mgr));
|
gw = gw.with_extension_manager(Arc::clone(ext_mgr));
|
||||||
}
|
}
|
||||||
if !components.catalog_entries.is_empty() {
|
if !components.catalog_entries.is_empty() {
|
||||||
@@ -661,6 +672,8 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|db| Arc::clone(db) as Arc<dyn ironclaw::db::SettingsStore>);
|
.map(|db| Arc::clone(db) as Arc<dyn ironclaw::db::SettingsStore>);
|
||||||
|
|
||||||
|
let db_for_shutdown = components.db.clone();
|
||||||
|
|
||||||
let deps = AgentDeps {
|
let deps = AgentDeps {
|
||||||
store: components.db,
|
store: components.db,
|
||||||
llm: components.llm,
|
llm: components.llm,
|
||||||
@@ -725,6 +738,7 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
|
use ironclaw::channels::ChannelSecretUpdater;
|
||||||
// Collect all channels that support secret updates
|
// Collect all channels that support secret updates
|
||||||
let mut secret_updaters: Vec<Arc<dyn ChannelSecretUpdater>> = Vec::new();
|
let mut secret_updaters: Vec<Arc<dyn ChannelSecretUpdater>> = Vec::new();
|
||||||
if let Some(ref state) = http_channel_state {
|
if let Some(ref state) = http_channel_state {
|
||||||
@@ -918,6 +932,12 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(db) = db_for_shutdown {
|
||||||
|
if let Err(e) = db.shutdown().await {
|
||||||
|
tracing::warn!("Failed to shutdown database cleanly: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tracing::debug!("Agent shutdown complete");
|
tracing::debug!("Agent shutdown complete");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -1067,6 +1067,8 @@ mod tests {
|
|||||||
prompt: "Check status".to_string(),
|
prompt: "Check status".to_string(),
|
||||||
context_paths: vec![],
|
context_paths: vec![],
|
||||||
max_tokens: 500,
|
max_tokens: 500,
|
||||||
|
use_tools: false,
|
||||||
|
max_tool_rounds: 3,
|
||||||
},
|
},
|
||||||
guardrails: RoutineGuardrails {
|
guardrails: RoutineGuardrails {
|
||||||
cooldown: std::time::Duration::from_secs(60),
|
cooldown: std::time::Duration::from_secs(60),
|
||||||
@@ -1198,6 +1200,8 @@ mod tests {
|
|||||||
prompt: "test".to_string(),
|
prompt: "test".to_string(),
|
||||||
context_paths: vec![],
|
context_paths: vec![],
|
||||||
max_tokens: 100,
|
max_tokens: 100,
|
||||||
|
use_tools: false,
|
||||||
|
max_tool_rounds: 3,
|
||||||
},
|
},
|
||||||
guardrails: RoutineGuardrails {
|
guardrails: RoutineGuardrails {
|
||||||
cooldown: std::time::Duration::from_secs(0),
|
cooldown: std::time::Duration::from_secs(0),
|
||||||
|
|||||||
@@ -256,8 +256,14 @@ impl Tool for ToolAuthTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
|
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
|
||||||
|
// In gateway mode, tool_auth only returns an auth URL for the frontend
|
||||||
|
// to open — no browser is launched server-side, so no approval needed.
|
||||||
|
if self.manager.should_use_gateway_mode() {
|
||||||
|
ApprovalRequirement::Never
|
||||||
|
} else {
|
||||||
ApprovalRequirement::UnlessAutoApproved
|
ApprovalRequirement::UnlessAutoApproved
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── tool_activate ────────────────────────────────────────────────────────
|
// ── tool_activate ────────────────────────────────────────────────────────
|
||||||
@@ -733,6 +739,22 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tool_auth_no_approval_in_gateway_mode() {
|
||||||
|
let manager = test_manager_stub();
|
||||||
|
manager
|
||||||
|
.enable_gateway_mode("http://localhost:3000".to_string())
|
||||||
|
.await;
|
||||||
|
let tool = ToolAuthTool {
|
||||||
|
manager: manager.clone(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
tool.requires_approval(&serde_json::json!({})),
|
||||||
|
ApprovalRequirement::Never,
|
||||||
|
"tool_auth should not require approval in gateway mode"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_tool_upgrade_schema() {
|
fn test_tool_upgrade_schema() {
|
||||||
use crate::tools::tool::ApprovalRequirement;
|
use crate::tools::tool::ApprovalRequirement;
|
||||||
|
|||||||
+189
-14
@@ -31,6 +31,12 @@ const MAX_RESPONSE_SIZE: usize = 5 * 1024 * 1024;
|
|||||||
/// in memory for LLM context. Matches the WASM attachment size cap.
|
/// in memory for LLM context. Matches the WASM attachment size cap.
|
||||||
const MAX_SAVE_TO_SIZE: usize = 50 * 1024 * 1024;
|
const MAX_SAVE_TO_SIZE: usize = 50 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Default request timeout when the caller does not provide one.
|
||||||
|
const DEFAULT_TIMEOUT_SECS: u64 = 30;
|
||||||
|
|
||||||
|
/// Maximum allowed request timeout to bound resource usage from LLM-controlled inputs.
|
||||||
|
const MAX_TIMEOUT_SECS: u64 = 300;
|
||||||
|
|
||||||
/// Maximum number of redirects to follow for simple GET requests.
|
/// Maximum number of redirects to follow for simple GET requests.
|
||||||
const MAX_REDIRECTS: usize = 3;
|
const MAX_REDIRECTS: usize = 3;
|
||||||
|
|
||||||
@@ -244,9 +250,9 @@ fn is_html_response(headers: &HashMap<String, String>) -> bool {
|
|||||||
fn parse_headers_param(
|
fn parse_headers_param(
|
||||||
headers: Option<&serde_json::Value>,
|
headers: Option<&serde_json::Value>,
|
||||||
) -> Result<Vec<(String, String)>, ToolError> {
|
) -> Result<Vec<(String, String)>, ToolError> {
|
||||||
match headers {
|
fn parse_header_object(
|
||||||
None => Ok(Vec::new()),
|
map: &serde_json::Map<String, serde_json::Value>,
|
||||||
Some(serde_json::Value::Object(map)) => {
|
) -> Result<Vec<(String, String)>, ToolError> {
|
||||||
let mut out = Vec::with_capacity(map.len());
|
let mut out = Vec::with_capacity(map.len());
|
||||||
for (k, v) in map {
|
for (k, v) in map {
|
||||||
let value = v.as_str().ok_or_else(|| {
|
let value = v.as_str().ok_or_else(|| {
|
||||||
@@ -256,7 +262,8 @@ fn parse_headers_param(
|
|||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
Some(serde_json::Value::Array(items)) => {
|
|
||||||
|
fn parse_header_array(items: &[serde_json::Value]) -> Result<Vec<(String, String)>, ToolError> {
|
||||||
let mut out = Vec::with_capacity(items.len());
|
let mut out = Vec::with_capacity(items.len());
|
||||||
for (idx, item) in items.iter().enumerate() {
|
for (idx, item) in items.iter().enumerate() {
|
||||||
let obj = item.as_object().ok_or_else(|| {
|
let obj = item.as_object().ok_or_else(|| {
|
||||||
@@ -275,12 +282,88 @@ fn parse_headers_param(
|
|||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
match headers {
|
||||||
|
None => Ok(Vec::new()),
|
||||||
|
Some(serde_json::Value::String(raw)) => {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let parsed = serde_json::from_str::<serde_json::Value>(trimmed).map_err(|e| {
|
||||||
|
ToolError::InvalidParameters(format!(
|
||||||
|
"headers string must contain valid JSON object/array: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
match parsed {
|
||||||
|
serde_json::Value::Object(map) => parse_header_object(&map),
|
||||||
|
serde_json::Value::Array(items) => parse_header_array(&items),
|
||||||
|
_ => Err(ToolError::InvalidParameters(
|
||||||
|
"headers string must decode to a JSON object or array".to_string(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(serde_json::Value::Object(map)) => parse_header_object(map),
|
||||||
|
Some(serde_json::Value::Array(items)) => parse_header_array(items),
|
||||||
Some(_) => Err(ToolError::InvalidParameters(
|
Some(_) => Err(ToolError::InvalidParameters(
|
||||||
"'headers' must be an object or an array of {name, value}".to_string(),
|
"'headers' must be an object or an array of {name, value}".to_string(),
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_timeout_secs_param(timeout: Option<&serde_json::Value>) -> Result<Option<u64>, ToolError> {
|
||||||
|
let parsed = match timeout {
|
||||||
|
None | Some(serde_json::Value::Null) => Ok(None),
|
||||||
|
Some(serde_json::Value::Number(n)) => n.as_u64().map(Some).ok_or_else(|| {
|
||||||
|
ToolError::InvalidParameters("timeout_secs must be a non-negative integer".to_string())
|
||||||
|
}),
|
||||||
|
Some(serde_json::Value::String(raw)) => {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let secs = trimmed.parse::<u64>().map_err(|_| {
|
||||||
|
ToolError::InvalidParameters(
|
||||||
|
"timeout_secs string must contain a non-negative integer".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(Some(secs))
|
||||||
|
}
|
||||||
|
Some(_) => Err(ToolError::InvalidParameters(
|
||||||
|
"timeout_secs must be an integer".to_string(),
|
||||||
|
)),
|
||||||
|
}?;
|
||||||
|
|
||||||
|
if let Some(secs) = parsed
|
||||||
|
&& secs > MAX_TIMEOUT_SECS
|
||||||
|
{
|
||||||
|
return Err(ToolError::InvalidParameters(format!(
|
||||||
|
"timeout_secs must be <= {}",
|
||||||
|
MAX_TIMEOUT_SECS
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_save_to_param(save_to: Option<&serde_json::Value>) -> Result<Option<String>, ToolError> {
|
||||||
|
match save_to {
|
||||||
|
None | Some(serde_json::Value::Null) => Ok(None),
|
||||||
|
Some(serde_json::Value::String(path)) => {
|
||||||
|
let trimmed = path.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
Ok(None)
|
||||||
|
} else {
|
||||||
|
Ok(Some(trimmed.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(_) => Err(ToolError::InvalidParameters(
|
||||||
|
"save_to must be a string".to_string(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Extract host from URL in params (for approval checks).
|
/// Extract host from URL in params (for approval checks).
|
||||||
fn extract_host_from_params(params: &serde_json::Value) -> Option<String> {
|
fn extract_host_from_params(params: &serde_json::Value) -> Option<String> {
|
||||||
params
|
params
|
||||||
@@ -358,6 +441,7 @@ impl Tool for HttpTool {
|
|||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
let method = require_str(¶ms, "method")?;
|
let method = require_str(¶ms, "method")?;
|
||||||
|
let method_upper = method.to_uppercase();
|
||||||
|
|
||||||
let url = require_str(¶ms, "url")?;
|
let url = require_str(¶ms, "url")?;
|
||||||
let mut parsed_url = validate_url(url)?;
|
let mut parsed_url = validate_url(url)?;
|
||||||
@@ -379,6 +463,9 @@ impl Tool for HttpTool {
|
|||||||
|
|
||||||
// Parse headers
|
// Parse headers
|
||||||
let mut headers_vec = parse_headers_param(params.get("headers"))?;
|
let mut headers_vec = parse_headers_param(params.get("headers"))?;
|
||||||
|
let timeout_secs = parse_timeout_secs_param(params.get("timeout_secs"))?;
|
||||||
|
let save_to = parse_save_to_param(params.get("save_to"))?;
|
||||||
|
let effective_timeout = Duration::from_secs(timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS));
|
||||||
|
|
||||||
// Build request
|
// Build request
|
||||||
let mut request = match method.to_uppercase().as_str() {
|
let mut request = match method.to_uppercase().as_str() {
|
||||||
@@ -395,6 +482,8 @@ impl Tool for HttpTool {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
request = request.timeout(effective_timeout);
|
||||||
|
|
||||||
// Add headers
|
// Add headers
|
||||||
for (key, value) in &headers_vec {
|
for (key, value) in &headers_vec {
|
||||||
request = request.header(key.as_str(), value.as_str());
|
request = request.header(key.as_str(), value.as_str());
|
||||||
@@ -403,7 +492,9 @@ impl Tool for HttpTool {
|
|||||||
// Add body if present
|
// Add body if present
|
||||||
let body_bytes = if let Some(body) = params.get("body") {
|
let body_bytes = if let Some(body) = params.get("body") {
|
||||||
if let Some(body_str) = body.as_str() {
|
if let Some(body_str) = body.as_str() {
|
||||||
if let Ok(json_body) = serde_json::from_str::<serde_json::Value>(body_str) {
|
if body_str.is_empty() {
|
||||||
|
None
|
||||||
|
} else if let Ok(json_body) = serde_json::from_str::<serde_json::Value>(body_str) {
|
||||||
let bytes = serde_json::to_vec(&json_body).map_err(|e| {
|
let bytes = serde_json::to_vec(&json_body).map_err(|e| {
|
||||||
ToolError::InvalidParameters(format!("invalid body JSON: {}", e))
|
ToolError::InvalidParameters(format!("invalid body JSON: {}", e))
|
||||||
})?;
|
})?;
|
||||||
@@ -468,7 +559,7 @@ impl Tool for HttpTool {
|
|||||||
|
|
||||||
// Build the interceptor request descriptor for recording/replay
|
// Build the interceptor request descriptor for recording/replay
|
||||||
let intercept_req = crate::llm::recording::HttpExchangeRequest {
|
let intercept_req = crate::llm::recording::HttpExchangeRequest {
|
||||||
method: method.to_uppercase(),
|
method: method_upper,
|
||||||
url: parsed_url.to_string(),
|
url: parsed_url.to_string(),
|
||||||
headers: headers_vec.clone(),
|
headers: headers_vec.clone(),
|
||||||
body: body_bytes
|
body: body_bytes
|
||||||
@@ -510,7 +601,7 @@ impl Tool for HttpTool {
|
|||||||
let hop_client = build_pinned_client(
|
let hop_client = build_pinned_client(
|
||||||
&hop_host,
|
&hop_host,
|
||||||
&hop_addrs,
|
&hop_addrs,
|
||||||
Duration::from_secs(30),
|
effective_timeout,
|
||||||
reqwest::redirect::Policy::none(),
|
reqwest::redirect::Policy::none(),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
@@ -524,7 +615,7 @@ impl Tool for HttpTool {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
if e.is_timeout() {
|
if e.is_timeout() {
|
||||||
ToolError::Timeout(Duration::from_secs(30))
|
ToolError::Timeout(effective_timeout)
|
||||||
} else {
|
} else {
|
||||||
ToolError::ExternalService(e.to_string())
|
ToolError::ExternalService(e.to_string())
|
||||||
}
|
}
|
||||||
@@ -588,7 +679,7 @@ impl Tool for HttpTool {
|
|||||||
} else {
|
} else {
|
||||||
let resp = request.send().await.map_err(|e| {
|
let resp = request.send().await.map_err(|e| {
|
||||||
if e.is_timeout() {
|
if e.is_timeout() {
|
||||||
ToolError::Timeout(Duration::from_secs(30))
|
ToolError::Timeout(effective_timeout)
|
||||||
} else {
|
} else {
|
||||||
ToolError::ExternalService(e.to_string())
|
ToolError::ExternalService(e.to_string())
|
||||||
}
|
}
|
||||||
@@ -616,7 +707,7 @@ impl Tool for HttpTool {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Use a larger size limit when saving to disk (file downloads)
|
// Use a larger size limit when saving to disk (file downloads)
|
||||||
let saving_to_disk = params.get("save_to").is_some();
|
let saving_to_disk = save_to.is_some();
|
||||||
let max_size = if saving_to_disk {
|
let max_size = if saving_to_disk {
|
||||||
MAX_SAVE_TO_SIZE
|
MAX_SAVE_TO_SIZE
|
||||||
} else {
|
} else {
|
||||||
@@ -661,11 +752,11 @@ impl Tool for HttpTool {
|
|||||||
let body_bytes = bytes::Bytes::from(body);
|
let body_bytes = bytes::Bytes::from(body);
|
||||||
|
|
||||||
// If save_to is specified, write raw bytes to file and return metadata.
|
// If save_to is specified, write raw bytes to file and return metadata.
|
||||||
if let Some(save_to) = params.get("save_to").and_then(|v| v.as_str()) {
|
if let Some(save_to) = save_to {
|
||||||
let save_to_owned = save_to.to_string();
|
let saved_to = save_to.clone();
|
||||||
let bytes_clone = body_bytes.clone();
|
let bytes_clone = body_bytes.clone();
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let canonical = validate_save_to_path(&save_to_owned)?;
|
let canonical = validate_save_to_path(&save_to)?;
|
||||||
std::fs::write(&canonical, &bytes_clone).map_err(|e| {
|
std::fs::write(&canonical, &bytes_clone).map_err(|e| {
|
||||||
ToolError::ExecutionFailed(format!("failed to write file: {}", e))
|
ToolError::ExecutionFailed(format!("failed to write file: {}", e))
|
||||||
})?;
|
})?;
|
||||||
@@ -676,7 +767,7 @@ impl Tool for HttpTool {
|
|||||||
.map_err(|e: ToolError| e)?;
|
.map_err(|e: ToolError| e)?;
|
||||||
let result = serde_json::json!({
|
let result = serde_json::json!({
|
||||||
"status": status,
|
"status": status,
|
||||||
"saved_to": save_to,
|
"saved_to": saved_to,
|
||||||
"size_bytes": body_bytes.len(),
|
"size_bytes": body_bytes.len(),
|
||||||
"headers": headers,
|
"headers": headers,
|
||||||
});
|
});
|
||||||
@@ -887,6 +978,71 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_headers_param_accepts_stringified_array() {
|
||||||
|
let headers =
|
||||||
|
serde_json::json!("[{\"name\":\"Authorization\",\"value\":\"Bearer token\"}]");
|
||||||
|
let parsed = parse_headers_param(Some(&headers)).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
parsed,
|
||||||
|
vec![("Authorization".to_string(), "Bearer token".to_string())]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_headers_param_rejects_double_string_encoding() {
|
||||||
|
let headers = serde_json::json!("\"hello\"");
|
||||||
|
let err = parse_headers_param(Some(&headers)).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.to_string()
|
||||||
|
.contains("headers string must decode to a JSON object or array"),
|
||||||
|
"unexpected error: {}",
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_timeout_secs_param_accepts_string_integer() {
|
||||||
|
let timeout = serde_json::json!("30");
|
||||||
|
assert_eq!(parse_timeout_secs_param(Some(&timeout)).unwrap(), Some(30));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_timeout_secs_param_treats_empty_string_as_none() {
|
||||||
|
let timeout = serde_json::json!("");
|
||||||
|
assert_eq!(parse_timeout_secs_param(Some(&timeout)).unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_timeout_secs_param_rejects_value_above_cap() {
|
||||||
|
let timeout = serde_json::json!(MAX_TIMEOUT_SECS + 1);
|
||||||
|
let err = parse_timeout_secs_param(Some(&timeout)).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.to_string()
|
||||||
|
.contains(&format!("timeout_secs must be <= {}", MAX_TIMEOUT_SECS)),
|
||||||
|
"unexpected error: {}",
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_timeout_secs_param_rejects_string_value_above_cap() {
|
||||||
|
let timeout = serde_json::json!((MAX_TIMEOUT_SECS + 1).to_string());
|
||||||
|
let err = parse_timeout_secs_param(Some(&timeout)).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.to_string()
|
||||||
|
.contains(&format!("timeout_secs must be <= {}", MAX_TIMEOUT_SECS)),
|
||||||
|
"unexpected error: {}",
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_save_to_param_treats_empty_string_as_none() {
|
||||||
|
let save_to = serde_json::json!("");
|
||||||
|
assert_eq!(parse_save_to_param(Some(&save_to)).unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_http_tool_schema_body_is_freeform() {
|
fn test_http_tool_schema_body_is_freeform() {
|
||||||
let schema = HttpTool::new().parameters_schema();
|
let schema = HttpTool::new().parameters_schema();
|
||||||
@@ -1119,6 +1275,25 @@ mod tests {
|
|||||||
assert_eq!(extract_host_from_params(¶ms), None);
|
assert_eq!(extract_host_from_params(¶ms), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_requires_approval_with_stringified_http_params() {
|
||||||
|
use crate::tools::wasm::SharedCredentialRegistry;
|
||||||
|
|
||||||
|
let tool = HttpTool::new().with_credentials(
|
||||||
|
Arc::new(SharedCredentialRegistry::new()),
|
||||||
|
Arc::new(test_secrets_store()),
|
||||||
|
);
|
||||||
|
let req = serde_json::json!({
|
||||||
|
"body": "",
|
||||||
|
"headers": "[]",
|
||||||
|
"method": "GET",
|
||||||
|
"save_to": "",
|
||||||
|
"timeout_secs": "30",
|
||||||
|
"url": "https://r.jina.ai/http://news.baidu.com/"
|
||||||
|
});
|
||||||
|
let _ = tool.requires_approval(&req);
|
||||||
|
}
|
||||||
|
|
||||||
// ── DNS pinning tests ─────────────────────────────────────────────
|
// ── DNS pinning tests ─────────────────────────────────────────────
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
//! Use `memory_write` to persist important facts that should be remembered
|
//! Use `memory_write` to persist important facts that should be remembered
|
||||||
//! across sessions.
|
//! across sessions.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@@ -26,6 +27,28 @@ use crate::workspace::{Workspace, paths};
|
|||||||
const PROTECTED_IDENTITY_FILES: &[&str] =
|
const PROTECTED_IDENTITY_FILES: &[&str] =
|
||||||
&[paths::IDENTITY, paths::SOUL, paths::AGENTS, paths::USER];
|
&[paths::IDENTITY, paths::SOUL, paths::AGENTS, paths::USER];
|
||||||
|
|
||||||
|
/// Detect paths that are clearly local filesystem references, not workspace-memory docs.
|
||||||
|
///
|
||||||
|
/// Examples:
|
||||||
|
/// - `/Users/.../file.md` (Unix absolute)
|
||||||
|
/// - `C:\Users\...` or `D:/work/...` (Windows absolute)
|
||||||
|
/// - `~/notes.md` (home expansion shorthand)
|
||||||
|
fn looks_like_filesystem_path(path: &str) -> bool {
|
||||||
|
if path.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if Path::new(path).is_absolute() || path.starts_with("~/") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = path.as_bytes();
|
||||||
|
bytes.len() >= 3
|
||||||
|
&& bytes[0].is_ascii_alphabetic()
|
||||||
|
&& bytes[1] == b':'
|
||||||
|
&& (bytes[2] == b'\\' || bytes[2] == b'/')
|
||||||
|
}
|
||||||
|
|
||||||
/// Tool for searching workspace memory.
|
/// Tool for searching workspace memory.
|
||||||
///
|
///
|
||||||
/// Performs hybrid search (FTS + semantic) across all memory documents.
|
/// Performs hybrid search (FTS + semantic) across all memory documents.
|
||||||
@@ -143,7 +166,8 @@ impl Tool for MemoryWriteTool {
|
|||||||
be remembered across sessions. Targets: 'memory' for curated long-term facts, \
|
be remembered across sessions. Targets: 'memory' for curated long-term facts, \
|
||||||
'daily_log' for timestamped session notes, 'heartbeat' for the periodic \
|
'daily_log' for timestamped session notes, 'heartbeat' for the periodic \
|
||||||
checklist (HEARTBEAT.md), 'bootstrap' to clear the first-run ritual file, \
|
checklist (HEARTBEAT.md), 'bootstrap' to clear the first-run ritual file, \
|
||||||
or provide a custom path for arbitrary file creation."
|
or provide a custom workspace path for arbitrary file creation. \
|
||||||
|
Never pass absolute filesystem paths like '/Users/...' or 'C:\\...'."
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parameters_schema(&self) -> serde_json::Value {
|
fn parameters_schema(&self) -> serde_json::Value {
|
||||||
@@ -183,6 +207,14 @@ impl Tool for MemoryWriteTool {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("daily_log");
|
.unwrap_or("daily_log");
|
||||||
|
|
||||||
|
if looks_like_filesystem_path(target) {
|
||||||
|
return Err(ToolError::InvalidParameters(format!(
|
||||||
|
"'{}' looks like a local filesystem path. memory_write only works with workspace-memory paths. \
|
||||||
|
Use write_file for filesystem writes. For opening files in an editor, use shell with: open \"<absolute_path>\".",
|
||||||
|
target
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
// Bootstrap target: clear BOOTSTRAP.md to mark first-run ritual complete.
|
// Bootstrap target: clear BOOTSTRAP.md to mark first-run ritual complete.
|
||||||
// Handled early because it accepts empty content (unlike other targets).
|
// Handled early because it accepts empty content (unlike other targets).
|
||||||
if target == "bootstrap" {
|
if target == "bootstrap" {
|
||||||
@@ -332,7 +364,8 @@ impl Tool for MemoryReadTool {
|
|||||||
fn description(&self) -> &str {
|
fn description(&self) -> &str {
|
||||||
"Read a file from the workspace memory (database-backed storage). \
|
"Read a file from the workspace memory (database-backed storage). \
|
||||||
Use this to read files shown by memory_tree. NOT for local filesystem files \
|
Use this to read files shown by memory_tree. NOT for local filesystem files \
|
||||||
(use read_file for those). Works with identity files, heartbeat checklist, \
|
(use read_file for those). Do not pass absolute paths like '/Users/...' or 'C:\\...'. \
|
||||||
|
Works with identity files, heartbeat checklist, \
|
||||||
memory, daily logs, or any custom workspace path."
|
memory, daily logs, or any custom workspace path."
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,6 +391,14 @@ impl Tool for MemoryReadTool {
|
|||||||
|
|
||||||
let path = require_str(¶ms, "path")?;
|
let path = require_str(¶ms, "path")?;
|
||||||
|
|
||||||
|
if looks_like_filesystem_path(path) {
|
||||||
|
return Err(ToolError::InvalidParameters(format!(
|
||||||
|
"'{}' looks like a local filesystem path. memory_read only works with workspace-memory paths. \
|
||||||
|
Use read_file for filesystem reads. For opening files in an editor, use shell with: open \"<absolute_path>\".",
|
||||||
|
path
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
let doc = self
|
let doc = self
|
||||||
.workspace
|
.workspace
|
||||||
.read(path)
|
.read(path)
|
||||||
@@ -498,6 +539,26 @@ impl Tool for MemoryTreeTool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod path_routing_tests {
|
||||||
|
use super::looks_like_filesystem_path;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_filesystem_paths() {
|
||||||
|
assert!(looks_like_filesystem_path("/Users/nige/file.md"));
|
||||||
|
assert!(looks_like_filesystem_path("C:\\Users\\nige\\file.md"));
|
||||||
|
assert!(looks_like_filesystem_path("D:/work/file.md"));
|
||||||
|
assert!(looks_like_filesystem_path("~/notes.md"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allows_workspace_memory_paths() {
|
||||||
|
assert!(!looks_like_filesystem_path("MEMORY.md"));
|
||||||
|
assert!(!looks_like_filesystem_path("daily/2026-03-11.md"));
|
||||||
|
assert!(!looks_like_filesystem_path("projects/alpha/notes.md"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(all(test, feature = "postgres"))]
|
#[cfg(all(test, feature = "postgres"))]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -104,6 +104,14 @@ impl Tool for RoutineCreateTool {
|
|||||||
"enum": ["lightweight", "full_job"],
|
"enum": ["lightweight", "full_job"],
|
||||||
"description": "Execution mode: 'lightweight' (single LLM call, default) or 'full_job' (multi-turn with tools)"
|
"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": {
|
"cooldown_secs": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "Minimum seconds between fires (default: 300)"
|
"description": "Minimum seconds between fires (default: 300)"
|
||||||
@@ -262,11 +270,24 @@ impl Tool for RoutineCreateTool {
|
|||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let use_tools = params
|
||||||
|
.get("use_tools")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let max_tool_rounds = params
|
||||||
|
.get("max_tool_rounds")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.map(|v| v.clamp(1, crate::agent::routine::MAX_TOOL_ROUNDS_LIMIT as u64) as u32)
|
||||||
|
.unwrap_or(3);
|
||||||
|
|
||||||
let action = match action_type {
|
let action = match action_type {
|
||||||
"lightweight" => RoutineAction::Lightweight {
|
"lightweight" => RoutineAction::Lightweight {
|
||||||
prompt: prompt.to_string(),
|
prompt: prompt.to_string(),
|
||||||
context_paths,
|
context_paths,
|
||||||
max_tokens: 4096,
|
max_tokens: 4096,
|
||||||
|
use_tools,
|
||||||
|
max_tool_rounds,
|
||||||
},
|
},
|
||||||
"full_job" => {
|
"full_job" => {
|
||||||
let tool_permissions = crate::agent::routine::parse_tool_permissions(¶ms);
|
let tool_permissions = crate::agent::routine::parse_tool_permissions(¶ms);
|
||||||
|
|||||||
+77
-2
@@ -669,7 +669,7 @@ pub async fn authorize_mcp_server(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Determine client_id and endpoints
|
// Determine client_id and endpoints
|
||||||
let (client_id, authorization_url, token_url, use_pkce, scopes, extra_params) =
|
let (client_id, authorization_url, token_url, use_pkce, scopes, mut extra_params) =
|
||||||
if let Some(oauth) = &server_config.oauth {
|
if let Some(oauth) = &server_config.oauth {
|
||||||
// Pre-configured OAuth
|
// Pre-configured OAuth
|
||||||
let (auth_url, tok_url) = discover_oauth_endpoints(server_config).await?;
|
let (auth_url, tok_url) = discover_oauth_endpoints(server_config).await?;
|
||||||
@@ -711,6 +711,13 @@ pub async fn authorize_mcp_server(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Generate OAuth state parameter. While optional in OAuth 2.1 with PKCE,
|
||||||
|
// some MCP servers (e.g. Attio) require it.
|
||||||
|
let mut state_bytes = [0u8; 16];
|
||||||
|
rand::rngs::OsRng.fill_bytes(&mut state_bytes);
|
||||||
|
let state = URL_SAFE_NO_PAD.encode(state_bytes);
|
||||||
|
extra_params.insert("state".to_string(), state);
|
||||||
|
|
||||||
// Compute canonical resource URI for RFC 8707
|
// Compute canonical resource URI for RFC 8707
|
||||||
let resource = canonical_resource_uri(&server_config.url);
|
let resource = canonical_resource_uri(&server_config.url);
|
||||||
|
|
||||||
@@ -741,7 +748,10 @@ pub async fn authorize_mcp_server(
|
|||||||
|
|
||||||
println!(" Waiting for authorization...");
|
println!(" Waiting for authorization...");
|
||||||
|
|
||||||
// Wait for callback
|
// Wait for callback. State is sent in the URL for servers that require it
|
||||||
|
// (e.g. Attio), but we don't enforce validation on the callback because MCP
|
||||||
|
// servers use PKCE which already binds the request to the token exchange,
|
||||||
|
// and some servers may not echo state back.
|
||||||
let code = wait_for_authorization_callback(listener, &server_config.name).await?;
|
let code = wait_for_authorization_callback(listener, &server_config.name).await?;
|
||||||
|
|
||||||
println!(" Exchanging code for token...");
|
println!(" Exchanging code for token...");
|
||||||
@@ -1711,4 +1721,69 @@ mod tests {
|
|||||||
|
|
||||||
assert!(!url.contains("resource="));
|
assert!(!url.contains("resource="));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression test: MCP OAuth authorization URLs must include a `state`
|
||||||
|
/// parameter. While OAuth 2.1 makes `state` optional when PKCE is used,
|
||||||
|
/// some MCP servers (e.g. Attio) require it and reject requests without it:
|
||||||
|
/// {"error":"invalid_request","error_description":"Invalid value provided
|
||||||
|
/// for: state"}
|
||||||
|
///
|
||||||
|
/// Including `state` is harmless for servers that don't require it, since
|
||||||
|
/// it is a standard OAuth parameter that compliant servers will echo back
|
||||||
|
/// or ignore.
|
||||||
|
///
|
||||||
|
/// The state is generated in `authorize_mcp_server` and injected into
|
||||||
|
/// `extra_params` before `build_authorization_url` is called. This test
|
||||||
|
/// verifies that `build_authorization_url` correctly propagates state from
|
||||||
|
/// extra_params into the URL, and that each generated state is unique.
|
||||||
|
#[test]
|
||||||
|
fn test_authorization_url_includes_state_parameter() {
|
||||||
|
// Simulate what authorize_mcp_server does: generate state and
|
||||||
|
// insert it into extra_params.
|
||||||
|
let mut extra_params = HashMap::new();
|
||||||
|
let mut state_bytes = [0u8; 16];
|
||||||
|
rand::rngs::OsRng.fill_bytes(&mut state_bytes);
|
||||||
|
let state = URL_SAFE_NO_PAD.encode(state_bytes);
|
||||||
|
extra_params.insert("state".to_string(), state.clone());
|
||||||
|
|
||||||
|
let pkce = PkceChallenge::generate();
|
||||||
|
let url = build_authorization_url(
|
||||||
|
"https://app.attio.com/oidc/authorize",
|
||||||
|
"test-client",
|
||||||
|
"http://127.0.0.1:9876/callback",
|
||||||
|
&[
|
||||||
|
"mcp".to_string(),
|
||||||
|
"offline_access".to_string(),
|
||||||
|
"openid".to_string(),
|
||||||
|
],
|
||||||
|
Some(&pkce),
|
||||||
|
&extra_params,
|
||||||
|
Some("https://mcp.attio.com/mcp"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// State must be present in the URL
|
||||||
|
assert!(
|
||||||
|
url.contains(&format!("state={}", state)),
|
||||||
|
"Authorization URL must include the state parameter, got: {}",
|
||||||
|
url,
|
||||||
|
);
|
||||||
|
|
||||||
|
// State must be base64url-encoded (no padding, no +/)
|
||||||
|
assert!(!state.contains('+'), "State must be base64url-safe");
|
||||||
|
assert!(!state.contains('/'), "State must be base64url-safe");
|
||||||
|
assert!(!state.contains('='), "State must not have padding");
|
||||||
|
|
||||||
|
// State must have sufficient entropy (16 bytes -> 22 base64url chars)
|
||||||
|
assert!(
|
||||||
|
state.len() >= 22,
|
||||||
|
"State must have at least 128 bits of entropy, got {} chars",
|
||||||
|
state.len(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Two generated states must differ
|
||||||
|
let mut state_bytes_2 = [0u8; 16];
|
||||||
|
rand::rngs::OsRng.fill_bytes(&mut state_bytes_2);
|
||||||
|
let state_2 = URL_SAFE_NO_PAD.encode(state_bytes_2);
|
||||||
|
assert_ne!(state, state_2, "State must be unique per request");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub mod builtin;
|
|||||||
pub mod execute;
|
pub mod execute;
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
pub mod rate_limiter;
|
pub mod rate_limiter;
|
||||||
|
pub mod redaction;
|
||||||
pub mod schema_validator;
|
pub mod schema_validator;
|
||||||
pub mod wasm;
|
pub mod wasm;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
const REDACTED: &str = "[REDACTED]";
|
||||||
|
const SENSITIVE_EXACT: &[&str] = &[
|
||||||
|
"authorization",
|
||||||
|
"proxy-authorization",
|
||||||
|
"cookie",
|
||||||
|
"set-cookie",
|
||||||
|
"x-api-key",
|
||||||
|
"api-key",
|
||||||
|
"api_key",
|
||||||
|
"access_token",
|
||||||
|
"refresh_token",
|
||||||
|
"session_token",
|
||||||
|
"id_token",
|
||||||
|
"token",
|
||||||
|
"password",
|
||||||
|
"passwd",
|
||||||
|
"secret",
|
||||||
|
"client_secret",
|
||||||
|
"private_key",
|
||||||
|
"apikey",
|
||||||
|
"apisecret",
|
||||||
|
];
|
||||||
|
|
||||||
|
const SENSITIVE_PARTS: &[&str] = &[
|
||||||
|
"password",
|
||||||
|
"passwd",
|
||||||
|
"secret",
|
||||||
|
"credential",
|
||||||
|
"authorization",
|
||||||
|
"cookie",
|
||||||
|
"apikey",
|
||||||
|
"apisecret",
|
||||||
|
];
|
||||||
|
const TOKEN_PARTS: &[&str] = &["token", "jwt"];
|
||||||
|
const KEY_PARTS: &[&str] = &["key"];
|
||||||
|
const CONTEXT_PARTS: &[&str] = &[
|
||||||
|
"auth",
|
||||||
|
"oauth",
|
||||||
|
"authorization",
|
||||||
|
"api",
|
||||||
|
"access",
|
||||||
|
"refresh",
|
||||||
|
"session",
|
||||||
|
"bearer",
|
||||||
|
"private",
|
||||||
|
"client",
|
||||||
|
"id",
|
||||||
|
"app",
|
||||||
|
"user",
|
||||||
|
"application",
|
||||||
|
"account",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn split_camel_case_key_parts(key: &str) -> Vec<String> {
|
||||||
|
if key.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let chars: Vec<char> = key.chars().collect();
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
let mut start = 0;
|
||||||
|
|
||||||
|
for i in 1..chars.len() {
|
||||||
|
let prev = chars[i - 1];
|
||||||
|
let cur = chars[i];
|
||||||
|
let next = chars.get(i + 1).copied();
|
||||||
|
|
||||||
|
let boundary = (prev.is_ascii_lowercase() && cur.is_ascii_uppercase())
|
||||||
|
|| (prev.is_ascii_alphabetic() && cur.is_ascii_digit())
|
||||||
|
|| (prev.is_ascii_digit() && cur.is_ascii_alphabetic())
|
||||||
|
|| (prev.is_ascii_uppercase()
|
||||||
|
&& cur.is_ascii_uppercase()
|
||||||
|
&& next.map(|n| n.is_ascii_lowercase()).unwrap_or(false));
|
||||||
|
|
||||||
|
if boundary {
|
||||||
|
parts.push(chars[start..i].iter().collect::<String>());
|
||||||
|
start = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(chars[start..].iter().collect::<String>());
|
||||||
|
parts
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tokenize_key_parts(key: &str) -> Vec<String> {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
|
||||||
|
for segment in key.split(|c: char| !c.is_ascii_alphanumeric()) {
|
||||||
|
if segment.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.extend(split_camel_case_key_parts(segment));
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.into_iter().map(|p| p.to_ascii_lowercase()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_exact(parts: &[String], candidates: &[&str]) -> bool {
|
||||||
|
parts
|
||||||
|
.iter()
|
||||||
|
.any(|part| candidates.iter().any(|candidate| part == candidate))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_candidate_or_numbered_variant(parts: &[String], candidates: &[&str]) -> bool {
|
||||||
|
parts.iter().any(|part| {
|
||||||
|
candidates.iter().any(|candidate| {
|
||||||
|
if part == candidate {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let Some(suffix) = part.strip_prefix(candidate) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
!suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_contextual_suffix(parts: &[String], candidates: &[&str]) -> bool {
|
||||||
|
parts.iter().any(|part| {
|
||||||
|
candidates.iter().any(|candidate| {
|
||||||
|
let Some(prefix) = part.strip_suffix(candidate) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
!prefix.is_empty() && CONTEXT_PARTS.contains(&prefix)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_sensitive_key(key: &str) -> bool {
|
||||||
|
let lower = key.to_ascii_lowercase();
|
||||||
|
if SENSITIVE_EXACT.contains(&lower.as_str()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parts = tokenize_key_parts(key);
|
||||||
|
if parts.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_candidate_or_numbered_variant(&parts, SENSITIVE_PARTS) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let has_token = has_candidate_or_numbered_variant(&parts, TOKEN_PARTS);
|
||||||
|
let has_key = has_candidate_or_numbered_variant(&parts, KEY_PARTS);
|
||||||
|
|
||||||
|
if has_token && has_key {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_contextual_suffix(&parts, TOKEN_PARTS) || has_contextual_suffix(&parts, KEY_PARTS) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let has_context = has_exact(&parts, CONTEXT_PARTS);
|
||||||
|
has_context && (has_token || has_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn redact_in_place(value: &mut Value) {
|
||||||
|
match value {
|
||||||
|
Value::Object(map) => redact_object(map),
|
||||||
|
Value::Array(items) => {
|
||||||
|
for item in items {
|
||||||
|
redact_in_place(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn redact_object(map: &mut Map<String, Value>) {
|
||||||
|
for (key, val) in map {
|
||||||
|
if is_sensitive_key(key) {
|
||||||
|
*val = Value::String(REDACTED.to_string());
|
||||||
|
} else {
|
||||||
|
redact_in_place(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn redact_sensitive_json(value: &Value) -> Value {
|
||||||
|
let mut cloned = value.clone();
|
||||||
|
redact_in_place(&mut cloned);
|
||||||
|
cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{is_sensitive_key, redact_sensitive_json};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn redacts_exact_sensitive_keys() {
|
||||||
|
let input = serde_json::json!({
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer abc",
|
||||||
|
"x-api-key": "k-123",
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"password": "p@ss"
|
||||||
|
});
|
||||||
|
let out = redact_sensitive_json(&input);
|
||||||
|
assert_eq!(out["headers"]["Authorization"], "[REDACTED]");
|
||||||
|
assert_eq!(out["headers"]["x-api-key"], "[REDACTED]");
|
||||||
|
assert_eq!(out["headers"]["content-type"], "application/json");
|
||||||
|
assert_eq!(out["password"], "[REDACTED]");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn redacts_nested_sensitive_keys() {
|
||||||
|
let input = serde_json::json!({
|
||||||
|
"body": {
|
||||||
|
"clientSecret": "xyz",
|
||||||
|
"nested": [{"authToken": "123"}, {"query": "ok"}]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let out = redact_sensitive_json(&input);
|
||||||
|
assert_eq!(out["body"]["clientSecret"], "[REDACTED]");
|
||||||
|
assert_eq!(out["body"]["nested"][0]["authToken"], "[REDACTED]");
|
||||||
|
assert_eq!(out["body"]["nested"][1]["query"], "ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn does_not_over_redact_common_non_sensitive_keys() {
|
||||||
|
assert!(!is_sensitive_key("author"));
|
||||||
|
assert!(!is_sensitive_key("authorize_user"));
|
||||||
|
assert!(!is_sensitive_key("token_count"));
|
||||||
|
assert!(!is_sensitive_key("tokenize"));
|
||||||
|
assert!(!is_sensitive_key("oauth_redirect_uri"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn still_redacts_expected_token_keys() {
|
||||||
|
assert!(is_sensitive_key("auth_token"));
|
||||||
|
assert!(is_sensitive_key("oauth_token"));
|
||||||
|
assert!(is_sensitive_key("accessToken"));
|
||||||
|
assert!(is_sensitive_key("apiKey"));
|
||||||
|
assert!(is_sensitive_key("token_key"));
|
||||||
|
assert!(is_sensitive_key("appTokenKey"));
|
||||||
|
assert!(is_sensitive_key("userJwt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn redacts_lowercase_digit_suffix_segments() {
|
||||||
|
assert!(is_sensitive_key("password123"));
|
||||||
|
assert!(is_sensitive_key("secret99"));
|
||||||
|
assert!(is_sensitive_key("accounttoken2"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
-1
@@ -23,7 +23,7 @@ use crate::tools::builtin::{
|
|||||||
ToolUpgradeTool, WriteFileTool,
|
ToolUpgradeTool, WriteFileTool,
|
||||||
};
|
};
|
||||||
use crate::tools::rate_limiter::RateLimiter;
|
use crate::tools::rate_limiter::RateLimiter;
|
||||||
use crate::tools::tool::{Tool, ToolDomain};
|
use crate::tools::tool::{ApprovalRequirement, Tool, ToolDomain};
|
||||||
use crate::tools::wasm::{
|
use crate::tools::wasm::{
|
||||||
Capabilities, OAuthRefreshConfig, ResourceLimits, SharedCredentialRegistry, WasmError,
|
Capabilities, OAuthRefreshConfig, ResourceLimits, SharedCredentialRegistry, WasmError,
|
||||||
WasmStorageError, WasmToolRuntime, WasmToolStore, WasmToolWrapper,
|
WasmStorageError, WasmToolRuntime, WasmToolStore, WasmToolWrapper,
|
||||||
@@ -278,6 +278,38 @@ impl ToolRegistry {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get tool definitions excluding specific tools by name.
|
||||||
|
///
|
||||||
|
/// Used by lightweight routines to filter out denylisted and approval-gated tools
|
||||||
|
/// so the LLM only sees tools it is actually allowed to call.
|
||||||
|
pub async fn tool_definitions_excluding(&self, deny: &[&str]) -> Vec<ToolDefinition> {
|
||||||
|
let empty_params = serde_json::Value::Object(serde_json::Map::new());
|
||||||
|
let mut defs: Vec<ToolDefinition> = self
|
||||||
|
.tools
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.values()
|
||||||
|
.filter(|tool| {
|
||||||
|
// Exclude denylisted tools
|
||||||
|
if deny.contains(&tool.name()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Exclude tools that require approval
|
||||||
|
matches!(
|
||||||
|
tool.requires_approval(&empty_params),
|
||||||
|
ApprovalRequirement::Never
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.map(|tool| ToolDefinition {
|
||||||
|
name: tool.name().to_string(),
|
||||||
|
description: tool.description().to_string(),
|
||||||
|
parameters: tool.parameters_schema(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
defs.sort_unstable_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
defs
|
||||||
|
}
|
||||||
|
|
||||||
/// Register development tools for building software.
|
/// Register development tools for building software.
|
||||||
///
|
///
|
||||||
/// These tools provide shell access, file operations, and code editing
|
/// These tools provide shell access, file operations, and code editing
|
||||||
|
|||||||
+19
-3
@@ -55,7 +55,9 @@ pub use embeddings::{
|
|||||||
};
|
};
|
||||||
#[cfg(feature = "postgres")]
|
#[cfg(feature = "postgres")]
|
||||||
pub use repository::Repository;
|
pub use repository::Repository;
|
||||||
pub use search::{RankedResult, SearchConfig, SearchResult, reciprocal_rank_fusion};
|
pub use search::{
|
||||||
|
FusionStrategy, RankedResult, SearchConfig, SearchResult, fuse_results, reciprocal_rank_fusion,
|
||||||
|
};
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -332,6 +334,8 @@ pub struct Workspace {
|
|||||||
storage: WorkspaceStorage,
|
storage: WorkspaceStorage,
|
||||||
/// Embedding provider for semantic search.
|
/// Embedding provider for semantic search.
|
||||||
embeddings: Option<Arc<dyn EmbeddingProvider>>,
|
embeddings: Option<Arc<dyn EmbeddingProvider>>,
|
||||||
|
/// Default search configuration applied to all queries.
|
||||||
|
search_defaults: SearchConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Workspace {
|
impl Workspace {
|
||||||
@@ -343,6 +347,7 @@ impl Workspace {
|
|||||||
agent_id: None,
|
agent_id: None,
|
||||||
storage: WorkspaceStorage::Repo(Repository::new(pool)),
|
storage: WorkspaceStorage::Repo(Repository::new(pool)),
|
||||||
embeddings: None,
|
embeddings: None,
|
||||||
|
search_defaults: SearchConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,6 +360,7 @@ impl Workspace {
|
|||||||
agent_id: None,
|
agent_id: None,
|
||||||
storage: WorkspaceStorage::Db(db),
|
storage: WorkspaceStorage::Db(db),
|
||||||
embeddings: None,
|
embeddings: None,
|
||||||
|
search_defaults: SearchConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,6 +376,16 @@ impl Workspace {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the default search configuration from workspace search config.
|
||||||
|
pub fn with_search_config(mut self, config: &crate::config::WorkspaceSearchConfig) -> Self {
|
||||||
|
self.search_defaults = SearchConfig::default()
|
||||||
|
.with_fusion_strategy(config.fusion_strategy)
|
||||||
|
.with_rrf_k(config.rrf_k)
|
||||||
|
.with_fts_weight(config.fts_weight)
|
||||||
|
.with_vector_weight(config.vector_weight);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the user ID.
|
/// Get the user ID.
|
||||||
pub fn user_id(&self) -> &str {
|
pub fn user_id(&self) -> &str {
|
||||||
&self.user_id
|
&self.user_id
|
||||||
@@ -709,13 +725,13 @@ impl Workspace {
|
|||||||
/// Hybrid search across all memory documents.
|
/// Hybrid search across all memory documents.
|
||||||
///
|
///
|
||||||
/// Combines full-text search (BM25) with semantic search (vector similarity)
|
/// Combines full-text search (BM25) with semantic search (vector similarity)
|
||||||
/// using Reciprocal Rank Fusion (RRF).
|
/// using the configured fusion strategy.
|
||||||
pub async fn search(
|
pub async fn search(
|
||||||
&self,
|
&self,
|
||||||
query: &str,
|
query: &str,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<Vec<SearchResult>, WorkspaceError> {
|
) -> Result<Vec<SearchResult>, WorkspaceError> {
|
||||||
self.search_with_config(query, SearchConfig::default().with_limit(limit))
|
self.search_with_config(query, self.search_defaults.clone().with_limit(limit))
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use uuid::Uuid;
|
|||||||
use crate::error::WorkspaceError;
|
use crate::error::WorkspaceError;
|
||||||
|
|
||||||
use crate::workspace::document::{MemoryChunk, MemoryDocument, WorkspaceEntry};
|
use crate::workspace::document::{MemoryChunk, MemoryDocument, WorkspaceEntry};
|
||||||
use crate::workspace::search::{RankedResult, SearchConfig, SearchResult, reciprocal_rank_fusion};
|
use crate::workspace::search::{RankedResult, SearchConfig, SearchResult, fuse_results};
|
||||||
|
|
||||||
/// Database repository for workspace operations.
|
/// Database repository for workspace operations.
|
||||||
pub struct Repository {
|
pub struct Repository {
|
||||||
@@ -415,7 +415,7 @@ impl Repository {
|
|||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(reciprocal_rank_fusion(fts_results, vector_results, config))
|
Ok(fuse_results(fts_results, vector_results, config))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Full-text search using PostgreSQL ts_rank_cd.
|
/// Full-text search using PostgreSQL ts_rank_cd.
|
||||||
|
|||||||
+314
-7
@@ -1,17 +1,30 @@
|
|||||||
//! Hybrid search combining full-text and semantic search.
|
//! Hybrid search combining full-text and semantic search.
|
||||||
//!
|
//!
|
||||||
//! Uses Reciprocal Rank Fusion (RRF) to combine results from:
|
//! Supports two fusion strategies:
|
||||||
//! 1. PostgreSQL full-text search (ts_rank_cd)
|
//! 1. **RRF** (Reciprocal Rank Fusion) — the default, rank-based method.
|
||||||
//! 2. pgvector cosine similarity search
|
//! `score = sum(1 / (k + rank))` for each retrieval method.
|
||||||
|
//! 2. **WeightedScore** — converts ranks to scores via `1/rank`, combines with
|
||||||
|
//! configurable weights (`fts_weight * fts_score + vector_weight * vector_score`),
|
||||||
|
//! then normalizes to \[0,1\] by dividing by the maximum combined score.
|
||||||
//!
|
//!
|
||||||
//! RRF formula: score = sum(1 / (k + rank)) for each retrieval method
|
//! Both strategies combine results from:
|
||||||
//! This is robust to different score scales and produces better results
|
//! - PostgreSQL / libSQL full-text search
|
||||||
//! than simple score averaging.
|
//! - pgvector / libsql_vector cosine similarity search
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Strategy used to fuse FTS and vector search results.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
pub enum FusionStrategy {
|
||||||
|
/// Reciprocal Rank Fusion (default). Ignores `fts_weight`/`vector_weight`.
|
||||||
|
#[default]
|
||||||
|
Rrf,
|
||||||
|
/// Weighted score fusion using normalized rank-derived scores.
|
||||||
|
WeightedScore,
|
||||||
|
}
|
||||||
|
|
||||||
/// Configuration for hybrid search.
|
/// Configuration for hybrid search.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SearchConfig {
|
pub struct SearchConfig {
|
||||||
@@ -27,6 +40,16 @@ pub struct SearchConfig {
|
|||||||
pub min_score: f32,
|
pub min_score: f32,
|
||||||
/// Maximum results to fetch from each method before fusion.
|
/// Maximum results to fetch from each method before fusion.
|
||||||
pub pre_fusion_limit: usize,
|
pub pre_fusion_limit: usize,
|
||||||
|
/// Fusion strategy to use when combining results.
|
||||||
|
pub fusion_strategy: FusionStrategy,
|
||||||
|
/// Weight for FTS results in `WeightedScore` fusion (default 0.5).
|
||||||
|
/// Ignored by `Rrf` fusion. For env-based config via
|
||||||
|
/// `WorkspaceSearchConfig::resolve`, defaults are per-strategy.
|
||||||
|
pub fts_weight: f32,
|
||||||
|
/// Weight for vector results in `WeightedScore` fusion (default 0.5).
|
||||||
|
/// Ignored by `Rrf` fusion. For env-based config via
|
||||||
|
/// `WorkspaceSearchConfig::resolve`, defaults are per-strategy.
|
||||||
|
pub vector_weight: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for SearchConfig {
|
impl Default for SearchConfig {
|
||||||
@@ -38,6 +61,9 @@ impl Default for SearchConfig {
|
|||||||
use_vector: true,
|
use_vector: true,
|
||||||
min_score: 0.0,
|
min_score: 0.0,
|
||||||
pre_fusion_limit: 50,
|
pre_fusion_limit: 50,
|
||||||
|
fusion_strategy: FusionStrategy::default(),
|
||||||
|
fts_weight: 0.5,
|
||||||
|
vector_weight: 0.5,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,6 +100,32 @@ impl SearchConfig {
|
|||||||
self.min_score = score.clamp(0.0, 1.0);
|
self.min_score = score.clamp(0.0, 1.0);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the fusion strategy.
|
||||||
|
pub fn with_fusion_strategy(mut self, strategy: FusionStrategy) -> Self {
|
||||||
|
self.fusion_strategy = strategy;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the FTS weight for `WeightedScore` fusion.
|
||||||
|
///
|
||||||
|
/// Non-finite (NaN, ±inf) or negative values are ignored.
|
||||||
|
pub fn with_fts_weight(mut self, weight: f32) -> Self {
|
||||||
|
if weight.is_finite() && weight >= 0.0 {
|
||||||
|
self.fts_weight = weight;
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the vector weight for `WeightedScore` fusion.
|
||||||
|
///
|
||||||
|
/// Non-finite (NaN, ±inf) or negative values are ignored.
|
||||||
|
pub fn with_vector_weight(mut self, weight: f32) -> Self {
|
||||||
|
if weight.is_finite() && weight >= 0.0 {
|
||||||
|
self.vector_weight = weight;
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A search result with hybrid scoring.
|
/// A search result with hybrid scoring.
|
||||||
@@ -87,7 +139,7 @@ pub struct SearchResult {
|
|||||||
pub chunk_id: Uuid,
|
pub chunk_id: Uuid,
|
||||||
/// Chunk content.
|
/// Chunk content.
|
||||||
pub content: String,
|
pub content: String,
|
||||||
/// Combined RRF score (0.0-1.0 normalized).
|
/// Combined fusion score (0.0-1.0 normalized). Strategy-dependent (RRF or WeightedScore).
|
||||||
pub score: f32,
|
pub score: f32,
|
||||||
/// Rank in FTS results (1-based, None if not in FTS results).
|
/// Rank in FTS results (1-based, None if not in FTS results).
|
||||||
pub fts_rank: Option<u32>,
|
pub fts_rank: Option<u32>,
|
||||||
@@ -123,6 +175,22 @@ pub struct RankedResult {
|
|||||||
pub rank: u32, // 1-based rank
|
pub rank: u32, // 1-based rank
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fuse FTS and vector search results using the strategy specified in `config`.
|
||||||
|
///
|
||||||
|
/// This is the primary entry point for result fusion. Delegates to
|
||||||
|
/// [`reciprocal_rank_fusion`] or [`weighted_score_fusion`] based on
|
||||||
|
/// `config.fusion_strategy`.
|
||||||
|
pub fn fuse_results(
|
||||||
|
fts_results: Vec<RankedResult>,
|
||||||
|
vector_results: Vec<RankedResult>,
|
||||||
|
config: &SearchConfig,
|
||||||
|
) -> Vec<SearchResult> {
|
||||||
|
match config.fusion_strategy {
|
||||||
|
FusionStrategy::Rrf => reciprocal_rank_fusion(fts_results, vector_results, config),
|
||||||
|
FusionStrategy::WeightedScore => weighted_score_fusion(fts_results, vector_results, config),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Reciprocal Rank Fusion algorithm.
|
/// Reciprocal Rank Fusion algorithm.
|
||||||
///
|
///
|
||||||
/// Combines ranked results from multiple retrieval methods using the formula:
|
/// Combines ranked results from multiple retrieval methods using the formula:
|
||||||
@@ -235,6 +303,109 @@ pub fn reciprocal_rank_fusion(
|
|||||||
results
|
results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Weighted score fusion.
|
||||||
|
///
|
||||||
|
/// Converts ranks from each method into scores using `1/rank`
|
||||||
|
/// (so rank 1 → 1.0, rank N → 1/N), then combines them with
|
||||||
|
/// configurable weights: `fts_weight * fts_score + vector_weight * vector_score`.
|
||||||
|
///
|
||||||
|
/// The combined scores are then normalized to [0,1] by dividing by the
|
||||||
|
/// maximum score; post-processing (normalization, min_score filter, sort,
|
||||||
|
/// truncate) matches RRF.
|
||||||
|
pub fn weighted_score_fusion(
|
||||||
|
fts_results: Vec<RankedResult>,
|
||||||
|
vector_results: Vec<RankedResult>,
|
||||||
|
config: &SearchConfig,
|
||||||
|
) -> Vec<SearchResult> {
|
||||||
|
struct ChunkInfo {
|
||||||
|
document_id: Uuid,
|
||||||
|
document_path: String,
|
||||||
|
content: String,
|
||||||
|
score: f32,
|
||||||
|
fts_rank: Option<u32>,
|
||||||
|
vector_rank: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut chunk_scores: HashMap<Uuid, ChunkInfo> = HashMap::new();
|
||||||
|
|
||||||
|
// Process FTS results: score = fts_weight * (1 / rank)
|
||||||
|
for result in fts_results {
|
||||||
|
let score = config.fts_weight * (1.0 / result.rank as f32);
|
||||||
|
chunk_scores
|
||||||
|
.entry(result.chunk_id)
|
||||||
|
.and_modify(|info| {
|
||||||
|
info.score += score;
|
||||||
|
info.fts_rank = Some(result.rank);
|
||||||
|
})
|
||||||
|
.or_insert(ChunkInfo {
|
||||||
|
document_id: result.document_id,
|
||||||
|
document_path: result.document_path,
|
||||||
|
content: result.content,
|
||||||
|
score,
|
||||||
|
fts_rank: Some(result.rank),
|
||||||
|
vector_rank: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process vector results: score = vector_weight * (1 / rank)
|
||||||
|
for result in vector_results {
|
||||||
|
let score = config.vector_weight * (1.0 / result.rank as f32);
|
||||||
|
chunk_scores
|
||||||
|
.entry(result.chunk_id)
|
||||||
|
.and_modify(|info| {
|
||||||
|
info.score += score;
|
||||||
|
info.vector_rank = Some(result.rank);
|
||||||
|
})
|
||||||
|
.or_insert(ChunkInfo {
|
||||||
|
document_id: result.document_id,
|
||||||
|
document_path: result.document_path,
|
||||||
|
content: result.content,
|
||||||
|
score,
|
||||||
|
fts_rank: None,
|
||||||
|
vector_rank: Some(result.rank),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut results: Vec<SearchResult> = chunk_scores
|
||||||
|
.into_iter()
|
||||||
|
.map(|(chunk_id, info)| SearchResult {
|
||||||
|
document_id: info.document_id,
|
||||||
|
document_path: info.document_path,
|
||||||
|
chunk_id,
|
||||||
|
content: info.content,
|
||||||
|
score: info.score,
|
||||||
|
fts_rank: info.fts_rank,
|
||||||
|
vector_rank: info.vector_rank,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Normalize scores to 0-1 range
|
||||||
|
if let Some(max_score) = results.iter().map(|r| r.score).reduce(f32::max)
|
||||||
|
&& max_score > 0.0
|
||||||
|
{
|
||||||
|
for result in &mut results {
|
||||||
|
result.score /= max_score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by minimum score
|
||||||
|
if config.min_score > 0.0 {
|
||||||
|
results.retain(|r| r.score >= config.min_score);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by score descending
|
||||||
|
results.sort_by(|a, b| {
|
||||||
|
b.score
|
||||||
|
.partial_cmp(&a.score)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Limit results
|
||||||
|
results.truncate(config.limit);
|
||||||
|
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -457,6 +628,142 @@ mod tests {
|
|||||||
let vector_only = SearchConfig::default().vector_only();
|
let vector_only = SearchConfig::default().vector_only();
|
||||||
assert!(!vector_only.use_fts);
|
assert!(!vector_only.use_fts);
|
||||||
assert!(vector_only.use_vector);
|
assert!(vector_only.use_vector);
|
||||||
|
|
||||||
|
let weighted = SearchConfig::default()
|
||||||
|
.with_fusion_strategy(FusionStrategy::WeightedScore)
|
||||||
|
.with_fts_weight(0.8)
|
||||||
|
.with_vector_weight(0.2);
|
||||||
|
assert_eq!(weighted.fusion_strategy, FusionStrategy::WeightedScore);
|
||||||
|
assert!((weighted.fts_weight - 0.8).abs() < 0.001);
|
||||||
|
assert!((weighted.vector_weight - 0.2).abs() < 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_weighted_fusion_basic() {
|
||||||
|
// With equal weights, a hybrid match should still rank highest.
|
||||||
|
let config = SearchConfig::default()
|
||||||
|
.with_fusion_strategy(FusionStrategy::WeightedScore)
|
||||||
|
.with_fts_weight(1.0)
|
||||||
|
.with_vector_weight(1.0)
|
||||||
|
.with_limit(10);
|
||||||
|
|
||||||
|
let chunk1 = Uuid::new_v4(); // In both
|
||||||
|
let chunk2 = Uuid::new_v4(); // FTS only
|
||||||
|
let chunk3 = Uuid::new_v4(); // Vector only
|
||||||
|
let doc = Uuid::new_v4();
|
||||||
|
|
||||||
|
let fts = vec![make_result(chunk1, doc, 1), make_result(chunk2, doc, 2)];
|
||||||
|
let vec_results = vec![make_result(chunk1, doc, 1), make_result(chunk3, doc, 2)];
|
||||||
|
|
||||||
|
let results = weighted_score_fusion(fts, vec_results, &config);
|
||||||
|
|
||||||
|
assert_eq!(results.len(), 3);
|
||||||
|
// Hybrid match (chunk1) should be first — it gets score from both
|
||||||
|
assert_eq!(results[0].chunk_id, chunk1);
|
||||||
|
assert!(results[0].is_hybrid());
|
||||||
|
assert!(results[0].score > results[1].score);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_weighted_fusion_fts_boost() {
|
||||||
|
// High FTS weight should elevate FTS-only results above vector-only.
|
||||||
|
let config = SearchConfig::default()
|
||||||
|
.with_fusion_strategy(FusionStrategy::WeightedScore)
|
||||||
|
.with_fts_weight(2.0)
|
||||||
|
.with_vector_weight(0.5)
|
||||||
|
.with_limit(10);
|
||||||
|
|
||||||
|
let chunk_fts = Uuid::new_v4(); // FTS only, rank 2
|
||||||
|
let chunk_vec = Uuid::new_v4(); // Vector only, rank 2
|
||||||
|
let doc = Uuid::new_v4();
|
||||||
|
|
||||||
|
let fts = vec![make_result(chunk_fts, doc, 2)];
|
||||||
|
let vec_results = vec![make_result(chunk_vec, doc, 2)];
|
||||||
|
|
||||||
|
let results = weighted_score_fusion(fts, vec_results, &config);
|
||||||
|
|
||||||
|
assert_eq!(results.len(), 2);
|
||||||
|
// FTS result should rank higher because of the 2.0 weight vs 0.5
|
||||||
|
assert_eq!(results[0].chunk_id, chunk_fts);
|
||||||
|
assert!(results[0].from_fts());
|
||||||
|
assert!(!results[0].from_vector());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_weighted_fusion_single_source() {
|
||||||
|
// Only FTS results — should still work correctly.
|
||||||
|
let config = SearchConfig::default()
|
||||||
|
.with_fusion_strategy(FusionStrategy::WeightedScore)
|
||||||
|
.with_limit(10);
|
||||||
|
|
||||||
|
let chunk1 = Uuid::new_v4();
|
||||||
|
let chunk2 = Uuid::new_v4();
|
||||||
|
let doc = Uuid::new_v4();
|
||||||
|
|
||||||
|
let fts = vec![make_result(chunk1, doc, 1), make_result(chunk2, doc, 3)];
|
||||||
|
|
||||||
|
let results = weighted_score_fusion(fts, Vec::new(), &config);
|
||||||
|
|
||||||
|
assert_eq!(results.len(), 2);
|
||||||
|
assert_eq!(results[0].chunk_id, chunk1);
|
||||||
|
assert!(results[0].score > results[1].score);
|
||||||
|
// Top result should be normalized to 1.0
|
||||||
|
assert!((results[0].score - 1.0).abs() < 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_weight_setters_reject_invalid() {
|
||||||
|
let config = SearchConfig::default();
|
||||||
|
let original_fts = config.fts_weight;
|
||||||
|
let original_vec = config.vector_weight;
|
||||||
|
|
||||||
|
// NaN is ignored
|
||||||
|
let c = config.clone().with_fts_weight(f32::NAN);
|
||||||
|
assert!((c.fts_weight - original_fts).abs() < 0.001);
|
||||||
|
|
||||||
|
// Infinity is ignored
|
||||||
|
let c = config.clone().with_vector_weight(f32::INFINITY);
|
||||||
|
assert!((c.vector_weight - original_vec).abs() < 0.001);
|
||||||
|
|
||||||
|
// Negative is ignored
|
||||||
|
let c = config.clone().with_fts_weight(-1.0);
|
||||||
|
assert!((c.fts_weight - original_fts).abs() < 0.001);
|
||||||
|
|
||||||
|
// Negative infinity is ignored
|
||||||
|
let c = config.clone().with_vector_weight(f32::NEG_INFINITY);
|
||||||
|
assert!((c.vector_weight - original_vec).abs() < 0.001);
|
||||||
|
|
||||||
|
// Valid values > 1.0 are accepted (weights don't need to sum to 1.0)
|
||||||
|
let c = config.clone().with_fts_weight(2.0);
|
||||||
|
assert!((c.fts_weight - 2.0).abs() < 0.001);
|
||||||
|
|
||||||
|
// Zero is valid
|
||||||
|
let c = config.clone().with_vector_weight(0.0);
|
||||||
|
assert!(c.vector_weight.abs() < 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fuse_results_dispatches_correctly() {
|
||||||
|
let chunk1 = Uuid::new_v4();
|
||||||
|
let doc = Uuid::new_v4();
|
||||||
|
|
||||||
|
let fts = vec![make_result(chunk1, doc, 1)];
|
||||||
|
|
||||||
|
// RRF strategy
|
||||||
|
let rrf_config = SearchConfig::default().with_limit(10);
|
||||||
|
let rrf_results = fuse_results(fts.clone(), Vec::new(), &rrf_config);
|
||||||
|
assert_eq!(rrf_results.len(), 1);
|
||||||
|
|
||||||
|
// Weighted strategy
|
||||||
|
let weighted_config = SearchConfig::default()
|
||||||
|
.with_fusion_strategy(FusionStrategy::WeightedScore)
|
||||||
|
.with_limit(10);
|
||||||
|
let weighted_results = fuse_results(fts, Vec::new(), &weighted_config);
|
||||||
|
assert_eq!(weighted_results.len(), 1);
|
||||||
|
|
||||||
|
// Both should normalize single result to 1.0
|
||||||
|
assert!((rrf_results[0].score - 1.0).abs() < 0.001);
|
||||||
|
assert!((weighted_results[0].score - 1.0).abs() < 0.001);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Edge case tests ---
|
// --- Edge case tests ---
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Scenario: Content Security Policy compliance.
|
||||||
|
|
||||||
|
Detects CSP violations (inline scripts, blocked resources) that would
|
||||||
|
break the gateway JS. This test catches regressions like adding
|
||||||
|
inline onclick handlers while a script-src CSP is active.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from helpers import SEL
|
||||||
|
|
||||||
|
|
||||||
|
async def test_no_csp_violations_on_load(page):
|
||||||
|
"""Page load must produce zero CSP violation reports."""
|
||||||
|
violations = []
|
||||||
|
|
||||||
|
page.on("console", lambda msg: (
|
||||||
|
violations.append(msg.text)
|
||||||
|
if "content security policy" in msg.text.lower()
|
||||||
|
or msg.type == "error" and "refused" in msg.text.lower()
|
||||||
|
else None
|
||||||
|
))
|
||||||
|
|
||||||
|
# Reload the page to catch violations from initial load.
|
||||||
|
# Use "load" (not "networkidle") because the SSE stream keeps the
|
||||||
|
# connection open indefinitely, preventing networkidle from firing.
|
||||||
|
await page.reload(wait_until="load")
|
||||||
|
# Wait a moment for any deferred script execution
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
assert violations == [], (
|
||||||
|
f"CSP violations detected on page load:\n" + "\n".join(violations)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_no_inline_event_handlers_in_html(page):
|
||||||
|
"""Static HTML must not contain any inline event handler attributes."""
|
||||||
|
inline_handlers = await page.evaluate("""() => {
|
||||||
|
const allElements = document.querySelectorAll('*');
|
||||||
|
const found = [];
|
||||||
|
const handlerAttrs = [
|
||||||
|
'onclick', 'onchange', 'onsubmit', 'onload', 'onerror',
|
||||||
|
'onmouseover', 'onfocus', 'onblur', 'onkeydown', 'onkeyup',
|
||||||
|
'oninput', 'onmousedown', 'onmouseup'
|
||||||
|
];
|
||||||
|
for (const el of allElements) {
|
||||||
|
for (const attr of handlerAttrs) {
|
||||||
|
if (el.hasAttribute(attr)) {
|
||||||
|
const tag = el.tagName.toLowerCase();
|
||||||
|
const id = el.id ? '#' + el.id : '';
|
||||||
|
const cls = el.className ? '.' + el.className.split(' ')[0] : '';
|
||||||
|
found.push(tag + id + cls + '[' + attr + ']');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}""")
|
||||||
|
|
||||||
|
assert inline_handlers == [], (
|
||||||
|
f"Found inline event handlers (CSP-incompatible):\n"
|
||||||
|
+ "\n".join(f" - {h}" for h in inline_handlers)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_no_js_errors_on_page_load(page):
|
||||||
|
"""No JavaScript errors should occur on page load."""
|
||||||
|
errors = []
|
||||||
|
page.on("pageerror", lambda err: errors.append(str(err)))
|
||||||
|
|
||||||
|
await page.reload(wait_until="load")
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
assert errors == [], (
|
||||||
|
f"JavaScript errors on page load:\n" + "\n".join(errors)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_buttons_still_functional_after_csp_migration(page):
|
||||||
|
"""Core buttons must still be wired up via addEventListener."""
|
||||||
|
# Verify that key buttons have click handlers attached (not inline)
|
||||||
|
# by checking that clicking them doesn't throw and they exist in the DOM
|
||||||
|
button_ids = [
|
||||||
|
'send-btn',
|
||||||
|
'thread-new-btn',
|
||||||
|
'thread-toggle-btn',
|
||||||
|
'restart-btn',
|
||||||
|
'memory-edit-btn',
|
||||||
|
'logs-pause-btn',
|
||||||
|
'logs-clear-btn',
|
||||||
|
]
|
||||||
|
|
||||||
|
for btn_id in button_ids:
|
||||||
|
exists = await page.evaluate(
|
||||||
|
"id => document.getElementById(id) !== null", btn_id
|
||||||
|
)
|
||||||
|
assert exists, f"Button #{btn_id} not found in DOM"
|
||||||
|
|
||||||
|
# Verify the assistant thread div is clickable (has no onclick but
|
||||||
|
# should be handled by delegation or direct addEventListener)
|
||||||
|
assistant_el = page.locator(SEL["chat_input"])
|
||||||
|
await assistant_el.wait_for(state="visible", timeout=5000)
|
||||||
@@ -403,4 +403,136 @@ mod advanced {
|
|||||||
rig.verify_trace_expects(&trace, &responses);
|
rig.verify_trace_expects(&trace, &responses);
|
||||||
rig.shutdown();
|
rig.shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 8. MCP extension lifecycle (search → install → activate → use)
|
||||||
|
//
|
||||||
|
// Exercises the MCP extension flow with a mock MCP server:
|
||||||
|
// Turn 1: tool_search → tool_install → text
|
||||||
|
// (inject token + activate between turns)
|
||||||
|
// Turn 2: mock-notion_notion-search → mock-notion_notion-fetch → text
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mcp_extension_lifecycle() {
|
||||||
|
use crate::support::mock_mcp_server::{MockToolResponse, start_mock_mcp_server};
|
||||||
|
use ironclaw::extensions::{AuthHint, ExtensionKind, ExtensionSource, RegistryEntry};
|
||||||
|
|
||||||
|
// 1. Start mock MCP server with pre-configured tool responses.
|
||||||
|
let mock_server = start_mock_mcp_server(vec![
|
||||||
|
MockToolResponse {
|
||||||
|
name: "notion-search".into(),
|
||||||
|
content: serde_json::json!({
|
||||||
|
"results": [
|
||||||
|
{"id": "page-001", "title": "Project Alpha", "type": "page"},
|
||||||
|
{"id": "page-002", "title": "Sprint Planning", "type": "page"}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
MockToolResponse {
|
||||||
|
name: "notion-fetch".into(),
|
||||||
|
content: serde_json::json!({
|
||||||
|
"id": "page-001",
|
||||||
|
"title": "Project Alpha",
|
||||||
|
"content": "Status: In Progress\n- Sprint planning on March 15\n- API redesign review pending"
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// 2. Load trace fixture.
|
||||||
|
let trace =
|
||||||
|
LlmTrace::from_file(format!("{FIXTURES}/mcp_extension_lifecycle.json")).unwrap();
|
||||||
|
|
||||||
|
// 3. Build rig with auto-approve (so tool_install doesn't block).
|
||||||
|
let rig = TestRigBuilder::new()
|
||||||
|
.with_trace(trace.clone())
|
||||||
|
.with_auto_approve_tools(true)
|
||||||
|
.with_max_tool_iterations(15)
|
||||||
|
.build()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// 4. Inject mock-notion registry entry pointing to the mock server.
|
||||||
|
let ext_mgr = rig
|
||||||
|
.extension_manager()
|
||||||
|
.expect("test rig must expose extension manager");
|
||||||
|
ext_mgr
|
||||||
|
.inject_registry_entry(RegistryEntry {
|
||||||
|
name: "mock-notion".to_string(),
|
||||||
|
display_name: "Mock Notion".to_string(),
|
||||||
|
kind: ExtensionKind::McpServer,
|
||||||
|
description: "Test MCP server for E2E lifecycle test".to_string(),
|
||||||
|
keywords: vec!["mock-notion".into(), "notion".into()],
|
||||||
|
source: ExtensionSource::McpUrl {
|
||||||
|
url: mock_server.mcp_url(),
|
||||||
|
},
|
||||||
|
fallback_source: None,
|
||||||
|
auth_hint: AuthHint::Dcr,
|
||||||
|
version: None,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// 5. Turn 1: "setup mock-notion" → search → install → text.
|
||||||
|
rig.send_message("setup mock-notion").await;
|
||||||
|
let r1 = rig.wait_for_responses(1, TIMEOUT).await;
|
||||||
|
assert!(!r1.is_empty(), "Turn 1: no response");
|
||||||
|
|
||||||
|
// 6. Simulate OAuth completion: inject token + activate.
|
||||||
|
// This mirrors what the gateway's oauth_callback_handler does after
|
||||||
|
// the user completes the OAuth flow in their browser.
|
||||||
|
let secret_name = "mcp_mock-notion_access_token";
|
||||||
|
ext_mgr
|
||||||
|
.secrets()
|
||||||
|
.create(
|
||||||
|
"default",
|
||||||
|
ironclaw::secrets::CreateSecretParams::new(secret_name, "mock-access-token")
|
||||||
|
.with_provider("mcp:mock-notion".to_string()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("failed to inject test token");
|
||||||
|
|
||||||
|
let activate_result = ext_mgr.activate("mock-notion").await;
|
||||||
|
assert!(
|
||||||
|
activate_result.is_ok(),
|
||||||
|
"activation failed: {:?}",
|
||||||
|
activate_result.err()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 7. Turn 2: "check what's in my notion" → notion-search → notion-fetch → text.
|
||||||
|
// Wait for r1.len() + 1 to ensure we observe at least one new turn-2 response.
|
||||||
|
let turn1_count = r1.len();
|
||||||
|
rig.send_message("it's done, check what's in my notion")
|
||||||
|
.await;
|
||||||
|
let r2 = rig.wait_for_responses(turn1_count + 1, TIMEOUT).await;
|
||||||
|
assert!(
|
||||||
|
r2.len() > turn1_count,
|
||||||
|
"Turn 2: expected new responses beyond turn 1's {turn1_count}, got {}",
|
||||||
|
r2.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 8. Verify tool calls across both turns.
|
||||||
|
let started = rig.tool_calls_started();
|
||||||
|
assert!(
|
||||||
|
started.iter().any(|s| s == "tool_search"),
|
||||||
|
"tool_search not called: {started:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
started.iter().any(|s| s == "tool_install"),
|
||||||
|
"tool_install not called: {started:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify MCP tools were called in turn 2.
|
||||||
|
assert!(
|
||||||
|
started.iter().any(|s| s.starts_with("mock-notion_")),
|
||||||
|
"No mock-notion MCP tools called: {started:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify all tools that completed did so successfully.
|
||||||
|
let completed = rig.tool_calls_completed();
|
||||||
|
let failed: Vec<_> = completed.iter().filter(|(_, success)| !success).collect();
|
||||||
|
assert!(failed.is_empty(), "Tools failed: {failed:?}");
|
||||||
|
|
||||||
|
mock_server.shutdown().await;
|
||||||
|
rig.shutdown();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ mod tests {
|
|||||||
prompt: prompt.to_string(),
|
prompt: prompt.to_string(),
|
||||||
context_paths: vec![],
|
context_paths: vec![],
|
||||||
max_tokens: 1000,
|
max_tokens: 1000,
|
||||||
|
use_tools: false,
|
||||||
|
max_tool_rounds: 3,
|
||||||
},
|
},
|
||||||
guardrails: RoutineGuardrails {
|
guardrails: RoutineGuardrails {
|
||||||
cooldown: Duration::from_secs(0),
|
cooldown: Duration::from_secs(0),
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
{
|
||||||
|
"model_name": "advanced-mcp-extension-lifecycle",
|
||||||
|
"expects": {
|
||||||
|
"tools_used": ["tool_search", "tool_install"],
|
||||||
|
"tools_order": ["tool_search", "tool_install"],
|
||||||
|
"all_tools_succeeded": true,
|
||||||
|
"min_responses": 2
|
||||||
|
},
|
||||||
|
"turns": [
|
||||||
|
{
|
||||||
|
"user_input": "setup mock-notion",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"request_hint": { "last_user_message_contains": "setup mock-notion" },
|
||||||
|
"response": {
|
||||||
|
"type": "tool_calls",
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"id": "call_search_1",
|
||||||
|
"name": "tool_search",
|
||||||
|
"arguments": { "query": "mock-notion" }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"input_tokens": 500,
|
||||||
|
"output_tokens": 30
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"request_hint": { "last_user_message_contains": "setup mock-notion", "min_message_count": 4 },
|
||||||
|
"response": {
|
||||||
|
"type": "tool_calls",
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"id": "call_install_1",
|
||||||
|
"name": "tool_install",
|
||||||
|
"arguments": { "name": "mock-notion" }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"input_tokens": 600,
|
||||||
|
"output_tokens": 30
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"request_hint": { "last_user_message_contains": "setup mock-notion", "min_message_count": 6 },
|
||||||
|
"response": {
|
||||||
|
"type": "text",
|
||||||
|
"content": "I've installed Mock Notion. Please authenticate to connect your account — once done, tell me and I'll load the MCP tools.",
|
||||||
|
"input_tokens": 700,
|
||||||
|
"output_tokens": 35
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"user_input": "it's done, check what's in my notion",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"request_hint": { "last_user_message_contains": "notion" },
|
||||||
|
"response": {
|
||||||
|
"type": "tool_calls",
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"id": "call_ns_1",
|
||||||
|
"name": "mock-notion_notion-search",
|
||||||
|
"arguments": { "query": "recent notes" }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"input_tokens": 900,
|
||||||
|
"output_tokens": 30
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"request_hint": { "min_message_count": 4 },
|
||||||
|
"response": {
|
||||||
|
"type": "tool_calls",
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"id": "call_nf_1",
|
||||||
|
"name": "mock-notion_notion-fetch",
|
||||||
|
"arguments": { "query": "page-001" }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"input_tokens": 1000,
|
||||||
|
"output_tokens": 30
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"response": {
|
||||||
|
"type": "text",
|
||||||
|
"content": "Here's what I found in your Notion:\n\n**Project Alpha** — Status: In Progress\n- Sprint planning on March 15\n- API redesign review pending\n\nLet me know if you want more details on any item.",
|
||||||
|
"input_tokens": 1100,
|
||||||
|
"output_tokens": 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
//! Mock MCP server for E2E testing of the extension lifecycle.
|
||||||
|
//!
|
||||||
|
//! Provides a minimal HTTP server with:
|
||||||
|
//! - OAuth 2.1 discovery (`.well-known/oauth-protected-resource`, `.well-known/oauth-authorization-server`)
|
||||||
|
//! - Dynamic Client Registration (`/register`)
|
||||||
|
//! - Token exchange (`/token`)
|
||||||
|
//! - MCP JSON-RPC endpoint (`/mcp`) with `initialize`, `tools/list`, `tools/call`
|
||||||
|
//!
|
||||||
|
//! Tool call responses are pre-configured via `MockToolResponse`.
|
||||||
|
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use axum::routing::{get, post};
|
||||||
|
use axum::{Json, Router};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::sync::oneshot;
|
||||||
|
|
||||||
|
/// A pre-configured response for a specific MCP tool call.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct MockToolResponse {
|
||||||
|
/// Tool name (e.g., "notion-search").
|
||||||
|
pub name: String,
|
||||||
|
/// JSON response content for `tools/call`.
|
||||||
|
pub content: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A running mock MCP server.
|
||||||
|
pub struct MockMcpServer {
|
||||||
|
/// Base URL including port (e.g., "http://127.0.0.1:12345").
|
||||||
|
pub base_url: String,
|
||||||
|
/// Shutdown signal sender.
|
||||||
|
shutdown_tx: Option<oneshot::Sender<()>>,
|
||||||
|
/// Server task handle.
|
||||||
|
handle: Option<tokio::task::JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockMcpServer {
|
||||||
|
/// The MCP endpoint URL for use in registry entries.
|
||||||
|
pub fn mcp_url(&self) -> String {
|
||||||
|
format!("{}/mcp", self.base_url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shut down the server.
|
||||||
|
pub async fn shutdown(mut self) {
|
||||||
|
if let Some(tx) = self.shutdown_tx.take() {
|
||||||
|
let _ = tx.send(());
|
||||||
|
}
|
||||||
|
if let Some(h) = self.handle.take() {
|
||||||
|
let _ = h.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for MockMcpServer {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Some(tx) = self.shutdown_tx.take() {
|
||||||
|
let _ = tx.send(());
|
||||||
|
}
|
||||||
|
if let Some(h) = self.handle.take() {
|
||||||
|
h.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared state for the mock server handlers.
|
||||||
|
struct MockState {
|
||||||
|
/// Base URL (filled after bind).
|
||||||
|
base_url: String,
|
||||||
|
/// Tool definitions served by tools/list.
|
||||||
|
tools: Vec<McpToolDef>,
|
||||||
|
/// Pre-configured tool call responses keyed by tool name.
|
||||||
|
/// Multiple calls to the same tool return responses in order.
|
||||||
|
tool_responses: HashMap<String, Vec<serde_json::Value>>,
|
||||||
|
/// Counter for tool_responses consumption (per tool name).
|
||||||
|
tool_response_idx: std::sync::Mutex<HashMap<String, usize>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
struct McpToolDef {
|
||||||
|
name: String,
|
||||||
|
description: String,
|
||||||
|
#[serde(rename = "inputSchema")]
|
||||||
|
input_schema: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a mock MCP server on a random port.
|
||||||
|
///
|
||||||
|
/// `tool_responses` configures what `tools/call` returns for each tool name.
|
||||||
|
/// Multiple responses for the same tool are returned in order.
|
||||||
|
pub async fn start_mock_mcp_server(tool_responses: Vec<MockToolResponse>) -> MockMcpServer {
|
||||||
|
// Build tool definitions and response map.
|
||||||
|
let mut tools = Vec::new();
|
||||||
|
let mut response_map: HashMap<String, Vec<serde_json::Value>> = HashMap::new();
|
||||||
|
let mut seen_tools = std::collections::HashSet::new();
|
||||||
|
|
||||||
|
for tr in &tool_responses {
|
||||||
|
if seen_tools.insert(tr.name.clone()) {
|
||||||
|
tools.push(McpToolDef {
|
||||||
|
name: tr.name.clone(),
|
||||||
|
description: format!("Mock tool: {}", tr.name),
|
||||||
|
input_schema: serde_json::json!({"type": "object", "properties": {}}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
response_map
|
||||||
|
.entry(tr.name.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(tr.content.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind to a random port.
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||||
|
.await
|
||||||
|
.expect("failed to bind mock MCP server");
|
||||||
|
let addr: SocketAddr = listener.local_addr().expect("no local addr");
|
||||||
|
let base_url = format!("http://127.0.0.1:{}", addr.port());
|
||||||
|
|
||||||
|
let state = Arc::new(MockState {
|
||||||
|
base_url: base_url.clone(),
|
||||||
|
tools,
|
||||||
|
tool_responses: response_map,
|
||||||
|
tool_response_idx: std::sync::Mutex::new(HashMap::new()),
|
||||||
|
});
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route(
|
||||||
|
"/.well-known/oauth-protected-resource/mcp",
|
||||||
|
get(handle_protected_resource),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/.well-known/oauth-authorization-server",
|
||||||
|
get(handle_auth_server_metadata),
|
||||||
|
)
|
||||||
|
.route("/register", post(handle_register))
|
||||||
|
.route("/authorize", get(handle_authorize))
|
||||||
|
.route("/token", post(handle_token))
|
||||||
|
.route("/mcp", post(handle_mcp))
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
axum::serve(listener, app)
|
||||||
|
.with_graceful_shutdown(async {
|
||||||
|
let _ = shutdown_rx.await;
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("mock MCP server failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait briefly for the server to start accepting.
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||||
|
|
||||||
|
MockMcpServer {
|
||||||
|
base_url,
|
||||||
|
shutdown_tx: Some(shutdown_tx),
|
||||||
|
handle: Some(handle),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OAuth discovery endpoints ───────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn handle_protected_resource(State(state): State<Arc<MockState>>) -> impl IntoResponse {
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"resource": format!("{}/mcp", state.base_url),
|
||||||
|
"authorization_servers": [state.base_url],
|
||||||
|
"scopes_supported": ["read", "write"]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_auth_server_metadata(State(state): State<Arc<MockState>>) -> impl IntoResponse {
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"issuer": state.base_url,
|
||||||
|
"authorization_endpoint": format!("{}/authorize", state.base_url),
|
||||||
|
"token_endpoint": format!("{}/token", state.base_url),
|
||||||
|
"registration_endpoint": format!("{}/register", state.base_url),
|
||||||
|
"response_types_supported": ["code"],
|
||||||
|
"grant_types_supported": ["authorization_code"],
|
||||||
|
"code_challenge_methods_supported": ["S256"],
|
||||||
|
"scopes_supported": ["read", "write"]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OAuth DCR ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn handle_register() -> impl IntoResponse {
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"client_id": "mock-client-id",
|
||||||
|
"client_name": "ironclaw-test",
|
||||||
|
"redirect_uris": [],
|
||||||
|
"grant_types": ["authorization_code"],
|
||||||
|
"response_types": ["code"],
|
||||||
|
"token_endpoint_auth_method": "none"
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OAuth authorize (auto-approve) ──────────────────────────────────────
|
||||||
|
|
||||||
|
/// In a real flow, this would show a consent screen. For testing, we just
|
||||||
|
/// need the endpoint to exist. The test will bypass OAuth by injecting
|
||||||
|
/// tokens directly.
|
||||||
|
async fn handle_authorize() -> impl IntoResponse {
|
||||||
|
// Return a simple HTML page; in practice the test injects tokens directly.
|
||||||
|
axum::response::Html(
|
||||||
|
"<html><body>Mock OAuth: authorize endpoint. Tests bypass this.</body></html>",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OAuth token exchange ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn handle_token() -> impl IntoResponse {
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"access_token": "mock-access-token",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": 3600,
|
||||||
|
"refresh_token": "mock-refresh-token"
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MCP JSON-RPC endpoint ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct JsonRpcRequest {
|
||||||
|
jsonrpc: String,
|
||||||
|
id: Option<serde_json::Value>,
|
||||||
|
method: String,
|
||||||
|
#[serde(default)]
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_mcp(
|
||||||
|
State(state): State<Arc<MockState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(req): Json<JsonRpcRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
// Check for auth header.
|
||||||
|
let auth = headers
|
||||||
|
.get("authorization")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
if !auth.starts_with("Bearer ") || &auth[7..] != "mock-access-token" {
|
||||||
|
// Return 401 with WWW-Authenticate header per MCP OAuth spec.
|
||||||
|
let www_auth = format!(
|
||||||
|
"Bearer resource_metadata=\"{}/.well-known/oauth-protected-resource/mcp\"",
|
||||||
|
state.base_url
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
[("www-authenticate", www_auth.as_str())],
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": req.id,
|
||||||
|
"error": {"code": -32000, "message": "Unauthorized"}
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle notifications (no id) silently.
|
||||||
|
if req.id.is_none() {
|
||||||
|
return StatusCode::OK.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = match req.method.as_str() {
|
||||||
|
"initialize" => serde_json::json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": req.id,
|
||||||
|
"result": {
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"serverInfo": {
|
||||||
|
"name": "mock-mcp-server",
|
||||||
|
"version": "1.0.0"
|
||||||
|
},
|
||||||
|
"capabilities": {
|
||||||
|
"tools": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
"tools/list" => {
|
||||||
|
let tools: Vec<serde_json::Value> = state
|
||||||
|
.tools
|
||||||
|
.iter()
|
||||||
|
.map(|t| serde_json::to_value(t).unwrap())
|
||||||
|
.collect();
|
||||||
|
serde_json::json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": req.id,
|
||||||
|
"result": {
|
||||||
|
"tools": tools
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"tools/call" => {
|
||||||
|
let tool_name = req
|
||||||
|
.params
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.get("name"))
|
||||||
|
.and_then(|n| n.as_str())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
|
||||||
|
let content = {
|
||||||
|
let mut idx_map = state.tool_response_idx.lock().unwrap();
|
||||||
|
let idx = idx_map.entry(tool_name.to_string()).or_insert(0);
|
||||||
|
let responses = state.tool_responses.get(tool_name);
|
||||||
|
let result = responses
|
||||||
|
.and_then(|r| r.get(*idx))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| serde_json::json!({"error": "no mock response configured"}));
|
||||||
|
*idx += 1;
|
||||||
|
result
|
||||||
|
};
|
||||||
|
|
||||||
|
serde_json::json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": req.id,
|
||||||
|
"result": {
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": serde_json::to_string(&content).unwrap_or_default()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => serde_json::json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": req.id,
|
||||||
|
"error": {"code": -32601, "message": format!("Method not found: {}", req.method)}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
Json(response).into_response()
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ pub mod cleanup;
|
|||||||
pub mod gateway_workflow_harness;
|
pub mod gateway_workflow_harness;
|
||||||
pub mod instrumented_llm;
|
pub mod instrumented_llm;
|
||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
|
pub mod mock_mcp_server;
|
||||||
pub mod mock_openai_server;
|
pub mod mock_openai_server;
|
||||||
pub mod test_channel;
|
pub mod test_channel;
|
||||||
pub mod test_rig;
|
pub mod test_rig;
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ pub struct TestRig {
|
|||||||
/// The underlying TraceLlm for inspecting captured requests.
|
/// The underlying TraceLlm for inspecting captured requests.
|
||||||
#[cfg(feature = "libsql")]
|
#[cfg(feature = "libsql")]
|
||||||
trace_llm: Option<Arc<TraceLlm>>,
|
trace_llm: Option<Arc<TraceLlm>>,
|
||||||
|
/// Extension manager for direct extension operations in tests.
|
||||||
|
#[cfg(feature = "libsql")]
|
||||||
|
extension_manager: Option<Arc<ironclaw::extensions::ExtensionManager>>,
|
||||||
/// Temp directory guard -- keeps the libSQL database file alive.
|
/// Temp directory guard -- keeps the libSQL database file alive.
|
||||||
#[cfg(feature = "libsql")]
|
#[cfg(feature = "libsql")]
|
||||||
_temp_dir: tempfile::TempDir,
|
_temp_dir: tempfile::TempDir,
|
||||||
@@ -76,6 +79,11 @@ impl TestRig {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return the extension manager for direct extension operations in tests.
|
||||||
|
pub fn extension_manager(&self) -> Option<&Arc<ironclaw::extensions::ExtensionManager>> {
|
||||||
|
self.extension_manager.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
/// Wait until at least `n` responses have been captured, or `timeout` elapses.
|
/// Wait until at least `n` responses have been captured, or `timeout` elapses.
|
||||||
pub async fn wait_for_responses(&self, n: usize, timeout: Duration) -> Vec<OutgoingResponse> {
|
pub async fn wait_for_responses(&self, n: usize, timeout: Duration) -> Vec<OutgoingResponse> {
|
||||||
self.channel.wait_for_responses(n, timeout).await
|
self.channel.wait_for_responses(n, timeout).await
|
||||||
@@ -600,6 +608,7 @@ impl TestRigBuilder {
|
|||||||
// Save references for test accessors.
|
// Save references for test accessors.
|
||||||
let db_ref = components.db.clone().expect("test rig requires a database");
|
let db_ref = components.db.clone().expect("test rig requires a database");
|
||||||
let workspace_ref = components.workspace.clone();
|
let workspace_ref = components.workspace.clone();
|
||||||
|
let ext_mgr_ref = components.extension_manager.clone();
|
||||||
|
|
||||||
// 7. Construct AgentDeps from AppComponents (mirrors main.rs).
|
// 7. Construct AgentDeps from AppComponents (mirrors main.rs).
|
||||||
let deps = AgentDeps {
|
let deps = AgentDeps {
|
||||||
@@ -695,6 +704,7 @@ impl TestRigBuilder {
|
|||||||
db: db_ref,
|
db: db_ref,
|
||||||
workspace: workspace_ref,
|
workspace: workspace_ref,
|
||||||
trace_llm: trace_llm_ref,
|
trace_llm: trace_llm_ref,
|
||||||
|
extension_manager: ext_mgr_ref,
|
||||||
_temp_dir: temp_dir,
|
_temp_dir: temp_dir,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
[package]
|
||||||
|
name = "llm-context-tool"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Brave Search LLM Context tool for IronClaw (WASM component)"
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
wit-bindgen = "0.41.0"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = "s"
|
||||||
|
lto = true
|
||||||
|
strip = true
|
||||||
|
codegen-units = 1
|
||||||
|
|
||||||
|
[workspace]
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"version": "0.1.0",
|
||||||
|
"wit_version": "0.3.0",
|
||||||
|
"capabilities": {
|
||||||
|
"http": {
|
||||||
|
"allowlist": [
|
||||||
|
{
|
||||||
|
"host": "api.search.brave.com",
|
||||||
|
"path_prefix": "/res/v1/llm/context",
|
||||||
|
"methods": [
|
||||||
|
"POST"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"brave_api_key": {
|
||||||
|
"secret_name": "brave_api_key",
|
||||||
|
"location": {
|
||||||
|
"type": "header",
|
||||||
|
"name": "X-Subscription-Token"
|
||||||
|
},
|
||||||
|
"host_patterns": [
|
||||||
|
"api.search.brave.com"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rate_limit": {
|
||||||
|
"requests_per_minute": 30,
|
||||||
|
"requests_per_hour": 500
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"secrets": {
|
||||||
|
"allowed_names": [
|
||||||
|
"brave_api_key"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"secret_name": "brave_api_key",
|
||||||
|
"display_name": "Brave Search",
|
||||||
|
"instructions": "Get a free API key at brave.com/search/api/ (Free tier: 2,000 queries/month). Same key as Web Search.",
|
||||||
|
"setup_url": "https://brave.com/search/api/",
|
||||||
|
"env_var": "BRAVE_API_KEY"
|
||||||
|
},
|
||||||
|
"setup": {
|
||||||
|
"required_secrets": [
|
||||||
|
{
|
||||||
|
"name": "brave_api_key",
|
||||||
|
"prompt": "Brave Search API key (from brave.com/search/api)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user