diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index 3836a5f9..26c15d89 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -29,18 +29,36 @@ 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 '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 '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:*)'" prompt: | Code review this pull request. Follow these steps precisely: - 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. + 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. - 2. Use a Haiku agent to summarize the PR change (use `gh pr diff`). + 2. Get the PR diff with `gh pr diff` and summarize the change. 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, then return a list of issues found: + 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] Agent 1 — Security & Safety Check for: command injection, path traversal, SSRF, XSS, auth bypass, @@ -63,22 +81,9 @@ jobs: timeouts, resource leaks (file handles, connections), large allocations in hot paths. - 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: + 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: ### Code review @@ -93,8 +98,12 @@ 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. - Notes: - - Use `gh` for all GitHub interactions, not web fetch + 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 - 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) diff --git a/.github/workflows/staging-ci.yml b/.github/workflows/staging-ci.yml index 8e3693b2..c8a39c80 100644 --- a/.github/workflows/staging-ci.yml +++ b/.github/workflows/staging-ci.yml @@ -406,6 +406,10 @@ 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' @@ -414,12 +418,15 @@ jobs: PR_NUMBER: ${{ needs.create-promotion-pr.outputs.pr_number }} run: | if [ -n "$PR_NUMBER" ]; then - 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" + 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 fi # ── Update tested tag (always, so next batch covers only new commits) ── @@ -437,7 +444,7 @@ jobs: - uses: actions/checkout@v6 with: ref: staging - fetch-depth: 1 + fetch-depth: 0 - name: Update staging-tested tag run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bb29dd2a..cf6917b0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,8 +42,8 @@ jobs: telegram-tests: name: Telegram Channel Tests if: > - github.event_name == 'push' || - (github.event_name == 'pull_request' && github.base_ref != 'staging') + 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 == 'push' || - (github.event_name == 'pull_request' && github.base_ref != 'staging') + 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 == 'push' || - (github.event_name == 'pull_request' && github.base_ref != 'staging') + 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 == 'push' || - (github.event_name == 'pull_request' && github.base_ref != 'staging') + github.event_name != 'pull_request' || + github.base_ref != 'staging' runs-on: ubuntu-latest steps: - name: Checkout repository diff --git a/Cargo.lock b/Cargo.lock index 54f51fd2..d27cc5da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2787,15 +2787,6 @@ dependencies = [ "hashbrown 0.14.5", ] -[[package]] -name = "hashlink" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" -dependencies = [ - "hashbrown 0.14.5", -] - [[package]] name = "heck" version = "0.5.0" @@ -3410,7 +3401,6 @@ dependencies = [ "regex", "reqwest", "rig-core", - "rusqlite", "rust_decimal", "rust_decimal_macros", "rustls 0.23.37", @@ -3707,7 +3697,7 @@ dependencies = [ "bitflags 2.11.0", "fallible-iterator 0.2.0", "fallible-streaming-iterator", - "hashlink 0.8.4", + "hashlink", "libsql-ffi", "smallvec", ] @@ -3770,17 +3760,6 @@ dependencies = [ "zerocopy 0.7.35", ] -[[package]] -name = "libsqlite3-sys" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - [[package]] name = "libyml" version = "0.0.5" @@ -5351,20 +5330,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "rusqlite" -version = "0.32.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" -dependencies = [ - "bitflags 2.11.0", - "fallible-iterator 0.3.0", - "fallible-streaming-iterator", - "hashlink 0.9.1", - "libsqlite3-sys", - "smallvec", -] - [[package]] name = "rust_decimal" version = "1.40.0" diff --git a/Cargo.toml b/Cargo.toml index cb03db07..7f5144fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -176,7 +176,6 @@ ed25519-dalek = { version = "2.2.0", features = ["std"] } hex = "0.4.3" # OpenClaw import (feature gated) -rusqlite = { version = "0.32", optional = true, features = ["bundled"] } json5 = { version = "0.4", optional = true } # macOS keychain @@ -214,7 +213,7 @@ 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:rusqlite", "dep:json5"] +import = ["dep:json5", "libsql"] [[test]] name = "html_to_markdown" diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index 634131fc..075d8007 100644 --- a/FEATURE_PARITY.md +++ b/FEATURE_PARITY.md @@ -440,6 +440,7 @@ 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 | | @@ -558,7 +559,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 -- ❌ Webhook trigger endpoint in web gateway +- ✅ Tool-driven webhook ingress (`/webhook/tools/{tool}` -> host-verified + tool-normalized `system_event` routines) - ❌ Channel health monitor with auto-restart - ❌ Partial output preservation on abort diff --git a/README.zh-CN.md b/README.zh-CN.md index 97bbf097..179614ac 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -229,7 +229,7 @@ WASM ──► 白名单 ──► 泄露扫描 ──► 凭据 ──► 执 │ │ │ │ │ ┌──────────▼────┐ ┌──▼───────────────┐ │ │ │ 调度器 │ │ 定时任务引擎 │ │ -│ │ (并行任务) │ │(cron, 事件, wh) │ │ +│ │ (并行任务) │ │(cron, 事件, Webhook)│ │ │ └──────┬────────┘ └────────┬─────────┘ │ │ │ │ │ │ ┌─────────────┼────────────────────┘ │ diff --git a/src/agent/thread_ops.rs b/src/agent/thread_ops.rs index e7f526e3..786c6d68 100644 --- a/src/agent/thread_ops.rs +++ b/src/agent/thread_ops.rs @@ -925,14 +925,20 @@ impl Agent { for (idx, tc) in deferred_tool_calls.iter().enumerate() { if let Some(tool) = self.tools().get(&tc.name).await { - 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) + // 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, } - ApprovalRequirement::Always => true, }; if needs_approval { diff --git a/src/channels/wasm/signature.rs b/src/channels/wasm/signature.rs index 8b48d88c..2253bff5 100644 --- a/src/channels/wasm/signature.rs +++ b/src/channels/wasm/signature.rs @@ -106,6 +106,34 @@ 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::::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::*; @@ -498,6 +526,24 @@ 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::::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"; diff --git a/src/channels/web/mod.rs b/src/channels/web/mod.rs index b0e1d29e..4c575caf 100644 --- a/src/channels/web/mod.rs +++ b/src/channels/web/mod.rs @@ -244,6 +244,12 @@ 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 diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 48d3407c..825685b5 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -318,7 +318,11 @@ 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("/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)); // Project file serving (behind auth to prevent unauthorized file access). let projects = Router::new() @@ -430,6 +434,46 @@ 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 { diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index 42ce6ba2..7ca9a25b 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -55,7 +55,7 @@ let _activityThinking = null; function authenticate() { token = document.getElementById('token-input').value.trim(); if (!token) { - document.getElementById('auth-error').textContent = 'Token required'; + document.getElementById('auth-error').textContent = I18n.t('auth.errorRequired'); 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 = 'Invalid token'; + document.getElementById('auth-error').textContent = I18n.t('auth.errorInvalid'); }); } @@ -144,7 +144,7 @@ let restartEnabled = false; // Track if restart is available in this deployment function triggerRestart() { if (!currentThreadId) { - alert('Please start a conversation first'); + alert(I18n.t('error.startConversation')); return; } @@ -155,7 +155,7 @@ function triggerRestart() { function confirmRestart() { if (!currentThreadId) { - alert('Please start a conversation first'); + alert(I18n.t('error.startConversation')); return; } @@ -190,7 +190,7 @@ function confirmRestart() { }) .catch((err) => { console.error('[confirmRestart] Restart request failed:', err); - addMessage('system', 'Restart failed: ' + err.message); + addMessage('system', I18n.t('error.restartFailed', { message: 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 = 'Connected'; + document.getElementById('sse-status').textContent = I18n.t('status.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 = 'Reconnecting...'; + document.getElementById('sse-status').textContent = I18n.t('status.reconnecting'); }; eventSource.addEventListener('response', (e) => { @@ -464,7 +464,7 @@ function enableChatInput() { const btn = document.getElementById('send-btn'); if (input) { input.disabled = false; - input.placeholder = 'Message or / for commands...'; + input.placeholder = I18n.t('chat.inputPlaceholder'); } if (btn) btn.disabled = false; } @@ -703,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 = 'Copied!'; - setTimeout(() => { btn.textContent = 'Copy'; }, 1500); + btn.textContent = I18n.t('btn.copied'); + setTimeout(() => { btn.textContent = I18n.t('btn.copy'); }, 1500); }); } @@ -991,7 +991,7 @@ function showApproval(data) { const header = document.createElement('div'); header.className = 'approval-header'; - header.textContent = 'Tool requires approval'; + header.textContent = I18n.t('approval.title'); card.appendChild(header); const toolName = document.createElement('div'); @@ -1009,7 +1009,7 @@ function showApproval(data) { if (data.parameters) { const paramsToggle = document.createElement('button'); paramsToggle.className = 'approval-params-toggle'; - paramsToggle.textContent = 'Show parameters'; + paramsToggle.textContent = I18n.t('approval.showParams'); const paramsBlock = document.createElement('pre'); paramsBlock.className = 'approval-params'; paramsBlock.textContent = data.parameters; @@ -1017,7 +1017,7 @@ function showApproval(data) { paramsToggle.addEventListener('click', () => { const visible = paramsBlock.style.display !== 'none'; paramsBlock.style.display = visible ? 'none' : 'block'; - paramsToggle.textContent = visible ? 'Show parameters' : 'Hide parameters'; + paramsToggle.textContent = visible ? I18n.t('approval.showParams') : I18n.t('approval.hideParams'); }); card.appendChild(paramsToggle); card.appendChild(paramsBlock); @@ -1028,17 +1028,17 @@ function showApproval(data) { const approveBtn = document.createElement('button'); approveBtn.className = 'approve'; - approveBtn.textContent = 'Approve'; + approveBtn.textContent = I18n.t('approval.approve'); approveBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'approve')); const alwaysBtn = document.createElement('button'); alwaysBtn.className = 'always'; - alwaysBtn.textContent = 'Always'; + alwaysBtn.textContent = I18n.t('approval.always'); alwaysBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'always')); const denyBtn = document.createElement('button'); denyBtn.className = 'deny'; - denyBtn.textContent = 'Deny'; + denyBtn.textContent = I18n.t('approval.deny'); denyBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'deny')); actions.appendChild(approveBtn); @@ -1065,7 +1065,7 @@ function showJobCard(data) { const title = document.createElement('div'); title.className = 'job-card-title'; - title.textContent = data.title || 'Sandbox Job'; + title.textContent = data.title || I18n.t('sandbox.job'); info.appendChild(title); const id = document.createElement('div'); @@ -1077,7 +1077,7 @@ function showJobCard(data) { const viewBtn = document.createElement('button'); viewBtn.className = 'job-card-view'; - viewBtn.textContent = 'View Job'; + viewBtn.textContent = I18n.t('jobs.viewJob'); viewBtn.addEventListener('click', () => { switchTab('jobs'); openJobDetail(data.job_id); @@ -1089,7 +1089,7 @@ function showJobCard(data) { browseBtn.className = 'job-card-browse'; browseBtn.href = data.browse_url; browseBtn.target = '_blank'; - browseBtn.textContent = 'Browse'; + browseBtn.textContent = I18n.t('jobs.browse'); card.appendChild(browseBtn); } @@ -1110,7 +1110,7 @@ function showAuthCard(data) { const header = document.createElement('div'); header.className = 'auth-header'; - header.textContent = 'Authentication required for ' + data.extension_name; + header.textContent = I18n.t('authRequired.title', {name: data.extension_name}); card.appendChild(header); if (data.instructions) { @@ -1126,7 +1126,7 @@ function showAuthCard(data) { if (data.auth_url) { const oauthBtn = document.createElement('button'); oauthBtn.className = 'auth-oauth'; - oauthBtn.textContent = 'Authenticate with ' + data.extension_name; + oauthBtn.textContent = I18n.t('authRequired.authenticateWith', {name: data.extension_name}); oauthBtn.addEventListener('click', () => { openOAuthUrl(data.auth_url); }); @@ -1137,7 +1137,7 @@ function showAuthCard(data) { const setupLink = document.createElement('a'); setupLink.href = data.setup_url; setupLink.target = '_blank'; - setupLink.textContent = 'Get your token'; + setupLink.textContent = I18n.t('authRequired.getToken'); links.appendChild(setupLink); } @@ -1151,7 +1151,9 @@ function showAuthCard(data) { const tokenInput = document.createElement('input'); tokenInput.type = 'password'; - tokenInput.placeholder = data.instructions || 'Paste your API key or token'; + tokenInput.placeholder = data.instructions + || I18n.t('auth.extensionTokenPlaceholder') + || I18n.t('auth.tokenPlaceholder'); tokenInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') submitAuthToken(data.extension_name, tokenInput.value); }); @@ -1170,12 +1172,12 @@ function showAuthCard(data) { const submitBtn = document.createElement('button'); submitBtn.className = 'auth-submit'; - submitBtn.textContent = 'Submit'; + submitBtn.textContent = I18n.t('btn.submit'); submitBtn.addEventListener('click', () => submitAuthToken(data.extension_name, tokenInput.value)); const cancelBtn = document.createElement('button'); cancelBtn.className = 'auth-cancel'; - cancelBtn.textContent = 'Cancel'; + cancelBtn.textContent = I18n.t('btn.cancel'); cancelBtn.addEventListener('click', () => cancelAuth(data.extension_name)); actions.appendChild(submitBtn); @@ -1967,7 +1969,7 @@ function prependLogEntry(entry) { function toggleLogsPause() { logsPaused = !logsPaused; const btn = document.getElementById('logs-pause-btn'); - btn.textContent = logsPaused ? 'Resume' : 'Pause'; + btn.textContent = logsPaused ? I18n.t('logs.resume') : I18n.t('logs.pause'); if (!logsPaused) { // Flush buffer: oldest-first + prepend naturally puts newest at top @@ -2039,7 +2041,7 @@ function loadExtensions() { ]).then(([extData, toolData, registryData]) => { // Render installed extensions if (extData.extensions.length === 0) { - extList.innerHTML = '
No extensions installed
'; + extList.innerHTML = '
' + I18n.t('extensions.noInstalled') + '
'; } else { extList.innerHTML = ''; for (const ext of extData.extensions) { @@ -2053,7 +2055,7 @@ function loadExtensions() { // Available WASM extensions if (wasmEntries.length === 0) { - wasmList.innerHTML = '
No additional WASM extensions available
'; + wasmList.innerHTML = '
' + I18n.t('extensions.noAvailable') + '
'; } else { wasmList.innerHTML = ''; for (const entry of wasmEntries) { @@ -2063,7 +2065,7 @@ function loadExtensions() { // MCP servers (show both installed and uninstalled) if (mcpEntries.length === 0) { - mcpList.innerHTML = '
No MCP servers available
'; + mcpList.innerHTML = '
' + I18n.t('mcp.noServers') + '
'; } else { mcpList.innerHTML = ''; for (const entry of mcpEntries) { @@ -2128,16 +2130,16 @@ function renderAvailableExtensionCard(entry) { const installBtn = document.createElement('button'); installBtn.className = 'btn-ext install'; - installBtn.textContent = 'Install'; + installBtn.textContent = I18n.t('extensions.install'); installBtn.addEventListener('click', function() { installBtn.disabled = true; - installBtn.textContent = 'Installing...'; + installBtn.textContent = I18n.t('extensions.installing'); apiFetch('/api/extensions/install', { method: 'POST', body: { name: entry.name, kind: entry.kind }, }).then(function(res) { if (res.success) { - showToast('Installed ' + entry.display_name, 'success'); + showToast(I18n.t('extensions.installedSuccess', {name: 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'); @@ -2201,39 +2203,39 @@ function renderMcpServerCard(entry, installedExt) { if (!installedExt.active) { var activateBtn = document.createElement('button'); activateBtn.className = 'btn-ext activate'; - activateBtn.textContent = 'Activate'; + activateBtn.textContent = I18n.t('common.activate'); activateBtn.addEventListener('click', function() { activateExtension(installedExt.name); }); actions.appendChild(activateBtn); } else { var activeLabel = document.createElement('span'); activeLabel.className = 'ext-active-label'; - activeLabel.textContent = 'Active'; + activeLabel.textContent = I18n.t('ext.active'); actions.appendChild(activeLabel); } var removeBtn = document.createElement('button'); removeBtn.className = 'btn-ext remove'; - removeBtn.textContent = 'Remove'; + removeBtn.textContent = I18n.t('ext.remove'); removeBtn.addEventListener('click', function() { removeExtension(installedExt.name); }); actions.appendChild(removeBtn); } else { var installBtn = document.createElement('button'); installBtn.className = 'btn-ext install'; - installBtn.textContent = 'Install'; + installBtn.textContent = I18n.t('ext.install'); installBtn.addEventListener('click', function() { installBtn.disabled = true; - installBtn.textContent = 'Installing...'; + installBtn.textContent = I18n.t('ext.installing'); apiFetch('/api/extensions/install', { method: 'POST', body: { name: entry.name, kind: entry.kind }, }).then(function(res) { if (res.success) { - showToast('Installed ' + entry.display_name, 'success'); + showToast(I18n.t('extensions.installedSuccess', { name: entry.display_name }), 'success'); } else { - showToast('Install: ' + (res.message || 'unknown error'), 'error'); + showToast(I18n.t('ext.install') + ': ' + (res.message || 'unknown error'), 'error'); } loadExtensions(); }).catch(function(err) { - showToast('Install failed: ' + err.message, 'error'); + showToast(I18n.t('ext.installFailed', { message: err.message }), 'error'); loadExtensions(); }); }); @@ -2247,7 +2249,7 @@ function renderMcpServerCard(entry, installedExt) { function createReconfigureButton(extName) { var btn = document.createElement('button'); btn.className = 'btn-ext configure'; - btn.textContent = 'Reconfigure'; + btn.textContent = I18n.t('ext.reconfigure'); btn.addEventListener('click', function() { showConfigureModal(extName); }); return btn; } @@ -2331,13 +2333,13 @@ function renderExtensionCard(ext) { if (status === 'active') { var activeLabel = document.createElement('span'); activeLabel.className = 'ext-active-label'; - activeLabel.textContent = 'Active'; + activeLabel.textContent = I18n.t('ext.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 = 'Awaiting Pairing'; + pairingLabel.textContent = I18n.t('status.awaitingPairing'); actions.appendChild(pairingLabel); actions.appendChild(createReconfigureButton(ext.name)); } else if (status === 'failed') { @@ -2346,7 +2348,7 @@ function renderExtensionCard(ext) { // installed or configured: show Setup button var setupBtn = document.createElement('button'); setupBtn.className = 'btn-ext configure'; - setupBtn.textContent = 'Setup'; + setupBtn.textContent = I18n.t('ext.setup'); setupBtn.addEventListener('click', function() { showConfigureModal(ext.name); }); actions.appendChild(setupBtn); } @@ -2354,14 +2356,14 @@ function renderExtensionCard(ext) { // WASM tools / MCP servers const activeLabel = document.createElement('span'); activeLabel.className = 'ext-active-label'; - activeLabel.textContent = ext.active ? 'Active' : 'Installed'; + activeLabel.textContent = ext.active ? I18n.t('ext.active') : I18n.t('status.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 = 'Activate'; + activateBtn.textContent = I18n.t('common.activate'); activateBtn.addEventListener('click', () => activateExtension(ext.name)); actions.appendChild(activateBtn); } @@ -2373,7 +2375,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 ? 'Reconfigure' : 'Configure'; + configBtn.textContent = ext.authenticated ? I18n.t('ext.reconfigure') : I18n.t('ext.configure'); configBtn.addEventListener('click', () => showConfigureModal(ext.name)); actions.appendChild(configBtn); } @@ -2381,7 +2383,7 @@ function renderExtensionCard(ext) { const removeBtn = document.createElement('button'); removeBtn.className = 'btn-ext remove'; - removeBtn.textContent = 'Remove'; + removeBtn.textContent = I18n.t('ext.remove'); removeBtn.addEventListener('click', () => removeExtension(ext.name)); actions.appendChild(removeBtn); @@ -2426,17 +2428,17 @@ function activateExtension(name) { } function removeExtension(name) { - if (!confirm('Remove extension "' + name + '"?')) return; + if (!confirm(I18n.t('ext.confirmRemove', { name: name }))) return; apiFetch('/api/extensions/' + encodeURIComponent(name) + '/remove', { method: 'POST' }) .then((res) => { if (!res.success) { - showToast('Remove failed: ' + res.message, 'error'); + showToast(I18n.t('ext.removeFailed', { message: res.message }), 'error'); } else { - showToast('Removed ' + name, 'success'); + showToast(I18n.t('ext.removed', { name: name }), 'success'); } loadExtensions(); }) - .catch((err) => showToast('Remove failed: ' + err.message, 'error')); + .catch((err) => showToast(I18n.t('ext.removeFailed', { message: err.message }), 'error')); } function showConfigureModal(name) { @@ -2463,7 +2465,7 @@ function renderConfigureModal(name, secrets) { modal.className = 'configure-modal'; const header = document.createElement('h3'); - header.textContent = 'Configure ' + name; + header.textContent = I18n.t('config.title', { name: name }); modal.appendChild(header); const form = document.createElement('div'); @@ -2479,7 +2481,7 @@ function renderConfigureModal(name, secrets) { if (secret.optional) { const opt = document.createElement('span'); opt.className = 'field-optional'; - opt.textContent = ' (optional)'; + opt.textContent = I18n.t('config.optional'); label.appendChild(opt); } field.appendChild(label); @@ -2490,7 +2492,7 @@ function renderConfigureModal(name, secrets) { const input = document.createElement('input'); input.type = 'password'; input.name = secret.name; - input.placeholder = secret.provided ? '(already set — leave empty to keep)' : ''; + input.placeholder = secret.provided ? I18n.t('config.alreadySet') : ''; input.addEventListener('keydown', (e) => { if (e.key === 'Enter') submitConfigureModal(name, fields); }); @@ -2500,13 +2502,13 @@ function renderConfigureModal(name, secrets) { const badge = document.createElement('span'); badge.className = 'field-provided'; badge.textContent = '\u2713'; - badge.title = 'Already configured'; + badge.title = I18n.t('config.alreadyConfigured'); inputRow.appendChild(badge); } if (secret.auto_generate && !secret.provided) { const hint = document.createElement('span'); hint.className = 'field-autogen'; - hint.textContent = 'Auto-generated if empty'; + hint.textContent = I18n.t('config.autoGenerate'); inputRow.appendChild(hint); } @@ -2522,13 +2524,13 @@ function renderConfigureModal(name, secrets) { const submitBtn = document.createElement('button'); submitBtn.className = 'btn-ext activate'; - submitBtn.textContent = 'Save'; + submitBtn.textContent = I18n.t('config.save'); submitBtn.addEventListener('click', () => submitConfigureModal(name, fields)); actions.appendChild(submitBtn); const cancelBtn = document.createElement('button'); cancelBtn.className = 'btn-ext remove'; - cancelBtn.textContent = 'Cancel'; + cancelBtn.textContent = I18n.t('config.cancel'); cancelBtn.addEventListener('click', closeConfigureModal); actions.appendChild(cancelBtn); @@ -2768,11 +2770,11 @@ function loadJobs() { function renderJobsSummary(s) { document.getElementById('jobs-summary').innerHTML = '' - + 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'); + + 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'); } function summaryCard(label, count, cls) { @@ -3302,11 +3304,11 @@ function loadRoutines() { function renderRoutinesSummary(s) { document.getElementById('routines-summary').innerHTML = '' - + 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'); + + 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'); } function renderRoutinesList(routines) { @@ -3472,17 +3474,18 @@ function formatRelativeTime(isoString) { const absDiff = Math.abs(diffMs); const future = diffMs < 0; - if (absDiff < 60000) return future ? 'in <1m' : '<1m ago'; + if (absDiff < 60000) + return future ? I18n.t('time.lessThan1MinuteFromNow') : I18n.t('time.lessThan1MinuteAgo'); if (absDiff < 3600000) { const m = Math.floor(absDiff / 60000); - return future ? 'in ' + m + 'm' : m + 'm ago'; + return future ? I18n.t('time.minutesFromNow', { n: m }) : I18n.t('time.minutesAgo', { n: m }); } if (absDiff < 86400000) { const h = Math.floor(absDiff / 3600000); - return future ? 'in ' + h + 'h' : h + 'h ago'; + return future ? I18n.t('time.hoursFromNow', { n: h }) : I18n.t('time.hoursAgo', { n: h }); } const days = Math.floor(absDiff / 86400000); - return future ? 'in ' + days + 'd' : days + 'd ago'; + return future ? I18n.t('time.daysFromNow', { n: days }) : I18n.t('time.daysAgo', { n: days }); } // --- Gateway status widget --- @@ -3532,18 +3535,18 @@ function fetchGatewayStatus() { } // Connection info - html += ''; - html += '
SSE' + (data.sse_connections || 0) + '
'; - html += '
WebSocket' + (data.ws_connections || 0) + '
'; - html += '
Uptime' + formatDuration(data.uptime_secs) + '
'; + html += ''; + html += '
' + I18n.t('dashboard.sse') + '' + (data.sse_connections || 0) + '
'; + html += '
' + I18n.t('dashboard.websocket') + '' + (data.ws_connections || 0) + '
'; + html += '
' + I18n.t('dashboard.uptime') + '' + formatDuration(data.uptime_secs) + '
'; // Cost tracker if (data.daily_cost != null) { html += '
'; - html += ''; - html += '
Spent' + formatCost(data.daily_cost) + '
'; + html += ''; + html += '
' + I18n.t('dashboard.spent') + '' + formatCost(data.daily_cost) + '
'; if (data.actions_this_hour != null) { - html += '
Actions/hr' + data.actions_this_hour + '
'; + html += '
' + I18n.t('dashboard.actionsPerHour') + '' + data.actions_this_hour + '
'; } } @@ -3751,7 +3754,7 @@ function loadSkills() { var skillsList = document.getElementById('skills-list'); apiFetch('/api/skills').then(function(data) { if (!data.skills || data.skills.length === 0) { - skillsList.innerHTML = '
No skills installed
'; + skillsList.innerHTML = '
' + I18n.t('skills.noInstalled') + '
'; return; } skillsList.innerHTML = ''; @@ -3759,7 +3762,7 @@ function loadSkills() { skillsList.appendChild(renderSkillCard(data.skills[i])); } }).catch(function(err) { - skillsList.innerHTML = '
Failed to load skills: ' + escapeHtml(err.message) + '
'; + skillsList.innerHTML = '
' + I18n.t('skills.loadFailed', {message: escapeHtml(err.message)}) + '
'; }); } @@ -3796,7 +3799,7 @@ function renderSkillCard(skill) { if (skill.keywords && skill.keywords.length > 0) { var kw = document.createElement('div'); kw.className = 'ext-keywords'; - kw.textContent = 'Activates on: ' + skill.keywords.join(', '); + kw.textContent = I18n.t('skills.activatesOn') + ': ' + skill.keywords.join(', '); card.appendChild(kw); } @@ -3807,7 +3810,7 @@ function renderSkillCard(skill) { if (skill.trust.toLowerCase() !== 'trusted') { var removeBtn = document.createElement('button'); removeBtn.className = 'btn-ext remove'; - removeBtn.textContent = 'Remove'; + removeBtn.textContent = I18n.t('skills.remove'); removeBtn.addEventListener('click', function() { removeSkill(skill.name); }); actions.appendChild(removeBtn); } @@ -3822,7 +3825,7 @@ function searchClawHub() { if (!query) return; var resultsDiv = document.getElementById('skill-search-results'); - resultsDiv.innerHTML = '
Searching...
'; + resultsDiv.innerHTML = '
' + I18n.t('skills.searching') + '
'; apiFetch('/api/skills/search', { method: 'POST', @@ -3838,7 +3841,7 @@ function searchClawHub() { warning.style.borderLeft = '3px solid #f0ad4e'; warning.style.paddingLeft = '12px'; warning.style.marginBottom = '16px'; - warning.textContent = 'Could not reach ClawHub registry: ' + data.catalog_error; + warning.textContent = I18n.t('skills.registryError', {message: data.catalog_error}); resultsDiv.appendChild(warning); } @@ -3870,10 +3873,10 @@ function searchClawHub() { } if (resultsDiv.children.length === 0) { - resultsDiv.innerHTML = '
No skills found for "' + escapeHtml(query) + '"
'; + resultsDiv.innerHTML = '
' + I18n.t('skills.noResults', {query: escapeHtml(query)}) + '
'; } }).catch(function(err) { - resultsDiv.innerHTML = '
Search failed: ' + escapeHtml(err.message) + '
'; + resultsDiv.innerHTML = '
' + I18n.t('skills.searchFailed', {message: escapeHtml(err.message)}) + '
'; }); } @@ -3967,17 +3970,17 @@ function renderCatalogSkillCard(entry, installedNames) { if (isInstalled) { var label = document.createElement('span'); label.className = 'ext-active-label'; - label.textContent = 'Installed'; + label.textContent = I18n.t('status.installed'); actions.appendChild(label); } else { var installBtn = document.createElement('button'); installBtn.className = 'btn-ext install'; - installBtn.textContent = 'Install'; + installBtn.textContent = I18n.t('extensions.install'); installBtn.addEventListener('click', (function(s, btn) { return function() { if (!confirm('Install skill "' + s + '" from ClawHub?')) return; btn.disabled = true; - btn.textContent = 'Installing...'; + btn.textContent = I18n.t('extensions.installing'); installSkill(s, null, btn); }; })(slug, installBtn)); @@ -4019,7 +4022,7 @@ function installSkill(nameOrSlug, url, btn) { body: body, }).then(function(res) { if (res.success) { - showToast('Installed skill "' + nameOrSlug + '"', 'success'); + showToast(I18n.t('skills.installedSuccess', {name: nameOrSlug}), 'success'); } else { showToast('Install failed: ' + (res.message || 'unknown error'), 'error'); } @@ -4032,19 +4035,19 @@ function installSkill(nameOrSlug, url, btn) { } function removeSkill(name) { - if (!confirm('Remove skill "' + name + '"?')) return; + if (!confirm(I18n.t('skills.confirmRemove', { name: name }))) return; apiFetch('/api/skills/' + encodeURIComponent(name), { method: 'DELETE', headers: { 'X-Confirm-Action': 'true' }, }).then(function(res) { if (res.success) { - showToast('Removed skill "' + name + '"', 'success'); + showToast(I18n.t('skills.removed', { name: name }), 'success'); } else { - showToast('Remove failed: ' + (res.message || 'unknown error'), 'error'); + showToast(I18n.t('skills.removeFailed', { message: res.message || 'unknown error' }), 'error'); } loadSkills(); }).catch(function(err) { - showToast('Remove failed: ' + err.message, 'error'); + showToast(I18n.t('skills.removeFailed', { message: err.message }), 'error'); }); } diff --git a/src/channels/web/static/i18n-app.js b/src/channels/web/static/i18n-app.js new file mode 100644 index 00000000..87624b96 --- /dev/null +++ b/src/channels/web/static/i18n-app.js @@ -0,0 +1,74 @@ +// 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'; + } + } +}); + diff --git a/src/channels/web/static/i18n/en.js b/src/channels/web/static/i18n/en.js new file mode 100644 index 00000000..b637f144 --- /dev/null +++ b/src/channels/web/static/i18n/en.js @@ -0,0 +1,351 @@ +// 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 for a specific job', + 'cmd.list.desc': 'List all jobs', + 'cmd.cancel.desc': '/cancel — 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', +}); diff --git a/src/channels/web/static/i18n/index.js b/src/channels/web/static/i18n/index.js new file mode 100644 index 00000000..4c92bcc5 --- /dev/null +++ b/src/channels/web/static/i18n/index.js @@ -0,0 +1,89 @@ +// 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; diff --git a/src/channels/web/static/i18n/zh-CN.js b/src/channels/web/static/i18n/zh-CN.js new file mode 100644 index 00000000..8a7fd520 --- /dev/null +++ b/src/channels/web/static/i18n/zh-CN.js @@ -0,0 +1,351 @@ +// 中文语言包 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 查看特定任务', + 'cmd.list.desc': '列出所有任务', + 'cmd.cancel.desc': '/cancel — 取消正在运行的任务', + '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': '取消', +}); diff --git a/src/channels/web/static/index.html b/src/channels/web/static/index.html index b4a78a12..6f21b428 100644 --- a/src/channels/web/static/index.html +++ b/src/channels/web/static/index.html @@ -9,6 +9,12 @@ + + + + + + + diff --git a/src/channels/web/static/style.css b/src/channels/web/static/style.css index 1536f9e9..a0985ce3 100644 --- a/src/channels/web/static/style.css +++ b/src/channels/web/static/style.css @@ -3885,6 +3885,61 @@ 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); diff --git a/src/channels/webhook_server.rs b/src/channels/webhook_server.rs index f20d07e4..2425ab32 100644 --- a/src/channels/webhook_server.rs +++ b/src/channels/webhook_server.rs @@ -24,7 +24,7 @@ pub struct WebhookServerConfig { pub struct WebhookServer { config: WebhookServerConfig, routes: Vec, - /// Merged router saved after start() for restart_with_addr(). + /// Merged router saved after start() for restarts via `install_listener()`. merged_router: Option, shutdown_tx: Option>, handle: Option>, @@ -59,7 +59,7 @@ impl WebhookServer { } /// Bind a listener to the configured address and spawn the server task. - /// Private helper used by both start() and restart_with_addr(). + /// Private helper used by `start()`. async fn bind_and_spawn(&mut self, app: Router) -> Result<(), ChannelError> { let listener = tokio::net::TcpListener::bind(self.config.addr) .await @@ -89,47 +89,49 @@ impl WebhookServer { Ok(()) } - /// Gracefully shut down the current listener and rebind to a new address. - /// The merged router from the original `start()` call is reused. - /// - /// 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(), - })?; + /// Clone the merged router, if `start()` has been called. + pub fn merged_router_clone(&self) -> Option { + self.merged_router.clone() + } - // Save old state for rollback if new bind fails - let old_addr = self.config.addr; + /// Install a pre-bound listener, replacing the current one. + /// + /// 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>, Option>) { + // Capture old handles so the caller can shut them down outside the lock. 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; - 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(()) + + // 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); } - 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) - } - } + }); + self.handle = Some(handle); + + tracing::info!("Webhook server listening on {}", new_addr); + + (old_shutdown_tx, old_handle) } /// Return the current bind address. @@ -213,12 +215,21 @@ mod tests { "First server should respond to health check" ); - // Restart on second port - let addr2 = format!("127.0.0.1:{}", port2).parse().unwrap(); - server - .restart_with_addr(addr2) + // 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) .await - .expect("Failed to restart with new addr"); + .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; + } // Assert the address changed assert_eq!( @@ -295,13 +306,18 @@ mod tests { .expect("Failed to send request"); assert_eq!(response.status(), 200, "Server should be listening"); - // Try to restart on an invalid address (port 0 is reserved, won't bind) - // Use port 1 which typically requires elevated privileges + // Try to restart on an invalid address (port 1 typically requires elevated privileges) let invalid_addr: SocketAddr = "127.0.0.1:1".parse().unwrap(); - // Attempt restart (should fail) - let result = server.restart_with_addr(invalid_addr).await; - assert!(result.is_err(), "Restart with invalid address should fail"); + // 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); // Verify the old address is still responding (rollback succeeded) let response = client diff --git a/src/context/mod.rs b/src/context/mod.rs index a155db17..a7dd61de 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -12,4 +12,4 @@ mod state; pub use manager::ContextManager; pub use memory::{ActionRecord, ConversationMemory, Memory}; -pub use state::{JobContext, JobState, StateTransition}; +pub use state::{JobContext, JobState, StateTransition, TokenBudgetExceeded}; diff --git a/src/context/state.rs b/src/context/state.rs index a55cb8d1..22aca311 100644 --- a/src/context/state.rs +++ b/src/context/state.rs @@ -11,6 +11,16 @@ 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")] @@ -265,15 +275,15 @@ impl JobContext { self.actual_cost += cost; } - /// 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> { + /// 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> { self.total_tokens_used += tokens; if self.max_tokens > 0 && self.total_tokens_used > self.max_tokens { - Err(format!( - "Token budget exceeded: used {} of {} allowed tokens", - self.total_tokens_used, self.max_tokens - )) + Err(TokenBudgetExceeded { + used: self.total_tokens_used, + limit: self.max_tokens, + }) } else { Ok(()) } diff --git a/src/import/openclaw/mod.rs b/src/import/openclaw/mod.rs index 5a28d197..acd3b984 100644 --- a/src/import/openclaw/mod.rs +++ b/src/import/openclaw/mod.rs @@ -77,7 +77,7 @@ impl OpenClawImporter { // 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) { + match reader.read_conversations(db_path).await { Ok(convs) => all_conversations.extend(convs), Err(e) => { tracing::warn!("Failed to read conversations: {}", e); @@ -88,7 +88,7 @@ impl OpenClawImporter { // 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) { + match reader.read_memory_chunks(db_path).await { Ok(chunks) => all_chunks.extend(chunks), Err(e) => { tracing::warn!("Failed to read memory chunks: {}", e); diff --git a/src/import/openclaw/reader.rs b/src/import/openclaw/reader.rs index f6694865..0a77df95 100644 --- a/src/import/openclaw/reader.rs +++ b/src/import/openclaw/reader.rs @@ -80,6 +80,16 @@ pub struct OpenClawMessage { pub created_at: Option>, } +/// Open an OpenClaw SQLite database file via libsql for read-only access. +#[cfg(feature = "import")] +async fn open_sqlite(db_path: &Path) -> Result { + 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, @@ -226,51 +236,52 @@ impl OpenClawReader { /// Read all memory chunks from an OpenClaw SQLite database. #[cfg(feature = "import")] - pub fn read_memory_chunks( + pub async fn read_memory_chunks( &self, db_path: &Path, ) -> Result, ImportError> { - use rusqlite::Connection; + let conn = open_sqlite(db_path).await?; - let conn = Connection::open(db_path).map_err(|e| ImportError::Sqlite(e.to_string()))?; - - let mut stmt = conn - .prepare("SELECT path, content, embedding, chunk_index FROM chunks") - .map_err(|e| ImportError::Sqlite(e.to_string()))?; - - let chunks = stmt - .query_map([], |row| { - let path: String = row.get(0)?; - let content: String = row.get(1)?; - let embedding_bytes: Option> = row.get(2)?; - let chunk_index: i32 = row.get(3)?; - - // Convert binary embedding blob to Vec if present - let embedding = embedding_bytes.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() - }); - - Ok(OpenClawMemoryChunk { - path, - content, - embedding, - chunk_index, - }) - }) + 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(); - for chunk_result in chunks { - result.push(chunk_result.map_err(|e| ImportError::Sqlite(e.to_string()))?); + 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> = + 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 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) @@ -278,63 +289,70 @@ impl OpenClawReader { /// Read all conversations from an OpenClaw SQLite database. #[cfg(feature = "import")] - pub fn read_conversations( + pub async fn read_conversations( &self, db_path: &Path, ) -> Result, ImportError> { - use rusqlite::Connection; + let conn = open_sqlite(db_path).await?; - let conn = Connection::open(db_path).map_err(|e| ImportError::Sqlite(e.to_string()))?; - - // First, read all conversations - let mut conv_stmt = conn - .prepare("SELECT id, channel, created_at FROM conversations ORDER BY created_at DESC") + 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(); - let conv_rows = conv_stmt - .query_map([], |row| { - let id: String = row.get(0)?; - let channel: String = row.get(1)?; - let created_at: Option = row.get(2)?; + 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 = + row.get(2).map_err(|e| ImportError::Sqlite(e.to_string()))?; - let created_at = created_at + 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 = 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)); - Ok((id, channel, created_at)) - }) - .map_err(|e| ImportError::Sqlite(e.to_string()))?; - - for row_result in conv_rows { - let (id, channel, created_at) = - row_result.map_err(|e| ImportError::Sqlite(e.to_string()))?; - - // Read messages for this conversation - let mut msg_stmt = conn.prepare( - "SELECT role, content, created_at FROM messages WHERE conversation_id = ? ORDER BY created_at" - ) - .map_err(|e| ImportError::Sqlite(e.to_string()))?; - - let messages = msg_stmt - .query_map([&id], |row| { - let role: String = row.get(0)?; - let content: String = row.get(1)?; - let created_at: Option = row.get(2)?; - - let created_at = created_at - .and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok()) - .map(|dt| dt.with_timezone(&chrono::Utc)); - - Ok(OpenClawMessage { - role, - content, - created_at, - }) - }) - .map_err(|e| ImportError::Sqlite(e.to_string()))? - .collect::, _>>() - .map_err(|e| ImportError::Sqlite(e.to_string()))?; + messages.push(OpenClawMessage { + role, + content, + created_at: msg_created_at, + }); + } conversations.push(OpenClawConversation { id, diff --git a/src/lib.rs b/src/lib.rs index 4ec8d906..51e54909 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -74,6 +74,7 @@ pub mod tracing_fmt; pub mod transcription; pub mod tunnel; pub mod util; +pub mod webhooks; pub mod worker; pub mod workspace; diff --git a/src/main.rs b/src/main.rs index 68f626d9..ebadb442 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,6 +24,7 @@ use ironclaw::{ orchestrator::{ReaperConfig, SandboxReaper}, pairing::PairingStore, tracing_fmt::{init_cli_tracing, init_worker_tracing}, + webhooks::{self, ToolWebhookState}, }; #[cfg(any(feature = "postgres", feature = "libsql"))] @@ -277,9 +278,25 @@ 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 = 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( @@ -431,7 +448,6 @@ async fn async_main() -> anyhow::Result<()> { let mut sse_sender: Option< tokio::sync::broadcast::Sender, > = None; - let mut routine_engine_slot: Option = 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)); @@ -455,6 +471,7 @@ 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)); } @@ -489,8 +506,6 @@ 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; } @@ -689,9 +704,7 @@ async fn async_main() -> anyhow::Result<()> { } // Give the agent the routine engine slot so it can expose the engine to the gateway. - if let Some(slot) = routine_engine_slot { - agent.set_routine_engine_slot(slot); - } + agent.set_routine_engine_slot(shared_routine_engine_slot); // Prepare SIGHUP handler for hot-reloading HTTP webhook config // Broadcast channel for clean shutdown of background tasks @@ -787,12 +800,12 @@ async fn async_main() -> anyhow::Result<()> { }; // Restart listener if addr changed. - // Minimize lock scope: acquire, read old addr, release, then restart. + // Two-phase approach: bind outside the lock, then swap under lock. let mut restart_failed = false; if let Some(ref ws_arc) = sighup_webhook_server { - let old_addr = { + let (old_addr, router) = { let ws = ws_arc.lock().await; - ws.current_addr() + (ws.current_addr(), ws.merged_router_clone()) }; // Lock released here if old_addr != new_addr { @@ -801,17 +814,45 @@ async fn async_main() -> anyhow::Result<()> { old_addr, new_addr ); - // 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); + + 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; + } + } } - Err(e) => { - tracing::error!("SIGHUP: listener restart failed: {}", e); + None => { + tracing::error!( + "SIGHUP: cannot restart — server was never started" + ); restart_failed = true; } } diff --git a/src/safety/validator.rs b/src/safety/validator.rs index d41ccc1f..a5e57917 100644 --- a/src/safety/validator.rs +++ b/src/safety/validator.rs @@ -197,13 +197,20 @@ 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 + // Recursively check all string values in the JSON. + // Depth is capped to prevent stack overflow on pathological input. + const MAX_DEPTH: usize = 32; + 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() { @@ -216,7 +223,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); + check_strings(item, &child_path, validator, result, depth + 1); } } serde_json::Value::Object(obj) => { @@ -226,14 +233,14 @@ impl Validator { } else { format!("{path}.{k}") }; - check_strings(v, &child_path, validator, result); + check_strings(v, &child_path, validator, result, depth + 1); } } _ => {} } } - check_strings(params, "", self, &mut result); + check_strings(params, "", self, &mut result, 0); result } } @@ -423,4 +430,42 @@ 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" + ); + } } diff --git a/src/tools/tool.rs b/src/tools/tool.rs index 8bf29168..b5879e5a 100644 --- a/src/tools/tool.rs +++ b/src/tools/tool.rs @@ -328,6 +328,14 @@ 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 { + None + } + /// Get the tool schema for LLM function calling. fn schema(&self) -> ToolSchema { ToolSchema { diff --git a/src/tools/wasm/capabilities.rs b/src/tools/wasm/capabilities.rs index 088d7e18..ff98ae03 100644 --- a/src/tools/wasm/capabilities.rs +++ b/src/tools/wasm/capabilities.rs @@ -32,6 +32,8 @@ pub struct Capabilities { pub tool_invoke: Option, /// Check if secrets exist. pub secrets: Option, + /// Webhook authentication and signature verification. + pub webhook: Option, } impl Capabilities { @@ -308,6 +310,25 @@ 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, + /// Secret name in secrets store for shared-secret validation. + pub secret_name: Option, + /// Secret name in secrets store containing Ed25519 public key (Discord-style). + pub signature_key_secret_name: Option, + /// Secret name in secrets store for HMAC-SHA256 signing validation. + pub hmac_secret_name: Option, + /// Header containing signature (e.g. X-Hub-Signature-256 or X-Slack-Signature). + pub hmac_signature_header: Option, + /// Optional timestamp header. When present, Slack-style v0 signature is used. + pub hmac_timestamp_header: Option, + /// Optional signature prefix (default: "sha256=" or "v0=" for timestamped mode). + pub hmac_prefix: Option, +} + #[cfg(test)] mod tests { use crate::tools::wasm::capabilities::{Capabilities, EndpointPattern, SecretsCapability}; @@ -319,6 +340,7 @@ mod tests { assert!(caps.http.is_none()); assert!(caps.tool_invoke.is_none()); assert!(caps.secrets.is_none()); + assert!(caps.webhook.is_none()); } #[test] diff --git a/src/tools/wasm/capabilities_schema.rs b/src/tools/wasm/capabilities_schema.rs index 9fa6e241..99007943 100644 --- a/src/tools/wasm/capabilities_schema.rs +++ b/src/tools/wasm/capabilities_schema.rs @@ -35,7 +35,7 @@ use serde::{Deserialize, Serialize}; use crate::secrets::{CredentialLocation, CredentialMapping}; use crate::tools::wasm::{ Capabilities, EndpointPattern, HttpCapability, RateLimitConfig, SecretsCapability, - ToolInvokeCapability, WorkspaceCapability, + ToolInvokeCapability, WebhookCapability, WorkspaceCapability, }; /// Root schema for a capabilities JSON file. @@ -65,6 +65,10 @@ pub struct CapabilitiesFile { #[serde(default)] pub workspace: Option, + /// Tool webhook authentication/signature configuration. + #[serde(default)] + pub webhook: Option, + /// Authentication setup instructions. /// Used by `ironclaw config` to guide users through auth setup. #[serde(default)] @@ -107,6 +111,7 @@ impl CapabilitiesFile { 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); } @@ -198,6 +203,10 @@ impl CapabilitiesFile { }); } + if let Some(webhook) = &self.webhook { + caps.webhook = Some(webhook.to_webhook_capability()); + } + caps } } @@ -419,6 +428,46 @@ pub struct WorkspaceCapabilitySchema { pub allowed_prefixes: Vec, } +/// 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, + /// Secret name in secrets store for shared-secret validation. + #[serde(default)] + pub secret_name: Option, + /// Secret name in secrets store containing Ed25519 public key. + #[serde(default)] + pub signature_key_secret_name: Option, + /// Secret name in secrets store for HMAC-SHA256 signing. + #[serde(default)] + pub hmac_secret_name: Option, + /// Signature header for HMAC verification. + #[serde(default)] + pub hmac_signature_header: Option, + /// Optional timestamp header for Slack-style v0 verification. + #[serde(default)] + pub hmac_timestamp_header: Option, + /// Optional signature prefix for body-only HMAC mode (default sha256=). + #[serde(default)] + pub hmac_prefix: Option, +} + +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 @@ -769,6 +818,28 @@ 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#"{ diff --git a/src/tools/wasm/mod.rs b/src/tools/wasm/mod.rs index 55b5b0cd..647d2837 100644 --- a/src/tools/wasm/mod.rs +++ b/src/tools/wasm/mod.rs @@ -108,7 +108,7 @@ pub use wrapper::{OAuthRefreshConfig, WasmToolWrapper}; // Capabilities (V2) pub use capabilities::{ Capabilities, EndpointPattern, HttpCapability, RateLimitConfig, SecretsCapability, - ToolInvokeCapability, WorkspaceCapability, WorkspaceReader, + ToolInvokeCapability, WebhookCapability, WorkspaceCapability, WorkspaceReader, }; // Security components (V2) diff --git a/src/tools/wasm/wrapper.rs b/src/tools/wasm/wrapper.rs index 26c2d5d1..c0294c51 100644 --- a/src/tools/wasm/wrapper.rs +++ b/src/tools/wasm/wrapper.rs @@ -808,6 +808,10 @@ impl Tool for WasmToolWrapper { // Use the timeout as a conservative estimate Some(self.prepared.limits.timeout) } + + fn webhook_capability(&self) -> Option { + self.capabilities.webhook.clone() + } } impl std::fmt::Debug for WasmToolWrapper { diff --git a/src/webhooks/mod.rs b/src/webhooks/mod.rs new file mode 100644 index 00000000..47f14300 --- /dev/null +++ b/src/webhooks/mod.rs @@ -0,0 +1,712 @@ +//! 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>>>; + +/// Shared state for the generic tools webhook ingress. +#[derive(Clone)] +pub struct ToolWebhookState { + pub tools: Arc, + pub routine_engine: RoutineEngineSlot, + pub user_id: String, + pub secrets_store: Option>, +} + +#[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, +} + +#[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, + State(state): State, +) -> (StatusCode, Json) { + 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, + State(state): State, + method: Method, + headers: HeaderMap, + Query(query): Query>, + body: axum::body::Bytes, +) -> (StatusCode, Json) { + 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, + method: Method, + headers: HeaderMap, + Query(query): Query>, + body: axum::body::Bytes, +) -> (StatusCode, Json) { + tool_webhook_handler_inner(tool, Some(rest), state, method, headers, query, body).await +} + +async fn tool_webhook_handler_inner( + tool: String, + rest: Option, + state: ToolWebhookState, + method: Method, + headers: HeaderMap, + query: HashMap, + body: axum::body::Bytes, +) -> (StatusCode, Json) { + 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::from_slice(&body).ok(); + let headers_map: HashMap = 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 { + 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 { + Ok(ToolOutput::success( + serde_json::json!({"emit_events":[]}), + Duration::from_millis(1), + )) + } + + fn webhook_capability(&self) -> Option { + 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 { + Ok(ToolOutput::success( + serde_json::json!({"emit_events":[]}), + Duration::from_millis(1), + )) + } + + fn webhook_capability(&self) -> Option { + 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 { + Ok(ToolOutput::success( + serde_json::json!({"emit_events":[]}), + Duration::from_millis(1), + )) + } + + fn webhook_capability(&self) -> Option { + 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::>::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::>::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::>::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::::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::>::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::>::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::>::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::>::oneshot(app, req) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } +} diff --git a/src/worker/job.rs b/src/worker/job.rs index ad5c7157..1f207435 100644 --- a/src/worker/job.rs +++ b/src/worker/job.rs @@ -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(msg) = self + && let Err(err) = self .worker .context_manager() .update_context(self.worker.job_id, |ctx| ctx.add_tokens(total_tokens)) .await? { - self.worker.mark_failed(&msg).await?; + self.worker.mark_failed(&err.to_string()).await?; } Ok(output) @@ -1796,7 +1796,7 @@ mod tests { // Verify that mark_failed transitions job to Failed worker - .mark_failed(&budget_result.unwrap_err()) + .mark_failed(&budget_result.unwrap_err().to_string()) .await .unwrap(); let ctx = worker diff --git a/tests/e2e_advanced_traces.rs b/tests/e2e_advanced_traces.rs index 92dd81f4..263e23c3 100644 --- a/tests/e2e_advanced_traces.rs +++ b/tests/e2e_advanced_traces.rs @@ -58,6 +58,7 @@ 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; @@ -95,7 +96,11 @@ 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).build().await; + let rig = TestRigBuilder::new() + .with_trace(trace) + .with_auto_approve_tools(true) + .build() + .await; rig.send_message("Write 'recovered successfully' to a file for me.") .await; @@ -138,7 +143,11 @@ 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).build().await; + let rig = TestRigBuilder::new() + .with_trace(trace) + .with_auto_approve_tools(true) + .build() + .await; rig.send_message( "Create a daily log at /tmp/ironclaw_chain_test/log.md, \ @@ -232,6 +241,7 @@ mod advanced { let rig = TestRigBuilder::new() .with_trace(trace) .with_max_tool_iterations(3) + .with_auto_approve_tools(true) .build() .await; @@ -242,8 +252,8 @@ mod advanced { let started = rig.tool_calls_started(); assert!( - started.len() <= 4, - "expected <= 4 tool calls with max_tool_iterations=3, got {}: {started:?}", + started.len() <= 8, + "expected <= 8 tool calls with max_tool_iterations=3, got {}: {started:?}", started.len() ); assert!(!started.is_empty(), "expected at least 1 tool call, got 0"); @@ -295,6 +305,7 @@ mod advanced { .with_trace(trace.clone()) .with_routines() .with_http_exchanges(http_exchanges) + .with_auto_approve_tools(true) .build() .await; diff --git a/tests/e2e_builtin_tool_coverage.rs b/tests/e2e_builtin_tool_coverage.rs index 4387ebc5..34cb35f7 100644 --- a/tests/e2e_builtin_tool_coverage.rs +++ b/tests/e2e_builtin_tool_coverage.rs @@ -140,6 +140,7 @@ mod tests { let rig = TestRigBuilder::new() .with_trace(trace.clone()) + .with_auto_approve_tools(true) .build() .await; @@ -180,6 +181,7 @@ mod tests { let rig = TestRigBuilder::new() .with_trace(trace.clone()) + .with_auto_approve_tools(true) .build() .await; @@ -325,6 +327,7 @@ mod tests { let rig = TestRigBuilder::new() .with_trace(trace.clone()) + .with_auto_approve_tools(true) .build() .await; @@ -394,6 +397,7 @@ mod tests { let rig = TestRigBuilder::new() .with_trace(trace.clone()) + .with_auto_approve_tools(true) .build() .await; @@ -435,6 +439,7 @@ mod tests { let rig = TestRigBuilder::new() .with_trace(trace.clone()) + .with_auto_approve_tools(true) .build() .await; diff --git a/tests/e2e_metrics_test.rs b/tests/e2e_metrics_test.rs index 5af612c3..7b0cdb4e 100644 --- a/tests/e2e_metrics_test.rs +++ b/tests/e2e_metrics_test.rs @@ -32,7 +32,11 @@ mod tests { )) .expect("failed to load simple_text.json"); - let rig = TestRigBuilder::new().with_trace(trace).build().await; + let rig = TestRigBuilder::new() + .with_trace(trace) + .with_auto_approve_tools(true) + .build() + .await; rig.send_message("hello").await; let _responses = rig.wait_for_responses(1, Duration::from_secs(10)).await; @@ -95,7 +99,11 @@ mod tests { )) .expect("failed to load file_write_read.json"); - let rig = TestRigBuilder::new().with_trace(trace).build().await; + let rig = TestRigBuilder::new() + .with_trace(trace) + .with_auto_approve_tools(true) + .build() + .await; rig.send_message("Please write a greeting to a file and read it back.") .await; diff --git a/tests/import_openclaw_comprehensive.rs b/tests/import_openclaw_comprehensive.rs index 96441751..53d869dd 100644 --- a/tests/import_openclaw_comprehensive.rs +++ b/tests/import_openclaw_comprehensive.rs @@ -47,15 +47,14 @@ mod comprehensive_import_tests { } /// Helper to create a synthetic SQLite database with memory chunks - fn create_synthetic_memory_db( + async fn create_synthetic_memory_db( agents_dir: &Path, ) -> Result> { - use rusqlite::Connection; - std::fs::create_dir_all(agents_dir)?; let db_path = agents_dir.join("test_agent.sqlite"); - let conn = Connection::open(&db_path)?; + let db = libsql::Builder::new_local(&db_path).build().await?; + let conn = db.connect()?; // Create chunks table (simplified schema) conn.execute( @@ -66,33 +65,36 @@ mod comprehensive_import_tests { embedding BLOB, chunk_index INTEGER NOT NULL )", - [], - )?; + (), + ) + .await?; // Insert test chunks conn.execute( "INSERT INTO chunks (id, path, content, embedding, chunk_index) VALUES (?, ?, ?, ?, ?)", - rusqlite::params![ + libsql::params![ Uuid::new_v4().to_string(), "test/doc.md", "This is test chunk 1 content.", - None::>, - 0 + libsql::Value::Null, + 0i64 ], - )?; + ) + .await?; conn.execute( "INSERT INTO chunks (id, path, content, embedding, chunk_index) VALUES (?, ?, ?, ?, ?)", - rusqlite::params![ + libsql::params![ Uuid::new_v4().to_string(), "test/doc.md", "This is test chunk 2 content.", - None::>, - 1 + libsql::Value::Null, + 1i64 ], - )?; + ) + .await?; // Create conversation table conn.execute( @@ -101,8 +103,9 @@ mod comprehensive_import_tests { channel TEXT NOT NULL, created_at TEXT )", - [], - )?; + (), + ) + .await?; // Create messages table conn.execute( @@ -114,40 +117,44 @@ mod comprehensive_import_tests { 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 (?, ?, ?)", - rusqlite::params![&conv_id, "telegram", "2024-01-15T10:30:00Z"], - )?; + 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 (?, ?, ?, ?, ?)", - rusqlite::params![ + libsql::params![ Uuid::new_v4().to_string(), - &conv_id, + 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 (?, ?, ?, ?, ?)", - rusqlite::params![ + libsql::params![ Uuid::new_v4().to_string(), - &conv_id, + conv_id.clone(), "assistant", "I'm doing well, thank you for asking!", "2024-01-15T10:31:00Z" ], - )?; + ) + .await?; Ok(db_path) } @@ -211,13 +218,15 @@ mod comprehensive_import_tests { let _ = temp_dir; } - #[test] - fn test_openclaw_reader_lists_agent_dbs() { + #[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).expect("failed to create test DB"); + 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"); @@ -230,18 +239,21 @@ mod comprehensive_import_tests { let _ = temp_dir; } - #[test] - fn test_openclaw_reader_reads_memory_chunks() { + #[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).expect("failed to create test DB"); + 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 @@ -260,18 +272,21 @@ mod comprehensive_import_tests { let _ = temp_dir; } - #[test] - fn test_openclaw_reader_reads_conversations() { + #[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).expect("failed to create test DB"); + 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 diff --git a/tests/import_openclaw_e2e.rs b/tests/import_openclaw_e2e.rs index 09dbd786..f74a5a4a 100644 --- a/tests/import_openclaw_e2e.rs +++ b/tests/import_openclaw_e2e.rs @@ -16,7 +16,8 @@ mod e2e_import_tests { use ironclaw::import::{ImportOptions, ImportStats}; /// Helper: Create a synthetic OpenClaw with full structure - fn setup_full_openclaw_test_env() -> Result<(TempDir, PathBuf), Box> { + async fn setup_full_openclaw_test_env() -> Result<(TempDir, PathBuf), Box> + { let temp_dir = TempDir::new()?; let openclaw_path = temp_dir.path().to_path_buf(); @@ -60,17 +61,16 @@ mod e2e_import_tests { let agents_dir = openclaw_path.join("agents"); std::fs::create_dir_all(&agents_dir)?; - create_full_agent_db(&agents_dir.join("primary_agent.sqlite"))?; - create_full_agent_db(&agents_dir.join("secondary_agent.sqlite"))?; + 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 - fn create_full_agent_db(db_path: &PathBuf) -> Result<(), Box> { - use rusqlite::Connection; - - let conn = Connection::open(db_path)?; + async fn create_full_agent_db(db_path: &PathBuf) -> Result<(), Box> { + let db = libsql::Builder::new_local(db_path).build().await?; + let conn = db.connect()?; // Chunks table conn.execute( @@ -81,22 +81,24 @@ mod e2e_import_tests { 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 (?, ?, ?, ?, ?)", - rusqlite::params![ + libsql::params![ Uuid::new_v4().to_string(), format!("notes/section_{}.md", i), format!("Content for section {}. This is important information.", i), - None::>, - i + libsql::Value::Null, + i as i64 ], - )?; + ) + .await?; } // Conversations table @@ -106,8 +108,9 @@ mod e2e_import_tests { channel TEXT NOT NULL, created_at TEXT )", - [], - )?; + (), + ) + .await?; // Messages table conn.execute( @@ -119,8 +122,9 @@ mod e2e_import_tests { created_at TEXT, FOREIGN KEY(conversation_id) REFERENCES conversations(id) )", - [], - )?; + (), + ) + .await?; // Insert 3 conversations with messages for conv_num in 0..3 { @@ -133,12 +137,13 @@ mod e2e_import_tests { conn.execute( "INSERT INTO conversations (id, channel, created_at) VALUES (?, ?, ?)", - rusqlite::params![ - &conv_id, + 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 { @@ -150,9 +155,9 @@ mod e2e_import_tests { conn.execute( "INSERT INTO messages (id, conversation_id, role, content, created_at) VALUES (?, ?, ?, ?, ?)", - rusqlite::params![ + libsql::params![ Uuid::new_v4().to_string(), - &conv_id, + conv_id.clone(), role, format!( "{} message {} from conversation {}", @@ -160,7 +165,8 @@ mod e2e_import_tests { ), format!("2024-01-{:02}T10:{:02}:00Z", 10 + conv_num, msg_num * 10) ], - )?; + ) + .await?; } } @@ -171,9 +177,9 @@ mod e2e_import_tests { // Configuration & Settings Tests // ──────────────────────────────────────────────────────────────────── - #[test] - fn test_full_config_extraction() { - let (_temp, openclaw_path) = setup_full_openclaw_test_env().expect("setup failed"); + #[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"); @@ -198,9 +204,9 @@ mod e2e_import_tests { assert!(config.other_settings.contains_key("custom_setting")); } - #[test] - fn test_settings_mapping_to_ironclaw_format() { - let (_temp, openclaw_path) = setup_full_openclaw_test_env().expect("setup failed"); + #[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"); @@ -224,9 +230,9 @@ mod e2e_import_tests { // Credential Extraction Tests // ──────────────────────────────────────────────────────────────────── - #[test] - fn test_credentials_extraction() { - let (_temp, openclaw_path) = setup_full_openclaw_test_env().expect("setup failed"); + #[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"); @@ -249,9 +255,9 @@ mod e2e_import_tests { } } - #[test] - fn test_credentials_never_logged() { - let (_temp, openclaw_path) = setup_full_openclaw_test_env().expect("setup failed"); + #[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"); @@ -271,9 +277,9 @@ mod e2e_import_tests { // Data Volume Tests // ──────────────────────────────────────────────────────────────────── - #[test] - fn test_full_workspace_import_counts() { - let (_temp, openclaw_path) = setup_full_openclaw_test_env().expect("setup failed"); + #[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"); @@ -288,9 +294,9 @@ mod e2e_import_tests { assert_eq!(agent_dbs.len(), 2); // primary + secondary } - #[test] - fn test_full_memory_chunks_import() { - let (_temp, openclaw_path) = setup_full_openclaw_test_env().expect("setup failed"); + #[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"); @@ -299,6 +305,7 @@ mod e2e_import_tests { 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); @@ -314,9 +321,9 @@ mod e2e_import_tests { } } - #[test] - fn test_full_conversations_import() { - let (_temp, openclaw_path) = setup_full_openclaw_test_env().expect("setup failed"); + #[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"); @@ -325,6 +332,7 @@ mod e2e_import_tests { for (_name, db_path) in agent_dbs { let conversations = reader .read_conversations(&db_path) + .await .expect("read conversations failed"); assert_eq!(conversations.len(), 3); @@ -387,8 +395,8 @@ mod e2e_import_tests { // Error Handling Tests // ──────────────────────────────────────────────────────────────────── - #[test] - fn test_error_on_corrupt_sqlite() { + #[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(); @@ -410,7 +418,7 @@ mod e2e_import_tests { assert_eq!(dbs.len(), 1); // But reading should fail - let result = reader.read_memory_chunks(&dbs[0].1); + let result = reader.read_memory_chunks(&dbs[0].1).await; assert!(result.is_err()); } @@ -437,9 +445,9 @@ mod e2e_import_tests { // Extensibility Tests // ──────────────────────────────────────────────────────────────────── - #[test] - fn test_multiple_agents_independent_data() { - let (_temp, openclaw_path) = setup_full_openclaw_test_env().expect("setup failed"); + #[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"); @@ -453,14 +461,15 @@ mod e2e_import_tests { 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); } } - #[test] - fn test_channel_diversity_in_conversations() { - let (_temp, openclaw_path) = setup_full_openclaw_test_env().expect("setup failed"); + #[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"); @@ -468,6 +477,7 @@ mod e2e_import_tests { // Get conversations from first agent let conversations = reader .read_conversations(&agent_dbs[0].1) + .await .expect("read conversations failed"); // Should have different channels diff --git a/tests/import_openclaw_errors.rs b/tests/import_openclaw_errors.rs index e0338292..76345a71 100644 --- a/tests/import_openclaw_errors.rs +++ b/tests/import_openclaw_errors.rs @@ -112,8 +112,8 @@ mod error_handling_tests { // SQLite Database Errors // ──────────────────────────────────────────────────────────────────── - #[test] - fn test_error_corrupt_sqlite_file() { + #[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(); @@ -133,12 +133,12 @@ mod error_handling_tests { assert_eq!(dbs.len(), 1); // But reading should fail - let result = reader.read_memory_chunks(&dbs[0].1); + let result = reader.read_memory_chunks(&dbs[0].1).await; assert!(result.is_err()); } - #[test] - fn test_error_missing_chunks_table() { + #[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(); @@ -148,14 +148,17 @@ mod error_handling_tests { let db_path = agents_dir.join("no_chunks.sqlite"); // Create valid SQLite but without chunks table - use rusqlite::Connection; - let conn = Connection::open(&db_path).expect("db creation failed"); + 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"); - drop(conn); let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed"); @@ -163,12 +166,12 @@ mod error_handling_tests { assert_eq!(dbs.len(), 1); // Should fail: chunks table doesn't exist - let result = reader.read_memory_chunks(&dbs[0].1); + let result = reader.read_memory_chunks(&dbs[0].1).await; assert!(result.is_err()); } - #[test] - fn test_error_missing_conversations_table() { + #[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(); @@ -177,15 +180,18 @@ mod error_handling_tests { let db_path = agents_dir.join("no_conversations.sqlite"); - use rusqlite::Connection; - let conn = Connection::open(&db_path).expect("db creation failed"); + 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"); - drop(conn); let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed"); @@ -193,7 +199,7 @@ mod error_handling_tests { assert_eq!(dbs.len(), 1); // Should fail: conversations table doesn't exist - let result = reader.read_conversations(&dbs[0].1); + let result = reader.read_conversations(&dbs[0].1).await; assert!(result.is_err()); } @@ -201,8 +207,8 @@ mod error_handling_tests { // Edge Cases // ──────────────────────────────────────────────────────────────────── - #[test] - fn test_edge_case_empty_chunks_table() { + #[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(); @@ -211,14 +217,17 @@ mod error_handling_tests { let db_path = agents_dir.join("empty.sqlite"); - use rusqlite::Connection; - let conn = Connection::open(&db_path).expect("db creation failed"); + 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"); - drop(conn); let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed"); @@ -227,12 +236,13 @@ mod error_handling_tests { // 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); } - #[test] - fn test_edge_case_empty_conversations_table() { + #[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(); @@ -241,19 +251,23 @@ mod error_handling_tests { let db_path = agents_dir.join("empty_conv.sqlite"); - use rusqlite::Connection; - let conn = Connection::open(&db_path).expect("db creation failed"); + 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"); - drop(conn); let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed"); @@ -262,12 +276,13 @@ mod error_handling_tests { // Should succeed but return empty list let conversations = reader .read_conversations(&dbs[0].1) + .await .expect("read conversations failed"); assert_eq!(conversations.len(), 0); } - #[test] - fn test_edge_case_very_large_content() { + #[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(); @@ -276,22 +291,26 @@ mod error_handling_tests { let db_path = agents_dir.join("large.sqlite"); - use rusqlite::Connection; - let conn = Connection::open(&db_path).expect("db creation failed"); + 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 (?, ?, ?, ?, ?)", - rusqlite::params!["id1", "path", large_content, None::>, 0], + libsql::params!["id1", "path", large_content, libsql::Value::Null, 0i64], ) + .await .expect("insert failed"); - drop(conn); let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed"); @@ -300,13 +319,14 @@ mod error_handling_tests { // 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); } - #[test] - fn test_edge_case_special_characters_in_content() { + #[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(); @@ -315,22 +335,26 @@ mod error_handling_tests { let db_path = agents_dir.join("special.sqlite"); - use rusqlite::Connection; - let conn = Connection::open(&db_path).expect("db creation failed"); + 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 🚀 and UTF-8: 中文, العربية, ελληνικά"; + 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 (?, ?, ?, ?, ?)", - rusqlite::params!["id1", "path", special_content, None::>, 0], + libsql::params!["id1", "path", special_content, libsql::Value::Null, 0i64], ) + .await .expect("insert failed"); - drop(conn); let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed"); @@ -339,14 +363,15 @@ mod error_handling_tests { // 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("🚀")); - assert!(chunks[0].content.contains("中文")); + assert!(chunks[0].content.contains("\u{1f680}")); + assert!(chunks[0].content.contains("\u{4e2d}\u{6587}")); } - #[test] - fn test_edge_case_null_values_in_fields() { + #[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(); @@ -355,33 +380,39 @@ mod error_handling_tests { let db_path = agents_dir.join("nulls.sqlite"); - use rusqlite::Connection; - let conn = Connection::open(&db_path).expect("db creation failed"); + 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 (?, ?, ?)", - rusqlite::params!["conv1", "telegram", None::], + libsql::params!["conv1", "telegram", libsql::Value::Null], ) + .await .expect("insert failed"); // Insert message with NULL created_at conn.execute( "INSERT INTO messages VALUES (?, ?, ?, ?, ?)", - rusqlite::params!["msg1", "conv1", "user", "hello", None::], + libsql::params!["msg1", "conv1", "user", "hello", libsql::Value::Null], ) + .await .expect("insert failed"); - drop(conn); let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed"); @@ -390,6 +421,7 @@ mod error_handling_tests { // 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()); diff --git a/tests/import_openclaw_idempotency.rs b/tests/import_openclaw_idempotency.rs index a0a044e4..22fb900d 100644 --- a/tests/import_openclaw_idempotency.rs +++ b/tests/import_openclaw_idempotency.rs @@ -17,7 +17,7 @@ mod idempotency_tests { use ironclaw::import::{ImportOptions, ImportStats}; /// Helper: Create minimal test OpenClaw - fn create_minimal_openclaw() -> Result<(TempDir, PathBuf), Box> { + async fn create_minimal_openclaw() -> Result<(TempDir, PathBuf), Box> { let temp_dir = TempDir::new()?; let openclaw_path = temp_dir.path().to_path_buf(); @@ -40,8 +40,8 @@ mod idempotency_tests { std::fs::create_dir_all(&agents_dir)?; let db_path = agents_dir.join("agent.sqlite"); - use rusqlite::Connection; - let conn = Connection::open(&db_path)?; + let db = libsql::Builder::new_local(&db_path).build().await?; + let conn = db.connect()?; conn.execute( "CREATE TABLE chunks ( @@ -51,24 +51,27 @@ mod idempotency_tests { embedding BLOB, chunk_index INTEGER )", - [], - )?; + (), + ) + .await?; conn.execute( "INSERT INTO chunks VALUES (?, ?, ?, ?, ?)", - rusqlite::params![ + libsql::params![ Uuid::new_v4().to_string(), "test.md", "Test content", - None::>, - 0 + 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 ( @@ -78,8 +81,9 @@ mod idempotency_tests { content TEXT, created_at TEXT )", - [], - )?; + (), + ) + .await?; Ok((temp_dir, openclaw_path)) } @@ -88,9 +92,9 @@ mod idempotency_tests { // Idempotency Tests // ──────────────────────────────────────────────────────────────────── - #[test] - fn test_reader_idempotent_config_reads() { - let (_temp, openclaw_path) = create_minimal_openclaw().expect("setup failed"); + #[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"); @@ -109,9 +113,9 @@ mod idempotency_tests { ); } - #[test] - fn test_reader_idempotent_workspace_file_listing() { - let (_temp, openclaw_path) = create_minimal_openclaw().expect("setup failed"); + #[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"); @@ -123,9 +127,9 @@ mod idempotency_tests { assert_eq!(count1, 1); // MEMORY.md } - #[test] - fn test_reader_idempotent_memory_chunk_reads() { - let (_temp, openclaw_path) = create_minimal_openclaw().expect("setup failed"); + #[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"); @@ -134,9 +138,11 @@ mod idempotency_tests { // 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 @@ -197,10 +203,10 @@ mod idempotency_tests { assert!(!normal_opts.dry_run); } - #[test] - fn test_dry_run_stats_would_be_same() { + #[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().expect("setup failed"); + let (_temp, openclaw_path) = create_minimal_openclaw().await.expect("setup failed"); let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed"); @@ -235,9 +241,9 @@ mod idempotency_tests { // Duplicate Prevention Tests // ──────────────────────────────────────────────────────────────────── - #[test] - fn test_chunk_deduplication_by_path() { - let (_temp, openclaw_path) = create_minimal_openclaw().expect("setup failed"); + #[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"); @@ -245,6 +251,7 @@ mod idempotency_tests { let chunks = reader .read_memory_chunks(db_path) + .await .expect("read chunks failed"); // All chunks should have unique (path, chunk_index) pairs diff --git a/tests/import_openclaw_integration.rs b/tests/import_openclaw_integration.rs index 2435770b..2a694098 100644 --- a/tests/import_openclaw_integration.rs +++ b/tests/import_openclaw_integration.rs @@ -4,14 +4,14 @@ //! verifying that data is correctly stored, idempotent, and that dry-run mode //! prevents modifications. -#![cfg(all(feature = "import", feature = "libsql"))] +#![cfg(feature = "import")] -#[cfg(all(feature = "import", feature = "libsql"))] +#[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 ironclaw::import::{ImportOptions, ImportStats}; use std::path::PathBuf; use std::sync::Arc; use tempfile::TempDir; @@ -29,7 +29,7 @@ mod import_integration_tests { } /// Helper: Create a test OpenClaw directory with full structure - fn create_test_openclaw() -> Result<(TempDir, PathBuf), Box> { + async fn create_test_openclaw() -> Result<(TempDir, PathBuf), Box> { let temp_dir = TempDir::new()?; let openclaw_path = temp_dir.path().to_path_buf(); @@ -63,17 +63,16 @@ mod import_integration_tests { let agents_dir = openclaw_path.join("agents"); std::fs::create_dir_all(&agents_dir)?; - create_test_agent_db(&agents_dir.join("agent1.sqlite"))?; - create_test_agent_db(&agents_dir.join("agent2.sqlite"))?; + 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 - fn create_test_agent_db(db_path: &PathBuf) -> Result<(), Box> { - use rusqlite::Connection; - - let conn = Connection::open(db_path)?; + /// Helper: Create a test agent SQLite database using libsql + async fn create_test_agent_db(db_path: &PathBuf) -> Result<(), Box> { + let db = libsql::Builder::new_local(db_path).build().await?; + let conn = db.connect()?; // Chunks table conn.execute( @@ -84,27 +83,30 @@ mod import_integration_tests { 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 (?, ?, ?, ?, ?)", - rusqlite::params![ + "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), - None::>, - 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 ( @@ -114,26 +116,29 @@ mod import_integration_tests { content TEXT NOT NULL, created_at TEXT )", - [], - )?; + (), + ) + .await?; let conv_id = Uuid::new_v4().to_string(); conn.execute( - "INSERT INTO conversations VALUES (?, ?, ?)", - rusqlite::params![&conv_id, "slack", "2024-01-15T10:00:00Z"], - )?; + "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 (?, ?, ?, ?, ?)", - rusqlite::params![ + "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, + conv_id.as_str(), if j % 2 == 0 { "user" } else { "assistant" }, format!("Message {}", j), format!("2024-01-15T10:{:02}:00Z", j) ], - )?; + ) + .await?; } Ok(()) @@ -146,8 +151,9 @@ mod import_integration_tests { #[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().expect("OpenClaw creation failed"); + let (_openclaw_temp, openclaw_path) = create_test_openclaw() + .await + .expect("OpenClaw creation failed"); // Verify DB starts empty let before_docs = db @@ -175,12 +181,14 @@ mod import_integration_tests { // 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 @@ -192,12 +200,13 @@ mod import_integration_tests { #[tokio::test] async fn test_import_command_execution() { - let (_openclaw_temp, openclaw_path) = - create_test_openclaw().expect("OpenClaw creation failed"); + 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 = ImportOptions { + let opts = ironclaw::import::ImportOptions { openclaw_path: openclaw_path.clone(), dry_run: false, re_embed: false, @@ -222,8 +231,9 @@ mod import_integration_tests { #[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().expect("OpenClaw creation failed"); + let (_openclaw_temp, openclaw_path) = create_test_openclaw() + .await + .expect("OpenClaw creation failed"); let user_id = "test_user"; @@ -235,7 +245,7 @@ mod import_integration_tests { let before_count = before_import.len(); // Create import options in DRY-RUN mode - let opts = ImportOptions { + let opts = ironclaw::import::ImportOptions { openclaw_path: openclaw_path.clone(), dry_run: true, // ← KEY: dry_run is enabled re_embed: false, @@ -266,8 +276,9 @@ mod import_integration_tests { #[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().expect("OpenClaw 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"); @@ -282,11 +293,13 @@ mod import_integration_tests { 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(); } @@ -330,8 +343,9 @@ mod import_integration_tests { #[tokio::test] async fn test_embedding_dimension_mismatch_queues_reembedding() { - let (_openclaw_temp, openclaw_path) = - create_test_openclaw().expect("OpenClaw creation failed"); + 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"); @@ -339,8 +353,11 @@ mod import_integration_tests { let db_path = agents_dir.join("with_embeddings.sqlite"); { - use rusqlite::Connection; - let conn = Connection::open(&db_path).expect("db open failed"); + 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 ( @@ -350,33 +367,36 @@ mod import_integration_tests { 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![0.1f32; 1536] + let embedding_1536_bytes: Vec = vec![0.1f32; 1536] .iter() .flat_map(|f| f.to_le_bytes().to_vec()) - .collect::>(); + .collect(); conn.execute( - "INSERT INTO chunks (id, path, content, embedding, chunk_index) VALUES (?, ?, ?, ?, ?)", - rusqlite::params![ + "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, - 0 + 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( @@ -387,8 +407,9 @@ mod import_integration_tests { content TEXT NOT NULL, created_at TEXT )", - [], + (), ) + .await .expect("create messages table failed"); } @@ -396,6 +417,7 @@ mod import_integration_tests { 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); @@ -417,16 +439,10 @@ mod import_integration_tests { } // Simulate dimension mismatch scenario: - // - Source: 1536-dim (ada-002) - // - Target: 3072-dim (text-embedding-3-large) - // This would trigger re-embedding logic - let source_dim = embedding.len(); let target_dim = 3072; // text-embedding-3-large if source_dim != target_dim { - // In real import, this would queue the chunk for re-embedding - // Verify the logic: dimensions don't match, so chunk needs re-embedding assert!( source_dim != target_dim, "Dimension mismatch detected: {} -> {}", @@ -434,7 +450,6 @@ mod import_integration_tests { target_dim ); - // Track that this chunk would need re-embedding let mut re_embed_queued = 0; if source_dim != target_dim { re_embed_queued += 1; @@ -466,8 +481,11 @@ mod import_integration_tests { let db_path = agents_dir.join("same_dim.sqlite"); { - use rusqlite::Connection; - let conn = Connection::open(&db_path).expect("db open failed"); + 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 ( @@ -477,32 +495,35 @@ mod import_integration_tests { embedding BLOB, chunk_index INTEGER NOT NULL )", - [], + (), ) + .await .expect("create table failed"); // 1536-dimensional embedding (text-embedding-3-small) - let embedding_bytes = vec![0.5f32; 1536] + let embedding_bytes: Vec = vec![0.5f32; 1536] .iter() .flat_map(|f| f.to_le_bytes().to_vec()) - .collect::>(); + .collect(); conn.execute( - "INSERT INTO chunks (id, path, content, embedding, chunk_index) VALUES (?, ?, ?, ?, ?)", - rusqlite::params![ + "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, - 0 + 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( @@ -513,14 +534,16 @@ mod import_integration_tests { 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(); diff --git a/tests/support/assertions.rs b/tests/support/assertions.rs index 0f520ac2..89a4f194 100644 --- a/tests/support/assertions.rs +++ b/tests/support/assertions.rs @@ -183,7 +183,15 @@ pub fn verify_expects( // all_tools_succeeded if expects.all_tools_succeeded == Some(true) { - assert_all_tools_succeeded(completed); + 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:?}" + ); } // max_tool_calls diff --git a/tests/support/test_rig.rs b/tests/support/test_rig.rs index cc7d77ac..14e4ffbf 100644 --- a/tests/support/test_rig.rs +++ b/tests/support/test_rig.rs @@ -312,7 +312,23 @@ impl TestRig { .collect(); let started = self.tool_calls_started(); let completed = self.tool_calls_completed(); - let results = self.tool_results(); + 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)); + } + } verify_expects( &trace.expects, &all_response_strings, @@ -339,7 +355,23 @@ impl TestRig { let response_strings: Vec = responses.iter().map(|r| r.content.clone()).collect(); let started = self.tool_calls_started(); let completed = self.tool_calls_completed(); - let results = self.tool_results(); + 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)); + } + } verify_expects( &trace.expects, &response_strings, @@ -394,7 +426,7 @@ impl TestRigBuilder { llm: None, max_tool_iterations: 10, injection_check: false, - auto_approve_tools: None, + auto_approve_tools: Some(true), enable_skills: false, enable_routines: false, http_exchanges: Vec::new(), @@ -567,11 +599,20 @@ 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()),