Merge pull request #917 from nearai/staging-promote/369741fc-22935740447

chore: promote staging to main (2026-03-11 03:47 UTC)
This commit is contained in:
Henry Park
2026-03-11 16:34:44 -07:00
committed by GitHub
43 changed files with 2826 additions and 689 deletions
+32 -23
View File
@@ -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] <brief description>
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)
+14 -7
View File
@@ -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: |
+8 -8
View File
@@ -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
Generated
+1 -36
View File
@@ -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"
+1 -2
View File
@@ -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"
+2 -1
View File
@@ -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
+1 -1
View File
@@ -229,7 +229,7 @@ WASM ──► 白名单 ──► 泄露扫描 ──► 凭据 ──► 执
│ │ │ │
│ ┌──────────▼────┐ ┌──▼───────────────┐ │
│ │ 调度器 │ │ 定时任务引擎 │ │
│ │ (并行任务) │ │(cron, 事件, wh) │
│ │ (并行任务) │ │(cron, 事件, Webhook)│
│ └──────┬────────┘ └────────┬─────────┘ │
│ │ │ │
│ ┌─────────────┼────────────────────┘ │
+13 -7
View File
@@ -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 {
+46
View File
@@ -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::<Sha256>::new_from_slice(secret.as_bytes()) {
Ok(m) => m,
Err(_) => return false,
};
mac.update(body);
let computed = mac.finalize().into_bytes();
let computed_hex = hex::encode(computed);
let expected = format!("{prefix}{computed_hex}");
expected
.as_bytes()
.ct_eq(signature_header.as_bytes())
.into()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -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::<Sha256>::new_from_slice(secret.as_bytes()).expect("hmac key");
mac.update(body);
let sig = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
assert!(verify_hmac_sha256_prefixed(secret, body, &sig, "sha256="));
assert!(!verify_hmac_sha256_prefixed(
secret,
body,
"sha256=deadbeef",
"sha256="
));
}
#[test]
fn test_slack_stale_timestamp_rejected() {
let signing_secret = "my-signing-secret";
+6
View File
@@ -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
+45 -1
View File
@@ -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<HealthResponse> {
+99 -96
View File
@@ -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 = '<div class="empty-state">No extensions installed</div>';
extList.innerHTML = '<div class="empty-state">' + I18n.t('extensions.noInstalled') + '</div>';
} else {
extList.innerHTML = '';
for (const ext of extData.extensions) {
@@ -2053,7 +2055,7 @@ function loadExtensions() {
// Available WASM extensions
if (wasmEntries.length === 0) {
wasmList.innerHTML = '<div class="empty-state">No additional WASM extensions available</div>';
wasmList.innerHTML = '<div class="empty-state">' + I18n.t('extensions.noAvailable') + '</div>';
} 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 = '<div class="empty-state">No MCP servers available</div>';
mcpList.innerHTML = '<div class="empty-state">' + I18n.t('mcp.noServers') + '</div>';
} 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 += '<div class="gw-section-label">Connections</div>';
html += '<div class="gw-stat"><span>SSE</span><span>' + (data.sse_connections || 0) + '</span></div>';
html += '<div class="gw-stat"><span>WebSocket</span><span>' + (data.ws_connections || 0) + '</span></div>';
html += '<div class="gw-stat"><span>Uptime</span><span>' + formatDuration(data.uptime_secs) + '</span></div>';
html += '<div class="gw-section-label">' + I18n.t('dashboard.connections') + '</div>';
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.sse') + '</span><span>' + (data.sse_connections || 0) + '</span></div>';
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.websocket') + '</span><span>' + (data.ws_connections || 0) + '</span></div>';
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.uptime') + '</span><span>' + formatDuration(data.uptime_secs) + '</span></div>';
// Cost tracker
if (data.daily_cost != null) {
html += '<div class="gw-divider"></div>';
html += '<div class="gw-section-label">Cost Today</div>';
html += '<div class="gw-stat"><span>Spent</span><span>' + formatCost(data.daily_cost) + '</span></div>';
html += '<div class="gw-section-label">' + I18n.t('dashboard.costToday') + '</div>';
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.spent') + '</span><span>' + formatCost(data.daily_cost) + '</span></div>';
if (data.actions_this_hour != null) {
html += '<div class="gw-stat"><span>Actions/hr</span><span>' + data.actions_this_hour + '</span></div>';
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.actionsPerHour') + '</span><span>' + data.actions_this_hour + '</span></div>';
}
}
@@ -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 = '<div class="empty-state">No skills installed</div>';
skillsList.innerHTML = '<div class="empty-state">' + I18n.t('skills.noInstalled') + '</div>';
return;
}
skillsList.innerHTML = '';
@@ -3759,7 +3762,7 @@ function loadSkills() {
skillsList.appendChild(renderSkillCard(data.skills[i]));
}
}).catch(function(err) {
skillsList.innerHTML = '<div class="empty-state">Failed to load skills: ' + escapeHtml(err.message) + '</div>';
skillsList.innerHTML = '<div class="empty-state">' + I18n.t('skills.loadFailed', {message: escapeHtml(err.message)}) + '</div>';
});
}
@@ -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 = '<div class="empty-state">Searching...</div>';
resultsDiv.innerHTML = '<div class="empty-state">' + I18n.t('skills.searching') + '</div>';
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 = '<div class="empty-state">No skills found for "' + escapeHtml(query) + '"</div>';
resultsDiv.innerHTML = '<div class="empty-state">' + I18n.t('skills.noResults', {query: escapeHtml(query)}) + '</div>';
}
}).catch(function(err) {
resultsDiv.innerHTML = '<div class="empty-state">Search failed: ' + escapeHtml(err.message) + '</div>';
resultsDiv.innerHTML = '<div class="empty-state">' + I18n.t('skills.searchFailed', {message: escapeHtml(err.message)}) + '</div>';
});
}
@@ -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');
});
}
+74
View File
@@ -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';
}
}
});
+351
View File
@@ -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 <id> for a specific job',
'cmd.list.desc': 'List all jobs',
'cmd.cancel.desc': '/cancel <job-id> — Cancel a running job',
'cmd.undo.desc': 'Undo last action',
'cmd.redo.desc': 'Redo undone action',
'cmd.compact.desc': 'Compact context window',
'cmd.clear.desc': 'Clear conversation and start fresh',
'cmd.interrupt.desc': 'Stop current operation',
'cmd.heartbeat.desc': 'Trigger manual heartbeat check',
'cmd.summarize.desc': 'Summarize current conversation',
'cmd.suggest.desc': 'Suggest next actions',
'cmd.help.desc': 'Show help',
'cmd.version.desc': 'Show version info',
'cmd.tools.desc': 'List available tools',
'cmd.skills.desc': 'List installed skills',
'cmd.model.desc': 'Show or switch LLM model',
'cmd.threadNew.desc': 'Create new conversation thread',
// Language Switcher
'language.title': 'Language',
'language.en': 'English',
'language.zhCN': '简体中文',
'language.switch': 'Switch Language',
// Tool Activity
'tool.thinking': 'Thinking...',
'tool.completed': 'Completed',
'tool.failed': 'Failed',
'tool.running': 'Running',
'tool.used': '{count} tool(s) used',
'tool.requiresApproval': 'Tool requires approval',
// TEE
'tee.loadingReport': 'Loading attestation report...',
'tee.loadFailed': 'Could not load attestation report',
// Common
'common.loading': 'Loading...',
'common.noData': 'No data',
'common.search': 'Search',
'common.add': 'Add',
'common.remove': 'Remove',
'common.install': 'Install',
'common.activate': 'Activate',
'common.deactivate': 'Deactivate',
'common.configure': 'Configure',
'common.save': 'Save',
'common.cancel': 'Cancel',
'common.confirm': 'Confirm',
'common.close': 'Close',
'common.edit': 'Edit',
'common.delete': 'Delete',
'common.refresh': 'Refresh',
'common.searchPlaceholder': 'Search...',
'common.name': 'Name',
'common.description': 'Description',
'common.status': 'Status',
'common.actions': 'Actions',
'common.version': 'Version',
'common.owner': 'Owner',
'common.tags': 'Tags',
// Extensions
'ext.active': 'Active',
'ext.remove': 'Remove',
'ext.install': 'Install',
'ext.installing': 'Installing...',
'ext.installed': 'Installed',
'ext.setup': 'Setup',
'ext.reconfigure': 'Reconfigure',
'ext.configure': 'Configure',
'ext.confirmRemove': 'Remove extension "{name}"?',
'ext.removeFailed': 'Remove failed: {message}',
'ext.removed': 'Removed {name}',
'ext.installFailed': 'Install failed: {message}',
// Configure
'config.title': 'Configure {name}',
'config.optional': ' (optional)',
'config.alreadySet': '(already set — leave empty to keep)',
'config.alreadyConfigured': 'Already configured',
'config.autoGenerate': 'Auto-generated if empty',
'config.save': 'Save',
'config.cancel': 'Cancel',
});
+89
View File
@@ -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;
+351
View File
@@ -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 <id> 查看特定任务',
'cmd.list.desc': '列出所有任务',
'cmd.cancel.desc': '/cancel <job-id> — 取消正在运行的任务',
'cmd.undo.desc': '撤销上一步',
'cmd.redo.desc': '重做已撤销的操作',
'cmd.compact.desc': '压缩上下文窗口',
'cmd.clear.desc': '清空对话并重新开始',
'cmd.interrupt.desc': '停止当前操作',
'cmd.heartbeat.desc': '触发手动心跳检查',
'cmd.summarize.desc': '总结当前对话',
'cmd.suggest.desc': '建议下一步操作',
'cmd.help.desc': '显示帮助',
'cmd.version.desc': '显示版本信息',
'cmd.tools.desc': '列出可用工具',
'cmd.skills.desc': '列出已安装的 AI 技能',
'cmd.model.desc': '显示或切换 LLM 模型',
'cmd.threadNew.desc': '创建新对话线程',
// 语言切换
'language.title': '语言',
'language.en': 'English',
'language.zhCN': '简体中文',
'language.switch': '切换语言',
// 工具活动
'tool.thinking': '思考中...',
'tool.completed': '已完成',
'tool.failed': '失败',
'tool.running': '运行中',
'tool.used': '{count} 个工具已使用',
'tool.requiresApproval': '工具需要审批',
// TEE
'tee.loadingReport': '正在加载证明报告...',
'tee.loadFailed': '无法加载证明报告',
// 通用
'common.loading': '加载中...',
'common.noData': '暂无数据',
'common.search': '搜索',
'common.add': '添加',
'common.remove': '移除',
'common.install': '安装',
'common.activate': '激活',
'common.deactivate': '停用',
'common.configure': '配置',
'common.save': '保存',
'common.cancel': '取消',
'common.confirm': '确认',
'common.close': '关闭',
'common.edit': '编辑',
'common.delete': '删除',
'common.refresh': '刷新',
'common.searchPlaceholder': '搜索...',
'common.name': '名称',
'common.description': '描述',
'common.status': '状态',
'common.actions': '操作',
'common.version': '版本',
'common.owner': '作者',
'common.tags': '标签',
// 扩展
'ext.active': '已激活',
'ext.remove': '移除',
'ext.install': '安装',
'ext.installing': '安装中...',
'ext.installed': '已安装',
'ext.setup': '设置',
'ext.reconfigure': '重新配置',
'ext.configure': '配置',
'ext.confirmRemove': '确定要移除扩展 "{name}" 吗?',
'ext.removeFailed': '移除失败: {message}',
'ext.removed': '已移除 {name}',
'ext.installFailed': '安装失败: {message}',
// 配置
'config.title': '配置 {name}',
'config.optional': '(可选)',
'config.alreadySet': '(已设置 — 留空以保持不变)',
'config.alreadyConfigured': '已配置',
'config.autoGenerate': '如果为空则自动生成',
'config.save': '保存',
'config.cancel': '取消',
});
+109 -86
View File
@@ -9,6 +9,12 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/style.css">
<!-- i18n Modules -->
<script src="/i18n/index.js"></script>
<script src="/i18n/en.js"></script>
<script src="/i18n/zh-CN.js"></script>
<script
src="https://cdn.jsdelivr.net/npm/[email protected]/lib/marked.umd.min.js"
integrity="sha384-pN9zSKOnTZwXRtYZAu0PBPEgR2B7DOC1aeLxQ33oJ0oy5iN1we6gm57xldM2irDG"
@@ -20,16 +26,16 @@
<div id="auth-screen">
<div class="auth-card-login">
<div class="auth-brand">
<h1>IronClaw</h1>
<p class="auth-tagline">Secure AI Assistant</p>
<h1 data-i18n="auth.title">IronClaw</h1>
<p class="auth-tagline" data-i18n="auth.tagline">Secure AI Assistant</p>
</div>
<div class="auth-form">
<label for="token-input">Gateway Token</label>
<input type="password" id="token-input" placeholder="Paste your auth token" autofocus>
<button onclick="authenticate()">Connect</button>
<label for="token-input" data-i18n="auth.tokenLabel">Gateway Token</label>
<input type="password" id="token-input" data-i18n="auth.tokenPlaceholder" data-i18n-attr="placeholder" placeholder="Paste your auth token" autofocus>
<button onclick="authenticate()" data-i18n="auth.connect">Connect</button>
</div>
<div id="auth-error"></div>
<p class="auth-hint">Enter the GATEWAY_AUTH_TOKEN from your .env configuration.</p>
<p class="auth-hint" data-i18n="auth.hint">Enter the GATEWAY_AUTH_TOKEN from your .env configuration.</p>
</div>
</div>
@@ -38,21 +44,22 @@
<div class="restart-modal-overlay" onclick="cancelRestart()"></div>
<div class="restart-modal-content">
<div class="restart-modal-header">
<h2>Restart IronClaw Instance</h2>
<button class="restart-modal-close" onclick="cancelRestart()" title="Close">×</button>
<h2 data-i18n="restart.title">Restart IronClaw Instance</h2>
<button class="restart-modal-close" onclick="cancelRestart()" data-i18n="restart.closeTooltip" data-i18n-attr="title"
title="Close">×</button>
</div>
<div class="restart-modal-body">
<p class="restart-modal-description">
<p class="restart-modal-description" data-i18n="restart.description">
Are you sure you want to restart the IronClaw instance? This will gracefully restart the process.
</p>
<div class="restart-modal-warning">
<span class="restart-modal-warning-icon">⚠️</span>
<p>Any in-progress jobs may be interrupted. The restart will complete within a few seconds.</p>
<p data-i18n="restart.warning">Any in-progress jobs may be interrupted. The restart will complete within a few seconds.</p>
</div>
</div>
<div class="restart-modal-footer">
<button class="restart-modal-btn cancel" onclick="cancelRestart()">Cancel</button>
<button class="restart-modal-btn confirm" onclick="confirmRestart()">Confirm Restart</button>
<button class="restart-modal-btn cancel" onclick="cancelRestart()" data-i18n="restart.cancel">Cancel</button>
<button class="restart-modal-btn confirm" onclick="confirmRestart()" data-i18n="restart.confirm">Confirm Restart</button>
</div>
</div>
</div>
@@ -63,13 +70,13 @@
<div class="restart-loader-content">
<div class="restart-spinner"></div>
<div class="restart-loader-text">
<p class="restart-title">Restarting IronClaw</p>
<p class="restart-subtitle">Please wait while the process restarts...</p>
<p class="restart-title" data-i18n="restart.progressTitle">Restarting IronClaw</p>
<p class="restart-subtitle" data-i18n="restart.progressSubtitle">Please wait while the process restarts...</p>
</div>
<div class="restart-progress-bar">
<div class="restart-progress-fill"></div>
</div>
<p class="restart-modal-info">
<p class="restart-modal-info" data-i18n="restart.checkLogs">
Check the Logs tab for details after the restart completes.
</p>
</div>
@@ -79,33 +86,45 @@
<div id="app">
<!-- Tab Bar -->
<div class="tab-bar">
<button class="active" data-tab="chat">Chat</button>
<button data-tab="memory">Memory</button>
<button data-tab="jobs">Jobs</button>
<button data-tab="routines">Routines</button>
<button data-tab="extensions">Extensions</button>
<button data-tab="skills">Skills</button>
<button class="active" data-tab="chat" data-i18n="tab.chat">Chat</button>
<button data-tab="memory" data-i18n="tab.memory">Memory</button>
<button data-tab="jobs" data-i18n="tab.jobs">Jobs</button>
<button data-tab="routines" data-i18n="tab.routines">Routines</button>
<button data-tab="extensions" data-i18n="tab.extensions">Extensions</button>
<button data-tab="skills" data-i18n="tab.skills">Skills</button>
<div class="spacer"></div>
<button class="status-logs-btn" data-tab="logs" title="Logs">Logs</button>
<!-- Language Switcher -->
<div class="language-switcher">
<button class="language-btn" id="language-btn" type="button" onclick="toggleLanguageMenu()" title="Switch Language"
aria-label="Switch language" aria-haspopup="true" aria-expanded="false" aria-controls="language-menu">🌐</button>
<div class="language-menu" id="language-menu" style="display: none;">
<button type="button" class="language-option" onclick="switchLanguage('en')" data-lang="en">English</button>
<button type="button" class="language-option" onclick="switchLanguage('zh-CN')" data-lang="zh-CN">简体中文</button>
</div>
</div>
<button class="status-logs-btn" data-tab="logs" data-i18n="tab.logs" title="Logs">Logs</button>
<div class="tee-shield" id="tee-shield" style="display:none" title="Running in a Trusted Execution Environment">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
<span id="tee-shield-label">TEE Verified</span>
<span id="tee-shield-label" data-i18n="status.teeVerified">TEE Verified</span>
<div class="tee-popover" id="tee-popover"></div>
</div>
<div class="status" id="gateway-status-trigger">
<div class="dot" id="sse-dot"></div>
<span id="sse-status">Connected</span>
<span id="sse-status" data-i18n="status.connected">Connected</span>
<div class="gateway-popover" id="gateway-popover"></div>
</div>
<button class="restart-btn" id="restart-btn" onclick="triggerRestart()" title="Gracefully restart the process" style="display: none;">
<button class="restart-btn" id="restart-btn" onclick="triggerRestart()" data-i18n="status.restartTooltip"
data-i18n-attr="title" title="Gracefully restart the process" style="display: none;">
<svg id="restart-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M23 4v6h-6"></path>
<path d="M1 20v-6h6"></path>
<path d="M3.51 9a9 9 0 0114.85-3.36M20.49 15a9 9 0 01-14.85 3.36"></path>
</svg>
<span>Restart</span>
<span data-i18n="status.restart">Restart</span>
</button>
</div>
@@ -113,16 +132,18 @@
<div class="tab-panel active" id="tab-chat">
<div class="thread-sidebar" id="thread-sidebar">
<div class="thread-sidebar-header">
<button class="thread-new-btn" onclick="createNewThread()" title="New thread (Ctrl/Cmd+N)">+</button>
<button class="thread-new-btn" onclick="createNewThread()" data-i18n="chat.newThread" data-i18n-attr="title"
title="New thread (Ctrl/Cmd+N)">+</button>
<div class="spacer"></div>
<button class="thread-toggle-btn" id="thread-toggle-btn" onclick="toggleThreadSidebar()" title="Toggle sidebar">&laquo;</button>
<button class="thread-toggle-btn" id="thread-toggle-btn" onclick="toggleThreadSidebar()" data-i18n="chat.toggleSidebar"
data-i18n-attr="title" title="Toggle sidebar">&laquo;</button>
</div>
<div class="assistant-item" id="assistant-thread" onclick="switchToAssistant()">
<span class="assistant-label" id="assistant-label">Assistant</span>
<span class="assistant-label" id="assistant-label" data-i18n="chat.assistant">Assistant</span>
<span class="assistant-meta" id="assistant-meta"></span>
</div>
<div class="threads-section-header">
<span>Conversations</span>
<span data-i18n="chat.conversations">Conversations</span>
</div>
<div class="thread-list" id="thread-list"></div>
</div>
@@ -131,10 +152,11 @@
<div id="slash-autocomplete" class="slash-autocomplete" style="display:none"></div>
<div class="chat-input">
<div id="image-preview-strip" class="image-preview-strip"></div>
<textarea id="chat-input" placeholder="Message or / for commands..." rows="1"></textarea>
<textarea id="chat-input" data-i18n="chat.inputPlaceholder" data-i18n-attr="placeholder" placeholder="Message or / for commands..." rows="1"></textarea>
<input type="file" id="image-file-input" accept="image/*" multiple style="display:none">
<button id="attach-btn" class="attach-btn" title="Attach images" aria-label="Attach images">&#x1F4CE;</button>
<button id="send-btn" onclick="sendMessage()">Send</button>
<button id="attach-btn" class="attach-btn" data-i18n="chat.attachImages" data-i18n-attr="title" title="Attach images"
aria-label="Attach images">&#x1F4CE;</button>
<button id="send-btn" onclick="sendMessage()" data-i18n="chat.send">Send</button>
</div>
</div>
</div>
@@ -144,23 +166,23 @@
<div class="memory-container">
<div class="memory-sidebar">
<div class="search-box">
<input type="text" id="memory-search" placeholder="Search memory...">
<input type="text" id="memory-search" data-i18n="memory.searchPlaceholder" data-i18n-attr="placeholder" placeholder="Search memory...">
</div>
<div class="memory-tree" id="memory-tree"></div>
</div>
<div class="memory-content">
<div class="memory-breadcrumb" id="memory-breadcrumb">
<span id="memory-breadcrumb-path">workspace /</span>
<button class="memory-edit-btn" id="memory-edit-btn" style="display:none" onclick="startMemoryEdit()">Edit</button>
<button class="memory-edit-btn" id="memory-edit-btn" style="display:none" onclick="startMemoryEdit()" data-i18n="memory.edit">Edit</button>
</div>
<div class="memory-viewer" id="memory-viewer">
<div class="empty">Select a file to view its contents</div>
<div class="empty" data-i18n="memory.selectFile">Select a file to view its contents</div>
</div>
<div class="memory-editor" id="memory-editor" style="display:none">
<textarea id="memory-edit-textarea"></textarea>
<div class="memory-editor-actions">
<button class="btn-save" onclick="saveMemoryEdit()">Save</button>
<button class="btn-cancel-edit" onclick="cancelMemoryEdit()">Cancel</button>
<button class="btn-save" onclick="saveMemoryEdit()" data-i18n="memory.save">Save</button>
<button class="btn-cancel-edit" onclick="cancelMemoryEdit()" data-i18n="memory.cancel">Cancel</button>
</div>
</div>
</div>
@@ -174,17 +196,17 @@
<table class="jobs-table" id="jobs-table">
<thead>
<tr>
<th>ID</th>
<th>Title</th>
<th>Source</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
<th data-i18n="jobs.id">ID</th>
<th data-i18n="jobs.title">Title</th>
<th data-i18n="jobs.source">Source</th>
<th data-i18n="jobs.status">Status</th>
<th data-i18n="jobs.created">Created</th>
<th data-i18n="jobs.actions">Actions</th>
</tr>
</thead>
<tbody id="jobs-tbody"></tbody>
</table>
<div class="empty-state" id="jobs-empty" style="display:none">No jobs found</div>
<div class="empty-state" id="jobs-empty" style="display:none" data-i18n="jobs.empty">No jobs found</div>
</div>
</div>
@@ -199,16 +221,16 @@
<option value="debug">Server: DEBUG</option>
</select>
<select id="logs-level-filter">
<option value="all">All Levels</option>
<option value="ERROR">Error</option>
<option value="WARN">Warn</option>
<option value="INFO">Info</option>
<option value="DEBUG">Debug</option>
<option value="all" data-i18n="logs.allLevels">All Levels</option>
<option value="ERROR" data-i18n="logs.error">Error</option>
<option value="WARN" data-i18n="logs.warn">Warn</option>
<option value="INFO" data-i18n="logs.info">Info</option>
<option value="DEBUG" data-i18n="logs.debug">Debug</option>
</select>
<input type="text" id="logs-target-filter" placeholder="Filter by target...">
<label class="logs-checkbox"><input type="checkbox" id="logs-autoscroll" checked> Auto-scroll</label>
<button id="logs-pause-btn" onclick="toggleLogsPause()">Pause</button>
<button onclick="clearLogs()">Clear</button>
<label class="logs-checkbox"><input type="checkbox" id="logs-autoscroll" checked> <span data-i18n="logs.autoScroll">Auto-scroll</span></label>
<button id="logs-pause-btn" onclick="toggleLogsPause()" data-i18n="logs.pause">Pause</button>
<button onclick="clearLogs()" data-i18n="logs.clear">Clear</button>
</div>
<div class="logs-output" id="logs-output"></div>
</div>
@@ -221,20 +243,20 @@
<table class="routines-table" id="routines-table">
<thead>
<tr>
<th>Name</th>
<th>Trigger</th>
<th>Action</th>
<th>Last Run</th>
<th>Next Run</th>
<th>Runs</th>
<th>Status</th>
<th>Actions</th>
<th data-i18n="routines.name">Name</th>
<th data-i18n="routines.trigger">Trigger</th>
<th data-i18n="routines.action">Action</th>
<th data-i18n="routines.lastRun">Last Run</th>
<th data-i18n="routines.nextRun">Next Run</th>
<th data-i18n="routines.runs">Runs</th>
<th data-i18n="routines.status">Status</th>
<th data-i18n="routines.actions">Actions</th>
</tr>
</thead>
<tbody id="routines-tbody"></tbody>
</table>
<div class="empty-state" id="routines-empty" style="display:none">
No routines configured. Ask the assistant to create one.
<span data-i18n="routines.noConfigured">No routines configured. Ask the assistant to create one.</span>
</div>
<div class="routine-detail" id="routine-detail" style="display:none"></div>
</div>
@@ -244,44 +266,44 @@
<div class="tab-panel" id="tab-extensions">
<div class="extensions-container">
<div class="extensions-section">
<h3>Installed Extensions</h3>
<h3 data-i18n="extensions.installed">Installed Extensions</h3>
<div class="extensions-list" id="extensions-list">
<div class="empty-state">Loading extensions...</div>
<div class="empty-state" data-i18n="common.loading">Loading...</div>
</div>
</div>
<div class="extensions-section" id="available-wasm-section">
<h3>Available WASM Extensions</h3>
<h3 data-i18n="extensions.available">Available WASM Extensions</h3>
<div class="extensions-list" id="available-wasm-list">
<div class="empty-state">Loading...</div>
<div class="empty-state" data-i18n="common.loading">Loading...</div>
</div>
</div>
<div class="extensions-section">
<h3>Install WASM Extension</h3>
<h3 data-i18n="extensions.installWasm">Install WASM Extension</h3>
<div class="ext-install-form">
<input type="text" id="wasm-install-name" placeholder="Extension name">
<input type="text" id="wasm-install-name" data-i18n-placeholder="common.name" placeholder="Extension name">
<input type="text" id="wasm-install-url" placeholder="URL to .tar.gz bundle">
<button onclick="installWasmExtension()">Install</button>
<button onclick="installWasmExtension()" data-i18n="extensions.install">Install</button>
</div>
</div>
<div class="extensions-section">
<h3>MCP Servers</h3>
<h3 data-i18n="mcp.servers">MCP Servers</h3>
<div class="extensions-list" id="mcp-servers-list">
<div class="empty-state">Loading...</div>
<div class="empty-state" data-i18n="common.loading">Loading...</div>
</div>
<h4>Add Custom MCP Server</h4>
<h4 data-i18n="mcp.addCustom">Add Custom MCP Server</h4>
<div class="ext-install-form">
<input type="text" id="mcp-install-name" placeholder="Server name">
<input type="text" id="mcp-install-name" data-i18n-placeholder="common.name" placeholder="Server name">
<input type="text" id="mcp-install-url" placeholder="MCP server URL (https://...)">
<button onclick="addMcpServer()">Add</button>
<button onclick="addMcpServer()" data-i18n="mcp.add">Add</button>
</div>
</div>
<div class="extensions-section">
<h3>Registered Tools</h3>
<h3 data-i18n="tools.registered">Registered Tools</h3>
<table class="tools-table" id="tools-table">
<thead><tr><th>Name</th><th>Description</th></tr></thead>
<thead><tr><th data-i18n="tools.name">Name</th><th data-i18n="tools.description">Description</th></tr></thead>
<tbody id="tools-tbody"></tbody>
</table>
<div class="empty-state" id="tools-empty" style="display:none">No tools registered</div>
<div class="empty-state" id="tools-empty" style="display:none" data-i18n="tools.empty">No tools registered</div>
</div>
</div>
</div>
@@ -290,25 +312,25 @@
<div class="tab-panel" id="tab-skills">
<div class="extensions-container">
<div class="extensions-section">
<h3>Search ClawHub</h3>
<h3 data-i18n="skills.searchClawHub">Search ClawHub</h3>
<div class="skill-search-box">
<input type="text" id="skill-search-input" placeholder="Search for skills...">
<button onclick="searchClawHub()">Search</button>
<input type="text" id="skill-search-input" data-i18n-placeholder="skills.searchPlaceholder" placeholder="Search...">
<button onclick="searchClawHub()" data-i18n="skills.search">Search</button>
</div>
<div class="extensions-list" id="skill-search-results"></div>
</div>
<div class="extensions-section">
<h3>Installed Skills</h3>
<h3 data-i18n="skills.installed">Installed Skills</h3>
<div class="extensions-list" id="skills-list">
<div class="empty-state">Loading skills...</div>
<div class="empty-state" data-i18n="skills.loading">Loading skills...</div>
</div>
</div>
<div class="extensions-section">
<h3>Install Skill by URL</h3>
<h3 data-i18n="skills.installByUrl">Install Skill by URL</h3>
<div class="ext-install-form">
<input type="text" id="skill-install-name" placeholder="Skill name or slug">
<input type="text" id="skill-install-url" placeholder="HTTPS URL to SKILL.md (optional)">
<button onclick="installSkillFromForm()">Install</button>
<input type="text" id="skill-install-name" data-i18n-placeholder="skills.namePlaceholder" placeholder="Skill name or slug">
<input type="text" id="skill-install-url" data-i18n-placeholder="skills.urlPlaceholder" placeholder="HTTPS URL to SKILL.md (optional)">
<button onclick="installSkillFromForm()" data-i18n="extensions.install">Install</button>
</div>
</div>
</div>
@@ -317,5 +339,6 @@
<div id="toasts"></div>
<script src="/app.js"></script>
<script src="/i18n-app.js"></script>
</body>
</html>
+55
View File
@@ -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);
+63 -47
View File
@@ -24,7 +24,7 @@ pub struct WebhookServerConfig {
pub struct WebhookServer {
config: WebhookServerConfig,
routes: Vec<Router>,
/// Merged router saved after start() for restart_with_addr().
/// Merged router saved after start() for restarts via `install_listener()`.
merged_router: Option<Router>,
shutdown_tx: Option<oneshot::Sender<()>>,
handle: Option<JoinHandle<()>>,
@@ -59,7 +59,7 @@ impl WebhookServer {
}
/// Bind a listener to the configured address and spawn the server task.
/// Private helper used by 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<Router> {
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<oneshot::Sender<()>>, Option<JoinHandle<()>>) {
// 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
+1 -1
View File
@@ -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};
+17 -7
View File
@@ -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(())
}
+2 -2
View File
@@ -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);
+102 -84
View File
@@ -80,6 +80,16 @@ pub struct OpenClawMessage {
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
}
/// Open an OpenClaw SQLite database file via libsql for read-only access.
#[cfg(feature = "import")]
async fn open_sqlite(db_path: &Path) -> Result<libsql::Connection, ImportError> {
let db = libsql::Builder::new_local(db_path)
.build()
.await
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
db.connect().map_err(|e| ImportError::Sqlite(e.to_string()))
}
/// Reader for OpenClaw data files and databases.
pub struct OpenClawReader {
openclaw_dir: PathBuf,
@@ -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<Vec<OpenClawMemoryChunk>, 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<Vec<u8>> = row.get(2)?;
let chunk_index: i32 = row.get(3)?;
// Convert binary embedding blob to Vec<f32> 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<Vec<u8>> =
row.get(2).map_err(|e| ImportError::Sqlite(e.to_string()))?;
let chunk_index: i32 = row.get(3).map_err(|e| ImportError::Sqlite(e.to_string()))?;
// Convert binary embedding blob to Vec<f32> if present
let embedding = embedding_blob.map(|bytes| {
bytes
.chunks(4)
.map(|chunk| {
if chunk.len() == 4 {
f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])
} else {
0.0
}
})
.collect()
});
result.push(OpenClawMemoryChunk {
path,
content,
embedding,
chunk_index,
});
}
Ok(result)
@@ -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<Vec<OpenClawConversation>, 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<String> = 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<String> =
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<String> = msg_row
.get(2)
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
let msg_created_at = msg_created_at
.and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok())
.map(|dt| dt.with_timezone(&chrono::Utc));
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<String> = 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::<Result<Vec<_>, _>>()
.map_err(|e| ImportError::Sqlite(e.to_string()))?;
messages.push(OpenClawMessage {
role,
content,
created_at: msg_created_at,
});
}
conversations.push(OpenClawConversation {
id,
+1
View File
@@ -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;
+60 -19
View File
@@ -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<axum::Router> = Vec::new();
webhook_routes.push(webhooks::routes(ToolWebhookState {
tools: Arc::clone(&components.tools),
routine_engine: Arc::clone(&shared_routine_engine_slot),
user_id: config
.channels
.gateway
.as_ref()
.map(|g| g.user_id.clone())
.unwrap_or_else(|| "default".to_string()),
secrets_store: components.secrets_store.clone(),
}));
// Load WASM channels and register their webhook routes.
if config.channels.wasm_channels_enabled && config.channels.wasm_channels_dir.exists() {
let wasm_result = ironclaw::channels::wasm::setup_wasm_channels(
@@ -431,7 +448,6 @@ async fn async_main() -> anyhow::Result<()> {
let mut sse_sender: Option<
tokio::sync::broadcast::Sender<ironclaw::channels::web::types::SseEvent>,
> = None;
let mut routine_engine_slot: Option<ironclaw::channels::web::server::RoutineEngineSlot> = None;
if let Some(ref gw_config) = config.channels.gateway {
let mut gw =
GatewayChannel::new(gw_config.clone()).with_llm_provider(Arc::clone(&components.llm));
@@ -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;
}
}
+49 -4
View File
@@ -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"
);
}
}
+8
View File
@@ -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<crate::tools::wasm::WebhookCapability> {
None
}
/// Get the tool schema for LLM function calling.
fn schema(&self) -> ToolSchema {
ToolSchema {
+22
View File
@@ -32,6 +32,8 @@ pub struct Capabilities {
pub tool_invoke: Option<ToolInvokeCapability>,
/// Check if secrets exist.
pub secrets: Option<SecretsCapability>,
/// Webhook authentication and signature verification.
pub webhook: Option<WebhookCapability>,
}
impl Capabilities {
@@ -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<String>,
/// Secret name in secrets store for shared-secret validation.
pub secret_name: Option<String>,
/// Secret name in secrets store containing Ed25519 public key (Discord-style).
pub signature_key_secret_name: Option<String>,
/// Secret name in secrets store for HMAC-SHA256 signing validation.
pub hmac_secret_name: Option<String>,
/// Header containing signature (e.g. X-Hub-Signature-256 or X-Slack-Signature).
pub hmac_signature_header: Option<String>,
/// Optional timestamp header. When present, Slack-style v0 signature is used.
pub hmac_timestamp_header: Option<String>,
/// Optional signature prefix (default: "sha256=" or "v0=" for timestamped mode).
pub hmac_prefix: Option<String>,
}
#[cfg(test)]
mod tests {
use crate::tools::wasm::capabilities::{Capabilities, EndpointPattern, SecretsCapability};
@@ -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]
+72 -1
View File
@@ -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<WorkspaceCapabilitySchema>,
/// Tool webhook authentication/signature configuration.
#[serde(default)]
pub webhook: Option<WebhookCapabilitySchema>,
/// Authentication setup instructions.
/// Used by `ironclaw config` to guide users through auth setup.
#[serde(default)]
@@ -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<String>,
}
/// Webhook capability schema for tools.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WebhookCapabilitySchema {
/// HTTP header name for secret validation.
#[serde(default)]
pub secret_header: Option<String>,
/// Secret name in secrets store for shared-secret validation.
#[serde(default)]
pub secret_name: Option<String>,
/// Secret name in secrets store containing Ed25519 public key.
#[serde(default)]
pub signature_key_secret_name: Option<String>,
/// Secret name in secrets store for HMAC-SHA256 signing.
#[serde(default)]
pub hmac_secret_name: Option<String>,
/// Signature header for HMAC verification.
#[serde(default)]
pub hmac_signature_header: Option<String>,
/// Optional timestamp header for Slack-style v0 verification.
#[serde(default)]
pub hmac_timestamp_header: Option<String>,
/// Optional signature prefix for body-only HMAC mode (default sha256=).
#[serde(default)]
pub hmac_prefix: Option<String>,
}
impl WebhookCapabilitySchema {
fn to_webhook_capability(&self) -> WebhookCapability {
WebhookCapability {
secret_header: self.secret_header.clone(),
secret_name: self.secret_name.clone(),
signature_key_secret_name: self.signature_key_secret_name.clone(),
hmac_secret_name: self.hmac_secret_name.clone(),
hmac_signature_header: self.hmac_signature_header.clone(),
hmac_timestamp_header: self.hmac_timestamp_header.clone(),
hmac_prefix: self.hmac_prefix.clone(),
}
}
}
/// Authentication setup schema.
///
/// Tools declare their auth requirements here. The agent uses this to provide
@@ -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#"{
+1 -1
View File
@@ -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)
+4
View File
@@ -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<crate::tools::wasm::WebhookCapability> {
self.capabilities.webhook.clone()
}
}
impl std::fmt::Debug for WasmToolWrapper {
+712
View File
@@ -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<tokio::sync::RwLock<Option<Arc<RoutineEngine>>>>;
/// Shared state for the generic tools webhook ingress.
#[derive(Clone)]
pub struct ToolWebhookState {
pub tools: Arc<ToolRegistry>,
pub routine_engine: RoutineEngineSlot,
pub user_id: String,
pub secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>>,
}
#[derive(Debug, Serialize)]
struct ToolWebhookResponse {
status: &'static str,
tool: String,
emitted_events: usize,
fired_routines: usize,
}
#[derive(Debug, Deserialize)]
struct ToolWebhookOutput {
#[serde(default)]
emit_events: Vec<SystemEventIntent>,
}
#[derive(Debug, Deserialize)]
struct SystemEventIntent {
source: String,
event_type: String,
#[serde(default)]
payload: serde_json::Value,
}
const MAX_WEBHOOK_BODY_BYTES: usize = 64 * 1024;
/// Build routes for tool-driven webhook ingestion.
pub fn routes(state: ToolWebhookState) -> Router {
Router::new()
.route("/webhook/tools/{tool}", post(tool_webhook_handler))
.route(
"/webhook/tools/{tool}/{*rest}",
post(tool_webhook_with_rest_handler),
)
.route("/webhook/tools/{tool}", get(tool_webhook_health))
.layer(DefaultBodyLimit::max(MAX_WEBHOOK_BODY_BYTES))
.with_state(state)
}
async fn tool_webhook_health(
Path(tool): Path<String>,
State(state): State<ToolWebhookState>,
) -> (StatusCode, Json<serde_json::Value>) {
let Some(tool_impl) = state.tools.get(&tool).await else {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": format!("Tool not found: {tool}") })),
);
};
if tool_impl.webhook_capability().is_none() {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": format!("Tool does not support webhooks: {tool}") })),
);
}
(
StatusCode::OK,
Json(serde_json::json!({ "status": "ok", "tool": tool })),
)
}
async fn tool_webhook_handler(
Path(tool): Path<String>,
State(state): State<ToolWebhookState>,
method: Method,
headers: HeaderMap,
Query(query): Query<HashMap<String, String>>,
body: axum::body::Bytes,
) -> (StatusCode, Json<serde_json::Value>) {
tool_webhook_handler_inner(tool, None, state, method, headers, query, body).await
}
async fn tool_webhook_with_rest_handler(
Path((tool, rest)): Path<(String, String)>,
State(state): State<ToolWebhookState>,
method: Method,
headers: HeaderMap,
Query(query): Query<HashMap<String, String>>,
body: axum::body::Bytes,
) -> (StatusCode, Json<serde_json::Value>) {
tool_webhook_handler_inner(tool, Some(rest), state, method, headers, query, body).await
}
async fn tool_webhook_handler_inner(
tool: String,
rest: Option<String>,
state: ToolWebhookState,
method: Method,
headers: HeaderMap,
query: HashMap<String, String>,
body: axum::body::Bytes,
) -> (StatusCode, Json<serde_json::Value>) {
if body.len() > MAX_WEBHOOK_BODY_BYTES {
return (
StatusCode::PAYLOAD_TOO_LARGE,
Json(serde_json::json!({
"error": format!("Webhook body exceeds {} bytes", MAX_WEBHOOK_BODY_BYTES)
})),
);
}
let Some(tool_impl) = state.tools.get(&tool).await else {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": format!("Tool not found: {tool}") })),
);
};
if let Err(msg) = validate_webhook_auth(
&*tool_impl,
state.secrets_store.as_deref(),
&state.user_id,
&headers,
&body,
)
.await
{
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({ "error": msg })),
);
}
let body_json: Option<serde_json::Value> = serde_json::from_slice(&body).ok();
let headers_map: HashMap<String, String> = headers
.iter()
.filter_map(|(k, v)| {
v.to_str()
.ok()
.map(|v| (k.as_str().to_string(), v.to_string()))
})
.collect();
let path = if let Some(rest) = rest.filter(|r| !r.is_empty()) {
format!("/webhook/tools/{tool}/{rest}")
} else {
format!("/webhook/tools/{tool}")
};
let params = serde_json::json!({
"action": "handle_webhook",
"webhook": {
"method": method.as_str(),
"path": path,
"query": query,
"headers": headers_map,
"body_json": body_json,
"body_raw": String::from_utf8_lossy(&body),
}
});
let ctx = JobContext::with_user(
state.user_id.clone(),
format!("webhook:{tool}"),
"Process external webhook",
);
let output = match tool_impl.execute(params, &ctx).await {
Ok(out) => out,
Err(e) => {
tracing::warn!(tool = %tool, error = %e, "Webhook tool execution failed");
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "Tool execution failed" })),
);
}
};
let parsed: ToolWebhookOutput = match serde_json::from_value(output.result) {
Ok(v) => v,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Tool webhook response must be a JSON object (optionally with 'emit_events' array)"
})),
);
}
};
let emitted_events = parsed.emit_events.len();
let mut fired_routines = 0usize;
if emitted_events > 0 {
let Some(engine) = state.routine_engine.read().await.as_ref().cloned() else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({ "error": "Routine engine not available" })),
);
};
for event in parsed.emit_events {
fired_routines += engine
.emit_system_event(
&event.source,
&event.event_type,
&event.payload,
Some(&state.user_id),
)
.await;
}
}
let response = ToolWebhookResponse {
status: "accepted",
tool,
emitted_events,
fired_routines,
};
(StatusCode::ACCEPTED, Json(serde_json::json!(response)))
}
fn header_value<'a>(headers: &'a HeaderMap, key: &str) -> Option<&'a str> {
// HeaderMap::get() already performs case-insensitive lookup per HTTP spec.
headers.get(key).and_then(|v| v.to_str().ok())
}
async fn validate_webhook_auth(
tool: &dyn crate::tools::Tool,
secrets_store: Option<&(dyn SecretsStore + Send + Sync)>,
user_id: &str,
headers: &HeaderMap,
body: &[u8],
) -> Result<(), String> {
let Some(cfg) = tool.webhook_capability() else {
return Err(
"Tool does not declare a webhook capability; webhook access denied".to_string(),
);
};
// Require at least one authentication mechanism to be configured.
if cfg.secret_name.is_none()
&& cfg.signature_key_secret_name.is_none()
&& cfg.hmac_secret_name.is_none()
{
return Err(
"Webhook capability misconfigured: at least one auth mechanism must be configured"
.to_string(),
);
}
let Some(store) = secrets_store else {
return Err("Secrets store not available for webhook verification".to_string());
};
if let Some(secret_name) = cfg.secret_name.as_deref() {
let expected = store
.get_decrypted(user_id, secret_name)
.await
.map_err(|_| format!("Missing webhook secret '{secret_name}'"))?;
let expected = expected.expose();
let secret_header = cfg.secret_header.as_deref().unwrap_or("x-webhook-secret");
let provided = header_value(headers, secret_header)
.or_else(|| {
if secret_header != "x-webhook-secret" {
header_value(headers, "x-webhook-secret")
} else {
None
}
})
.ok_or_else(|| "Webhook secret required".to_string())?;
if !bool::from(expected.as_bytes().ct_eq(provided.as_bytes())) {
return Err("Invalid webhook secret".to_string());
}
}
if let Some(public_key_name) = cfg.signature_key_secret_name.as_deref() {
let key = store
.get_decrypted(user_id, public_key_name)
.await
.map_err(|_| format!("Missing signature key secret '{public_key_name}'"))?;
let key = key.expose();
let sig = header_value(headers, "x-signature-ed25519")
.ok_or_else(|| "Missing signature header".to_string())?;
let ts = header_value(headers, "x-signature-timestamp")
.ok_or_else(|| "Missing signature timestamp header".to_string())?;
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
if !crate::channels::wasm::signature::verify_discord_signature(key, sig, ts, body, now_secs)
{
return Err("Invalid signature".to_string());
}
}
if let Some(hmac_secret_name) = cfg.hmac_secret_name.as_deref() {
let secret = store
.get_decrypted(user_id, hmac_secret_name)
.await
.map_err(|_| format!("Missing HMAC secret '{hmac_secret_name}'"))?;
let secret = secret.expose();
if let Some(timestamp_header) = cfg.hmac_timestamp_header.as_deref() {
let sig_header = cfg
.hmac_signature_header
.as_deref()
.unwrap_or("x-slack-signature");
let sig = header_value(headers, sig_header)
.ok_or_else(|| "Missing HMAC signature header".to_string())?;
let ts = header_value(headers, timestamp_header)
.ok_or_else(|| "Missing HMAC timestamp header".to_string())?;
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
if !crate::channels::wasm::signature::verify_slack_signature(
secret, ts, body, sig, now_secs,
) {
return Err("Invalid timestamped HMAC signature".to_string());
}
} else {
let sig_header = cfg
.hmac_signature_header
.as_deref()
.unwrap_or("x-hub-signature-256");
let prefix = cfg.hmac_prefix.as_deref().unwrap_or("sha256=");
let sig = header_value(headers, sig_header)
.ok_or_else(|| "Missing HMAC signature header".to_string())?;
if !crate::channels::wasm::signature::verify_hmac_sha256_prefixed(
secret, body, sig, prefix,
) {
return Err("Invalid HMAC signature".to_string());
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use axum::body::Body;
use tower::ServiceExt;
use crate::context::JobContext;
use crate::secrets::{CreateSecretParams, InMemorySecretsStore, SecretsCrypto};
use crate::tools::{Tool, ToolError, ToolOutput, ToolRegistry};
use super::*;
struct TestWebhookTool;
struct ProtectedWebhookTool;
struct HmacWebhookTool;
/// Tool that declares webhook_capability() but with no auth mechanism configured.
struct MisconfiguredWebhookTool;
#[async_trait]
impl Tool for TestWebhookTool {
fn name(&self) -> &str {
"test_webhook"
}
fn description(&self) -> &str {
"test"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({"type":"object"})
}
async fn execute(
&self,
_params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
Ok(ToolOutput::success(
serde_json::json!({"emit_events":[]}),
Duration::from_millis(1),
))
}
}
#[async_trait]
impl Tool for ProtectedWebhookTool {
fn name(&self) -> &str {
"protected_webhook"
}
fn description(&self) -> &str {
"protected test"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({"type":"object"})
}
async fn execute(
&self,
_params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
Ok(ToolOutput::success(
serde_json::json!({"emit_events":[]}),
Duration::from_millis(1),
))
}
fn webhook_capability(&self) -> Option<crate::tools::wasm::WebhookCapability> {
Some(crate::tools::wasm::WebhookCapability {
secret_name: Some("test_webhook_secret".to_string()),
secret_header: Some("x-webhook-secret".to_string()),
..Default::default()
})
}
}
#[async_trait]
impl Tool for HmacWebhookTool {
fn name(&self) -> &str {
"hmac_webhook"
}
fn description(&self) -> &str {
"hmac test"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({"type":"object"})
}
async fn execute(
&self,
_params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
Ok(ToolOutput::success(
serde_json::json!({"emit_events":[]}),
Duration::from_millis(1),
))
}
fn webhook_capability(&self) -> Option<crate::tools::wasm::WebhookCapability> {
Some(crate::tools::wasm::WebhookCapability {
hmac_secret_name: Some("hmac_secret".to_string()),
hmac_signature_header: Some("x-hub-signature-256".to_string()),
hmac_prefix: Some("sha256=".to_string()),
..Default::default()
})
}
}
#[async_trait]
impl Tool for MisconfiguredWebhookTool {
fn name(&self) -> &str {
"misconfigured_webhook"
}
fn description(&self) -> &str {
"misconfigured test"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({"type":"object"})
}
async fn execute(
&self,
_params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
Ok(ToolOutput::success(
serde_json::json!({"emit_events":[]}),
Duration::from_millis(1),
))
}
fn webhook_capability(&self) -> Option<crate::tools::wasm::WebhookCapability> {
Some(crate::tools::wasm::WebhookCapability::default())
}
}
#[tokio::test]
async fn returns_not_found_for_unknown_tool() {
let tools = Arc::new(ToolRegistry::new());
let app = routes(ToolWebhookState {
tools,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
user_id: "test".to_string(),
secrets_store: None,
});
let req = axum::http::Request::builder()
.method("POST")
.uri("/webhook/tools/missing")
.body(Body::from("{}"))
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn rejects_tool_without_webhook_capability() {
let tools = Arc::new(ToolRegistry::new());
tools.register(Arc::new(TestWebhookTool)).await;
let app = routes(ToolWebhookState {
tools,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
user_id: "test".to_string(),
secrets_store: None,
});
let req = axum::http::Request::builder()
.method("POST")
.uri("/webhook/tools/test_webhook")
.header("content-type", "application/json")
.body(Body::from(r#"{"ok":true}"#))
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn rejects_when_required_secret_missing() {
let tools = Arc::new(ToolRegistry::new());
tools.register(Arc::new(ProtectedWebhookTool)).await;
let secrets = Arc::new(InMemorySecretsStore::new(Arc::new(
SecretsCrypto::new(secrecy::SecretString::from(
"test-key-at-least-32-chars-long!!".to_string(),
))
.expect("crypto"),
)));
secrets
.create(
"test",
CreateSecretParams::new("test_webhook_secret", "s3cret"),
)
.await
.expect("secret create");
let app = routes(ToolWebhookState {
tools,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
user_id: "test".to_string(),
secrets_store: Some(secrets),
});
let req = axum::http::Request::builder()
.method("POST")
.uri("/webhook/tools/protected_webhook")
.header("content-type", "application/json")
.body(Body::from(r#"{"ok":true}"#))
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn accepts_with_valid_hmac_signature() {
use hmac::Mac;
let tools = Arc::new(ToolRegistry::new());
tools.register(Arc::new(HmacWebhookTool)).await;
let secrets = Arc::new(InMemorySecretsStore::new(Arc::new(
SecretsCrypto::new(secrecy::SecretString::from(
"test-key-at-least-32-chars-long!!".to_string(),
))
.expect("crypto"),
)));
secrets
.create(
"test",
CreateSecretParams::new("hmac_secret", "github-secret"),
)
.await
.expect("secret create");
let app = routes(ToolWebhookState {
tools,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
user_id: "test".to_string(),
secrets_store: Some(secrets),
});
let payload = br#"{"action":"opened"}"#;
let mut mac =
hmac::Hmac::<sha2::Sha256>::new_from_slice(b"github-secret").expect("hmac key");
mac.update(payload);
let sig = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
let req = axum::http::Request::builder()
.method("POST")
.uri("/webhook/tools/hmac_webhook")
.header("content-type", "application/json")
.header("x-hub-signature-256", sig)
.body(Body::from(payload.to_vec()))
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::ACCEPTED);
}
#[tokio::test]
async fn rejects_empty_webhook_capability_as_misconfigured() {
let tools = Arc::new(ToolRegistry::new());
tools.register(Arc::new(MisconfiguredWebhookTool)).await;
let secrets = Arc::new(InMemorySecretsStore::new(Arc::new(
SecretsCrypto::new(secrecy::SecretString::from(
"test-key-at-least-32-chars-long!!".to_string(),
))
.expect("crypto"),
)));
let app = routes(ToolWebhookState {
tools,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
user_id: "test".to_string(),
secrets_store: Some(secrets),
});
let req = axum::http::Request::builder()
.method("POST")
.uri("/webhook/tools/misconfigured_webhook")
.header("content-type", "application/json")
.body(Body::from(r#"{"ok":true}"#))
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn health_check_returns_ok_for_webhook_capable_tool() {
let tools = Arc::new(ToolRegistry::new());
tools.register(Arc::new(ProtectedWebhookTool)).await;
let app = routes(ToolWebhookState {
tools,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
user_id: "test".to_string(),
secrets_store: None,
});
let req = axum::http::Request::builder()
.method("GET")
.uri("/webhook/tools/protected_webhook")
.body(Body::empty())
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn health_check_returns_not_found_for_non_webhook_tool() {
let tools = Arc::new(ToolRegistry::new());
tools.register(Arc::new(TestWebhookTool)).await;
let app = routes(ToolWebhookState {
tools,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
user_id: "test".to_string(),
secrets_store: None,
});
let req = axum::http::Request::builder()
.method("GET")
.uri("/webhook/tools/test_webhook")
.body(Body::empty())
.expect("request");
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
}
+3 -3
View File
@@ -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
+15 -4
View File
@@ -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;
+5
View File
@@ -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;
+10 -2
View File
@@ -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;
+50 -35
View File
@@ -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<PathBuf, Box<dyn std::error::Error>> {
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::<Vec<u8>>,
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::<Vec<u8>>,
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
+63 -53
View File
@@ -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<dyn std::error::Error>> {
async fn setup_full_openclaw_test_env() -> Result<(TempDir, PathBuf), Box<dyn std::error::Error>>
{
let temp_dir = TempDir::new()?;
let openclaw_path = temp_dir.path().to_path_buf();
@@ -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<dyn std::error::Error>> {
use rusqlite::Connection;
let conn = Connection::open(db_path)?;
async fn create_full_agent_db(db_path: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
let db = libsql::Builder::new_local(db_path).build().await?;
let conn = db.connect()?;
// Chunks table
conn.execute(
@@ -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::<Vec<u8>>,
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
+88 -56
View File
@@ -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::<Vec<u8>>, 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::<Vec<u8>>, 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::<String>],
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::<String>],
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());
+35 -28
View File
@@ -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<dyn std::error::Error>> {
async fn create_minimal_openclaw() -> Result<(TempDir, PathBuf), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
let openclaw_path = temp_dir.path().to_path_buf();
@@ -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::<Vec<u8>>,
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
+93 -70
View File
@@ -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<dyn std::error::Error>> {
async fn create_test_openclaw() -> Result<(TempDir, PathBuf), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
let openclaw_path = temp_dir.path().to_path_buf();
@@ -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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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::<Vec<u8>>,
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<u8> = vec![0.1f32; 1536]
.iter()
.flat_map(|f| f.to_le_bytes().to_vec())
.collect::<Vec<u8>>();
.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<u8> = vec![0.5f32; 1536]
.iter()
.flat_map(|f| f.to_le_bytes().to_vec())
.collect::<Vec<u8>>();
.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();
+9 -1
View File
@@ -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
+44 -3
View File
@@ -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<String> = 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()),