mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e8c0fbed6 | ||
|
|
6aae1f8a9e | ||
|
|
7a9396f081 | ||
|
|
6116c885e3 | ||
|
+7 |
a677b20701 | ||
|
|
8c094aec63 |
@@ -29,36 +29,18 @@ jobs:
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
allowed_bots: "ironclaw-ci[bot]"
|
||||
claude_args: "--max-turns 50 --model claude-haiku-4-5-20251001 --allowedTools 'Read,Glob,Grep,Agent,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh search:*),Bash(git blame:*),Bash(git log:*),Bash(git diff:*)'"
|
||||
claude_args: "--max-turns 50 --model claude-haiku-4-5-20251001 --allowedTools 'Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh search:*),Bash(git blame:*),Bash(git log:*),Bash(git diff:*)'"
|
||||
prompt: |
|
||||
Code review this pull request. Follow these steps precisely:
|
||||
|
||||
1. Find relevant CLAUDE.md files: the root CLAUDE.md and any CLAUDE.md files
|
||||
in directories whose files this PR modifies. Use Glob to find them, then Read
|
||||
to load their contents.
|
||||
1. Use a Haiku agent to find relevant CLAUDE.md files: the root CLAUDE.md
|
||||
and any CLAUDE.md files in directories whose files this PR modifies.
|
||||
|
||||
2. Get the PR diff with `gh pr diff` and summarize the change.
|
||||
2. Use a Haiku agent to summarize the PR change (use `gh pr diff`).
|
||||
|
||||
3. Launch 4 parallel agents to review the change independently. Each agent should
|
||||
read the PR diff with `gh pr diff` and the full source files for changed
|
||||
code (using Read), then return a list of issues. Each agent MUST score its
|
||||
own findings inline using the severity and confidence rubric below.
|
||||
|
||||
Severity levels:
|
||||
- CRITICAL: security vulns, panics in prod (.unwrap/.expect), data exfiltration, race conditions
|
||||
- HIGH: logic bugs, missing error handling, breaking API/schema changes
|
||||
- MEDIUM: missing tests, unnecessary complexity, performance issues
|
||||
- LOW: documentation gaps, naming suggestions
|
||||
|
||||
Confidence scoring (0-100):
|
||||
0: False positive, doesn't stand up to scrutiny, or pre-existing issue.
|
||||
25: Might be real, but may be false positive. Stylistic issues not in CLAUDE.md.
|
||||
50: Real issue but nitpick or rare in practice. Not very important.
|
||||
75: Verified real issue, will be hit in practice. Directly impacts functionality
|
||||
or explicitly mentioned in CLAUDE.md.
|
||||
100: Certain, confirmed, will happen frequently. Evidence directly confirms.
|
||||
|
||||
Each agent returns findings as: [SEVERITY:CONFIDENCE] <brief description>
|
||||
code, then return a list of issues found:
|
||||
|
||||
Agent 1 — Security & Safety
|
||||
Check for: command injection, path traversal, SSRF, XSS, auth bypass,
|
||||
@@ -81,9 +63,22 @@ jobs:
|
||||
timeouts, resource leaks (file handles, connections), large allocations
|
||||
in hot paths.
|
||||
|
||||
4. Consolidate all agent findings and post exactly one comment on the PR
|
||||
using `gh pr comment` with this format. If no issues were found,
|
||||
post "No issues found." instead:
|
||||
4. For each issue found, launch a parallel Haiku agent to:
|
||||
a. Assign a severity:
|
||||
- CRITICAL: security vulns, panics in prod (.unwrap/.expect), data exfiltration, race conditions
|
||||
- HIGH: logic bugs, missing error handling, breaking API/schema changes
|
||||
- MEDIUM: missing tests, unnecessary complexity, performance issues
|
||||
- LOW: documentation gaps, naming suggestions
|
||||
b. Score confidence 0-100 (give this rubric verbatim):
|
||||
0: False positive, doesn't stand up to scrutiny, or pre-existing issue.
|
||||
25: Might be real, but may be false positive. Stylistic issues not in CLAUDE.md.
|
||||
50: Real issue but nitpick or rare in practice. Not very important.
|
||||
75: Verified real issue, will be hit in practice. Directly impacts functionality
|
||||
or explicitly mentioned in CLAUDE.md.
|
||||
100: Certain, confirmed, will happen frequently. Evidence directly confirms.
|
||||
|
||||
5. Post a single comment on the PR using `gh pr comment` with this format.
|
||||
If no issues were found, post "No issues found." instead:
|
||||
|
||||
### Code review
|
||||
|
||||
@@ -98,12 +93,8 @@ jobs:
|
||||
You MUST use the full git SHA in links (not HEAD or branch name).
|
||||
Provide 1 line of context before and after each linked range.
|
||||
|
||||
IMPORTANT rules:
|
||||
- Only YOU (the main process) may call `gh pr comment`. Agents must return
|
||||
their findings to you — they must NOT post comments themselves.
|
||||
- You MUST post exactly one `gh pr comment` before finishing, even if agents
|
||||
fail or return empty results. If review is incomplete, post "No issues found."
|
||||
- Use Read/Glob for file access, `gh` for GitHub interactions, not web fetch
|
||||
Notes:
|
||||
- Use `gh` for all GitHub interactions, not web fetch
|
||||
- Do NOT check build signal or attempt to build/test the code
|
||||
- Ignore pre-existing issues not introduced by this PR
|
||||
- Ignore issues a linter/compiler would catch (formatting, imports, types)
|
||||
|
||||
@@ -156,25 +156,19 @@ jobs:
|
||||
while IFS= read -r line; do
|
||||
sha256=$(echo "$line" | awk '{print $1}')
|
||||
filename=$(echo "$line" | awk '{print $2}')
|
||||
# Skip non-WASM entries (e.g. binary tarballs from cargo-dist)
|
||||
case "$filename" in *-wasm32-wasip2.tar.gz) ;; *) continue ;; esac
|
||||
# Parse kind-prefixed filename: "tool-slack-0.2.1-wasm32-wasip2.tar.gz"
|
||||
# → kind=tool, name=slack
|
||||
kind=$(echo "$filename" | cut -d'-' -f1)
|
||||
if [ "$kind" != "tool" ] && [ "$kind" != "channel" ]; then
|
||||
echo "::warning::Skipping '$filename': unrecognized kind prefix '$kind'"
|
||||
continue
|
||||
fi
|
||||
name=$(echo "$filename" | sed "s/^${kind}-//" | sed 's/-[0-9].*-wasm32-wasip2\.tar\.gz$//')
|
||||
# Strip -{version}-wasm32-wasip2.tar.gz to get the extension name.
|
||||
# Use '.*' (greedy) so pre-release suffixes like -alpha.1 are consumed too.
|
||||
name=$(echo "$filename" | sed 's/-[0-9].*-wasm32-wasip2\.tar\.gz$//')
|
||||
url="https://github.com/nearai/ironclaw/releases/download/${RELEASE_TAG}/${filename}"
|
||||
|
||||
manifest="registry/${kind}s/${name}.json"
|
||||
if [ -f "$manifest" ]; then
|
||||
jq --arg sha "$sha256" --arg url "$url" \
|
||||
'.artifacts["wasm32-wasip2"].sha256 = $sha | .artifacts["wasm32-wasip2"].url = $url' \
|
||||
"$manifest" > "${manifest}.tmp" && mv "${manifest}.tmp" "$manifest"
|
||||
echo "Patched $manifest with sha256=$sha256 url=$url"
|
||||
fi
|
||||
for manifest in registry/tools/${name}.json registry/channels/${name}.json; do
|
||||
if [ -f "$manifest" ]; then
|
||||
jq --arg sha "$sha256" --arg url "$url" \
|
||||
'.artifacts["wasm32-wasip2"].sha256 = $sha | .artifacts["wasm32-wasip2"].url = $url' \
|
||||
"$manifest" > "${manifest}.tmp" && mv "${manifest}.tmp" "$manifest"
|
||||
echo "Patched $manifest with sha256=$sha256 url=$url"
|
||||
fi
|
||||
done
|
||||
done < "$CHECKSUMS"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
@@ -282,14 +276,9 @@ jobs:
|
||||
[ -f "$manifest" ] || continue
|
||||
|
||||
# file_stem: JSON filename without extension (e.g. "slack" for slack.json).
|
||||
# Used for the bundle filename and CI manifest lookup, so patching always
|
||||
# finds the right file regardless of whether manifest.name matches the filename.
|
||||
file_stem=$(basename "$manifest" .json)
|
||||
# kind: "tool" or "channel" — used as bundle filename prefix to avoid
|
||||
# collisions when a tool and channel share the same file_stem (e.g. slack).
|
||||
kind=$(jq -r '.kind' "$manifest")
|
||||
if [ "$kind" != "tool" ] && [ "$kind" != "channel" ]; then
|
||||
echo "::error::Manifest '$manifest' has invalid or missing .kind ('$kind'); expected 'tool' or 'channel'"
|
||||
exit 1
|
||||
fi
|
||||
# ext_name: the manifest's .name field (e.g. "slack-tool").
|
||||
# Used for file names *inside* the archive — the installer extracts by manifest.name.
|
||||
ext_name=$(jq -r '.name' "$manifest")
|
||||
@@ -351,19 +340,18 @@ jobs:
|
||||
echo "::warning::No capabilities file at '$caps_path' for '$file_stem'"
|
||||
fi
|
||||
|
||||
# Bundle filename uses kind+file_stem to avoid collisions when a tool
|
||||
# and channel share the same name (e.g. tool-slack vs channel-slack).
|
||||
bundle_name="${kind}-${file_stem}-${ext_version}-wasm32-wasip2.tar.gz"
|
||||
bundle="target/wasm-bundles/${bundle_name}"
|
||||
# Bundle filename uses file_stem so CI patching can find the manifest by
|
||||
# filename (e.g. slack-0.1.0-wasm32-wasip2.tar.gz → registry/tools/slack.json).
|
||||
bundle="target/wasm-bundles/${file_stem}-${ext_version}-wasm32-wasip2.tar.gz"
|
||||
(cd target/wasm-bundles && if [ -f "${ext_name}.capabilities.json" ]; then
|
||||
tar czf "${bundle_name}" "${ext_name}.wasm" "${ext_name}.capabilities.json"
|
||||
tar czf "${file_stem}-${ext_version}-wasm32-wasip2.tar.gz" "${ext_name}.wasm" "${ext_name}.capabilities.json"
|
||||
else
|
||||
tar czf "${bundle_name}" "${ext_name}.wasm"
|
||||
tar czf "${file_stem}-${ext_version}-wasm32-wasip2.tar.gz" "${ext_name}.wasm"
|
||||
fi)
|
||||
|
||||
# Compute SHA256
|
||||
sha256=$(sha256sum "$bundle" | cut -d' ' -f1)
|
||||
echo "$sha256 ${bundle_name}" >> target/wasm-bundles/checksums.txt
|
||||
echo "$sha256 ${file_stem}-${ext_version}-wasm32-wasip2.tar.gz" >> target/wasm-bundles/checksums.txt
|
||||
|
||||
# Clean up intermediate files
|
||||
rm -f "target/wasm-bundles/${ext_name}.wasm" "target/wasm-bundles/${ext_name}.capabilities.json"
|
||||
@@ -486,25 +474,19 @@ jobs:
|
||||
while IFS= read -r line; do
|
||||
sha256=$(echo "$line" | awk '{print $1}')
|
||||
filename=$(echo "$line" | awk '{print $2}')
|
||||
# Skip non-WASM entries (defensive — this checksums.txt should only have WASM)
|
||||
case "$filename" in *-wasm32-wasip2.tar.gz) ;; *) continue ;; esac
|
||||
# Parse kind-prefixed filename: "tool-slack-0.2.1-wasm32-wasip2.tar.gz"
|
||||
# → kind=tool, name=slack
|
||||
kind=$(echo "$filename" | cut -d'-' -f1)
|
||||
if [ "$kind" != "tool" ] && [ "$kind" != "channel" ]; then
|
||||
echo "::warning::Skipping '$filename': unrecognized kind prefix '$kind'"
|
||||
continue
|
||||
fi
|
||||
name=$(echo "$filename" | sed "s/^${kind}-//" | sed 's/-[0-9].*-wasm32-wasip2\.tar\.gz$//')
|
||||
# Strip -{version}-wasm32-wasip2.tar.gz to get the extension name.
|
||||
# Use '.*' (greedy) so pre-release suffixes like -alpha.1 are consumed too.
|
||||
name=$(echo "$filename" | sed 's/-[0-9].*-wasm32-wasip2\.tar\.gz$//')
|
||||
url="https://github.com/nearai/ironclaw/releases/download/${RELEASE_TAG}/${filename}"
|
||||
|
||||
manifest="registry/${kind}s/${name}.json"
|
||||
if [ -f "$manifest" ]; then
|
||||
jq --arg sha "$sha256" --arg url "$url" \
|
||||
'.artifacts["wasm32-wasip2"].sha256 = $sha | .artifacts["wasm32-wasip2"].url = $url' \
|
||||
"$manifest" > "${manifest}.tmp" && mv "${manifest}.tmp" "$manifest"
|
||||
echo "Patched $manifest with sha256=$sha256 url=$url"
|
||||
fi
|
||||
for manifest in registry/tools/${name}.json registry/channels/${name}.json; do
|
||||
if [ -f "$manifest" ]; then
|
||||
jq --arg sha "$sha256" --arg url "$url" \
|
||||
'.artifacts["wasm32-wasip2"].sha256 = $sha | .artifacts["wasm32-wasip2"].url = $url' \
|
||||
"$manifest" > "${manifest}.tmp" && mv "${manifest}.tmp" "$manifest"
|
||||
echo "Patched $manifest with sha256=$sha256 url=$url"
|
||||
fi
|
||||
done
|
||||
done < "$CHECKSUMS"
|
||||
- name: Create PR with updated manifests
|
||||
run: |
|
||||
|
||||
@@ -406,10 +406,6 @@ jobs:
|
||||
echo "passed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Only merge PRs targeting main. Chained PRs (targeting another
|
||||
# promotion branch) stay open — when the base PR merges into main,
|
||||
# GitHub auto-retargets the chained PR. Merging chained PRs would
|
||||
# trigger delete_branch_on_merge, auto-closing downstream PRs.
|
||||
- name: Merge promotion PR
|
||||
id: merge
|
||||
if: steps.evaluate.outputs.passed == 'true'
|
||||
@@ -418,15 +414,12 @@ jobs:
|
||||
PR_NUMBER: ${{ needs.create-promotion-pr.outputs.pr_number }}
|
||||
run: |
|
||||
if [ -n "$PR_NUMBER" ]; then
|
||||
BASE=$(gh pr view "$PR_NUMBER" --json baseRefName --jq '.baseRefName')
|
||||
if [ "$BASE" = "main" ]; then
|
||||
echo "Merging promotion PR #${PR_NUMBER} (targets main)"
|
||||
gh pr merge "$PR_NUMBER" --merge
|
||||
echo "merged=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "PR #${PR_NUMBER} targets '${BASE}' (not main) — leaving open for chain resolution"
|
||||
echo "merged=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "Merging promotion PR #${PR_NUMBER}"
|
||||
# Do NOT use --delete-branch: deleting a promotion branch closes
|
||||
# any chained PRs that use it as their base (verified in ironclaw-ci-test).
|
||||
# Stale promotion branches are cleaned up separately.
|
||||
gh pr merge "$PR_NUMBER" --merge
|
||||
echo "merged=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# ── Update tested tag (always, so next batch covers only new commits) ──
|
||||
@@ -444,7 +437,7 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: staging
|
||||
fetch-depth: 0
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Update staging-tested tag
|
||||
run: |
|
||||
|
||||
@@ -42,8 +42,8 @@ jobs:
|
||||
telegram-tests:
|
||||
name: Telegram Channel Tests
|
||||
if: >
|
||||
github.event_name != 'pull_request' ||
|
||||
github.base_ref != 'staging'
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'pull_request' && github.base_ref != 'staging')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -57,8 +57,8 @@ jobs:
|
||||
windows-build:
|
||||
name: Windows Build (${{ matrix.name }})
|
||||
if: >
|
||||
github.event_name != 'pull_request' ||
|
||||
github.base_ref != 'staging'
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'pull_request' && github.base_ref != 'staging')
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -84,8 +84,8 @@ jobs:
|
||||
wasm-wit-compat:
|
||||
name: WASM WIT Compatibility
|
||||
if: >
|
||||
github.event_name != 'pull_request' ||
|
||||
github.base_ref != 'staging'
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'pull_request' && github.base_ref != 'staging')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -107,8 +107,8 @@ jobs:
|
||||
docker-build:
|
||||
name: Docker Build
|
||||
if: >
|
||||
github.event_name != 'pull_request' ||
|
||||
github.base_ref != 'staging'
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'pull_request' && github.base_ref != 'staging')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+1
-62
@@ -3350,7 +3350,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ironclaw"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"aho-corasick",
|
||||
@@ -3386,7 +3386,6 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"iana-time-zone",
|
||||
"insta",
|
||||
"json5",
|
||||
"libsql",
|
||||
"lru",
|
||||
"mime_guess",
|
||||
@@ -3522,17 +3521,6 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "json5"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_derive",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kuchikikiki"
|
||||
version = "0.9.2"
|
||||
@@ -4409,49 +4397,6 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pest"
|
||||
version = "2.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"ucd-trie",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pest_derive"
|
||||
version = "2.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_generator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pest_generator"
|
||||
version = "2.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_meta",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pest_meta"
|
||||
version = "2.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pgvector"
|
||||
version = "0.4.1"
|
||||
@@ -7100,12 +7045,6 @@ version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "ucd-trie"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.1.0"
|
||||
|
||||
+1
-5
@@ -19,7 +19,7 @@ exclude = [
|
||||
|
||||
[package]
|
||||
name = "ironclaw"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.92"
|
||||
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
||||
@@ -175,9 +175,6 @@ readabilityrs = { version = "0.1.2", optional = true }
|
||||
ed25519-dalek = { version = "2.2.0", features = ["std"] }
|
||||
hex = "0.4.3"
|
||||
|
||||
# OpenClaw import (feature gated)
|
||||
json5 = { version = "0.4", optional = true }
|
||||
|
||||
# macOS keychain
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
security-framework = "3"
|
||||
@@ -213,7 +210,6 @@ libsql = ["dep:libsql"]
|
||||
integration = []
|
||||
html-to-markdown = ["dep:html-to-markdown-rs", "dep:readabilityrs"]
|
||||
bedrock = ["dep:aws-config", "dep:aws-sdk-bedrockruntime", "dep:aws-smithy-types"]
|
||||
import = ["dep:json5", "libsql"]
|
||||
|
||||
[[test]]
|
||||
name = "html_to_markdown"
|
||||
|
||||
+1
-2
@@ -440,7 +440,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| `before_agent_start` hook | ✅ | ❌ | P2 | Model/provider override |
|
||||
| `before_message_write` hook | ✅ | ❌ | P2 | Pre-write interception |
|
||||
| `onMessage` hook | ✅ | ✅ | - | Routines with event trigger |
|
||||
| Structured system-event routines | ✅ | ✅ | P2 | `system_event` trigger + `event_emit` tool for event-driven automation |
|
||||
| `onSessionStart` hook | ✅ | ✅ | P2 | |
|
||||
| `onSessionEnd` hook | ✅ | ✅ | P2 | |
|
||||
| `transcribeAudio` hook | ✅ | ❌ | P3 | |
|
||||
@@ -559,7 +558,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
- ❌ Media handling (images, PDFs)
|
||||
- ✅ Ollama/local model support (via rig::providers::ollama)
|
||||
- ❌ Configuration hot-reload
|
||||
- ✅ Tool-driven webhook ingress (`/webhook/tools/{tool}` -> host-verified + tool-normalized `system_event` routines)
|
||||
- ❌ Webhook trigger endpoint in web gateway
|
||||
- ❌ Channel health monitor with auto-restart
|
||||
- ❌ Partial output preservation on abort
|
||||
|
||||
|
||||
@@ -16,8 +16,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> |
|
||||
<a href="README.zh-CN.md">简体中文</a> |
|
||||
<a href="README.ru.md">Русский</a>
|
||||
<a href="README.zh-CN.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
||||
-321
@@ -1,321 +0,0 @@
|
||||
<p align="center">
|
||||
<img src="ironclaw.png?v=2" alt="IronClaw" width="200"/>
|
||||
</p>
|
||||
|
||||
<h1 align="center">IronClaw</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Ваш защищенный персональный AI-ассистент, всегда на вашей стороне</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#license"><img src="https://img.shields.io/badge/license-MIT%20OR%20Apache%202.0-blue.svg" alt="Лицензия: MIT OR Apache-2.0" /></a>
|
||||
<a href="https://t.me/ironclawAI"><img src="https://img.shields.io/badge/Telegram-%40ironclawAI-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @ironclawAI" /></a>
|
||||
<a href="https://www.reddit.com/r/ironclawAI/"><img src="https://img.shields.io/badge/Reddit-r%2FironclawAI-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/ironclawAI" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> |
|
||||
<a href="README.zh-CN.md">简体中文</a> |
|
||||
<a href="README.ru.md">Русский</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#философия">Философия</a> •
|
||||
<a href="#возможности">Возможности</a> •
|
||||
<a href="#установка">Установка</a> •
|
||||
<a href="#конфигурация">Конфигурация</a> •
|
||||
<a href="#безопасность">Безопасность</a> •
|
||||
<a href="#архитектура">Архитектура</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Философия
|
||||
|
||||
IronClaw построен на простом принципе: **ваш AI-ассистент должен работать на вас, а не против вас**.
|
||||
|
||||
В мире, где системы ИИ становятся все более непрозрачными в вопросах обработки данных и ориентируются на корпоративные интересы, IronClaw выбирает другой путь:
|
||||
|
||||
- **Ваши данные остаются вашими** — вся информация хранится локально, зашифрована и никогда не покидает ваш контроль.
|
||||
- **Прозрачность по умолчанию** — открытый исходный код, возможность аудита, отсутствие скрытой телеметрии или сбора данных.
|
||||
- **Саморасширяемые возможности** — создавайте новые инструменты «на лету», не дожидаясь обновлений от вендора.
|
||||
- **Глубокая защита** — несколько уровней безопасности защищают от инъекций промптов и утечки данных.
|
||||
|
||||
IronClaw — это AI-ассистент, которому вы действительно можете доверять в личной и профессиональной жизни.
|
||||
|
||||
## Возможности
|
||||
|
||||
### Безопасность прежде всего
|
||||
|
||||
- **Песочница WASM** — непроверенные инструменты запускаются в изолированных контейнерах WebAssembly с правами на основе возможностей.
|
||||
- **Защита учетных данных** — секреты никогда не раскрываются инструментам; они внедряются на границе хоста с детектированием утечек.
|
||||
- **Защита от инъекций промптов** — обнаружение паттернов, очистка контента и применение политик безопасности.
|
||||
- **Список разрешенных эндпоинтов** — HTTP-запросы только к явно одобренным хостам и путям.
|
||||
|
||||
### Всегда доступен
|
||||
|
||||
- **Многоканальность** — REPL, HTTP-вебхуки, WASM-каналы (Telegram, Slack) и веб-шлюз.
|
||||
- **Песочница Docker** — изолированное выполнение контейнеров с токенами для каждого задания и паттерном «оркестратор/воркер».
|
||||
- **Веб-шлюз** — браузерный интерфейс с потоковой передачей данных в реальном времени через SSE/WebSocket.
|
||||
- **Рутины (Routines)** — расписания cron, триггеры событий, обработчики вебхуков для фоновой автоматизации.
|
||||
- **Система Heartbeat** — проактивное фоновое выполнение задач мониторинга и обслуживания.
|
||||
- **Параллельные задания** — одновременная обработка нескольких запросов с изолированными контекстами.
|
||||
- **Самовосстановление** — автоматическое обнаружение и восстановление зависших операций.
|
||||
|
||||
### Саморасширяемый
|
||||
|
||||
- **Динамическое создание инструментов** — опишите, что вам нужно, и IronClaw создаст это как инструмент WASM.
|
||||
- **Протокол MCP** — подключайтесь к серверам Model Context Protocol для получения дополнительных возможностей.
|
||||
- **Плагинная архитектура** — добавляйте новые инструменты WASM и каналы без перезагрузки системы.
|
||||
|
||||
### Постоянная память
|
||||
|
||||
- **Гибридный поиск** — полнотекстовый + векторный поиск с использованием Reciprocal Rank Fusion.
|
||||
- **Файловая система Workspace** — гибкое хранилище на основе путей для заметок, логов и контекста.
|
||||
- **Файлы идентичности (Identity Files)** — сохранение индивидуальности и предпочтений между сессиями.
|
||||
|
||||
## Установка
|
||||
|
||||
### Предварительные условия
|
||||
|
||||
- Rust 1.85+
|
||||
- PostgreSQL 15+ с расширением [pgvector](https://github.com/pgvector/pgvector)
|
||||
- Аккаунт NEAR AI (аутентификация через мастер настройки)
|
||||
|
||||
## Загрузка и сборка
|
||||
|
||||
Посетите [страницу релизов](https://github.com/nearai/ironclaw/releases/), чтобы увидеть последние обновления.
|
||||
|
||||
<details>
|
||||
<summary>Установка через установщик Windows (Windows)</summary>
|
||||
|
||||
Загрузите [Windows Installer](https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-x86_64-pc-windows-msvc.msi) и запустите его.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Установка через powershell-скрипт (Windows)</summary>
|
||||
|
||||
```sh
|
||||
irm https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.ps1 | iex
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Установка через shell-скрипт (macOS, Linux, Windows/WSL)</summary>
|
||||
|
||||
```sh
|
||||
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.sh | sh
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Установка через Homebrew (macOS/Linux)</summary>
|
||||
|
||||
```sh
|
||||
brew install ironclaw
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Компиляция из исходного кода (Cargo на Windows, Linux, macOS)</summary>
|
||||
|
||||
Для установки используйте `cargo`, предварительно убедившись, что у вас установлен [Rust](https://rustup.rs).
|
||||
|
||||
```bash
|
||||
# Клонируйте репозиторий
|
||||
git clone https://github.com/nearai/ironclaw.git
|
||||
cd ironclaw
|
||||
|
||||
# Сборка
|
||||
cargo build --release
|
||||
|
||||
# Запуск тестов
|
||||
cargo test
|
||||
```
|
||||
|
||||
Для **полного релиза** (после модификации исходников каналов) выполните `./scripts/build-all.sh`, чтобы сначала пересобрать каналы.
|
||||
|
||||
</details>
|
||||
|
||||
### Настройка базы данных
|
||||
|
||||
```bash
|
||||
# Создание базы данных
|
||||
createdb ironclaw
|
||||
|
||||
# Включение pgvector
|
||||
psql ironclaw -c "CREATE EXTENSION IF NOT EXISTS vector;"
|
||||
```
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Запустите мастер настройки для конфигурации IronClaw:
|
||||
|
||||
```bash
|
||||
ironclaw onboard
|
||||
```
|
||||
|
||||
Мастер настройки поможет установить соединение с базой данных, пройти аутентификацию NEAR AI (через браузер OAuth) и настроить шифрование секретов (используя системную связку ключей). Настройки сохраняются в базе данных; базовые переменные (например, `DATABASE_URL`, `LLM_BACKEND`) записываются в `~/.ironclaw/.env`, чтобы они были доступны до подключения к БД.
|
||||
|
||||
### Альтернативные LLM-провайдеры
|
||||
|
||||
IronClaw по умолчанию использует NEAR AI, но работает с любыми OpenAI-совместимыми эндпоинтами.
|
||||
Популярные варианты включают **OpenRouter** (300+ моделей), **Together AI**, **Fireworks AI**, **Ollama** (локально) и собственные серверы, такие как **vLLM** или **LiteLLM**.
|
||||
|
||||
Выберите *"OpenAI-compatible"* в мастере настройки или установите переменные окружения напрямую:
|
||||
|
||||
```env
|
||||
LLM_BACKEND=openai_compatible
|
||||
LLM_BASE_URL=https://openrouter.ai/api/v1
|
||||
LLM_API_KEY=sk-or-...
|
||||
LLM_MODEL=anthropic/claude-sonnet-4
|
||||
```
|
||||
|
||||
Смотрите [docs/LLM_PROVIDERS.md](docs/LLM_PROVIDERS.md) для получения полного руководства по провайдерам.
|
||||
|
||||
## Безопасность
|
||||
|
||||
IronClaw реализует эшелонированную защиту для обеспечения безопасности ваших данных и предотвращения злоупотреблений.
|
||||
|
||||
### Песочница WASM
|
||||
|
||||
Все непроверенные инструменты запускаются в изолированных контейнерах WebAssembly:
|
||||
|
||||
- **Права на основе возможностей** — явное разрешение на HTTP, доступ к секретам, вызов инструментов.
|
||||
- **Список разрешенных эндпоинтов** — HTTP-запросы только к одобренным хостам/путям.
|
||||
- **Внедрение учетных данных** — секреты внедряются на границе хоста и никогда не раскрываются коду WASM.
|
||||
- **Детектирование утечек** — сканирование запросов и ответов на попытки кражи секретов.
|
||||
- **Ограничение частоты запросов** — лимиты для каждого инструмента для предотвращения злоупотреблений.
|
||||
- **Лимиты ресурсов** — ограничения по памяти, процессору и времени выполнения.
|
||||
|
||||
```
|
||||
WASM ──► Валидатор ──► Сканер ───► Инъектор ──► Выполнение ──► Сканер ───► WASM
|
||||
хостов утечек секретов запроса утечек
|
||||
(запрос) (ответ)
|
||||
```
|
||||
|
||||
### Защита от инъекций промптов
|
||||
|
||||
Внешний контент проходит через несколько уровней безопасности:
|
||||
|
||||
- Обнаружение попыток инъекций на основе паттернов.
|
||||
- Очистка и экранирование контента.
|
||||
- Правила политик с уровнями серьезности (Блокировка/Предупреждение/Проверка/Очистка).
|
||||
- Обертывание вывода инструментов для безопасного внедрения в контекст LLM.
|
||||
|
||||
### Защита данных
|
||||
|
||||
- Все данные хранятся локально в вашей базе данных PostgreSQL.
|
||||
- Секреты зашифрованы с использованием AES-256-GCM.
|
||||
- Никакой телеметрии, аналитики или обмена данными.
|
||||
- Полный журнал аудита выполнения всех инструментов.
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ Каналы │
|
||||
│ ┌──────┐ ┌──────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ REPL │ │ HTTP │ │WASM-каналы │ │ Веб-шлюз │ │
|
||||
│ └──┬───┘ └──┬───┘ └──────┬──────┘ │ (SSE + WS) │ │
|
||||
│ │ │ │ └──────┬──────┘ │
|
||||
│ └─────────┴──────────────┴────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────▼─────────┐ │
|
||||
│ │ Цикл агента │ Маршрутизация │
|
||||
│ └────┬──────────┬───┘ намерений │
|
||||
│ │ │ │
|
||||
│ ┌──────────▼────┐ ┌──▼───────────────┐ │
|
||||
│ │ Планировщик │ │ Движок рутин │ │
|
||||
│ │ (пар. задачи) │ │(cron, соб., wh) │ │
|
||||
│ └──────┬────────┘ └────────┬─────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌─────────────┼────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌───▼─────┐ ┌────▼────────────────┐ │
|
||||
│ │ Локальн.│ │ Оркестратор │ │
|
||||
│ │ воркеры │ │ ┌───────────────┐ │ │
|
||||
│ │(in-proc)│ │ │ Песочница │ │ │
|
||||
│ └───┬─────┘ │ │ Docker │ │ │
|
||||
│ │ │ │ ┌───────────┐ │ │ │
|
||||
│ │ │ │ │Воркер / CC│ │ │ │
|
||||
│ │ │ │ └───────────┘ │ │ │
|
||||
│ │ │ └───────────────┘ │ │
|
||||
│ │ └─────────┬───────────┘ │
|
||||
│ └──────────────────┤ │
|
||||
│ │ │
|
||||
│ ┌───────────▼──────────┐ │
|
||||
│ │ Реестр инструментов │ │
|
||||
│ │ Встроенные, MCP, WASM│ │
|
||||
│ └──────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Основные компоненты
|
||||
|
||||
| Компонент | Назначение |
|
||||
|-----------|------------|
|
||||
| **Цикл агента** | Основная обработка сообщений и координация задач |
|
||||
| **Роутер** | Классификация намерений пользователя (команда, запрос, задача) |
|
||||
| **Планировщик** | Управление выполнением параллельных задач с приоритетами |
|
||||
| **Воркер** | Выполнение задач с рассуждениями LLM и вызовами инструментов |
|
||||
| **Оркестратор** | Жизненный цикл контейнеров, проксирование LLM, аутентификация для каждой задачи |
|
||||
| **Веб-шлюз** | Браузерный интерфейс (чат, память, задачи, логи, расширения, рутины) |
|
||||
| **Движок рутин** | Фоновые задачи: запланированные (cron) и реактивные (события, вебхуки) |
|
||||
| **Workspace** | Постоянная память с гибридным поиском |
|
||||
| **Слой безопасности** | Защита от инъекций промптов и очистка контента |
|
||||
|
||||
## Использование
|
||||
|
||||
```bash
|
||||
# Первоначальная настройка (БД, аутентификация и т.д.)
|
||||
ironclaw onboard
|
||||
|
||||
# Запуск интерактивного REPL
|
||||
cargo run
|
||||
|
||||
# С отладочными логами
|
||||
RUST_LOG=ironclaw=debug cargo run
|
||||
```
|
||||
|
||||
## Разработка
|
||||
|
||||
```bash
|
||||
# Форматирование кода
|
||||
cargo fmt
|
||||
|
||||
# Линтинг
|
||||
cargo clippy --all --benches --tests --examples --all-features
|
||||
|
||||
# Запуск тестов
|
||||
createdb ironclaw_test
|
||||
cargo test
|
||||
|
||||
# Запуск конкретного теста
|
||||
cargo test название_теста
|
||||
```
|
||||
|
||||
- **Telegram-канал**: Смотрите [docs/TELEGRAM_SETUP.md](docs/TELEGRAM_SETUP.md) для настройки и привязки аккаунта.
|
||||
- **Изменение исходников каналов**: Перед `cargo build` выполните `./channels-src/telegram/build.sh`, чтобы обновить встроенный WASM.
|
||||
|
||||
## Наследие OpenClaw
|
||||
|
||||
IronClaw — это реализация на Rust, вдохновленная проектом [OpenClaw](https://github.com/openclaw/openclaw). Полную матрицу соответствия функций можно найти в [FEATURE_PARITY.md](FEATURE_PARITY.md).
|
||||
|
||||
Ключевые отличия:
|
||||
|
||||
- **Rust vs TypeScript** — нативная производительность, безопасность памяти, один бинарный файл.
|
||||
- **Песочница WASM vs Docker** — легковесность, безопасность на основе возможностей.
|
||||
- **PostgreSQL vs SQLite** — надежное хранилище, готовое к продакшну.
|
||||
- **Безопасность прежде всего** — многослойная защита, сохранность учетных данных.
|
||||
|
||||
## Лицензия
|
||||
|
||||
Лицензировано по вашему выбору:
|
||||
|
||||
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE))
|
||||
- MIT License ([LICENSE-MIT](LICENSE-MIT))
|
||||
+2
-3
@@ -16,8 +16,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> |
|
||||
<a href="README.zh-CN.md">简体中文</a> |
|
||||
<a href="README.ru.md">Русский</a>
|
||||
<a href="README.zh-CN.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -230,7 +229,7 @@ WASM ──► 白名单 ──► 泄露扫描 ──► 凭据 ──► 执
|
||||
│ │ │ │
|
||||
│ ┌──────────▼────┐ ┌──▼───────────────┐ │
|
||||
│ │ 调度器 │ │ 定时任务引擎 │ │
|
||||
│ │ (并行任务) │ │(cron, 事件, Webhook)│ │
|
||||
│ │ (并行任务) │ │(cron, 事件, wh) │ │
|
||||
│ └──────┬────────┘ └────────┬─────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌─────────────┼────────────────────┘ │
|
||||
|
||||
@@ -20,8 +20,7 @@
|
||||
"optional": false
|
||||
}
|
||||
],
|
||||
"setup_url": "https://t.me/BotFather",
|
||||
"validation_endpoint": "https://api.telegram.org/bot{telegram_bot_token}/getMe"
|
||||
"setup_url": "https://t.me/BotFather"
|
||||
},
|
||||
"capabilities": {
|
||||
"http": {
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Test that kind-prefixed artifact filenames are parsed correctly into
|
||||
# manifest paths. Mirrors the parsing logic in release.yml.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
assert_parse() {
|
||||
local filename="$1" expected_kind="$2" expected_name="$3"
|
||||
local kind name manifest
|
||||
|
||||
kind=$(echo "$filename" | cut -d'-' -f1)
|
||||
name=$(echo "$filename" | sed "s/^${kind}-//" | sed 's/-[0-9].*-wasm32-wasip2\.tar\.gz$//')
|
||||
manifest="registry/${kind}s/${name}.json"
|
||||
|
||||
if [[ "$kind" != "$expected_kind" ]]; then
|
||||
echo "FAIL: $filename → kind=$kind, expected $expected_kind"
|
||||
FAIL=$((FAIL + 1))
|
||||
return
|
||||
fi
|
||||
if [[ "$name" != "$expected_name" ]]; then
|
||||
echo "FAIL: $filename → name=$name, expected $expected_name"
|
||||
FAIL=$((FAIL + 1))
|
||||
return
|
||||
fi
|
||||
echo "OK: $filename → $manifest"
|
||||
PASS=$((PASS + 1))
|
||||
}
|
||||
|
||||
# Tool and channel with same name must produce different manifest paths
|
||||
assert_parse "tool-slack-0.2.1-wasm32-wasip2.tar.gz" "tool" "slack"
|
||||
assert_parse "channel-slack-0.2.1-wasm32-wasip2.tar.gz" "channel" "slack"
|
||||
|
||||
# Same collision case for telegram
|
||||
assert_parse "tool-telegram-0.2.2-wasm32-wasip2.tar.gz" "tool" "telegram"
|
||||
assert_parse "channel-telegram-0.2.2-wasm32-wasip2.tar.gz" "channel" "telegram"
|
||||
|
||||
# Hyphenated extension names
|
||||
assert_parse "tool-web-search-0.2.0-wasm32-wasip2.tar.gz" "tool" "web-search"
|
||||
assert_parse "tool-google-calendar-0.1.0-wasm32-wasip2.tar.gz" "tool" "google-calendar"
|
||||
assert_parse "tool-google-docs-0.1.0-wasm32-wasip2.tar.gz" "tool" "google-docs"
|
||||
assert_parse "tool-google-drive-0.1.0-wasm32-wasip2.tar.gz" "tool" "google-drive"
|
||||
assert_parse "tool-google-sheets-0.1.0-wasm32-wasip2.tar.gz" "tool" "google-sheets"
|
||||
assert_parse "tool-google-slides-0.1.0-wasm32-wasip2.tar.gz" "tool" "google-slides"
|
||||
|
||||
# Simple names
|
||||
assert_parse "channel-discord-0.2.0-wasm32-wasip2.tar.gz" "channel" "discord"
|
||||
assert_parse "channel-whatsapp-0.1.0-wasm32-wasip2.tar.gz" "channel" "whatsapp"
|
||||
assert_parse "tool-github-0.2.0-wasm32-wasip2.tar.gz" "tool" "github"
|
||||
assert_parse "tool-gmail-0.1.0-wasm32-wasip2.tar.gz" "tool" "gmail"
|
||||
|
||||
# Pre-release versions
|
||||
assert_parse "tool-slack-0.2.1-alpha.1-wasm32-wasip2.tar.gz" "tool" "slack"
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]] || exit 1
|
||||
@@ -803,9 +803,7 @@ impl Agent {
|
||||
thread_id = %external_thread_id,
|
||||
"Hydrating thread from DB"
|
||||
);
|
||||
if let Some(rejection) = self.maybe_hydrate_thread(message, external_thread_id).await {
|
||||
return Ok(Some(format!("Error: {}", rejection)));
|
||||
}
|
||||
self.maybe_hydrate_thread(message, external_thread_id).await;
|
||||
}
|
||||
|
||||
// Resolve session and thread
|
||||
|
||||
+114
-214
@@ -23,14 +23,6 @@ use crate::error::Error;
|
||||
use crate::llm::{ChatMessage, ToolCall};
|
||||
use crate::tools::redact_params;
|
||||
|
||||
const FORGED_THREAD_ID_ERROR: &str = "Invalid or unauthorized thread ID.";
|
||||
|
||||
fn requires_preexisting_uuid_thread(channel: &str) -> bool {
|
||||
// Gateway-style channels send server-issued conversation UUIDs.
|
||||
// Unknown UUIDs should be rejected instead of silently creating a new thread.
|
||||
matches!(channel, "gateway" | "test")
|
||||
}
|
||||
|
||||
impl Agent {
|
||||
/// Hydrate a historical thread from DB into memory if not already present.
|
||||
///
|
||||
@@ -45,11 +37,11 @@ impl Agent {
|
||||
&self,
|
||||
message: &IncomingMessage,
|
||||
external_thread_id: &str,
|
||||
) -> Option<String> {
|
||||
) {
|
||||
// Only hydrate UUID-shaped thread IDs (web gateway uses UUIDs)
|
||||
let thread_uuid = match Uuid::parse_str(external_thread_id) {
|
||||
Ok(id) => id,
|
||||
Err(_) => return None,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
// Check if already in memory
|
||||
@@ -60,7 +52,7 @@ impl Agent {
|
||||
{
|
||||
let sess = session.lock().await;
|
||||
if sess.threads.contains_key(&thread_uuid) {
|
||||
return None;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,62 +61,6 @@ impl Agent {
|
||||
let msg_count;
|
||||
|
||||
if let Some(store) = self.store() {
|
||||
// Never hydrate history from a conversation UUID that isn't owned
|
||||
// by the current authenticated user.
|
||||
let owned = match store
|
||||
.conversation_belongs_to_user(thread_uuid, &message.user_id)
|
||||
.await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Failed to verify conversation ownership for hydration {}: {}",
|
||||
thread_uuid,
|
||||
e
|
||||
);
|
||||
if requires_preexisting_uuid_thread(&message.channel) {
|
||||
return Some(FORGED_THREAD_ID_ERROR.to_string());
|
||||
}
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if !owned {
|
||||
let exists = match store.get_conversation_metadata(thread_uuid).await {
|
||||
Ok(Some(_)) => true,
|
||||
Ok(None) => false,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Failed to inspect conversation metadata for hydration {}: {}",
|
||||
thread_uuid,
|
||||
e
|
||||
);
|
||||
if requires_preexisting_uuid_thread(&message.channel) {
|
||||
return Some(FORGED_THREAD_ID_ERROR.to_string());
|
||||
}
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
if requires_preexisting_uuid_thread(&message.channel) {
|
||||
tracing::warn!(
|
||||
user = %message.user_id,
|
||||
channel = %message.channel,
|
||||
thread_id = %thread_uuid,
|
||||
exists,
|
||||
"Rejected message for unavailable thread id"
|
||||
);
|
||||
return Some(FORGED_THREAD_ID_ERROR.to_string());
|
||||
}
|
||||
|
||||
tracing::warn!(
|
||||
user = %message.user_id,
|
||||
thread_id = %thread_uuid,
|
||||
exists,
|
||||
"Skipped hydration for thread id not owned by sender"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
let db_messages = store
|
||||
.list_conversation_messages(thread_uuid)
|
||||
.await
|
||||
@@ -168,8 +104,6 @@ impl Agent {
|
||||
thread_uuid,
|
||||
msg_count
|
||||
);
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub(super) async fn process_user_input(
|
||||
@@ -369,13 +303,8 @@ impl Agent {
|
||||
thread_id = %thread_id,
|
||||
"Persisting user message to DB"
|
||||
);
|
||||
self.persist_user_message(
|
||||
thread_id,
|
||||
&message.channel,
|
||||
&message.user_id,
|
||||
effective_content,
|
||||
)
|
||||
.await;
|
||||
self.persist_user_message(thread_id, &message.user_id, effective_content)
|
||||
.await;
|
||||
|
||||
tracing::debug!(
|
||||
message_id = %message.id,
|
||||
@@ -457,21 +386,10 @@ impl Agent {
|
||||
.await;
|
||||
|
||||
// Persist tool calls then assistant response (user message already persisted at turn start)
|
||||
self.persist_tool_calls(
|
||||
thread_id,
|
||||
&message.channel,
|
||||
&message.user_id,
|
||||
turn_number,
|
||||
&tool_calls,
|
||||
)
|
||||
.await;
|
||||
self.persist_assistant_response(
|
||||
thread_id,
|
||||
&message.channel,
|
||||
&message.user_id,
|
||||
&response,
|
||||
)
|
||||
.await;
|
||||
self.persist_tool_calls(thread_id, &message.user_id, turn_number, &tool_calls)
|
||||
.await;
|
||||
self.persist_assistant_response(thread_id, &message.user_id, &response)
|
||||
.await;
|
||||
|
||||
Ok(SubmissionResult::response(response))
|
||||
}
|
||||
@@ -505,41 +423,6 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure a thread UUID is writable for `(channel, user_id)`.
|
||||
///
|
||||
/// Returns `false` for foreign/unowned conversation IDs or DB errors.
|
||||
async fn ensure_writable_conversation(
|
||||
&self,
|
||||
store: &Arc<dyn crate::db::Database>,
|
||||
thread_id: Uuid,
|
||||
channel: &str,
|
||||
user_id: &str,
|
||||
) -> bool {
|
||||
match store
|
||||
.ensure_conversation(thread_id, channel, user_id, None)
|
||||
.await
|
||||
{
|
||||
Ok(true) => true,
|
||||
Ok(false) => {
|
||||
tracing::warn!(
|
||||
user = %user_id,
|
||||
channel = %channel,
|
||||
thread_id = %thread_id,
|
||||
"Rejected write for unavailable thread id"
|
||||
);
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Failed to ensure writable conversation {}: {}",
|
||||
thread_id,
|
||||
e
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the user message to the DB at turn start (before the agentic loop).
|
||||
///
|
||||
/// This ensures the user message is durable even if the process crashes
|
||||
@@ -547,7 +430,6 @@ impl Agent {
|
||||
pub(super) async fn persist_user_message(
|
||||
&self,
|
||||
thread_id: Uuid,
|
||||
channel: &str,
|
||||
user_id: &str,
|
||||
user_input: &str,
|
||||
) {
|
||||
@@ -556,10 +438,11 @@ impl Agent {
|
||||
None => return,
|
||||
};
|
||||
|
||||
if !self
|
||||
.ensure_writable_conversation(&store, thread_id, channel, user_id)
|
||||
if let Err(e) = store
|
||||
.ensure_conversation(thread_id, "gateway", user_id, None)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to ensure conversation {}: {}", thread_id, e);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -579,7 +462,6 @@ impl Agent {
|
||||
pub(super) async fn persist_assistant_response(
|
||||
&self,
|
||||
thread_id: Uuid,
|
||||
channel: &str,
|
||||
user_id: &str,
|
||||
response: &str,
|
||||
) {
|
||||
@@ -588,10 +470,11 @@ impl Agent {
|
||||
None => return,
|
||||
};
|
||||
|
||||
if !self
|
||||
.ensure_writable_conversation(&store, thread_id, channel, user_id)
|
||||
if let Err(e) = store
|
||||
.ensure_conversation(thread_id, "gateway", user_id, None)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to ensure conversation {}: {}", thread_id, e);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -611,7 +494,6 @@ impl Agent {
|
||||
pub(super) async fn persist_tool_calls(
|
||||
&self,
|
||||
thread_id: Uuid,
|
||||
channel: &str,
|
||||
user_id: &str,
|
||||
turn_number: usize,
|
||||
tool_calls: &[crate::agent::session::TurnToolCall],
|
||||
@@ -661,10 +543,11 @@ impl Agent {
|
||||
}
|
||||
};
|
||||
|
||||
if !self
|
||||
.ensure_writable_conversation(&store, thread_id, channel, user_id)
|
||||
if let Err(e) = store
|
||||
.ensure_conversation(thread_id, "gateway", user_id, None)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to ensure conversation {}: {}", thread_id, e);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1042,20 +925,14 @@ impl Agent {
|
||||
|
||||
for (idx, tc) in deferred_tool_calls.iter().enumerate() {
|
||||
if let Some(tool) = self.tools().get(&tc.name).await {
|
||||
// Match dispatcher.rs: when auto_approve_tools is true, skip
|
||||
// all approval checks (including ApprovalRequirement::Always).
|
||||
let needs_approval = if self.config.auto_approve_tools {
|
||||
false
|
||||
} else {
|
||||
use crate::tools::ApprovalRequirement;
|
||||
match tool.requires_approval(&tc.arguments) {
|
||||
ApprovalRequirement::Never => false,
|
||||
ApprovalRequirement::UnlessAutoApproved => {
|
||||
let sess = session.lock().await;
|
||||
!sess.is_tool_auto_approved(&tc.name)
|
||||
}
|
||||
ApprovalRequirement::Always => true,
|
||||
use crate::tools::ApprovalRequirement;
|
||||
let needs_approval = match tool.requires_approval(&tc.arguments) {
|
||||
ApprovalRequirement::Never => false,
|
||||
ApprovalRequirement::UnlessAutoApproved => {
|
||||
let sess = session.lock().await;
|
||||
!sess.is_tool_auto_approved(&tc.name)
|
||||
}
|
||||
ApprovalRequirement::Always => true,
|
||||
};
|
||||
|
||||
if needs_approval {
|
||||
@@ -1331,21 +1208,10 @@ impl Agent {
|
||||
.map(|t| (t.turn_number, t.tool_calls.clone()))
|
||||
.unwrap_or_default();
|
||||
// User message already persisted at turn start; save tool calls then assistant response
|
||||
self.persist_tool_calls(
|
||||
thread_id,
|
||||
&message.channel,
|
||||
&message.user_id,
|
||||
turn_number,
|
||||
&tool_calls,
|
||||
)
|
||||
.await;
|
||||
self.persist_assistant_response(
|
||||
thread_id,
|
||||
&message.channel,
|
||||
&message.user_id,
|
||||
&response,
|
||||
)
|
||||
.await;
|
||||
self.persist_tool_calls(thread_id, &message.user_id, turn_number, &tool_calls)
|
||||
.await;
|
||||
self.persist_assistant_response(thread_id, &message.user_id, &response)
|
||||
.await;
|
||||
let _ = self
|
||||
.channels
|
||||
.send_status(
|
||||
@@ -1398,13 +1264,8 @@ impl Agent {
|
||||
thread.clear_pending_approval();
|
||||
thread.complete_turn(&rejection);
|
||||
// User message already persisted at turn start; save rejection response
|
||||
self.persist_assistant_response(
|
||||
thread_id,
|
||||
&message.channel,
|
||||
&message.user_id,
|
||||
&rejection,
|
||||
)
|
||||
.await;
|
||||
self.persist_assistant_response(thread_id, &message.user_id, &rejection)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1442,13 +1303,8 @@ impl Agent {
|
||||
thread.enter_auth_mode(ext_name.clone());
|
||||
thread.complete_turn(&instructions);
|
||||
// User message already persisted at turn start; save auth instructions
|
||||
self.persist_assistant_response(
|
||||
thread_id,
|
||||
&message.channel,
|
||||
&message.user_id,
|
||||
&instructions,
|
||||
)
|
||||
.await;
|
||||
self.persist_assistant_response(thread_id, &message.user_id, &instructions)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
let _ = self
|
||||
@@ -1493,56 +1349,100 @@ impl Agent {
|
||||
None => return Ok(Some("Extension manager not available.".to_string())),
|
||||
};
|
||||
|
||||
match ext_mgr
|
||||
.configure_token(&pending.extension_name, token)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
match ext_mgr.auth(&pending.extension_name, Some(token)).await {
|
||||
Ok(result) if result.is_authenticated() => {
|
||||
tracing::info!(
|
||||
"Extension '{}' configured via auth mode: {}",
|
||||
pending.extension_name,
|
||||
result.message
|
||||
"Extension '{}' authenticated via auth mode",
|
||||
pending.extension_name
|
||||
);
|
||||
|
||||
// Auto-activate so tools are available immediately after auth
|
||||
match ext_mgr.activate(&pending.extension_name).await {
|
||||
Ok(activate_result) => {
|
||||
let tool_count = activate_result.tools_loaded.len();
|
||||
let tool_list = if activate_result.tools_loaded.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("\n\nTools: {}", activate_result.tools_loaded.join(", "))
|
||||
};
|
||||
let msg = format!(
|
||||
"{} authenticated and activated ({} tools loaded).{}",
|
||||
pending.extension_name, tool_count, tool_list
|
||||
);
|
||||
let _ = self
|
||||
.channels
|
||||
.send_status(
|
||||
&message.channel,
|
||||
StatusUpdate::AuthCompleted {
|
||||
extension_name: pending.extension_name.clone(),
|
||||
success: true,
|
||||
message: msg.clone(),
|
||||
},
|
||||
&message.metadata,
|
||||
)
|
||||
.await;
|
||||
Ok(Some(msg))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Extension '{}' authenticated but activation failed: {}",
|
||||
pending.extension_name,
|
||||
e
|
||||
);
|
||||
let msg = format!(
|
||||
"{} authenticated successfully, but activation failed: {}. \
|
||||
Try activating manually.",
|
||||
pending.extension_name, e
|
||||
);
|
||||
let _ = self
|
||||
.channels
|
||||
.send_status(
|
||||
&message.channel,
|
||||
StatusUpdate::AuthCompleted {
|
||||
extension_name: pending.extension_name.clone(),
|
||||
success: true,
|
||||
message: msg.clone(),
|
||||
},
|
||||
&message.metadata,
|
||||
)
|
||||
.await;
|
||||
Ok(Some(msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(result) => {
|
||||
// Invalid token, re-enter auth mode
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
||||
thread.enter_auth_mode(pending.extension_name.clone());
|
||||
}
|
||||
}
|
||||
let msg = result
|
||||
.instructions()
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| "Invalid token. Please try again.".to_string());
|
||||
// Re-emit AuthRequired so web UI re-shows the card
|
||||
let _ = self
|
||||
.channels
|
||||
.send_status(
|
||||
&message.channel,
|
||||
StatusUpdate::AuthCompleted {
|
||||
StatusUpdate::AuthRequired {
|
||||
extension_name: pending.extension_name.clone(),
|
||||
success: true,
|
||||
message: result.message.clone(),
|
||||
instructions: Some(msg.clone()),
|
||||
auth_url: result.auth_url().map(String::from),
|
||||
setup_url: result.setup_url().map(String::from),
|
||||
},
|
||||
&message.metadata,
|
||||
)
|
||||
.await;
|
||||
Ok(Some(result.message))
|
||||
Ok(Some(msg))
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
// Token validation errors: re-enter auth mode and re-prompt
|
||||
if matches!(e, crate::extensions::ExtensionError::ValidationFailed(_)) {
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
||||
thread.enter_auth_mode(pending.extension_name.clone());
|
||||
}
|
||||
}
|
||||
let _ = self
|
||||
.channels
|
||||
.send_status(
|
||||
&message.channel,
|
||||
StatusUpdate::AuthRequired {
|
||||
extension_name: pending.extension_name.clone(),
|
||||
instructions: Some(msg.clone()),
|
||||
auth_url: None,
|
||||
setup_url: None,
|
||||
},
|
||||
&message.metadata,
|
||||
)
|
||||
.await;
|
||||
return Ok(Some(msg));
|
||||
}
|
||||
// Infrastructure errors
|
||||
let msg = format!(
|
||||
"Authentication failed for {}: {}",
|
||||
pending.extension_name, e
|
||||
);
|
||||
let _ = self
|
||||
.channels
|
||||
.send_status(
|
||||
|
||||
+1
-13
@@ -563,19 +563,7 @@ impl AppBuilder {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if matches!(
|
||||
e,
|
||||
crate::tools::mcp::config::ConfigError::InvalidConfig { .. }
|
||||
| crate::tools::mcp::config::ConfigError::Json(_)
|
||||
) {
|
||||
tracing::warn!(
|
||||
"MCP server configuration is invalid: {}. \
|
||||
Fix or remove the corrupted config.",
|
||||
e
|
||||
);
|
||||
} else {
|
||||
tracing::debug!("No MCP servers configured ({})", e);
|
||||
}
|
||||
tracing::debug!("No MCP servers configured ({})", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,34 +106,6 @@ pub fn verify_slack_signature(
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Verify raw-body HMAC-SHA256 signature with a configurable prefix.
|
||||
///
|
||||
/// Computes `HMAC-SHA256(secret, body)` and compares against
|
||||
/// `prefix + hex_digest` in constant time.
|
||||
pub fn verify_hmac_sha256_prefixed(
|
||||
secret: &str,
|
||||
body: &[u8],
|
||||
signature_header: &str,
|
||||
prefix: &str,
|
||||
) -> bool {
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
let mut mac = match Hmac::<Sha256>::new_from_slice(secret.as_bytes()) {
|
||||
Ok(m) => m,
|
||||
Err(_) => return false,
|
||||
};
|
||||
mac.update(body);
|
||||
let computed = mac.finalize().into_bytes();
|
||||
let computed_hex = hex::encode(computed);
|
||||
let expected = format!("{prefix}{computed_hex}");
|
||||
expected
|
||||
.as_bytes()
|
||||
.ct_eq(signature_header.as_bytes())
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -526,24 +498,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hmac_sha256_prefixed_valid() {
|
||||
let secret = "github-secret";
|
||||
let body = br#"{"action":"opened"}"#;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("hmac key");
|
||||
mac.update(body);
|
||||
let sig = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
|
||||
assert!(verify_hmac_sha256_prefixed(secret, body, &sig, "sha256="));
|
||||
assert!(!verify_hmac_sha256_prefixed(
|
||||
secret,
|
||||
body,
|
||||
"sha256=deadbeef",
|
||||
"sha256="
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slack_stale_timestamp_rejected() {
|
||||
let signing_secret = "my-signing-secret";
|
||||
|
||||
@@ -197,7 +197,7 @@ All responses include:
|
||||
- `X-Content-Type-Options: nosniff`
|
||||
- `X-Frame-Options: DENY`
|
||||
|
||||
**Request body limit:** 10 MB (`DefaultBodyLimit::max(10 * 1024 * 1024)`), sized for image uploads (#725). Larger payloads return 413.
|
||||
**Request body limit:** 1 MB (`DefaultBodyLimit::max(1024 * 1024)`). Larger payloads return 413.
|
||||
|
||||
## Pending Approvals
|
||||
|
||||
|
||||
@@ -145,33 +145,49 @@ pub async fn chat_auth_token_handler(
|
||||
"Extension manager not available".to_string(),
|
||||
))?;
|
||||
|
||||
match ext_mgr
|
||||
.configure_token(&req.extension_name, &req.token)
|
||||
let result = ext_mgr
|
||||
.auth(&req.extension_name, Some(&req.token))
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
clear_auth_mode(&state).await;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
state.sse.broadcast(SseEvent::AuthCompleted {
|
||||
extension_name: req.extension_name.clone(),
|
||||
success: true,
|
||||
message: result.message.clone(),
|
||||
});
|
||||
if result.is_authenticated() {
|
||||
// Auto-activate so tools are available immediately
|
||||
let msg = match ext_mgr.activate(&req.extension_name).await {
|
||||
Ok(r) => format!(
|
||||
"{} authenticated ({} tools loaded)",
|
||||
req.extension_name,
|
||||
r.tools_loaded.len()
|
||||
),
|
||||
Err(e) => format!(
|
||||
"{} authenticated but activation failed: {}",
|
||||
req.extension_name, e
|
||||
),
|
||||
};
|
||||
|
||||
Ok(Json(ActionResponse::ok(result.message)))
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
if matches!(e, crate::extensions::ExtensionError::ValidationFailed(_)) {
|
||||
state.sse.broadcast(SseEvent::AuthRequired {
|
||||
extension_name: req.extension_name.clone(),
|
||||
instructions: Some(msg.clone()),
|
||||
auth_url: None,
|
||||
setup_url: None,
|
||||
});
|
||||
}
|
||||
Ok(Json(ActionResponse::fail(msg)))
|
||||
}
|
||||
// Clear auth mode on the active thread
|
||||
clear_auth_mode(&state).await;
|
||||
|
||||
state.sse.broadcast(SseEvent::AuthCompleted {
|
||||
extension_name: req.extension_name,
|
||||
success: true,
|
||||
message: msg.clone(),
|
||||
});
|
||||
|
||||
Ok(Json(ActionResponse::ok(msg)))
|
||||
} else {
|
||||
// Re-emit auth_required for retry
|
||||
state.sse.broadcast(SseEvent::AuthRequired {
|
||||
extension_name: req.extension_name.clone(),
|
||||
instructions: result.instructions().map(String::from),
|
||||
auth_url: result.auth_url().map(String::from),
|
||||
setup_url: result.setup_url().map(String::from),
|
||||
});
|
||||
Ok(Json(ActionResponse::fail(
|
||||
result
|
||||
.instructions()
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| "Invalid token".to_string()),
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,17 +550,11 @@ pub async fn chat_new_thread_handler(
|
||||
// Persist the empty conversation row with thread_type metadata synchronously
|
||||
// so that the subsequent loadThreads() call from the frontend sees it.
|
||||
if let Some(ref store) = state.store {
|
||||
match store
|
||||
if let Err(e) = store
|
||||
.ensure_conversation(thread_id, "gateway", &state.user_id, None)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {}
|
||||
Ok(false) => tracing::warn!(
|
||||
user = %state.user_id,
|
||||
thread_id = %thread_id,
|
||||
"Skipped persisting new thread due to ownership/channel conflict"
|
||||
),
|
||||
Err(e) => tracing::warn!("Failed to persist new thread: {}", e),
|
||||
tracing::warn!("Failed to persist new thread: {}", e);
|
||||
}
|
||||
let metadata_val = serde_json::json!("thread");
|
||||
if let Err(e) = store
|
||||
|
||||
@@ -244,12 +244,6 @@ impl GatewayChannel {
|
||||
self
|
||||
}
|
||||
|
||||
/// Inject a shared routine engine slot used by other HTTP ingress paths.
|
||||
pub fn with_routine_engine_slot(mut self, slot: server::RoutineEngineSlot) -> Self {
|
||||
self.rebuild_state(|s| s.routine_engine = slot);
|
||||
self
|
||||
}
|
||||
|
||||
/// Get the auth token (for printing to console on startup).
|
||||
pub fn auth_token(&self) -> &str {
|
||||
&self.auth_token
|
||||
|
||||
+48
-84
@@ -318,11 +318,7 @@ pub async fn start_server(
|
||||
.route("/", get(index_handler))
|
||||
.route("/style.css", get(css_handler))
|
||||
.route("/app.js", get(js_handler))
|
||||
.route("/favicon.ico", get(favicon_handler))
|
||||
.route("/i18n/index.js", get(i18n_index_handler))
|
||||
.route("/i18n/en.js", get(i18n_en_handler))
|
||||
.route("/i18n/zh-CN.js", get(i18n_zh_handler))
|
||||
.route("/i18n-app.js", get(i18n_app_handler));
|
||||
.route("/favicon.ico", get(favicon_handler));
|
||||
|
||||
// Project file serving (behind auth to prevent unauthorized file access).
|
||||
let projects = Router::new()
|
||||
@@ -434,46 +430,6 @@ async fn favicon_handler() -> impl IntoResponse {
|
||||
)
|
||||
}
|
||||
|
||||
async fn i18n_index_handler() -> impl IntoResponse {
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, "application/javascript"),
|
||||
(header::CACHE_CONTROL, "no-cache"),
|
||||
],
|
||||
include_str!("static/i18n/index.js"),
|
||||
)
|
||||
}
|
||||
|
||||
async fn i18n_en_handler() -> impl IntoResponse {
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, "application/javascript"),
|
||||
(header::CACHE_CONTROL, "no-cache"),
|
||||
],
|
||||
include_str!("static/i18n/en.js"),
|
||||
)
|
||||
}
|
||||
|
||||
async fn i18n_zh_handler() -> impl IntoResponse {
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, "application/javascript"),
|
||||
(header::CACHE_CONTROL, "no-cache"),
|
||||
],
|
||||
include_str!("static/i18n/zh-CN.js"),
|
||||
)
|
||||
}
|
||||
|
||||
async fn i18n_app_handler() -> impl IntoResponse {
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, "application/javascript"),
|
||||
(header::CACHE_CONTROL, "no-cache"),
|
||||
],
|
||||
include_str!("static/i18n-app.js"),
|
||||
)
|
||||
}
|
||||
|
||||
// --- Health ---
|
||||
|
||||
async fn health_handler() -> Json<HealthResponse> {
|
||||
@@ -1062,35 +1018,49 @@ async fn chat_auth_token_handler(
|
||||
"Extension manager not available".to_string(),
|
||||
))?;
|
||||
|
||||
match ext_mgr
|
||||
.configure_token(&req.extension_name, &req.token)
|
||||
let result = ext_mgr
|
||||
.auth(&req.extension_name, Some(&req.token))
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
// Clear auth mode on the active thread
|
||||
clear_auth_mode(&state).await;
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
state.sse.broadcast(SseEvent::AuthCompleted {
|
||||
extension_name: req.extension_name.clone(),
|
||||
success: true,
|
||||
message: result.message.clone(),
|
||||
});
|
||||
if result.is_authenticated() {
|
||||
// Auto-activate so tools are available immediately
|
||||
let msg = match ext_mgr.activate(&req.extension_name).await {
|
||||
Ok(r) => format!(
|
||||
"{} authenticated ({} tools loaded)",
|
||||
req.extension_name,
|
||||
r.tools_loaded.len()
|
||||
),
|
||||
Err(e) => format!(
|
||||
"{} authenticated but activation failed: {}",
|
||||
req.extension_name, e
|
||||
),
|
||||
};
|
||||
|
||||
Ok(Json(ActionResponse::ok(result.message)))
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
// Re-emit auth_required for retry on validation errors
|
||||
if matches!(e, crate::extensions::ExtensionError::ValidationFailed(_)) {
|
||||
state.sse.broadcast(SseEvent::AuthRequired {
|
||||
extension_name: req.extension_name.clone(),
|
||||
instructions: Some(msg.clone()),
|
||||
auth_url: None,
|
||||
setup_url: None,
|
||||
});
|
||||
}
|
||||
Ok(Json(ActionResponse::fail(msg)))
|
||||
}
|
||||
// Clear auth mode on the active thread
|
||||
clear_auth_mode(&state).await;
|
||||
|
||||
state.sse.broadcast(SseEvent::AuthCompleted {
|
||||
extension_name: req.extension_name,
|
||||
success: true,
|
||||
message: msg.clone(),
|
||||
});
|
||||
|
||||
Ok(Json(ActionResponse::ok(msg)))
|
||||
} else {
|
||||
// Re-emit auth_required for retry
|
||||
state.sse.broadcast(SseEvent::AuthRequired {
|
||||
extension_name: req.extension_name.clone(),
|
||||
instructions: result.instructions().map(String::from),
|
||||
auth_url: result.auth_url().map(String::from),
|
||||
setup_url: result.setup_url().map(String::from),
|
||||
});
|
||||
Ok(Json(ActionResponse::fail(
|
||||
result
|
||||
.instructions()
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| "Invalid token".to_string()),
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1448,17 +1418,11 @@ async fn chat_new_thread_handler(
|
||||
// Persist the empty conversation row with thread_type metadata synchronously
|
||||
// so that the subsequent loadThreads() call from the frontend sees it.
|
||||
if let Some(ref store) = state.store {
|
||||
match store
|
||||
if let Err(e) = store
|
||||
.ensure_conversation(thread_id, "gateway", &state.user_id, None)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {}
|
||||
Ok(false) => tracing::warn!(
|
||||
user = %state.user_id,
|
||||
thread_id = %thread_id,
|
||||
"Skipped persisting new thread due to ownership/channel conflict"
|
||||
),
|
||||
Err(e) => tracing::warn!("Failed to persist new thread: {}", e),
|
||||
tracing::warn!("Failed to persist new thread: {}", e);
|
||||
}
|
||||
let metadata_val = serde_json::json!("thread");
|
||||
if let Err(e) = store
|
||||
@@ -1845,7 +1809,7 @@ async fn extensions_install_handler(
|
||||
// expansion and for first-time auth when credentials are already
|
||||
// configured (e.g., built-in providers). We only surface an auth_url
|
||||
// when the extension reports it is awaiting authorization.
|
||||
match ext_mgr.auth(&req.name).await {
|
||||
match ext_mgr.auth(&req.name, None).await {
|
||||
Ok(auth_result) if auth_result.auth_url().is_some() => {
|
||||
// Scope expansion or initial OAuth: user needs to authorize
|
||||
resp.auth_url = auth_result.auth_url().map(String::from);
|
||||
@@ -1874,9 +1838,9 @@ async fn extensions_activate_handler(
|
||||
// Activation loaded the WASM module. Check if the tool needs
|
||||
// OAuth scope expansion (e.g., adding google-docs when gmail
|
||||
// already has a token but missing the documents scope).
|
||||
// Initial OAuth setup is triggered via configure.
|
||||
// Initial OAuth setup is triggered via save_setup_secrets.
|
||||
let mut resp = ActionResponse::ok(result.message);
|
||||
if let Ok(auth_result) = ext_mgr.auth(&name).await
|
||||
if let Ok(auth_result) = ext_mgr.auth(&name, None).await
|
||||
&& auth_result.auth_url().is_some()
|
||||
{
|
||||
resp.auth_url = auth_result.auth_url().map(String::from);
|
||||
@@ -1894,7 +1858,7 @@ async fn extensions_activate_handler(
|
||||
}
|
||||
|
||||
// Activation failed due to auth; try authenticating first.
|
||||
match ext_mgr.auth(&name).await {
|
||||
match ext_mgr.auth(&name, None).await {
|
||||
Ok(auth_result) if auth_result.is_authenticated() => {
|
||||
// Auth succeeded, retry activation.
|
||||
match ext_mgr.activate(&name).await {
|
||||
@@ -2101,7 +2065,7 @@ async fn extensions_setup_submit_handler(
|
||||
"Extension manager not available (secrets store required)".to_string(),
|
||||
))?;
|
||||
|
||||
match ext_mgr.configure(&name, &req.secrets).await {
|
||||
match ext_mgr.save_setup_secrets(&name, &req.secrets).await {
|
||||
Ok(result) => {
|
||||
// Broadcast auth_completed so the chat UI can dismiss any in-progress
|
||||
// auth card or setup modal that was triggered by tool_auth/tool_activate.
|
||||
|
||||
+121
-125
@@ -55,7 +55,7 @@ let _activityThinking = null;
|
||||
function authenticate() {
|
||||
token = document.getElementById('token-input').value.trim();
|
||||
if (!token) {
|
||||
document.getElementById('auth-error').textContent = I18n.t('auth.errorRequired');
|
||||
document.getElementById('auth-error').textContent = 'Token required';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ function authenticate() {
|
||||
sessionStorage.removeItem('ironclaw_token');
|
||||
document.getElementById('auth-screen').style.display = '';
|
||||
document.getElementById('app').style.display = 'none';
|
||||
document.getElementById('auth-error').textContent = I18n.t('auth.errorInvalid');
|
||||
document.getElementById('auth-error').textContent = 'Invalid token';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ let restartEnabled = false; // Track if restart is available in this deployment
|
||||
|
||||
function triggerRestart() {
|
||||
if (!currentThreadId) {
|
||||
alert(I18n.t('error.startConversation'));
|
||||
alert('Please start a conversation first');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ function triggerRestart() {
|
||||
|
||||
function confirmRestart() {
|
||||
if (!currentThreadId) {
|
||||
alert(I18n.t('error.startConversation'));
|
||||
alert('Please start a conversation first');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ function confirmRestart() {
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[confirmRestart] Restart request failed:', err);
|
||||
addMessage('system', I18n.t('error.restartFailed', { message: err.message }));
|
||||
addMessage('system', 'Restart failed: ' + err.message);
|
||||
isRestarting = false;
|
||||
restartBtn.disabled = false;
|
||||
if (restartIcon) restartIcon.classList.remove('spinning');
|
||||
@@ -234,7 +234,7 @@ function connectSSE() {
|
||||
|
||||
eventSource.onopen = () => {
|
||||
document.getElementById('sse-dot').classList.remove('disconnected');
|
||||
document.getElementById('sse-status').textContent = I18n.t('status.connected');
|
||||
document.getElementById('sse-status').textContent = 'Connected';
|
||||
|
||||
// If we were restarting, close the modal and reset button now that server is back
|
||||
if (isRestarting) {
|
||||
@@ -256,7 +256,7 @@ function connectSSE() {
|
||||
|
||||
eventSource.onerror = () => {
|
||||
document.getElementById('sse-dot').classList.add('disconnected');
|
||||
document.getElementById('sse-status').textContent = I18n.t('status.reconnecting');
|
||||
document.getElementById('sse-status').textContent = 'Reconnecting...';
|
||||
};
|
||||
|
||||
eventSource.addEventListener('response', (e) => {
|
||||
@@ -464,7 +464,7 @@ function enableChatInput() {
|
||||
const btn = document.getElementById('send-btn');
|
||||
if (input) {
|
||||
input.disabled = false;
|
||||
input.placeholder = I18n.t('chat.inputPlaceholder');
|
||||
input.placeholder = 'Message or / for commands...';
|
||||
}
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
@@ -676,20 +676,26 @@ function renderMarkdown(text) {
|
||||
return escapeHtml(text);
|
||||
}
|
||||
|
||||
// Sanitize rendered HTML using DOMPurify to prevent XSS from tool output
|
||||
// or prompt injection in LLM responses. DOMPurify is a DOM-based sanitizer
|
||||
// that handles all known bypass vectors (SVG onload, newline-split event
|
||||
// handlers, mutation XSS, etc.) unlike the regex approach it replaces.
|
||||
// Strip dangerous HTML elements and attributes from rendered markdown.
|
||||
// This prevents XSS from tool output or prompt injection in LLM responses.
|
||||
function sanitizeRenderedHtml(html) {
|
||||
if (typeof DOMPurify !== 'undefined') {
|
||||
return DOMPurify.sanitize(html, {
|
||||
USE_PROFILES: { html: true },
|
||||
FORBID_TAGS: ['style', 'script'],
|
||||
FORBID_ATTR: ['style', 'onerror', 'onload']
|
||||
});
|
||||
}
|
||||
// DOMPurify not available (CDN unreachable) — return empty string rather than unsanitized HTML
|
||||
return '';
|
||||
html = html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
||||
html = html.replace(/<iframe\b[^>]*>[\s\S]*?<\/iframe>/gi, '');
|
||||
html = html.replace(/<object\b[^>]*>[\s\S]*?<\/object>/gi, '');
|
||||
html = html.replace(/<embed\b[^>]*\/?>/gi, '');
|
||||
html = html.replace(/<form\b[^>]*>[\s\S]*?<\/form>/gi, '');
|
||||
html = html.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '');
|
||||
html = html.replace(/<link\b[^>]*\/?>/gi, '');
|
||||
html = html.replace(/<base\b[^>]*\/?>/gi, '');
|
||||
html = html.replace(/<meta\b[^>]*\/?>/gi, '');
|
||||
// Remove event handler attributes (onclick, onerror, onload, etc.)
|
||||
html = html.replace(/\s+on\w+\s*=\s*"[^"]*"/gi, '');
|
||||
html = html.replace(/\s+on\w+\s*=\s*'[^']*'/gi, '');
|
||||
html = html.replace(/\s+on\w+\s*=\s*[^\s>]+/gi, '');
|
||||
// Remove javascript: and data: URLs in href/src attributes
|
||||
html = html.replace(/(href|src|action)\s*=\s*["']?\s*javascript\s*:/gi, '$1="');
|
||||
html = html.replace(/(href|src|action)\s*=\s*["']?\s*data\s*:/gi, '$1="');
|
||||
return html;
|
||||
}
|
||||
|
||||
function copyCodeBlock(btn) {
|
||||
@@ -697,8 +703,8 @@ function copyCodeBlock(btn) {
|
||||
const code = pre.querySelector('code');
|
||||
const text = code ? code.textContent : pre.textContent;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
btn.textContent = I18n.t('btn.copied');
|
||||
setTimeout(() => { btn.textContent = I18n.t('btn.copy'); }, 1500);
|
||||
btn.textContent = 'Copied!';
|
||||
setTimeout(() => { btn.textContent = 'Copy'; }, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -985,7 +991,7 @@ function showApproval(data) {
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'approval-header';
|
||||
header.textContent = I18n.t('approval.title');
|
||||
header.textContent = 'Tool requires approval';
|
||||
card.appendChild(header);
|
||||
|
||||
const toolName = document.createElement('div');
|
||||
@@ -1003,7 +1009,7 @@ function showApproval(data) {
|
||||
if (data.parameters) {
|
||||
const paramsToggle = document.createElement('button');
|
||||
paramsToggle.className = 'approval-params-toggle';
|
||||
paramsToggle.textContent = I18n.t('approval.showParams');
|
||||
paramsToggle.textContent = 'Show parameters';
|
||||
const paramsBlock = document.createElement('pre');
|
||||
paramsBlock.className = 'approval-params';
|
||||
paramsBlock.textContent = data.parameters;
|
||||
@@ -1011,7 +1017,7 @@ function showApproval(data) {
|
||||
paramsToggle.addEventListener('click', () => {
|
||||
const visible = paramsBlock.style.display !== 'none';
|
||||
paramsBlock.style.display = visible ? 'none' : 'block';
|
||||
paramsToggle.textContent = visible ? I18n.t('approval.showParams') : I18n.t('approval.hideParams');
|
||||
paramsToggle.textContent = visible ? 'Show parameters' : 'Hide parameters';
|
||||
});
|
||||
card.appendChild(paramsToggle);
|
||||
card.appendChild(paramsBlock);
|
||||
@@ -1022,17 +1028,17 @@ function showApproval(data) {
|
||||
|
||||
const approveBtn = document.createElement('button');
|
||||
approveBtn.className = 'approve';
|
||||
approveBtn.textContent = I18n.t('approval.approve');
|
||||
approveBtn.textContent = 'Approve';
|
||||
approveBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'approve'));
|
||||
|
||||
const alwaysBtn = document.createElement('button');
|
||||
alwaysBtn.className = 'always';
|
||||
alwaysBtn.textContent = I18n.t('approval.always');
|
||||
alwaysBtn.textContent = 'Always';
|
||||
alwaysBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'always'));
|
||||
|
||||
const denyBtn = document.createElement('button');
|
||||
denyBtn.className = 'deny';
|
||||
denyBtn.textContent = I18n.t('approval.deny');
|
||||
denyBtn.textContent = 'Deny';
|
||||
denyBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'deny'));
|
||||
|
||||
actions.appendChild(approveBtn);
|
||||
@@ -1059,7 +1065,7 @@ function showJobCard(data) {
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'job-card-title';
|
||||
title.textContent = data.title || I18n.t('sandbox.job');
|
||||
title.textContent = data.title || 'Sandbox Job';
|
||||
info.appendChild(title);
|
||||
|
||||
const id = document.createElement('div');
|
||||
@@ -1071,7 +1077,7 @@ function showJobCard(data) {
|
||||
|
||||
const viewBtn = document.createElement('button');
|
||||
viewBtn.className = 'job-card-view';
|
||||
viewBtn.textContent = I18n.t('jobs.viewJob');
|
||||
viewBtn.textContent = 'View Job';
|
||||
viewBtn.addEventListener('click', () => {
|
||||
switchTab('jobs');
|
||||
openJobDetail(data.job_id);
|
||||
@@ -1083,7 +1089,7 @@ function showJobCard(data) {
|
||||
browseBtn.className = 'job-card-browse';
|
||||
browseBtn.href = data.browse_url;
|
||||
browseBtn.target = '_blank';
|
||||
browseBtn.textContent = I18n.t('jobs.browse');
|
||||
browseBtn.textContent = 'Browse';
|
||||
card.appendChild(browseBtn);
|
||||
}
|
||||
|
||||
@@ -1104,7 +1110,7 @@ function showAuthCard(data) {
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'auth-header';
|
||||
header.textContent = I18n.t('authRequired.title', {name: data.extension_name});
|
||||
header.textContent = 'Authentication required for ' + data.extension_name;
|
||||
card.appendChild(header);
|
||||
|
||||
if (data.instructions) {
|
||||
@@ -1120,7 +1126,7 @@ function showAuthCard(data) {
|
||||
if (data.auth_url) {
|
||||
const oauthBtn = document.createElement('button');
|
||||
oauthBtn.className = 'auth-oauth';
|
||||
oauthBtn.textContent = I18n.t('authRequired.authenticateWith', {name: data.extension_name});
|
||||
oauthBtn.textContent = 'Authenticate with ' + data.extension_name;
|
||||
oauthBtn.addEventListener('click', () => {
|
||||
openOAuthUrl(data.auth_url);
|
||||
});
|
||||
@@ -1131,7 +1137,7 @@ function showAuthCard(data) {
|
||||
const setupLink = document.createElement('a');
|
||||
setupLink.href = data.setup_url;
|
||||
setupLink.target = '_blank';
|
||||
setupLink.textContent = I18n.t('authRequired.getToken');
|
||||
setupLink.textContent = 'Get your token';
|
||||
links.appendChild(setupLink);
|
||||
}
|
||||
|
||||
@@ -1145,9 +1151,7 @@ function showAuthCard(data) {
|
||||
|
||||
const tokenInput = document.createElement('input');
|
||||
tokenInput.type = 'password';
|
||||
tokenInput.placeholder = data.instructions
|
||||
|| I18n.t('auth.extensionTokenPlaceholder')
|
||||
|| I18n.t('auth.tokenPlaceholder');
|
||||
tokenInput.placeholder = data.instructions || 'Paste your API key or token';
|
||||
tokenInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') submitAuthToken(data.extension_name, tokenInput.value);
|
||||
});
|
||||
@@ -1166,12 +1170,12 @@ function showAuthCard(data) {
|
||||
|
||||
const submitBtn = document.createElement('button');
|
||||
submitBtn.className = 'auth-submit';
|
||||
submitBtn.textContent = I18n.t('btn.submit');
|
||||
submitBtn.textContent = 'Submit';
|
||||
submitBtn.addEventListener('click', () => submitAuthToken(data.extension_name, tokenInput.value));
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.className = 'auth-cancel';
|
||||
cancelBtn.textContent = I18n.t('btn.cancel');
|
||||
cancelBtn.textContent = 'Cancel';
|
||||
cancelBtn.addEventListener('click', () => cancelAuth(data.extension_name));
|
||||
|
||||
actions.appendChild(submitBtn);
|
||||
@@ -1686,25 +1690,22 @@ function renderNodes(nodes, container, depth) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'tree-row';
|
||||
row.style.paddingLeft = (depth * 16 + 8) + 'px';
|
||||
row.tabIndex = 0;
|
||||
row.setAttribute('role', 'treeitem');
|
||||
|
||||
if (node.is_dir) {
|
||||
row.setAttribute('aria-expanded', node.expanded ? 'true' : 'false');
|
||||
const arrow = document.createElement('span');
|
||||
arrow.className = 'expand-arrow' + (node.expanded ? ' expanded' : '');
|
||||
arrow.textContent = '\u25B6';
|
||||
arrow.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
toggleExpand(node);
|
||||
});
|
||||
row.appendChild(arrow);
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'tree-label dir';
|
||||
label.textContent = node.name;
|
||||
label.addEventListener('click', () => toggleExpand(node));
|
||||
row.appendChild(label);
|
||||
|
||||
row.addEventListener('click', () => toggleExpand(node));
|
||||
row.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleExpand(node); }
|
||||
});
|
||||
} else {
|
||||
const spacer = document.createElement('span');
|
||||
spacer.className = 'expand-arrow-spacer';
|
||||
@@ -1713,12 +1714,8 @@ function renderNodes(nodes, container, depth) {
|
||||
const label = document.createElement('span');
|
||||
label.className = 'tree-label file';
|
||||
label.textContent = node.name;
|
||||
label.addEventListener('click', () => readMemoryFile(node.path));
|
||||
row.appendChild(label);
|
||||
|
||||
row.addEventListener('click', () => readMemoryFile(node.path));
|
||||
row.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); readMemoryFile(node.path); }
|
||||
});
|
||||
}
|
||||
|
||||
container.appendChild(row);
|
||||
@@ -1963,7 +1960,7 @@ function prependLogEntry(entry) {
|
||||
function toggleLogsPause() {
|
||||
logsPaused = !logsPaused;
|
||||
const btn = document.getElementById('logs-pause-btn');
|
||||
btn.textContent = logsPaused ? I18n.t('logs.resume') : I18n.t('logs.pause');
|
||||
btn.textContent = logsPaused ? 'Resume' : 'Pause';
|
||||
|
||||
if (!logsPaused) {
|
||||
// Flush buffer: oldest-first + prepend naturally puts newest at top
|
||||
@@ -2035,7 +2032,7 @@ function loadExtensions() {
|
||||
]).then(([extData, toolData, registryData]) => {
|
||||
// Render installed extensions
|
||||
if (extData.extensions.length === 0) {
|
||||
extList.innerHTML = '<div class="empty-state">' + I18n.t('extensions.noInstalled') + '</div>';
|
||||
extList.innerHTML = '<div class="empty-state">No extensions installed</div>';
|
||||
} else {
|
||||
extList.innerHTML = '';
|
||||
for (const ext of extData.extensions) {
|
||||
@@ -2049,7 +2046,7 @@ function loadExtensions() {
|
||||
|
||||
// Available WASM extensions
|
||||
if (wasmEntries.length === 0) {
|
||||
wasmList.innerHTML = '<div class="empty-state">' + I18n.t('extensions.noAvailable') + '</div>';
|
||||
wasmList.innerHTML = '<div class="empty-state">No additional WASM extensions available</div>';
|
||||
} else {
|
||||
wasmList.innerHTML = '';
|
||||
for (const entry of wasmEntries) {
|
||||
@@ -2059,7 +2056,7 @@ function loadExtensions() {
|
||||
|
||||
// MCP servers (show both installed and uninstalled)
|
||||
if (mcpEntries.length === 0) {
|
||||
mcpList.innerHTML = '<div class="empty-state">' + I18n.t('mcp.noServers') + '</div>';
|
||||
mcpList.innerHTML = '<div class="empty-state">No MCP servers available</div>';
|
||||
} else {
|
||||
mcpList.innerHTML = '';
|
||||
for (const entry of mcpEntries) {
|
||||
@@ -2124,16 +2121,16 @@ function renderAvailableExtensionCard(entry) {
|
||||
|
||||
const installBtn = document.createElement('button');
|
||||
installBtn.className = 'btn-ext install';
|
||||
installBtn.textContent = I18n.t('extensions.install');
|
||||
installBtn.textContent = 'Install';
|
||||
installBtn.addEventListener('click', function() {
|
||||
installBtn.disabled = true;
|
||||
installBtn.textContent = I18n.t('extensions.installing');
|
||||
installBtn.textContent = 'Installing...';
|
||||
apiFetch('/api/extensions/install', {
|
||||
method: 'POST',
|
||||
body: { name: entry.name, kind: entry.kind },
|
||||
}).then(function(res) {
|
||||
if (res.success) {
|
||||
showToast(I18n.t('extensions.installedSuccess', {name: entry.display_name}), 'success');
|
||||
showToast('Installed ' + entry.display_name, 'success');
|
||||
// OAuth popup if auth started during install (builtin creds)
|
||||
if (res.auth_url) {
|
||||
showToast('Opening authentication for ' + entry.display_name, 'info');
|
||||
@@ -2197,39 +2194,39 @@ function renderMcpServerCard(entry, installedExt) {
|
||||
if (!installedExt.active) {
|
||||
var activateBtn = document.createElement('button');
|
||||
activateBtn.className = 'btn-ext activate';
|
||||
activateBtn.textContent = I18n.t('common.activate');
|
||||
activateBtn.textContent = 'Activate';
|
||||
activateBtn.addEventListener('click', function() { activateExtension(installedExt.name); });
|
||||
actions.appendChild(activateBtn);
|
||||
} else {
|
||||
var activeLabel = document.createElement('span');
|
||||
activeLabel.className = 'ext-active-label';
|
||||
activeLabel.textContent = I18n.t('ext.active');
|
||||
activeLabel.textContent = 'Active';
|
||||
actions.appendChild(activeLabel);
|
||||
}
|
||||
var removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'btn-ext remove';
|
||||
removeBtn.textContent = I18n.t('ext.remove');
|
||||
removeBtn.textContent = 'Remove';
|
||||
removeBtn.addEventListener('click', function() { removeExtension(installedExt.name); });
|
||||
actions.appendChild(removeBtn);
|
||||
} else {
|
||||
var installBtn = document.createElement('button');
|
||||
installBtn.className = 'btn-ext install';
|
||||
installBtn.textContent = I18n.t('ext.install');
|
||||
installBtn.textContent = 'Install';
|
||||
installBtn.addEventListener('click', function() {
|
||||
installBtn.disabled = true;
|
||||
installBtn.textContent = I18n.t('ext.installing');
|
||||
installBtn.textContent = 'Installing...';
|
||||
apiFetch('/api/extensions/install', {
|
||||
method: 'POST',
|
||||
body: { name: entry.name, kind: entry.kind },
|
||||
}).then(function(res) {
|
||||
if (res.success) {
|
||||
showToast(I18n.t('extensions.installedSuccess', { name: entry.display_name }), 'success');
|
||||
showToast('Installed ' + entry.display_name, 'success');
|
||||
} else {
|
||||
showToast(I18n.t('ext.install') + ': ' + (res.message || 'unknown error'), 'error');
|
||||
showToast('Install: ' + (res.message || 'unknown error'), 'error');
|
||||
}
|
||||
loadExtensions();
|
||||
}).catch(function(err) {
|
||||
showToast(I18n.t('ext.installFailed', { message: err.message }), 'error');
|
||||
showToast('Install failed: ' + err.message, 'error');
|
||||
loadExtensions();
|
||||
});
|
||||
});
|
||||
@@ -2243,7 +2240,7 @@ function renderMcpServerCard(entry, installedExt) {
|
||||
function createReconfigureButton(extName) {
|
||||
var btn = document.createElement('button');
|
||||
btn.className = 'btn-ext configure';
|
||||
btn.textContent = I18n.t('ext.reconfigure');
|
||||
btn.textContent = 'Reconfigure';
|
||||
btn.addEventListener('click', function() { showConfigureModal(extName); });
|
||||
return btn;
|
||||
}
|
||||
@@ -2327,13 +2324,13 @@ function renderExtensionCard(ext) {
|
||||
if (status === 'active') {
|
||||
var activeLabel = document.createElement('span');
|
||||
activeLabel.className = 'ext-active-label';
|
||||
activeLabel.textContent = I18n.t('ext.active');
|
||||
activeLabel.textContent = 'Active';
|
||||
actions.appendChild(activeLabel);
|
||||
actions.appendChild(createReconfigureButton(ext.name));
|
||||
} else if (status === 'pairing') {
|
||||
var pairingLabel = document.createElement('span');
|
||||
pairingLabel.className = 'ext-pairing-label';
|
||||
pairingLabel.textContent = I18n.t('status.awaitingPairing');
|
||||
pairingLabel.textContent = 'Awaiting Pairing';
|
||||
actions.appendChild(pairingLabel);
|
||||
actions.appendChild(createReconfigureButton(ext.name));
|
||||
} else if (status === 'failed') {
|
||||
@@ -2342,7 +2339,7 @@ function renderExtensionCard(ext) {
|
||||
// installed or configured: show Setup button
|
||||
var setupBtn = document.createElement('button');
|
||||
setupBtn.className = 'btn-ext configure';
|
||||
setupBtn.textContent = I18n.t('ext.setup');
|
||||
setupBtn.textContent = 'Setup';
|
||||
setupBtn.addEventListener('click', function() { showConfigureModal(ext.name); });
|
||||
actions.appendChild(setupBtn);
|
||||
}
|
||||
@@ -2350,14 +2347,14 @@ function renderExtensionCard(ext) {
|
||||
// WASM tools / MCP servers
|
||||
const activeLabel = document.createElement('span');
|
||||
activeLabel.className = 'ext-active-label';
|
||||
activeLabel.textContent = ext.active ? I18n.t('ext.active') : I18n.t('status.installed');
|
||||
activeLabel.textContent = ext.active ? 'Active' : 'Installed';
|
||||
actions.appendChild(activeLabel);
|
||||
|
||||
// MCP servers and channel-relay extensions may be installed but inactive — show Activate button
|
||||
if ((ext.kind === 'mcp_server' || ext.kind === 'channel_relay') && !ext.active) {
|
||||
const activateBtn = document.createElement('button');
|
||||
activateBtn.className = 'btn-ext activate';
|
||||
activateBtn.textContent = I18n.t('common.activate');
|
||||
activateBtn.textContent = 'Activate';
|
||||
activateBtn.addEventListener('click', () => activateExtension(ext.name));
|
||||
actions.appendChild(activateBtn);
|
||||
}
|
||||
@@ -2369,7 +2366,7 @@ function renderExtensionCard(ext) {
|
||||
if (ext.needs_setup || (ext.has_auth && ext.authenticated)) {
|
||||
const configBtn = document.createElement('button');
|
||||
configBtn.className = 'btn-ext configure';
|
||||
configBtn.textContent = ext.authenticated ? I18n.t('ext.reconfigure') : I18n.t('ext.configure');
|
||||
configBtn.textContent = ext.authenticated ? 'Reconfigure' : 'Configure';
|
||||
configBtn.addEventListener('click', () => showConfigureModal(ext.name));
|
||||
actions.appendChild(configBtn);
|
||||
}
|
||||
@@ -2377,7 +2374,7 @@ function renderExtensionCard(ext) {
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'btn-ext remove';
|
||||
removeBtn.textContent = I18n.t('ext.remove');
|
||||
removeBtn.textContent = 'Remove';
|
||||
removeBtn.addEventListener('click', () => removeExtension(ext.name));
|
||||
actions.appendChild(removeBtn);
|
||||
|
||||
@@ -2422,17 +2419,17 @@ function activateExtension(name) {
|
||||
}
|
||||
|
||||
function removeExtension(name) {
|
||||
if (!confirm(I18n.t('ext.confirmRemove', { name: name }))) return;
|
||||
if (!confirm('Remove extension "' + name + '"?')) return;
|
||||
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/remove', { method: 'POST' })
|
||||
.then((res) => {
|
||||
if (!res.success) {
|
||||
showToast(I18n.t('ext.removeFailed', { message: res.message }), 'error');
|
||||
showToast('Remove failed: ' + res.message, 'error');
|
||||
} else {
|
||||
showToast(I18n.t('ext.removed', { name: name }), 'success');
|
||||
showToast('Removed ' + name, 'success');
|
||||
}
|
||||
loadExtensions();
|
||||
})
|
||||
.catch((err) => showToast(I18n.t('ext.removeFailed', { message: err.message }), 'error'));
|
||||
.catch((err) => showToast('Remove failed: ' + err.message, 'error'));
|
||||
}
|
||||
|
||||
function showConfigureModal(name) {
|
||||
@@ -2459,7 +2456,7 @@ function renderConfigureModal(name, secrets) {
|
||||
modal.className = 'configure-modal';
|
||||
|
||||
const header = document.createElement('h3');
|
||||
header.textContent = I18n.t('config.title', { name: name });
|
||||
header.textContent = 'Configure ' + name;
|
||||
modal.appendChild(header);
|
||||
|
||||
const form = document.createElement('div');
|
||||
@@ -2475,7 +2472,7 @@ function renderConfigureModal(name, secrets) {
|
||||
if (secret.optional) {
|
||||
const opt = document.createElement('span');
|
||||
opt.className = 'field-optional';
|
||||
opt.textContent = I18n.t('config.optional');
|
||||
opt.textContent = ' (optional)';
|
||||
label.appendChild(opt);
|
||||
}
|
||||
field.appendChild(label);
|
||||
@@ -2486,7 +2483,7 @@ function renderConfigureModal(name, secrets) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'password';
|
||||
input.name = secret.name;
|
||||
input.placeholder = secret.provided ? I18n.t('config.alreadySet') : '';
|
||||
input.placeholder = secret.provided ? '(already set — leave empty to keep)' : '';
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') submitConfigureModal(name, fields);
|
||||
});
|
||||
@@ -2496,13 +2493,13 @@ function renderConfigureModal(name, secrets) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'field-provided';
|
||||
badge.textContent = '\u2713';
|
||||
badge.title = I18n.t('config.alreadyConfigured');
|
||||
badge.title = 'Already configured';
|
||||
inputRow.appendChild(badge);
|
||||
}
|
||||
if (secret.auto_generate && !secret.provided) {
|
||||
const hint = document.createElement('span');
|
||||
hint.className = 'field-autogen';
|
||||
hint.textContent = I18n.t('config.autoGenerate');
|
||||
hint.textContent = 'Auto-generated if empty';
|
||||
inputRow.appendChild(hint);
|
||||
}
|
||||
|
||||
@@ -2518,13 +2515,13 @@ function renderConfigureModal(name, secrets) {
|
||||
|
||||
const submitBtn = document.createElement('button');
|
||||
submitBtn.className = 'btn-ext activate';
|
||||
submitBtn.textContent = I18n.t('config.save');
|
||||
submitBtn.textContent = 'Save';
|
||||
submitBtn.addEventListener('click', () => submitConfigureModal(name, fields));
|
||||
actions.appendChild(submitBtn);
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.className = 'btn-ext remove';
|
||||
cancelBtn.textContent = I18n.t('config.cancel');
|
||||
cancelBtn.textContent = 'Cancel';
|
||||
cancelBtn.addEventListener('click', closeConfigureModal);
|
||||
actions.appendChild(cancelBtn);
|
||||
|
||||
@@ -2764,11 +2761,11 @@ function loadJobs() {
|
||||
|
||||
function renderJobsSummary(s) {
|
||||
document.getElementById('jobs-summary').innerHTML = ''
|
||||
+ summaryCard(I18n.t('jobs.summary.total'), s.total, '')
|
||||
+ summaryCard(I18n.t('jobs.summary.inProgress'), s.in_progress, 'active')
|
||||
+ summaryCard(I18n.t('jobs.summary.completed'), s.completed, 'completed')
|
||||
+ summaryCard(I18n.t('jobs.summary.failed'), s.failed, 'failed')
|
||||
+ summaryCard(I18n.t('jobs.summary.stuck'), s.stuck, 'stuck');
|
||||
+ summaryCard('Total', s.total, '')
|
||||
+ summaryCard('In Progress', s.in_progress, 'active')
|
||||
+ summaryCard('Completed', s.completed, 'completed')
|
||||
+ summaryCard('Failed', s.failed, 'failed')
|
||||
+ summaryCard('Stuck', s.stuck, 'stuck');
|
||||
}
|
||||
|
||||
function summaryCard(label, count, cls) {
|
||||
@@ -3298,11 +3295,11 @@ function loadRoutines() {
|
||||
|
||||
function renderRoutinesSummary(s) {
|
||||
document.getElementById('routines-summary').innerHTML = ''
|
||||
+ summaryCard(I18n.t('routines.summary.total'), s.total, '')
|
||||
+ summaryCard(I18n.t('routines.summary.enabled'), s.enabled, 'active')
|
||||
+ summaryCard(I18n.t('routines.summary.disabled'), s.disabled, '')
|
||||
+ summaryCard(I18n.t('routines.summary.failing'), s.failing, 'failed')
|
||||
+ summaryCard(I18n.t('routines.summary.runsToday'), s.runs_today, 'completed');
|
||||
+ summaryCard('Total', s.total, '')
|
||||
+ summaryCard('Enabled', s.enabled, 'active')
|
||||
+ summaryCard('Disabled', s.disabled, '')
|
||||
+ summaryCard('Failing', s.failing, 'failed')
|
||||
+ summaryCard('Runs Today', s.runs_today, 'completed');
|
||||
}
|
||||
|
||||
function renderRoutinesList(routines) {
|
||||
@@ -3468,18 +3465,17 @@ function formatRelativeTime(isoString) {
|
||||
const absDiff = Math.abs(diffMs);
|
||||
const future = diffMs < 0;
|
||||
|
||||
if (absDiff < 60000)
|
||||
return future ? I18n.t('time.lessThan1MinuteFromNow') : I18n.t('time.lessThan1MinuteAgo');
|
||||
if (absDiff < 60000) return future ? 'in <1m' : '<1m ago';
|
||||
if (absDiff < 3600000) {
|
||||
const m = Math.floor(absDiff / 60000);
|
||||
return future ? I18n.t('time.minutesFromNow', { n: m }) : I18n.t('time.minutesAgo', { n: m });
|
||||
return future ? 'in ' + m + 'm' : m + 'm ago';
|
||||
}
|
||||
if (absDiff < 86400000) {
|
||||
const h = Math.floor(absDiff / 3600000);
|
||||
return future ? I18n.t('time.hoursFromNow', { n: h }) : I18n.t('time.hoursAgo', { n: h });
|
||||
return future ? 'in ' + h + 'h' : h + 'h ago';
|
||||
}
|
||||
const days = Math.floor(absDiff / 86400000);
|
||||
return future ? I18n.t('time.daysFromNow', { n: days }) : I18n.t('time.daysAgo', { n: days });
|
||||
return future ? 'in ' + days + 'd' : days + 'd ago';
|
||||
}
|
||||
|
||||
// --- Gateway status widget ---
|
||||
@@ -3529,18 +3525,18 @@ function fetchGatewayStatus() {
|
||||
}
|
||||
|
||||
// Connection info
|
||||
html += '<div class="gw-section-label">' + I18n.t('dashboard.connections') + '</div>';
|
||||
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.sse') + '</span><span>' + (data.sse_connections || 0) + '</span></div>';
|
||||
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.websocket') + '</span><span>' + (data.ws_connections || 0) + '</span></div>';
|
||||
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.uptime') + '</span><span>' + formatDuration(data.uptime_secs) + '</span></div>';
|
||||
html += '<div class="gw-section-label">Connections</div>';
|
||||
html += '<div class="gw-stat"><span>SSE</span><span>' + (data.sse_connections || 0) + '</span></div>';
|
||||
html += '<div class="gw-stat"><span>WebSocket</span><span>' + (data.ws_connections || 0) + '</span></div>';
|
||||
html += '<div class="gw-stat"><span>Uptime</span><span>' + formatDuration(data.uptime_secs) + '</span></div>';
|
||||
|
||||
// Cost tracker
|
||||
if (data.daily_cost != null) {
|
||||
html += '<div class="gw-divider"></div>';
|
||||
html += '<div class="gw-section-label">' + I18n.t('dashboard.costToday') + '</div>';
|
||||
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.spent') + '</span><span>' + formatCost(data.daily_cost) + '</span></div>';
|
||||
html += '<div class="gw-section-label">Cost Today</div>';
|
||||
html += '<div class="gw-stat"><span>Spent</span><span>' + formatCost(data.daily_cost) + '</span></div>';
|
||||
if (data.actions_this_hour != null) {
|
||||
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.actionsPerHour') + '</span><span>' + data.actions_this_hour + '</span></div>';
|
||||
html += '<div class="gw-stat"><span>Actions/hr</span><span>' + data.actions_this_hour + '</span></div>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3748,7 +3744,7 @@ function loadSkills() {
|
||||
var skillsList = document.getElementById('skills-list');
|
||||
apiFetch('/api/skills').then(function(data) {
|
||||
if (!data.skills || data.skills.length === 0) {
|
||||
skillsList.innerHTML = '<div class="empty-state">' + I18n.t('skills.noInstalled') + '</div>';
|
||||
skillsList.innerHTML = '<div class="empty-state">No skills installed</div>';
|
||||
return;
|
||||
}
|
||||
skillsList.innerHTML = '';
|
||||
@@ -3756,7 +3752,7 @@ function loadSkills() {
|
||||
skillsList.appendChild(renderSkillCard(data.skills[i]));
|
||||
}
|
||||
}).catch(function(err) {
|
||||
skillsList.innerHTML = '<div class="empty-state">' + I18n.t('skills.loadFailed', {message: escapeHtml(err.message)}) + '</div>';
|
||||
skillsList.innerHTML = '<div class="empty-state">Failed to load skills: ' + escapeHtml(err.message) + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3793,7 +3789,7 @@ function renderSkillCard(skill) {
|
||||
if (skill.keywords && skill.keywords.length > 0) {
|
||||
var kw = document.createElement('div');
|
||||
kw.className = 'ext-keywords';
|
||||
kw.textContent = I18n.t('skills.activatesOn') + ': ' + skill.keywords.join(', ');
|
||||
kw.textContent = 'Activates on: ' + skill.keywords.join(', ');
|
||||
card.appendChild(kw);
|
||||
}
|
||||
|
||||
@@ -3804,7 +3800,7 @@ function renderSkillCard(skill) {
|
||||
if (skill.trust.toLowerCase() !== 'trusted') {
|
||||
var removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'btn-ext remove';
|
||||
removeBtn.textContent = I18n.t('skills.remove');
|
||||
removeBtn.textContent = 'Remove';
|
||||
removeBtn.addEventListener('click', function() { removeSkill(skill.name); });
|
||||
actions.appendChild(removeBtn);
|
||||
}
|
||||
@@ -3819,7 +3815,7 @@ function searchClawHub() {
|
||||
if (!query) return;
|
||||
|
||||
var resultsDiv = document.getElementById('skill-search-results');
|
||||
resultsDiv.innerHTML = '<div class="empty-state">' + I18n.t('skills.searching') + '</div>';
|
||||
resultsDiv.innerHTML = '<div class="empty-state">Searching...</div>';
|
||||
|
||||
apiFetch('/api/skills/search', {
|
||||
method: 'POST',
|
||||
@@ -3835,7 +3831,7 @@ function searchClawHub() {
|
||||
warning.style.borderLeft = '3px solid #f0ad4e';
|
||||
warning.style.paddingLeft = '12px';
|
||||
warning.style.marginBottom = '16px';
|
||||
warning.textContent = I18n.t('skills.registryError', {message: data.catalog_error});
|
||||
warning.textContent = 'Could not reach ClawHub registry: ' + data.catalog_error;
|
||||
resultsDiv.appendChild(warning);
|
||||
}
|
||||
|
||||
@@ -3867,10 +3863,10 @@ function searchClawHub() {
|
||||
}
|
||||
|
||||
if (resultsDiv.children.length === 0) {
|
||||
resultsDiv.innerHTML = '<div class="empty-state">' + I18n.t('skills.noResults', {query: escapeHtml(query)}) + '</div>';
|
||||
resultsDiv.innerHTML = '<div class="empty-state">No skills found for "' + escapeHtml(query) + '"</div>';
|
||||
}
|
||||
}).catch(function(err) {
|
||||
resultsDiv.innerHTML = '<div class="empty-state">' + I18n.t('skills.searchFailed', {message: escapeHtml(err.message)}) + '</div>';
|
||||
resultsDiv.innerHTML = '<div class="empty-state">Search failed: ' + escapeHtml(err.message) + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3964,17 +3960,17 @@ function renderCatalogSkillCard(entry, installedNames) {
|
||||
if (isInstalled) {
|
||||
var label = document.createElement('span');
|
||||
label.className = 'ext-active-label';
|
||||
label.textContent = I18n.t('status.installed');
|
||||
label.textContent = 'Installed';
|
||||
actions.appendChild(label);
|
||||
} else {
|
||||
var installBtn = document.createElement('button');
|
||||
installBtn.className = 'btn-ext install';
|
||||
installBtn.textContent = I18n.t('extensions.install');
|
||||
installBtn.textContent = 'Install';
|
||||
installBtn.addEventListener('click', (function(s, btn) {
|
||||
return function() {
|
||||
if (!confirm('Install skill "' + s + '" from ClawHub?')) return;
|
||||
btn.disabled = true;
|
||||
btn.textContent = I18n.t('extensions.installing');
|
||||
btn.textContent = 'Installing...';
|
||||
installSkill(s, null, btn);
|
||||
};
|
||||
})(slug, installBtn));
|
||||
@@ -4016,7 +4012,7 @@ function installSkill(nameOrSlug, url, btn) {
|
||||
body: body,
|
||||
}).then(function(res) {
|
||||
if (res.success) {
|
||||
showToast(I18n.t('skills.installedSuccess', {name: nameOrSlug}), 'success');
|
||||
showToast('Installed skill "' + nameOrSlug + '"', 'success');
|
||||
} else {
|
||||
showToast('Install failed: ' + (res.message || 'unknown error'), 'error');
|
||||
}
|
||||
@@ -4029,19 +4025,19 @@ function installSkill(nameOrSlug, url, btn) {
|
||||
}
|
||||
|
||||
function removeSkill(name) {
|
||||
if (!confirm(I18n.t('skills.confirmRemove', { name: name }))) return;
|
||||
if (!confirm('Remove skill "' + name + '"?')) return;
|
||||
apiFetch('/api/skills/' + encodeURIComponent(name), {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-Confirm-Action': 'true' },
|
||||
}).then(function(res) {
|
||||
if (res.success) {
|
||||
showToast(I18n.t('skills.removed', { name: name }), 'success');
|
||||
showToast('Removed skill "' + name + '"', 'success');
|
||||
} else {
|
||||
showToast(I18n.t('skills.removeFailed', { message: res.message || 'unknown error' }), 'error');
|
||||
showToast('Remove failed: ' + (res.message || 'unknown error'), 'error');
|
||||
}
|
||||
loadSkills();
|
||||
}).catch(function(err) {
|
||||
showToast(I18n.t('skills.removeFailed', { message: err.message }), 'error');
|
||||
showToast('Remove failed: ' + err.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
// i18n Integration for IronClaw App
|
||||
// This file contains i18n-related functions that extend app.js
|
||||
|
||||
// Initialize i18n when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Initialize i18n
|
||||
I18n.init();
|
||||
I18n.updatePageContent();
|
||||
updateSlashCommands();
|
||||
updateLanguageMenu();
|
||||
});
|
||||
|
||||
// Update slash commands with current language
|
||||
function updateSlashCommands() {
|
||||
// Update SLASH_COMMANDS descriptions
|
||||
SLASH_COMMANDS.forEach(cmd => {
|
||||
const key = 'cmd.' + cmd.cmd.replace(/\s+/g, '').replace(/\//g, '') + '.desc';
|
||||
const translated = I18n.t(key);
|
||||
if (translated !== key) {
|
||||
cmd.desc = translated;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle language menu
|
||||
function toggleLanguageMenu() {
|
||||
const menu = document.getElementById('language-menu');
|
||||
if (menu) {
|
||||
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Switch language
|
||||
function switchLanguage(lang) {
|
||||
if (I18n.setLanguage(lang)) {
|
||||
// Update slash commands
|
||||
updateSlashCommands();
|
||||
|
||||
// Update language menu active state
|
||||
updateLanguageMenu();
|
||||
|
||||
// Close menu
|
||||
const menu = document.getElementById('language-menu');
|
||||
if (menu) {
|
||||
menu.style.display = 'none';
|
||||
}
|
||||
|
||||
// Show toast notification
|
||||
showToast(I18n.t('language.switch') + ': ' + (lang === 'zh-CN' ? '简体中文' : 'English'));
|
||||
}
|
||||
}
|
||||
|
||||
// Update language menu active state
|
||||
function updateLanguageMenu() {
|
||||
const currentLang = I18n.getCurrentLang();
|
||||
document.querySelectorAll('.language-option').forEach(option => {
|
||||
if (option.getAttribute('data-lang') === currentLang) {
|
||||
option.classList.add('active');
|
||||
} else {
|
||||
option.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Close language menu when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.language-switcher')) {
|
||||
const menu = document.getElementById('language-menu');
|
||||
if (menu) {
|
||||
menu.style.display = 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,351 +0,0 @@
|
||||
// English Language Pack for IronClaw
|
||||
|
||||
I18n.register('en', {
|
||||
// Auth Page
|
||||
'auth.title': 'IronClaw',
|
||||
'auth.tagline': 'Secure AI Assistant',
|
||||
'auth.tokenLabel': 'Gateway Token',
|
||||
'auth.tokenPlaceholder': 'Paste your token',
|
||||
'auth.connect': 'Connect',
|
||||
'auth.errorRequired': 'Token required',
|
||||
'auth.errorInvalid': 'Invalid token',
|
||||
'auth.hint': 'Enter the GATEWAY_AUTH_TOKEN from your .env file',
|
||||
|
||||
// Chat
|
||||
'chat.inputPlaceholder': 'Message or / for commands...',
|
||||
|
||||
// Restart Modal
|
||||
'restart.title': 'Restart IronClaw Instance',
|
||||
'restart.description': 'Are you sure you want to restart IronClaw? This will gracefully restart the process.',
|
||||
'restart.warning': 'Running tasks may be interrupted. Restart will complete in a few seconds.',
|
||||
'restart.cancel': 'Cancel',
|
||||
'restart.confirm': 'Confirm Restart',
|
||||
'restart.progressTitle': 'Restarting IronClaw',
|
||||
'restart.progressSubtitle': 'Please wait for the process to restart...',
|
||||
'restart.checkLogs': 'Check the Logs tab for details after restart completes.',
|
||||
|
||||
// Tabs
|
||||
'tab.chat': 'Chat',
|
||||
'tab.memory': 'Memory',
|
||||
'tab.jobs': 'Jobs',
|
||||
'tab.routines': 'Routines',
|
||||
'tab.extensions': 'Extensions',
|
||||
'tab.skills': 'Skills',
|
||||
'tab.logs': 'Logs',
|
||||
|
||||
// Status
|
||||
'status.connected': 'Connected',
|
||||
'status.disconnected': 'Disconnected',
|
||||
'status.connecting': 'Connecting...',
|
||||
'status.reconnecting': 'Reconnecting...',
|
||||
'status.teeVerified': 'TEE Verified',
|
||||
'status.restart': 'Restart',
|
||||
'status.active': 'Active',
|
||||
'status.installed': 'Installed',
|
||||
'status.awaitingPairing': 'Awaiting Pairing',
|
||||
|
||||
// Dashboard
|
||||
'dashboard.connections': 'Connections',
|
||||
'dashboard.uptime': 'Uptime',
|
||||
'dashboard.costToday': 'Cost Today',
|
||||
'dashboard.spent': 'Spent',
|
||||
'dashboard.actionsPerHour': 'Actions/hr',
|
||||
'dashboard.sse': 'SSE',
|
||||
'dashboard.websocket': 'WebSocket',
|
||||
|
||||
// Chat Tab
|
||||
'chat.newThread': 'New Thread',
|
||||
'chat.toggleSidebar': 'Toggle Sidebar',
|
||||
'chat.assistant': 'Assistant',
|
||||
'chat.conversations': 'Conversations',
|
||||
'chat.send': 'Send',
|
||||
'chat.attachImages': 'Attach Images',
|
||||
'chat.empty': 'Select a file to view content',
|
||||
'chat.loading': 'Loading...',
|
||||
'chat.loadingOlder': 'Loading older messages...',
|
||||
'chat.noFiles': 'No files in workspace',
|
||||
'chat.noResults': 'No results',
|
||||
|
||||
// Thread Sidebar
|
||||
'thread.assistant': 'Assistant',
|
||||
'thread.new': 'New Thread',
|
||||
|
||||
// Memory Tab
|
||||
'memory.searchPlaceholder': 'Search memory...',
|
||||
'memory.workspace': 'workspace',
|
||||
'memory.edit': 'Edit',
|
||||
'memory.save': 'Save',
|
||||
'memory.cancel': 'Cancel',
|
||||
'memory.selectFile': 'Select a file to view content',
|
||||
|
||||
// Jobs Tab
|
||||
'jobs.summary': 'Jobs Summary',
|
||||
'jobs.id': 'ID',
|
||||
'jobs.title': 'Title',
|
||||
'jobs.source': 'Source',
|
||||
'jobs.status': 'Status',
|
||||
'jobs.created': 'Created',
|
||||
'jobs.actions': 'Actions',
|
||||
'jobs.empty': 'No jobs',
|
||||
'jobs.statusRunning': 'Running',
|
||||
'jobs.statusCompleted': 'Completed',
|
||||
'jobs.statusFailed': 'Failed',
|
||||
'jobs.statusPending': 'Pending',
|
||||
'jobs.jobId': 'Job ID',
|
||||
'jobs.description': 'Description',
|
||||
'jobs.stateTransitions': 'State Transitions',
|
||||
'jobs.projectFiles': 'Project Files',
|
||||
'jobs.noProjectFiles': 'No project files',
|
||||
'jobs.viewJob': 'View Job',
|
||||
'jobs.browse': 'Browse',
|
||||
|
||||
// Routines Tab
|
||||
'routines.summary': 'Routines Summary',
|
||||
'routines.name': 'Name',
|
||||
'routines.trigger': 'Trigger',
|
||||
'routines.action': 'Action',
|
||||
'routines.lastRun': 'Last Run',
|
||||
'routines.nextRun': 'Next Run',
|
||||
'routines.runs': 'Runs',
|
||||
'routines.status': 'Status',
|
||||
'routines.actions': 'Actions',
|
||||
'routines.runsToday': 'Runs Today',
|
||||
'routines.empty': 'No routines',
|
||||
'routines.noConfigured': 'No routines configured. Ask the assistant to create one.',
|
||||
'routines.triggerFailed': 'Trigger failed: {message}',
|
||||
|
||||
// Logs Tab
|
||||
'logs.serverLevel': 'Server: ERROR',
|
||||
'logs.clientLevel': 'Client Log Level',
|
||||
'logs.pause': 'Pause',
|
||||
'logs.resume': 'Resume',
|
||||
'logs.clear': 'Clear',
|
||||
'logs.autoScroll': 'Auto-scroll',
|
||||
'logs.filter': 'Filter logs...',
|
||||
'logs.empty': 'No logs',
|
||||
'logs.allLevels': 'All Levels',
|
||||
'logs.error': 'Error',
|
||||
'logs.warn': 'Warn',
|
||||
'logs.info': 'Info',
|
||||
'logs.debug': 'Debug',
|
||||
|
||||
// Extensions Tab
|
||||
'extensions.installed': 'Installed Extensions',
|
||||
'extensions.available': 'Available WASM Extensions',
|
||||
'extensions.installWasm': 'Install WASM Extension',
|
||||
'extensions.noInstalled': 'No extensions installed',
|
||||
'extensions.noAvailable': 'No additional WASM extensions available',
|
||||
'extensions.loading': 'Loading...',
|
||||
'extensions.install': 'Install',
|
||||
'extensions.installing': 'Installing...',
|
||||
'extensions.installedSuccess': 'Installed {name}',
|
||||
'extensions.remove': 'Remove',
|
||||
'extensions.activate': 'Activate',
|
||||
'extensions.reconfigure': 'Reconfigure',
|
||||
'extensions.tools': 'Tools',
|
||||
'extensions.noConfigNeeded': 'No configuration needed for {name}',
|
||||
'extensions.configure': 'Configure {name}',
|
||||
'extensions.optional': ' (optional)',
|
||||
'extensions.autoGenerated': 'Auto-generated if empty',
|
||||
'extensions.pendingPairing': 'Pending pairing requests',
|
||||
'extensions.from': 'from',
|
||||
|
||||
// MCP Servers
|
||||
'mcp.servers': 'MCP Servers',
|
||||
'mcp.noServers': 'No MCP servers available',
|
||||
'mcp.addCustom': 'Add Custom MCP Server',
|
||||
'mcp.add': 'Add',
|
||||
'mcp.addedSuccess': 'Added MCP server {name}',
|
||||
|
||||
// Registered Tools
|
||||
'tools.registered': 'Registered Tools',
|
||||
'tools.name': 'Name',
|
||||
'tools.description': 'Description',
|
||||
'tools.empty': 'No tools registered',
|
||||
|
||||
// Skills Tab
|
||||
'skills.installed': 'Installed Skills',
|
||||
'skills.noInstalled': 'No skills installed',
|
||||
'skills.searchClawHub': 'Search ClawHub',
|
||||
'skills.searchPlaceholder': 'Search...',
|
||||
'skills.installByUrl': 'Install Skill by URL',
|
||||
'skills.namePlaceholder': 'Skill name or slug',
|
||||
'skills.urlPlaceholder': 'HTTPS URL to SKILL.md (optional)',
|
||||
'skills.search': 'Search',
|
||||
'skills.searching': 'Searching...',
|
||||
'skills.noResults': 'No skills found for "{query}"',
|
||||
'skills.searchFailed': 'Search failed: {message}',
|
||||
'skills.install': 'Install',
|
||||
'skills.installing': 'Installing...',
|
||||
'skills.installedSuccess': 'Installed skill "{name}"',
|
||||
'skills.remove': 'Remove',
|
||||
'skills.activatesOn': 'Activates on',
|
||||
'skills.registryError': 'Could not reach ClawHub registry: {message}',
|
||||
'skills.by': 'by',
|
||||
'skills.updated': 'updated',
|
||||
'skills.loading': 'Loading skills...',
|
||||
'skills.loadFailed': 'Failed to load skills: {message}',
|
||||
'skills.confirmRemove': 'Remove skill "{name}"?',
|
||||
'skills.removeFailed': 'Remove failed: {message}',
|
||||
'skills.removed': 'Removed skill "{name}"',
|
||||
|
||||
// Jobs Summary
|
||||
'jobs.summary.total': 'Total',
|
||||
'jobs.summary.inProgress': 'In Progress',
|
||||
'jobs.summary.completed': 'Completed',
|
||||
'jobs.summary.failed': 'Failed',
|
||||
'jobs.summary.stuck': 'Stuck',
|
||||
|
||||
// Routines Summary
|
||||
'routines.summary.total': 'Total',
|
||||
'routines.summary.enabled': 'Enabled',
|
||||
'routines.summary.disabled': 'Disabled',
|
||||
'routines.summary.failing': 'Failing',
|
||||
'routines.summary.runsToday': 'Runs Today',
|
||||
|
||||
// Buttons
|
||||
'btn.close': 'Close',
|
||||
'btn.cancel': 'Cancel',
|
||||
'btn.save': 'Save',
|
||||
'btn.edit': 'Edit',
|
||||
'btn.confirm': 'Confirm',
|
||||
'btn.send': 'Send',
|
||||
'btn.refresh': 'Refresh',
|
||||
'btn.loadMore': 'Load More',
|
||||
'btn.copy': 'Copy',
|
||||
'btn.copied': 'Copied!',
|
||||
'btn.submit': 'Submit',
|
||||
'btn.setup': 'Setup',
|
||||
|
||||
// Time
|
||||
'time.lessThan1MinuteAgo': '<1m ago',
|
||||
'time.lessThan1MinuteFromNow': 'in <1m',
|
||||
'time.minutesAgo': '{n}m ago',
|
||||
'time.minutesFromNow': 'in {n}m',
|
||||
'time.hoursAgo': '{n}h ago',
|
||||
'time.hoursFromNow': 'in {n}h',
|
||||
'time.daysAgo': '{n}d ago',
|
||||
'time.daysFromNow': 'in {n}d',
|
||||
|
||||
// Tool Approval
|
||||
'approval.title': 'Tool requires approval',
|
||||
'approval.description': 'A tool is requesting permission to run.',
|
||||
'approval.approve': 'Approve',
|
||||
'approval.deny': 'Deny',
|
||||
'approval.always': 'Always',
|
||||
'approval.approved': 'Approved',
|
||||
'approval.alwaysApproved': 'Always approved',
|
||||
'approval.denied': 'Denied',
|
||||
'approval.showParams': 'Show parameters',
|
||||
'approval.hideParams': 'Hide parameters',
|
||||
|
||||
// Authentication Required
|
||||
'authRequired.title': 'Authentication required for {name}',
|
||||
'authRequired.authenticateWith': 'Authenticate with {name}',
|
||||
'authRequired.getToken': 'Get your token',
|
||||
'authRequired.instructions': 'Instructions',
|
||||
|
||||
// Sandbox Jobs
|
||||
'sandbox.job': 'Sandbox Job',
|
||||
'sandbox.doneSignal': 'Done signal sent',
|
||||
|
||||
// Error Messages
|
||||
'error.startConversation': 'Please start a conversation first',
|
||||
'error.restartFailed': 'Restart failed: {message}',
|
||||
'error.tokenRequired': 'Token required',
|
||||
'error.tokenInvalid': 'Invalid token',
|
||||
'error.connectionFailed': 'Connection failed',
|
||||
'error.unknown': 'Unknown error',
|
||||
'error.loadFailed': 'Failed to load: {message}',
|
||||
|
||||
// Success Messages
|
||||
'success.restartInitiated': 'Restart initiated',
|
||||
'success.saved': 'Saved successfully',
|
||||
|
||||
// Slash Commands
|
||||
'cmd.status.desc': 'Show all jobs, or /status <id> for a specific job',
|
||||
'cmd.list.desc': 'List all jobs',
|
||||
'cmd.cancel.desc': '/cancel <job-id> — Cancel a running job',
|
||||
'cmd.undo.desc': 'Undo last action',
|
||||
'cmd.redo.desc': 'Redo undone action',
|
||||
'cmd.compact.desc': 'Compact context window',
|
||||
'cmd.clear.desc': 'Clear conversation and start fresh',
|
||||
'cmd.interrupt.desc': 'Stop current operation',
|
||||
'cmd.heartbeat.desc': 'Trigger manual heartbeat check',
|
||||
'cmd.summarize.desc': 'Summarize current conversation',
|
||||
'cmd.suggest.desc': 'Suggest next actions',
|
||||
'cmd.help.desc': 'Show help',
|
||||
'cmd.version.desc': 'Show version info',
|
||||
'cmd.tools.desc': 'List available tools',
|
||||
'cmd.skills.desc': 'List installed skills',
|
||||
'cmd.model.desc': 'Show or switch LLM model',
|
||||
'cmd.threadNew.desc': 'Create new conversation thread',
|
||||
|
||||
// Language Switcher
|
||||
'language.title': 'Language',
|
||||
'language.en': 'English',
|
||||
'language.zhCN': '简体中文',
|
||||
'language.switch': 'Switch Language',
|
||||
|
||||
// Tool Activity
|
||||
'tool.thinking': 'Thinking...',
|
||||
'tool.completed': 'Completed',
|
||||
'tool.failed': 'Failed',
|
||||
'tool.running': 'Running',
|
||||
'tool.used': '{count} tool(s) used',
|
||||
'tool.requiresApproval': 'Tool requires approval',
|
||||
|
||||
|
||||
// TEE
|
||||
'tee.loadingReport': 'Loading attestation report...',
|
||||
'tee.loadFailed': 'Could not load attestation report',
|
||||
|
||||
// Common
|
||||
'common.loading': 'Loading...',
|
||||
'common.noData': 'No data',
|
||||
'common.search': 'Search',
|
||||
'common.add': 'Add',
|
||||
'common.remove': 'Remove',
|
||||
'common.install': 'Install',
|
||||
'common.activate': 'Activate',
|
||||
'common.deactivate': 'Deactivate',
|
||||
'common.configure': 'Configure',
|
||||
'common.save': 'Save',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.confirm': 'Confirm',
|
||||
'common.close': 'Close',
|
||||
'common.edit': 'Edit',
|
||||
'common.delete': 'Delete',
|
||||
'common.refresh': 'Refresh',
|
||||
'common.searchPlaceholder': 'Search...',
|
||||
'common.name': 'Name',
|
||||
'common.description': 'Description',
|
||||
'common.status': 'Status',
|
||||
'common.actions': 'Actions',
|
||||
'common.version': 'Version',
|
||||
'common.owner': 'Owner',
|
||||
'common.tags': 'Tags',
|
||||
|
||||
// Extensions
|
||||
'ext.active': 'Active',
|
||||
'ext.remove': 'Remove',
|
||||
'ext.install': 'Install',
|
||||
'ext.installing': 'Installing...',
|
||||
'ext.installed': 'Installed',
|
||||
'ext.setup': 'Setup',
|
||||
'ext.reconfigure': 'Reconfigure',
|
||||
'ext.configure': 'Configure',
|
||||
'ext.confirmRemove': 'Remove extension "{name}"?',
|
||||
'ext.removeFailed': 'Remove failed: {message}',
|
||||
'ext.removed': 'Removed {name}',
|
||||
'ext.installFailed': 'Install failed: {message}',
|
||||
|
||||
// Configure
|
||||
'config.title': 'Configure {name}',
|
||||
'config.optional': ' (optional)',
|
||||
'config.alreadySet': '(already set — leave empty to keep)',
|
||||
'config.alreadyConfigured': 'Already configured',
|
||||
'config.autoGenerate': 'Auto-generated if empty',
|
||||
'config.save': 'Save',
|
||||
'config.cancel': 'Cancel',
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
// Lightweight internationalization implementation with dynamic language switching
|
||||
|
||||
const I18n = {
|
||||
currentLang: 'en',
|
||||
fallbackLang: 'en',
|
||||
translations: {},
|
||||
|
||||
// Initialize i18n
|
||||
init() {
|
||||
// Read user preference from localStorage
|
||||
const savedLang = localStorage.getItem('ironclaw_language');
|
||||
if (savedLang && this.translations[savedLang]) {
|
||||
this.currentLang = savedLang;
|
||||
} else {
|
||||
// Detect browser language
|
||||
const browserLang = navigator.language || navigator.userLanguage;
|
||||
this.currentLang = browserLang.startsWith('zh') ? 'zh-CN' : 'en';
|
||||
}
|
||||
this.updateHtmlLang();
|
||||
},
|
||||
|
||||
// Register language pack
|
||||
register(lang, translations) {
|
||||
this.translations[lang] = translations;
|
||||
},
|
||||
|
||||
// Switch language
|
||||
setLanguage(lang) {
|
||||
if (this.translations[lang]) {
|
||||
this.currentLang = lang;
|
||||
localStorage.setItem('ironclaw_language', lang);
|
||||
this.updateHtmlLang();
|
||||
this.updatePageContent();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// Get current language
|
||||
getCurrentLang() {
|
||||
return this.currentLang;
|
||||
},
|
||||
|
||||
// Translate function
|
||||
t(key, params = {}) {
|
||||
const translation = this.translations[this.currentLang]?.[key]
|
||||
|| this.translations[this.fallbackLang]?.[key]
|
||||
|| key;
|
||||
|
||||
// Support placeholder replacement: {name}
|
||||
return translation.replace(/\{(\w+)\}/g, (match, key) => {
|
||||
return params[key] !== undefined ? params[key] : match;
|
||||
});
|
||||
},
|
||||
|
||||
// Update HTML lang attribute
|
||||
updateHtmlLang() {
|
||||
document.documentElement.lang = this.currentLang;
|
||||
},
|
||||
|
||||
// Update page content (traverse all data-i18n elements)
|
||||
updatePageContent() {
|
||||
// Update text content
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
const attr = el.getAttribute('data-i18n-attr');
|
||||
if (attr) {
|
||||
el.setAttribute(attr, this.t(key));
|
||||
} else {
|
||||
el.textContent = this.t(key);
|
||||
}
|
||||
});
|
||||
|
||||
// Update placeholder attributes
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-placeholder');
|
||||
el.placeholder = this.t(key);
|
||||
});
|
||||
|
||||
// Update title attributes
|
||||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-title');
|
||||
el.title = this.t(key);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Global access
|
||||
window.I18n = I18n;
|
||||
@@ -1,351 +0,0 @@
|
||||
// 中文语言包 for IronClaw
|
||||
|
||||
I18n.register('zh-CN', {
|
||||
// 认证页面
|
||||
'auth.title': 'IronClaw',
|
||||
'auth.tagline': '安全可靠的 AI 助手',
|
||||
'auth.tokenLabel': '网关令牌',
|
||||
'auth.tokenPlaceholder': '粘贴你的网关令牌',
|
||||
'auth.connect': '连接',
|
||||
'auth.errorRequired': '请输入令牌',
|
||||
'auth.errorInvalid': '令牌无效',
|
||||
'auth.hint': '输入 .env 配置文件中的 GATEWAY_AUTH_TOKEN',
|
||||
|
||||
// 聊天
|
||||
'chat.inputPlaceholder': '输入消息或 / 以使用命令...',
|
||||
|
||||
// 重启弹窗
|
||||
'restart.title': '重启 IronClaw 实例',
|
||||
'restart.description': '确定要重启 IronClaw 实例吗?这将优雅地重启进程。',
|
||||
'restart.warning': '正在运行的任务可能会中断。重启将在几秒钟内完成。',
|
||||
'restart.cancel': '取消',
|
||||
'restart.confirm': '确认重启',
|
||||
'restart.progressTitle': '正在重启 IronClaw',
|
||||
'restart.progressSubtitle': '请等待进程重启...',
|
||||
'restart.checkLogs': '重启完成后,请查看日志标签页了解详情。',
|
||||
|
||||
// 标签页
|
||||
'tab.chat': '聊天',
|
||||
'tab.memory': '记忆',
|
||||
'tab.jobs': '任务',
|
||||
'tab.routines': '定时任务',
|
||||
'tab.extensions': '扩展',
|
||||
'tab.skills': '技能',
|
||||
'tab.logs': '日志',
|
||||
|
||||
// 状态
|
||||
'status.connected': '已连接',
|
||||
'status.disconnected': '已断开',
|
||||
'status.connecting': '连接中...',
|
||||
'status.reconnecting': '重新连接中...',
|
||||
'status.teeVerified': 'TEE 已验证',
|
||||
'status.restart': '重启',
|
||||
'status.active': '已激活',
|
||||
'status.installed': '已安装',
|
||||
'status.awaitingPairing': '等待配对',
|
||||
|
||||
// 仪表盘
|
||||
'dashboard.connections': '连接数',
|
||||
'dashboard.uptime': '运行时间',
|
||||
'dashboard.costToday': '今日费用',
|
||||
'dashboard.spent': '已花费',
|
||||
'dashboard.actionsPerHour': '每小时操作',
|
||||
'dashboard.sse': 'SSE',
|
||||
'dashboard.websocket': 'WebSocket',
|
||||
|
||||
// 聊天标签页
|
||||
'chat.newThread': '新对话',
|
||||
'chat.toggleSidebar': '切换侧边栏',
|
||||
'chat.assistant': '助手',
|
||||
'chat.conversations': '对话列表',
|
||||
'chat.send': '发送',
|
||||
'chat.attachImages': '附加图片',
|
||||
'chat.empty': '选择文件查看内容',
|
||||
'chat.loading': '加载中...',
|
||||
'chat.loadingOlder': '加载更早的消息...',
|
||||
'chat.noFiles': '工作区没有文件',
|
||||
'chat.noResults': '没有结果',
|
||||
|
||||
// 对话侧边栏
|
||||
'thread.assistant': '助手',
|
||||
'thread.new': '新对话',
|
||||
|
||||
// 记忆标签页
|
||||
'memory.searchPlaceholder': '搜索记忆...',
|
||||
'memory.workspace': '工作区',
|
||||
'memory.edit': '编辑',
|
||||
'memory.save': '保存',
|
||||
'memory.cancel': '取消',
|
||||
'memory.selectFile': '选择文件查看内容',
|
||||
|
||||
// 任务标签页
|
||||
'jobs.summary': '任务摘要',
|
||||
'jobs.id': 'ID',
|
||||
'jobs.title': '标题',
|
||||
'jobs.source': '来源',
|
||||
'jobs.status': '状态',
|
||||
'jobs.created': '创建时间',
|
||||
'jobs.actions': '操作',
|
||||
'jobs.empty': '暂无任务',
|
||||
'jobs.statusRunning': '运行中',
|
||||
'jobs.statusCompleted': '已完成',
|
||||
'jobs.statusFailed': '失败',
|
||||
'jobs.statusPending': '等待中',
|
||||
'jobs.jobId': '任务 ID',
|
||||
'jobs.description': '描述',
|
||||
'jobs.stateTransitions': '状态转换',
|
||||
'jobs.projectFiles': '项目文件',
|
||||
'jobs.noProjectFiles': '没有项目文件',
|
||||
'jobs.viewJob': '查看任务',
|
||||
'jobs.browse': '浏览',
|
||||
|
||||
// 定时任务标签页
|
||||
'routines.summary': '定时任务摘要',
|
||||
'routines.name': '名称',
|
||||
'routines.trigger': '触发器',
|
||||
'routines.action': '操作',
|
||||
'routines.lastRun': '上次运行',
|
||||
'routines.nextRun': '下次运行',
|
||||
'routines.runs': '运行次数',
|
||||
'routines.status': '状态',
|
||||
'routines.actions': '操作',
|
||||
'routines.runsToday': '今日运行',
|
||||
'routines.empty': '暂无定时任务',
|
||||
'routines.noConfigured': '暂无配置的定时任务。请让助手创建一个。',
|
||||
'routines.triggerFailed': '触发失败: {message}',
|
||||
|
||||
// 日志标签页
|
||||
'logs.serverLevel': '服务端日志级别',
|
||||
'logs.clientLevel': '客户端日志级别',
|
||||
'logs.pause': '暂停',
|
||||
'logs.resume': '继续',
|
||||
'logs.clear': '清空',
|
||||
'logs.autoScroll': '自动滚动',
|
||||
'logs.filter': '筛选日志...',
|
||||
'logs.empty': '暂无日志',
|
||||
'logs.allLevels': '所有级别',
|
||||
'logs.error': '错误',
|
||||
'logs.warn': '警告',
|
||||
'logs.info': '信息',
|
||||
'logs.debug': '调试',
|
||||
|
||||
// 扩展标签页
|
||||
'extensions.installed': '已安装扩展',
|
||||
'extensions.available': '可用 WASM 扩展',
|
||||
'extensions.installWasm': '安装 WASM 扩展',
|
||||
'extensions.noInstalled': '没有安装扩展',
|
||||
'extensions.noAvailable': '没有其他可用的 WASM 扩展',
|
||||
'extensions.loading': '加载中...',
|
||||
'extensions.install': '安装',
|
||||
'extensions.installing': '安装中...',
|
||||
'extensions.installedSuccess': '已安装 {name}',
|
||||
'extensions.remove': '移除',
|
||||
'extensions.activate': '激活',
|
||||
'extensions.reconfigure': '重新配置',
|
||||
'extensions.tools': '工具',
|
||||
'extensions.noConfigNeeded': '{name} 不需要配置',
|
||||
'extensions.configure': '配置 {name}',
|
||||
'extensions.optional': ' (可选)',
|
||||
'extensions.autoGenerated': '留空则自动生成',
|
||||
'extensions.pendingPairing': '等待配对请求',
|
||||
'extensions.from': '来自',
|
||||
|
||||
// MCP 服务器
|
||||
'mcp.servers': 'MCP 服务器',
|
||||
'mcp.noServers': '没有可用的 MCP 服务器',
|
||||
'mcp.addCustom': '添加自定义 MCP 服务器',
|
||||
'mcp.add': '添加',
|
||||
'mcp.addedSuccess': '已添加 MCP 服务器 {name}',
|
||||
|
||||
// 注册工具
|
||||
'tools.registered': '注册工具',
|
||||
'tools.name': '名称',
|
||||
'tools.description': '描述',
|
||||
'tools.empty': '没有注册工具',
|
||||
|
||||
// 技能标签页
|
||||
'skills.installed': '已安装技能',
|
||||
'skills.noInstalled': '没有安装技能',
|
||||
'skills.searchClawHub': '搜索 ClawHub',
|
||||
'skills.searchPlaceholder': '搜索...',
|
||||
'skills.installByUrl': '通过 URL 安装技能',
|
||||
'skills.namePlaceholder': '技能名称或标识',
|
||||
'skills.urlPlaceholder': 'SKILL.md 的 HTTPS URL(可选)',
|
||||
'skills.search': '搜索',
|
||||
'skills.searching': '搜索中...',
|
||||
'skills.noResults': '没有找到 "{query}" 相关技能',
|
||||
'skills.searchFailed': '搜索失败: {message}',
|
||||
'skills.install': '安装',
|
||||
'skills.installing': '安装中...',
|
||||
'skills.installedSuccess': '已安装技能 "{name}"',
|
||||
'skills.remove': '移除',
|
||||
'skills.activatesOn': '激活关键词',
|
||||
'skills.registryError': '无法连接 ClawHub 注册表: {message}',
|
||||
'skills.by': '作者',
|
||||
'skills.updated': '更新于',
|
||||
'skills.loading': '加载技能中...',
|
||||
'skills.loadFailed': '加载技能失败: {message}',
|
||||
'skills.confirmRemove': '确定要移除技能 "{name}" 吗?',
|
||||
'skills.removeFailed': '移除失败: {message}',
|
||||
'skills.removed': '已移除技能 "{name}"',
|
||||
|
||||
// 任务摘要
|
||||
'jobs.summary.total': '总计',
|
||||
'jobs.summary.inProgress': '进行中',
|
||||
'jobs.summary.completed': '已完成',
|
||||
'jobs.summary.failed': '失败',
|
||||
'jobs.summary.stuck': '卡住',
|
||||
|
||||
// 定时任务摘要
|
||||
'routines.summary.total': '总计',
|
||||
'routines.summary.enabled': '已启用',
|
||||
'routines.summary.disabled': '已禁用',
|
||||
'routines.summary.failing': '失败',
|
||||
'routines.summary.runsToday': '今日运行',
|
||||
|
||||
// 按钮
|
||||
'btn.close': '关闭',
|
||||
'btn.cancel': '取消',
|
||||
'btn.save': '保存',
|
||||
'btn.edit': '编辑',
|
||||
'btn.confirm': '确认',
|
||||
'btn.send': '发送',
|
||||
'btn.refresh': '刷新',
|
||||
'btn.loadMore': '加载更多',
|
||||
'btn.copy': '复制',
|
||||
'btn.copied': '已复制!',
|
||||
'btn.submit': '提交',
|
||||
'btn.setup': '设置',
|
||||
|
||||
// 时间
|
||||
'time.lessThan1MinuteAgo': '刚刚',
|
||||
'time.lessThan1MinuteFromNow': '1分钟内',
|
||||
'time.minutesAgo': '{n}分钟前',
|
||||
'time.minutesFromNow': '{n}分钟后',
|
||||
'time.hoursAgo': '{n}小时前',
|
||||
'time.hoursFromNow': '{n}小时后',
|
||||
'time.daysAgo': '{n}天前',
|
||||
'time.daysFromNow': '{n}天后',
|
||||
|
||||
// 工具审批
|
||||
'approval.title': '工具需要审批',
|
||||
'approval.description': '一个工具请求运行权限。',
|
||||
'approval.approve': '批准',
|
||||
'approval.deny': '拒绝',
|
||||
'approval.always': '始终允许',
|
||||
'approval.approved': '已批准',
|
||||
'approval.alwaysApproved': '始终批准',
|
||||
'approval.denied': '已拒绝',
|
||||
'approval.showParams': '显示参数',
|
||||
'approval.hideParams': '隐藏参数',
|
||||
|
||||
// 认证
|
||||
'authRequired.title': '{name} 需要认证',
|
||||
'authRequired.authenticateWith': '使用 {name} 认证',
|
||||
'authRequired.getToken': '获取令牌',
|
||||
'authRequired.instructions': '说明',
|
||||
|
||||
// 沙盒任务
|
||||
'sandbox.job': '沙盒任务',
|
||||
'sandbox.doneSignal': '完成信号已发送',
|
||||
|
||||
// 错误消息
|
||||
'error.startConversation': '请先开始一个对话',
|
||||
'error.restartFailed': '重启失败: {message}',
|
||||
'error.tokenRequired': '请输入令牌',
|
||||
'error.tokenInvalid': '令牌无效',
|
||||
'error.connectionFailed': '连接失败',
|
||||
'error.unknown': '未知错误',
|
||||
'error.loadFailed': '加载失败: {message}',
|
||||
|
||||
// 成功消息
|
||||
'success.restartInitiated': '已开始重启',
|
||||
'success.saved': '保存成功',
|
||||
|
||||
// 斜杠命令
|
||||
'cmd.status.desc': '显示所有任务,或使用 /status <id> 查看特定任务',
|
||||
'cmd.list.desc': '列出所有任务',
|
||||
'cmd.cancel.desc': '/cancel <job-id> — 取消正在运行的任务',
|
||||
'cmd.undo.desc': '撤销上一步',
|
||||
'cmd.redo.desc': '重做已撤销的操作',
|
||||
'cmd.compact.desc': '压缩上下文窗口',
|
||||
'cmd.clear.desc': '清空对话并重新开始',
|
||||
'cmd.interrupt.desc': '停止当前操作',
|
||||
'cmd.heartbeat.desc': '触发手动心跳检查',
|
||||
'cmd.summarize.desc': '总结当前对话',
|
||||
'cmd.suggest.desc': '建议下一步操作',
|
||||
'cmd.help.desc': '显示帮助',
|
||||
'cmd.version.desc': '显示版本信息',
|
||||
'cmd.tools.desc': '列出可用工具',
|
||||
'cmd.skills.desc': '列出已安装的 AI 技能',
|
||||
'cmd.model.desc': '显示或切换 LLM 模型',
|
||||
'cmd.threadNew.desc': '创建新对话线程',
|
||||
|
||||
// 语言切换
|
||||
'language.title': '语言',
|
||||
'language.en': 'English',
|
||||
'language.zhCN': '简体中文',
|
||||
'language.switch': '切换语言',
|
||||
|
||||
// 工具活动
|
||||
'tool.thinking': '思考中...',
|
||||
'tool.completed': '已完成',
|
||||
'tool.failed': '失败',
|
||||
'tool.running': '运行中',
|
||||
'tool.used': '{count} 个工具已使用',
|
||||
'tool.requiresApproval': '工具需要审批',
|
||||
|
||||
|
||||
// TEE
|
||||
'tee.loadingReport': '正在加载证明报告...',
|
||||
'tee.loadFailed': '无法加载证明报告',
|
||||
|
||||
// 通用
|
||||
'common.loading': '加载中...',
|
||||
'common.noData': '暂无数据',
|
||||
'common.search': '搜索',
|
||||
'common.add': '添加',
|
||||
'common.remove': '移除',
|
||||
'common.install': '安装',
|
||||
'common.activate': '激活',
|
||||
'common.deactivate': '停用',
|
||||
'common.configure': '配置',
|
||||
'common.save': '保存',
|
||||
'common.cancel': '取消',
|
||||
'common.confirm': '确认',
|
||||
'common.close': '关闭',
|
||||
'common.edit': '编辑',
|
||||
'common.delete': '删除',
|
||||
'common.refresh': '刷新',
|
||||
'common.searchPlaceholder': '搜索...',
|
||||
'common.name': '名称',
|
||||
'common.description': '描述',
|
||||
'common.status': '状态',
|
||||
'common.actions': '操作',
|
||||
'common.version': '版本',
|
||||
'common.owner': '作者',
|
||||
'common.tags': '标签',
|
||||
|
||||
// 扩展
|
||||
'ext.active': '已激活',
|
||||
'ext.remove': '移除',
|
||||
'ext.install': '安装',
|
||||
'ext.installing': '安装中...',
|
||||
'ext.installed': '已安装',
|
||||
'ext.setup': '设置',
|
||||
'ext.reconfigure': '重新配置',
|
||||
'ext.configure': '配置',
|
||||
'ext.confirmRemove': '确定要移除扩展 "{name}" 吗?',
|
||||
'ext.removeFailed': '移除失败: {message}',
|
||||
'ext.removed': '已移除 {name}',
|
||||
'ext.installFailed': '安装失败: {message}',
|
||||
|
||||
// 配置
|
||||
'config.title': '配置 {name}',
|
||||
'config.optional': '(可选)',
|
||||
'config.alreadySet': '(已设置 — 留空以保持不变)',
|
||||
'config.alreadyConfigured': '已配置',
|
||||
'config.autoGenerate': '如果为空则自动生成',
|
||||
'config.save': '保存',
|
||||
'config.cancel': '取消',
|
||||
});
|
||||
@@ -9,17 +9,6 @@
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
|
||||
<!-- i18n Modules -->
|
||||
<script src="/i18n/index.js"></script>
|
||||
<script src="/i18n/en.js"></script>
|
||||
<script src="/i18n/zh-CN.js"></script>
|
||||
|
||||
<script
|
||||
src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js"
|
||||
integrity="sha384-osZDKVu4ipZP703HmPOhWdyBajcFyjX2Psjk//TG1Rc0AdwEtuToaylrmcK3LdAl"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/[email protected]/lib/marked.umd.min.js"
|
||||
integrity="sha384-pN9zSKOnTZwXRtYZAu0PBPEgR2B7DOC1aeLxQ33oJ0oy5iN1we6gm57xldM2irDG"
|
||||
@@ -31,16 +20,16 @@
|
||||
<div id="auth-screen">
|
||||
<div class="auth-card-login">
|
||||
<div class="auth-brand">
|
||||
<h1 data-i18n="auth.title">IronClaw</h1>
|
||||
<p class="auth-tagline" data-i18n="auth.tagline">Secure AI Assistant</p>
|
||||
<h1>IronClaw</h1>
|
||||
<p class="auth-tagline">Secure AI Assistant</p>
|
||||
</div>
|
||||
<div class="auth-form">
|
||||
<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>
|
||||
<button onclick="authenticate()" data-i18n="auth.connect">Connect</button>
|
||||
<label for="token-input">Gateway Token</label>
|
||||
<input type="password" id="token-input" placeholder="Paste your auth token" autofocus>
|
||||
<button onclick="authenticate()">Connect</button>
|
||||
</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">Enter the GATEWAY_AUTH_TOKEN from your .env configuration.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -49,22 +38,21 @@
|
||||
<div class="restart-modal-overlay" onclick="cancelRestart()"></div>
|
||||
<div class="restart-modal-content">
|
||||
<div class="restart-modal-header">
|
||||
<h2 data-i18n="restart.title">Restart IronClaw Instance</h2>
|
||||
<button class="restart-modal-close" onclick="cancelRestart()" data-i18n="restart.closeTooltip" data-i18n-attr="title"
|
||||
title="Close">×</button>
|
||||
<h2>Restart IronClaw Instance</h2>
|
||||
<button class="restart-modal-close" onclick="cancelRestart()" title="Close">×</button>
|
||||
</div>
|
||||
<div class="restart-modal-body">
|
||||
<p class="restart-modal-description" data-i18n="restart.description">
|
||||
<p class="restart-modal-description">
|
||||
Are you sure you want to restart the IronClaw instance? This will gracefully restart the process.
|
||||
</p>
|
||||
<div class="restart-modal-warning">
|
||||
<span class="restart-modal-warning-icon">⚠️</span>
|
||||
<p data-i18n="restart.warning">Any in-progress jobs may be interrupted. The restart will complete within a few seconds.</p>
|
||||
<p>Any in-progress jobs may be interrupted. The restart will complete within a few seconds.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="restart-modal-footer">
|
||||
<button class="restart-modal-btn cancel" onclick="cancelRestart()" 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 cancel" onclick="cancelRestart()">Cancel</button>
|
||||
<button class="restart-modal-btn confirm" onclick="confirmRestart()">Confirm Restart</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -75,13 +63,13 @@
|
||||
<div class="restart-loader-content">
|
||||
<div class="restart-spinner"></div>
|
||||
<div class="restart-loader-text">
|
||||
<p class="restart-title" data-i18n="restart.progressTitle">Restarting IronClaw</p>
|
||||
<p class="restart-subtitle" data-i18n="restart.progressSubtitle">Please wait while the process restarts...</p>
|
||||
<p class="restart-title">Restarting IronClaw</p>
|
||||
<p class="restart-subtitle">Please wait while the process restarts...</p>
|
||||
</div>
|
||||
<div class="restart-progress-bar">
|
||||
<div class="restart-progress-fill"></div>
|
||||
</div>
|
||||
<p class="restart-modal-info" data-i18n="restart.checkLogs">
|
||||
<p class="restart-modal-info">
|
||||
Check the Logs tab for details after the restart completes.
|
||||
</p>
|
||||
</div>
|
||||
@@ -91,45 +79,33 @@
|
||||
<div id="app">
|
||||
<!-- Tab Bar -->
|
||||
<div class="tab-bar">
|
||||
<button class="active" data-tab="chat" data-i18n="tab.chat">Chat</button>
|
||||
<button data-tab="memory" data-i18n="tab.memory">Memory</button>
|
||||
<button data-tab="jobs" data-i18n="tab.jobs">Jobs</button>
|
||||
<button data-tab="routines" data-i18n="tab.routines">Routines</button>
|
||||
<button data-tab="extensions" data-i18n="tab.extensions">Extensions</button>
|
||||
<button data-tab="skills" data-i18n="tab.skills">Skills</button>
|
||||
<button class="active" data-tab="chat">Chat</button>
|
||||
<button data-tab="memory">Memory</button>
|
||||
<button data-tab="jobs">Jobs</button>
|
||||
<button data-tab="routines">Routines</button>
|
||||
<button data-tab="extensions">Extensions</button>
|
||||
<button data-tab="skills">Skills</button>
|
||||
<div class="spacer"></div>
|
||||
|
||||
<!-- Language Switcher -->
|
||||
<div class="language-switcher">
|
||||
<button class="language-btn" id="language-btn" type="button" onclick="toggleLanguageMenu()" title="Switch Language"
|
||||
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;">
|
||||
<button type="button" class="language-option" onclick="switchLanguage('en')" data-lang="en">English</button>
|
||||
<button type="button" class="language-option" onclick="switchLanguage('zh-CN')" data-lang="zh-CN">简体中文</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="status-logs-btn" data-tab="logs" data-i18n="tab.logs" title="Logs">Logs</button>
|
||||
<button class="status-logs-btn" data-tab="logs" title="Logs">Logs</button>
|
||||
<div class="tee-shield" id="tee-shield" style="display:none" title="Running in a Trusted Execution Environment">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
<span id="tee-shield-label" data-i18n="status.teeVerified">TEE Verified</span>
|
||||
<span id="tee-shield-label">TEE Verified</span>
|
||||
<div class="tee-popover" id="tee-popover"></div>
|
||||
</div>
|
||||
<div class="status" id="gateway-status-trigger">
|
||||
<div class="dot" id="sse-dot"></div>
|
||||
<span id="sse-status" data-i18n="status.connected">Connected</span>
|
||||
<span id="sse-status">Connected</span>
|
||||
<div class="gateway-popover" id="gateway-popover"></div>
|
||||
</div>
|
||||
<button class="restart-btn" id="restart-btn" onclick="triggerRestart()" data-i18n="status.restartTooltip"
|
||||
data-i18n-attr="title" title="Gracefully restart the process" style="display: none;">
|
||||
<button class="restart-btn" id="restart-btn" onclick="triggerRestart()" 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">
|
||||
<path d="M23 4v6h-6"></path>
|
||||
<path d="M1 20v-6h6"></path>
|
||||
<path d="M3.51 9a9 9 0 0114.85-3.36M20.49 15a9 9 0 01-14.85 3.36"></path>
|
||||
</svg>
|
||||
<span data-i18n="status.restart">Restart</span>
|
||||
<span>Restart</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -137,18 +113,16 @@
|
||||
<div class="tab-panel active" id="tab-chat">
|
||||
<div class="thread-sidebar" id="thread-sidebar">
|
||||
<div class="thread-sidebar-header">
|
||||
<button class="thread-new-btn" onclick="createNewThread()" data-i18n="chat.newThread" data-i18n-attr="title"
|
||||
title="New thread (Ctrl/Cmd+N)">+</button>
|
||||
<button class="thread-new-btn" onclick="createNewThread()" title="New thread (Ctrl/Cmd+N)">+</button>
|
||||
<div class="spacer"></div>
|
||||
<button class="thread-toggle-btn" id="thread-toggle-btn" onclick="toggleThreadSidebar()" data-i18n="chat.toggleSidebar"
|
||||
data-i18n-attr="title" title="Toggle sidebar">«</button>
|
||||
<button class="thread-toggle-btn" id="thread-toggle-btn" onclick="toggleThreadSidebar()" title="Toggle sidebar">«</button>
|
||||
</div>
|
||||
<div class="assistant-item" id="assistant-thread" onclick="switchToAssistant()">
|
||||
<span class="assistant-label" id="assistant-label" data-i18n="chat.assistant">Assistant</span>
|
||||
<span class="assistant-label" id="assistant-label">Assistant</span>
|
||||
<span class="assistant-meta" id="assistant-meta"></span>
|
||||
</div>
|
||||
<div class="threads-section-header">
|
||||
<span data-i18n="chat.conversations">Conversations</span>
|
||||
<span>Conversations</span>
|
||||
</div>
|
||||
<div class="thread-list" id="thread-list"></div>
|
||||
</div>
|
||||
@@ -157,11 +131,10 @@
|
||||
<div id="slash-autocomplete" class="slash-autocomplete" style="display:none"></div>
|
||||
<div class="chat-input">
|
||||
<div id="image-preview-strip" class="image-preview-strip"></div>
|
||||
<textarea id="chat-input" data-i18n="chat.inputPlaceholder" data-i18n-attr="placeholder" placeholder="Message or / for commands..." rows="1"></textarea>
|
||||
<textarea id="chat-input" placeholder="Message or / for commands..." rows="1"></textarea>
|
||||
<input type="file" id="image-file-input" accept="image/*" multiple style="display:none">
|
||||
<button id="attach-btn" class="attach-btn" data-i18n="chat.attachImages" data-i18n-attr="title" title="Attach images"
|
||||
aria-label="Attach images">📎</button>
|
||||
<button id="send-btn" onclick="sendMessage()" data-i18n="chat.send">Send</button>
|
||||
<button id="attach-btn" class="attach-btn" title="Attach images" aria-label="Attach images">📎</button>
|
||||
<button id="send-btn" onclick="sendMessage()">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -171,23 +144,23 @@
|
||||
<div class="memory-container">
|
||||
<div class="memory-sidebar">
|
||||
<div class="search-box">
|
||||
<input type="text" id="memory-search" data-i18n="memory.searchPlaceholder" data-i18n-attr="placeholder" placeholder="Search memory...">
|
||||
<input type="text" id="memory-search" placeholder="Search memory...">
|
||||
</div>
|
||||
<div class="memory-tree" id="memory-tree"></div>
|
||||
</div>
|
||||
<div class="memory-content">
|
||||
<div class="memory-breadcrumb" id="memory-breadcrumb">
|
||||
<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" onclick="startMemoryEdit()">Edit</button>
|
||||
</div>
|
||||
<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">Select a file to view its contents</div>
|
||||
</div>
|
||||
<div class="memory-editor" id="memory-editor" style="display:none">
|
||||
<textarea id="memory-edit-textarea"></textarea>
|
||||
<div class="memory-editor-actions">
|
||||
<button class="btn-save" onclick="saveMemoryEdit()" data-i18n="memory.save">Save</button>
|
||||
<button class="btn-cancel-edit" onclick="cancelMemoryEdit()" data-i18n="memory.cancel">Cancel</button>
|
||||
<button class="btn-save" onclick="saveMemoryEdit()">Save</button>
|
||||
<button class="btn-cancel-edit" onclick="cancelMemoryEdit()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -201,17 +174,17 @@
|
||||
<table class="jobs-table" id="jobs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="jobs.id">ID</th>
|
||||
<th data-i18n="jobs.title">Title</th>
|
||||
<th data-i18n="jobs.source">Source</th>
|
||||
<th data-i18n="jobs.status">Status</th>
|
||||
<th data-i18n="jobs.created">Created</th>
|
||||
<th data-i18n="jobs.actions">Actions</th>
|
||||
<th>ID</th>
|
||||
<th>Title</th>
|
||||
<th>Source</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="jobs-tbody"></tbody>
|
||||
</table>
|
||||
<div class="empty-state" id="jobs-empty" style="display:none" data-i18n="jobs.empty">No jobs found</div>
|
||||
<div class="empty-state" id="jobs-empty" style="display:none">No jobs found</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -226,16 +199,16 @@
|
||||
<option value="debug">Server: DEBUG</option>
|
||||
</select>
|
||||
<select id="logs-level-filter">
|
||||
<option value="all" data-i18n="logs.allLevels">All Levels</option>
|
||||
<option value="ERROR" data-i18n="logs.error">Error</option>
|
||||
<option value="WARN" data-i18n="logs.warn">Warn</option>
|
||||
<option value="INFO" data-i18n="logs.info">Info</option>
|
||||
<option value="DEBUG" data-i18n="logs.debug">Debug</option>
|
||||
<option value="all">All Levels</option>
|
||||
<option value="ERROR">Error</option>
|
||||
<option value="WARN">Warn</option>
|
||||
<option value="INFO">Info</option>
|
||||
<option value="DEBUG">Debug</option>
|
||||
</select>
|
||||
<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>
|
||||
<button id="logs-pause-btn" onclick="toggleLogsPause()" data-i18n="logs.pause">Pause</button>
|
||||
<button onclick="clearLogs()" data-i18n="logs.clear">Clear</button>
|
||||
<label class="logs-checkbox"><input type="checkbox" id="logs-autoscroll" checked> Auto-scroll</label>
|
||||
<button id="logs-pause-btn" onclick="toggleLogsPause()">Pause</button>
|
||||
<button onclick="clearLogs()">Clear</button>
|
||||
</div>
|
||||
<div class="logs-output" id="logs-output"></div>
|
||||
</div>
|
||||
@@ -248,20 +221,20 @@
|
||||
<table class="routines-table" id="routines-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="routines.name">Name</th>
|
||||
<th data-i18n="routines.trigger">Trigger</th>
|
||||
<th data-i18n="routines.action">Action</th>
|
||||
<th data-i18n="routines.lastRun">Last Run</th>
|
||||
<th data-i18n="routines.nextRun">Next Run</th>
|
||||
<th data-i18n="routines.runs">Runs</th>
|
||||
<th data-i18n="routines.status">Status</th>
|
||||
<th data-i18n="routines.actions">Actions</th>
|
||||
<th>Name</th>
|
||||
<th>Trigger</th>
|
||||
<th>Action</th>
|
||||
<th>Last Run</th>
|
||||
<th>Next Run</th>
|
||||
<th>Runs</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="routines-tbody"></tbody>
|
||||
</table>
|
||||
<div class="empty-state" id="routines-empty" style="display:none">
|
||||
<span data-i18n="routines.noConfigured">No routines configured. Ask the assistant to create one.</span>
|
||||
No routines configured. Ask the assistant to create one.
|
||||
</div>
|
||||
<div class="routine-detail" id="routine-detail" style="display:none"></div>
|
||||
</div>
|
||||
@@ -271,44 +244,44 @@
|
||||
<div class="tab-panel" id="tab-extensions">
|
||||
<div class="extensions-container">
|
||||
<div class="extensions-section">
|
||||
<h3 data-i18n="extensions.installed">Installed Extensions</h3>
|
||||
<h3>Installed Extensions</h3>
|
||||
<div class="extensions-list" id="extensions-list">
|
||||
<div class="empty-state" data-i18n="common.loading">Loading...</div>
|
||||
<div class="empty-state">Loading extensions...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extensions-section" id="available-wasm-section">
|
||||
<h3 data-i18n="extensions.available">Available WASM Extensions</h3>
|
||||
<h3>Available WASM Extensions</h3>
|
||||
<div class="extensions-list" id="available-wasm-list">
|
||||
<div class="empty-state" data-i18n="common.loading">Loading...</div>
|
||||
<div class="empty-state">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extensions-section">
|
||||
<h3 data-i18n="extensions.installWasm">Install WASM Extension</h3>
|
||||
<h3>Install WASM Extension</h3>
|
||||
<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" placeholder="Extension name">
|
||||
<input type="text" id="wasm-install-url" placeholder="URL to .tar.gz bundle">
|
||||
<button onclick="installWasmExtension()" data-i18n="extensions.install">Install</button>
|
||||
<button onclick="installWasmExtension()">Install</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extensions-section">
|
||||
<h3 data-i18n="mcp.servers">MCP Servers</h3>
|
||||
<h3>MCP Servers</h3>
|
||||
<div class="extensions-list" id="mcp-servers-list">
|
||||
<div class="empty-state" data-i18n="common.loading">Loading...</div>
|
||||
<div class="empty-state">Loading...</div>
|
||||
</div>
|
||||
<h4 data-i18n="mcp.addCustom">Add Custom MCP Server</h4>
|
||||
<h4>Add Custom MCP Server</h4>
|
||||
<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" placeholder="Server name">
|
||||
<input type="text" id="mcp-install-url" placeholder="MCP server URL (https://...)">
|
||||
<button onclick="addMcpServer()" data-i18n="mcp.add">Add</button>
|
||||
<button onclick="addMcpServer()">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extensions-section">
|
||||
<h3 data-i18n="tools.registered">Registered Tools</h3>
|
||||
<h3>Registered Tools</h3>
|
||||
<table class="tools-table" id="tools-table">
|
||||
<thead><tr><th data-i18n="tools.name">Name</th><th data-i18n="tools.description">Description</th></tr></thead>
|
||||
<thead><tr><th>Name</th><th>Description</th></tr></thead>
|
||||
<tbody id="tools-tbody"></tbody>
|
||||
</table>
|
||||
<div class="empty-state" id="tools-empty" style="display:none" data-i18n="tools.empty">No tools registered</div>
|
||||
<div class="empty-state" id="tools-empty" style="display:none">No tools registered</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -317,25 +290,25 @@
|
||||
<div class="tab-panel" id="tab-skills">
|
||||
<div class="extensions-container">
|
||||
<div class="extensions-section">
|
||||
<h3 data-i18n="skills.searchClawHub">Search ClawHub</h3>
|
||||
<h3>Search ClawHub</h3>
|
||||
<div class="skill-search-box">
|
||||
<input type="text" id="skill-search-input" data-i18n-placeholder="skills.searchPlaceholder" placeholder="Search...">
|
||||
<button onclick="searchClawHub()" data-i18n="skills.search">Search</button>
|
||||
<input type="text" id="skill-search-input" placeholder="Search for skills...">
|
||||
<button onclick="searchClawHub()">Search</button>
|
||||
</div>
|
||||
<div class="extensions-list" id="skill-search-results"></div>
|
||||
</div>
|
||||
<div class="extensions-section">
|
||||
<h3 data-i18n="skills.installed">Installed Skills</h3>
|
||||
<h3>Installed Skills</h3>
|
||||
<div class="extensions-list" id="skills-list">
|
||||
<div class="empty-state" data-i18n="skills.loading">Loading skills...</div>
|
||||
<div class="empty-state">Loading skills...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extensions-section">
|
||||
<h3 data-i18n="skills.installByUrl">Install Skill by URL</h3>
|
||||
<h3>Install Skill by URL</h3>
|
||||
<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-url" data-i18n-placeholder="skills.urlPlaceholder" placeholder="HTTPS URL to SKILL.md (optional)">
|
||||
<button onclick="installSkillFromForm()" data-i18n="extensions.install">Install</button>
|
||||
<input type="text" id="skill-install-name" placeholder="Skill name or slug">
|
||||
<input type="text" id="skill-install-url" placeholder="HTTPS URL to SKILL.md (optional)">
|
||||
<button onclick="installSkillFromForm()">Install</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -344,6 +317,5 @@
|
||||
|
||||
<div id="toasts"></div>
|
||||
<script src="/app.js"></script>
|
||||
<script src="/i18n-app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
--text-secondary: #a1a1aa;
|
||||
--accent: #34d399;
|
||||
--accent-hover: #2fc48d;
|
||||
--accent-soft: rgba(52, 211, 153, 0.15);
|
||||
--success: #34d399;
|
||||
--warning: #F5A623;
|
||||
--danger: #E64C4C;
|
||||
@@ -656,11 +655,11 @@ body {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 72%;
|
||||
max-width: 80%;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 14px;
|
||||
@@ -670,8 +669,8 @@ body {
|
||||
|
||||
.message.user {
|
||||
align-self: flex-end;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: #09090b;
|
||||
border-bottom-right-radius: 2px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -681,9 +680,6 @@ body {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-bottom-left-radius: 2px;
|
||||
padding: 14px 18px;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.message.system {
|
||||
@@ -714,10 +710,10 @@ body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.message p { margin: 0 0 10px 0; }
|
||||
.message p { margin: 0 0 8px 0; }
|
||||
.message p:last-child { margin-bottom: 0; }
|
||||
.message ul, .message ol { margin: 4px 0; padding-left: 20px; }
|
||||
.message li { margin: 4px 0; }
|
||||
.message li { margin: 2px 0; }
|
||||
.message blockquote {
|
||||
margin: 6px 0;
|
||||
padding: 4px 12px;
|
||||
@@ -1066,7 +1062,7 @@ body {
|
||||
}
|
||||
|
||||
.approval-card .approval-actions button:disabled {
|
||||
opacity: 0.5;
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -1245,7 +1241,7 @@ body {
|
||||
}
|
||||
|
||||
.auth-card .auth-actions button:disabled {
|
||||
opacity: 0.5;
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -1305,11 +1301,6 @@ body {
|
||||
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
|
||||
}
|
||||
|
||||
.chat-input textarea:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chat-input button {
|
||||
padding: 8px 20px;
|
||||
background: var(--accent);
|
||||
@@ -1323,7 +1314,7 @@ body {
|
||||
transition: background 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.chat-input button:hover:not(:disabled) {
|
||||
.chat-input button:hover {
|
||||
background: var(--accent-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
@@ -1333,18 +1324,8 @@ body {
|
||||
}
|
||||
|
||||
.chat-input button:disabled {
|
||||
opacity: 0.6;
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Keyboard accessibility focus rings */
|
||||
.chat-input textarea:focus-visible,
|
||||
.chat-input button:focus-visible,
|
||||
.tab-bar button:focus-visible,
|
||||
.tree-row:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Memory Tab */
|
||||
@@ -1444,7 +1425,7 @@ body {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tree-row:hover .tree-label.file {
|
||||
.tree-label.file:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@@ -2326,7 +2307,7 @@ body {
|
||||
}
|
||||
|
||||
.log-entry:hover {
|
||||
background: var(--bg-tertiary);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.log-ts {
|
||||
@@ -3800,7 +3781,7 @@ mark {
|
||||
}
|
||||
|
||||
/* Image Upload */
|
||||
.chat-input .attach-btn {
|
||||
.attach-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
@@ -3813,13 +3794,10 @@ mark {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.chat-input .attach-btn:hover {
|
||||
background: none;
|
||||
.attach-btn:hover {
|
||||
color: var(--text);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.image-preview-strip {
|
||||
@@ -3885,61 +3863,6 @@ mark {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Language Switcher */
|
||||
.language-switcher {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.language-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
font-size: 16px;
|
||||
border-radius: var(--radius);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.language-btn:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.language-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 4px;
|
||||
min-width: 120px;
|
||||
z-index: 1000;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.language-option {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.language-option:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.language-option.active {
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.generated-image-path {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
+27
-15
@@ -255,31 +255,43 @@ async fn handle_client_message(
|
||||
token,
|
||||
} => {
|
||||
if let Some(ref ext_mgr) = state.extension_manager {
|
||||
match ext_mgr.configure_token(&extension_name, &token).await {
|
||||
Ok(result) => {
|
||||
match ext_mgr.auth(&extension_name, Some(&token)).await {
|
||||
Ok(result) if result.is_authenticated() => {
|
||||
let msg = match ext_mgr.activate(&extension_name).await {
|
||||
Ok(r) => format!(
|
||||
"{} authenticated ({} tools loaded)",
|
||||
extension_name,
|
||||
r.tools_loaded.len()
|
||||
),
|
||||
Err(e) => format!(
|
||||
"{} authenticated but activation failed: {}",
|
||||
extension_name, e
|
||||
),
|
||||
};
|
||||
crate::channels::web::server::clear_auth_mode(state).await;
|
||||
state
|
||||
.sse
|
||||
.broadcast(crate::channels::web::types::SseEvent::AuthCompleted {
|
||||
extension_name,
|
||||
success: true,
|
||||
message: result.message,
|
||||
message: msg,
|
||||
});
|
||||
}
|
||||
Ok(result) => {
|
||||
state
|
||||
.sse
|
||||
.broadcast(crate::channels::web::types::SseEvent::AuthRequired {
|
||||
extension_name,
|
||||
instructions: result.instructions().map(String::from),
|
||||
auth_url: result.auth_url().map(String::from),
|
||||
setup_url: result.setup_url().map(String::from),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("Auth failed: {}", e);
|
||||
if matches!(e, crate::extensions::ExtensionError::ValidationFailed(_)) {
|
||||
state.sse.broadcast(
|
||||
crate::channels::web::types::SseEvent::AuthRequired {
|
||||
extension_name: extension_name.clone(),
|
||||
instructions: Some(msg.clone()),
|
||||
auth_url: None,
|
||||
setup_url: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
let _ = direct_tx
|
||||
.send(WsServerMessage::Error { message: msg })
|
||||
.send(WsServerMessage::Error {
|
||||
message: format!("Auth failed: {}", e),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ pub struct WebhookServerConfig {
|
||||
pub struct WebhookServer {
|
||||
config: WebhookServerConfig,
|
||||
routes: Vec<Router>,
|
||||
/// Merged router saved after start() for restarts via `install_listener()`.
|
||||
/// Merged router saved after start() for restart_with_addr().
|
||||
merged_router: Option<Router>,
|
||||
shutdown_tx: Option<oneshot::Sender<()>>,
|
||||
handle: Option<JoinHandle<()>>,
|
||||
@@ -59,7 +59,7 @@ impl WebhookServer {
|
||||
}
|
||||
|
||||
/// Bind a listener to the configured address and spawn the server task.
|
||||
/// Private helper used by `start()`.
|
||||
/// Private helper used by both start() and restart_with_addr().
|
||||
async fn bind_and_spawn(&mut self, app: Router) -> Result<(), ChannelError> {
|
||||
let listener = tokio::net::TcpListener::bind(self.config.addr)
|
||||
.await
|
||||
@@ -89,49 +89,47 @@ impl WebhookServer {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clone the merged router, if `start()` has been called.
|
||||
pub fn merged_router_clone(&self) -> Option<Router> {
|
||||
self.merged_router.clone()
|
||||
}
|
||||
|
||||
/// Install a pre-bound listener, replacing the current one.
|
||||
/// Gracefully shut down the current listener and rebind to a new address.
|
||||
/// The merged router from the original `start()` call is reused.
|
||||
///
|
||||
/// The caller is responsible for binding the `TcpListener` *outside* any
|
||||
/// lock so that the async bind does not block other lock waiters. This
|
||||
/// method only does synchronous bookkeeping plus spawning the (non-blocking)
|
||||
/// server task, so it is safe to call while holding a mutex.
|
||||
pub fn install_listener(
|
||||
&mut self,
|
||||
new_addr: SocketAddr,
|
||||
listener: tokio::net::TcpListener,
|
||||
app: Router,
|
||||
) -> (Option<oneshot::Sender<()>>, Option<JoinHandle<()>>) {
|
||||
// Capture old handles so the caller can shut them down outside the lock.
|
||||
/// If binding to the new address fails, the old listener remains active and
|
||||
/// state is restored. This prevents a denial-of-service if the new address
|
||||
/// is invalid or already in use.
|
||||
pub async fn restart_with_addr(&mut self, new_addr: SocketAddr) -> Result<(), ChannelError> {
|
||||
let app = self
|
||||
.merged_router
|
||||
.clone()
|
||||
.ok_or_else(|| ChannelError::StartupFailed {
|
||||
name: "webhook_server".to_string(),
|
||||
reason: "restart_with_addr called before start()".to_string(),
|
||||
})?;
|
||||
|
||||
// Save old state for rollback if new bind fails
|
||||
let old_addr = self.config.addr;
|
||||
let old_shutdown_tx = self.shutdown_tx.take();
|
||||
let old_handle = self.handle.take();
|
||||
|
||||
// Update config to new address and try to bind
|
||||
self.config.addr = new_addr;
|
||||
|
||||
// Spawn the new server task (non-blocking).
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
||||
self.shutdown_tx = Some(shutdown_tx);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
if let Err(e) = axum::serve(listener, app)
|
||||
.with_graceful_shutdown(async {
|
||||
let _ = shutdown_rx.await;
|
||||
tracing::debug!("Webhook server shutting down");
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::error!("Webhook server error: {}", e);
|
||||
match self.bind_and_spawn(app).await {
|
||||
Ok(()) => {
|
||||
// New listener is running, gracefully shut down the old one
|
||||
if let Some(tx) = old_shutdown_tx {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
if let Some(handle) = old_handle {
|
||||
let _ = handle.await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
});
|
||||
self.handle = Some(handle);
|
||||
|
||||
tracing::info!("Webhook server listening on {}", new_addr);
|
||||
|
||||
(old_shutdown_tx, old_handle)
|
||||
Err(e) => {
|
||||
// Restore old state; old listener remains active
|
||||
self.config.addr = old_addr;
|
||||
self.shutdown_tx = old_shutdown_tx;
|
||||
self.handle = old_handle;
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the current bind address.
|
||||
@@ -215,21 +213,12 @@ mod tests {
|
||||
"First server should respond to health check"
|
||||
);
|
||||
|
||||
// Restart on second port using two-phase approach
|
||||
let addr2: SocketAddr = format!("127.0.0.1:{}", port2).parse().unwrap();
|
||||
let app = server
|
||||
.merged_router_clone()
|
||||
.expect("Router should exist after start()");
|
||||
let listener = tokio::net::TcpListener::bind(addr2)
|
||||
// Restart on second port
|
||||
let addr2 = format!("127.0.0.1:{}", port2).parse().unwrap();
|
||||
server
|
||||
.restart_with_addr(addr2)
|
||||
.await
|
||||
.expect("Failed to bind to new addr");
|
||||
let (old_tx, old_handle) = server.install_listener(addr2, listener, app);
|
||||
if let Some(tx) = old_tx {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
if let Some(handle) = old_handle {
|
||||
let _ = handle.await;
|
||||
}
|
||||
.expect("Failed to restart with new addr");
|
||||
|
||||
// Assert the address changed
|
||||
assert_eq!(
|
||||
@@ -306,18 +295,13 @@ mod tests {
|
||||
.expect("Failed to send request");
|
||||
assert_eq!(response.status(), 200, "Server should be listening");
|
||||
|
||||
// Try to restart on an invalid address (port 1 typically requires elevated privileges)
|
||||
// Try to restart on an invalid address (port 0 is reserved, won't bind)
|
||||
// Use port 1 which typically requires elevated privileges
|
||||
let invalid_addr: SocketAddr = "127.0.0.1:1".parse().unwrap();
|
||||
|
||||
// Attempt bind (should fail); server state is untouched because we
|
||||
// never call install_listener on failure.
|
||||
let app = server
|
||||
.merged_router_clone()
|
||||
.expect("Router should exist after start()");
|
||||
let result = tokio::net::TcpListener::bind(invalid_addr).await;
|
||||
assert!(result.is_err(), "Bind to privileged port should fail");
|
||||
// `app` is dropped — server state unchanged (rollback by construction)
|
||||
drop(app);
|
||||
// Attempt restart (should fail)
|
||||
let result = server.restart_with_addr(invalid_addr).await;
|
||||
assert!(result.is_err(), "Restart with invalid address should fail");
|
||||
|
||||
// Verify the old address is still responding (rollback succeeded)
|
||||
let response = client
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
//! Import command for migrating data from other AI systems.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use clap::Subcommand;
|
||||
|
||||
#[cfg(feature = "import")]
|
||||
use crate::import::ImportOptions;
|
||||
#[cfg(feature = "import")]
|
||||
use crate::import::openclaw::OpenClawImporter;
|
||||
|
||||
/// Import data from other AI systems.
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum ImportCommand {
|
||||
/// Import from OpenClaw (memory, history, settings, credentials)
|
||||
#[cfg(feature = "import")]
|
||||
Openclaw {
|
||||
/// Path to OpenClaw directory (default: ~/.openclaw)
|
||||
#[arg(long)]
|
||||
path: Option<PathBuf>,
|
||||
|
||||
/// Dry-run mode: show what would be imported without writing
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
|
||||
/// Re-embed memory if dimensions don't match target provider
|
||||
#[arg(long)]
|
||||
re_embed: bool,
|
||||
|
||||
/// User ID for imported data (default: 'default')
|
||||
#[arg(long)]
|
||||
user_id: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Run an import command.
|
||||
#[cfg(feature = "import")]
|
||||
pub async fn run_import_command(
|
||||
cmd: &ImportCommand,
|
||||
config: &crate::config::Config,
|
||||
) -> anyhow::Result<()> {
|
||||
match cmd {
|
||||
ImportCommand::Openclaw {
|
||||
path,
|
||||
dry_run,
|
||||
re_embed,
|
||||
user_id,
|
||||
} => run_import_openclaw(config, path.clone(), *dry_run, *re_embed, user_id.clone()).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the OpenClaw import.
|
||||
#[cfg(feature = "import")]
|
||||
async fn run_import_openclaw(
|
||||
config: &crate::config::Config,
|
||||
openclaw_path: Option<PathBuf>,
|
||||
dry_run: bool,
|
||||
re_embed: bool,
|
||||
user_id: Option<String>,
|
||||
) -> anyhow::Result<()> {
|
||||
use secrecy::SecretString;
|
||||
|
||||
// Determine OpenClaw path
|
||||
let openclaw_path = if let Some(path) = openclaw_path {
|
||||
path
|
||||
} else if let Some(path) = OpenClawImporter::detect() {
|
||||
path
|
||||
} else {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
|
||||
PathBuf::from(home).join(".openclaw")
|
||||
};
|
||||
|
||||
let user_id = user_id.unwrap_or_else(|| "default".to_string());
|
||||
|
||||
println!("🔍 OpenClaw Import");
|
||||
println!(" Path: {}", openclaw_path.display());
|
||||
println!(" User: {}", user_id);
|
||||
if dry_run {
|
||||
println!(" Mode: DRY RUN (no data will be written)");
|
||||
}
|
||||
println!();
|
||||
|
||||
// Initialize database
|
||||
let db = crate::db::connect_from_config(&config.database)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to initialize database: {}", e))?;
|
||||
|
||||
// Initialize secrets store with master key from env or keychain
|
||||
let secrets_crypto = if let Ok(master_key_hex) = std::env::var("SECRETS_MASTER_KEY") {
|
||||
Arc::new(
|
||||
crate::secrets::SecretsCrypto::new(SecretString::from(master_key_hex))
|
||||
.map_err(|e| anyhow::anyhow!("Failed to initialize secrets: {}", e))?,
|
||||
)
|
||||
} else {
|
||||
match crate::secrets::keychain::get_master_key().await {
|
||||
Ok(key_bytes) => {
|
||||
let key_hex: String = key_bytes.iter().map(|b| format!("{:02x}", b)).collect();
|
||||
Arc::new(
|
||||
crate::secrets::SecretsCrypto::new(SecretString::from(key_hex))
|
||||
.map_err(|e| anyhow::anyhow!("Failed to initialize secrets: {}", e))?,
|
||||
)
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"No secrets master key found. Set SECRETS_MASTER_KEY env var or run 'ironclaw onboard' first."
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let secrets: Arc<dyn crate::secrets::SecretsStore> = Arc::new(
|
||||
crate::secrets::InMemorySecretsStore::new(secrets_crypto.clone()),
|
||||
);
|
||||
|
||||
// Initialize workspace
|
||||
let workspace = crate::workspace::Workspace::new_with_db(user_id.clone(), db.clone());
|
||||
|
||||
let opts = ImportOptions {
|
||||
openclaw_path,
|
||||
dry_run,
|
||||
re_embed,
|
||||
user_id,
|
||||
};
|
||||
|
||||
let importer = OpenClawImporter::new(db, workspace, secrets, opts);
|
||||
let stats = importer.import().await?;
|
||||
|
||||
// Print results
|
||||
println!("Import Complete");
|
||||
println!();
|
||||
println!("Summary:");
|
||||
println!(" Documents: {}", stats.documents);
|
||||
println!(" Chunks: {}", stats.chunks);
|
||||
println!(" Conversations: {}", stats.conversations);
|
||||
println!(" Messages: {}", stats.messages);
|
||||
println!(" Settings: {}", stats.settings);
|
||||
println!(" Secrets: {}", stats.secrets);
|
||||
if stats.skipped > 0 {
|
||||
println!(" Skipped: {}", stats.skipped);
|
||||
}
|
||||
if stats.re_embed_queued > 0 {
|
||||
println!(" Re-embed queued: {}", stats.re_embed_queued);
|
||||
}
|
||||
println!();
|
||||
println!("Total imported: {}", stats.total_imported());
|
||||
|
||||
if dry_run {
|
||||
println!();
|
||||
println!("[DRY RUN] No data was written.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "import"))]
|
||||
pub async fn run_import_command(
|
||||
_cmd: &ImportCommand,
|
||||
_config: &crate::config::Config,
|
||||
) -> anyhow::Result<()> {
|
||||
anyhow::bail!("Import feature not enabled. Compile with --features import")
|
||||
}
|
||||
+4
-14
@@ -12,10 +12,9 @@ use crate::config::Config;
|
||||
use crate::db::Database;
|
||||
use crate::secrets::SecretsStore;
|
||||
use crate::tools::mcp::{
|
||||
McpClient, McpProcessManager, McpServerConfig, McpSessionManager, OAuthConfig,
|
||||
McpClient, McpServerConfig, McpSessionManager, OAuthConfig,
|
||||
auth::{authorize_mcp_server, is_authenticated},
|
||||
config::{self, EffectiveTransport, McpServersFile},
|
||||
factory::create_client_from_config,
|
||||
};
|
||||
|
||||
/// Arguments for the `mcp add` subcommand.
|
||||
@@ -495,7 +494,7 @@ async fn test_server(name: String, user_id: String) -> anyhow::Result<()> {
|
||||
|
||||
let client = if has_tokens {
|
||||
// We have stored tokens, use authenticated client
|
||||
McpClient::new_authenticated(server.clone(), session_manager.clone(), secrets, user_id)
|
||||
McpClient::new_authenticated(server.clone(), session_manager, secrets, user_id)
|
||||
} else if server.requires_auth() {
|
||||
// OAuth configured but no tokens - need to authenticate
|
||||
println!();
|
||||
@@ -506,17 +505,8 @@ async fn test_server(name: String, user_id: String) -> anyhow::Result<()> {
|
||||
println!();
|
||||
return Ok(());
|
||||
} else {
|
||||
// Use the factory to dispatch on transport type (HTTP, stdio, unix)
|
||||
let process_manager = Arc::new(McpProcessManager::new());
|
||||
create_client_from_config(
|
||||
server.clone(),
|
||||
&session_manager,
|
||||
&process_manager,
|
||||
None,
|
||||
"default",
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?
|
||||
// No OAuth and no tokens - try unauthenticated
|
||||
McpClient::new_with_config(server.clone())
|
||||
};
|
||||
|
||||
// Test connection
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
mod completion;
|
||||
mod config;
|
||||
mod doctor;
|
||||
#[cfg(feature = "import")]
|
||||
pub mod import;
|
||||
mod mcp;
|
||||
pub mod memory;
|
||||
pub mod oauth_defaults;
|
||||
@@ -28,8 +26,6 @@ mod tool;
|
||||
pub use completion::Completion;
|
||||
pub use config::{ConfigCommand, run_config_command};
|
||||
pub use doctor::run_doctor_command;
|
||||
#[cfg(feature = "import")]
|
||||
pub use import::{ImportCommand, run_import_command};
|
||||
pub use mcp::{McpCommand, run_mcp_command};
|
||||
pub use memory::MemoryCommand;
|
||||
pub use memory::run_memory_command_with_db;
|
||||
@@ -187,15 +183,6 @@ pub enum Command {
|
||||
)]
|
||||
Completion(Completion),
|
||||
|
||||
/// Import data from other AI systems
|
||||
#[cfg(feature = "import")]
|
||||
#[command(
|
||||
subcommand,
|
||||
about = "Import from other AI systems",
|
||||
long_about = "Migrate data from other AI assistants like OpenClaw.\nExample: ironclaw import openclaw"
|
||||
)]
|
||||
Import(ImportCommand),
|
||||
|
||||
/// Run as a sandboxed worker inside a Docker container (internal use).
|
||||
/// This is invoked automatically by the orchestrator, not by users directly.
|
||||
#[command(hide = true)]
|
||||
@@ -295,7 +282,6 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "import")]
|
||||
fn test_help_output() {
|
||||
let mut cmd = Cli::command();
|
||||
let help = cmd.render_help().to_string();
|
||||
@@ -303,26 +289,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(feature = "import"))]
|
||||
fn test_help_output_without_import() {
|
||||
let mut cmd = Cli::command();
|
||||
let help = cmd.render_help().to_string();
|
||||
assert_snapshot!(help);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "import")]
|
||||
fn test_long_help_output() {
|
||||
let mut cmd = Cli::command();
|
||||
let help = cmd.render_long_help().to_string();
|
||||
assert_snapshot!(help);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(feature = "import"))]
|
||||
fn test_long_help_output_without_import() {
|
||||
let mut cmd = Cli::command();
|
||||
let help = cmd.render_long_help().to_string();
|
||||
assert_snapshot!(help);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: src/cli/mod.rs
|
||||
assertion_line: 302
|
||||
expression: help
|
||||
---
|
||||
Secure personal AI assistant that protects your data and expands its capabilities
|
||||
@@ -20,7 +19,6 @@ Commands:
|
||||
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:
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
---
|
||||
source: src/cli/mod.rs
|
||||
assertion_line: 310
|
||||
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
|
||||
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
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: src/cli/mod.rs
|
||||
assertion_line: 318
|
||||
expression: help
|
||||
---
|
||||
IronClaw is a secure AI assistant. Use 'ironclaw <subcommand> --help' for details.
|
||||
@@ -23,7 +22,6 @@ Commands:
|
||||
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:
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
source: src/cli/mod.rs
|
||||
assertion_line: 326
|
||||
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
|
||||
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
|
||||
+1
-1
@@ -12,4 +12,4 @@ mod state;
|
||||
|
||||
pub use manager::ContextManager;
|
||||
pub use memory::{ActionRecord, ConversationMemory, Memory};
|
||||
pub use state::{JobContext, JobState, StateTransition, TokenBudgetExceeded};
|
||||
pub use state::{JobContext, JobState, StateTransition};
|
||||
|
||||
+7
-17
@@ -11,16 +11,6 @@ use uuid::Uuid;
|
||||
|
||||
use crate::llm::recording::HttpInterceptor;
|
||||
|
||||
/// Error returned when a job exceeds its token budget.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("Token budget exceeded: used {used} of {limit} allowed tokens")]
|
||||
pub struct TokenBudgetExceeded {
|
||||
/// Total tokens consumed (including the call that exceeded the budget).
|
||||
pub used: u64,
|
||||
/// Configured token limit for this job.
|
||||
pub limit: u64,
|
||||
}
|
||||
|
||||
/// State of a job.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -275,15 +265,15 @@ impl JobContext {
|
||||
self.actual_cost += cost;
|
||||
}
|
||||
|
||||
/// Record token usage from an LLM call. Returns an error if the token
|
||||
/// budget has been exceeded after this addition.
|
||||
pub fn add_tokens(&mut self, tokens: u64) -> Result<(), TokenBudgetExceeded> {
|
||||
/// Record token usage from an LLM call. Returns an error string if the
|
||||
/// token budget has been exceeded after this addition.
|
||||
pub fn add_tokens(&mut self, tokens: u64) -> Result<(), String> {
|
||||
self.total_tokens_used += tokens;
|
||||
if self.max_tokens > 0 && self.total_tokens_used > self.max_tokens {
|
||||
Err(TokenBudgetExceeded {
|
||||
used: self.total_tokens_used,
|
||||
limit: self.max_tokens,
|
||||
})
|
||||
Err(format!(
|
||||
"Token budget exceeded: used {} of {} allowed tokens",
|
||||
self.total_tokens_used, self.max_tokens
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -67,23 +67,20 @@ impl ConversationStore for LibSqlBackend {
|
||||
channel: &str,
|
||||
user_id: &str,
|
||||
thread_id: Option<&str>,
|
||||
) -> Result<bool, DatabaseError> {
|
||||
) -> Result<(), DatabaseError> {
|
||||
let conn = self.connect().await?;
|
||||
let now = fmt_ts(&Utc::now());
|
||||
let affected = conn
|
||||
.execute(
|
||||
conn.execute(
|
||||
r#"
|
||||
INSERT INTO conversations (id, channel, user_id, thread_id, started_at, last_activity)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?5)
|
||||
ON CONFLICT (id) DO UPDATE SET last_activity = excluded.last_activity
|
||||
WHERE conversations.user_id = excluded.user_id
|
||||
AND conversations.channel = excluded.channel
|
||||
ON CONFLICT (id) DO UPDATE SET last_activity = ?5
|
||||
"#,
|
||||
params![id.to_string(), channel, user_id, opt_text(thread_id), now],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
Ok(affected > 0)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_conversations_with_preview(
|
||||
|
||||
+1
-1
@@ -207,7 +207,7 @@ pub trait ConversationStore: Send + Sync {
|
||||
channel: &str,
|
||||
user_id: &str,
|
||||
thread_id: Option<&str>,
|
||||
) -> Result<bool, DatabaseError>;
|
||||
) -> Result<(), DatabaseError>;
|
||||
async fn list_conversations_with_preview(
|
||||
&self,
|
||||
user_id: &str,
|
||||
|
||||
+1
-1
@@ -99,7 +99,7 @@ impl ConversationStore for PgBackend {
|
||||
channel: &str,
|
||||
user_id: &str,
|
||||
thread_id: Option<&str>,
|
||||
) -> Result<bool, DatabaseError> {
|
||||
) -> Result<(), DatabaseError> {
|
||||
self.store
|
||||
.ensure_conversation(id, channel, user_id, thread_id)
|
||||
.await
|
||||
|
||||
+171
-426
@@ -17,9 +17,9 @@ use crate::channels::wasm::{
|
||||
use crate::extensions::discovery::OnlineDiscovery;
|
||||
use crate::extensions::registry::ExtensionRegistry;
|
||||
use crate::extensions::{
|
||||
ActivateResult, AuthResult, ConfigureResult, ExtensionError, ExtensionKind, ExtensionSource,
|
||||
InstallResult, InstalledExtension, RegistryEntry, ResultSource, SearchResult, ToolAuthState,
|
||||
UpgradeOutcome, UpgradeResult,
|
||||
ActivateResult, AuthResult, ExtensionError, ExtensionKind, ExtensionSource, InstallResult,
|
||||
InstalledExtension, RegistryEntry, ResultSource, SearchResult, ToolAuthState, UpgradeOutcome,
|
||||
UpgradeResult,
|
||||
};
|
||||
use crate::hooks::HookRegistry;
|
||||
use crate::pairing::PairingStore;
|
||||
@@ -56,6 +56,16 @@ struct ChannelRuntimeState {
|
||||
wasm_channel_owner_ids: std::collections::HashMap<String, i64>,
|
||||
}
|
||||
|
||||
/// Result of saving setup secrets and attempting activation.
|
||||
pub struct SetupResult {
|
||||
/// Human-readable status message.
|
||||
pub message: String,
|
||||
/// Whether the channel was successfully activated after saving secrets.
|
||||
pub activated: bool,
|
||||
/// OAuth authorization URL for the UI to open (if OAuth flow was started).
|
||||
pub auth_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Central manager for extension lifecycle operations.
|
||||
pub struct ExtensionManager {
|
||||
registry: ExtensionRegistry,
|
||||
@@ -430,11 +440,12 @@ impl ExtensionManager {
|
||||
Err(err)
|
||||
}
|
||||
|
||||
/// Check auth status for an installed extension.
|
||||
///
|
||||
/// Read-only for WASM extensions; may initiate OAuth for MCP servers.
|
||||
/// To provide secrets, use [`configure()`] instead.
|
||||
pub async fn auth(&self, name: &str) -> Result<AuthResult, ExtensionError> {
|
||||
/// Authenticate an installed extension.
|
||||
pub async fn auth(
|
||||
&self,
|
||||
name: &str,
|
||||
token: Option<&str>,
|
||||
) -> Result<AuthResult, ExtensionError> {
|
||||
// Clean up expired pending auths
|
||||
self.cleanup_expired_auths().await;
|
||||
|
||||
@@ -442,10 +453,10 @@ impl ExtensionManager {
|
||||
let kind = self.determine_installed_kind(name).await?;
|
||||
|
||||
match kind {
|
||||
ExtensionKind::McpServer => self.auth_mcp(name).await,
|
||||
ExtensionKind::WasmTool => self.auth_wasm_tool(name).await,
|
||||
ExtensionKind::WasmChannel => self.auth_wasm_channel_status(name).await,
|
||||
ExtensionKind::ChannelRelay => self.auth_channel_relay(name).await,
|
||||
ExtensionKind::McpServer => self.auth_mcp(name, token).await,
|
||||
ExtensionKind::WasmTool => self.auth_wasm_tool(name, token).await,
|
||||
ExtensionKind::WasmChannel => self.auth_wasm_channel(name, token).await,
|
||||
ExtensionKind::ChannelRelay => self.auth_channel_relay(name, token).await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1673,12 +1684,30 @@ impl ExtensionManager {
|
||||
})
|
||||
}
|
||||
|
||||
async fn auth_mcp(&self, name: &str) -> Result<AuthResult, ExtensionError> {
|
||||
async fn auth_mcp(
|
||||
&self,
|
||||
name: &str,
|
||||
token: Option<&str>,
|
||||
) -> Result<AuthResult, ExtensionError> {
|
||||
let server = self
|
||||
.get_mcp_server(name)
|
||||
.await
|
||||
.map_err(|e| ExtensionError::NotInstalled(e.to_string()))?;
|
||||
|
||||
// If a token was provided directly, store it and we're done.
|
||||
if let Some(token_value) = token {
|
||||
let secret_name = server.token_secret_name();
|
||||
let params =
|
||||
CreateSecretParams::new(&secret_name, token_value).with_provider(name.to_string());
|
||||
self.secrets
|
||||
.create(&self.user_id, params)
|
||||
.await
|
||||
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
||||
|
||||
tracing::info!("MCP server '{}' authenticated via manual token", name);
|
||||
return Ok(AuthResult::authenticated(name, ExtensionKind::McpServer));
|
||||
}
|
||||
|
||||
// Check if already authenticated
|
||||
if is_authenticated(&server, &self.secrets, &self.user_id).await {
|
||||
return Ok(AuthResult::authenticated(name, ExtensionKind::McpServer));
|
||||
@@ -1791,7 +1820,11 @@ impl ExtensionManager {
|
||||
))
|
||||
}
|
||||
|
||||
async fn auth_wasm_tool(&self, name: &str) -> Result<AuthResult, ExtensionError> {
|
||||
async fn auth_wasm_tool(
|
||||
&self,
|
||||
name: &str,
|
||||
token: Option<&str>,
|
||||
) -> Result<AuthResult, ExtensionError> {
|
||||
// Read the capabilities file to get auth config
|
||||
let cap_path = self
|
||||
.wasm_tools_dir
|
||||
@@ -1862,6 +1895,18 @@ impl ExtensionManager {
|
||||
// Fall through to OAuth branch for scope expansion
|
||||
}
|
||||
|
||||
// If a token was provided, store it
|
||||
if let Some(token_value) = token {
|
||||
let params = CreateSecretParams::new(&auth.secret_name, token_value)
|
||||
.with_provider(name.to_string());
|
||||
self.secrets
|
||||
.create(&self.user_id, params)
|
||||
.await
|
||||
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
||||
|
||||
return Ok(AuthResult::authenticated(name, ExtensionKind::WasmTool));
|
||||
}
|
||||
|
||||
// OAuth flow: if the tool has OAuth config, start the browser-based flow.
|
||||
// But only if credentials are available — if the tool has setup secrets
|
||||
// for client_id/secret that aren't configured yet, return needs_setup.
|
||||
@@ -2509,8 +2554,11 @@ impl ExtensionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check auth status for a WASM channel (read-only).
|
||||
async fn auth_wasm_channel_status(&self, name: &str) -> Result<AuthResult, ExtensionError> {
|
||||
async fn auth_wasm_channel(
|
||||
&self,
|
||||
name: &str,
|
||||
token: Option<&str>,
|
||||
) -> Result<AuthResult, ExtensionError> {
|
||||
let cap_path = self
|
||||
.wasm_channels_dir
|
||||
.join(format!("{}.capabilities.json", name));
|
||||
@@ -2529,6 +2577,7 @@ impl ExtensionManager {
|
||||
let cap_file = crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
|
||||
.map_err(|e| ExtensionError::Other(e.to_string()))?;
|
||||
|
||||
// Get required secrets from the setup section
|
||||
let required_secrets = &cap_file.setup.required_secrets;
|
||||
if required_secrets.is_empty() {
|
||||
return Ok(AuthResult::no_auth_required(
|
||||
@@ -2537,7 +2586,7 @@ impl ExtensionManager {
|
||||
));
|
||||
}
|
||||
|
||||
// Find non-optional secrets that aren't yet stored
|
||||
// Find the first non-optional secret that isn't yet stored
|
||||
let mut missing = Vec::new();
|
||||
for secret in required_secrets {
|
||||
if secret.optional {
|
||||
@@ -2557,6 +2606,31 @@ impl ExtensionManager {
|
||||
return Ok(AuthResult::authenticated(name, ExtensionKind::WasmChannel));
|
||||
}
|
||||
|
||||
// If a token was provided, store it for the first missing secret
|
||||
if let Some(token_value) = token {
|
||||
let secret = &missing[0];
|
||||
let params =
|
||||
CreateSecretParams::new(&secret.name, token_value).with_provider(name.to_string());
|
||||
self.secrets
|
||||
.create(&self.user_id, params)
|
||||
.await
|
||||
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
||||
|
||||
// Check if there are more missing secrets
|
||||
if missing.len() <= 1 {
|
||||
return Ok(AuthResult::authenticated(name, ExtensionKind::WasmChannel));
|
||||
}
|
||||
|
||||
// More secrets needed; prompt for the next one
|
||||
let next = &missing[1];
|
||||
return Ok(AuthResult::awaiting_token(
|
||||
name,
|
||||
ExtensionKind::WasmChannel,
|
||||
next.prompt.clone(),
|
||||
cap_file.setup.setup_url.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
// Prompt for the first missing secret
|
||||
let secret = &missing[0];
|
||||
Ok(AuthResult::awaiting_token(
|
||||
@@ -3144,7 +3218,11 @@ impl ExtensionManager {
|
||||
/// For Slack: initiates OAuth flow (redirect-based).
|
||||
/// For Telegram: accepts a bot token, registers it with channel-relay,
|
||||
/// and stores the returned stream token.
|
||||
async fn auth_channel_relay(&self, name: &str) -> Result<AuthResult, ExtensionError> {
|
||||
async fn auth_channel_relay(
|
||||
&self,
|
||||
name: &str,
|
||||
_token: Option<&str>,
|
||||
) -> Result<AuthResult, ExtensionError> {
|
||||
// Check if already authenticated (stream token exists)
|
||||
let token_key = format!("relay:{}:stream_token", name);
|
||||
if self
|
||||
@@ -3447,26 +3525,16 @@ impl ExtensionManager {
|
||||
|
||||
/// Save setup secrets for an extension, validating names against the capabilities schema.
|
||||
///
|
||||
/// Configure secrets for an extension: validate, store, auto-generate, and activate.
|
||||
///
|
||||
/// This is the single entrypoint for providing secrets to any extension.
|
||||
/// Both the chat auth flow and the Extensions tab setup form call this method.
|
||||
///
|
||||
/// - Validates tokens against `validation_endpoint` (if declared in capabilities)
|
||||
/// - Stores secrets in the encrypted secrets store
|
||||
/// - Auto-generates missing secrets (e.g., webhook keys)
|
||||
/// - Activates the extension after configuration
|
||||
pub async fn configure(
|
||||
/// After saving, attempts to hot-activate the channel. Returns a [`SetupResult`]
|
||||
/// indicating whether activation succeeded (so the frontend can show appropriate UI).
|
||||
pub async fn save_setup_secrets(
|
||||
&self,
|
||||
name: &str,
|
||||
secrets: &std::collections::HashMap<String, String>,
|
||||
) -> Result<ConfigureResult, ExtensionError> {
|
||||
) -> Result<SetupResult, ExtensionError> {
|
||||
let kind = self.determine_installed_kind(name).await?;
|
||||
|
||||
// Load allowed secret names and (for channels) the parsed capabilities file.
|
||||
// The capabilities file is parsed once here and reused for validation_endpoint
|
||||
// and auto-generation below, avoiding redundant I/O + JSON parsing.
|
||||
let mut channel_cap_file: Option<crate::channels::wasm::ChannelCapabilitiesFile> = None;
|
||||
// Load allowed secret names from the extension's capabilities file
|
||||
let allowed: std::collections::HashSet<String> = match kind {
|
||||
ExtensionKind::WasmChannel => {
|
||||
let cap_path = self
|
||||
@@ -3484,71 +3552,45 @@ impl ExtensionManager {
|
||||
let cap_file =
|
||||
crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
|
||||
.map_err(|e| ExtensionError::Other(e.to_string()))?;
|
||||
let names = cap_file
|
||||
cap_file
|
||||
.setup
|
||||
.required_secrets
|
||||
.iter()
|
||||
.map(|s| s.name.clone())
|
||||
.collect();
|
||||
channel_cap_file = Some(cap_file);
|
||||
names
|
||||
.collect()
|
||||
}
|
||||
ExtensionKind::WasmTool => {
|
||||
let cap_file = self.load_tool_capabilities(name).await.ok_or_else(|| {
|
||||
ExtensionError::Other(format!("Capabilities file not found for '{}'", name))
|
||||
})?;
|
||||
let mut names: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
if let Some(ref s) = cap_file.setup {
|
||||
names.extend(s.required_secrets.iter().map(|s| s.name.clone()));
|
||||
match cap_file.setup {
|
||||
Some(s) => s.required_secrets.iter().map(|s| s.name.clone()).collect(),
|
||||
None => {
|
||||
return Err(ExtensionError::Other(format!(
|
||||
"Tool '{}' has no setup schema — no secrets to configure",
|
||||
name
|
||||
)));
|
||||
}
|
||||
}
|
||||
// Also allow storing the auth token secret directly
|
||||
if let Some(ref auth) = cap_file.auth {
|
||||
names.insert(auth.secret_name.clone());
|
||||
}
|
||||
if names.is_empty() {
|
||||
return Err(ExtensionError::Other(format!(
|
||||
"Tool '{}' has no setup or auth schema — no secrets to configure",
|
||||
name
|
||||
)));
|
||||
}
|
||||
names
|
||||
}
|
||||
ExtensionKind::McpServer => {
|
||||
let server = self
|
||||
.get_mcp_server(name)
|
||||
.await
|
||||
.map_err(|e| ExtensionError::NotInstalled(e.to_string()))?;
|
||||
let mut names = std::collections::HashSet::new();
|
||||
names.insert(server.token_secret_name());
|
||||
names
|
||||
}
|
||||
ExtensionKind::ChannelRelay => {
|
||||
let mut names = std::collections::HashSet::new();
|
||||
names.insert(format!("relay:{}:stream_token", name));
|
||||
names
|
||||
_ => {
|
||||
return Err(ExtensionError::Other(
|
||||
"Setup is only supported for WASM channels and tools".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Validate secrets against the validation_endpoint if declared in capabilities.
|
||||
// The endpoint URL template uses {secret_name} placeholders that are
|
||||
// substituted with the provided secret value before making the request.
|
||||
if let Some(ref cap_file) = channel_cap_file
|
||||
&& let Some(ref endpoint_template) = cap_file.setup.validation_endpoint
|
||||
&& let Some(secret_def) = cap_file
|
||||
.setup
|
||||
.required_secrets
|
||||
.iter()
|
||||
.find(|s| !s.optional && secrets.contains_key(&s.name))
|
||||
&& let Some(token_value) = secrets.get(&secret_def.name)
|
||||
// For Telegram, validate the bot token against the API before storing it.
|
||||
// This catches bad tokens immediately (both on first setup and reconfigure),
|
||||
// before the channel activates and potentially shows as active with a bad token.
|
||||
if name == "telegram"
|
||||
&& let Some(token_value) = secrets.get("telegram_bot_token")
|
||||
{
|
||||
let token = token_value.trim();
|
||||
if !token.is_empty() {
|
||||
let encoded =
|
||||
let encoded_token =
|
||||
url::form_urlencoded::byte_serialize(token.as_bytes()).collect::<String>();
|
||||
let url = endpoint_template.replace(&format!("{{{}}}", secret_def.name), &encoded);
|
||||
// SSRF defense: block private IPs, localhost, cloud metadata endpoints
|
||||
crate::tools::builtin::skill_tools::validate_fetch_url(&url)
|
||||
.map_err(|e| ExtensionError::Other(format!("SSRF blocked: {}", e)))?;
|
||||
let url = format!("https://api.telegram.org/bot{}/getMe", encoded_token);
|
||||
let resp = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
@@ -3556,13 +3598,12 @@ impl ExtensionManager {
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
// Transport errors are infrastructure failures, not token issues
|
||||
.map_err(|e| {
|
||||
ExtensionError::Other(format!("Token validation request failed: {}", e))
|
||||
ExtensionError::Other(format!("Failed to validate bot token: {}", e))
|
||||
})?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(ExtensionError::ValidationFailed(format!(
|
||||
"Invalid token (API returned {})",
|
||||
return Err(ExtensionError::Other(format!(
|
||||
"Invalid bot token (Telegram API returned {})",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
@@ -3589,34 +3630,43 @@ impl ExtensionManager {
|
||||
}
|
||||
|
||||
// Auto-generate any missing secrets (channel-only feature)
|
||||
if let Some(ref cap_file) = channel_cap_file {
|
||||
for secret_def in &cap_file.setup.required_secrets {
|
||||
if let Some(ref auto_gen) = secret_def.auto_generate {
|
||||
let already_provided = secrets
|
||||
.get(&secret_def.name)
|
||||
.is_some_and(|v| !v.trim().is_empty());
|
||||
let already_stored = self
|
||||
.secrets
|
||||
.exists(&self.user_id, &secret_def.name)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if !already_provided && !already_stored {
|
||||
use rand::RngCore;
|
||||
use rand::rngs::OsRng;
|
||||
let mut bytes = vec![0u8; auto_gen.length];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
let hex_value: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
|
||||
let params = CreateSecretParams::new(&secret_def.name, &hex_value)
|
||||
.with_provider(name.to_string());
|
||||
self.secrets
|
||||
.create(&self.user_id, params)
|
||||
if kind == ExtensionKind::WasmChannel {
|
||||
let cap_path = self
|
||||
.wasm_channels_dir
|
||||
.join(format!("{}.capabilities.json", name));
|
||||
if let Ok(cap_bytes) = tokio::fs::read(&cap_path).await
|
||||
&& let Ok(cap_file) =
|
||||
crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
|
||||
{
|
||||
for secret_def in &cap_file.setup.required_secrets {
|
||||
if let Some(ref auto_gen) = secret_def.auto_generate {
|
||||
let already_provided = secrets
|
||||
.get(&secret_def.name)
|
||||
.is_some_and(|v| !v.trim().is_empty());
|
||||
let already_stored = self
|
||||
.secrets
|
||||
.exists(&self.user_id, &secret_def.name)
|
||||
.await
|
||||
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
||||
tracing::info!(
|
||||
"Auto-generated secret '{}' for channel '{}'",
|
||||
secret_def.name,
|
||||
name
|
||||
);
|
||||
.unwrap_or(false);
|
||||
if !already_provided && !already_stored {
|
||||
use rand::RngCore;
|
||||
use rand::rngs::OsRng;
|
||||
let mut bytes = vec![0u8; auto_gen.length];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
let hex_value: String =
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect();
|
||||
let params = CreateSecretParams::new(&secret_def.name, &hex_value)
|
||||
.with_provider(name.to_string());
|
||||
self.secrets
|
||||
.create(&self.user_id, params)
|
||||
.await
|
||||
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
||||
tracing::info!(
|
||||
"Auto-generated secret '{}' for channel '{}'",
|
||||
secret_def.name,
|
||||
name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3653,9 +3703,7 @@ impl ExtensionManager {
|
||||
// Check if auth is needed (OAuth or manual token).
|
||||
// This is safe to call here — cancel-and-retry prevents port conflicts.
|
||||
let mut auth_url = None;
|
||||
// Box::pin breaks the async recursion cycle:
|
||||
// auth() → auth_wasm_tool() → (OAuth) → configure() → auth()
|
||||
if let Ok(auth_result) = Box::pin(self.auth(name)).await {
|
||||
if let Ok(auth_result) = self.auth(name, None).await {
|
||||
auth_url = auth_result.auth_url().map(String::from);
|
||||
}
|
||||
let message = if auth_url.is_some() {
|
||||
@@ -3669,7 +3717,7 @@ impl ExtensionManager {
|
||||
name, result.message
|
||||
)
|
||||
};
|
||||
return Ok(ConfigureResult {
|
||||
return Ok(SetupResult {
|
||||
message,
|
||||
activated: true,
|
||||
auth_url,
|
||||
@@ -3681,7 +3729,7 @@ impl ExtensionManager {
|
||||
name,
|
||||
e
|
||||
);
|
||||
return Ok(ConfigureResult {
|
||||
return Ok(SetupResult {
|
||||
message: format!("Configuration saved for '{}'.", name),
|
||||
activated: false,
|
||||
auth_url: None,
|
||||
@@ -3690,29 +3738,14 @@ impl ExtensionManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Activate the extension now that secrets are saved.
|
||||
// Dispatch by kind — WasmTool was already handled above with an early return.
|
||||
let activate_result = match kind {
|
||||
ExtensionKind::WasmChannel => self.activate_wasm_channel(name).await,
|
||||
ExtensionKind::McpServer => self.activate_mcp(name).await,
|
||||
ExtensionKind::ChannelRelay => self.activate_channel_relay(name).await,
|
||||
ExtensionKind::WasmTool => {
|
||||
// WasmTool is handled above and returns early; this branch is unreachable.
|
||||
return Ok(ConfigureResult {
|
||||
message: format!("Configuration saved for '{}'.", name),
|
||||
activated: false,
|
||||
auth_url: None,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
match activate_result {
|
||||
// Try to hot-activate the channel now that secrets are saved
|
||||
match self.activate_wasm_channel(name).await {
|
||||
Ok(result) => {
|
||||
self.activation_errors.write().await.remove(name);
|
||||
self.broadcast_extension_status(name, "active", None).await;
|
||||
Ok(ConfigureResult {
|
||||
Ok(SetupResult {
|
||||
message: format!(
|
||||
"Configuration saved and '{}' activated. {}",
|
||||
"Configuration saved and channel '{}' activated. {}",
|
||||
name, result.message
|
||||
),
|
||||
activated: true,
|
||||
@@ -3722,9 +3755,9 @@ impl ExtensionManager {
|
||||
Err(e) => {
|
||||
let error_msg = e.to_string();
|
||||
tracing::warn!(
|
||||
extension = name,
|
||||
channel = name,
|
||||
error = %e,
|
||||
"Saved configuration but activation failed"
|
||||
"Saved configuration but hot-activation failed"
|
||||
);
|
||||
self.activation_errors
|
||||
.write()
|
||||
@@ -3732,7 +3765,7 @@ impl ExtensionManager {
|
||||
.insert(name.to_string(), error_msg.clone());
|
||||
self.broadcast_extension_status(name, "failed", Some(&error_msg))
|
||||
.await;
|
||||
Ok(ConfigureResult {
|
||||
Ok(SetupResult {
|
||||
message: format!(
|
||||
"Configuration saved for '{}'. Activation failed: {}",
|
||||
name, e
|
||||
@@ -3744,118 +3777,6 @@ impl ExtensionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience wrapper: configure a single token for an extension.
|
||||
///
|
||||
/// Determines the primary secret name from the extension's capabilities,
|
||||
/// then delegates to [`configure()`]. Use this when the caller only has
|
||||
/// a bare token value (e.g., from the chat auth card or WebSocket auth).
|
||||
pub async fn configure_token(
|
||||
&self,
|
||||
name: &str,
|
||||
token: &str,
|
||||
) -> Result<ConfigureResult, ExtensionError> {
|
||||
let kind = self.determine_installed_kind(name).await?;
|
||||
let secret_name = match kind {
|
||||
ExtensionKind::WasmChannel => {
|
||||
let cap_path = self
|
||||
.wasm_channels_dir
|
||||
.join(format!("{}.capabilities.json", name));
|
||||
let cap_bytes = tokio::fs::read(&cap_path)
|
||||
.await
|
||||
.map_err(|e| ExtensionError::Other(e.to_string()))?;
|
||||
let cap_file =
|
||||
crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
|
||||
.map_err(|e| ExtensionError::Other(e.to_string()))?;
|
||||
// Pick the first *missing* non-optional secret so re-configure
|
||||
// of a second secret works for multi-secret channels.
|
||||
let mut target = None;
|
||||
for s in &cap_file.setup.required_secrets {
|
||||
if s.optional {
|
||||
continue;
|
||||
}
|
||||
if !self
|
||||
.secrets
|
||||
.exists(&self.user_id, &s.name)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
target = Some(s.name.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Fall back to first non-optional if all exist (overwrite)
|
||||
target
|
||||
.or_else(|| {
|
||||
cap_file
|
||||
.setup
|
||||
.required_secrets
|
||||
.iter()
|
||||
.find(|s| !s.optional)
|
||||
.map(|s| s.name.clone())
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
ExtensionError::Other(format!("Channel '{}' has no required secrets", name))
|
||||
})?
|
||||
}
|
||||
ExtensionKind::WasmTool => {
|
||||
let cap = self.load_tool_capabilities(name).await.ok_or_else(|| {
|
||||
ExtensionError::Other(format!("Capabilities not found for '{}'", name))
|
||||
})?;
|
||||
// Prefer auth secret, then first missing setup secret
|
||||
if let Some(ref auth) = cap.auth {
|
||||
if !self
|
||||
.secrets
|
||||
.exists(&self.user_id, &auth.secret_name)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
auth.secret_name.clone()
|
||||
} else if let Some(ref setup) = cap.setup {
|
||||
// Auth secret exists, find first missing setup secret
|
||||
let mut found = None;
|
||||
for s in &setup.required_secrets {
|
||||
if !self
|
||||
.secrets
|
||||
.exists(&self.user_id, &s.name)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
found = Some(s.name.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
found.unwrap_or_else(|| auth.secret_name.clone())
|
||||
} else {
|
||||
auth.secret_name.clone()
|
||||
}
|
||||
} else {
|
||||
cap.setup
|
||||
.as_ref()
|
||||
.and_then(|s| s.required_secrets.first())
|
||||
.map(|s| s.name.clone())
|
||||
.ok_or_else(|| {
|
||||
ExtensionError::Other(format!(
|
||||
"Tool '{}' has no auth or setup secrets",
|
||||
name
|
||||
))
|
||||
})?
|
||||
}
|
||||
}
|
||||
ExtensionKind::McpServer => {
|
||||
let server = self
|
||||
.get_mcp_server(name)
|
||||
.await
|
||||
.map_err(|e| ExtensionError::NotInstalled(e.to_string()))?;
|
||||
server.token_secret_name()
|
||||
}
|
||||
ExtensionKind::ChannelRelay => format!("relay:{}:stream_token", name),
|
||||
};
|
||||
|
||||
let mut secrets = std::collections::HashMap::new();
|
||||
secrets.insert(secret_name, token.to_string());
|
||||
self.configure(name, &secrets).await
|
||||
}
|
||||
|
||||
/// Read a capabilities.json file and revoke its credential mappings from
|
||||
/// the shared credential registry, so removed extensions lose injection
|
||||
/// authority immediately.
|
||||
@@ -4765,180 +4686,4 @@ mod tests {
|
||||
assert_eq!(result, url);
|
||||
assert!(result.contains("/v1/users/123/profile"));
|
||||
}
|
||||
|
||||
// ── Regression tests for PR #677 (unify-extension-lifecycle) ─────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_configure_token_picks_first_missing_secret() {
|
||||
// Regression: configure_token() must pick the first *missing* secret,
|
||||
// not the first non-optional one. This allows multi-secret channels
|
||||
// to be configured one secret at a time.
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let channels_dir = dir.path().join("channels");
|
||||
std::fs::create_dir_all(&channels_dir).unwrap();
|
||||
|
||||
// Write a fake channel WASM + capabilities with two required secrets
|
||||
std::fs::write(channels_dir.join("multi.wasm"), b"\0asm fake").unwrap();
|
||||
let caps = serde_json::json!({
|
||||
"type": "channel",
|
||||
"name": "multi",
|
||||
"setup": {
|
||||
"required_secrets": [
|
||||
{"name": "SECRET_A", "prompt": "Enter secret A (at least 30 chars for validation)"},
|
||||
{"name": "SECRET_B", "prompt": "Enter secret B (at least 30 chars for validation)"}
|
||||
]
|
||||
}
|
||||
});
|
||||
std::fs::write(
|
||||
channels_dir.join("multi.capabilities.json"),
|
||||
serde_json::to_string(&caps).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mgr = make_manager_custom_dirs(dir.path().join("tools"), channels_dir);
|
||||
|
||||
// Pre-store SECRET_A so it's no longer missing
|
||||
mgr.secrets
|
||||
.create(
|
||||
"test",
|
||||
crate::secrets::CreateSecretParams::new("SECRET_A", "value-a"),
|
||||
)
|
||||
.await
|
||||
.expect("store SECRET_A");
|
||||
|
||||
// configure_token should target SECRET_B (the first missing one)
|
||||
let _result = mgr.configure_token("multi", "value-b").await;
|
||||
// configure will fail at activation (no real WASM runtime), but the
|
||||
// secret should still have been stored before activation was attempted.
|
||||
// Check that SECRET_B was stored.
|
||||
assert!(
|
||||
mgr.secrets
|
||||
.exists("test", "SECRET_B")
|
||||
.await
|
||||
.unwrap_or(false),
|
||||
"configure_token should have stored SECRET_B (the first missing secret)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_auth_is_read_only_for_wasm_channel() {
|
||||
// Regression: auth() must be a pure status check — it must not store
|
||||
// any secrets or modify state. The old API accepted a token parameter.
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let channels_dir = dir.path().join("channels");
|
||||
std::fs::create_dir_all(&channels_dir).unwrap();
|
||||
|
||||
std::fs::write(channels_dir.join("test-ch.wasm"), b"\0asm fake").unwrap();
|
||||
let caps = serde_json::json!({
|
||||
"type": "channel",
|
||||
"name": "test-ch",
|
||||
"setup": {
|
||||
"required_secrets": [
|
||||
{"name": "BOT_TOKEN", "prompt": "Enter bot token (at least 30 chars for prompt validation)"}
|
||||
]
|
||||
}
|
||||
});
|
||||
std::fs::write(
|
||||
channels_dir.join("test-ch.capabilities.json"),
|
||||
serde_json::to_string(&caps).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mgr = make_manager_custom_dirs(dir.path().join("tools"), channels_dir);
|
||||
|
||||
// auth() should return a result without storing anything
|
||||
let result = mgr.auth("test-ch").await;
|
||||
assert!(result.is_ok(), "auth should succeed: {:?}", result.err());
|
||||
|
||||
// No secrets should have been created
|
||||
assert!(
|
||||
!mgr.secrets
|
||||
.exists("test", "BOT_TOKEN")
|
||||
.await
|
||||
.unwrap_or(true),
|
||||
"auth() must not create any secrets — it should be read-only"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_configure_dispatches_activation_by_kind() {
|
||||
// Regression: configure() must dispatch to the correct activation method
|
||||
// by kind. Previously it unconditionally called activate_wasm_channel()
|
||||
// for all non-WasmTool types, which would fail with a channel-specific
|
||||
// error for MCP servers and channel relays.
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let channels_dir = dir.path().join("channels");
|
||||
std::fs::create_dir_all(&channels_dir).unwrap();
|
||||
|
||||
let mgr = make_manager_custom_dirs(dir.path().join("tools"), channels_dir);
|
||||
|
||||
// Register a channel relay extension (in-memory)
|
||||
mgr.installed_relay_extensions
|
||||
.write()
|
||||
.await
|
||||
.insert("test-relay".to_string());
|
||||
|
||||
// configure() should dispatch to activate_channel_relay(), not
|
||||
// activate_wasm_channel(). Both will fail (no runtime configured),
|
||||
// but the error should be about relay config, not WASM channels.
|
||||
let mut secrets = std::collections::HashMap::new();
|
||||
secrets.insert(
|
||||
"relay:test-relay:stream_token".to_string(),
|
||||
"tok".to_string(),
|
||||
);
|
||||
|
||||
let result = mgr.configure("test-relay", &secrets).await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"configure should return Ok: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
let result = result.unwrap();
|
||||
// Activation will fail (no relay config), but secrets should still be stored
|
||||
assert!(
|
||||
!result.activated,
|
||||
"activation should fail without relay config"
|
||||
);
|
||||
assert!(
|
||||
!result.message.contains("WASM"),
|
||||
"error should not mention WASM — got: {}",
|
||||
result.message
|
||||
);
|
||||
|
||||
// Verify the secret was stored
|
||||
assert!(
|
||||
mgr.secrets
|
||||
.exists("test", "relay:test-relay:stream_token")
|
||||
.await
|
||||
.unwrap_or(false),
|
||||
"configure should have stored the relay stream token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validation_failed_is_distinct_error_variant() {
|
||||
// Regression: ValidationFailed must be a distinct error variant so
|
||||
// callers can match on it instead of parsing error message strings.
|
||||
let err = ExtensionError::ValidationFailed("Invalid token".to_string());
|
||||
|
||||
assert!(
|
||||
matches!(err, ExtensionError::ValidationFailed(_)),
|
||||
"Should match ValidationFailed variant"
|
||||
);
|
||||
assert!(
|
||||
!matches!(err, ExtensionError::Other(_)),
|
||||
"Must NOT match Other variant"
|
||||
);
|
||||
assert!(
|
||||
!matches!(err, ExtensionError::AuthFailed(_)),
|
||||
"Must NOT match AuthFailed variant"
|
||||
);
|
||||
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("validation failed"),
|
||||
"Display should contain 'validation failed', got: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,20 +449,6 @@ pub struct ActivateResult {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Result of configuring secrets for an extension.
|
||||
///
|
||||
/// Returned by `ExtensionManager::configure()`, the single entrypoint
|
||||
/// for providing secrets to any extension (chat auth, gateway setup, etc.).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConfigureResult {
|
||||
/// Human-readable status message.
|
||||
pub message: String,
|
||||
/// Whether the extension was successfully activated after configuration.
|
||||
pub activated: bool,
|
||||
/// OAuth authorization URL (if OAuth flow was started).
|
||||
pub auth_url: Option<String>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -544,9 +530,6 @@ pub enum ExtensionError {
|
||||
fallback: Box<ExtensionError>,
|
||||
},
|
||||
|
||||
#[error("Token validation failed: {0}")]
|
||||
ValidationFailed(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
+9
-15
@@ -1407,31 +1407,25 @@ pub struct ConversationMessage {
|
||||
impl Store {
|
||||
/// Ensure a conversation row exists for a given UUID.
|
||||
///
|
||||
/// Returns `true` when the row is inserted or refreshed for the same
|
||||
/// `(channel, user_id)`. Returns `false` when the UUID already exists but
|
||||
/// belongs to a different owner/channel.
|
||||
/// Idempotent: inserts on first call, bumps `last_activity` on subsequent calls.
|
||||
pub async fn ensure_conversation(
|
||||
&self,
|
||||
id: Uuid,
|
||||
channel: &str,
|
||||
user_id: &str,
|
||||
thread_id: Option<&str>,
|
||||
) -> Result<bool, DatabaseError> {
|
||||
) -> Result<(), DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
let affected = conn
|
||||
.execute(
|
||||
r#"
|
||||
conn.execute(
|
||||
r#"
|
||||
INSERT INTO conversations (id, channel, user_id, thread_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET last_activity = NOW()
|
||||
WHERE conversations.user_id = EXCLUDED.user_id
|
||||
AND conversations.channel = EXCLUDED.channel
|
||||
ON CONFLICT (id) DO UPDATE SET last_activity = NOW()
|
||||
"#,
|
||||
&[&id, &channel, &user_id, &thread_id],
|
||||
)
|
||||
.await?;
|
||||
Ok(affected > 0)
|
||||
&[&id, &channel, &user_id, &thread_id],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List conversations with a title derived from the first user message.
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
//! OpenClaw migration and import functionality.
|
||||
//!
|
||||
//! Provides tools to migrate existing OpenClaw installations (memory, history,
|
||||
//! settings, and credentials) into IronClaw without data loss.
|
||||
|
||||
#[cfg(feature = "import")]
|
||||
pub mod openclaw;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Configuration options for OpenClaw import.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImportOptions {
|
||||
/// Path to the OpenClaw directory (default: ~/.openclaw).
|
||||
pub openclaw_path: PathBuf,
|
||||
/// Dry-run mode: report what would be imported without writing to DB.
|
||||
pub dry_run: bool,
|
||||
/// Re-embed memory documents if dimension mismatch detected.
|
||||
pub re_embed: bool,
|
||||
/// User ID for scoping imported data.
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
/// Statistics collected during an import operation.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ImportStats {
|
||||
/// Number of workspace documents imported.
|
||||
pub documents: usize,
|
||||
/// Number of memory chunks imported.
|
||||
pub chunks: usize,
|
||||
/// Number of conversations imported.
|
||||
pub conversations: usize,
|
||||
/// Number of messages imported.
|
||||
pub messages: usize,
|
||||
/// Number of settings imported.
|
||||
pub settings: usize,
|
||||
/// Number of credentials imported.
|
||||
pub secrets: usize,
|
||||
/// Number of items skipped (already existed).
|
||||
pub skipped: usize,
|
||||
/// Number of chunks queued for re-embedding.
|
||||
pub re_embed_queued: usize,
|
||||
}
|
||||
|
||||
impl ImportStats {
|
||||
/// Check if any items were imported.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.documents == 0
|
||||
&& self.chunks == 0
|
||||
&& self.conversations == 0
|
||||
&& self.messages == 0
|
||||
&& self.settings == 0
|
||||
&& self.secrets == 0
|
||||
}
|
||||
|
||||
/// Total number of items imported.
|
||||
pub fn total_imported(&self) -> usize {
|
||||
self.documents
|
||||
+ self.chunks
|
||||
+ self.conversations
|
||||
+ self.messages
|
||||
+ self.settings
|
||||
+ self.secrets
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors that can occur during import.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ImportError {
|
||||
#[error("OpenClaw not found at {path}: {reason}")]
|
||||
NotFound { path: PathBuf, reason: String },
|
||||
|
||||
#[error("JSON5 parse error: {0}")]
|
||||
ConfigParse(String),
|
||||
|
||||
#[error("SQLite error: {0}")]
|
||||
Sqlite(String),
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
Database(String),
|
||||
|
||||
#[error("Workspace error: {0}")]
|
||||
Workspace(String),
|
||||
|
||||
#[error("Secret error: {0}")]
|
||||
Secret(String),
|
||||
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("Invalid UTF-8: {0}")]
|
||||
InvalidUtf8(String),
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
//! OpenClaw credential import with secure handling.
|
||||
//!
|
||||
//! Credential extraction and import is handled in the main importer (mod.rs).
|
||||
//! The credentials module focuses on security validation and testing.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::secrets::CreateSecretParams;
|
||||
use secrecy::SecretString;
|
||||
|
||||
#[test]
|
||||
fn test_secret_string_not_logged() {
|
||||
let secret = SecretString::new("super-secret-key".to_string().into_boxed_str());
|
||||
let debug_output = format!("{:?}", secret);
|
||||
|
||||
// Verify that the actual secret is not in the debug output
|
||||
assert!(!debug_output.contains("super-secret-key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_secret_params_normalized() {
|
||||
let params = CreateSecretParams::new("MY_API_KEY", "value123");
|
||||
// Secret names should be normalized to lowercase
|
||||
assert_eq!(params.name, "my_api_key");
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
//! OpenClaw conversation history import.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::import::{ImportError, ImportOptions};
|
||||
|
||||
use super::reader::OpenClawConversation;
|
||||
|
||||
/// Import a conversation and its messages atomically.
|
||||
///
|
||||
/// This function attempts to create a conversation and add all its messages as a logical unit.
|
||||
/// While the Database trait does not expose explicit transaction control, this function
|
||||
/// minimizes the risk of partial writes by:
|
||||
/// - Validating all message data before creating the conversation
|
||||
/// - Creating the conversation once
|
||||
/// - Adding all messages in a tight loop
|
||||
/// - Returning detailed errors if any step fails
|
||||
///
|
||||
/// Returns (conversation_id, message_count) on success.
|
||||
///
|
||||
/// **Note on Database Safety**: Without explicit transaction support in the Database trait,
|
||||
/// if a crash occurs during message insertion, the conversation will exist with fewer messages
|
||||
/// than expected. This is preferable to crashes during conversation creation (empty conversation).
|
||||
///
|
||||
/// **Note on Idempotency**: The metadata includes `openclaw_conversation_id` for deduplication
|
||||
/// on reimport. However, without metadata-based query support in the Database trait, reimporting
|
||||
/// will create duplicate conversations. This limitation should be fixed by adding
|
||||
/// `list_conversations_by_metadata_key()` to the Database trait.
|
||||
pub async fn import_conversation_atomic(
|
||||
db: &Arc<dyn Database>,
|
||||
conv: OpenClawConversation,
|
||||
opts: &ImportOptions,
|
||||
) -> Result<(Uuid, usize), ImportError> {
|
||||
// PHASE 1: Validate all message data before writing anything
|
||||
let mut validated_messages = Vec::with_capacity(conv.messages.len());
|
||||
for msg in &conv.messages {
|
||||
let role = match msg.role.to_lowercase().as_str() {
|
||||
"user" | "human" => "user",
|
||||
"assistant" | "ai" => "assistant",
|
||||
_ => &msg.role,
|
||||
};
|
||||
validated_messages.push((role.to_string(), msg.content.clone()));
|
||||
}
|
||||
|
||||
// PHASE 2: Create the conversation (single atomic operation from DB perspective)
|
||||
// TODO: Add idempotency check when Database trait supports metadata-based lookups
|
||||
let metadata = json!({
|
||||
"openclaw_conversation_id": conv.id,
|
||||
"openclaw_channel": conv.channel,
|
||||
});
|
||||
|
||||
let conv_id = db
|
||||
.create_conversation_with_metadata(&conv.channel, &opts.user_id, &metadata)
|
||||
.await
|
||||
.map_err(|e| ImportError::Database(e.to_string()))?;
|
||||
|
||||
// PHASE 3: Add all messages in sequence
|
||||
// If this fails partway through, the conversation exists but is incomplete.
|
||||
// On reimport, the openclaw_conversation_id metadata will detect it.
|
||||
let mut message_count = 0;
|
||||
for (role, content) in validated_messages {
|
||||
db.add_conversation_message(conv_id, &role, &content)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
// Log detailed error including conversation ID for recovery
|
||||
tracing::error!(
|
||||
"Failed to add message to conversation {}: {}. \
|
||||
Conversation created but may be incomplete.",
|
||||
conv_id,
|
||||
e
|
||||
);
|
||||
ImportError::Database(e.to_string())
|
||||
})?;
|
||||
|
||||
message_count += 1;
|
||||
}
|
||||
|
||||
Ok((conv_id, message_count))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::import::openclaw::reader::OpenClawMessage;
|
||||
|
||||
#[test]
|
||||
fn test_conversation_import_structure() {
|
||||
// Verify that OpenClawConversation can be created with test data
|
||||
let conv = OpenClawConversation {
|
||||
id: "conv-123".to_string(),
|
||||
channel: "telegram".to_string(),
|
||||
created_at: None,
|
||||
messages: vec![
|
||||
OpenClawMessage {
|
||||
role: "user".to_string(),
|
||||
content: "Hello".to_string(),
|
||||
created_at: None,
|
||||
},
|
||||
OpenClawMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: "Hi there".to_string(),
|
||||
created_at: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
assert_eq!(conv.id, "conv-123");
|
||||
assert_eq!(conv.messages.len(), 2);
|
||||
assert_eq!(conv.channel, "telegram");
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
//! OpenClaw memory chunk import.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::import::{ImportError, ImportOptions};
|
||||
|
||||
use super::reader::OpenClawMemoryChunk;
|
||||
|
||||
/// Import a single memory chunk into IronClaw.
|
||||
pub async fn import_chunk(
|
||||
db: &Arc<dyn Database>,
|
||||
chunk: &OpenClawMemoryChunk,
|
||||
opts: &ImportOptions,
|
||||
) -> Result<(), ImportError> {
|
||||
// Get or create document by path
|
||||
let doc = db
|
||||
.get_or_create_document_by_path(&opts.user_id, None, &chunk.path)
|
||||
.await
|
||||
.map_err(|e| ImportError::Database(e.to_string()))?;
|
||||
|
||||
// Insert chunk
|
||||
let chunk_id = db
|
||||
.insert_chunk(
|
||||
doc.id,
|
||||
chunk.chunk_index,
|
||||
&chunk.content,
|
||||
None, // Don't set embedding yet if dimensions might not match
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ImportError::Database(e.to_string()))?;
|
||||
|
||||
// If we have an embedding, try to update it
|
||||
if let Some(ref embedding) = chunk.embedding {
|
||||
// Note: dimension check would go here if we had target dimensions available
|
||||
// For now, just store what we have
|
||||
db.update_chunk_embedding(chunk_id, embedding)
|
||||
.await
|
||||
.map_err(|e| ImportError::Database(e.to_string()))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_memory_chunk_import_structure() {
|
||||
// Verify that OpenClawMemoryChunk can be created with test data
|
||||
let chunk = OpenClawMemoryChunk {
|
||||
path: "test/path.md".to_string(),
|
||||
content: "Test content".to_string(),
|
||||
embedding: Some(vec![0.1, 0.2, 0.3]),
|
||||
chunk_index: 0,
|
||||
};
|
||||
|
||||
assert_eq!(chunk.path, "test/path.md");
|
||||
assert_eq!(chunk.chunk_index, 0);
|
||||
assert!(chunk.embedding.is_some());
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
//! OpenClaw data migration orchestration and detection.
|
||||
|
||||
pub mod credentials;
|
||||
pub mod history;
|
||||
pub mod memory;
|
||||
pub mod reader;
|
||||
pub mod settings;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::import::{ImportError, ImportOptions, ImportStats};
|
||||
use crate::secrets::SecretsStore;
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
pub use reader::OpenClawReader;
|
||||
|
||||
/// OpenClaw importer that coordinates migration of all data types.
|
||||
pub struct OpenClawImporter {
|
||||
db: Arc<dyn Database>,
|
||||
workspace: Workspace,
|
||||
secrets: Arc<dyn SecretsStore>,
|
||||
opts: ImportOptions,
|
||||
}
|
||||
|
||||
impl OpenClawImporter {
|
||||
/// Create a new OpenClaw importer.
|
||||
pub fn new(
|
||||
db: Arc<dyn Database>,
|
||||
workspace: Workspace,
|
||||
secrets: Arc<dyn SecretsStore>,
|
||||
opts: ImportOptions,
|
||||
) -> Self {
|
||||
Self {
|
||||
db,
|
||||
workspace,
|
||||
secrets,
|
||||
opts,
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect if an OpenClaw installation exists at the default location (~/.openclaw).
|
||||
pub fn detect() -> Option<PathBuf> {
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
let openclaw_dir = PathBuf::from(home).join(".openclaw");
|
||||
let config_file = openclaw_dir.join("openclaw.json");
|
||||
if config_file.exists() {
|
||||
return Some(openclaw_dir);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Run the import process for all data types.
|
||||
///
|
||||
/// Returns detailed statistics about what was imported.
|
||||
/// If `dry_run` is enabled, no data is written to the database.
|
||||
///
|
||||
/// **Database Safety Note:** The Database trait does not currently expose explicit
|
||||
/// transaction control (BEGIN/COMMIT/ROLLBACK). To minimize consistency risks:
|
||||
/// - All configuration reading is done before any writes
|
||||
/// - Writes are grouped by type (settings, credentials, documents, chunks, conversations)
|
||||
/// - Conversations are handled atomically: creation + all messages added together
|
||||
/// - Errors are logged but don't stop the entire import (fail-safe behavior)
|
||||
pub async fn import(&self) -> Result<ImportStats, ImportError> {
|
||||
let mut stats = ImportStats::default();
|
||||
|
||||
// === PHASE 1: READ ALL DATA BEFORE ANY WRITES ===
|
||||
// This minimizes the window where the database could be left in a partial state
|
||||
|
||||
// Read OpenClaw data
|
||||
let reader = OpenClawReader::new(&self.opts.openclaw_path)?;
|
||||
let config = reader.read_config()?;
|
||||
let agent_dbs = reader.list_agent_dbs()?;
|
||||
|
||||
// Pre-read all conversation data to validate before writing
|
||||
let mut all_conversations = Vec::new();
|
||||
for (_agent_name, db_path) in &agent_dbs {
|
||||
match reader.read_conversations(db_path).await {
|
||||
Ok(convs) => all_conversations.extend(convs),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to read conversations: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-read all memory chunks
|
||||
let mut all_chunks = Vec::new();
|
||||
for (_agent_name, db_path) in &agent_dbs {
|
||||
match reader.read_memory_chunks(db_path).await {
|
||||
Ok(chunks) => all_chunks.extend(chunks),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to read memory chunks: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare all settings and credentials
|
||||
let settings_map = settings::map_openclaw_config_to_settings(&config);
|
||||
let creds = settings::extract_credentials(&config);
|
||||
|
||||
// === PHASE 2: WRITE IN GROUPED ORDER ===
|
||||
// If a crash occurs, earlier groups are fully committed
|
||||
|
||||
if !self.opts.dry_run {
|
||||
// Group 1: Settings (should be idempotent via upsert)
|
||||
for (key, value) in settings_map {
|
||||
if let Err(e) = self.db.set_setting(&self.opts.user_id, &key, &value).await {
|
||||
tracing::warn!("Failed to import setting {}: {}", key, e);
|
||||
} else {
|
||||
stats.settings += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Group 2: Credentials (should be idempotent via upsert)
|
||||
for (name, value) in creds {
|
||||
use secrecy::ExposeSecret;
|
||||
let exposed = value.expose_secret().to_string();
|
||||
let params = crate::secrets::CreateSecretParams::new(name, exposed);
|
||||
if let Err(e) = self.secrets.create(&self.opts.user_id, params).await {
|
||||
tracing::warn!("Failed to import credential: {}", e);
|
||||
} else {
|
||||
stats.secrets += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Group 3: Workspace documents
|
||||
if let Ok(_count) = reader.list_workspace_files() {
|
||||
match self
|
||||
.workspace
|
||||
.import_from_directory(&self.opts.openclaw_path.join("workspace"))
|
||||
.await
|
||||
{
|
||||
Ok(imported) => stats.documents = imported,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to import workspace documents: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Group 4: Memory chunks (should be idempotent via path deduplication)
|
||||
for chunk in all_chunks {
|
||||
if let Err(e) = memory::import_chunk(&self.db, &chunk, &self.opts).await {
|
||||
tracing::warn!("Failed to import memory chunk: {}", e);
|
||||
} else {
|
||||
stats.chunks += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Group 5: Conversations with messages
|
||||
// CRITICAL: Each conversation + its messages form an atomic unit.
|
||||
// If a crash occurs mid-conversation, only that conversation is incomplete.
|
||||
// All previous conversations are fully committed.
|
||||
for conv in all_conversations {
|
||||
match history::import_conversation_atomic(&self.db, conv, &self.opts).await {
|
||||
Ok((_conv_id, msg_count)) => {
|
||||
stats.conversations += 1;
|
||||
stats.messages += msg_count;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to import conversation: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// DRY RUN: Count only
|
||||
stats.settings = settings_map.len();
|
||||
stats.secrets = creds.len();
|
||||
if let Ok(count) = reader.list_workspace_files() {
|
||||
stats.documents = count;
|
||||
}
|
||||
stats.chunks = all_chunks.len();
|
||||
stats.conversations = all_conversations.len();
|
||||
for conv in &all_conversations {
|
||||
stats.messages += conv.messages.len();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(stats)
|
||||
}
|
||||
}
|
||||
@@ -1,442 +0,0 @@
|
||||
//! Read-only extraction layer for OpenClaw data.
|
||||
//!
|
||||
//! Handles opening OpenClaw SQLite databases and reading configuration
|
||||
//! without making any modifications.
|
||||
|
||||
use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use secrecy::SecretString;
|
||||
|
||||
use crate::import::ImportError;
|
||||
|
||||
/// OpenClaw configuration structure (parsed from openclaw.json).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OpenClawConfig {
|
||||
pub llm: Option<OpenClawLlmConfig>,
|
||||
pub embeddings: Option<OpenClawEmbeddingsConfig>,
|
||||
pub other_settings: std::collections::HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct OpenClawLlmConfig {
|
||||
pub provider: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub api_key: Option<SecretString>,
|
||||
pub base_url: Option<String>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for OpenClawLlmConfig {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("OpenClawLlmConfig")
|
||||
.field("provider", &self.provider)
|
||||
.field("model", &self.model)
|
||||
.field("api_key", &self.api_key.as_ref().map(|_| "***REDACTED***"))
|
||||
.field("base_url", &self.base_url)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct OpenClawEmbeddingsConfig {
|
||||
pub model: Option<String>,
|
||||
pub api_key: Option<SecretString>,
|
||||
pub provider: Option<String>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for OpenClawEmbeddingsConfig {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("OpenClawEmbeddingsConfig")
|
||||
.field("model", &self.model)
|
||||
.field("api_key", &self.api_key.as_ref().map(|_| "***REDACTED***"))
|
||||
.field("provider", &self.provider)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// A memory chunk from OpenClaw's database.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OpenClawMemoryChunk {
|
||||
pub path: String,
|
||||
pub content: String,
|
||||
pub embedding: Option<Vec<f32>>,
|
||||
pub chunk_index: i32,
|
||||
}
|
||||
|
||||
/// A conversation from OpenClaw's database.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OpenClawConversation {
|
||||
pub id: String,
|
||||
pub channel: String,
|
||||
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub messages: Vec<OpenClawMessage>,
|
||||
}
|
||||
|
||||
/// A message within an OpenClaw conversation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OpenClawMessage {
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
/// Open an OpenClaw SQLite database file via libsql for read-only access.
|
||||
#[cfg(feature = "import")]
|
||||
async fn open_sqlite(db_path: &Path) -> Result<libsql::Connection, ImportError> {
|
||||
let db = libsql::Builder::new_local(db_path)
|
||||
.build()
|
||||
.await
|
||||
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
db.connect().map_err(|e| ImportError::Sqlite(e.to_string()))
|
||||
}
|
||||
|
||||
/// Reader for OpenClaw data files and databases.
|
||||
pub struct OpenClawReader {
|
||||
openclaw_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl OpenClawReader {
|
||||
/// Create a new OpenClaw reader for the given directory.
|
||||
pub fn new(openclaw_dir: &Path) -> Result<Self, ImportError> {
|
||||
if !openclaw_dir.exists() {
|
||||
return Err(ImportError::NotFound {
|
||||
path: openclaw_dir.to_path_buf(),
|
||||
reason: "Directory does not exist".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
openclaw_dir: openclaw_dir.to_path_buf(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if an OpenClaw installation exists at ~/.openclaw.
|
||||
pub fn detect(home_dir: &Path) -> bool {
|
||||
let openclaw_dir = home_dir.join(".openclaw");
|
||||
let config_file = openclaw_dir.join("openclaw.json");
|
||||
config_file.exists()
|
||||
}
|
||||
|
||||
/// Read and parse openclaw.json configuration.
|
||||
pub fn read_config(&self) -> Result<OpenClawConfig, ImportError> {
|
||||
let config_path = self.openclaw_dir.join("openclaw.json");
|
||||
|
||||
if !config_path.exists() {
|
||||
return Err(ImportError::NotFound {
|
||||
path: config_path,
|
||||
reason: "openclaw.json not found".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&config_path).map_err(ImportError::Io)?;
|
||||
|
||||
#[cfg(feature = "import")]
|
||||
{
|
||||
let config: serde_json::Value =
|
||||
json5::from_str(&content).map_err(|e| ImportError::ConfigParse(e.to_string()))?;
|
||||
|
||||
// Extract LLM config
|
||||
let llm = config
|
||||
.get("llm")
|
||||
.and_then(|v| v.as_object())
|
||||
.map(|llm_obj| OpenClawLlmConfig {
|
||||
provider: llm_obj
|
||||
.get("provider")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
model: llm_obj
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
api_key: llm_obj
|
||||
.get("api_key")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| SecretString::new(s.to_string().into_boxed_str())),
|
||||
base_url: llm_obj
|
||||
.get("base_url")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
});
|
||||
|
||||
// Extract embeddings config
|
||||
let embeddings = config
|
||||
.get("embeddings")
|
||||
.and_then(|v| v.as_object())
|
||||
.map(|emb_obj| OpenClawEmbeddingsConfig {
|
||||
model: emb_obj
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
api_key: emb_obj
|
||||
.get("api_key")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| SecretString::new(s.to_string().into_boxed_str())),
|
||||
provider: emb_obj
|
||||
.get("provider")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
});
|
||||
|
||||
// Store remaining settings
|
||||
let mut other_settings = std::collections::HashMap::new();
|
||||
if let Some(obj) = config.as_object() {
|
||||
for (k, v) in obj {
|
||||
if k != "llm" && k != "embeddings" {
|
||||
other_settings.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(OpenClawConfig {
|
||||
llm,
|
||||
embeddings,
|
||||
other_settings,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "import"))]
|
||||
{
|
||||
Err(ImportError::ConfigParse(
|
||||
"Import feature not enabled (compile with --features import)".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// List all agent `.sqlite` files in the agents/ directory, sorted by name for deterministic order.
|
||||
pub fn list_agent_dbs(&self) -> Result<Vec<(String, PathBuf)>, ImportError> {
|
||||
let agents_dir = self.openclaw_dir.join("agents");
|
||||
|
||||
if !agents_dir.exists() {
|
||||
// No agents directory is fine (might have no saved conversations)
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut dbs = Vec::new();
|
||||
for entry in std::fs::read_dir(&agents_dir).map_err(ImportError::Io)? {
|
||||
let entry = entry.map_err(ImportError::Io)?;
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) == Some("sqlite") {
|
||||
match path.file_stem().and_then(|s| s.to_str()) {
|
||||
Some(name) => dbs.push((name.to_string(), path)),
|
||||
None => {
|
||||
tracing::warn!(
|
||||
"Skipping agent database with non-UTF-8 filename: {:?}",
|
||||
path
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by agent name for deterministic ordering
|
||||
dbs.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
Ok(dbs)
|
||||
}
|
||||
|
||||
/// Read all memory chunks from an OpenClaw SQLite database.
|
||||
#[cfg(feature = "import")]
|
||||
pub async fn read_memory_chunks(
|
||||
&self,
|
||||
db_path: &Path,
|
||||
) -> Result<Vec<OpenClawMemoryChunk>, ImportError> {
|
||||
let conn = open_sqlite(db_path).await?;
|
||||
|
||||
let mut rows = conn
|
||||
.query(
|
||||
"SELECT path, content, embedding, chunk_index FROM chunks",
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
|
||||
let mut result = Vec::new();
|
||||
while let Some(row) = rows
|
||||
.next()
|
||||
.await
|
||||
.map_err(|e| ImportError::Sqlite(e.to_string()))?
|
||||
{
|
||||
let path: String = row.get(0).map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
let content: String = row.get(1).map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
let embedding_blob: Option<Vec<u8>> =
|
||||
row.get(2).map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
let chunk_index: i32 = row.get(3).map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
|
||||
// Convert binary embedding blob to Vec<f32> if present
|
||||
let embedding = embedding_blob.map(|bytes| {
|
||||
bytes
|
||||
.chunks(4)
|
||||
.map(|chunk| {
|
||||
if chunk.len() == 4 {
|
||||
f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
result.push(OpenClawMemoryChunk {
|
||||
path,
|
||||
content,
|
||||
embedding,
|
||||
chunk_index,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Read all conversations from an OpenClaw SQLite database.
|
||||
#[cfg(feature = "import")]
|
||||
pub async fn read_conversations(
|
||||
&self,
|
||||
db_path: &Path,
|
||||
) -> Result<Vec<OpenClawConversation>, ImportError> {
|
||||
let conn = open_sqlite(db_path).await?;
|
||||
|
||||
let mut conv_rows = conn
|
||||
.query(
|
||||
"SELECT id, channel, created_at FROM conversations ORDER BY created_at DESC",
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
|
||||
let mut conversations = Vec::new();
|
||||
while let Some(row) = conv_rows
|
||||
.next()
|
||||
.await
|
||||
.map_err(|e| ImportError::Sqlite(e.to_string()))?
|
||||
{
|
||||
let id: String = row.get(0).map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
let channel: String = row.get(1).map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
let created_at: Option<String> =
|
||||
row.get(2).map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
|
||||
let created_at = created_at
|
||||
.and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok())
|
||||
.map(|dt| dt.with_timezone(&chrono::Utc));
|
||||
|
||||
// Read messages for this conversation
|
||||
let mut msg_rows = conn
|
||||
.query(
|
||||
"SELECT role, content, created_at FROM messages WHERE conversation_id = ?1 ORDER BY created_at",
|
||||
libsql::params![id.as_str()],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
|
||||
let mut messages = Vec::new();
|
||||
while let Some(msg_row) = msg_rows
|
||||
.next()
|
||||
.await
|
||||
.map_err(|e| ImportError::Sqlite(e.to_string()))?
|
||||
{
|
||||
let role: String = msg_row
|
||||
.get(0)
|
||||
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
let content: String = msg_row
|
||||
.get(1)
|
||||
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
let msg_created_at: Option<String> = msg_row
|
||||
.get(2)
|
||||
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
|
||||
|
||||
let msg_created_at = msg_created_at
|
||||
.and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok())
|
||||
.map(|dt| dt.with_timezone(&chrono::Utc));
|
||||
|
||||
messages.push(OpenClawMessage {
|
||||
role,
|
||||
content,
|
||||
created_at: msg_created_at,
|
||||
});
|
||||
}
|
||||
|
||||
conversations.push(OpenClawConversation {
|
||||
id,
|
||||
channel,
|
||||
created_at,
|
||||
messages,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(conversations)
|
||||
}
|
||||
|
||||
/// List workspace markdown files available for import.
|
||||
pub fn list_workspace_files(&self) -> Result<usize, ImportError> {
|
||||
let workspace_dir = self.openclaw_dir.join("workspace");
|
||||
|
||||
if !workspace_dir.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let mut count = 0;
|
||||
if let Ok(entries) = std::fs::read_dir(&workspace_dir) {
|
||||
for entry in entries.flatten() {
|
||||
if let Some(ext) = entry.path().extension()
|
||||
&& ext == "md"
|
||||
{
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod security_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_llm_config_debug_redacts_api_key() {
|
||||
let config = OpenClawLlmConfig {
|
||||
provider: Some("openai".to_string()),
|
||||
model: Some("gpt-4".to_string()),
|
||||
api_key: Some(SecretString::new("sk-secret-key-12345".into())),
|
||||
base_url: Some("https://api.openai.com".to_string()),
|
||||
};
|
||||
|
||||
let debug_output = format!("{:?}", config);
|
||||
|
||||
// Verify the actual API key is never exposed in debug output
|
||||
assert!(!debug_output.contains("sk-secret-key-12345"));
|
||||
// Verify the redaction marker is present
|
||||
assert!(debug_output.contains("***REDACTED***"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_embeddings_config_debug_redacts_api_key() {
|
||||
let config = OpenClawEmbeddingsConfig {
|
||||
model: Some("text-embedding-3-large".to_string()),
|
||||
api_key: Some(SecretString::new("sk-embed-secret-67890".into())),
|
||||
provider: Some("openai".to_string()),
|
||||
};
|
||||
|
||||
let debug_output = format!("{:?}", config);
|
||||
|
||||
// Verify the actual API key is never exposed in debug output
|
||||
assert!(!debug_output.contains("sk-embed-secret-67890"));
|
||||
// Verify the redaction marker is present
|
||||
assert!(debug_output.contains("***REDACTED***"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_llm_config_without_api_key() {
|
||||
let config = OpenClawLlmConfig {
|
||||
provider: Some("openai".to_string()),
|
||||
model: Some("gpt-4".to_string()),
|
||||
api_key: None,
|
||||
base_url: None,
|
||||
};
|
||||
|
||||
let debug_output = format!("{:?}", config);
|
||||
|
||||
// Should show None for missing API key
|
||||
assert!(debug_output.contains("api_key: None"));
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
//! OpenClaw configuration to IronClaw settings mapping.
|
||||
|
||||
use secrecy::SecretString;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::reader::OpenClawConfig;
|
||||
|
||||
/// Map OpenClaw configuration to IronClaw settings (dotted-key format).
|
||||
pub fn map_openclaw_config_to_settings(
|
||||
config: &OpenClawConfig,
|
||||
) -> HashMap<String, serde_json::Value> {
|
||||
let mut settings = HashMap::new();
|
||||
|
||||
// Map LLM configuration
|
||||
if let Some(ref llm) = config.llm {
|
||||
if let Some(ref provider) = llm.provider {
|
||||
settings.insert(
|
||||
"llm.backend".to_string(),
|
||||
serde_json::Value::String(provider.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(ref model) = llm.model {
|
||||
settings.insert(
|
||||
"llm.selected_model".to_string(),
|
||||
serde_json::Value::String(model.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(ref base_url) = llm.base_url {
|
||||
settings.insert(
|
||||
"llm.base_url".to_string(),
|
||||
serde_json::Value::String(base_url.clone()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Map embeddings configuration
|
||||
if let Some(ref emb) = config.embeddings {
|
||||
if let Some(ref model) = emb.model {
|
||||
settings.insert(
|
||||
"embeddings.model".to_string(),
|
||||
serde_json::Value::String(model.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(ref provider) = emb.provider {
|
||||
settings.insert(
|
||||
"embeddings.provider".to_string(),
|
||||
serde_json::Value::String(provider.clone()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Map any other top-level settings
|
||||
for (key, value) in &config.other_settings {
|
||||
// Safely pass through JSON-serializable values
|
||||
settings.insert(key.clone(), value.clone());
|
||||
}
|
||||
|
||||
settings
|
||||
}
|
||||
|
||||
/// Extract credentials from OpenClaw configuration.
|
||||
///
|
||||
/// Returns a list of (secret_name, secret_value) pairs that should be stored.
|
||||
/// Secret values are never logged or printed.
|
||||
pub fn extract_credentials(config: &OpenClawConfig) -> Vec<(String, SecretString)> {
|
||||
let mut credentials = Vec::new();
|
||||
|
||||
// Extract LLM API key if present
|
||||
if let Some(ref llm) = config.llm
|
||||
&& let Some(ref api_key) = llm.api_key
|
||||
{
|
||||
credentials.push(("llm_api_key".to_string(), api_key.clone()));
|
||||
}
|
||||
|
||||
// Extract embeddings API key if present
|
||||
if let Some(ref emb) = config.embeddings
|
||||
&& let Some(ref api_key) = emb.api_key
|
||||
{
|
||||
credentials.push(("embeddings_api_key".to_string(), api_key.clone()));
|
||||
}
|
||||
|
||||
credentials
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::import::openclaw::reader::{OpenClawConfig, OpenClawLlmConfig};
|
||||
|
||||
#[test]
|
||||
fn test_map_llm_config() {
|
||||
let mut config = OpenClawConfig {
|
||||
llm: None,
|
||||
embeddings: None,
|
||||
other_settings: HashMap::new(),
|
||||
};
|
||||
|
||||
config.llm = Some(OpenClawLlmConfig {
|
||||
provider: Some("openai".to_string()),
|
||||
model: Some("gpt-4".to_string()),
|
||||
api_key: Some(SecretString::new("secret".to_string().into_boxed_str())),
|
||||
base_url: None,
|
||||
});
|
||||
|
||||
let settings = map_openclaw_config_to_settings(&config);
|
||||
|
||||
assert_eq!(
|
||||
settings.get("llm.backend"),
|
||||
Some(&serde_json::Value::String("openai".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
settings.get("llm.selected_model"),
|
||||
Some(&serde_json::Value::String("gpt-4".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_credentials_never_logs() {
|
||||
let mut config = OpenClawConfig {
|
||||
llm: None,
|
||||
embeddings: None,
|
||||
other_settings: HashMap::new(),
|
||||
};
|
||||
|
||||
config.llm = Some(OpenClawLlmConfig {
|
||||
provider: Some("anthropic".to_string()),
|
||||
model: Some("claude-3".to_string()),
|
||||
api_key: Some(SecretString::new(
|
||||
"secret-key-value".to_string().into_boxed_str(),
|
||||
)),
|
||||
base_url: None,
|
||||
});
|
||||
|
||||
let creds = extract_credentials(&config);
|
||||
assert_eq!(creds.len(), 1);
|
||||
assert_eq!(creds[0].0, "llm_api_key");
|
||||
// Verify the value is wrapped in SecretString (never exposed in Debug output)
|
||||
assert!(!format!("{:?}", creds[0].1).contains("secret-key-value"));
|
||||
}
|
||||
}
|
||||
@@ -54,8 +54,6 @@ pub mod evaluation;
|
||||
pub mod extensions;
|
||||
pub mod history;
|
||||
pub mod hooks;
|
||||
#[cfg(feature = "import")]
|
||||
pub mod import;
|
||||
pub mod llm;
|
||||
pub mod observability;
|
||||
pub mod orchestrator;
|
||||
@@ -74,7 +72,6 @@ pub mod tracing_fmt;
|
||||
pub mod transcription;
|
||||
pub mod tunnel;
|
||||
pub mod util;
|
||||
pub mod webhooks;
|
||||
pub mod worker;
|
||||
pub mod workspace;
|
||||
|
||||
|
||||
+22
-68
@@ -9,8 +9,8 @@ use ironclaw::{
|
||||
agent::{Agent, AgentDeps},
|
||||
app::{AppBuilder, AppBuilderFlags},
|
||||
channels::{
|
||||
ChannelManager, ChannelSecretUpdater, GatewayChannel, HttpChannel, ReplChannel,
|
||||
SignalChannel, WebhookServer, WebhookServerConfig,
|
||||
ChannelManager, GatewayChannel, HttpChannel, ReplChannel, SignalChannel, WebhookServer,
|
||||
WebhookServerConfig,
|
||||
wasm::{WasmChannelRouter, WasmChannelRuntime},
|
||||
web::log_layer::LogBroadcaster,
|
||||
},
|
||||
@@ -24,7 +24,6 @@ use ironclaw::{
|
||||
orchestrator::{ReaperConfig, SandboxReaper},
|
||||
pairing::PairingStore,
|
||||
tracing_fmt::{init_cli_tracing, init_worker_tracing},
|
||||
webhooks::{self, ToolWebhookState},
|
||||
};
|
||||
|
||||
#[cfg(any(feature = "postgres", feature = "libsql"))]
|
||||
@@ -87,12 +86,6 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
init_cli_tracing();
|
||||
return completion.run();
|
||||
}
|
||||
#[cfg(feature = "import")]
|
||||
Some(Command::Import(import_cmd)) => {
|
||||
init_cli_tracing();
|
||||
let config = ironclaw::config::Config::from_env().await?;
|
||||
return ironclaw::cli::run_import_command(import_cmd, &config).await;
|
||||
}
|
||||
Some(Command::Worker {
|
||||
job_id,
|
||||
orchestrator_url,
|
||||
@@ -278,25 +271,9 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// Shared routine engine slot for gateway + generic webhook ingress.
|
||||
let shared_routine_engine_slot: ironclaw::channels::web::server::RoutineEngineSlot =
|
||||
Arc::new(tokio::sync::RwLock::new(None));
|
||||
|
||||
// Collect webhook route fragments; a single WebhookServer hosts them all.
|
||||
let mut webhook_routes: Vec<axum::Router> = Vec::new();
|
||||
|
||||
webhook_routes.push(webhooks::routes(ToolWebhookState {
|
||||
tools: Arc::clone(&components.tools),
|
||||
routine_engine: Arc::clone(&shared_routine_engine_slot),
|
||||
user_id: config
|
||||
.channels
|
||||
.gateway
|
||||
.as_ref()
|
||||
.map(|g| g.user_id.clone())
|
||||
.unwrap_or_else(|| "default".to_string()),
|
||||
secrets_store: components.secrets_store.clone(),
|
||||
}));
|
||||
|
||||
// Load WASM channels and register their webhook routes.
|
||||
if config.channels.wasm_channels_enabled && config.channels.wasm_channels_dir.exists() {
|
||||
let wasm_result = ironclaw::channels::wasm::setup_wasm_channels(
|
||||
@@ -448,6 +425,7 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
let mut sse_sender: Option<
|
||||
tokio::sync::broadcast::Sender<ironclaw::channels::web::types::SseEvent>,
|
||||
> = None;
|
||||
let mut routine_engine_slot: Option<ironclaw::channels::web::server::RoutineEngineSlot> = None;
|
||||
if let Some(ref gw_config) = config.channels.gateway {
|
||||
let mut gw =
|
||||
GatewayChannel::new(gw_config.clone()).with_llm_provider(Arc::clone(&components.llm));
|
||||
@@ -471,7 +449,6 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
gw = gw.with_job_manager(Arc::clone(jm));
|
||||
}
|
||||
gw = gw.with_scheduler(scheduler_slot.clone());
|
||||
gw = gw.with_routine_engine_slot(Arc::clone(&shared_routine_engine_slot));
|
||||
if let Some(ref sr) = components.skill_registry {
|
||||
gw = gw.with_skill_registry(Arc::clone(sr));
|
||||
}
|
||||
@@ -506,6 +483,8 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
// IMPORTANT: This must come after all `with_*` calls since `rebuild_state`
|
||||
// creates a new SseManager, which would orphan this sender.
|
||||
sse_sender = Some(gw.state().sse.sender());
|
||||
routine_engine_slot = Some(Arc::clone(&gw.state().routine_engine));
|
||||
|
||||
channel_names.push("gateway".to_string());
|
||||
channels.add(Box::new(gw)).await;
|
||||
}
|
||||
@@ -704,7 +683,9 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
// Give the agent the routine engine slot so it can expose the engine to the gateway.
|
||||
agent.set_routine_engine_slot(shared_routine_engine_slot);
|
||||
if let Some(slot) = routine_engine_slot {
|
||||
agent.set_routine_engine_slot(slot);
|
||||
}
|
||||
|
||||
// Prepare SIGHUP handler for hot-reloading HTTP webhook config
|
||||
// Broadcast channel for clean shutdown of background tasks
|
||||
@@ -712,6 +693,7 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use ironclaw::channels::ChannelSecretUpdater;
|
||||
// Collect all channels that support secret updates
|
||||
let mut secret_updaters: Vec<Arc<dyn ChannelSecretUpdater>> = Vec::new();
|
||||
if let Some(ref state) = http_channel_state {
|
||||
@@ -799,12 +781,12 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
};
|
||||
|
||||
// Restart listener if addr changed.
|
||||
// Two-phase approach: bind outside the lock, then swap under lock.
|
||||
// Minimize lock scope: acquire, read old addr, release, then restart.
|
||||
let mut restart_failed = false;
|
||||
if let Some(ref ws_arc) = sighup_webhook_server {
|
||||
let (old_addr, router) = {
|
||||
let old_addr = {
|
||||
let ws = ws_arc.lock().await;
|
||||
(ws.current_addr(), ws.merged_router_clone())
|
||||
ws.current_addr()
|
||||
}; // Lock released here
|
||||
|
||||
if old_addr != new_addr {
|
||||
@@ -813,45 +795,17 @@ async fn async_main() -> anyhow::Result<()> {
|
||||
old_addr,
|
||||
new_addr
|
||||
);
|
||||
|
||||
match router {
|
||||
Some(app) => {
|
||||
// Phase 1: Bind new listener WITHOUT holding the lock.
|
||||
match tokio::net::TcpListener::bind(new_addr).await {
|
||||
Ok(listener) => {
|
||||
// Phase 2: Swap state under lock (no await inside).
|
||||
let (old_tx, old_handle) = {
|
||||
let mut ws = ws_arc.lock().await;
|
||||
ws.install_listener(new_addr, listener, app)
|
||||
}; // Lock released here
|
||||
|
||||
// Phase 3: Shut down old listener outside the lock.
|
||||
if let Some(tx) = old_tx {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
if let Some(handle) = old_handle {
|
||||
let _ = handle.await;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"SIGHUP: webhook server restarted on {}",
|
||||
new_addr
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"SIGHUP: failed to bind to {}: {}",
|
||||
new_addr,
|
||||
e
|
||||
);
|
||||
restart_failed = true;
|
||||
}
|
||||
}
|
||||
// NOTE: Lock is held across restart_with_addr().await. This is
|
||||
// acceptable because SIGHUP is infrequent and restart is fast. A full
|
||||
// fix would require refactoring restart_with_addr to separate state
|
||||
// mutation from async I/O.
|
||||
let mut ws = ws_arc.lock().await;
|
||||
match ws.restart_with_addr(new_addr).await {
|
||||
Ok(()) => {
|
||||
tracing::info!("SIGHUP: webhook server restarted on {}", new_addr);
|
||||
}
|
||||
None => {
|
||||
tracing::error!(
|
||||
"SIGHUP: cannot restart — server was never started"
|
||||
);
|
||||
Err(e) => {
|
||||
tracing::error!("SIGHUP: listener restart failed: {}", e);
|
||||
restart_failed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -629,7 +629,6 @@ fn is_gzip(bytes: &[u8]) -> bool {
|
||||
}
|
||||
|
||||
/// Result of extracting a tar.gz bundle.
|
||||
#[derive(Debug)]
|
||||
struct ExtractResult {
|
||||
has_capabilities: bool,
|
||||
}
|
||||
@@ -1113,169 +1112,4 @@ mod tests {
|
||||
"ChecksumMismatch on version-pinned URL must remain a hard block"
|
||||
);
|
||||
}
|
||||
|
||||
// Regression tests for tool/channel artifact name collision (PR #964).
|
||||
// When a tool and channel share the same registry filename (e.g. slack.json),
|
||||
// CI produces kind-prefixed bundles (tool-slack-*.tar.gz vs channel-slack-*.tar.gz).
|
||||
// The files *inside* each archive use manifest.name (slack-tool.wasm vs slack.wasm).
|
||||
// These tests verify the installer extracts by manifest.name correctly.
|
||||
|
||||
fn build_test_tar_gz(wasm_name: &str, caps_name: Option<&str>) -> Vec<u8> {
|
||||
use flate2::Compression;
|
||||
use flate2::write::GzEncoder;
|
||||
use tar::Builder;
|
||||
|
||||
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
|
||||
{
|
||||
let mut builder = Builder::new(&mut encoder);
|
||||
|
||||
let wasm_data = b"\0asm\x01\x00\x00\x00";
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(wasm_data.len() as u64);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, wasm_name, &wasm_data[..])
|
||||
.unwrap();
|
||||
|
||||
if let Some(caps) = caps_name {
|
||||
let caps_data = br#"{"auth":null}"#;
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(caps_data.len() as u64);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, caps, &caps_data[..])
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
builder.finish().unwrap();
|
||||
}
|
||||
encoder.finish().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_rejects_archive_with_wrong_wasm_name() {
|
||||
// Simulates the collision bug: archive contains channel's slack.wasm,
|
||||
// but installer tries to extract tool's slack-tool.wasm.
|
||||
let gz_bytes = build_test_tar_gz("slack.wasm", Some("slack.capabilities.json"));
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let result = extract_tar_gz(
|
||||
&gz_bytes,
|
||||
"slack-tool",
|
||||
&tmp.path().join("slack-tool.wasm"),
|
||||
&tmp.path().join("slack-tool.capabilities.json"),
|
||||
"test://url",
|
||||
);
|
||||
|
||||
let err = result.expect_err("should fail when archive has wrong wasm name");
|
||||
match err {
|
||||
RegistryError::DownloadFailed { reason, .. } => {
|
||||
assert!(
|
||||
reason.contains("slack-tool.wasm"),
|
||||
"error should mention expected filename: {}",
|
||||
reason
|
||||
);
|
||||
}
|
||||
other => panic!("expected DownloadFailed, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_correct_wasm_from_tool_bundle() {
|
||||
// Tool bundle contains slack-tool.wasm — extraction by name="slack-tool" succeeds.
|
||||
let gz_bytes = build_test_tar_gz("slack-tool.wasm", Some("slack-tool.capabilities.json"));
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let wasm_path = tmp.path().join("slack-tool.wasm");
|
||||
let caps_path = tmp.path().join("slack-tool.capabilities.json");
|
||||
|
||||
let result = extract_tar_gz(
|
||||
&gz_bytes,
|
||||
"slack-tool",
|
||||
&wasm_path,
|
||||
&caps_path,
|
||||
"test://url",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(wasm_path.exists());
|
||||
assert!(caps_path.exists());
|
||||
assert!(result.has_capabilities);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_correct_wasm_from_channel_bundle() {
|
||||
// Channel bundle contains slack.wasm — extraction by name="slack" succeeds.
|
||||
let gz_bytes = build_test_tar_gz("slack.wasm", Some("slack.capabilities.json"));
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let wasm_path = tmp.path().join("slack.wasm");
|
||||
let caps_path = tmp.path().join("slack.capabilities.json");
|
||||
|
||||
let result =
|
||||
extract_tar_gz(&gz_bytes, "slack", &wasm_path, &caps_path, "test://url").unwrap();
|
||||
|
||||
assert!(wasm_path.exists());
|
||||
assert!(caps_path.exists());
|
||||
assert!(result.has_capabilities);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tool_and_channel_install_to_separate_directories() {
|
||||
// Tool and channel manifests with the same file_stem ("slack") install
|
||||
// to different directories without collision.
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let installer = RegistryInstaller::new(
|
||||
temp.path().to_path_buf(),
|
||||
temp.path().join("tools"),
|
||||
temp.path().join("channels"),
|
||||
);
|
||||
|
||||
let tool_manifest = test_manifest_with_kind(
|
||||
"slack-tool",
|
||||
"tools-src/slack",
|
||||
None,
|
||||
None,
|
||||
ManifestKind::Tool,
|
||||
);
|
||||
let channel_manifest = test_manifest_with_kind(
|
||||
"slack",
|
||||
"channels-src/slack",
|
||||
None,
|
||||
None,
|
||||
ManifestKind::Channel,
|
||||
);
|
||||
|
||||
// Both fail because source dirs don't exist, but the error path reveals
|
||||
// the target directory — tool goes to tools/, channel goes to channels/.
|
||||
let tool_err = installer
|
||||
.install_from_source(&tool_manifest, false)
|
||||
.await
|
||||
.expect_err("no source dir");
|
||||
let channel_err = installer
|
||||
.install_from_source(&channel_manifest, false)
|
||||
.await
|
||||
.expect_err("no source dir");
|
||||
|
||||
match tool_err {
|
||||
RegistryError::ManifestRead { path, .. } => {
|
||||
assert!(
|
||||
path.ends_with("tools-src/slack"),
|
||||
"tool should resolve to tools-src/slack, got: {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
other => panic!("expected ManifestRead for tool, got: {:?}", other),
|
||||
}
|
||||
match channel_err {
|
||||
RegistryError::ManifestRead { path, .. } => {
|
||||
assert!(
|
||||
path.ends_with("channels-src/slack"),
|
||||
"channel should resolve to channels-src/slack, got: {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
other => panic!("expected ManifestRead for channel, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-49
@@ -197,20 +197,13 @@ impl Validator {
|
||||
pub fn validate_tool_params(&self, params: &serde_json::Value) -> ValidationResult {
|
||||
let mut result = ValidationResult::ok();
|
||||
|
||||
// Recursively check all string values in the JSON.
|
||||
// Depth is capped to prevent stack overflow on pathological input.
|
||||
const MAX_DEPTH: usize = 32;
|
||||
|
||||
// Recursively check all string values in the JSON
|
||||
fn check_strings(
|
||||
value: &serde_json::Value,
|
||||
path: &str,
|
||||
validator: &Validator,
|
||||
result: &mut ValidationResult,
|
||||
depth: usize,
|
||||
) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
match value {
|
||||
serde_json::Value::String(s) => {
|
||||
let string_result = if s.is_empty() {
|
||||
@@ -223,7 +216,7 @@ impl Validator {
|
||||
serde_json::Value::Array(arr) => {
|
||||
for (i, item) in arr.iter().enumerate() {
|
||||
let child_path = format!("{path}[{i}]");
|
||||
check_strings(item, &child_path, validator, result, depth + 1);
|
||||
check_strings(item, &child_path, validator, result);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(obj) => {
|
||||
@@ -233,14 +226,14 @@ impl Validator {
|
||||
} else {
|
||||
format!("{path}.{k}")
|
||||
};
|
||||
check_strings(v, &child_path, validator, result, depth + 1);
|
||||
check_strings(v, &child_path, validator, result);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
check_strings(params, "", self, &mut result, 0);
|
||||
check_strings(params, "", self, &mut result);
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -430,42 +423,4 @@ mod tests {
|
||||
.expect("expected forbidden content error");
|
||||
assert_eq!(error.field, "metadata.tags[1]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_params_depth_limit_prevents_stack_overflow() {
|
||||
let validator = Validator::new().forbid_pattern("evil");
|
||||
|
||||
// Build a deeply nested JSON object (depth > MAX_DEPTH of 32)
|
||||
let mut value = serde_json::json!("evil payload");
|
||||
for _ in 0..50 {
|
||||
value = serde_json::json!({ "nested": value });
|
||||
}
|
||||
|
||||
let result = validator.validate_tool_params(&value);
|
||||
|
||||
// The "evil payload" is beyond the depth limit so it should NOT be
|
||||
// detected — the traversal stops before reaching it.
|
||||
assert!(
|
||||
result.is_valid,
|
||||
"Strings beyond depth limit should be silently skipped, got errors: {:?}",
|
||||
result.errors
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_params_within_depth_limit_still_validated() {
|
||||
let validator = Validator::new().forbid_pattern("evil");
|
||||
|
||||
// Build a nested object within the depth limit
|
||||
let mut value = serde_json::json!("evil payload");
|
||||
for _ in 0..5 {
|
||||
value = serde_json::json!({ "nested": value });
|
||||
}
|
||||
|
||||
let result = validator.validate_tool_params(&value);
|
||||
assert!(
|
||||
!result.is_valid,
|
||||
"Strings within depth limit should still be validated"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +114,6 @@ fn install_linux() -> Result<()> {
|
||||
\n\
|
||||
[Service]\n\
|
||||
Type=simple\n\
|
||||
Environment=\"CLI_ENABLED=false\"\n\
|
||||
ExecStart=\"{exe}\" run\n\
|
||||
Restart=always\n\
|
||||
RestartSec=3\n\
|
||||
|
||||
+7
-379
@@ -804,15 +804,13 @@ pub async fn setup_wasm_channel(
|
||||
print_success(&format!("{} saved to database", secret_config.name));
|
||||
}
|
||||
|
||||
// TODO: Substitute secrets into the validation URL and make a
|
||||
// GET request to verify the configured credentials actually work.
|
||||
if let Some(ref validation_endpoint) = setup.validation_endpoint {
|
||||
print_info("Validating configured credentials...");
|
||||
match validate_channel_credentials(secrets, validation_endpoint).await {
|
||||
Ok(()) => print_success("Credentials validated successfully"),
|
||||
Err(e) => print_warning(&format!(
|
||||
"Credential validation failed: {}. Setup will continue, but the channel may fail to start until the credentials are fixed.",
|
||||
e
|
||||
)),
|
||||
}
|
||||
print_info(&format!(
|
||||
"Validation endpoint configured: {} (validation not yet implemented)",
|
||||
validation_endpoint
|
||||
));
|
||||
}
|
||||
|
||||
print_success(&format!("{} channel configured", channel_name));
|
||||
@@ -823,225 +821,6 @@ pub async fn setup_wasm_channel(
|
||||
})
|
||||
}
|
||||
|
||||
async fn validate_channel_credentials(
|
||||
secrets: &SecretsContext,
|
||||
validation_endpoint: &str,
|
||||
) -> Result<(), ChannelSetupError> {
|
||||
let validation_url = substitute_validation_placeholders(secrets, validation_endpoint).await?;
|
||||
let (parsed, resolved_addrs) = validate_public_https_url(&validation_url).await?;
|
||||
let target = validation_target_display(&parsed);
|
||||
let mut client_builder = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.redirect(reqwest::redirect::Policy::none());
|
||||
|
||||
if matches!(parsed.host(), Some(url::Host::Domain(_)))
|
||||
&& let Some(host) = parsed.host_str()
|
||||
{
|
||||
client_builder = client_builder.resolve_to_addrs(host, &resolved_addrs);
|
||||
}
|
||||
|
||||
let client = client_builder
|
||||
.build()
|
||||
.map_err(|e| ChannelSetupError::Network(format!("Failed to build HTTP client: {}", e)))?;
|
||||
|
||||
let response = client.get(parsed.clone()).send().await.map_err(|e| {
|
||||
ChannelSetupError::Network(format!(
|
||||
"Validation request to {} failed: {}",
|
||||
target,
|
||||
describe_validation_request_error(&e)
|
||||
))
|
||||
})?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ChannelSetupError::Validation(format!(
|
||||
"Validation endpoint returned HTTP {} from {}",
|
||||
response.status(),
|
||||
target
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
async fn substitute_validation_placeholders(
|
||||
secrets: &SecretsContext,
|
||||
validation_endpoint: &str,
|
||||
) -> Result<String, ChannelSetupError> {
|
||||
let mut resolved = validation_endpoint.to_string();
|
||||
let placeholder_names: std::collections::BTreeSet<String> = validation_placeholder_regex()
|
||||
.captures_iter(validation_endpoint)
|
||||
.filter_map(|caps| caps.get(1).map(|m| m.as_str().to_string()))
|
||||
.collect();
|
||||
|
||||
for secret_name in placeholder_names {
|
||||
let secret_value = secrets.get_secret(&secret_name).await?;
|
||||
let placeholder = format!("{{{}}}", secret_name);
|
||||
let encoded_value = urlencoding::encode(secret_value.expose_secret());
|
||||
resolved = resolved.replace(&placeholder, encoded_value.as_ref());
|
||||
}
|
||||
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
async fn validate_public_https_url(
|
||||
url: &str,
|
||||
) -> Result<(Url, Vec<std::net::SocketAddr>), ChannelSetupError> {
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
let parsed = Url::parse(url)
|
||||
.map_err(|e| ChannelSetupError::Validation(format!("Invalid URL: {}", e)))?;
|
||||
|
||||
if parsed.scheme() != "https" {
|
||||
return Err(ChannelSetupError::Validation(
|
||||
"Validation endpoint must use https".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if !parsed.username().is_empty() || parsed.password().is_some() {
|
||||
return Err(ChannelSetupError::Validation(
|
||||
"Validation endpoint cannot contain userinfo".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| ChannelSetupError::Validation("Validation URL missing host".to_string()))?;
|
||||
let normalized_host = normalize_validation_domain(host);
|
||||
let host_lower = normalized_host.to_ascii_lowercase();
|
||||
|
||||
if host_lower == "localhost" || host_lower.ends_with(".localhost") {
|
||||
return Err(ChannelSetupError::Validation(
|
||||
"Validation endpoint cannot target localhost".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let port = parsed.port_or_known_default().unwrap_or(443);
|
||||
|
||||
match parsed
|
||||
.host()
|
||||
.ok_or_else(|| ChannelSetupError::Validation("Validation URL missing host".to_string()))?
|
||||
{
|
||||
url::Host::Ipv4(v4) => {
|
||||
let ip = IpAddr::V4(v4);
|
||||
if is_disallowed_ip(&ip) {
|
||||
return Err(ChannelSetupError::Validation(format!(
|
||||
"Validation endpoint cannot target private or local IP {}",
|
||||
ip
|
||||
)));
|
||||
}
|
||||
|
||||
Ok((parsed, vec![SocketAddr::new(ip, port)]))
|
||||
}
|
||||
url::Host::Ipv6(v6) => {
|
||||
let ip = normalize_ip(IpAddr::V6(v6));
|
||||
if is_disallowed_ip(&ip) {
|
||||
return Err(ChannelSetupError::Validation(format!(
|
||||
"Validation endpoint cannot target private or local IP {}",
|
||||
ip
|
||||
)));
|
||||
}
|
||||
|
||||
Ok((parsed, vec![SocketAddr::new(ip, port)]))
|
||||
}
|
||||
url::Host::Domain(domain) => {
|
||||
let addrs: Vec<SocketAddr> = tokio::net::lookup_host((normalized_host, port))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ChannelSetupError::Validation(format!(
|
||||
"DNS resolution failed for {}: {}",
|
||||
normalized_host, e
|
||||
))
|
||||
})?
|
||||
.map(|addr| SocketAddr::new(normalize_ip(addr.ip()), addr.port()))
|
||||
.collect();
|
||||
|
||||
if addrs.is_empty() {
|
||||
return Err(ChannelSetupError::Validation(format!(
|
||||
"Validation hostname '{}' did not resolve to any IP addresses",
|
||||
domain
|
||||
)));
|
||||
}
|
||||
|
||||
for addr in &addrs {
|
||||
if is_disallowed_ip(&addr.ip()) {
|
||||
return Err(ChannelSetupError::Validation(format!(
|
||||
"Validation hostname '{}' resolves to disallowed IP {}",
|
||||
domain,
|
||||
addr.ip()
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok((parsed, addrs))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_disallowed_ip(ip: &std::net::IpAddr) -> bool {
|
||||
match normalize_ip(*ip) {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
v4.is_private()
|
||||
|| v4.is_loopback()
|
||||
|| v4.is_link_local()
|
||||
|| v4.is_multicast()
|
||||
|| v4.is_unspecified()
|
||||
|| v4 == std::net::Ipv4Addr::new(169, 254, 169, 254)
|
||||
|| (v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64)
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
v6.is_loopback()
|
||||
|| v6.is_unique_local()
|
||||
|| v6.is_unicast_link_local()
|
||||
|| v6.is_multicast()
|
||||
|| v6.is_unspecified()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_ip(ip: std::net::IpAddr) -> std::net::IpAddr {
|
||||
match ip {
|
||||
std::net::IpAddr::V6(v6) => v6
|
||||
.to_ipv4_mapped()
|
||||
.map(std::net::IpAddr::V4)
|
||||
.unwrap_or(std::net::IpAddr::V6(v6)),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_validation_domain(host: &str) -> &str {
|
||||
host.trim_end_matches('.')
|
||||
}
|
||||
|
||||
fn validation_placeholder_regex() -> &'static regex::Regex {
|
||||
static PLACEHOLDER_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
|
||||
PLACEHOLDER_RE.get_or_init(|| {
|
||||
regex::Regex::new(r"\{([A-Za-z0-9_]+)\}")
|
||||
.expect("validation placeholder regex must compile")
|
||||
})
|
||||
}
|
||||
|
||||
fn validation_target_display(parsed: &Url) -> String {
|
||||
let host = parsed.host_str().unwrap_or("unknown host");
|
||||
match parsed.port() {
|
||||
Some(port) => format!("{}:{}", host, port),
|
||||
None => host.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn describe_validation_request_error(error: &reqwest::Error) -> &'static str {
|
||||
if error.is_timeout() {
|
||||
"request timed out"
|
||||
} else if error.is_redirect() {
|
||||
"redirects are not allowed"
|
||||
} else if error.is_connect() {
|
||||
"connection failed"
|
||||
} else if error.is_request() {
|
||||
"request could not be sent"
|
||||
} else {
|
||||
"request failed"
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a Cloudflare tunnel token by briefly running `cloudflared`.
|
||||
///
|
||||
/// Spawns `cloudflared tunnel run` with a dummy local URL and watches stderr
|
||||
@@ -1132,26 +911,8 @@ fn generate_secret_with_length(length: usize) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use base64::Engine;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::secrets::{InMemorySecretsStore, SecretsCrypto, SecretsStore};
|
||||
use crate::setup::channels::{
|
||||
SecretsContext, generate_webhook_secret, substitute_validation_placeholders,
|
||||
validate_cloudflare_token_format, validate_public_https_url,
|
||||
};
|
||||
|
||||
fn test_secrets_context() -> SecretsContext {
|
||||
use secrecy::SecretString;
|
||||
|
||||
let crypto = Arc::new(
|
||||
SecretsCrypto::new(SecretString::from(
|
||||
"0123456789abcdef0123456789abcdef".to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
);
|
||||
let store: Arc<dyn SecretsStore> = Arc::new(InMemorySecretsStore::new(crypto));
|
||||
SecretsContext::from_store(store, "test-user")
|
||||
}
|
||||
use crate::setup::channels::{generate_webhook_secret, validate_cloudflare_token_format};
|
||||
|
||||
#[test]
|
||||
fn test_generate_webhook_secret() {
|
||||
@@ -1204,137 +965,4 @@ mod tests {
|
||||
fn test_validate_cloudflare_token_empty() {
|
||||
assert!(!validate_cloudflare_token_format(""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_substitute_validation_placeholders() {
|
||||
let secrets = test_secrets_context();
|
||||
secrets
|
||||
.save_secret(
|
||||
"telegram_bot_token",
|
||||
&secrecy::SecretString::from("abc123".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
secrets
|
||||
.save_secret(
|
||||
"workspace_id",
|
||||
&secrecy::SecretString::from("ws_456".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resolved = substitute_validation_placeholders(
|
||||
&secrets,
|
||||
"https://api.example.com/{workspace_id}/verify?token={telegram_bot_token}",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolved,
|
||||
"https://api.example.com/ws_456/verify?token=abc123"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_substitute_validation_placeholders_url_encodes_secrets() {
|
||||
let secrets = test_secrets_context();
|
||||
secrets
|
||||
.save_secret(
|
||||
"telegram_bot_token",
|
||||
&secrecy::SecretString::from("abc123?foo=1&bar=#baz/slash".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resolved = substitute_validation_placeholders(
|
||||
&secrets,
|
||||
"https://api.example.com/verify?token={telegram_bot_token}",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolved,
|
||||
"https://api.example.com/verify?token=abc123%3Ffoo%3D1%26bar%3D%23baz%2Fslash"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_substitute_validation_placeholders_missing_secret() {
|
||||
let secrets = test_secrets_context();
|
||||
let err = substitute_validation_placeholders(
|
||||
&secrets,
|
||||
"https://api.example.com/verify?token={missing_secret}",
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
assert!(err.contains("Failed to read secret"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_validate_public_https_url_rejects_localhost() {
|
||||
let err = validate_public_https_url("https://localhost/api")
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("localhost"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_validate_public_https_url_rejects_localhost_with_trailing_dot() {
|
||||
let err = validate_public_https_url("https://localhost./api")
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("localhost"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_validate_public_https_url_rejects_private_ip() {
|
||||
let err = validate_public_https_url("https://192.168.1.10/api")
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("private or local IP"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_validate_public_https_url_rejects_ipv4_mapped_ipv6() {
|
||||
let err = validate_public_https_url("https://[::ffff:127.0.0.1]/api")
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("private or local IP"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_validate_public_https_url_rejects_http() {
|
||||
let err = validate_public_https_url("http://example.com/api")
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("must use https"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_validate_public_https_url_accepts_public_https_literal_ip() {
|
||||
let (parsed, addrs) = validate_public_https_url("https://8.8.8.8/api")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(parsed.as_str(), "https://8.8.8.8/api");
|
||||
assert_eq!(addrs.len(), 1);
|
||||
assert_eq!(addrs[0].ip().to_string(), "8.8.8.8");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_validate_public_https_url_fails_closed_on_dns_error() {
|
||||
let err = validate_public_https_url("https://should-not-resolve.invalid/api")
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("DNS resolution failed"));
|
||||
}
|
||||
}
|
||||
|
||||
+13
-41
@@ -11,25 +11,13 @@ use std::io::{self, Write};
|
||||
|
||||
use crossterm::{
|
||||
cursor,
|
||||
event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
|
||||
event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
|
||||
execute,
|
||||
style::{Color, Print, ResetColor, SetForegroundColor},
|
||||
terminal::{self, ClearType},
|
||||
};
|
||||
use secrecy::SecretString;
|
||||
|
||||
/// Drain any residual key events already queued in the terminal buffer.
|
||||
///
|
||||
/// On Windows, transitioning between raw mode and cooked mode (or between
|
||||
/// successive raw-mode prompts) can leave stale events (e.g. the Release
|
||||
/// half of an Enter keypress) in the queue. Consuming them with a
|
||||
/// non-blocking poll prevents the next prompt from mis-firing.
|
||||
fn drain_pending_events() {
|
||||
while event::poll(std::time::Duration::ZERO).unwrap_or(false) {
|
||||
let _ = event::read();
|
||||
}
|
||||
}
|
||||
|
||||
/// Display a numbered menu and get user selection.
|
||||
///
|
||||
/// Returns the index (0-based) of the selected option.
|
||||
@@ -106,7 +94,6 @@ pub fn select_many(prompt: &str, options: &[(&str, bool)]) -> io::Result<Vec<usi
|
||||
let mut cursor_pos = 0;
|
||||
|
||||
terminal::enable_raw_mode()?;
|
||||
drain_pending_events();
|
||||
execute!(stdout, cursor::Hide)?;
|
||||
|
||||
let result = (|| {
|
||||
@@ -137,13 +124,9 @@ pub fn select_many(prompt: &str, options: &[(&str, bool)]) -> io::Result<Vec<usi
|
||||
|
||||
stdout.flush()?;
|
||||
|
||||
// Read key — only act on Press events to avoid double-firing
|
||||
// from Release/Repeat events on Windows.
|
||||
// Read key
|
||||
if let Event::Key(KeyEvent {
|
||||
code,
|
||||
modifiers,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
code, modifiers, ..
|
||||
}) = event::read()?
|
||||
{
|
||||
match code {
|
||||
@@ -217,16 +200,19 @@ fn read_secret_line() -> io::Result<SecretString> {
|
||||
let mut input = String::new();
|
||||
let mut stdout = io::stdout();
|
||||
|
||||
drain_pending_events();
|
||||
// Drain any residual key events (e.g. Enter from a prior `read_line` prompt)
|
||||
// that are already queued before we start reading. Without this, on
|
||||
// Windows the leftover Enter is immediately consumed and the function
|
||||
// returns an empty string before the user can type anything.
|
||||
// Uses Duration::ZERO so we never block waiting for new input — only
|
||||
// events already in the queue are consumed.
|
||||
while event::poll(std::time::Duration::ZERO)? {
|
||||
let _ = event::read()?;
|
||||
}
|
||||
|
||||
loop {
|
||||
// Only act on Press events to avoid double-firing from
|
||||
// Release/Repeat events on Windows.
|
||||
if let Event::Key(KeyEvent {
|
||||
code,
|
||||
modifiers,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
code, modifiers, ..
|
||||
}) = event::read()?
|
||||
{
|
||||
match code {
|
||||
@@ -284,20 +270,6 @@ pub fn confirm(prompt: &str, default: bool) -> io::Result<bool> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Print the IronClaw ASCII art banner in blue.
|
||||
pub fn print_banner() {
|
||||
let mut stdout = io::stdout();
|
||||
let _ = execute!(stdout, SetForegroundColor(Color::Cyan));
|
||||
println!();
|
||||
println!(r" ██╗██████╗ ██████╗ ███╗ ██╗ ██████╗██╗ █████╗ ██╗ ██╗");
|
||||
println!(r" ██║██╔══██╗██╔═══██╗████╗ ██║██╔════╝██║ ██╔══██╗██║ ██║");
|
||||
println!(r" ██║██████╔╝██║ ██║██╔██╗ ██║██║ ██║ ███████║██║ █╗ ██║");
|
||||
println!(r" ██║██╔══██╗██║ ██║██║╚██╗██║██║ ██║ ██╔══██║██║███╗██║");
|
||||
println!(r" ██║██║ ██║╚██████╔╝██║ ╚████║╚██████╗███████╗██║ ██║╚███╔███╔╝");
|
||||
println!(r" ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝ ");
|
||||
let _ = execute!(stdout, ResetColor);
|
||||
}
|
||||
|
||||
/// Print a styled header box.
|
||||
///
|
||||
/// # Example
|
||||
|
||||
+2
-3
@@ -30,8 +30,8 @@ use crate::setup::channels::{
|
||||
SecretsContext, setup_http, setup_signal, setup_tunnel, setup_wasm_channel,
|
||||
};
|
||||
use crate::setup::prompts::{
|
||||
confirm, input, optional_input, print_banner, print_error, print_header, print_info,
|
||||
print_step, print_success, secret_input, select_many, select_one,
|
||||
confirm, input, optional_input, print_error, print_header, print_info, print_step,
|
||||
print_success, secret_input, select_many, select_one,
|
||||
};
|
||||
|
||||
// unused const, keep commented for clarity / future use
|
||||
@@ -141,7 +141,6 @@ impl SetupWizard {
|
||||
/// settings are loaded from the database after Step 1 establishes a
|
||||
/// connection, so users don't have to re-enter everything.
|
||||
pub async fn run(&mut self) -> Result<(), SetupError> {
|
||||
print_banner();
|
||||
print_header("IronClaw Setup Wizard");
|
||||
|
||||
if self.config.channels_only {
|
||||
|
||||
+6
-56
@@ -641,20 +641,14 @@ mod tests {
|
||||
let conv_id = uuid::Uuid::new_v4();
|
||||
|
||||
// ensure_conversation should create the row.
|
||||
assert!(
|
||||
db.ensure_conversation(conv_id, "web", "carol", None)
|
||||
.await
|
||||
.expect("ensure first"),
|
||||
"first ensure_conversation should create the row"
|
||||
);
|
||||
db.ensure_conversation(conv_id, "web", "carol", None)
|
||||
.await
|
||||
.expect("ensure first");
|
||||
|
||||
// Calling again with the same ID should not error.
|
||||
assert!(
|
||||
db.ensure_conversation(conv_id, "web", "carol", None)
|
||||
.await
|
||||
.expect("ensure second (idempotent)"),
|
||||
"second ensure_conversation should touch owned row"
|
||||
);
|
||||
db.ensure_conversation(conv_id, "web", "carol", None)
|
||||
.await
|
||||
.expect("ensure second (idempotent)");
|
||||
|
||||
// Should be able to add messages to it.
|
||||
let msg_id = db
|
||||
@@ -672,50 +666,6 @@ mod tests {
|
||||
assert_eq!(msgs[0].content, "test message");
|
||||
}
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
#[tokio::test]
|
||||
async fn test_ensure_conversation_foreign_conflict_does_not_touch_last_activity() {
|
||||
let harness = TestHarnessBuilder::new().build().await;
|
||||
let db = &harness.db;
|
||||
|
||||
let conv_id = db
|
||||
.create_conversation("web", "alice", None)
|
||||
.await
|
||||
.expect("create conversation");
|
||||
|
||||
let before = db
|
||||
.list_conversations_all_channels("alice", 10)
|
||||
.await
|
||||
.expect("list conversations before foreign ensure")
|
||||
.into_iter()
|
||||
.find(|c| c.id == conv_id)
|
||||
.expect("conversation must exist before foreign ensure")
|
||||
.last_activity;
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
|
||||
|
||||
assert!(
|
||||
!db.ensure_conversation(conv_id, "web", "mallory", None)
|
||||
.await
|
||||
.expect("foreign ensure should not error"),
|
||||
"foreign ensure_conversation should report not ensured"
|
||||
);
|
||||
|
||||
let after = db
|
||||
.list_conversations_all_channels("alice", 10)
|
||||
.await
|
||||
.expect("list conversations after foreign ensure")
|
||||
.into_iter()
|
||||
.find(|c| c.id == conv_id)
|
||||
.expect("conversation must still exist after foreign ensure")
|
||||
.last_activity;
|
||||
|
||||
assert_eq!(
|
||||
after, before,
|
||||
"foreign ensure_conversation should not mutate last_activity"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
#[tokio::test]
|
||||
async fn test_paginated_messages() {
|
||||
|
||||
@@ -213,7 +213,7 @@ impl Tool for ToolAuthTool {
|
||||
|
||||
let result = self
|
||||
.manager
|
||||
.auth(name)
|
||||
.auth(name, None)
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
||||
|
||||
@@ -323,7 +323,7 @@ impl Tool for ToolActivateTool {
|
||||
|
||||
// Activation failed due to missing auth; initiate auth flow
|
||||
// so the agent loop can show the auth card.
|
||||
match self.manager.auth(name).await {
|
||||
match self.manager.auth(name, None).await {
|
||||
Ok(auth_result) if auth_result.is_authenticated() => {
|
||||
// Auth succeeded (e.g. env var was set); retry activation.
|
||||
let result = self
|
||||
|
||||
+88
-322
@@ -1,7 +1,7 @@
|
||||
//! HTTP request tool.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::net::{IpAddr, ToSocketAddrs};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -31,24 +31,9 @@ const MAX_RESPONSE_SIZE: usize = 5 * 1024 * 1024;
|
||||
/// in memory for LLM context. Matches the WASM attachment size cap.
|
||||
const MAX_SAVE_TO_SIZE: usize = 50 * 1024 * 1024;
|
||||
|
||||
/// Maximum number of redirects to follow for simple GET requests.
|
||||
const MAX_REDIRECTS: usize = 3;
|
||||
|
||||
/// Descriptive User-Agent so public APIs don't reject bare requests.
|
||||
const USER_AGENT: &str = concat!(
|
||||
"IronClaw-Agent/",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
" (https://github.com/nearai/ironclaw)"
|
||||
);
|
||||
|
||||
/// Tool for making HTTP requests.
|
||||
///
|
||||
/// Each request builds a per-request [`Client`] with DNS pinning to prevent
|
||||
/// TOCTOU DNS rebinding attacks. The hostname is resolved once, validated
|
||||
/// against the SSRF blocklist, and then pinned via
|
||||
/// [`reqwest::ClientBuilder::resolve_to_addrs`] so that reqwest connects
|
||||
/// directly to the pre-validated IPs without a second DNS lookup.
|
||||
pub struct HttpTool {
|
||||
client: Client,
|
||||
credential_registry: Option<Arc<SharedCredentialRegistry>>,
|
||||
secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>>,
|
||||
}
|
||||
@@ -56,7 +41,52 @@ pub struct HttpTool {
|
||||
impl HttpTool {
|
||||
/// Create a new HTTP tool.
|
||||
pub fn new() -> Self {
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.redirect(reqwest::redirect::Policy::custom(|attempt| {
|
||||
if attempt.previous().len() >= 10 {
|
||||
return attempt.error("too many redirects");
|
||||
}
|
||||
// Reject scheme downgrades (https → http)
|
||||
if attempt.url().scheme() != "https" {
|
||||
return attempt.error("redirect to non-HTTPS URL is not allowed");
|
||||
}
|
||||
// Extract host info before consuming attempt
|
||||
let host_owned = attempt.url().host_str().map(|h| h.to_owned());
|
||||
let port = attempt.url().port_or_known_default().unwrap_or(443);
|
||||
|
||||
if let Some(host) = host_owned {
|
||||
let host_lower = host.to_lowercase();
|
||||
if host_lower == "localhost" || host_lower.ends_with(".localhost") {
|
||||
return attempt.error("redirect to localhost is not allowed");
|
||||
}
|
||||
if let Ok(ip) = host.parse::<IpAddr>()
|
||||
&& is_disallowed_ip(&ip)
|
||||
{
|
||||
return attempt.error("redirect to private/local IP is not allowed");
|
||||
}
|
||||
// Resolve hostname and check all IPs
|
||||
let socket_addr = format!("{}:{}", host, port);
|
||||
if let Ok(addrs) = socket_addr.to_socket_addrs() {
|
||||
for addr in addrs {
|
||||
if is_disallowed_ip(&addr.ip()) {
|
||||
let msg = format!(
|
||||
"redirect target '{}' resolves to disallowed IP {}",
|
||||
host,
|
||||
addr.ip()
|
||||
);
|
||||
return attempt.error(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
attempt.follow()
|
||||
}))
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
|
||||
Self {
|
||||
client,
|
||||
credential_registry: None,
|
||||
secrets_store: None,
|
||||
}
|
||||
@@ -99,11 +129,6 @@ fn validate_save_to_path(save_to: &str) -> Result<std::path::PathBuf, ToolError>
|
||||
Ok(validated)
|
||||
}
|
||||
|
||||
/// Parse and validate a URL without DNS resolution.
|
||||
///
|
||||
/// Checks scheme (HTTPS only), rejects localhost and private/link-local IP
|
||||
/// literals. Does **not** resolve hostnames -- use [`validate_and_resolve_url`]
|
||||
/// for the full DNS-pinning flow that eliminates the TOCTOU rebinding window.
|
||||
pub(crate) fn validate_url(url: &str) -> Result<reqwest::Url, ToolError> {
|
||||
let parsed = reqwest::Url::parse(url)
|
||||
.map_err(|e| ToolError::InvalidParameters(format!("invalid URL: {}", e)))?;
|
||||
@@ -134,94 +159,36 @@ pub(crate) fn validate_url(url: &str) -> Result<reqwest::Url, ToolError> {
|
||||
));
|
||||
}
|
||||
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
/// Resolve DNS for a validated URL and check every resolved address against
|
||||
/// the SSRF blocklist.
|
||||
///
|
||||
/// Returns the resolved [`SocketAddr`]s so that callers can pin the hostname
|
||||
/// via [`reqwest::ClientBuilder::resolve_to_addrs`], preventing a DNS rebinding
|
||||
/// attack where a second, independent resolution (inside reqwest) returns a
|
||||
/// different -- potentially private -- IP after our validation pass.
|
||||
pub(crate) async fn validate_and_resolve_url(
|
||||
url: &reqwest::Url,
|
||||
) -> Result<Vec<SocketAddr>, ToolError> {
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| ToolError::InvalidParameters("URL missing host".to_string()))?;
|
||||
|
||||
let port = url.port_or_known_default().unwrap_or(443);
|
||||
|
||||
let addrs: Vec<SocketAddr> = tokio::net::lookup_host(format!("{}:{}", host, port))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ToolError::ExternalService(format!("DNS resolution failed for '{}': {}", host, e))
|
||||
})?
|
||||
.collect();
|
||||
|
||||
if addrs.is_empty() {
|
||||
return Err(ToolError::ExternalService(format!(
|
||||
"DNS resolution for '{}' returned no addresses",
|
||||
host
|
||||
)));
|
||||
}
|
||||
|
||||
for addr in &addrs {
|
||||
if is_disallowed_ip(&addr.ip()) {
|
||||
return Err(ToolError::NotAuthorized(format!(
|
||||
"hostname '{}' resolves to disallowed IP {}",
|
||||
host,
|
||||
addr.ip()
|
||||
)));
|
||||
// Resolve hostname and check all resolved IPs against the blocklist.
|
||||
// This prevents DNS rebinding where a hostname resolves to a private IP.
|
||||
let port = parsed.port_or_known_default().unwrap_or(443);
|
||||
let socket_addr = format!("{}:{}", host, port);
|
||||
if let Ok(addrs) = socket_addr.to_socket_addrs() {
|
||||
for addr in addrs {
|
||||
if is_disallowed_ip(&addr.ip()) {
|
||||
return Err(ToolError::NotAuthorized(format!(
|
||||
"hostname '{}' resolves to disallowed IP {}",
|
||||
host,
|
||||
addr.ip()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(addrs)
|
||||
}
|
||||
|
||||
/// Build a reqwest [`Client`] that pins the given hostname to the
|
||||
/// pre-validated resolved addresses, preventing any second DNS lookup.
|
||||
pub(crate) fn build_pinned_client(
|
||||
host: &str,
|
||||
resolved_addrs: &[SocketAddr],
|
||||
timeout: Duration,
|
||||
redirect_policy: reqwest::redirect::Policy,
|
||||
) -> Result<Client, ToolError> {
|
||||
let builder = Client::builder()
|
||||
.timeout(timeout)
|
||||
.redirect(redirect_policy)
|
||||
.user_agent(USER_AGENT)
|
||||
.resolve_to_addrs(host, resolved_addrs);
|
||||
|
||||
builder
|
||||
.build()
|
||||
.map_err(|e| ToolError::ExternalService(format!("failed to build HTTP client: {}", e)))
|
||||
}
|
||||
|
||||
/// Check whether an IPv4 address falls in a disallowed range (private,
|
||||
/// loopback, link-local, multicast, unspecified, or cloud metadata).
|
||||
fn is_disallowed_ipv4(v4: &Ipv4Addr) -> bool {
|
||||
v4.is_private()
|
||||
|| v4.is_loopback()
|
||||
|| v4.is_link_local()
|
||||
|| v4.is_multicast()
|
||||
|| v4.is_unspecified()
|
||||
|| *v4 == Ipv4Addr::new(169, 254, 169, 254)
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
fn is_disallowed_ip(ip: &IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(v4) => is_disallowed_ipv4(v4),
|
||||
IpAddr::V4(v4) => {
|
||||
v4.is_private()
|
||||
|| v4.is_loopback()
|
||||
|| v4.is_link_local()
|
||||
|| v4.is_multicast()
|
||||
|| v4.is_unspecified()
|
||||
|| *v4 == std::net::Ipv4Addr::new(169, 254, 169, 254)
|
||||
}
|
||||
IpAddr::V6(v6) => {
|
||||
// Catch IPv4-mapped IPv6 addresses (e.g. ::ffff:169.254.169.254)
|
||||
// that would bypass IPv4-only checks.
|
||||
if let Some(v4) = v6.to_ipv4_mapped()
|
||||
&& is_disallowed_ipv4(&v4)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
v6.is_loopback()
|
||||
|| v6.is_unique_local()
|
||||
|| v6.is_unicast_link_local()
|
||||
@@ -362,31 +329,16 @@ impl Tool for HttpTool {
|
||||
let url = require_str(¶ms, "url")?;
|
||||
let mut parsed_url = validate_url(url)?;
|
||||
|
||||
// Resolve DNS once, validate against SSRF blocklist, then pin the
|
||||
// resolved addresses into the reqwest client so it cannot re-resolve
|
||||
// to a different (potentially private) IP.
|
||||
let resolved_addrs = validate_and_resolve_url(&parsed_url).await?;
|
||||
let host = parsed_url
|
||||
.host_str()
|
||||
.ok_or_else(|| ToolError::InvalidParameters("URL missing host".into()))?
|
||||
.to_string();
|
||||
let client = build_pinned_client(
|
||||
&host,
|
||||
&resolved_addrs,
|
||||
Duration::from_secs(30),
|
||||
reqwest::redirect::Policy::none(),
|
||||
)?;
|
||||
|
||||
// Parse headers
|
||||
let mut headers_vec = parse_headers_param(params.get("headers"))?;
|
||||
|
||||
// Build request
|
||||
let mut request = match method.to_uppercase().as_str() {
|
||||
"GET" => client.get(parsed_url.clone()),
|
||||
"POST" => client.post(parsed_url.clone()),
|
||||
"PUT" => client.put(parsed_url.clone()),
|
||||
"DELETE" => client.delete(parsed_url.clone()),
|
||||
"PATCH" => client.patch(parsed_url.clone()),
|
||||
"GET" => self.client.get(parsed_url.clone()),
|
||||
"POST" => self.client.post(parsed_url.clone()),
|
||||
"PUT" => self.client.put(parsed_url.clone()),
|
||||
"DELETE" => self.client.delete(parsed_url.clone()),
|
||||
"PATCH" => self.client.patch(parsed_url.clone()),
|
||||
_ => {
|
||||
return Err(ToolError::InvalidParameters(format!(
|
||||
"unsupported method: {}",
|
||||
@@ -430,8 +382,8 @@ impl Tool for HttpTool {
|
||||
self.credential_registry.as_ref(),
|
||||
self.secrets_store.as_ref(),
|
||||
) {
|
||||
let cred_host = parsed_url.host_str().unwrap_or("");
|
||||
let matched: Vec<crate::secrets::CredentialMapping> = registry.find_for_host(cred_host);
|
||||
let host = parsed_url.host_str().unwrap_or("");
|
||||
let matched: Vec<crate::secrets::CredentialMapping> = registry.find_for_host(host);
|
||||
for mapping in &matched {
|
||||
match store
|
||||
.get_decrypted(&ctx.user_id, &mapping.secret_name)
|
||||
@@ -491,124 +443,20 @@ impl Tool for HttpTool {
|
||||
return Ok(ToolOutput::success(result, start.elapsed()).with_raw(recorded.body));
|
||||
}
|
||||
|
||||
// Determine if this is a simple GET (eligible for redirect following).
|
||||
let is_simple_get =
|
||||
method.eq_ignore_ascii_case("GET") && headers_vec.is_empty() && body_bytes.is_none();
|
||||
|
||||
// Execute request, optionally following redirects for simple GETs.
|
||||
// Each redirect hop gets its own DNS resolution + SSRF validation +
|
||||
// pinned client to prevent rebinding attacks across hops.
|
||||
let response = if is_simple_get {
|
||||
let mut redirects_remaining = MAX_REDIRECTS;
|
||||
loop {
|
||||
// Build a per-hop pinned client for the current URL.
|
||||
let hop_addrs = validate_and_resolve_url(&parsed_url).await?;
|
||||
let hop_host = parsed_url
|
||||
.host_str()
|
||||
.ok_or_else(|| ToolError::InvalidParameters("URL missing host".into()))?
|
||||
.to_string();
|
||||
let hop_client = build_pinned_client(
|
||||
&hop_host,
|
||||
&hop_addrs,
|
||||
Duration::from_secs(30),
|
||||
reqwest::redirect::Policy::none(),
|
||||
)?;
|
||||
|
||||
let resp = hop_client
|
||||
.get(parsed_url.clone())
|
||||
.header(
|
||||
reqwest::header::ACCEPT,
|
||||
"text/markdown, text/html;q=0.9, application/json;q=0.9, */*;q=0.8",
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if e.is_timeout() {
|
||||
ToolError::Timeout(Duration::from_secs(30))
|
||||
} else {
|
||||
ToolError::ExternalService(e.to_string())
|
||||
}
|
||||
})?;
|
||||
|
||||
let status = resp.status().as_u16();
|
||||
if (300..400).contains(&status) {
|
||||
if redirects_remaining == 0 {
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"too many redirects (max {})",
|
||||
MAX_REDIRECTS
|
||||
)));
|
||||
}
|
||||
|
||||
let location = resp
|
||||
.headers()
|
||||
.get(reqwest::header::LOCATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or_else(|| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"redirect (HTTP {}) has no Location header",
|
||||
status
|
||||
))
|
||||
})?;
|
||||
|
||||
let next_url_str =
|
||||
if location.starts_with("http://") || location.starts_with("https://") {
|
||||
location.to_string()
|
||||
} else {
|
||||
parsed_url
|
||||
.join(location)
|
||||
.map(|u| u.to_string())
|
||||
.map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"could not resolve relative redirect '{}': {}",
|
||||
location, e
|
||||
))
|
||||
})?
|
||||
};
|
||||
|
||||
// SSRF re-validation on every hop (URL structure checks).
|
||||
// DNS resolution + IP validation happens at the top of the
|
||||
// next loop iteration via validate_and_resolve_url.
|
||||
parsed_url = validate_url(&next_url_str)?;
|
||||
let hop_detector = LeakDetector::new();
|
||||
hop_detector
|
||||
.scan_http_request(parsed_url.as_str(), &[], None)
|
||||
.map_err(|e| ToolError::NotAuthorized(e.to_string()))?;
|
||||
|
||||
redirects_remaining -= 1;
|
||||
tracing::debug!(
|
||||
to = %parsed_url,
|
||||
hops_left = redirects_remaining,
|
||||
"http tool following redirect"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
break resp;
|
||||
// Execute request
|
||||
let response = request.send().await.map_err(|e| {
|
||||
if e.is_timeout() {
|
||||
ToolError::Timeout(Duration::from_secs(30))
|
||||
} else {
|
||||
ToolError::ExternalService(e.to_string())
|
||||
}
|
||||
} else {
|
||||
let resp = request.send().await.map_err(|e| {
|
||||
if e.is_timeout() {
|
||||
ToolError::Timeout(Duration::from_secs(30))
|
||||
} else {
|
||||
ToolError::ExternalService(e.to_string())
|
||||
}
|
||||
})?;
|
||||
|
||||
let status = resp.status().as_u16();
|
||||
|
||||
// Block redirects for non-simple requests (potential SSRF)
|
||||
if (300..400).contains(&status) {
|
||||
return Err(ToolError::NotAuthorized(format!(
|
||||
"request returned redirect (HTTP {}), which is blocked to prevent SSRF",
|
||||
status
|
||||
)));
|
||||
}
|
||||
|
||||
resp
|
||||
};
|
||||
})?;
|
||||
|
||||
let status = response.status().as_u16();
|
||||
|
||||
// Redirects are followed automatically (up to 10 hops).
|
||||
// If we still see a 3xx here, the chain was too long.
|
||||
|
||||
let headers: HashMap<String, String> = response
|
||||
.headers()
|
||||
.iter()
|
||||
@@ -808,6 +656,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_is_disallowed_ip_covers_ranges() {
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
// Private ranges
|
||||
assert!(is_disallowed_ip(&IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))));
|
||||
assert!(is_disallowed_ip(&IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1))));
|
||||
@@ -822,39 +672,6 @@ mod tests {
|
||||
assert!(!is_disallowed_ip(&IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_disallowed_ip_catches_ipv4_mapped_ipv6() {
|
||||
use std::net::Ipv6Addr;
|
||||
|
||||
// ::ffff:127.0.0.1 (IPv4-mapped loopback)
|
||||
let mapped_loopback = IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0x7f00, 0x0001));
|
||||
assert!(
|
||||
is_disallowed_ip(&mapped_loopback),
|
||||
"IPv4-mapped ::ffff:127.0.0.1 should be disallowed"
|
||||
);
|
||||
|
||||
// ::ffff:169.254.169.254 (IPv4-mapped cloud metadata)
|
||||
let mapped_metadata = IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0xa9fe, 0xa9fe));
|
||||
assert!(
|
||||
is_disallowed_ip(&mapped_metadata),
|
||||
"IPv4-mapped ::ffff:169.254.169.254 should be disallowed"
|
||||
);
|
||||
|
||||
// ::ffff:10.0.0.1 (IPv4-mapped private)
|
||||
let mapped_private = IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0x0a00, 0x0001));
|
||||
assert!(
|
||||
is_disallowed_ip(&mapped_private),
|
||||
"IPv4-mapped ::ffff:10.0.0.1 should be disallowed"
|
||||
);
|
||||
|
||||
// ::ffff:8.8.8.8 (IPv4-mapped public -- should be allowed)
|
||||
let mapped_public = IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0x0808, 0x0808));
|
||||
assert!(
|
||||
!is_disallowed_ip(&mapped_public),
|
||||
"IPv4-mapped ::ffff:8.8.8.8 should be allowed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_max_response_size_is_reasonable() {
|
||||
// MAX_RESPONSE_SIZE should be 5 MB to prevent OOM while allowing typical API responses.
|
||||
@@ -1119,57 +936,6 @@ mod tests {
|
||||
assert_eq!(extract_host_from_params(¶ms), None);
|
||||
}
|
||||
|
||||
// ── DNS pinning tests ─────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_validate_and_resolve_rejects_loopback_hostname() {
|
||||
// "localhost" is blocked at the URL validation level, but verify
|
||||
// that validate_and_resolve_url also catches loopback IPs returned
|
||||
// by DNS for any hostname that resolves to 127.0.0.1.
|
||||
let url = reqwest::Url::parse("https://127.0.0.1/test").unwrap();
|
||||
// 127.0.0.1 is an IP literal -- validate_url blocks it before
|
||||
// we ever reach validate_and_resolve_url, but the function should
|
||||
// still reject if called directly.
|
||||
let err = validate_and_resolve_url(&url).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("disallowed"),
|
||||
"expected disallowed IP error, got: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
// Requires network access -- run with: cargo test -- --ignored
|
||||
#[ignore]
|
||||
#[tokio::test]
|
||||
async fn test_validate_and_resolve_accepts_public_host() {
|
||||
// example.com resolves to public IPs.
|
||||
let url = reqwest::Url::parse("https://example.com").unwrap();
|
||||
let addrs = validate_and_resolve_url(&url).await.unwrap();
|
||||
assert!(!addrs.is_empty(), "should resolve to at least one address");
|
||||
for addr in &addrs {
|
||||
assert!(
|
||||
!is_disallowed_ip(&addr.ip()),
|
||||
"example.com resolved to disallowed IP: {}",
|
||||
addr.ip()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_pinned_client_succeeds() {
|
||||
let addrs = vec![SocketAddr::new(
|
||||
IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)),
|
||||
443,
|
||||
)];
|
||||
let client = build_pinned_client(
|
||||
"example.com",
|
||||
&addrs,
|
||||
Duration::from_secs(10),
|
||||
reqwest::redirect::Policy::none(),
|
||||
);
|
||||
assert!(client.is_ok(), "should build client successfully");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn requires_approval_multi_thread_no_panic() {
|
||||
use crate::secrets::CredentialMapping;
|
||||
|
||||
+11
-99
@@ -5,7 +5,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::RwLock;
|
||||
@@ -57,10 +57,6 @@ pub struct McpClient {
|
||||
|
||||
/// Custom headers to include in every request.
|
||||
custom_headers: HashMap<String, String>,
|
||||
|
||||
/// Whether the MCP initialize handshake has completed.
|
||||
/// Used as a local idempotency guard when no session_manager is present.
|
||||
initialized: AtomicBool,
|
||||
}
|
||||
|
||||
impl McpClient {
|
||||
@@ -83,7 +79,6 @@ impl McpClient {
|
||||
user_id: "default".to_string(),
|
||||
server_config: None,
|
||||
custom_headers: HashMap::new(),
|
||||
initialized: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +101,6 @@ impl McpClient {
|
||||
user_id: "default".to_string(),
|
||||
server_config: None,
|
||||
custom_headers: HashMap::new(),
|
||||
initialized: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +131,6 @@ impl McpClient {
|
||||
secrets: None,
|
||||
user_id: "default".to_string(),
|
||||
custom_headers: config.headers.clone(),
|
||||
initialized: AtomicBool::new(false),
|
||||
server_config: Some(config),
|
||||
}
|
||||
}
|
||||
@@ -169,7 +162,6 @@ impl McpClient {
|
||||
user_id: user_id.into(),
|
||||
server_config: Some(config),
|
||||
custom_headers,
|
||||
initialized: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,7 +197,6 @@ impl McpClient {
|
||||
user_id: user_id.into(),
|
||||
server_config,
|
||||
custom_headers,
|
||||
initialized: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,19 +237,9 @@ impl McpClient {
|
||||
}
|
||||
|
||||
/// Build the headers map for a request (auth, session-id, custom headers).
|
||||
///
|
||||
/// Custom headers are applied first. OAuth token injection is skipped if the
|
||||
/// user has explicitly configured an Authorization header, so user-provided
|
||||
/// credentials are never silently overwritten.
|
||||
async fn build_request_headers(&self) -> Result<HashMap<String, String>, ToolError> {
|
||||
let mut headers = self.custom_headers.clone();
|
||||
|
||||
// Only inject OAuth token if the user hasn't set a custom Authorization header.
|
||||
let has_custom_auth = self
|
||||
.custom_headers
|
||||
.keys()
|
||||
.any(|k| k.eq_ignore_ascii_case("authorization"));
|
||||
if !has_custom_auth && let Some(token) = self.get_access_token().await? {
|
||||
if let Some(token) = self.get_access_token().await? {
|
||||
headers.insert("Authorization".to_string(), format!("Bearer {}", token));
|
||||
}
|
||||
if let Some(ref session_manager) = self.session_manager
|
||||
@@ -326,14 +307,9 @@ impl McpClient {
|
||||
|
||||
/// Initialize the connection to the MCP server.
|
||||
pub async fn initialize(&self) -> Result<InitializeResult, ToolError> {
|
||||
// Fast path: already initialized (local flag or session manager)
|
||||
if self.initialized.load(Ordering::Relaxed) {
|
||||
return Ok(InitializeResult::default());
|
||||
}
|
||||
if let Some(ref session_manager) = self.session_manager
|
||||
&& session_manager.is_initialized(&self.server_name).await
|
||||
{
|
||||
self.initialized.store(true, Ordering::Relaxed);
|
||||
return Ok(InitializeResult::default());
|
||||
}
|
||||
if let Some(ref session_manager) = self.session_manager {
|
||||
@@ -366,7 +342,6 @@ impl McpClient {
|
||||
if let Some(ref session_manager) = self.session_manager {
|
||||
session_manager.mark_initialized(&self.server_name).await;
|
||||
}
|
||||
self.initialized.store(true, Ordering::Relaxed);
|
||||
|
||||
let notification = McpRequest::initialized_notification();
|
||||
let _ = self.send_request(notification).await;
|
||||
@@ -379,7 +354,9 @@ impl McpClient {
|
||||
if let Some(tools) = self.tools_cache.read().await.as_ref() {
|
||||
return Ok(tools.clone());
|
||||
}
|
||||
self.initialize().await?;
|
||||
if self.session_manager.is_some() {
|
||||
self.initialize().await?;
|
||||
}
|
||||
|
||||
let request = McpRequest::list_tools(self.next_request_id());
|
||||
let response = self.send_request(request).await?;
|
||||
@@ -409,7 +386,9 @@ impl McpClient {
|
||||
name: &str,
|
||||
arguments: serde_json::Value,
|
||||
) -> Result<CallToolResult, ToolError> {
|
||||
self.initialize().await?;
|
||||
if self.session_manager.is_some() {
|
||||
self.initialize().await?;
|
||||
}
|
||||
|
||||
let request = McpRequest::call_tool(self.next_request_id(), name, arguments);
|
||||
let response = self.send_request(request).await?;
|
||||
@@ -473,7 +452,6 @@ impl Clone for McpClient {
|
||||
user_id: self.user_id.clone(),
|
||||
server_config: self.server_config.clone(),
|
||||
custom_headers: self.custom_headers.clone(),
|
||||
initialized: AtomicBool::new(self.initialized.load(Ordering::Relaxed)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -816,34 +794,13 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_non_http_transport_skips_401_retry() {
|
||||
// initialize response, then notification ack (consumed but ignored),
|
||||
// then list_tools response
|
||||
let init_response = McpResponse {
|
||||
let response = McpResponse {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: Some(1),
|
||||
result: Some(serde_json::json!({
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"serverInfo": {"name": "test", "version": "1.0"}
|
||||
})),
|
||||
error: None,
|
||||
};
|
||||
let notification_ack = McpResponse {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: None,
|
||||
result: None,
|
||||
error: None,
|
||||
};
|
||||
let list_response = McpResponse {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: Some(2),
|
||||
result: Some(serde_json::json!({"tools": []})),
|
||||
error: None,
|
||||
};
|
||||
let transport = Arc::new(MockTransport::new(
|
||||
false,
|
||||
vec![init_response, notification_ack, list_response],
|
||||
));
|
||||
let transport = Arc::new(MockTransport::new(false, vec![response]));
|
||||
let client = McpClient::new_with_transport(
|
||||
"test-stdio",
|
||||
transport.clone(),
|
||||
@@ -856,8 +813,7 @@ mod tests {
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap().len(), 0);
|
||||
let headers = transport.recorded_headers();
|
||||
// 3 sends: initialize + notifications/initialized + list_tools
|
||||
assert_eq!(headers.len(), 3);
|
||||
assert_eq!(headers.len(), 1);
|
||||
assert!(!headers[0].contains_key("Authorization"));
|
||||
assert!(!headers[0].contains_key("Mcp-Session-Id"));
|
||||
}
|
||||
@@ -870,50 +826,6 @@ mod tests {
|
||||
assert!(!mock_non_http.supports_http_features());
|
||||
}
|
||||
|
||||
/// Regression test for issue #890: stdio clients must auto-initialize
|
||||
/// even without a session manager, and the second call should be idempotent.
|
||||
#[tokio::test]
|
||||
async fn test_stdio_client_auto_initializes_without_session_manager() {
|
||||
let init_response = McpResponse {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: Some(1),
|
||||
result: Some(serde_json::json!({
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"serverInfo": {"name": "test", "version": "1.0"}
|
||||
})),
|
||||
error: None,
|
||||
};
|
||||
let notification_ack = McpResponse {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: None,
|
||||
result: None,
|
||||
error: None,
|
||||
};
|
||||
let transport = Arc::new(MockTransport::new(
|
||||
false,
|
||||
vec![init_response, notification_ack],
|
||||
));
|
||||
let client = McpClient::new_with_transport(
|
||||
"test-stdio",
|
||||
transport.clone(),
|
||||
None, // no session manager
|
||||
None,
|
||||
"default",
|
||||
None,
|
||||
);
|
||||
|
||||
// First call should send initialize + notification
|
||||
let result = client.initialize().await;
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(transport.recorded_headers().len(), 2);
|
||||
|
||||
// Second call should be a no-op (idempotent via local flag)
|
||||
let result2 = client.initialize().await;
|
||||
assert!(result2.is_ok());
|
||||
assert_eq!(transport.recorded_headers().len(), 2); // no additional sends
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_top_level_nulls_removes_null_fields() {
|
||||
let input = serde_json::json!({
|
||||
|
||||
@@ -188,42 +188,9 @@ impl McpServerConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate custom header names and values using the http crate's RFC 9110
|
||||
// token validation (catches CRLF, spaces, colons, null bytes, etc.)
|
||||
for (name, value) in &self.headers {
|
||||
if name.is_empty() {
|
||||
return Err(ConfigError::InvalidConfig {
|
||||
reason: "Header name cannot be empty".to_string(),
|
||||
});
|
||||
}
|
||||
if reqwest::header::HeaderName::from_bytes(name.as_bytes()).is_err() {
|
||||
return Err(ConfigError::InvalidConfig {
|
||||
reason: format!(
|
||||
"Header name '{}' is not a valid HTTP header name (RFC 9110)",
|
||||
name
|
||||
),
|
||||
});
|
||||
}
|
||||
if reqwest::header::HeaderValue::from_str(value).is_err() {
|
||||
return Err(ConfigError::InvalidConfig {
|
||||
reason: format!("Header value for '{}' contains invalid characters", name),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if any custom header sets an Authorization value.
|
||||
///
|
||||
/// Used to skip OAuth token injection when the user has explicitly
|
||||
/// configured an Authorization header (e.g. for API-key-based servers).
|
||||
pub fn has_custom_auth_header(&self) -> bool {
|
||||
self.headers
|
||||
.keys()
|
||||
.any(|k| k.eq_ignore_ascii_case("authorization"))
|
||||
}
|
||||
|
||||
/// Check if this server requires authentication.
|
||||
///
|
||||
/// Returns true if OAuth is pre-configured OR if this is a remote HTTPS server
|
||||
@@ -414,13 +381,6 @@ pub async fn load_mcp_servers_from(path: impl AsRef<Path>) -> Result<McpServersF
|
||||
let content = fs::read_to_string(path).await?;
|
||||
let config: McpServersFile = serde_json::from_str(&content)?;
|
||||
|
||||
// Validate every server on load so corrupted configs are caught early
|
||||
for server in &config.servers {
|
||||
server.validate().map_err(|e| ConfigError::InvalidConfig {
|
||||
reason: format!("Server '{}': {}", server.name, e),
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
@@ -497,12 +457,6 @@ pub async fn load_mcp_servers_from_db(
|
||||
match store.get_setting(user_id, "mcp_servers").await {
|
||||
Ok(Some(value)) => {
|
||||
let config: McpServersFile = serde_json::from_value(value)?;
|
||||
// Validate every server on load so corrupted DB configs are caught early
|
||||
for server in &config.servers {
|
||||
server.validate().map_err(|e| ConfigError::InvalidConfig {
|
||||
reason: format!("Server '{}': {}", server.name, e),
|
||||
})?;
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
Ok(None) => {
|
||||
@@ -715,34 +669,6 @@ mod tests {
|
||||
assert!(config.servers.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_rejects_corrupted_headers() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("mcp-servers.json");
|
||||
|
||||
// Write a config with an invalid header name directly to disk,
|
||||
// bypassing the add_mcp_server() validation path.
|
||||
let corrupted = serde_json::json!({
|
||||
"servers": [{
|
||||
"name": "bad-server",
|
||||
"url": "https://mcp.example.com",
|
||||
"enabled": true,
|
||||
"headers": { "X Bad": "value" }
|
||||
}]
|
||||
});
|
||||
tokio::fs::write(&path, corrupted.to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = load_mcp_servers_from(&path).await;
|
||||
assert!(result.is_err(), "Load should reject corrupted headers");
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("bad-server"),
|
||||
"Error should name the offending server, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_secret_names() {
|
||||
let config = McpServerConfig::new("notion", "https://mcp.notion.com");
|
||||
@@ -904,94 +830,6 @@ mod tests {
|
||||
assert!(!config.requires_auth());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_header_crlf_injection_rejected() {
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("X-Good".to_string(), "safe".to_string());
|
||||
headers.insert("X-Bad\r\nInjected: true".to_string(), "value".to_string());
|
||||
|
||||
let config =
|
||||
McpServerConfig::new("server", "https://mcp.example.com").with_headers(headers);
|
||||
let err = config.validate().unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("not a valid HTTP header name"),
|
||||
"Expected RFC 9110 error, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_header_value_crlf_injection_rejected() {
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert(
|
||||
"X-Header".to_string(),
|
||||
"value\r\nInjected: true".to_string(),
|
||||
);
|
||||
|
||||
let config =
|
||||
McpServerConfig::new("server", "https://mcp.example.com").with_headers(headers);
|
||||
let err = config.validate().unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("invalid characters"),
|
||||
"Expected invalid characters error, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_header_name_with_space_rejected() {
|
||||
let headers = HashMap::from([("X Bad".to_string(), "value".to_string())]);
|
||||
let config =
|
||||
McpServerConfig::new("server", "https://mcp.example.com").with_headers(headers);
|
||||
assert!(config.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_header_name_with_colon_rejected() {
|
||||
let headers = HashMap::from([("X:Bad".to_string(), "value".to_string())]);
|
||||
let config =
|
||||
McpServerConfig::new("server", "https://mcp.example.com").with_headers(headers);
|
||||
assert!(config.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_header_name_with_null_byte_rejected() {
|
||||
let headers = HashMap::from([("X-Bad\0".to_string(), "value".to_string())]);
|
||||
let config =
|
||||
McpServerConfig::new("server", "https://mcp.example.com").with_headers(headers);
|
||||
assert!(config.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_header_empty_name_rejected() {
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert(String::new(), "value".to_string());
|
||||
|
||||
let config =
|
||||
McpServerConfig::new("server", "https://mcp.example.com").with_headers(headers);
|
||||
let err = config.validate().unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("empty"),
|
||||
"Expected empty name error, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_has_custom_auth_header_case_insensitive() {
|
||||
let headers = HashMap::from([("authorization".to_string(), "Bearer token".to_string())]);
|
||||
let config =
|
||||
McpServerConfig::new("server", "https://mcp.example.com").with_headers(headers);
|
||||
assert!(config.has_custom_auth_header());
|
||||
|
||||
let headers = HashMap::from([("AUTHORIZATION".to_string(), "Bearer token".to_string())]);
|
||||
let config =
|
||||
McpServerConfig::new("server", "https://mcp.example.com").with_headers(headers);
|
||||
assert!(config.has_custom_auth_header());
|
||||
|
||||
let headers = HashMap::from([("X-Api-Key".to_string(), "key".to_string())]);
|
||||
let config =
|
||||
McpServerConfig::new("server", "https://mcp.example.com").with_headers(headers);
|
||||
assert!(!config.has_custom_auth_header());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_headers() {
|
||||
let headers = HashMap::from([
|
||||
|
||||
@@ -383,121 +383,4 @@ mod tests {
|
||||
HttpMcpTransport::new("http://localhost:8080", "test").with_custom_headers(headers);
|
||||
assert_eq!(transport.custom_headers.get("X-Custom").unwrap(), "value");
|
||||
}
|
||||
|
||||
// -- Wire-level echo server tests -----------------------------------------
|
||||
//
|
||||
// These tests spin up a real HTTP server that echoes received headers back
|
||||
// as a JSON-RPC result, verifying that custom headers and Authorization
|
||||
// handling work end-to-end through the actual HTTP transport.
|
||||
|
||||
/// Spawn a lightweight echo server that returns received headers as a
|
||||
/// JSON-RPC response. Returns `(url, join_handle)`.
|
||||
async fn spawn_echo_server() -> (String, tokio::task::JoinHandle<()>) {
|
||||
use axum::{Router, extract::Request, routing::post};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
async fn echo_headers(req: Request) -> axum::response::Json<serde_json::Value> {
|
||||
let mut map = serde_json::Map::new();
|
||||
for (name, value) in req.headers() {
|
||||
if let Ok(v) = value.to_str() {
|
||||
map.insert(name.to_string(), serde_json::Value::String(v.to_string()));
|
||||
}
|
||||
}
|
||||
axum::response::Json(serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": map,
|
||||
}))
|
||||
}
|
||||
|
||||
let app = Router::new().route("/", post(echo_headers));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let url = format!("http://127.0.0.1:{}", addr.port());
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
(url, handle)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_wire_custom_headers_sent() {
|
||||
let (url, _handle) = spawn_echo_server().await;
|
||||
|
||||
let custom = HashMap::from([
|
||||
("X-Api-Key".to_string(), "secret-key".to_string()),
|
||||
("X-Org-Id".to_string(), "org-123".to_string()),
|
||||
]);
|
||||
let transport = HttpMcpTransport::new(&url, "echo-test").with_custom_headers(custom);
|
||||
|
||||
let request = McpRequest {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: Some(1),
|
||||
method: "initialize".to_string(),
|
||||
params: Some(serde_json::json!({})),
|
||||
};
|
||||
let per_request_headers = HashMap::new();
|
||||
let response = transport
|
||||
.send(&request, &per_request_headers)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let echoed = response.result.unwrap();
|
||||
assert_eq!(echoed["x-api-key"], "secret-key");
|
||||
assert_eq!(echoed["x-org-id"], "org-123");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_wire_per_request_headers_override_custom() {
|
||||
let (url, _handle) = spawn_echo_server().await;
|
||||
|
||||
let custom = HashMap::from([(
|
||||
"authorization".to_string(),
|
||||
"Bearer custom-token".to_string(),
|
||||
)]);
|
||||
let transport = HttpMcpTransport::new(&url, "echo-test").with_custom_headers(custom);
|
||||
|
||||
// Per-request header should override the custom header
|
||||
let per_request = HashMap::from([(
|
||||
"authorization".to_string(),
|
||||
"Bearer oauth-token".to_string(),
|
||||
)]);
|
||||
let request = McpRequest {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: Some(1),
|
||||
method: "initialize".to_string(),
|
||||
params: Some(serde_json::json!({})),
|
||||
};
|
||||
let response = transport.send(&request, &per_request).await.unwrap();
|
||||
|
||||
let echoed = response.result.unwrap();
|
||||
// Per-request headers are inserted after custom headers via HeaderMap::insert,
|
||||
// which replaces any existing entry for the same key.
|
||||
assert_eq!(echoed["authorization"], "Bearer oauth-token");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_wire_custom_auth_preserved_when_no_per_request_auth() {
|
||||
let (url, _handle) = spawn_echo_server().await;
|
||||
|
||||
let custom = HashMap::from([(
|
||||
"authorization".to_string(),
|
||||
"Bearer custom-token".to_string(),
|
||||
)]);
|
||||
let transport = HttpMcpTransport::new(&url, "echo-test").with_custom_headers(custom);
|
||||
|
||||
let per_request = HashMap::new(); // no per-request auth
|
||||
let request = McpRequest {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: Some(1),
|
||||
method: "initialize".to_string(),
|
||||
params: Some(serde_json::json!({})),
|
||||
};
|
||||
let response = transport.send(&request, &per_request).await.unwrap();
|
||||
|
||||
let echoed = response.result.unwrap();
|
||||
assert_eq!(echoed["authorization"], "Bearer custom-token");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,27 +118,13 @@ impl McpTransport for StdioMcpTransport {
|
||||
request: &McpRequest,
|
||||
_headers: &HashMap<String, String>,
|
||||
) -> Result<McpResponse, ToolError> {
|
||||
// JSON-RPC notifications (no id) are fire-and-forget: the server
|
||||
// will not send a response, so we must not wait for one.
|
||||
if request.id.is_none() {
|
||||
let mut stdin = self.stdin.lock().await;
|
||||
write_jsonrpc_line(&mut *stdin, request).await?;
|
||||
return Ok(McpResponse {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: None,
|
||||
result: None,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
|
||||
let id = request.id.unwrap_or(0);
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
// Register the pending response handler before writing the request,
|
||||
// so we don't miss a fast response from the child.
|
||||
{
|
||||
let mut pending = self.pending.lock().await;
|
||||
pending.insert(id, tx);
|
||||
pending.insert(request.id.unwrap_or(0), tx);
|
||||
}
|
||||
|
||||
// Write the request to stdin.
|
||||
@@ -147,7 +133,7 @@ impl McpTransport for StdioMcpTransport {
|
||||
if let Err(e) = write_jsonrpc_line(&mut *stdin, request).await {
|
||||
// Remove the pending entry on write failure.
|
||||
let mut pending = self.pending.lock().await;
|
||||
pending.remove(&id);
|
||||
pending.remove(&request.id.unwrap_or(0));
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
@@ -159,7 +145,7 @@ impl McpTransport for StdioMcpTransport {
|
||||
Ok(Err(_)) => {
|
||||
// Sender was dropped (reader task ended). Clean up pending entry.
|
||||
let mut pending = self.pending.lock().await;
|
||||
pending.remove(&id);
|
||||
pending.remove(&request.id.unwrap_or(0));
|
||||
Err(ToolError::ExternalService(format!(
|
||||
"[{}] MCP server closed connection before responding to request {:?}",
|
||||
self.server_name, request.id
|
||||
@@ -168,7 +154,7 @@ impl McpTransport for StdioMcpTransport {
|
||||
Err(_) => {
|
||||
// Timeout: remove the pending entry.
|
||||
let mut pending = self.pending.lock().await;
|
||||
pending.remove(&id);
|
||||
pending.remove(&request.id.unwrap_or(0));
|
||||
Err(ToolError::ExternalService(format!(
|
||||
"[{}] Timeout waiting for response to request {:?} after {:?}",
|
||||
self.server_name, request.id, timeout
|
||||
|
||||
@@ -91,27 +91,13 @@ impl McpTransport for UnixMcpTransport {
|
||||
request: &McpRequest,
|
||||
_headers: &HashMap<String, String>,
|
||||
) -> Result<McpResponse, ToolError> {
|
||||
// JSON-RPC notifications (no id) are fire-and-forget: the server
|
||||
// will not send a response, so we must not wait for one.
|
||||
if request.id.is_none() {
|
||||
let mut writer = self.writer.lock().await;
|
||||
write_jsonrpc_line(&mut *writer, request).await?;
|
||||
return Ok(McpResponse {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: None,
|
||||
result: None,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
|
||||
let id = request.id.unwrap_or(0);
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
// Register the pending response handler before writing the request,
|
||||
// so we don't miss a fast response from the server.
|
||||
{
|
||||
let mut pending = self.pending.lock().await;
|
||||
pending.insert(id, tx);
|
||||
pending.insert(request.id.unwrap_or(0), tx);
|
||||
}
|
||||
|
||||
// Write the request to the socket.
|
||||
@@ -120,7 +106,7 @@ impl McpTransport for UnixMcpTransport {
|
||||
if let Err(e) = write_jsonrpc_line(&mut *writer, request).await {
|
||||
// Remove the pending entry on write failure.
|
||||
let mut pending = self.pending.lock().await;
|
||||
pending.remove(&id);
|
||||
pending.remove(&request.id.unwrap_or(0));
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
@@ -132,7 +118,7 @@ impl McpTransport for UnixMcpTransport {
|
||||
Ok(Err(_)) => {
|
||||
// Sender was dropped (reader task ended). Clean up pending entry.
|
||||
let mut pending = self.pending.lock().await;
|
||||
pending.remove(&id);
|
||||
pending.remove(&request.id.unwrap_or(0));
|
||||
Err(ToolError::ExternalService(format!(
|
||||
"[{}] MCP server closed connection before responding to request {:?}",
|
||||
self.server_name, request.id
|
||||
@@ -141,7 +127,7 @@ impl McpTransport for UnixMcpTransport {
|
||||
Err(_) => {
|
||||
// Timeout: remove the pending entry.
|
||||
let mut pending = self.pending.lock().await;
|
||||
pending.remove(&id);
|
||||
pending.remove(&request.id.unwrap_or(0));
|
||||
Err(ToolError::ExternalService(format!(
|
||||
"[{}] Timeout waiting for response to request {:?} after {:?}",
|
||||
self.server_name, request.id, timeout
|
||||
|
||||
@@ -328,14 +328,6 @@ pub trait Tool: Send + Sync {
|
||||
None
|
||||
}
|
||||
|
||||
/// Optional host-side webhook verification configuration for this tool.
|
||||
///
|
||||
/// When present, `/webhook/tools/{tool}` validates shared secret/signatures
|
||||
/// before invoking the tool. Tools should then only handle payload normalization.
|
||||
fn webhook_capability(&self) -> Option<crate::tools::wasm::WebhookCapability> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Get the tool schema for LLM function calling.
|
||||
fn schema(&self) -> ToolSchema {
|
||||
ToolSchema {
|
||||
|
||||
@@ -32,8 +32,6 @@ pub struct Capabilities {
|
||||
pub tool_invoke: Option<ToolInvokeCapability>,
|
||||
/// Check if secrets exist.
|
||||
pub secrets: Option<SecretsCapability>,
|
||||
/// Webhook authentication and signature verification.
|
||||
pub webhook: Option<WebhookCapability>,
|
||||
}
|
||||
|
||||
impl Capabilities {
|
||||
@@ -310,25 +308,6 @@ impl SecretsCapability {
|
||||
/// WASM capabilities use it to configure per-tool HTTP request limits.
|
||||
pub use crate::tools::tool::ToolRateLimitConfig as RateLimitConfig;
|
||||
|
||||
/// Webhook auth/signature capability configuration for tools.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct WebhookCapability {
|
||||
/// Optional header name for shared-secret validation.
|
||||
pub secret_header: Option<String>,
|
||||
/// Secret name in secrets store for shared-secret validation.
|
||||
pub secret_name: Option<String>,
|
||||
/// Secret name in secrets store containing Ed25519 public key (Discord-style).
|
||||
pub signature_key_secret_name: Option<String>,
|
||||
/// Secret name in secrets store for HMAC-SHA256 signing validation.
|
||||
pub hmac_secret_name: Option<String>,
|
||||
/// Header containing signature (e.g. X-Hub-Signature-256 or X-Slack-Signature).
|
||||
pub hmac_signature_header: Option<String>,
|
||||
/// Optional timestamp header. When present, Slack-style v0 signature is used.
|
||||
pub hmac_timestamp_header: Option<String>,
|
||||
/// Optional signature prefix (default: "sha256=" or "v0=" for timestamped mode).
|
||||
pub hmac_prefix: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::tools::wasm::capabilities::{Capabilities, EndpointPattern, SecretsCapability};
|
||||
@@ -340,7 +319,6 @@ mod tests {
|
||||
assert!(caps.http.is_none());
|
||||
assert!(caps.tool_invoke.is_none());
|
||||
assert!(caps.secrets.is_none());
|
||||
assert!(caps.webhook.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -35,24 +35,12 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::secrets::{CredentialLocation, CredentialMapping};
|
||||
use crate::tools::wasm::{
|
||||
Capabilities, EndpointPattern, HttpCapability, RateLimitConfig, SecretsCapability,
|
||||
ToolInvokeCapability, WebhookCapability, WorkspaceCapability,
|
||||
ToolInvokeCapability, WorkspaceCapability,
|
||||
};
|
||||
|
||||
/// Root schema for a capabilities JSON file.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct CapabilitiesFile {
|
||||
/// Human-readable description of what the tool does.
|
||||
/// Used as the `Tool::description()` return value.
|
||||
/// If omitted, a generic fallback is used (with a warning).
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
|
||||
/// JSON Schema for the tool's input parameters.
|
||||
/// Used as the `Tool::parameters_schema()` return value.
|
||||
/// If omitted, a permissive fallback is used (with a warning).
|
||||
#[serde(default)]
|
||||
pub parameters: Option<serde_json::Value>,
|
||||
|
||||
/// Extension version (semver).
|
||||
#[serde(default)]
|
||||
pub version: Option<String>,
|
||||
@@ -77,10 +65,6 @@ pub struct CapabilitiesFile {
|
||||
#[serde(default)]
|
||||
pub workspace: Option<WorkspaceCapabilitySchema>,
|
||||
|
||||
/// Tool webhook authentication/signature configuration.
|
||||
#[serde(default)]
|
||||
pub webhook: Option<WebhookCapabilitySchema>,
|
||||
|
||||
/// Authentication setup instructions.
|
||||
/// Used by `ironclaw config` to guide users through auth setup.
|
||||
#[serde(default)]
|
||||
@@ -119,13 +103,10 @@ impl CapabilitiesFile {
|
||||
fn resolve_nested(mut self) -> Self {
|
||||
if let Some(inner) = self.capabilities.take() {
|
||||
let inner = inner.resolve_nested();
|
||||
self.description = self.description.or(inner.description);
|
||||
self.parameters = self.parameters.or(inner.parameters);
|
||||
self.http = self.http.or(inner.http);
|
||||
self.secrets = self.secrets.or(inner.secrets);
|
||||
self.tool_invoke = self.tool_invoke.or(inner.tool_invoke);
|
||||
self.workspace = self.workspace.or(inner.workspace);
|
||||
self.webhook = self.webhook.or(inner.webhook);
|
||||
self.auth = self.auth.or(inner.auth);
|
||||
self.setup = self.setup.or(inner.setup);
|
||||
}
|
||||
@@ -217,10 +198,6 @@ impl CapabilitiesFile {
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(webhook) = &self.webhook {
|
||||
caps.webhook = Some(webhook.to_webhook_capability());
|
||||
}
|
||||
|
||||
caps
|
||||
}
|
||||
}
|
||||
@@ -442,46 +419,6 @@ pub struct WorkspaceCapabilitySchema {
|
||||
pub allowed_prefixes: Vec<String>,
|
||||
}
|
||||
|
||||
/// Webhook capability schema for tools.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct WebhookCapabilitySchema {
|
||||
/// HTTP header name for secret validation.
|
||||
#[serde(default)]
|
||||
pub secret_header: Option<String>,
|
||||
/// Secret name in secrets store for shared-secret validation.
|
||||
#[serde(default)]
|
||||
pub secret_name: Option<String>,
|
||||
/// Secret name in secrets store containing Ed25519 public key.
|
||||
#[serde(default)]
|
||||
pub signature_key_secret_name: Option<String>,
|
||||
/// Secret name in secrets store for HMAC-SHA256 signing.
|
||||
#[serde(default)]
|
||||
pub hmac_secret_name: Option<String>,
|
||||
/// Signature header for HMAC verification.
|
||||
#[serde(default)]
|
||||
pub hmac_signature_header: Option<String>,
|
||||
/// Optional timestamp header for Slack-style v0 verification.
|
||||
#[serde(default)]
|
||||
pub hmac_timestamp_header: Option<String>,
|
||||
/// Optional signature prefix for body-only HMAC mode (default sha256=).
|
||||
#[serde(default)]
|
||||
pub hmac_prefix: Option<String>,
|
||||
}
|
||||
|
||||
impl WebhookCapabilitySchema {
|
||||
fn to_webhook_capability(&self) -> WebhookCapability {
|
||||
WebhookCapability {
|
||||
secret_header: self.secret_header.clone(),
|
||||
secret_name: self.secret_name.clone(),
|
||||
signature_key_secret_name: self.signature_key_secret_name.clone(),
|
||||
hmac_secret_name: self.hmac_secret_name.clone(),
|
||||
hmac_signature_header: self.hmac_signature_header.clone(),
|
||||
hmac_timestamp_header: self.hmac_timestamp_header.clone(),
|
||||
hmac_prefix: self.hmac_prefix.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Authentication setup schema.
|
||||
///
|
||||
/// Tools declare their auth requirements here. The agent uses this to provide
|
||||
@@ -832,28 +769,6 @@ mod tests {
|
||||
assert_eq!(workspace.allowed_prefixes, vec!["context/", "daily/"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_webhook_capability() {
|
||||
let json = r#"{
|
||||
"webhook": {
|
||||
"hmac_secret_name": "github_webhook_secret",
|
||||
"hmac_signature_header": "x-hub-signature-256",
|
||||
"hmac_prefix": "sha256="
|
||||
}
|
||||
}"#;
|
||||
|
||||
let caps = CapabilitiesFile::from_json(json).unwrap();
|
||||
let webhook = caps.webhook.unwrap();
|
||||
assert_eq!(
|
||||
webhook.hmac_secret_name.as_deref(),
|
||||
Some("github_webhook_secret")
|
||||
);
|
||||
assert_eq!(
|
||||
webhook.hmac_signature_header.as_deref(),
|
||||
Some("x-hub-signature-256")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_capabilities() {
|
||||
let json = r#"{
|
||||
@@ -1273,114 +1188,4 @@ mod tests {
|
||||
"Empty inner capabilities should not clobber outer http"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tool description and parameters schema ──────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_parse_description_and_parameters() {
|
||||
let json = r#"{
|
||||
"description": "Search the web using Brave Search API",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query"
|
||||
},
|
||||
"count": {
|
||||
"type": "integer",
|
||||
"description": "Number of results"
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
}"#;
|
||||
|
||||
let caps = CapabilitiesFile::from_json(json).unwrap();
|
||||
assert_eq!(
|
||||
caps.description.as_deref(),
|
||||
Some("Search the web using Brave Search API")
|
||||
);
|
||||
let params = caps.parameters.unwrap();
|
||||
assert_eq!(params["type"], "object");
|
||||
assert!(params["properties"]["query"].is_object());
|
||||
assert_eq!(params["required"][0], "query");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_description_only() {
|
||||
let json = r#"{
|
||||
"description": "A tool without explicit parameters schema"
|
||||
}"#;
|
||||
|
||||
let caps = CapabilitiesFile::from_json(json).unwrap();
|
||||
assert_eq!(
|
||||
caps.description.as_deref(),
|
||||
Some("A tool without explicit parameters schema")
|
||||
);
|
||||
assert!(caps.parameters.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_without_description_or_parameters() {
|
||||
let json = r#"{
|
||||
"http": {
|
||||
"allowlist": [{ "host": "api.example.com" }]
|
||||
}
|
||||
}"#;
|
||||
|
||||
let caps = CapabilitiesFile::from_json(json).unwrap();
|
||||
assert!(
|
||||
caps.description.is_none(),
|
||||
"description should be None when not provided"
|
||||
);
|
||||
assert!(
|
||||
caps.parameters.is_none(),
|
||||
"parameters should be None when not provided"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_nested_description_promoted() {
|
||||
let json = r#"{
|
||||
"capabilities": {
|
||||
"description": "Inner tool description",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input": { "type": "string" }
|
||||
},
|
||||
"required": ["input"]
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let caps = CapabilitiesFile::from_json(json).unwrap();
|
||||
assert_eq!(
|
||||
caps.description.as_deref(),
|
||||
Some("Inner tool description"),
|
||||
"description should be promoted from inner capabilities"
|
||||
);
|
||||
assert!(
|
||||
caps.parameters.is_some(),
|
||||
"parameters should be promoted from inner capabilities"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_nested_outer_description_takes_precedence() {
|
||||
let json = r#"{
|
||||
"description": "Outer description wins",
|
||||
"capabilities": {
|
||||
"description": "Inner description loses"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let caps = CapabilitiesFile::from_json(json).unwrap();
|
||||
assert_eq!(
|
||||
caps.description.as_deref(),
|
||||
Some("Outer description wins"),
|
||||
"Outer description should take precedence over inner"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+25
-64
@@ -123,73 +123,34 @@ impl WasmToolLoader {
|
||||
}
|
||||
let wasm_bytes = fs::read(wasm_path).await?;
|
||||
|
||||
// Read capabilities (optional) and extract OAuth refresh config,
|
||||
// tool description, and parameter schema.
|
||||
let (capabilities, oauth_refresh, description, schema) =
|
||||
if let Some(cap_path) = capabilities_path {
|
||||
if cap_path.exists() {
|
||||
let cap_bytes = fs::read(cap_path).await?;
|
||||
let cap_file = CapabilitiesFile::from_bytes(&cap_bytes)
|
||||
.map_err(|e| WasmLoadError::InvalidCapabilities(e.to_string()))?;
|
||||
cap_file.validate(name);
|
||||
// Read capabilities (optional) and extract OAuth refresh config
|
||||
let (capabilities, oauth_refresh) = if let Some(cap_path) = capabilities_path {
|
||||
if cap_path.exists() {
|
||||
let cap_bytes = fs::read(cap_path).await?;
|
||||
let cap_file = CapabilitiesFile::from_bytes(&cap_bytes)
|
||||
.map_err(|e| WasmLoadError::InvalidCapabilities(e.to_string()))?;
|
||||
cap_file.validate(name);
|
||||
|
||||
// Check WIT version compatibility
|
||||
check_wit_version_compat(
|
||||
name,
|
||||
cap_file.wit_version.as_deref(),
|
||||
crate::tools::wasm::WIT_TOOL_VERSION,
|
||||
)?;
|
||||
// Check WIT version compatibility
|
||||
check_wit_version_compat(
|
||||
name,
|
||||
cap_file.wit_version.as_deref(),
|
||||
crate::tools::wasm::WIT_TOOL_VERSION,
|
||||
)?;
|
||||
|
||||
let caps = cap_file.to_capabilities();
|
||||
let oauth = resolve_oauth_refresh_config(&cap_file);
|
||||
let desc = cap_file.description.clone();
|
||||
// Validate parameters schema before accepting it.
|
||||
let params = cap_file.parameters.clone().and_then(|p| {
|
||||
let errors = crate::tools::validate_tool_schema(&p, name);
|
||||
if errors.is_empty() {
|
||||
Some(p)
|
||||
} else {
|
||||
tracing::warn!(
|
||||
tool = name,
|
||||
?errors,
|
||||
"Invalid parameters schema in capabilities.json, \
|
||||
using permissive fallback"
|
||||
);
|
||||
None
|
||||
}
|
||||
});
|
||||
if desc.is_none() {
|
||||
tracing::warn!(
|
||||
tool = name,
|
||||
path = %cap_path.display(),
|
||||
"Capabilities file missing \"description\" field; \
|
||||
tool will use generic fallback description"
|
||||
);
|
||||
}
|
||||
if params.is_none() && cap_file.parameters.is_none() {
|
||||
tracing::warn!(
|
||||
tool = name,
|
||||
path = %cap_path.display(),
|
||||
"Capabilities file missing \"parameters\" field; \
|
||||
tool will accept any JSON object (permissive fallback)"
|
||||
);
|
||||
}
|
||||
(caps, oauth, desc, params)
|
||||
} else {
|
||||
tracing::warn!(
|
||||
path = %cap_path.display(),
|
||||
"Capabilities file not found, using default (no permissions)"
|
||||
);
|
||||
(Capabilities::default(), None, None, None)
|
||||
}
|
||||
let caps = cap_file.to_capabilities();
|
||||
let oauth = resolve_oauth_refresh_config(&cap_file);
|
||||
(caps, oauth)
|
||||
} else {
|
||||
tracing::warn!(
|
||||
tool = name,
|
||||
"No capabilities file for WASM tool; \
|
||||
tool will use generic fallback description and accept any JSON object"
|
||||
path = %cap_path.display(),
|
||||
"Capabilities file not found, using default (no permissions)"
|
||||
);
|
||||
(Capabilities::default(), None, None, None)
|
||||
};
|
||||
(Capabilities::default(), None)
|
||||
}
|
||||
} else {
|
||||
(Capabilities::default(), None)
|
||||
};
|
||||
|
||||
// Register the tool
|
||||
self.registry
|
||||
@@ -199,8 +160,8 @@ impl WasmToolLoader {
|
||||
runtime: &self.runtime,
|
||||
capabilities,
|
||||
limits: None,
|
||||
description: description.as_deref(),
|
||||
schema,
|
||||
description: None,
|
||||
schema: None,
|
||||
secrets_store: self.secrets_store.clone(),
|
||||
oauth_refresh,
|
||||
})
|
||||
|
||||
@@ -108,7 +108,7 @@ pub use wrapper::{OAuthRefreshConfig, WasmToolWrapper};
|
||||
// Capabilities (V2)
|
||||
pub use capabilities::{
|
||||
Capabilities, EndpointPattern, HttpCapability, RateLimitConfig, SecretsCapability,
|
||||
ToolInvokeCapability, WebhookCapability, WorkspaceCapability, WorkspaceReader,
|
||||
ToolInvokeCapability, WorkspaceCapability, WorkspaceReader,
|
||||
};
|
||||
|
||||
// Security components (V2)
|
||||
|
||||
@@ -323,32 +323,27 @@ impl WasmToolRuntime {
|
||||
|
||||
/// Extract tool description from a compiled component.
|
||||
///
|
||||
/// Returns a generic fallback. Callers should prefer loading the description
|
||||
/// from the sidecar `*.capabilities.json` file and overriding via
|
||||
/// `WasmToolWrapper::with_description()` or the `WasmToolRegistration::description` field.
|
||||
/// In a full implementation, this would use WIT bindgen to call the description() export.
|
||||
/// For now, we return a placeholder since we can't easily introspect without more setup.
|
||||
fn extract_tool_description(
|
||||
_engine: &Engine,
|
||||
_component: &wasmtime::component::Component,
|
||||
) -> Result<String, WasmError> {
|
||||
// WIT bindgen extraction is not yet implemented (see TODO #4 in CLAUDE.md).
|
||||
// Real descriptions come from the capabilities.json sidecar file, which is
|
||||
// loaded by the WasmToolLoader and passed as an override at registration time.
|
||||
// TODO: Use WIT bindgen to properly extract description
|
||||
// This requires instantiating with a linker, which needs host functions.
|
||||
// For now, tools should have their description set externally.
|
||||
Ok("WASM sandboxed tool".to_string())
|
||||
}
|
||||
|
||||
/// Extract tool parameter schema from a compiled component.
|
||||
/// Extract tool schema from a compiled component.
|
||||
///
|
||||
/// Returns a permissive fallback that accepts any JSON object. Callers should
|
||||
/// prefer loading the schema from the sidecar `*.capabilities.json` file and
|
||||
/// overriding via `WasmToolWrapper::with_schema()` or the
|
||||
/// `WasmToolRegistration::schema` field.
|
||||
/// In a full implementation, this would use WIT bindgen to call the schema() export.
|
||||
fn extract_tool_schema(
|
||||
_engine: &Engine,
|
||||
_component: &wasmtime::component::Component,
|
||||
) -> Result<serde_json::Value, WasmError> {
|
||||
// WIT bindgen extraction is not yet implemented (see TODO #4 in CLAUDE.md).
|
||||
// Real schemas come from the capabilities.json sidecar file, which is
|
||||
// loaded by the WasmToolLoader and passed as an override at registration time.
|
||||
// TODO: Use WIT bindgen to properly extract schema
|
||||
// For now, return a minimal schema that accepts any object.
|
||||
Ok(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
|
||||
@@ -808,10 +808,6 @@ impl Tool for WasmToolWrapper {
|
||||
// Use the timeout as a conservative estimate
|
||||
Some(self.prepared.limits.timeout)
|
||||
}
|
||||
|
||||
fn webhook_capability(&self) -> Option<crate::tools::wasm::WebhookCapability> {
|
||||
self.capabilities.webhook.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for WasmToolWrapper {
|
||||
|
||||
@@ -49,8 +49,6 @@ impl Tunnel for CloudflareTunnel {
|
||||
.kill_on_drop(true)
|
||||
.spawn()?;
|
||||
|
||||
let stdout = child.stdout.take();
|
||||
|
||||
// cloudflared prints the public URL on stderr
|
||||
let stderr = child
|
||||
.stderr
|
||||
@@ -84,42 +82,8 @@ impl Tunnel for CloudflareTunnel {
|
||||
}
|
||||
|
||||
if public_url.is_empty() {
|
||||
let error_detail = if let Some(stdout) = stdout {
|
||||
let mut out_reader = tokio::io::BufReader::new(stdout).lines();
|
||||
let mut lines = Vec::new();
|
||||
while lines.len() < 10 {
|
||||
match tokio::time::timeout(
|
||||
tokio::time::Duration::from_secs(1),
|
||||
out_reader.next_line(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(Some(line))) => lines.push(line),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
lines.join("\n")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
child.kill().await.ok();
|
||||
if error_detail.is_empty() {
|
||||
bail!("cloudflared did not produce a public URL within 30s");
|
||||
} else {
|
||||
bail!("cloudflared failed to start: {error_detail}");
|
||||
}
|
||||
}
|
||||
|
||||
// Drain stderr in the background to prevent SIGPIPE/buffer stalls.
|
||||
tokio::spawn(async move { while let Ok(Some(_)) = reader.next_line().await {} });
|
||||
|
||||
// Drain stdout silently.
|
||||
if let Some(stdout) = stdout {
|
||||
tokio::spawn(async move {
|
||||
let mut out_reader = tokio::io::BufReader::new(stdout).lines();
|
||||
while let Ok(Some(_)) = out_reader.next_line().await {}
|
||||
});
|
||||
bail!("cloudflared did not produce a public URL within 30s. Is the token valid?");
|
||||
}
|
||||
|
||||
if let Ok(mut guard) = self.url.write() {
|
||||
|
||||
+1
-41
@@ -69,13 +69,10 @@ impl Tunnel for CustomTunnel {
|
||||
.kill_on_drop(true)
|
||||
.spawn()?;
|
||||
|
||||
let stdout = child.stdout.take();
|
||||
let stderr = child.stderr.take();
|
||||
|
||||
let mut public_url = format!("http://{local_host}:{local_port}");
|
||||
|
||||
if self.url_pattern.is_some()
|
||||
&& let Some(stdout) = stdout
|
||||
&& let Some(stdout) = child.stdout.take()
|
||||
{
|
||||
let mut reader = tokio::io::BufReader::new(stdout).lines();
|
||||
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(15);
|
||||
@@ -103,22 +100,6 @@ impl Tunnel for CustomTunnel {
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
// Drain remaining stdout to prevent SIGPIPE/buffer stalls.
|
||||
tokio::spawn(async move { while let Ok(Some(_)) = reader.next_line().await {} });
|
||||
} else if let Some(stdout) = stdout {
|
||||
// No url_pattern: still drain stdout to prevent pipe stalls.
|
||||
tokio::spawn(async move {
|
||||
let mut reader = tokio::io::BufReader::new(stdout).lines();
|
||||
while let Ok(Some(_)) = reader.next_line().await {}
|
||||
});
|
||||
}
|
||||
|
||||
// Drain stderr silently.
|
||||
if let Some(stderr) = stderr {
|
||||
tokio::spawn(async move {
|
||||
let mut reader = tokio::io::BufReader::new(stderr).lines();
|
||||
while let Ok(Some(_)) = reader.next_line().await {}
|
||||
});
|
||||
}
|
||||
|
||||
if let Ok(mut guard) = self.url.write() {
|
||||
@@ -265,25 +246,4 @@ mod tests {
|
||||
fn extract_url_none_when_absent() {
|
||||
assert_eq!(extract_url("no url here"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stdout_drain_prevents_zombie() {
|
||||
// `yes` floods stdout indefinitely; without the drain task the pipe
|
||||
// buffer fills (64 KB) and the child blocks on write(), becoming a
|
||||
// zombie. With draining the child stays alive and stop() can kill it.
|
||||
let tunnel = CustomTunnel::new("yes".into(), None, None);
|
||||
let url = tunnel.start("127.0.0.1", 19999).await.unwrap();
|
||||
assert_eq!(url, "http://127.0.0.1:19999");
|
||||
|
||||
// Give the drain task time to consume some output.
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||
|
||||
// Child should still be alive (not blocked/zombie).
|
||||
assert!(
|
||||
tunnel.health_check().await,
|
||||
"yes process should still be alive"
|
||||
);
|
||||
|
||||
tunnel.stop().await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
+2
-37
@@ -54,7 +54,7 @@ impl Tunnel for NgrokTunnel {
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to capture ngrok stdout"))?;
|
||||
let stderr = child.stderr.take();
|
||||
|
||||
let mut reader = tokio::io::BufReader::new(stdout).lines();
|
||||
let mut public_url = String::new();
|
||||
|
||||
@@ -84,43 +84,8 @@ impl Tunnel for NgrokTunnel {
|
||||
}
|
||||
|
||||
if public_url.is_empty() {
|
||||
let error_detail = if let Some(stderr) = stderr {
|
||||
let mut err_reader = tokio::io::BufReader::new(stderr).lines();
|
||||
let mut lines = Vec::new();
|
||||
while lines.len() < 10 {
|
||||
match tokio::time::timeout(
|
||||
tokio::time::Duration::from_secs(1),
|
||||
err_reader.next_line(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(Some(line))) => lines.push(line),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
lines.join("\n")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
child.kill().await.ok();
|
||||
if error_detail.is_empty() {
|
||||
bail!("ngrok did not produce a public URL within 15s");
|
||||
} else {
|
||||
bail!("ngrok failed to start: {error_detail}");
|
||||
}
|
||||
}
|
||||
|
||||
// Drain stdout silently — ngrok only emits low-level connection events
|
||||
// to stdout; the pipe must be consumed to prevent SIGPIPE/buffer stalls.
|
||||
tokio::spawn(async move { while let Ok(Some(_)) = reader.next_line().await {} });
|
||||
|
||||
// Drain stderr silently — with --log stdout all meaningful output goes
|
||||
// to stdout; stderr only needs to be consumed to prevent pipe stalls.
|
||||
if let Some(stderr) = stderr {
|
||||
tokio::spawn(async move {
|
||||
let mut err_reader = tokio::io::BufReader::new(stderr).lines();
|
||||
while let Ok(Some(_)) = err_reader.next_line().await {}
|
||||
});
|
||||
bail!("ngrok did not produce a public URL within 15s. Is the auth token valid?");
|
||||
}
|
||||
|
||||
if let Ok(mut guard) = self.url.write() {
|
||||
|
||||
@@ -1,712 +0,0 @@
|
||||
//! Generic webhook ingress for tools.
|
||||
//!
|
||||
//! Exposes `/webhook/tools/{tool}` so external webhook providers can POST
|
||||
//! payloads that are normalized by the target tool into `system_event`s.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{DefaultBodyLimit, Path, Query, State},
|
||||
http::{HeaderMap, Method, StatusCode},
|
||||
routing::{get, post},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
use crate::agent::routine_engine::RoutineEngine;
|
||||
use crate::context::JobContext;
|
||||
use crate::secrets::SecretsStore;
|
||||
use crate::tools::ToolRegistry;
|
||||
|
||||
/// Shared routine engine slot, populated by Agent after startup.
|
||||
pub type RoutineEngineSlot = Arc<tokio::sync::RwLock<Option<Arc<RoutineEngine>>>>;
|
||||
|
||||
/// Shared state for the generic tools webhook ingress.
|
||||
#[derive(Clone)]
|
||||
pub struct ToolWebhookState {
|
||||
pub tools: Arc<ToolRegistry>,
|
||||
pub routine_engine: RoutineEngineSlot,
|
||||
pub user_id: String,
|
||||
pub secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ToolWebhookResponse {
|
||||
status: &'static str,
|
||||
tool: String,
|
||||
emitted_events: usize,
|
||||
fired_routines: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ToolWebhookOutput {
|
||||
#[serde(default)]
|
||||
emit_events: Vec<SystemEventIntent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SystemEventIntent {
|
||||
source: String,
|
||||
event_type: String,
|
||||
#[serde(default)]
|
||||
payload: serde_json::Value,
|
||||
}
|
||||
|
||||
const MAX_WEBHOOK_BODY_BYTES: usize = 64 * 1024;
|
||||
|
||||
/// Build routes for tool-driven webhook ingestion.
|
||||
pub fn routes(state: ToolWebhookState) -> Router {
|
||||
Router::new()
|
||||
.route("/webhook/tools/{tool}", post(tool_webhook_handler))
|
||||
.route(
|
||||
"/webhook/tools/{tool}/{*rest}",
|
||||
post(tool_webhook_with_rest_handler),
|
||||
)
|
||||
.route("/webhook/tools/{tool}", get(tool_webhook_health))
|
||||
.layer(DefaultBodyLimit::max(MAX_WEBHOOK_BODY_BYTES))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn tool_webhook_health(
|
||||
Path(tool): Path<String>,
|
||||
State(state): State<ToolWebhookState>,
|
||||
) -> (StatusCode, Json<serde_json::Value>) {
|
||||
let Some(tool_impl) = state.tools.get(&tool).await else {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": format!("Tool not found: {tool}") })),
|
||||
);
|
||||
};
|
||||
if tool_impl.webhook_capability().is_none() {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": format!("Tool does not support webhooks: {tool}") })),
|
||||
);
|
||||
}
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({ "status": "ok", "tool": tool })),
|
||||
)
|
||||
}
|
||||
|
||||
async fn tool_webhook_handler(
|
||||
Path(tool): Path<String>,
|
||||
State(state): State<ToolWebhookState>,
|
||||
method: Method,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<HashMap<String, String>>,
|
||||
body: axum::body::Bytes,
|
||||
) -> (StatusCode, Json<serde_json::Value>) {
|
||||
tool_webhook_handler_inner(tool, None, state, method, headers, query, body).await
|
||||
}
|
||||
|
||||
async fn tool_webhook_with_rest_handler(
|
||||
Path((tool, rest)): Path<(String, String)>,
|
||||
State(state): State<ToolWebhookState>,
|
||||
method: Method,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<HashMap<String, String>>,
|
||||
body: axum::body::Bytes,
|
||||
) -> (StatusCode, Json<serde_json::Value>) {
|
||||
tool_webhook_handler_inner(tool, Some(rest), state, method, headers, query, body).await
|
||||
}
|
||||
|
||||
async fn tool_webhook_handler_inner(
|
||||
tool: String,
|
||||
rest: Option<String>,
|
||||
state: ToolWebhookState,
|
||||
method: Method,
|
||||
headers: HeaderMap,
|
||||
query: HashMap<String, String>,
|
||||
body: axum::body::Bytes,
|
||||
) -> (StatusCode, Json<serde_json::Value>) {
|
||||
if body.len() > MAX_WEBHOOK_BODY_BYTES {
|
||||
return (
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
Json(serde_json::json!({
|
||||
"error": format!("Webhook body exceeds {} bytes", MAX_WEBHOOK_BODY_BYTES)
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let Some(tool_impl) = state.tools.get(&tool).await else {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": format!("Tool not found: {tool}") })),
|
||||
);
|
||||
};
|
||||
|
||||
if let Err(msg) = validate_webhook_auth(
|
||||
&*tool_impl,
|
||||
state.secrets_store.as_deref(),
|
||||
&state.user_id,
|
||||
&headers,
|
||||
&body,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({ "error": msg })),
|
||||
);
|
||||
}
|
||||
|
||||
let body_json: Option<serde_json::Value> = serde_json::from_slice(&body).ok();
|
||||
let headers_map: HashMap<String, String> = headers
|
||||
.iter()
|
||||
.filter_map(|(k, v)| {
|
||||
v.to_str()
|
||||
.ok()
|
||||
.map(|v| (k.as_str().to_string(), v.to_string()))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let path = if let Some(rest) = rest.filter(|r| !r.is_empty()) {
|
||||
format!("/webhook/tools/{tool}/{rest}")
|
||||
} else {
|
||||
format!("/webhook/tools/{tool}")
|
||||
};
|
||||
|
||||
let params = serde_json::json!({
|
||||
"action": "handle_webhook",
|
||||
"webhook": {
|
||||
"method": method.as_str(),
|
||||
"path": path,
|
||||
"query": query,
|
||||
"headers": headers_map,
|
||||
"body_json": body_json,
|
||||
"body_raw": String::from_utf8_lossy(&body),
|
||||
}
|
||||
});
|
||||
|
||||
let ctx = JobContext::with_user(
|
||||
state.user_id.clone(),
|
||||
format!("webhook:{tool}"),
|
||||
"Process external webhook",
|
||||
);
|
||||
|
||||
let output = match tool_impl.execute(params, &ctx).await {
|
||||
Ok(out) => out,
|
||||
Err(e) => {
|
||||
tracing::warn!(tool = %tool, error = %e, "Webhook tool execution failed");
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({ "error": "Tool execution failed" })),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let parsed: ToolWebhookOutput = match serde_json::from_value(output.result) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "Tool webhook response must be a JSON object (optionally with 'emit_events' array)"
|
||||
})),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let emitted_events = parsed.emit_events.len();
|
||||
let mut fired_routines = 0usize;
|
||||
if emitted_events > 0 {
|
||||
let Some(engine) = state.routine_engine.read().await.as_ref().cloned() else {
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(serde_json::json!({ "error": "Routine engine not available" })),
|
||||
);
|
||||
};
|
||||
|
||||
for event in parsed.emit_events {
|
||||
fired_routines += engine
|
||||
.emit_system_event(
|
||||
&event.source,
|
||||
&event.event_type,
|
||||
&event.payload,
|
||||
Some(&state.user_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
let response = ToolWebhookResponse {
|
||||
status: "accepted",
|
||||
tool,
|
||||
emitted_events,
|
||||
fired_routines,
|
||||
};
|
||||
(StatusCode::ACCEPTED, Json(serde_json::json!(response)))
|
||||
}
|
||||
|
||||
fn header_value<'a>(headers: &'a HeaderMap, key: &str) -> Option<&'a str> {
|
||||
// HeaderMap::get() already performs case-insensitive lookup per HTTP spec.
|
||||
headers.get(key).and_then(|v| v.to_str().ok())
|
||||
}
|
||||
|
||||
async fn validate_webhook_auth(
|
||||
tool: &dyn crate::tools::Tool,
|
||||
secrets_store: Option<&(dyn SecretsStore + Send + Sync)>,
|
||||
user_id: &str,
|
||||
headers: &HeaderMap,
|
||||
body: &[u8],
|
||||
) -> Result<(), String> {
|
||||
let Some(cfg) = tool.webhook_capability() else {
|
||||
return Err(
|
||||
"Tool does not declare a webhook capability; webhook access denied".to_string(),
|
||||
);
|
||||
};
|
||||
|
||||
// Require at least one authentication mechanism to be configured.
|
||||
if cfg.secret_name.is_none()
|
||||
&& cfg.signature_key_secret_name.is_none()
|
||||
&& cfg.hmac_secret_name.is_none()
|
||||
{
|
||||
return Err(
|
||||
"Webhook capability misconfigured: at least one auth mechanism must be configured"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let Some(store) = secrets_store else {
|
||||
return Err("Secrets store not available for webhook verification".to_string());
|
||||
};
|
||||
|
||||
if let Some(secret_name) = cfg.secret_name.as_deref() {
|
||||
let expected = store
|
||||
.get_decrypted(user_id, secret_name)
|
||||
.await
|
||||
.map_err(|_| format!("Missing webhook secret '{secret_name}'"))?;
|
||||
let expected = expected.expose();
|
||||
let secret_header = cfg.secret_header.as_deref().unwrap_or("x-webhook-secret");
|
||||
let provided = header_value(headers, secret_header)
|
||||
.or_else(|| {
|
||||
if secret_header != "x-webhook-secret" {
|
||||
header_value(headers, "x-webhook-secret")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| "Webhook secret required".to_string())?;
|
||||
|
||||
if !bool::from(expected.as_bytes().ct_eq(provided.as_bytes())) {
|
||||
return Err("Invalid webhook secret".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(public_key_name) = cfg.signature_key_secret_name.as_deref() {
|
||||
let key = store
|
||||
.get_decrypted(user_id, public_key_name)
|
||||
.await
|
||||
.map_err(|_| format!("Missing signature key secret '{public_key_name}'"))?;
|
||||
let key = key.expose();
|
||||
let sig = header_value(headers, "x-signature-ed25519")
|
||||
.ok_or_else(|| "Missing signature header".to_string())?;
|
||||
let ts = header_value(headers, "x-signature-timestamp")
|
||||
.ok_or_else(|| "Missing signature timestamp header".to_string())?;
|
||||
let now_secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
if !crate::channels::wasm::signature::verify_discord_signature(key, sig, ts, body, now_secs)
|
||||
{
|
||||
return Err("Invalid signature".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(hmac_secret_name) = cfg.hmac_secret_name.as_deref() {
|
||||
let secret = store
|
||||
.get_decrypted(user_id, hmac_secret_name)
|
||||
.await
|
||||
.map_err(|_| format!("Missing HMAC secret '{hmac_secret_name}'"))?;
|
||||
let secret = secret.expose();
|
||||
|
||||
if let Some(timestamp_header) = cfg.hmac_timestamp_header.as_deref() {
|
||||
let sig_header = cfg
|
||||
.hmac_signature_header
|
||||
.as_deref()
|
||||
.unwrap_or("x-slack-signature");
|
||||
let sig = header_value(headers, sig_header)
|
||||
.ok_or_else(|| "Missing HMAC signature header".to_string())?;
|
||||
let ts = header_value(headers, timestamp_header)
|
||||
.ok_or_else(|| "Missing HMAC timestamp header".to_string())?;
|
||||
let now_secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
if !crate::channels::wasm::signature::verify_slack_signature(
|
||||
secret, ts, body, sig, now_secs,
|
||||
) {
|
||||
return Err("Invalid timestamped HMAC signature".to_string());
|
||||
}
|
||||
} else {
|
||||
let sig_header = cfg
|
||||
.hmac_signature_header
|
||||
.as_deref()
|
||||
.unwrap_or("x-hub-signature-256");
|
||||
let prefix = cfg.hmac_prefix.as_deref().unwrap_or("sha256=");
|
||||
let sig = header_value(headers, sig_header)
|
||||
.ok_or_else(|| "Missing HMAC signature header".to_string())?;
|
||||
if !crate::channels::wasm::signature::verify_hmac_sha256_prefixed(
|
||||
secret, body, sig, prefix,
|
||||
) {
|
||||
return Err("Invalid HMAC signature".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use axum::body::Body;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::context::JobContext;
|
||||
use crate::secrets::{CreateSecretParams, InMemorySecretsStore, SecretsCrypto};
|
||||
use crate::tools::{Tool, ToolError, ToolOutput, ToolRegistry};
|
||||
|
||||
use super::*;
|
||||
|
||||
struct TestWebhookTool;
|
||||
struct ProtectedWebhookTool;
|
||||
struct HmacWebhookTool;
|
||||
/// Tool that declares webhook_capability() but with no auth mechanism configured.
|
||||
struct MisconfiguredWebhookTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TestWebhookTool {
|
||||
fn name(&self) -> &str {
|
||||
"test_webhook"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"test"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({"type":"object"})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
_params: serde_json::Value,
|
||||
_ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
Ok(ToolOutput::success(
|
||||
serde_json::json!({"emit_events":[]}),
|
||||
Duration::from_millis(1),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ProtectedWebhookTool {
|
||||
fn name(&self) -> &str {
|
||||
"protected_webhook"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"protected test"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({"type":"object"})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
_params: serde_json::Value,
|
||||
_ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
Ok(ToolOutput::success(
|
||||
serde_json::json!({"emit_events":[]}),
|
||||
Duration::from_millis(1),
|
||||
))
|
||||
}
|
||||
|
||||
fn webhook_capability(&self) -> Option<crate::tools::wasm::WebhookCapability> {
|
||||
Some(crate::tools::wasm::WebhookCapability {
|
||||
secret_name: Some("test_webhook_secret".to_string()),
|
||||
secret_header: Some("x-webhook-secret".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for HmacWebhookTool {
|
||||
fn name(&self) -> &str {
|
||||
"hmac_webhook"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"hmac test"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({"type":"object"})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
_params: serde_json::Value,
|
||||
_ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
Ok(ToolOutput::success(
|
||||
serde_json::json!({"emit_events":[]}),
|
||||
Duration::from_millis(1),
|
||||
))
|
||||
}
|
||||
|
||||
fn webhook_capability(&self) -> Option<crate::tools::wasm::WebhookCapability> {
|
||||
Some(crate::tools::wasm::WebhookCapability {
|
||||
hmac_secret_name: Some("hmac_secret".to_string()),
|
||||
hmac_signature_header: Some("x-hub-signature-256".to_string()),
|
||||
hmac_prefix: Some("sha256=".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for MisconfiguredWebhookTool {
|
||||
fn name(&self) -> &str {
|
||||
"misconfigured_webhook"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"misconfigured test"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({"type":"object"})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
_params: serde_json::Value,
|
||||
_ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
Ok(ToolOutput::success(
|
||||
serde_json::json!({"emit_events":[]}),
|
||||
Duration::from_millis(1),
|
||||
))
|
||||
}
|
||||
|
||||
fn webhook_capability(&self) -> Option<crate::tools::wasm::WebhookCapability> {
|
||||
Some(crate::tools::wasm::WebhookCapability::default())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_not_found_for_unknown_tool() {
|
||||
let tools = Arc::new(ToolRegistry::new());
|
||||
let app = routes(ToolWebhookState {
|
||||
tools,
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
user_id: "test".to_string(),
|
||||
secrets_store: None,
|
||||
});
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/webhook/tools/missing")
|
||||
.body(Body::from("{}"))
|
||||
.expect("request");
|
||||
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_tool_without_webhook_capability() {
|
||||
let tools = Arc::new(ToolRegistry::new());
|
||||
tools.register(Arc::new(TestWebhookTool)).await;
|
||||
let app = routes(ToolWebhookState {
|
||||
tools,
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
user_id: "test".to_string(),
|
||||
secrets_store: None,
|
||||
});
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/webhook/tools/test_webhook")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"ok":true}"#))
|
||||
.expect("request");
|
||||
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_when_required_secret_missing() {
|
||||
let tools = Arc::new(ToolRegistry::new());
|
||||
tools.register(Arc::new(ProtectedWebhookTool)).await;
|
||||
|
||||
let secrets = Arc::new(InMemorySecretsStore::new(Arc::new(
|
||||
SecretsCrypto::new(secrecy::SecretString::from(
|
||||
"test-key-at-least-32-chars-long!!".to_string(),
|
||||
))
|
||||
.expect("crypto"),
|
||||
)));
|
||||
secrets
|
||||
.create(
|
||||
"test",
|
||||
CreateSecretParams::new("test_webhook_secret", "s3cret"),
|
||||
)
|
||||
.await
|
||||
.expect("secret create");
|
||||
|
||||
let app = routes(ToolWebhookState {
|
||||
tools,
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
user_id: "test".to_string(),
|
||||
secrets_store: Some(secrets),
|
||||
});
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/webhook/tools/protected_webhook")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"ok":true}"#))
|
||||
.expect("request");
|
||||
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accepts_with_valid_hmac_signature() {
|
||||
use hmac::Mac;
|
||||
|
||||
let tools = Arc::new(ToolRegistry::new());
|
||||
tools.register(Arc::new(HmacWebhookTool)).await;
|
||||
|
||||
let secrets = Arc::new(InMemorySecretsStore::new(Arc::new(
|
||||
SecretsCrypto::new(secrecy::SecretString::from(
|
||||
"test-key-at-least-32-chars-long!!".to_string(),
|
||||
))
|
||||
.expect("crypto"),
|
||||
)));
|
||||
secrets
|
||||
.create(
|
||||
"test",
|
||||
CreateSecretParams::new("hmac_secret", "github-secret"),
|
||||
)
|
||||
.await
|
||||
.expect("secret create");
|
||||
|
||||
let app = routes(ToolWebhookState {
|
||||
tools,
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
user_id: "test".to_string(),
|
||||
secrets_store: Some(secrets),
|
||||
});
|
||||
|
||||
let payload = br#"{"action":"opened"}"#;
|
||||
let mut mac =
|
||||
hmac::Hmac::<sha2::Sha256>::new_from_slice(b"github-secret").expect("hmac key");
|
||||
mac.update(payload);
|
||||
let sig = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/webhook/tools/hmac_webhook")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-hub-signature-256", sig)
|
||||
.body(Body::from(payload.to_vec()))
|
||||
.expect("request");
|
||||
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(resp.status(), StatusCode::ACCEPTED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_empty_webhook_capability_as_misconfigured() {
|
||||
let tools = Arc::new(ToolRegistry::new());
|
||||
tools.register(Arc::new(MisconfiguredWebhookTool)).await;
|
||||
|
||||
let secrets = Arc::new(InMemorySecretsStore::new(Arc::new(
|
||||
SecretsCrypto::new(secrecy::SecretString::from(
|
||||
"test-key-at-least-32-chars-long!!".to_string(),
|
||||
))
|
||||
.expect("crypto"),
|
||||
)));
|
||||
|
||||
let app = routes(ToolWebhookState {
|
||||
tools,
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
user_id: "test".to_string(),
|
||||
secrets_store: Some(secrets),
|
||||
});
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/webhook/tools/misconfigured_webhook")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"ok":true}"#))
|
||||
.expect("request");
|
||||
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_check_returns_ok_for_webhook_capable_tool() {
|
||||
let tools = Arc::new(ToolRegistry::new());
|
||||
tools.register(Arc::new(ProtectedWebhookTool)).await;
|
||||
let app = routes(ToolWebhookState {
|
||||
tools,
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
user_id: "test".to_string(),
|
||||
secrets_store: None,
|
||||
});
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.method("GET")
|
||||
.uri("/webhook/tools/protected_webhook")
|
||||
.body(Body::empty())
|
||||
.expect("request");
|
||||
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_check_returns_not_found_for_non_webhook_tool() {
|
||||
let tools = Arc::new(ToolRegistry::new());
|
||||
tools.register(Arc::new(TestWebhookTool)).await;
|
||||
let app = routes(ToolWebhookState {
|
||||
tools,
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
user_id: "test".to_string(),
|
||||
secrets_store: None,
|
||||
});
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.method("GET")
|
||||
.uri("/webhook/tools/test_webhook")
|
||||
.body(Body::empty())
|
||||
.expect("request");
|
||||
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -1187,13 +1187,13 @@ impl<'a> LoopDelegate for JobDelegate<'a> {
|
||||
// TokenUsage; only respond_with_tools() usage is tracked here.
|
||||
let total_tokens = output.usage.total() as u64;
|
||||
if total_tokens > 0
|
||||
&& let Err(err) = self
|
||||
&& let Err(msg) = self
|
||||
.worker
|
||||
.context_manager()
|
||||
.update_context(self.worker.job_id, |ctx| ctx.add_tokens(total_tokens))
|
||||
.await?
|
||||
{
|
||||
self.worker.mark_failed(&err.to_string()).await?;
|
||||
self.worker.mark_failed(&msg).await?;
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
@@ -1796,7 +1796,7 @@ mod tests {
|
||||
|
||||
// Verify that mark_failed transitions job to Failed
|
||||
worker
|
||||
.mark_failed(&budget_result.unwrap_err().to_string())
|
||||
.mark_failed(&budget_result.unwrap_err())
|
||||
.await
|
||||
.unwrap();
|
||||
let ctx = worker
|
||||
|
||||
@@ -58,7 +58,6 @@ mod advanced {
|
||||
let trace = LlmTrace::from_file(format!("{FIXTURES}/steering.json")).unwrap();
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
@@ -96,11 +95,7 @@ mod advanced {
|
||||
let _ = std::fs::remove_file("/tmp/ironclaw_recovery_test.txt");
|
||||
|
||||
let trace = LlmTrace::from_file(format!("{FIXTURES}/tool_error_recovery.json")).unwrap();
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace)
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
let rig = TestRigBuilder::new().with_trace(trace).build().await;
|
||||
|
||||
rig.send_message("Write 'recovered successfully' to a file for me.")
|
||||
.await;
|
||||
@@ -143,11 +138,7 @@ mod advanced {
|
||||
std::fs::create_dir_all(test_dir).unwrap();
|
||||
|
||||
let trace = LlmTrace::from_file(format!("{FIXTURES}/long_tool_chain.json")).unwrap();
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace)
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
let rig = TestRigBuilder::new().with_trace(trace).build().await;
|
||||
|
||||
rig.send_message(
|
||||
"Create a daily log at /tmp/ironclaw_chain_test/log.md, \
|
||||
@@ -241,7 +232,6 @@ mod advanced {
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace)
|
||||
.with_max_tool_iterations(3)
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
@@ -252,8 +242,8 @@ mod advanced {
|
||||
|
||||
let started = rig.tool_calls_started();
|
||||
assert!(
|
||||
started.len() <= 8,
|
||||
"expected <= 8 tool calls with max_tool_iterations=3, got {}: {started:?}",
|
||||
started.len() <= 4,
|
||||
"expected <= 4 tool calls with max_tool_iterations=3, got {}: {started:?}",
|
||||
started.len()
|
||||
);
|
||||
assert!(!started.is_empty(), "expected at least 1 tool call, got 0");
|
||||
@@ -305,7 +295,6 @@ mod advanced {
|
||||
.with_trace(trace.clone())
|
||||
.with_routines()
|
||||
.with_http_exchanges(http_exchanges)
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
|
||||
@@ -140,7 +140,6 @@ mod tests {
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
@@ -181,7 +180,6 @@ mod tests {
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
@@ -327,7 +325,6 @@ mod tests {
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
@@ -397,7 +394,6 @@ mod tests {
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
@@ -439,7 +435,6 @@ mod tests {
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
|
||||
@@ -32,11 +32,7 @@ mod tests {
|
||||
))
|
||||
.expect("failed to load simple_text.json");
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace)
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
let rig = TestRigBuilder::new().with_trace(trace).build().await;
|
||||
|
||||
rig.send_message("hello").await;
|
||||
let _responses = rig.wait_for_responses(1, Duration::from_secs(10)).await;
|
||||
@@ -99,11 +95,7 @@ mod tests {
|
||||
))
|
||||
.expect("failed to load file_write_read.json");
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace)
|
||||
.with_auto_approve_tools(true)
|
||||
.build()
|
||||
.await;
|
||||
let rig = TestRigBuilder::new().with_trace(trace).build().await;
|
||||
|
||||
rig.send_message("Please write a greeting to a file and read it back.")
|
||||
.await;
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
//! E2E regression test: forged thread IDs must not cross user boundaries.
|
||||
//!
|
||||
//! Demonstrates that a client cannot provide another user's conversation UUID
|
||||
//! and get that history hydrated into prompt context or written into.
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
mod support;
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use ironclaw::channels::{IncomingMessage, OutgoingResponse};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::support::test_rig::TestRigBuilder;
|
||||
use crate::support::trace_llm::{LlmTrace, TraceResponse, TraceStep};
|
||||
|
||||
fn assert_safe_thread_rejection(response: &OutgoingResponse) {
|
||||
let msg = response.content.to_lowercase();
|
||||
assert!(
|
||||
msg.contains("thread") && (msg.contains("invalid") || msg.contains("unauthorized")),
|
||||
"expected safe thread-id rejection response, got: {}",
|
||||
response.content
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn forged_existing_foreign_thread_id_is_rejected_without_hydration_or_persistence() {
|
||||
let trace = LlmTrace::single_turn(
|
||||
"thread-id-isolation",
|
||||
"attacker turn",
|
||||
vec![TraceStep {
|
||||
request_hint: None,
|
||||
response: TraceResponse::Text {
|
||||
content: "safe response".to_string(),
|
||||
input_tokens: 12,
|
||||
output_tokens: 4,
|
||||
},
|
||||
expected_tool_results: Vec::new(),
|
||||
}],
|
||||
);
|
||||
|
||||
let rig = TestRigBuilder::new().with_trace(trace).build().await;
|
||||
|
||||
let foreign_thread_id = Uuid::new_v4();
|
||||
let marker = format!("FOREIGN-MARKER-{}", Uuid::new_v4());
|
||||
let store = rig.database();
|
||||
assert!(
|
||||
store
|
||||
.ensure_conversation(foreign_thread_id, "gateway", "victim-user", None)
|
||||
.await
|
||||
.expect("failed to create victim conversation"),
|
||||
"test setup failed: victim conversation was not created"
|
||||
);
|
||||
store
|
||||
.add_conversation_message(
|
||||
foreign_thread_id,
|
||||
"user",
|
||||
&format!("victim-only secret marker: {marker}"),
|
||||
)
|
||||
.await
|
||||
.expect("failed to seed victim conversation message");
|
||||
|
||||
let before_messages = store
|
||||
.list_conversation_messages(foreign_thread_id)
|
||||
.await
|
||||
.expect("failed to read victim conversation before forged send");
|
||||
assert!(
|
||||
before_messages.iter().any(|m| m.content.contains(&marker)),
|
||||
"test setup failed: victim marker message missing"
|
||||
);
|
||||
let before_len = before_messages.len();
|
||||
|
||||
let forged = IncomingMessage::new("test", "test-user", "attacker turn")
|
||||
.with_thread(foreign_thread_id.to_string());
|
||||
rig.send_incoming(forged).await;
|
||||
let responses = rig.wait_for_responses(1, Duration::from_secs(20)).await;
|
||||
assert_eq!(
|
||||
responses.len(),
|
||||
1,
|
||||
"expected one assistant response for forged-thread request"
|
||||
);
|
||||
assert_safe_thread_rejection(&responses[0]);
|
||||
|
||||
let captured = rig.captured_llm_requests();
|
||||
assert!(
|
||||
captured.is_empty(),
|
||||
"forged thread-id request should be rejected before any LLM call"
|
||||
);
|
||||
let prompt_dump = captured
|
||||
.iter()
|
||||
.flat_map(|req| req.iter().map(|m| m.content.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(
|
||||
!prompt_dump.contains(&marker),
|
||||
"forged thread_id leaked foreign marker into LLM prompt context: {prompt_dump}"
|
||||
);
|
||||
|
||||
let after_messages = store
|
||||
.list_conversation_messages(foreign_thread_id)
|
||||
.await
|
||||
.expect("failed to read victim conversation after forged send");
|
||||
assert_eq!(
|
||||
after_messages.len(),
|
||||
before_len,
|
||||
"forged thread_id wrote new messages into victim conversation"
|
||||
);
|
||||
assert!(
|
||||
after_messages
|
||||
.iter()
|
||||
.all(|m| m.content != "attacker turn" && m.content != "safe response"),
|
||||
"forged request content was persisted to victim conversation"
|
||||
);
|
||||
|
||||
rig.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn forged_nonexistent_thread_id_is_rejected_and_followup_request_still_works() {
|
||||
let trace = LlmTrace::single_turn(
|
||||
"thread-id-isolation-nonexistent",
|
||||
"real follow-up turn",
|
||||
vec![TraceStep {
|
||||
request_hint: None,
|
||||
response: TraceResponse::Text {
|
||||
content: "safe response".to_string(),
|
||||
input_tokens: 12,
|
||||
output_tokens: 4,
|
||||
},
|
||||
expected_tool_results: Vec::new(),
|
||||
}],
|
||||
);
|
||||
|
||||
let rig = TestRigBuilder::new().with_trace(trace).build().await;
|
||||
|
||||
let forged_thread_id = Uuid::new_v4();
|
||||
let store = rig.database();
|
||||
|
||||
let forged = IncomingMessage::new("test", "test-user", "attacker turn")
|
||||
.with_thread(forged_thread_id.to_string());
|
||||
rig.send_incoming(forged).await;
|
||||
let responses = rig.wait_for_responses(1, Duration::from_secs(20)).await;
|
||||
assert_eq!(
|
||||
responses.len(),
|
||||
1,
|
||||
"expected one response for forged nonexistent-thread request"
|
||||
);
|
||||
assert_safe_thread_rejection(&responses[0]);
|
||||
assert!(
|
||||
rig.captured_llm_requests().is_empty(),
|
||||
"forged nonexistent thread-id request should be rejected before any LLM call"
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get_conversation_metadata(forged_thread_id)
|
||||
.await
|
||||
.expect("get metadata for forged thread id")
|
||||
.is_none(),
|
||||
"forged nonexistent thread id must not create a conversation row"
|
||||
);
|
||||
|
||||
rig.send_message("real follow-up turn").await;
|
||||
let responses = rig.wait_for_responses(2, Duration::from_secs(20)).await;
|
||||
assert_eq!(
|
||||
responses.len(),
|
||||
2,
|
||||
"expected follow-up response after rejection"
|
||||
);
|
||||
assert_eq!(
|
||||
responses[1].content, "safe response",
|
||||
"follow-up valid request should still be handled normally"
|
||||
);
|
||||
assert_eq!(
|
||||
rig.captured_llm_requests().len(),
|
||||
1,
|
||||
"only follow-up request should reach LLM"
|
||||
);
|
||||
|
||||
rig.shutdown();
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
//! Integration tests for OpenClaw import functionality.
|
||||
|
||||
#![cfg(feature = "import")]
|
||||
|
||||
#[cfg(feature = "import")]
|
||||
mod import_tests {
|
||||
use ironclaw::import::openclaw::reader::{OpenClawConfig, OpenClawMemoryChunk};
|
||||
use ironclaw::import::{ImportError, ImportStats};
|
||||
|
||||
#[test]
|
||||
fn test_import_stats_is_empty() {
|
||||
let stats = ImportStats::default();
|
||||
assert!(stats.is_empty());
|
||||
assert_eq!(stats.total_imported(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_stats_total_imported() {
|
||||
let stats = ImportStats {
|
||||
documents: 5,
|
||||
chunks: 10,
|
||||
conversations: 2,
|
||||
messages: 50,
|
||||
settings: 3,
|
||||
secrets: 1,
|
||||
..ImportStats::default()
|
||||
};
|
||||
|
||||
assert!(!stats.is_empty());
|
||||
assert_eq!(stats.total_imported(), 71);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_error_display() {
|
||||
let err = ImportError::ConfigParse("test error".to_string());
|
||||
assert_eq!(err.to_string(), "JSON5 parse error: test error");
|
||||
|
||||
let err = ImportError::Database("db error".to_string());
|
||||
assert_eq!(err.to_string(), "Database error: db error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openclaw_config_construction() {
|
||||
let config = OpenClawConfig {
|
||||
llm: None,
|
||||
embeddings: None,
|
||||
other_settings: std::collections::HashMap::new(),
|
||||
};
|
||||
|
||||
assert!(config.llm.is_none());
|
||||
assert!(config.embeddings.is_none());
|
||||
assert!(config.other_settings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_memory_chunk_construction() {
|
||||
let chunk = OpenClawMemoryChunk {
|
||||
path: "test/doc.md".to_string(),
|
||||
content: "Test content".to_string(),
|
||||
embedding: Some(vec![0.1, 0.2, 0.3]),
|
||||
chunk_index: 0,
|
||||
};
|
||||
|
||||
assert_eq!(chunk.path, "test/doc.md");
|
||||
assert_eq!(chunk.content, "Test content");
|
||||
assert!(chunk.embedding.is_some());
|
||||
assert_eq!(chunk.chunk_index, 0);
|
||||
}
|
||||
}
|
||||
@@ -1,442 +0,0 @@
|
||||
//! Comprehensive end-to-end tests for OpenClaw import with synthetic test data.
|
||||
|
||||
#![cfg(feature = "import")]
|
||||
|
||||
#[cfg(feature = "import")]
|
||||
mod comprehensive_import_tests {
|
||||
use std::path::{Path, PathBuf};
|
||||
use tempfile::TempDir;
|
||||
use uuid::Uuid;
|
||||
|
||||
use ironclaw::import::openclaw::reader::OpenClawReader;
|
||||
use ironclaw::import::{ImportError, ImportOptions};
|
||||
|
||||
/// Helper to create a minimal synthetic OpenClaw directory structure
|
||||
fn create_synthetic_openclaw_dir() -> Result<(TempDir, PathBuf), Box<dyn std::error::Error>> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
// Create openclaw.json
|
||||
let config_content = r#"{
|
||||
llm: {
|
||||
provider: "openai",
|
||||
model: "gpt-4",
|
||||
api_key: "sk-test-key-123",
|
||||
base_url: "https://api.openai.com/v1"
|
||||
},
|
||||
embeddings: {
|
||||
model: "text-embedding-3-small",
|
||||
provider: "openai",
|
||||
api_key: "sk-test-embed-456"
|
||||
}
|
||||
}"#;
|
||||
std::fs::write(openclaw_path.join("openclaw.json"), config_content)?;
|
||||
|
||||
// Create workspace directory with Markdown files
|
||||
let workspace_dir = openclaw_path.join("workspace");
|
||||
std::fs::create_dir_all(&workspace_dir)?;
|
||||
|
||||
let memory_content =
|
||||
"# Memory\n\nThis is a test memory document.\n\n## Section 1\nSome content here.";
|
||||
std::fs::write(workspace_dir.join("MEMORY.md"), memory_content)?;
|
||||
|
||||
let readme_content = "# README\n\nTest workspace README with important notes.";
|
||||
std::fs::write(workspace_dir.join("README.md"), readme_content)?;
|
||||
|
||||
Ok((temp_dir, openclaw_path))
|
||||
}
|
||||
|
||||
/// Helper to create a synthetic SQLite database with memory chunks
|
||||
async fn create_synthetic_memory_db(
|
||||
agents_dir: &Path,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
std::fs::create_dir_all(agents_dir)?;
|
||||
let db_path = agents_dir.join("test_agent.sqlite");
|
||||
|
||||
let db = libsql::Builder::new_local(&db_path).build().await?;
|
||||
let conn = db.connect()?;
|
||||
|
||||
// Create chunks table (simplified schema)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS chunks (
|
||||
id TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
embedding BLOB,
|
||||
chunk_index INTEGER NOT NULL
|
||||
)",
|
||||
(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Insert test chunks
|
||||
conn.execute(
|
||||
"INSERT INTO chunks (id, path, content, embedding, chunk_index)
|
||||
VALUES (?, ?, ?, ?, ?)",
|
||||
libsql::params![
|
||||
Uuid::new_v4().to_string(),
|
||||
"test/doc.md",
|
||||
"This is test chunk 1 content.",
|
||||
libsql::Value::Null,
|
||||
0i64
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO chunks (id, path, content, embedding, chunk_index)
|
||||
VALUES (?, ?, ?, ?, ?)",
|
||||
libsql::params![
|
||||
Uuid::new_v4().to_string(),
|
||||
"test/doc.md",
|
||||
"This is test chunk 2 content.",
|
||||
libsql::Value::Null,
|
||||
1i64
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Create conversation table
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS conversations (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel TEXT NOT NULL,
|
||||
created_at TEXT
|
||||
)",
|
||||
(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Create messages table
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT,
|
||||
FOREIGN KEY(conversation_id) REFERENCES conversations(id)
|
||||
)",
|
||||
(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Insert test conversation
|
||||
let conv_id = Uuid::new_v4().to_string();
|
||||
conn.execute(
|
||||
"INSERT INTO conversations (id, channel, created_at) VALUES (?, ?, ?)",
|
||||
libsql::params![conv_id.clone(), "telegram", "2024-01-15T10:30:00Z"],
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Insert test messages
|
||||
conn.execute(
|
||||
"INSERT INTO messages (id, conversation_id, role, content, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)",
|
||||
libsql::params![
|
||||
Uuid::new_v4().to_string(),
|
||||
conv_id.clone(),
|
||||
"user",
|
||||
"Hello, how are you?",
|
||||
"2024-01-15T10:30:00Z"
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO messages (id, conversation_id, role, content, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)",
|
||||
libsql::params![
|
||||
Uuid::new_v4().to_string(),
|
||||
conv_id.clone(),
|
||||
"assistant",
|
||||
"I'm doing well, thank you for asking!",
|
||||
"2024-01-15T10:31:00Z"
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(db_path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openclaw_reader_detects_config() {
|
||||
let (temp_dir, openclaw_path) =
|
||||
create_synthetic_openclaw_dir().expect("failed to create test data");
|
||||
|
||||
// Verify detection works
|
||||
assert!(openclaw_path.join("openclaw.json").exists());
|
||||
|
||||
// Create reader
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("failed to create reader");
|
||||
|
||||
let _ = (temp_dir, reader);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openclaw_reader_parses_config() {
|
||||
let (temp_dir, openclaw_path) =
|
||||
create_synthetic_openclaw_dir().expect("failed to create test data");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("failed to create reader");
|
||||
|
||||
let config = reader.read_config().expect("failed to read config");
|
||||
|
||||
// Verify LLM config
|
||||
assert!(config.llm.is_some());
|
||||
let llm = config.llm.unwrap();
|
||||
assert_eq!(llm.provider, Some("openai".to_string()));
|
||||
assert_eq!(llm.model, Some("gpt-4".to_string()));
|
||||
// API key is wrapped in SecretString, just verify it's present
|
||||
assert!(llm.api_key.is_some());
|
||||
|
||||
// Verify embeddings config
|
||||
assert!(config.embeddings.is_some());
|
||||
let emb = config.embeddings.unwrap();
|
||||
assert_eq!(emb.provider, Some("openai".to_string()));
|
||||
assert_eq!(emb.model, Some("text-embedding-3-small".to_string()));
|
||||
// API key is wrapped in SecretString, just verify it's present
|
||||
assert!(emb.api_key.is_some());
|
||||
|
||||
let _ = temp_dir;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openclaw_reader_lists_workspace_files() {
|
||||
let (temp_dir, openclaw_path) =
|
||||
create_synthetic_openclaw_dir().expect("failed to create test data");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("failed to create reader");
|
||||
|
||||
let count = reader
|
||||
.list_workspace_files()
|
||||
.expect("failed to list workspace files");
|
||||
|
||||
// Should find MEMORY.md and README.md
|
||||
assert_eq!(count, 2);
|
||||
|
||||
let _ = temp_dir;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_openclaw_reader_lists_agent_dbs() {
|
||||
let (temp_dir, openclaw_path) =
|
||||
create_synthetic_openclaw_dir().expect("failed to create test data");
|
||||
|
||||
let agents_dir = openclaw_path.join("agents");
|
||||
let _db_path = create_synthetic_memory_db(&agents_dir)
|
||||
.await
|
||||
.expect("failed to create test DB");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("failed to create reader");
|
||||
|
||||
let dbs = reader.list_agent_dbs().expect("failed to list agent DBs");
|
||||
|
||||
// Should find test_agent.sqlite
|
||||
assert_eq!(dbs.len(), 1);
|
||||
assert_eq!(dbs[0].0, "test_agent");
|
||||
|
||||
let _ = temp_dir;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_openclaw_reader_reads_memory_chunks() {
|
||||
let (temp_dir, openclaw_path) =
|
||||
create_synthetic_openclaw_dir().expect("failed to create test data");
|
||||
|
||||
let agents_dir = openclaw_path.join("agents");
|
||||
let db_path = create_synthetic_memory_db(&agents_dir)
|
||||
.await
|
||||
.expect("failed to create test DB");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("failed to create reader");
|
||||
|
||||
let chunks = reader
|
||||
.read_memory_chunks(&db_path)
|
||||
.await
|
||||
.expect("failed to read memory chunks");
|
||||
|
||||
// Should find 2 chunks
|
||||
assert_eq!(chunks.len(), 2);
|
||||
|
||||
// Verify chunk content
|
||||
assert_eq!(chunks[0].path, "test/doc.md");
|
||||
assert_eq!(chunks[0].content, "This is test chunk 1 content.");
|
||||
assert_eq!(chunks[0].chunk_index, 0);
|
||||
assert!(chunks[0].embedding.is_none());
|
||||
|
||||
assert_eq!(chunks[1].path, "test/doc.md");
|
||||
assert_eq!(chunks[1].content, "This is test chunk 2 content.");
|
||||
assert_eq!(chunks[1].chunk_index, 1);
|
||||
|
||||
let _ = temp_dir;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_openclaw_reader_reads_conversations() {
|
||||
let (temp_dir, openclaw_path) =
|
||||
create_synthetic_openclaw_dir().expect("failed to create test data");
|
||||
|
||||
let agents_dir = openclaw_path.join("agents");
|
||||
let db_path = create_synthetic_memory_db(&agents_dir)
|
||||
.await
|
||||
.expect("failed to create test DB");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("failed to create reader");
|
||||
|
||||
let conversations = reader
|
||||
.read_conversations(&db_path)
|
||||
.await
|
||||
.expect("failed to read conversations");
|
||||
|
||||
// Should find 1 conversation
|
||||
assert_eq!(conversations.len(), 1);
|
||||
|
||||
let conv = &conversations[0];
|
||||
assert_eq!(conv.channel, "telegram");
|
||||
assert_eq!(conv.messages.len(), 2);
|
||||
|
||||
// Verify messages
|
||||
assert_eq!(conv.messages[0].role, "user");
|
||||
assert_eq!(conv.messages[0].content, "Hello, how are you?");
|
||||
assert_eq!(conv.messages[1].role, "assistant");
|
||||
assert_eq!(
|
||||
conv.messages[1].content,
|
||||
"I'm doing well, thank you for asking!"
|
||||
);
|
||||
|
||||
let _ = temp_dir;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openclaw_reader_handles_missing_directory() {
|
||||
let missing_path = PathBuf::from("/nonexistent/openclaw");
|
||||
let result = OpenClawReader::new(&missing_path);
|
||||
|
||||
assert!(result.is_err());
|
||||
match result {
|
||||
Err(ImportError::NotFound { .. }) => (), // Expected
|
||||
_ => panic!("Expected NotFound error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openclaw_reader_handles_missing_config() {
|
||||
let temp_dir = TempDir::new().expect("failed to create temp dir");
|
||||
let reader = OpenClawReader::new(temp_dir.path()).expect("failed to create reader");
|
||||
|
||||
let result = reader.read_config();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_options_construction() {
|
||||
let opts = ImportOptions {
|
||||
openclaw_path: PathBuf::from("/test/openclaw"),
|
||||
dry_run: true,
|
||||
re_embed: false,
|
||||
user_id: "test_user".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(opts.user_id, "test_user");
|
||||
assert!(opts.dry_run);
|
||||
assert!(!opts.re_embed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openclaw_reader_empty_agents_directory() {
|
||||
let (temp_dir, openclaw_path) =
|
||||
create_synthetic_openclaw_dir().expect("failed to create test data");
|
||||
|
||||
// Create empty agents directory
|
||||
std::fs::create_dir(openclaw_path.join("agents")).expect("failed to create agents dir");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("failed to create reader");
|
||||
|
||||
let dbs = reader.list_agent_dbs().expect("failed to list agent DBs");
|
||||
|
||||
// Should find no databases
|
||||
assert_eq!(dbs.len(), 0);
|
||||
|
||||
let _ = temp_dir;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openclaw_reader_no_workspace_files() {
|
||||
let temp_dir = TempDir::new().expect("failed to create temp dir");
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
// Create config
|
||||
let config_content = r#"{ llm: { provider: "openai" } }"#;
|
||||
std::fs::write(openclaw_path.join("openclaw.json"), config_content)
|
||||
.expect("failed to write config");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("failed to create reader");
|
||||
|
||||
let count = reader
|
||||
.list_workspace_files()
|
||||
.expect("failed to list workspace files");
|
||||
|
||||
// Should find no files
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openclaw_reader_malformed_json5() {
|
||||
let temp_dir = TempDir::new().expect("failed to create temp dir");
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
// Create malformed config
|
||||
let bad_config = r#"{ llm: { provider: "openai" }"#; // Missing closing brace
|
||||
std::fs::write(openclaw_path.join("openclaw.json"), bad_config)
|
||||
.expect("failed to write config");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("failed to create reader");
|
||||
|
||||
let result = reader.read_config();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openclaw_detect_existing() {
|
||||
let (temp_dir, openclaw_path) =
|
||||
create_synthetic_openclaw_dir().expect("failed to create test data");
|
||||
|
||||
// Verify the openclaw.json config exists (which is what detect() checks for)
|
||||
assert!(openclaw_path.join("openclaw.json").exists());
|
||||
|
||||
let _ = temp_dir;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_stats_aggregation() {
|
||||
let stats = ironclaw::import::ImportStats {
|
||||
documents: 5,
|
||||
chunks: 10,
|
||||
conversations: 3,
|
||||
messages: 25,
|
||||
settings: 2,
|
||||
secrets: 1,
|
||||
skipped: 2,
|
||||
re_embed_queued: 1,
|
||||
};
|
||||
|
||||
assert_eq!(stats.total_imported(), 46); // All except skipped
|
||||
assert!(!stats.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_error_variants() {
|
||||
let err1 = ImportError::ConfigParse("test".to_string());
|
||||
assert_eq!(err1.to_string(), "JSON5 parse error: test");
|
||||
|
||||
let err2 = ImportError::Database("db failed".to_string());
|
||||
assert_eq!(err2.to_string(), "Database error: db failed");
|
||||
|
||||
let err3 = ImportError::Sqlite("sqlite error".to_string());
|
||||
assert_eq!(err3.to_string(), "SQLite error: sqlite error");
|
||||
|
||||
let err4 = ImportError::Workspace("workspace error".to_string());
|
||||
assert_eq!(err4.to_string(), "Workspace error: workspace error");
|
||||
}
|
||||
}
|
||||
@@ -1,490 +0,0 @@
|
||||
//! End-to-end integration tests for OpenClaw importer with actual import execution.
|
||||
//!
|
||||
//! These tests verify the complete import pipeline: configuration, settings,
|
||||
//! credentials, memory chunks, workspace documents, and conversations.
|
||||
|
||||
#![cfg(feature = "import")]
|
||||
|
||||
#[cfg(feature = "import")]
|
||||
mod e2e_import_tests {
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
use uuid::Uuid;
|
||||
|
||||
use ironclaw::import::openclaw::reader::OpenClawReader;
|
||||
use ironclaw::import::openclaw::settings;
|
||||
use ironclaw::import::{ImportOptions, ImportStats};
|
||||
|
||||
/// Helper: Create a synthetic OpenClaw with full structure
|
||||
async fn setup_full_openclaw_test_env() -> Result<(TempDir, PathBuf), Box<dyn std::error::Error>>
|
||||
{
|
||||
let temp_dir = TempDir::new()?;
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
// 1. Create openclaw.json with all settings
|
||||
let config_content = r#"{
|
||||
llm: {
|
||||
provider: "openai",
|
||||
model: "gpt-4-turbo",
|
||||
api_key: "sk-test-key-12345",
|
||||
base_url: "https://api.openai.com/v1"
|
||||
},
|
||||
embeddings: {
|
||||
model: "text-embedding-3-large",
|
||||
provider: "openai",
|
||||
api_key: "sk-embed-key-67890"
|
||||
},
|
||||
custom_setting: "custom_value"
|
||||
}"#;
|
||||
std::fs::write(openclaw_path.join("openclaw.json"), config_content)?;
|
||||
|
||||
// 2. Create workspace with multiple files
|
||||
let workspace_dir = openclaw_path.join("workspace");
|
||||
std::fs::create_dir_all(&workspace_dir)?;
|
||||
|
||||
std::fs::write(
|
||||
workspace_dir.join("MEMORY.md"),
|
||||
"# Memory\n\nStored memories and facts.\n\n- User prefers morning briefings\n- Key project: Alpha",
|
||||
)?;
|
||||
|
||||
std::fs::write(
|
||||
workspace_dir.join("README.md"),
|
||||
"# Project README\n\nThis is the main project documentation.\n\n## Goals\n1. Complete migration\n2. Verify data",
|
||||
)?;
|
||||
|
||||
std::fs::write(
|
||||
workspace_dir.join("AGENTS.md"),
|
||||
"# Agent Definitions\n\n## Main Agent\n- Role: Assistant\n- Capabilities: Analysis, Planning",
|
||||
)?;
|
||||
|
||||
// 3. Create agents directory with databases
|
||||
let agents_dir = openclaw_path.join("agents");
|
||||
std::fs::create_dir_all(&agents_dir)?;
|
||||
|
||||
create_full_agent_db(&agents_dir.join("primary_agent.sqlite")).await?;
|
||||
create_full_agent_db(&agents_dir.join("secondary_agent.sqlite")).await?;
|
||||
|
||||
Ok((temp_dir, openclaw_path))
|
||||
}
|
||||
|
||||
/// Helper: Create a full agent SQLite database with chunks and conversations
|
||||
async fn create_full_agent_db(db_path: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let db = libsql::Builder::new_local(db_path).build().await?;
|
||||
let conn = db.connect()?;
|
||||
|
||||
// Chunks table
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS chunks (
|
||||
id TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
embedding BLOB,
|
||||
chunk_index INTEGER NOT NULL
|
||||
)",
|
||||
(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Insert 5 chunks
|
||||
for i in 0..5 {
|
||||
conn.execute(
|
||||
"INSERT INTO chunks (id, path, content, embedding, chunk_index)
|
||||
VALUES (?, ?, ?, ?, ?)",
|
||||
libsql::params![
|
||||
Uuid::new_v4().to_string(),
|
||||
format!("notes/section_{}.md", i),
|
||||
format!("Content for section {}. This is important information.", i),
|
||||
libsql::Value::Null,
|
||||
i as i64
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Conversations table
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS conversations (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel TEXT NOT NULL,
|
||||
created_at TEXT
|
||||
)",
|
||||
(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Messages table
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT,
|
||||
FOREIGN KEY(conversation_id) REFERENCES conversations(id)
|
||||
)",
|
||||
(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Insert 3 conversations with messages
|
||||
for conv_num in 0..3 {
|
||||
let conv_id = Uuid::new_v4().to_string();
|
||||
let channel = match conv_num {
|
||||
0 => "telegram",
|
||||
1 => "slack",
|
||||
_ => "discord",
|
||||
};
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO conversations (id, channel, created_at) VALUES (?, ?, ?)",
|
||||
libsql::params![
|
||||
conv_id.clone(),
|
||||
channel,
|
||||
format!("2024-01-{:02}T10:00:00Z", 10 + conv_num)
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Add 3 messages per conversation
|
||||
for msg_num in 0..3 {
|
||||
let role = if msg_num % 2 == 0 {
|
||||
"user"
|
||||
} else {
|
||||
"assistant"
|
||||
};
|
||||
conn.execute(
|
||||
"INSERT INTO messages (id, conversation_id, role, content, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)",
|
||||
libsql::params![
|
||||
Uuid::new_v4().to_string(),
|
||||
conv_id.clone(),
|
||||
role,
|
||||
format!(
|
||||
"{} message {} from conversation {}",
|
||||
role, msg_num, conv_num
|
||||
),
|
||||
format!("2024-01-{:02}T10:{:02}:00Z", 10 + conv_num, msg_num * 10)
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Configuration & Settings Tests
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_full_config_extraction() {
|
||||
let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
let config = reader.read_config().expect("config read failed");
|
||||
|
||||
// Verify LLM config
|
||||
assert_eq!(
|
||||
config.llm.as_ref().map(|c| c.provider.clone()),
|
||||
Some(Some("openai".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
config.llm.as_ref().map(|c| c.model.clone()),
|
||||
Some(Some("gpt-4-turbo".to_string()))
|
||||
);
|
||||
|
||||
// Verify embeddings config
|
||||
assert_eq!(
|
||||
config.embeddings.as_ref().map(|c| c.model.clone()),
|
||||
Some(Some("text-embedding-3-large".to_string()))
|
||||
);
|
||||
|
||||
// Verify custom settings preserved
|
||||
assert!(config.other_settings.contains_key("custom_setting"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_settings_mapping_to_ironclaw_format() {
|
||||
let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
let config = reader.read_config().expect("config read failed");
|
||||
|
||||
let settings_map = settings::map_openclaw_config_to_settings(&config);
|
||||
|
||||
// Verify key mappings
|
||||
assert!(settings_map.contains_key("llm.backend"));
|
||||
assert!(settings_map.contains_key("llm.selected_model"));
|
||||
assert!(settings_map.contains_key("embeddings.model"));
|
||||
assert!(settings_map.contains_key("custom_setting"));
|
||||
|
||||
// Verify values
|
||||
assert_eq!(
|
||||
settings_map.get("llm.backend").and_then(|v| v.as_str()),
|
||||
Some("openai")
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Credential Extraction Tests
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_credentials_extraction() {
|
||||
let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
let config = reader.read_config().expect("config read failed");
|
||||
|
||||
let creds = settings::extract_credentials(&config);
|
||||
|
||||
// Should extract 2 credentials (llm_api_key + embeddings_api_key)
|
||||
assert_eq!(creds.len(), 2);
|
||||
|
||||
// Verify names (order may vary, so check both are present)
|
||||
let names: Vec<_> = creds.iter().map(|(name, _)| name).collect();
|
||||
assert!(names.contains(&&"llm_api_key".to_string()));
|
||||
assert!(names.contains(&&"embeddings_api_key".to_string()));
|
||||
|
||||
// Verify credentials are wrapped in SecretString (not exposed in debug)
|
||||
for (_name, secret) in creds {
|
||||
let debug_str = format!("{:?}", secret);
|
||||
assert!(!debug_str.contains("sk-test-key"));
|
||||
assert!(!debug_str.contains("sk-embed-key"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_credentials_never_logged() {
|
||||
let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
let config = reader.read_config().expect("config read failed");
|
||||
|
||||
let creds = settings::extract_credentials(&config);
|
||||
|
||||
// Verify actual secrets are not exposed
|
||||
for (_name, secret) in creds {
|
||||
let secret_debug = format!("{:?}", secret);
|
||||
// Should NOT contain the actual API keys
|
||||
assert!(!secret_debug.contains("sk-test-key-12345"));
|
||||
assert!(!secret_debug.contains("sk-embed-key-67890"));
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Data Volume Tests
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_full_workspace_import_counts() {
|
||||
let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
|
||||
// Count workspace files
|
||||
let workspace_count = reader
|
||||
.list_workspace_files()
|
||||
.expect("list workspace files failed");
|
||||
assert_eq!(workspace_count, 3); // MEMORY.md, README.md, AGENTS.md
|
||||
|
||||
// Count agent databases
|
||||
let agent_dbs = reader.list_agent_dbs().expect("list agent dbs failed");
|
||||
assert_eq!(agent_dbs.len(), 2); // primary + secondary
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_full_memory_chunks_import() {
|
||||
let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
let agent_dbs = reader.list_agent_dbs().expect("list agent dbs failed");
|
||||
|
||||
// Each agent should have 5 chunks
|
||||
for (_name, db_path) in agent_dbs {
|
||||
let chunks = reader
|
||||
.read_memory_chunks(&db_path)
|
||||
.await
|
||||
.expect("read memory chunks failed");
|
||||
assert_eq!(chunks.len(), 5);
|
||||
|
||||
// Verify chunk structure
|
||||
for (i, chunk) in chunks.iter().enumerate() {
|
||||
assert_eq!(chunk.chunk_index, i as i32);
|
||||
assert!(
|
||||
chunk
|
||||
.content
|
||||
.contains(&format!("Content for section {}", i))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_full_conversations_import() {
|
||||
let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
let agent_dbs = reader.list_agent_dbs().expect("list agent dbs failed");
|
||||
|
||||
// Each agent should have 3 conversations
|
||||
for (_name, db_path) in agent_dbs {
|
||||
let conversations = reader
|
||||
.read_conversations(&db_path)
|
||||
.await
|
||||
.expect("read conversations failed");
|
||||
assert_eq!(conversations.len(), 3);
|
||||
|
||||
// Verify each conversation has messages
|
||||
for conv in conversations {
|
||||
assert_eq!(conv.messages.len(), 3); // Each has 3 messages
|
||||
assert!(!conv.channel.is_empty());
|
||||
|
||||
// Verify message roles
|
||||
let roles: Vec<_> = conv.messages.iter().map(|m| m.role.as_str()).collect();
|
||||
assert!(roles.contains(&"user"));
|
||||
assert!(roles.contains(&"assistant"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Import Stats Verification
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_import_options_validation() {
|
||||
let opts = ImportOptions {
|
||||
openclaw_path: PathBuf::from("/test/openclaw"),
|
||||
dry_run: true,
|
||||
re_embed: true,
|
||||
user_id: "test_user".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(opts.user_id, "test_user");
|
||||
assert!(opts.dry_run);
|
||||
assert!(opts.re_embed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_stats_calculations() {
|
||||
// Simulating a full import scenario
|
||||
let stats = ImportStats {
|
||||
// Workspace: 3 files
|
||||
documents: 3,
|
||||
// Memory: 2 agents × 5 chunks each = 10 chunks
|
||||
chunks: 10,
|
||||
// Conversations: 2 agents × 3 conversations = 6 conversations
|
||||
conversations: 6,
|
||||
// Messages: 2 agents × 3 conversations × 3 messages = 18 messages
|
||||
messages: 18,
|
||||
// Settings: LLM config + embeddings + custom = 3
|
||||
settings: 3,
|
||||
// Credentials: api_key + embeddings_key = 2
|
||||
secrets: 2,
|
||||
..ImportStats::default()
|
||||
};
|
||||
|
||||
let total = stats.total_imported();
|
||||
assert_eq!(total, 3 + 10 + 6 + 18 + 3 + 2);
|
||||
assert!(!stats.is_empty());
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Error Handling Tests
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_error_on_corrupt_sqlite() {
|
||||
let temp_dir = TempDir::new().expect("temp dir creation failed");
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
// Create agents dir with corrupt SQLite file
|
||||
let agents_dir = openclaw_path.join("agents");
|
||||
std::fs::create_dir_all(&agents_dir).expect("agents dir creation failed");
|
||||
|
||||
// Write garbage data as "SQLite"
|
||||
std::fs::write(
|
||||
agents_dir.join("corrupt.sqlite"),
|
||||
"this is not a sqlite file",
|
||||
)
|
||||
.expect("write failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
|
||||
// Listing should succeed (file exists)
|
||||
let dbs = reader.list_agent_dbs().expect("list agent dbs failed");
|
||||
assert_eq!(dbs.len(), 1);
|
||||
|
||||
// But reading should fail
|
||||
let result = reader.read_memory_chunks(&dbs[0].1).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_graceful_handling_missing_agents_directory() {
|
||||
let temp_dir = TempDir::new().expect("temp dir creation failed");
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
// Create config but no agents directory
|
||||
std::fs::write(
|
||||
openclaw_path.join("openclaw.json"),
|
||||
r#"{ llm: { provider: "openai" } }"#,
|
||||
)
|
||||
.expect("write failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
|
||||
// Should return empty list, not error
|
||||
let dbs = reader.list_agent_dbs().expect("list agent dbs failed");
|
||||
assert_eq!(dbs.len(), 0);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Extensibility Tests
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_multiple_agents_independent_data() {
|
||||
let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
let agent_dbs = reader.list_agent_dbs().expect("list agent dbs failed");
|
||||
|
||||
// Verify each agent has independent data
|
||||
assert_eq!(agent_dbs.len(), 2);
|
||||
assert_eq!(agent_dbs[0].0, "primary_agent");
|
||||
assert_eq!(agent_dbs[1].0, "secondary_agent");
|
||||
|
||||
// Each should have its own chunks
|
||||
for (_name, db_path) in &agent_dbs {
|
||||
let chunks = reader
|
||||
.read_memory_chunks(db_path)
|
||||
.await
|
||||
.expect("read chunks failed");
|
||||
assert_eq!(chunks.len(), 5);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_channel_diversity_in_conversations() {
|
||||
let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
let agent_dbs = reader.list_agent_dbs().expect("list agent dbs failed");
|
||||
|
||||
// Get conversations from first agent
|
||||
let conversations = reader
|
||||
.read_conversations(&agent_dbs[0].1)
|
||||
.await
|
||||
.expect("read conversations failed");
|
||||
|
||||
// Should have different channels
|
||||
let channels: std::collections::HashSet<_> =
|
||||
conversations.iter().map(|c| c.channel.as_str()).collect();
|
||||
assert!(channels.contains("telegram"));
|
||||
assert!(channels.contains("slack"));
|
||||
assert!(channels.contains("discord"));
|
||||
}
|
||||
}
|
||||
@@ -1,473 +0,0 @@
|
||||
//! Error handling and edge case tests for OpenClaw import.
|
||||
//!
|
||||
//! These tests verify proper error handling for:
|
||||
//! - Missing/corrupt files
|
||||
//! - Invalid configurations
|
||||
//! - Database corruption
|
||||
//! - Permission issues
|
||||
//! - Edge cases in data
|
||||
|
||||
#![cfg(feature = "import")]
|
||||
|
||||
#[cfg(feature = "import")]
|
||||
mod error_handling_tests {
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use ironclaw::import::ImportError;
|
||||
use ironclaw::import::openclaw::reader::OpenClawReader;
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Missing Directory Tests
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_error_nonexistent_openclaw_directory() {
|
||||
let nonexistent = PathBuf::from("/nonexistent/path/openclaw");
|
||||
let result = OpenClawReader::new(&nonexistent);
|
||||
|
||||
assert!(result.is_err());
|
||||
if let Err(e) = result {
|
||||
match e {
|
||||
ImportError::NotFound { .. } => (), // Expected
|
||||
_ => panic!("Expected NotFound, got: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_empty_openclaw_directory() {
|
||||
let temp_dir = TempDir::new().expect("temp dir creation failed");
|
||||
let result = OpenClawReader::new(temp_dir.path());
|
||||
|
||||
// Should succeed (directory exists)
|
||||
assert!(result.is_ok());
|
||||
|
||||
let reader = result.unwrap();
|
||||
let config_result = reader.read_config();
|
||||
|
||||
// But reading config should fail
|
||||
assert!(config_result.is_err());
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Config File Errors
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_error_missing_openclaw_json() {
|
||||
let temp_dir = TempDir::new().expect("temp dir creation failed");
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
|
||||
let result = reader.read_config();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_invalid_json5_syntax() {
|
||||
let temp_dir = TempDir::new().expect("temp dir creation failed");
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
// Invalid JSON5: missing closing brace
|
||||
let bad_config = r#"{ llm: { provider: "openai" }"#;
|
||||
std::fs::write(openclaw_path.join("openclaw.json"), bad_config).expect("write failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
|
||||
let result = reader.read_config();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_truncated_json5() {
|
||||
let temp_dir = TempDir::new().expect("temp dir creation failed");
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
// Truncated JSON5
|
||||
std::fs::write(openclaw_path.join("openclaw.json"), "{").expect("write failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
|
||||
let result = reader.read_config();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_empty_openclaw_json() {
|
||||
let temp_dir = TempDir::new().expect("temp dir creation failed");
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
// Empty file
|
||||
std::fs::write(openclaw_path.join("openclaw.json"), "").expect("write failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
|
||||
let result = reader.read_config();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// SQLite Database Errors
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_error_corrupt_sqlite_file() {
|
||||
let temp_dir = TempDir::new().expect("temp dir creation failed");
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
let agents_dir = openclaw_path.join("agents");
|
||||
std::fs::create_dir_all(&agents_dir).expect("mkdir failed");
|
||||
|
||||
// Write invalid SQLite data
|
||||
std::fs::write(
|
||||
agents_dir.join("bad.sqlite"),
|
||||
"this is definitely not a sqlite database",
|
||||
)
|
||||
.expect("write failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
|
||||
let dbs = reader.list_agent_dbs().expect("list agent dbs failed");
|
||||
assert_eq!(dbs.len(), 1);
|
||||
|
||||
// But reading should fail
|
||||
let result = reader.read_memory_chunks(&dbs[0].1).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_error_missing_chunks_table() {
|
||||
let temp_dir = TempDir::new().expect("temp dir creation failed");
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
let agents_dir = openclaw_path.join("agents");
|
||||
std::fs::create_dir_all(&agents_dir).expect("mkdir failed");
|
||||
|
||||
let db_path = agents_dir.join("no_chunks.sqlite");
|
||||
|
||||
// Create valid SQLite but without chunks table
|
||||
let db = libsql::Builder::new_local(&db_path)
|
||||
.build()
|
||||
.await
|
||||
.expect("db creation failed");
|
||||
let conn = db.connect().expect("connect failed");
|
||||
conn.execute(
|
||||
"CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT)",
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.expect("create table failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
|
||||
let dbs = reader.list_agent_dbs().expect("list agent dbs failed");
|
||||
assert_eq!(dbs.len(), 1);
|
||||
|
||||
// Should fail: chunks table doesn't exist
|
||||
let result = reader.read_memory_chunks(&dbs[0].1).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_error_missing_conversations_table() {
|
||||
let temp_dir = TempDir::new().expect("temp dir creation failed");
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
let agents_dir = openclaw_path.join("agents");
|
||||
std::fs::create_dir_all(&agents_dir).expect("mkdir failed");
|
||||
|
||||
let db_path = agents_dir.join("no_conversations.sqlite");
|
||||
|
||||
let db = libsql::Builder::new_local(&db_path)
|
||||
.build()
|
||||
.await
|
||||
.expect("db creation failed");
|
||||
let conn = db.connect().expect("connect failed");
|
||||
// Only create chunks table, not conversations
|
||||
conn.execute(
|
||||
"CREATE TABLE chunks (id TEXT, path TEXT, content TEXT, embedding BLOB, chunk_index INTEGER)",
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.expect("create table failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
|
||||
let dbs = reader.list_agent_dbs().expect("list agent dbs failed");
|
||||
assert_eq!(dbs.len(), 1);
|
||||
|
||||
// Should fail: conversations table doesn't exist
|
||||
let result = reader.read_conversations(&dbs[0].1).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Edge Cases
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_edge_case_empty_chunks_table() {
|
||||
let temp_dir = TempDir::new().expect("temp dir creation failed");
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
let agents_dir = openclaw_path.join("agents");
|
||||
std::fs::create_dir_all(&agents_dir).expect("mkdir failed");
|
||||
|
||||
let db_path = agents_dir.join("empty.sqlite");
|
||||
|
||||
let db = libsql::Builder::new_local(&db_path)
|
||||
.build()
|
||||
.await
|
||||
.expect("db creation failed");
|
||||
let conn = db.connect().expect("connect failed");
|
||||
conn.execute(
|
||||
"CREATE TABLE chunks (id TEXT, path TEXT, content TEXT, embedding BLOB, chunk_index INTEGER)",
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.expect("create table failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
|
||||
let dbs = reader.list_agent_dbs().expect("list agent dbs failed");
|
||||
|
||||
// Should succeed but return empty list
|
||||
let chunks = reader
|
||||
.read_memory_chunks(&dbs[0].1)
|
||||
.await
|
||||
.expect("read chunks failed");
|
||||
assert_eq!(chunks.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_edge_case_empty_conversations_table() {
|
||||
let temp_dir = TempDir::new().expect("temp dir creation failed");
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
let agents_dir = openclaw_path.join("agents");
|
||||
std::fs::create_dir_all(&agents_dir).expect("mkdir failed");
|
||||
|
||||
let db_path = agents_dir.join("empty_conv.sqlite");
|
||||
|
||||
let db = libsql::Builder::new_local(&db_path)
|
||||
.build()
|
||||
.await
|
||||
.expect("db creation failed");
|
||||
let conn = db.connect().expect("connect failed");
|
||||
conn.execute(
|
||||
"CREATE TABLE conversations (id TEXT, channel TEXT, created_at TEXT)",
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.expect("create table failed");
|
||||
conn.execute(
|
||||
"CREATE TABLE messages (id TEXT, conversation_id TEXT, role TEXT, content TEXT, created_at TEXT)",
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.expect("create table failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
|
||||
let dbs = reader.list_agent_dbs().expect("list agent dbs failed");
|
||||
|
||||
// Should succeed but return empty list
|
||||
let conversations = reader
|
||||
.read_conversations(&dbs[0].1)
|
||||
.await
|
||||
.expect("read conversations failed");
|
||||
assert_eq!(conversations.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_edge_case_very_large_content() {
|
||||
let temp_dir = TempDir::new().expect("temp dir creation failed");
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
let agents_dir = openclaw_path.join("agents");
|
||||
std::fs::create_dir_all(&agents_dir).expect("mkdir failed");
|
||||
|
||||
let db_path = agents_dir.join("large.sqlite");
|
||||
|
||||
let db = libsql::Builder::new_local(&db_path)
|
||||
.build()
|
||||
.await
|
||||
.expect("db creation failed");
|
||||
let conn = db.connect().expect("connect failed");
|
||||
conn.execute(
|
||||
"CREATE TABLE chunks (id TEXT, path TEXT, content TEXT, embedding BLOB, chunk_index INTEGER)",
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.expect("create table failed");
|
||||
|
||||
// Insert very large content (1MB)
|
||||
let large_content = "x".repeat(1024 * 1024);
|
||||
conn.execute(
|
||||
"INSERT INTO chunks VALUES (?, ?, ?, ?, ?)",
|
||||
libsql::params!["id1", "path", large_content, libsql::Value::Null, 0i64],
|
||||
)
|
||||
.await
|
||||
.expect("insert failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
|
||||
let dbs = reader.list_agent_dbs().expect("list agent dbs failed");
|
||||
|
||||
// Should still succeed
|
||||
let chunks = reader
|
||||
.read_memory_chunks(&dbs[0].1)
|
||||
.await
|
||||
.expect("read chunks failed");
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert_eq!(chunks[0].content.len(), 1024 * 1024);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_edge_case_special_characters_in_content() {
|
||||
let temp_dir = TempDir::new().expect("temp dir creation failed");
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
let agents_dir = openclaw_path.join("agents");
|
||||
std::fs::create_dir_all(&agents_dir).expect("mkdir failed");
|
||||
|
||||
let db_path = agents_dir.join("special.sqlite");
|
||||
|
||||
let db = libsql::Builder::new_local(&db_path)
|
||||
.build()
|
||||
.await
|
||||
.expect("db creation failed");
|
||||
let conn = db.connect().expect("connect failed");
|
||||
conn.execute(
|
||||
"CREATE TABLE chunks (id TEXT, path TEXT, content TEXT, embedding BLOB, chunk_index INTEGER)",
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.expect("create table failed");
|
||||
|
||||
// Insert content with special characters
|
||||
let special_content = "Content with emoji \u{1f680} and UTF-8: \u{4e2d}\u{6587}, \u{0627}\u{0644}\u{0639}\u{0631}\u{0628}\u{064a}\u{0629}, \u{03b5}\u{03bb}\u{03bb}\u{03b7}\u{03bd}\u{03b9}\u{03ba}\u{03ac}";
|
||||
conn.execute(
|
||||
"INSERT INTO chunks VALUES (?, ?, ?, ?, ?)",
|
||||
libsql::params!["id1", "path", special_content, libsql::Value::Null, 0i64],
|
||||
)
|
||||
.await
|
||||
.expect("insert failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
|
||||
let dbs = reader.list_agent_dbs().expect("list agent dbs failed");
|
||||
|
||||
// Should handle special characters
|
||||
let chunks = reader
|
||||
.read_memory_chunks(&dbs[0].1)
|
||||
.await
|
||||
.expect("read chunks failed");
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert!(chunks[0].content.contains("\u{1f680}"));
|
||||
assert!(chunks[0].content.contains("\u{4e2d}\u{6587}"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_edge_case_null_values_in_fields() {
|
||||
let temp_dir = TempDir::new().expect("temp dir creation failed");
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
let agents_dir = openclaw_path.join("agents");
|
||||
std::fs::create_dir_all(&agents_dir).expect("mkdir failed");
|
||||
|
||||
let db_path = agents_dir.join("nulls.sqlite");
|
||||
|
||||
let db = libsql::Builder::new_local(&db_path)
|
||||
.build()
|
||||
.await
|
||||
.expect("db creation failed");
|
||||
let conn = db.connect().expect("connect failed");
|
||||
conn.execute(
|
||||
"CREATE TABLE conversations (id TEXT, channel TEXT, created_at TEXT)",
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.expect("create table failed");
|
||||
conn.execute(
|
||||
"CREATE TABLE messages (id TEXT, conversation_id TEXT, role TEXT, content TEXT, created_at TEXT)",
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.expect("create table failed");
|
||||
|
||||
// Insert conversation with NULL created_at
|
||||
conn.execute(
|
||||
"INSERT INTO conversations VALUES (?, ?, ?)",
|
||||
libsql::params!["conv1", "telegram", libsql::Value::Null],
|
||||
)
|
||||
.await
|
||||
.expect("insert failed");
|
||||
|
||||
// Insert message with NULL created_at
|
||||
conn.execute(
|
||||
"INSERT INTO messages VALUES (?, ?, ?, ?, ?)",
|
||||
libsql::params!["msg1", "conv1", "user", "hello", libsql::Value::Null],
|
||||
)
|
||||
.await
|
||||
.expect("insert failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
|
||||
let dbs = reader.list_agent_dbs().expect("list agent dbs failed");
|
||||
|
||||
// Should handle NULL timestamps gracefully
|
||||
let conversations = reader
|
||||
.read_conversations(&dbs[0].1)
|
||||
.await
|
||||
.expect("read conversations failed");
|
||||
assert_eq!(conversations.len(), 1);
|
||||
assert!(conversations[0].created_at.is_none());
|
||||
assert!(conversations[0].messages[0].created_at.is_none());
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Workspace File Errors
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_error_workspace_not_directory() {
|
||||
let temp_dir = TempDir::new().expect("temp dir creation failed");
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
// Create "workspace" as a file, not a directory
|
||||
std::fs::write(openclaw_path.join("workspace"), "not a directory").expect("write failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
|
||||
// Should handle gracefully (no files found)
|
||||
let count = reader
|
||||
.list_workspace_files()
|
||||
.expect("list workspace files failed");
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edge_case_many_markdown_files() {
|
||||
let temp_dir = TempDir::new().expect("temp dir creation failed");
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
let workspace_dir = openclaw_path.join("workspace");
|
||||
std::fs::create_dir_all(&workspace_dir).expect("mkdir failed");
|
||||
|
||||
// Create 100 markdown files
|
||||
for i in 0..100 {
|
||||
std::fs::write(workspace_dir.join(format!("doc_{}.md", i)), "content")
|
||||
.expect("write failed");
|
||||
}
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
|
||||
let count = reader
|
||||
.list_workspace_files()
|
||||
.expect("list workspace files failed");
|
||||
assert_eq!(count, 100);
|
||||
}
|
||||
}
|
||||
@@ -1,374 +0,0 @@
|
||||
//! Idempotency and dry-run tests for OpenClaw import.
|
||||
//!
|
||||
//! These tests verify that:
|
||||
//! 1. Running import twice produces the same results (idempotency)
|
||||
//! 2. Dry-run mode doesn't modify any state
|
||||
//! 3. Re-running import doesn't create duplicates
|
||||
|
||||
#![cfg(feature = "import")]
|
||||
|
||||
#[cfg(feature = "import")]
|
||||
mod idempotency_tests {
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
use uuid::Uuid;
|
||||
|
||||
use ironclaw::import::openclaw::reader::OpenClawReader;
|
||||
use ironclaw::import::{ImportOptions, ImportStats};
|
||||
|
||||
/// Helper: Create minimal test OpenClaw
|
||||
async fn create_minimal_openclaw() -> Result<(TempDir, PathBuf), Box<dyn std::error::Error>> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
// Config
|
||||
std::fs::write(
|
||||
openclaw_path.join("openclaw.json"),
|
||||
r#"{ llm: { provider: "openai", model: "gpt-4" } }"#,
|
||||
)?;
|
||||
|
||||
// Workspace
|
||||
let workspace_dir = openclaw_path.join("workspace");
|
||||
std::fs::create_dir_all(&workspace_dir)?;
|
||||
std::fs::write(
|
||||
workspace_dir.join("MEMORY.md"),
|
||||
"# Memory\nTest memory content",
|
||||
)?;
|
||||
|
||||
// Agent DB
|
||||
let agents_dir = openclaw_path.join("agents");
|
||||
std::fs::create_dir_all(&agents_dir)?;
|
||||
let db_path = agents_dir.join("agent.sqlite");
|
||||
|
||||
let db = libsql::Builder::new_local(&db_path).build().await?;
|
||||
let conn = db.connect()?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE chunks (
|
||||
id TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
embedding BLOB,
|
||||
chunk_index INTEGER
|
||||
)",
|
||||
(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO chunks VALUES (?, ?, ?, ?, ?)",
|
||||
libsql::params![
|
||||
Uuid::new_v4().to_string(),
|
||||
"test.md",
|
||||
"Test content",
|
||||
libsql::Value::Null,
|
||||
0i64
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE conversations (id TEXT PRIMARY KEY, channel TEXT, created_at TEXT)",
|
||||
(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT,
|
||||
role TEXT,
|
||||
content TEXT,
|
||||
created_at TEXT
|
||||
)",
|
||||
(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok((temp_dir, openclaw_path))
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Idempotency Tests
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reader_idempotent_config_reads() {
|
||||
let (_temp, openclaw_path) = create_minimal_openclaw().await.expect("setup failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
|
||||
// Read config twice
|
||||
let config1 = reader.read_config().expect("first read failed");
|
||||
let config2 = reader.read_config().expect("second read failed");
|
||||
|
||||
// Results should be identical
|
||||
assert_eq!(
|
||||
config1.llm.as_ref().map(|c| &c.provider),
|
||||
config2.llm.as_ref().map(|c| &c.provider)
|
||||
);
|
||||
assert_eq!(
|
||||
config1.llm.as_ref().map(|c| &c.model),
|
||||
config2.llm.as_ref().map(|c| &c.model)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reader_idempotent_workspace_file_listing() {
|
||||
let (_temp, openclaw_path) = create_minimal_openclaw().await.expect("setup failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
|
||||
// List files twice
|
||||
let count1 = reader.list_workspace_files().expect("first list failed");
|
||||
let count2 = reader.list_workspace_files().expect("second list failed");
|
||||
|
||||
assert_eq!(count1, count2);
|
||||
assert_eq!(count1, 1); // MEMORY.md
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reader_idempotent_memory_chunk_reads() {
|
||||
let (_temp, openclaw_path) = create_minimal_openclaw().await.expect("setup failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
let agent_dbs = reader.list_agent_dbs().expect("list agent dbs failed");
|
||||
let db_path = &agent_dbs[0].1;
|
||||
|
||||
// Read chunks twice
|
||||
let chunks1 = reader
|
||||
.read_memory_chunks(db_path)
|
||||
.await
|
||||
.expect("first read failed");
|
||||
let chunks2 = reader
|
||||
.read_memory_chunks(db_path)
|
||||
.await
|
||||
.expect("second read failed");
|
||||
|
||||
// Same number of chunks
|
||||
assert_eq!(chunks1.len(), chunks2.len());
|
||||
|
||||
// Same content
|
||||
for (c1, c2) in chunks1.iter().zip(chunks2.iter()) {
|
||||
assert_eq!(c1.path, c2.path);
|
||||
assert_eq!(c1.content, c2.content);
|
||||
assert_eq!(c1.chunk_index, c2.chunk_index);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_options_are_independent() {
|
||||
let opts1 = ImportOptions {
|
||||
openclaw_path: std::path::PathBuf::from("/test1"),
|
||||
dry_run: true,
|
||||
re_embed: false,
|
||||
user_id: "user1".to_string(),
|
||||
};
|
||||
|
||||
let opts2 = ImportOptions {
|
||||
openclaw_path: std::path::PathBuf::from("/test2"),
|
||||
dry_run: false,
|
||||
re_embed: true,
|
||||
user_id: "user2".to_string(),
|
||||
};
|
||||
|
||||
// Different options should remain independent
|
||||
assert_ne!(opts1.user_id, opts2.user_id);
|
||||
assert_ne!(opts1.dry_run, opts2.dry_run);
|
||||
assert_ne!(opts1.re_embed, opts2.re_embed);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Dry-Run Verification Tests
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_dry_run_option_construction() {
|
||||
let dry_run_opts = ImportOptions {
|
||||
openclaw_path: std::path::PathBuf::from("/test"),
|
||||
dry_run: true,
|
||||
re_embed: false,
|
||||
user_id: "test".to_string(),
|
||||
};
|
||||
|
||||
let normal_opts = ImportOptions {
|
||||
openclaw_path: std::path::PathBuf::from("/test"),
|
||||
dry_run: false,
|
||||
re_embed: false,
|
||||
user_id: "test".to_string(),
|
||||
};
|
||||
|
||||
// Verify dry_run flag is set correctly
|
||||
assert!(dry_run_opts.dry_run);
|
||||
assert!(!normal_opts.dry_run);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dry_run_stats_would_be_same() {
|
||||
// Simulating what import stats would be in dry-run vs real run
|
||||
let (_temp, openclaw_path) = create_minimal_openclaw().await.expect("setup failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
|
||||
let document_count = reader
|
||||
.list_workspace_files()
|
||||
.expect("list workspace files failed");
|
||||
|
||||
// Dry-run would count: 1 config, 1 document, 1 chunk, 0 conversations
|
||||
let dry_run_stats = ImportStats {
|
||||
settings: 1,
|
||||
documents: document_count,
|
||||
chunks: 1,
|
||||
conversations: 0,
|
||||
..ImportStats::default()
|
||||
};
|
||||
|
||||
// Real run would have same stats (just written to DB)
|
||||
let real_run_stats = ImportStats {
|
||||
settings: 1,
|
||||
documents: document_count,
|
||||
chunks: 1,
|
||||
conversations: 0,
|
||||
..ImportStats::default()
|
||||
};
|
||||
|
||||
// Stats should match (same data would be imported)
|
||||
assert_eq!(dry_run_stats.documents, real_run_stats.documents);
|
||||
assert_eq!(dry_run_stats.chunks, real_run_stats.chunks);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Duplicate Prevention Tests
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_chunk_deduplication_by_path() {
|
||||
let (_temp, openclaw_path) = create_minimal_openclaw().await.expect("setup failed");
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
let agent_dbs = reader.list_agent_dbs().expect("list agent dbs failed");
|
||||
let db_path = &agent_dbs[0].1;
|
||||
|
||||
let chunks = reader
|
||||
.read_memory_chunks(db_path)
|
||||
.await
|
||||
.expect("read chunks failed");
|
||||
|
||||
// All chunks should have unique (path, chunk_index) pairs
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for chunk in chunks {
|
||||
let key = (chunk.path.clone(), chunk.chunk_index);
|
||||
assert!(seen.insert(key.clone()), "Duplicate chunk: {:?}", key);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conversation_deduplication_by_id() {
|
||||
// This would be verified by metadata.openclaw_conversation_id in real import
|
||||
let conversation_ids = vec![
|
||||
"conv_1".to_string(),
|
||||
"conv_2".to_string(),
|
||||
"conv_1".to_string(), // Duplicate
|
||||
];
|
||||
|
||||
// In real import, check if already exists
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut duplicates = 0;
|
||||
|
||||
for id in conversation_ids {
|
||||
if !seen.insert(id) {
|
||||
duplicates += 1;
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(duplicates, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_setting_upsert_semantics() {
|
||||
// Settings should use upsert (update if exists, insert if not)
|
||||
let settings_map = vec![
|
||||
("llm.backend", "openai"),
|
||||
("llm.backend", "anthropic"), // Same key, different value
|
||||
("embeddings.model", "text-embedding-3"),
|
||||
];
|
||||
|
||||
// Simulate upsert with HashMap
|
||||
let mut result = std::collections::HashMap::new();
|
||||
for (key, value) in settings_map {
|
||||
result.insert(key, value);
|
||||
}
|
||||
|
||||
// Should have 2 entries, not 3 (last value wins)
|
||||
assert_eq!(result.len(), 2);
|
||||
assert_eq!(result.get("llm.backend"), Some(&"anthropic")); // Last value
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credential_idempotent_storage() {
|
||||
// Credentials use secrets store's upsert semantics
|
||||
let credentials = vec![
|
||||
("api_key_1", "secret1"),
|
||||
("api_key_2", "secret2"),
|
||||
("api_key_1", "secret1_updated"), // Same name, updated value
|
||||
];
|
||||
|
||||
// Simulate upsert with HashMap
|
||||
let mut result = std::collections::HashMap::new();
|
||||
for (name, value) in credentials {
|
||||
result.insert(name, value);
|
||||
}
|
||||
|
||||
// Should have 2 entries (same name means upsert)
|
||||
assert_eq!(result.len(), 2);
|
||||
assert_eq!(result.get("api_key_1"), Some(&"secret1_updated"));
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Re-import Scenarios
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_stats_on_second_import_would_be_zero() {
|
||||
// After first import, second import should find all items already exist
|
||||
// and report stats.skipped instead of new imports
|
||||
|
||||
let _first_import_stats = ImportStats {
|
||||
documents: 1,
|
||||
chunks: 1,
|
||||
conversations: 0,
|
||||
..ImportStats::default()
|
||||
};
|
||||
|
||||
let second_import_stats = ImportStats {
|
||||
documents: 0,
|
||||
chunks: 0,
|
||||
conversations: 0,
|
||||
skipped: 2, // 1 doc + 1 chunk already exist
|
||||
..ImportStats::default()
|
||||
};
|
||||
|
||||
// Second import should report skipped, not imported
|
||||
assert_eq!(second_import_stats.total_imported(), 0);
|
||||
assert!(second_import_stats.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_partial_re_import_new_content() {
|
||||
// If OpenClaw adds new content and import is run again
|
||||
let first_stats = ImportStats {
|
||||
chunks: 5,
|
||||
..ImportStats::default()
|
||||
};
|
||||
|
||||
let second_stats = ImportStats {
|
||||
chunks: 3, // 3 new chunks added
|
||||
skipped: 5, // 5 chunks already exist
|
||||
..ImportStats::default()
|
||||
};
|
||||
|
||||
// Total should reflect new additions
|
||||
assert_eq!(first_stats.chunks + second_stats.chunks, 8);
|
||||
assert_eq!(second_stats.total_imported(), 3);
|
||||
}
|
||||
}
|
||||
@@ -1,559 +0,0 @@
|
||||
//! Integration tests for OpenClaw import with actual database state verification.
|
||||
//!
|
||||
//! These tests exercise the full import pipeline with real database writes,
|
||||
//! verifying that data is correctly stored, idempotent, and that dry-run mode
|
||||
//! prevents modifications.
|
||||
|
||||
#![cfg(feature = "import")]
|
||||
|
||||
#[cfg(feature = "import")]
|
||||
mod import_integration_tests {
|
||||
use ironclaw::db::Database;
|
||||
use ironclaw::db::libsql::LibSqlBackend;
|
||||
use ironclaw::import::ImportStats;
|
||||
use ironclaw::import::openclaw::reader::OpenClawReader;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Helper: Create a test database and return both the DB and temp dir
|
||||
async fn create_test_db()
|
||||
-> Result<(Arc<dyn ironclaw::db::Database>, TempDir), Box<dyn std::error::Error>> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let db_path = temp_dir.path().join("test.db");
|
||||
let backend = LibSqlBackend::new_local(&db_path).await?;
|
||||
backend.run_migrations().await?;
|
||||
let db: Arc<dyn ironclaw::db::Database> = Arc::new(backend);
|
||||
Ok((db, temp_dir))
|
||||
}
|
||||
|
||||
/// Helper: Create a test OpenClaw directory with full structure
|
||||
async fn create_test_openclaw() -> Result<(TempDir, PathBuf), Box<dyn std::error::Error>> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
// Config
|
||||
let config = r#"{
|
||||
llm: {
|
||||
provider: "openai",
|
||||
model: "gpt-4",
|
||||
api_key: "sk-test-12345"
|
||||
},
|
||||
embeddings: {
|
||||
model: "text-embedding-3-small",
|
||||
api_key: "sk-embed-67890"
|
||||
}
|
||||
}"#;
|
||||
std::fs::write(openclaw_path.join("openclaw.json"), config)?;
|
||||
|
||||
// Workspace files
|
||||
let workspace_dir = openclaw_path.join("workspace");
|
||||
std::fs::create_dir_all(&workspace_dir)?;
|
||||
std::fs::write(
|
||||
workspace_dir.join("MEMORY.md"),
|
||||
"# Memory\n\nTest memory content for integration test.",
|
||||
)?;
|
||||
std::fs::write(
|
||||
workspace_dir.join("NOTES.md"),
|
||||
"# Notes\n\nAdditional notes content.",
|
||||
)?;
|
||||
|
||||
// Agent databases
|
||||
let agents_dir = openclaw_path.join("agents");
|
||||
std::fs::create_dir_all(&agents_dir)?;
|
||||
|
||||
create_test_agent_db(&agents_dir.join("agent1.sqlite")).await?;
|
||||
create_test_agent_db(&agents_dir.join("agent2.sqlite")).await?;
|
||||
|
||||
Ok((temp_dir, openclaw_path))
|
||||
}
|
||||
|
||||
/// Helper: Create a test agent SQLite database using libsql
|
||||
async fn create_test_agent_db(db_path: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let db = libsql::Builder::new_local(db_path).build().await?;
|
||||
let conn = db.connect()?;
|
||||
|
||||
// Chunks table
|
||||
conn.execute(
|
||||
"CREATE TABLE chunks (
|
||||
id TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
embedding BLOB,
|
||||
chunk_index INTEGER NOT NULL
|
||||
)",
|
||||
(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
for i in 0..3 {
|
||||
conn.execute(
|
||||
"INSERT INTO chunks (id, path, content, embedding, chunk_index) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
libsql::params![
|
||||
Uuid::new_v4().to_string(),
|
||||
format!("doc/section_{}.md", i),
|
||||
format!("Chunk {} content", i),
|
||||
libsql::Value::Null,
|
||||
i as i64
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Conversations
|
||||
conn.execute(
|
||||
"CREATE TABLE conversations (id TEXT PRIMARY KEY, channel TEXT, created_at TEXT)",
|
||||
(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT
|
||||
)",
|
||||
(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let conv_id = Uuid::new_v4().to_string();
|
||||
conn.execute(
|
||||
"INSERT INTO conversations VALUES (?1, ?2, ?3)",
|
||||
libsql::params![conv_id.as_str(), "slack", "2024-01-15T10:00:00Z"],
|
||||
)
|
||||
.await?;
|
||||
|
||||
for j in 0..2 {
|
||||
conn.execute(
|
||||
"INSERT INTO messages (id, conversation_id, role, content, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
libsql::params![
|
||||
Uuid::new_v4().to_string(),
|
||||
conv_id.as_str(),
|
||||
if j % 2 == 0 { "user" } else { "assistant" },
|
||||
format!("Message {}", j),
|
||||
format!("2024-01-15T10:{:02}:00Z", j)
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Integration Test 1: Full Import with Database Verification
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_full_import_with_database_writes() {
|
||||
let (db, _db_temp) = create_test_db().await.expect("DB creation failed");
|
||||
let (_openclaw_temp, openclaw_path) = create_test_openclaw()
|
||||
.await
|
||||
.expect("OpenClaw creation failed");
|
||||
|
||||
// Verify DB starts empty
|
||||
let before_docs = db
|
||||
.list_documents("test_user", None)
|
||||
.await
|
||||
.expect("list docs failed");
|
||||
assert_eq!(before_docs.len(), 0);
|
||||
|
||||
// Create reader
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
|
||||
// Read config
|
||||
let config = reader.read_config().expect("config read failed");
|
||||
assert!(config.llm.is_some());
|
||||
|
||||
// Verify reader can find data
|
||||
let workspace_count = reader
|
||||
.list_workspace_files()
|
||||
.expect("list workspace files failed");
|
||||
assert_eq!(workspace_count, 2); // MEMORY.md, NOTES.md
|
||||
|
||||
let agent_dbs = reader.list_agent_dbs().expect("list agent dbs failed");
|
||||
assert_eq!(agent_dbs.len(), 2); // agent1, agent2
|
||||
|
||||
// Read chunks from first agent
|
||||
let chunks = reader
|
||||
.read_memory_chunks(&agent_dbs[0].1)
|
||||
.await
|
||||
.expect("read chunks failed");
|
||||
assert_eq!(chunks.len(), 3); // 3 chunks created
|
||||
|
||||
// Read conversations from first agent
|
||||
let conversations = reader
|
||||
.read_conversations(&agent_dbs[0].1)
|
||||
.await
|
||||
.expect("read conversations failed");
|
||||
assert_eq!(conversations.len(), 1); // 1 conversation created
|
||||
assert_eq!(conversations[0].messages.len(), 2); // 2 messages
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Integration Test 2: CLI Import Command End-to-End
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_import_command_execution() {
|
||||
let (_openclaw_temp, openclaw_path) = create_test_openclaw()
|
||||
.await
|
||||
.expect("OpenClaw creation failed");
|
||||
let (_db, _db_temp) = create_test_db().await.expect("DB creation failed");
|
||||
|
||||
// Create import options
|
||||
let opts = ironclaw::import::ImportOptions {
|
||||
openclaw_path: openclaw_path.clone(),
|
||||
dry_run: false,
|
||||
re_embed: false,
|
||||
user_id: "test_user".to_string(),
|
||||
};
|
||||
|
||||
// Verify options are correctly configured
|
||||
assert_eq!(opts.user_id, "test_user");
|
||||
assert!(!opts.dry_run);
|
||||
assert!(!opts.re_embed);
|
||||
|
||||
// Verify the OpenClaw path exists
|
||||
assert!(openclaw_path.join("openclaw.json").exists());
|
||||
assert!(openclaw_path.join("workspace").exists());
|
||||
assert!(openclaw_path.join("agents").exists());
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Integration Test 3: Dry-Run Prevents Database Writes
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dry_run_prevents_database_writes() {
|
||||
let (db, _db_temp) = create_test_db().await.expect("DB creation failed");
|
||||
let (_openclaw_temp, openclaw_path) = create_test_openclaw()
|
||||
.await
|
||||
.expect("OpenClaw creation failed");
|
||||
|
||||
let user_id = "test_user";
|
||||
|
||||
// Count documents before import
|
||||
let before_import = db
|
||||
.list_documents(user_id, None)
|
||||
.await
|
||||
.expect("list docs before failed");
|
||||
let before_count = before_import.len();
|
||||
|
||||
// Create import options in DRY-RUN mode
|
||||
let opts = ironclaw::import::ImportOptions {
|
||||
openclaw_path: openclaw_path.clone(),
|
||||
dry_run: true, // ← KEY: dry_run is enabled
|
||||
re_embed: false,
|
||||
user_id: user_id.to_string(),
|
||||
};
|
||||
|
||||
// Verify dry_run flag is set
|
||||
assert!(opts.dry_run, "dry_run should be true");
|
||||
|
||||
// Count documents after (in dry-run mode, no writes should occur)
|
||||
let after_import = db
|
||||
.list_documents(user_id, None)
|
||||
.await
|
||||
.expect("list docs after failed");
|
||||
let after_count = after_import.len();
|
||||
|
||||
// Counts should be identical (no writes in dry-run)
|
||||
assert_eq!(
|
||||
before_count, after_count,
|
||||
"Dry-run should not modify database"
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Integration Test 4: Database-Level Idempotency (No Duplicates on Reimport)
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_import_idempotency_no_duplicates_on_reimport() {
|
||||
let (_db, _db_temp) = create_test_db().await.expect("DB creation failed");
|
||||
let (_openclaw_temp, openclaw_path) = create_test_openclaw()
|
||||
.await
|
||||
.expect("OpenClaw creation failed");
|
||||
|
||||
// Simulate first import: count what would be imported
|
||||
let reader1 = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
let workspace_count1 = reader1
|
||||
.list_workspace_files()
|
||||
.expect("list workspace failed");
|
||||
let agent_dbs1 = reader1.list_agent_dbs().expect("list agent dbs failed");
|
||||
|
||||
let mut total_chunks_first = 0;
|
||||
let mut total_conversations_first = 0;
|
||||
|
||||
for (_, db_path) in &agent_dbs1 {
|
||||
let chunks = reader1
|
||||
.read_memory_chunks(db_path)
|
||||
.await
|
||||
.expect("read chunks failed");
|
||||
total_chunks_first += chunks.len();
|
||||
|
||||
let conversations = reader1
|
||||
.read_conversations(db_path)
|
||||
.await
|
||||
.expect("read conversations failed");
|
||||
total_conversations_first += conversations.len();
|
||||
}
|
||||
|
||||
let stats1 = ImportStats {
|
||||
documents: workspace_count1,
|
||||
chunks: total_chunks_first,
|
||||
conversations: total_conversations_first,
|
||||
..ImportStats::default()
|
||||
};
|
||||
|
||||
// Simulate second import: same data
|
||||
let reader2 = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
let workspace_count2 = reader2
|
||||
.list_workspace_files()
|
||||
.expect("list workspace failed");
|
||||
let agent_dbs2 = reader2.list_agent_dbs().expect("list agent dbs failed");
|
||||
|
||||
// Should find the exact same data
|
||||
assert_eq!(workspace_count1, workspace_count2);
|
||||
assert_eq!(agent_dbs1.len(), agent_dbs2.len());
|
||||
|
||||
// On second import, all items would already exist, so skipped count == first import total
|
||||
let second_stats = ImportStats {
|
||||
documents: 0, // Already exist
|
||||
chunks: 0, // Already exist
|
||||
conversations: 0, // Already exist
|
||||
skipped: stats1.total_imported(),
|
||||
..ImportStats::default()
|
||||
};
|
||||
|
||||
// Verify that total imported in second run would be 0
|
||||
assert_eq!(second_stats.total_imported(), 0);
|
||||
assert!(second_stats.is_empty());
|
||||
assert_eq!(second_stats.skipped, stats1.total_imported());
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Integration Test 5: Embedding Dimension Mismatch Handling
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_embedding_dimension_mismatch_queues_reembedding() {
|
||||
let (_openclaw_temp, openclaw_path) = create_test_openclaw()
|
||||
.await
|
||||
.expect("OpenClaw creation failed");
|
||||
|
||||
// Create an agent DB with embeddings (1536-dim)
|
||||
let agents_dir = openclaw_path.join("agents");
|
||||
std::fs::create_dir_all(&agents_dir).expect("mkdir failed");
|
||||
let db_path = agents_dir.join("with_embeddings.sqlite");
|
||||
|
||||
{
|
||||
let db = libsql::Builder::new_local(&db_path)
|
||||
.build()
|
||||
.await
|
||||
.expect("db build failed");
|
||||
let conn = db.connect().expect("db connect failed");
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE chunks (
|
||||
id TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
embedding BLOB,
|
||||
chunk_index INTEGER NOT NULL
|
||||
)",
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.expect("create table failed");
|
||||
|
||||
// Create a 1536-dimensional embedding (ada-002 size)
|
||||
// Each f32 is 4 bytes, so 1536 * 4 = 6144 bytes
|
||||
let embedding_1536_bytes: Vec<u8> = vec![0.1f32; 1536]
|
||||
.iter()
|
||||
.flat_map(|f| f.to_le_bytes().to_vec())
|
||||
.collect();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO chunks (id, path, content, embedding, chunk_index) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
libsql::params![
|
||||
Uuid::new_v4().to_string(),
|
||||
"test.md",
|
||||
"Chunk with embedding",
|
||||
embedding_1536_bytes,
|
||||
0i64
|
||||
],
|
||||
)
|
||||
.await
|
||||
.expect("insert failed");
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE conversations (id TEXT PRIMARY KEY, channel TEXT, created_at TEXT)",
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.expect("create conv table failed");
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT
|
||||
)",
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.expect("create messages table failed");
|
||||
}
|
||||
|
||||
// Read the chunks back
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
let chunks = reader
|
||||
.read_memory_chunks(&db_path)
|
||||
.await
|
||||
.expect("read chunks failed");
|
||||
|
||||
assert_eq!(chunks.len(), 1);
|
||||
let chunk = &chunks[0];
|
||||
|
||||
// Verify embedding was read correctly
|
||||
assert!(chunk.embedding.is_some());
|
||||
let embedding = chunk.embedding.as_ref().unwrap();
|
||||
assert_eq!(embedding.len(), 1536);
|
||||
|
||||
// Verify all values are approximately 0.1
|
||||
for (i, val) in embedding.iter().enumerate() {
|
||||
assert!(
|
||||
(val - 0.1).abs() < 0.001,
|
||||
"Embedding value {} should be ~0.1, got {}",
|
||||
i,
|
||||
val
|
||||
);
|
||||
}
|
||||
|
||||
// Simulate dimension mismatch scenario:
|
||||
let source_dim = embedding.len();
|
||||
let target_dim = 3072; // text-embedding-3-large
|
||||
|
||||
if source_dim != target_dim {
|
||||
assert!(
|
||||
source_dim != target_dim,
|
||||
"Dimension mismatch detected: {} -> {}",
|
||||
source_dim,
|
||||
target_dim
|
||||
);
|
||||
|
||||
let mut re_embed_queued = 0;
|
||||
if source_dim != target_dim {
|
||||
re_embed_queued += 1;
|
||||
}
|
||||
|
||||
assert_eq!(re_embed_queued, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Integration Test 6: Embedding Dimension Match (No Re-embedding)
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_embedding_same_dimension_no_reembedding() {
|
||||
let temp_dir = TempDir::new().expect("temp dir failed");
|
||||
let openclaw_path = temp_dir.path().to_path_buf();
|
||||
|
||||
// Create minimal config
|
||||
std::fs::write(
|
||||
openclaw_path.join("openclaw.json"),
|
||||
r#"{ llm: { provider: "openai", model: "gpt-4" } }"#,
|
||||
)
|
||||
.expect("write config failed");
|
||||
|
||||
// Create agent DB with 1536-dim embeddings
|
||||
let agents_dir = openclaw_path.join("agents");
|
||||
std::fs::create_dir_all(&agents_dir).expect("mkdir failed");
|
||||
let db_path = agents_dir.join("same_dim.sqlite");
|
||||
|
||||
{
|
||||
let db = libsql::Builder::new_local(&db_path)
|
||||
.build()
|
||||
.await
|
||||
.expect("db build failed");
|
||||
let conn = db.connect().expect("db connect failed");
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE chunks (
|
||||
id TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
embedding BLOB,
|
||||
chunk_index INTEGER NOT NULL
|
||||
)",
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.expect("create table failed");
|
||||
|
||||
// 1536-dimensional embedding (text-embedding-3-small)
|
||||
let embedding_bytes: Vec<u8> = vec![0.5f32; 1536]
|
||||
.iter()
|
||||
.flat_map(|f| f.to_le_bytes().to_vec())
|
||||
.collect();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO chunks (id, path, content, embedding, chunk_index) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
libsql::params![
|
||||
Uuid::new_v4().to_string(),
|
||||
"test.md",
|
||||
"Chunk",
|
||||
embedding_bytes,
|
||||
0i64
|
||||
],
|
||||
)
|
||||
.await
|
||||
.expect("insert failed");
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE conversations (id TEXT PRIMARY KEY, channel TEXT, created_at TEXT)",
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.expect("create conv table failed");
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT
|
||||
)",
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.expect("create messages table failed");
|
||||
}
|
||||
|
||||
let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed");
|
||||
let chunks = reader
|
||||
.read_memory_chunks(&db_path)
|
||||
.await
|
||||
.expect("read chunks failed");
|
||||
|
||||
let embedding = chunks[0].embedding.as_ref().unwrap();
|
||||
let source_dim = embedding.len();
|
||||
let target_dim = 1536; // Same as source (text-embedding-3-small)
|
||||
|
||||
// Dimensions match, so no re-embedding needed
|
||||
assert_eq!(source_dim, target_dim);
|
||||
|
||||
let re_embed_queued = if source_dim != target_dim { 1 } else { 0 };
|
||||
assert_eq!(re_embed_queued, 0);
|
||||
}
|
||||
}
|
||||
@@ -729,45 +729,21 @@ async fn test_no_llm_provider_returns_503() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_chat_completions_body_too_large() {
|
||||
use axum::{Router, body::Body, extract::DefaultBodyLimit, middleware, routing::post};
|
||||
use tower::ServiceExt;
|
||||
let (addr, _state, _mock_state) = start_test_server().await;
|
||||
let url = format!("http://{}/v1/chat/completions", addr);
|
||||
|
||||
let mock_state = Arc::new(MockLlmState::default());
|
||||
let llm_provider: Arc<dyn LlmProvider> = Arc::new(MockLlmProvider::new(mock_state));
|
||||
let state = ironclaw::channels::web::test_helpers::TestGatewayBuilder::new()
|
||||
.llm_provider(llm_provider)
|
||||
.build();
|
||||
let auth_state = ironclaw::channels::web::auth::AuthState {
|
||||
token: AUTH_TOKEN.to_string(),
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/v1/chat/completions",
|
||||
post(ironclaw::channels::web::openai_compat::chat_completions_handler),
|
||||
)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
auth_state,
|
||||
ironclaw::channels::web::auth::auth_middleware,
|
||||
))
|
||||
.layer(DefaultBodyLimit::max(10 * 1024 * 1024))
|
||||
.with_state(state);
|
||||
|
||||
// Build a payload over 10 MB (the gateway's DefaultBodyLimit).
|
||||
// Build a payload over 10 MB (the gateway's DefaultBodyLimit)
|
||||
let big_content = "x".repeat(11 * 1024 * 1024);
|
||||
let body = serde_json::to_vec(&serde_json::json!({
|
||||
"model": "mock-model-v1",
|
||||
"messages": [{"role": "user", "content": big_content}]
|
||||
}))
|
||||
.unwrap();
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/v1/chat/completions")
|
||||
.header("authorization", format!("Bearer {}", AUTH_TOKEN))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body))
|
||||
let resp = client()
|
||||
.post(&url)
|
||||
.bearer_auth(AUTH_TOKEN)
|
||||
.json(&serde_json::json!({
|
||||
"model": "mock-model-v1",
|
||||
"messages": [{"role": "user", "content": big_content}]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), 413);
|
||||
}
|
||||
|
||||
@@ -183,15 +183,7 @@ pub fn verify_expects(
|
||||
|
||||
// all_tools_succeeded
|
||||
if expects.all_tools_succeeded == Some(true) {
|
||||
let failed: Vec<&str> = completed
|
||||
.iter()
|
||||
.filter(|(_, success)| !*success)
|
||||
.map(|(name, _)| name.as_str())
|
||||
.collect();
|
||||
assert!(
|
||||
failed.is_empty(),
|
||||
"[{label}] Expected all tools to succeed, failed={failed:?}, completed={completed:?}, results={results:?}"
|
||||
);
|
||||
assert_all_tools_succeeded(completed);
|
||||
}
|
||||
|
||||
// max_tool_calls
|
||||
|
||||
@@ -312,23 +312,7 @@ impl TestRig {
|
||||
.collect();
|
||||
let started = self.tool_calls_started();
|
||||
let completed = self.tool_calls_completed();
|
||||
let mut results = self.tool_results();
|
||||
for status in self.channel.captured_status_events() {
|
||||
if let ironclaw::channels::StatusUpdate::ToolCompleted {
|
||||
name,
|
||||
success: false,
|
||||
error,
|
||||
parameters,
|
||||
} = status
|
||||
{
|
||||
let detail = format!(
|
||||
"error={}; params={}",
|
||||
error.unwrap_or_else(|| "unknown".to_string()),
|
||||
parameters.unwrap_or_else(|| "{}".to_string())
|
||||
);
|
||||
results.push((name, detail));
|
||||
}
|
||||
}
|
||||
let results = self.tool_results();
|
||||
verify_expects(
|
||||
&trace.expects,
|
||||
&all_response_strings,
|
||||
@@ -355,23 +339,7 @@ impl TestRig {
|
||||
let response_strings: Vec<String> = responses.iter().map(|r| r.content.clone()).collect();
|
||||
let started = self.tool_calls_started();
|
||||
let completed = self.tool_calls_completed();
|
||||
let mut results = self.tool_results();
|
||||
for status in self.channel.captured_status_events() {
|
||||
if let ironclaw::channels::StatusUpdate::ToolCompleted {
|
||||
name,
|
||||
success: false,
|
||||
error,
|
||||
parameters,
|
||||
} = status
|
||||
{
|
||||
let detail = format!(
|
||||
"error={}; params={}",
|
||||
error.unwrap_or_else(|| "unknown".to_string()),
|
||||
parameters.unwrap_or_else(|| "{}".to_string())
|
||||
);
|
||||
results.push((name, detail));
|
||||
}
|
||||
}
|
||||
let results = self.tool_results();
|
||||
verify_expects(
|
||||
&trace.expects,
|
||||
&response_strings,
|
||||
@@ -426,7 +394,7 @@ impl TestRigBuilder {
|
||||
llm: None,
|
||||
max_tool_iterations: 10,
|
||||
injection_check: false,
|
||||
auto_approve_tools: Some(true),
|
||||
auto_approve_tools: None,
|
||||
enable_skills: false,
|
||||
enable_routines: false,
|
||||
http_exchanges: Vec::new(),
|
||||
@@ -599,20 +567,11 @@ impl TestRigBuilder {
|
||||
.await
|
||||
.expect("AppBuilder::build_all() failed in test rig");
|
||||
|
||||
// AppBuilder may re-resolve config from env/TOML and override test defaults.
|
||||
// Force test-rig agent flags to the requested deterministic values.
|
||||
components.config.agent.auto_approve_tools = auto_approve_tools.unwrap_or(true);
|
||||
components.config.agent.allow_local_tools = true;
|
||||
|
||||
let scheduler_slot: ironclaw::tools::builtin::SchedulerSlot =
|
||||
Arc::new(tokio::sync::RwLock::new(None));
|
||||
|
||||
// 6. Register job tools, routine tools, and extra tools.
|
||||
{
|
||||
// Ensure filesystem/shell dev tools are always available in the
|
||||
// test rig, even if upstream builder flags/config disable local tools.
|
||||
components.tools.register_dev_tools();
|
||||
|
||||
components.tools.register_job_tools(
|
||||
Arc::clone(&components.context_manager),
|
||||
Some(scheduler_slot.clone()),
|
||||
|
||||
Reference in New Issue
Block a user