From 369741fc60bf4ec1a28445c23d99db4a7f9c04c3 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Wed, 11 Mar 2026 03:36:25 +0000 Subject: [PATCH 1/8] Add generic host-verified /webhook/tools/{tool} ingress (#757) * Add generic host-verified webhook ingress for tools * Stabilize trace E2E test rig and approval behavior * Fix webhook security issues from review feedback - Reject tools without webhook_capability() (was unauthenticated RCE) - Remove secret-in-query-string fallback (leak via logs/referrers) - Require approval for event_emit tool (escalation via routine triggers) - Simplify header_value() (HeaderMap already case-insensitive) - Redact internal errors from webhook HTTP responses - Remove unused hmac_timestamp_tolerance_secs field - Add regression test for tool without webhook capability [skip-regression-check] Co-Authored-By: Claude Opus 4.6 * Harden webhook ingress: require auth mechanism, body limit layer, health check - Reject webhook capabilities that declare no auth mechanism (empty WebhookCapability would previously allow unauthenticated access) - Add DefaultBodyLimit layer to reject oversized payloads before buffering - Health check (GET) now verifies tool has webhook_capability(), not just existence - Add regression tests for all three fixes [skip-regression-check] Co-Authored-By: Claude Opus 4.6 * Fix auto_approve_tools inconsistency between dispatcher and thread_ops dispatcher.rs skips all approval checks (including Always) when auto_approve_tools is true, but thread_ops.rs still required approval for Always tools. This caused deferred tool calls to unexpectedly halt in test rigs and auto-approve configurations. Match dispatcher behavior: short-circuit all approval when auto_approve_tools is enabled. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- FEATURE_PARITY.md | 3 +- src/agent/thread_ops.rs | 20 +- src/channels/wasm/signature.rs | 46 ++ src/channels/web/mod.rs | 6 + src/lib.rs | 1 + src/main.rs | 25 +- src/tools/tool.rs | 8 + src/tools/wasm/capabilities.rs | 22 + src/tools/wasm/capabilities_schema.rs | 73 ++- src/tools/wasm/mod.rs | 2 +- src/tools/wasm/wrapper.rs | 4 + src/webhooks/mod.rs | 712 ++++++++++++++++++++++++++ tests/e2e_advanced_traces.rs | 19 +- tests/e2e_builtin_tool_coverage.rs | 5 + tests/e2e_metrics_test.rs | 12 +- tests/support/assertions.rs | 10 +- tests/support/test_rig.rs | 47 +- 17 files changed, 989 insertions(+), 26 deletions(-) create mode 100644 src/webhooks/mod.rs diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index 634131fc..075d8007 100644 --- a/FEATURE_PARITY.md +++ b/FEATURE_PARITY.md @@ -440,6 +440,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | `before_agent_start` hook | ✅ | ❌ | P2 | Model/provider override | | `before_message_write` hook | ✅ | ❌ | P2 | Pre-write interception | | `onMessage` hook | ✅ | ✅ | - | Routines with event trigger | +| Structured system-event routines | ✅ | ✅ | P2 | `system_event` trigger + `event_emit` tool for event-driven automation | | `onSessionStart` hook | ✅ | ✅ | P2 | | | `onSessionEnd` hook | ✅ | ✅ | P2 | | | `transcribeAudio` hook | ✅ | ❌ | P3 | | @@ -558,7 +559,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O - ❌ Media handling (images, PDFs) - ✅ Ollama/local model support (via rig::providers::ollama) - ❌ Configuration hot-reload -- ❌ Webhook trigger endpoint in web gateway +- ✅ Tool-driven webhook ingress (`/webhook/tools/{tool}` -> host-verified + tool-normalized `system_event` routines) - ❌ Channel health monitor with auto-restart - ❌ Partial output preservation on abort diff --git a/src/agent/thread_ops.rs b/src/agent/thread_ops.rs index e7f526e3..786c6d68 100644 --- a/src/agent/thread_ops.rs +++ b/src/agent/thread_ops.rs @@ -925,14 +925,20 @@ impl Agent { for (idx, tc) in deferred_tool_calls.iter().enumerate() { if let Some(tool) = self.tools().get(&tc.name).await { - use crate::tools::ApprovalRequirement; - let needs_approval = match tool.requires_approval(&tc.arguments) { - ApprovalRequirement::Never => false, - ApprovalRequirement::UnlessAutoApproved => { - let sess = session.lock().await; - !sess.is_tool_auto_approved(&tc.name) + // Match dispatcher.rs: when auto_approve_tools is true, skip + // all approval checks (including ApprovalRequirement::Always). + let needs_approval = if self.config.auto_approve_tools { + false + } else { + use crate::tools::ApprovalRequirement; + match tool.requires_approval(&tc.arguments) { + ApprovalRequirement::Never => false, + ApprovalRequirement::UnlessAutoApproved => { + let sess = session.lock().await; + !sess.is_tool_auto_approved(&tc.name) + } + ApprovalRequirement::Always => true, } - ApprovalRequirement::Always => true, }; if needs_approval { diff --git a/src/channels/wasm/signature.rs b/src/channels/wasm/signature.rs index 8b48d88c..2253bff5 100644 --- a/src/channels/wasm/signature.rs +++ b/src/channels/wasm/signature.rs @@ -106,6 +106,34 @@ pub fn verify_slack_signature( .into() } +/// Verify raw-body HMAC-SHA256 signature with a configurable prefix. +/// +/// Computes `HMAC-SHA256(secret, body)` and compares against +/// `prefix + hex_digest` in constant time. +pub fn verify_hmac_sha256_prefixed( + secret: &str, + body: &[u8], + signature_header: &str, + prefix: &str, +) -> bool { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + use subtle::ConstantTimeEq; + + let mut mac = match Hmac::::new_from_slice(secret.as_bytes()) { + Ok(m) => m, + Err(_) => return false, + }; + mac.update(body); + let computed = mac.finalize().into_bytes(); + let computed_hex = hex::encode(computed); + let expected = format!("{prefix}{computed_hex}"); + expected + .as_bytes() + .ct_eq(signature_header.as_bytes()) + .into() +} + #[cfg(test)] mod tests { use super::*; @@ -498,6 +526,24 @@ mod tests { ); } + #[test] + fn test_hmac_sha256_prefixed_valid() { + let secret = "github-secret"; + let body = br#"{"action":"opened"}"#; + use hmac::{Hmac, Mac}; + use sha2::Sha256; + let mut mac = Hmac::::new_from_slice(secret.as_bytes()).expect("hmac key"); + mac.update(body); + let sig = format!("sha256={}", hex::encode(mac.finalize().into_bytes())); + assert!(verify_hmac_sha256_prefixed(secret, body, &sig, "sha256=")); + assert!(!verify_hmac_sha256_prefixed( + secret, + body, + "sha256=deadbeef", + "sha256=" + )); + } + #[test] fn test_slack_stale_timestamp_rejected() { let signing_secret = "my-signing-secret"; diff --git a/src/channels/web/mod.rs b/src/channels/web/mod.rs index b0e1d29e..4c575caf 100644 --- a/src/channels/web/mod.rs +++ b/src/channels/web/mod.rs @@ -244,6 +244,12 @@ impl GatewayChannel { self } + /// Inject a shared routine engine slot used by other HTTP ingress paths. + pub fn with_routine_engine_slot(mut self, slot: server::RoutineEngineSlot) -> Self { + self.rebuild_state(|s| s.routine_engine = slot); + self + } + /// Get the auth token (for printing to console on startup). pub fn auth_token(&self) -> &str { &self.auth_token diff --git a/src/lib.rs b/src/lib.rs index 4ec8d906..51e54909 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -74,6 +74,7 @@ pub mod tracing_fmt; pub mod transcription; pub mod tunnel; pub mod util; +pub mod webhooks; pub mod worker; pub mod workspace; diff --git a/src/main.rs b/src/main.rs index 4190de2a..46421428 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,6 +24,7 @@ use ironclaw::{ orchestrator::{ReaperConfig, SandboxReaper}, pairing::PairingStore, tracing_fmt::{init_cli_tracing, init_worker_tracing}, + webhooks::{self, ToolWebhookState}, }; #[cfg(any(feature = "postgres", feature = "libsql"))] @@ -277,9 +278,25 @@ async fn async_main() -> anyhow::Result<()> { } } + // Shared routine engine slot for gateway + generic webhook ingress. + let shared_routine_engine_slot: ironclaw::channels::web::server::RoutineEngineSlot = + Arc::new(tokio::sync::RwLock::new(None)); + // Collect webhook route fragments; a single WebhookServer hosts them all. let mut webhook_routes: Vec = Vec::new(); + webhook_routes.push(webhooks::routes(ToolWebhookState { + tools: Arc::clone(&components.tools), + routine_engine: Arc::clone(&shared_routine_engine_slot), + user_id: config + .channels + .gateway + .as_ref() + .map(|g| g.user_id.clone()) + .unwrap_or_else(|| "default".to_string()), + secrets_store: components.secrets_store.clone(), + })); + // Load WASM channels and register their webhook routes. if config.channels.wasm_channels_enabled && config.channels.wasm_channels_dir.exists() { let wasm_result = ironclaw::channels::wasm::setup_wasm_channels( @@ -431,7 +448,6 @@ async fn async_main() -> anyhow::Result<()> { let mut sse_sender: Option< tokio::sync::broadcast::Sender, > = None; - let mut routine_engine_slot: Option = None; if let Some(ref gw_config) = config.channels.gateway { let mut gw = GatewayChannel::new(gw_config.clone()).with_llm_provider(Arc::clone(&components.llm)); @@ -455,6 +471,7 @@ async fn async_main() -> anyhow::Result<()> { gw = gw.with_job_manager(Arc::clone(jm)); } gw = gw.with_scheduler(scheduler_slot.clone()); + gw = gw.with_routine_engine_slot(Arc::clone(&shared_routine_engine_slot)); if let Some(ref sr) = components.skill_registry { gw = gw.with_skill_registry(Arc::clone(sr)); } @@ -489,8 +506,6 @@ async fn async_main() -> anyhow::Result<()> { // IMPORTANT: This must come after all `with_*` calls since `rebuild_state` // creates a new SseManager, which would orphan this sender. sse_sender = Some(gw.state().sse.sender()); - routine_engine_slot = Some(Arc::clone(&gw.state().routine_engine)); - channel_names.push("gateway".to_string()); channels.add(Box::new(gw)).await; } @@ -689,9 +704,7 @@ async fn async_main() -> anyhow::Result<()> { } // Give the agent the routine engine slot so it can expose the engine to the gateway. - if let Some(slot) = routine_engine_slot { - agent.set_routine_engine_slot(slot); - } + agent.set_routine_engine_slot(shared_routine_engine_slot); // Prepare SIGHUP handler for hot-reloading HTTP webhook config // Broadcast channel for clean shutdown of background tasks diff --git a/src/tools/tool.rs b/src/tools/tool.rs index 8bf29168..b5879e5a 100644 --- a/src/tools/tool.rs +++ b/src/tools/tool.rs @@ -328,6 +328,14 @@ pub trait Tool: Send + Sync { None } + /// Optional host-side webhook verification configuration for this tool. + /// + /// When present, `/webhook/tools/{tool}` validates shared secret/signatures + /// before invoking the tool. Tools should then only handle payload normalization. + fn webhook_capability(&self) -> Option { + None + } + /// Get the tool schema for LLM function calling. fn schema(&self) -> ToolSchema { ToolSchema { diff --git a/src/tools/wasm/capabilities.rs b/src/tools/wasm/capabilities.rs index 088d7e18..ff98ae03 100644 --- a/src/tools/wasm/capabilities.rs +++ b/src/tools/wasm/capabilities.rs @@ -32,6 +32,8 @@ pub struct Capabilities { pub tool_invoke: Option, /// Check if secrets exist. pub secrets: Option, + /// Webhook authentication and signature verification. + pub webhook: Option, } impl Capabilities { @@ -308,6 +310,25 @@ impl SecretsCapability { /// WASM capabilities use it to configure per-tool HTTP request limits. pub use crate::tools::tool::ToolRateLimitConfig as RateLimitConfig; +/// Webhook auth/signature capability configuration for tools. +#[derive(Debug, Clone, Default)] +pub struct WebhookCapability { + /// Optional header name for shared-secret validation. + pub secret_header: Option, + /// Secret name in secrets store for shared-secret validation. + pub secret_name: Option, + /// Secret name in secrets store containing Ed25519 public key (Discord-style). + pub signature_key_secret_name: Option, + /// Secret name in secrets store for HMAC-SHA256 signing validation. + pub hmac_secret_name: Option, + /// Header containing signature (e.g. X-Hub-Signature-256 or X-Slack-Signature). + pub hmac_signature_header: Option, + /// Optional timestamp header. When present, Slack-style v0 signature is used. + pub hmac_timestamp_header: Option, + /// Optional signature prefix (default: "sha256=" or "v0=" for timestamped mode). + pub hmac_prefix: Option, +} + #[cfg(test)] mod tests { use crate::tools::wasm::capabilities::{Capabilities, EndpointPattern, SecretsCapability}; @@ -319,6 +340,7 @@ mod tests { assert!(caps.http.is_none()); assert!(caps.tool_invoke.is_none()); assert!(caps.secrets.is_none()); + assert!(caps.webhook.is_none()); } #[test] diff --git a/src/tools/wasm/capabilities_schema.rs b/src/tools/wasm/capabilities_schema.rs index 9fa6e241..99007943 100644 --- a/src/tools/wasm/capabilities_schema.rs +++ b/src/tools/wasm/capabilities_schema.rs @@ -35,7 +35,7 @@ use serde::{Deserialize, Serialize}; use crate::secrets::{CredentialLocation, CredentialMapping}; use crate::tools::wasm::{ Capabilities, EndpointPattern, HttpCapability, RateLimitConfig, SecretsCapability, - ToolInvokeCapability, WorkspaceCapability, + ToolInvokeCapability, WebhookCapability, WorkspaceCapability, }; /// Root schema for a capabilities JSON file. @@ -65,6 +65,10 @@ pub struct CapabilitiesFile { #[serde(default)] pub workspace: Option, + /// Tool webhook authentication/signature configuration. + #[serde(default)] + pub webhook: Option, + /// Authentication setup instructions. /// Used by `ironclaw config` to guide users through auth setup. #[serde(default)] @@ -107,6 +111,7 @@ impl CapabilitiesFile { self.secrets = self.secrets.or(inner.secrets); self.tool_invoke = self.tool_invoke.or(inner.tool_invoke); self.workspace = self.workspace.or(inner.workspace); + self.webhook = self.webhook.or(inner.webhook); self.auth = self.auth.or(inner.auth); self.setup = self.setup.or(inner.setup); } @@ -198,6 +203,10 @@ impl CapabilitiesFile { }); } + if let Some(webhook) = &self.webhook { + caps.webhook = Some(webhook.to_webhook_capability()); + } + caps } } @@ -419,6 +428,46 @@ pub struct WorkspaceCapabilitySchema { pub allowed_prefixes: Vec, } +/// Webhook capability schema for tools. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct WebhookCapabilitySchema { + /// HTTP header name for secret validation. + #[serde(default)] + pub secret_header: Option, + /// Secret name in secrets store for shared-secret validation. + #[serde(default)] + pub secret_name: Option, + /// Secret name in secrets store containing Ed25519 public key. + #[serde(default)] + pub signature_key_secret_name: Option, + /// Secret name in secrets store for HMAC-SHA256 signing. + #[serde(default)] + pub hmac_secret_name: Option, + /// Signature header for HMAC verification. + #[serde(default)] + pub hmac_signature_header: Option, + /// Optional timestamp header for Slack-style v0 verification. + #[serde(default)] + pub hmac_timestamp_header: Option, + /// Optional signature prefix for body-only HMAC mode (default sha256=). + #[serde(default)] + pub hmac_prefix: Option, +} + +impl WebhookCapabilitySchema { + fn to_webhook_capability(&self) -> WebhookCapability { + WebhookCapability { + secret_header: self.secret_header.clone(), + secret_name: self.secret_name.clone(), + signature_key_secret_name: self.signature_key_secret_name.clone(), + hmac_secret_name: self.hmac_secret_name.clone(), + hmac_signature_header: self.hmac_signature_header.clone(), + hmac_timestamp_header: self.hmac_timestamp_header.clone(), + hmac_prefix: self.hmac_prefix.clone(), + } + } +} + /// Authentication setup schema. /// /// Tools declare their auth requirements here. The agent uses this to provide @@ -769,6 +818,28 @@ mod tests { assert_eq!(workspace.allowed_prefixes, vec!["context/", "daily/"]); } + #[test] + fn test_parse_webhook_capability() { + let json = r#"{ + "webhook": { + "hmac_secret_name": "github_webhook_secret", + "hmac_signature_header": "x-hub-signature-256", + "hmac_prefix": "sha256=" + } + }"#; + + let caps = CapabilitiesFile::from_json(json).unwrap(); + let webhook = caps.webhook.unwrap(); + assert_eq!( + webhook.hmac_secret_name.as_deref(), + Some("github_webhook_secret") + ); + assert_eq!( + webhook.hmac_signature_header.as_deref(), + Some("x-hub-signature-256") + ); + } + #[test] fn test_to_capabilities() { let json = r#"{ diff --git a/src/tools/wasm/mod.rs b/src/tools/wasm/mod.rs index 55b5b0cd..647d2837 100644 --- a/src/tools/wasm/mod.rs +++ b/src/tools/wasm/mod.rs @@ -108,7 +108,7 @@ pub use wrapper::{OAuthRefreshConfig, WasmToolWrapper}; // Capabilities (V2) pub use capabilities::{ Capabilities, EndpointPattern, HttpCapability, RateLimitConfig, SecretsCapability, - ToolInvokeCapability, WorkspaceCapability, WorkspaceReader, + ToolInvokeCapability, WebhookCapability, WorkspaceCapability, WorkspaceReader, }; // Security components (V2) diff --git a/src/tools/wasm/wrapper.rs b/src/tools/wasm/wrapper.rs index 26c2d5d1..c0294c51 100644 --- a/src/tools/wasm/wrapper.rs +++ b/src/tools/wasm/wrapper.rs @@ -808,6 +808,10 @@ impl Tool for WasmToolWrapper { // Use the timeout as a conservative estimate Some(self.prepared.limits.timeout) } + + fn webhook_capability(&self) -> Option { + self.capabilities.webhook.clone() + } } impl std::fmt::Debug for WasmToolWrapper { diff --git a/src/webhooks/mod.rs b/src/webhooks/mod.rs new file mode 100644 index 00000000..47f14300 --- /dev/null +++ b/src/webhooks/mod.rs @@ -0,0 +1,712 @@ +//! Generic webhook ingress for tools. +//! +//! Exposes `/webhook/tools/{tool}` so external webhook providers can POST +//! payloads that are normalized by the target tool into `system_event`s. + +use std::collections::HashMap; +use std::sync::Arc; + +use axum::{ + Json, Router, + extract::{DefaultBodyLimit, Path, Query, State}, + http::{HeaderMap, Method, StatusCode}, + routing::{get, post}, +}; +use serde::{Deserialize, Serialize}; +use subtle::ConstantTimeEq; + +use crate::agent::routine_engine::RoutineEngine; +use crate::context::JobContext; +use crate::secrets::SecretsStore; +use crate::tools::ToolRegistry; + +/// Shared routine engine slot, populated by Agent after startup. +pub type RoutineEngineSlot = Arc>>>; + +/// Shared state for the generic tools webhook ingress. +#[derive(Clone)] +pub struct ToolWebhookState { + pub tools: Arc, + pub routine_engine: RoutineEngineSlot, + pub user_id: String, + pub secrets_store: Option>, +} + +#[derive(Debug, Serialize)] +struct ToolWebhookResponse { + status: &'static str, + tool: String, + emitted_events: usize, + fired_routines: usize, +} + +#[derive(Debug, Deserialize)] +struct ToolWebhookOutput { + #[serde(default)] + emit_events: Vec, +} + +#[derive(Debug, Deserialize)] +struct SystemEventIntent { + source: String, + event_type: String, + #[serde(default)] + payload: serde_json::Value, +} + +const MAX_WEBHOOK_BODY_BYTES: usize = 64 * 1024; + +/// Build routes for tool-driven webhook ingestion. +pub fn routes(state: ToolWebhookState) -> Router { + Router::new() + .route("/webhook/tools/{tool}", post(tool_webhook_handler)) + .route( + "/webhook/tools/{tool}/{*rest}", + post(tool_webhook_with_rest_handler), + ) + .route("/webhook/tools/{tool}", get(tool_webhook_health)) + .layer(DefaultBodyLimit::max(MAX_WEBHOOK_BODY_BYTES)) + .with_state(state) +} + +async fn tool_webhook_health( + Path(tool): Path, + State(state): State, +) -> (StatusCode, Json) { + let Some(tool_impl) = state.tools.get(&tool).await else { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": format!("Tool not found: {tool}") })), + ); + }; + if tool_impl.webhook_capability().is_none() { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": format!("Tool does not support webhooks: {tool}") })), + ); + } + ( + StatusCode::OK, + Json(serde_json::json!({ "status": "ok", "tool": tool })), + ) +} + +async fn tool_webhook_handler( + Path(tool): Path, + State(state): State, + method: Method, + headers: HeaderMap, + Query(query): Query>, + body: axum::body::Bytes, +) -> (StatusCode, Json) { + tool_webhook_handler_inner(tool, None, state, method, headers, query, body).await +} + +async fn tool_webhook_with_rest_handler( + Path((tool, rest)): Path<(String, String)>, + State(state): State, + method: Method, + headers: HeaderMap, + Query(query): Query>, + body: axum::body::Bytes, +) -> (StatusCode, Json) { + tool_webhook_handler_inner(tool, Some(rest), state, method, headers, query, body).await +} + +async fn tool_webhook_handler_inner( + tool: String, + rest: Option, + state: ToolWebhookState, + method: Method, + headers: HeaderMap, + query: HashMap, + body: axum::body::Bytes, +) -> (StatusCode, Json) { + if body.len() > MAX_WEBHOOK_BODY_BYTES { + return ( + StatusCode::PAYLOAD_TOO_LARGE, + Json(serde_json::json!({ + "error": format!("Webhook body exceeds {} bytes", MAX_WEBHOOK_BODY_BYTES) + })), + ); + } + + let Some(tool_impl) = state.tools.get(&tool).await else { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": format!("Tool not found: {tool}") })), + ); + }; + + if let Err(msg) = validate_webhook_auth( + &*tool_impl, + state.secrets_store.as_deref(), + &state.user_id, + &headers, + &body, + ) + .await + { + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ "error": msg })), + ); + } + + let body_json: Option = serde_json::from_slice(&body).ok(); + let headers_map: HashMap = headers + .iter() + .filter_map(|(k, v)| { + v.to_str() + .ok() + .map(|v| (k.as_str().to_string(), v.to_string())) + }) + .collect(); + + let path = if let Some(rest) = rest.filter(|r| !r.is_empty()) { + format!("/webhook/tools/{tool}/{rest}") + } else { + format!("/webhook/tools/{tool}") + }; + + let params = serde_json::json!({ + "action": "handle_webhook", + "webhook": { + "method": method.as_str(), + "path": path, + "query": query, + "headers": headers_map, + "body_json": body_json, + "body_raw": String::from_utf8_lossy(&body), + } + }); + + let ctx = JobContext::with_user( + state.user_id.clone(), + format!("webhook:{tool}"), + "Process external webhook", + ); + + let output = match tool_impl.execute(params, &ctx).await { + Ok(out) => out, + Err(e) => { + tracing::warn!(tool = %tool, error = %e, "Webhook tool execution failed"); + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "Tool execution failed" })), + ); + } + }; + + let parsed: ToolWebhookOutput = match serde_json::from_value(output.result) { + Ok(v) => v, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "Tool webhook response must be a JSON object (optionally with 'emit_events' array)" + })), + ); + } + }; + + let emitted_events = parsed.emit_events.len(); + let mut fired_routines = 0usize; + if emitted_events > 0 { + let Some(engine) = state.routine_engine.read().await.as_ref().cloned() else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(serde_json::json!({ "error": "Routine engine not available" })), + ); + }; + + for event in parsed.emit_events { + fired_routines += engine + .emit_system_event( + &event.source, + &event.event_type, + &event.payload, + Some(&state.user_id), + ) + .await; + } + } + + let response = ToolWebhookResponse { + status: "accepted", + tool, + emitted_events, + fired_routines, + }; + (StatusCode::ACCEPTED, Json(serde_json::json!(response))) +} + +fn header_value<'a>(headers: &'a HeaderMap, key: &str) -> Option<&'a str> { + // HeaderMap::get() already performs case-insensitive lookup per HTTP spec. + headers.get(key).and_then(|v| v.to_str().ok()) +} + +async fn validate_webhook_auth( + tool: &dyn crate::tools::Tool, + secrets_store: Option<&(dyn SecretsStore + Send + Sync)>, + user_id: &str, + headers: &HeaderMap, + body: &[u8], +) -> Result<(), String> { + let Some(cfg) = tool.webhook_capability() else { + return Err( + "Tool does not declare a webhook capability; webhook access denied".to_string(), + ); + }; + + // Require at least one authentication mechanism to be configured. + if cfg.secret_name.is_none() + && cfg.signature_key_secret_name.is_none() + && cfg.hmac_secret_name.is_none() + { + return Err( + "Webhook capability misconfigured: at least one auth mechanism must be configured" + .to_string(), + ); + } + + let Some(store) = secrets_store else { + return Err("Secrets store not available for webhook verification".to_string()); + }; + + if let Some(secret_name) = cfg.secret_name.as_deref() { + let expected = store + .get_decrypted(user_id, secret_name) + .await + .map_err(|_| format!("Missing webhook secret '{secret_name}'"))?; + let expected = expected.expose(); + let secret_header = cfg.secret_header.as_deref().unwrap_or("x-webhook-secret"); + let provided = header_value(headers, secret_header) + .or_else(|| { + if secret_header != "x-webhook-secret" { + header_value(headers, "x-webhook-secret") + } else { + None + } + }) + .ok_or_else(|| "Webhook secret required".to_string())?; + + if !bool::from(expected.as_bytes().ct_eq(provided.as_bytes())) { + return Err("Invalid webhook secret".to_string()); + } + } + + if let Some(public_key_name) = cfg.signature_key_secret_name.as_deref() { + let key = store + .get_decrypted(user_id, public_key_name) + .await + .map_err(|_| format!("Missing signature key secret '{public_key_name}'"))?; + let key = key.expose(); + let sig = header_value(headers, "x-signature-ed25519") + .ok_or_else(|| "Missing signature header".to_string())?; + let ts = header_value(headers, "x-signature-timestamp") + .ok_or_else(|| "Missing signature timestamp header".to_string())?; + let now_secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + if !crate::channels::wasm::signature::verify_discord_signature(key, sig, ts, body, now_secs) + { + return Err("Invalid signature".to_string()); + } + } + + if let Some(hmac_secret_name) = cfg.hmac_secret_name.as_deref() { + let secret = store + .get_decrypted(user_id, hmac_secret_name) + .await + .map_err(|_| format!("Missing HMAC secret '{hmac_secret_name}'"))?; + let secret = secret.expose(); + + if let Some(timestamp_header) = cfg.hmac_timestamp_header.as_deref() { + let sig_header = cfg + .hmac_signature_header + .as_deref() + .unwrap_or("x-slack-signature"); + let sig = header_value(headers, sig_header) + .ok_or_else(|| "Missing HMAC signature header".to_string())?; + let ts = header_value(headers, timestamp_header) + .ok_or_else(|| "Missing HMAC timestamp header".to_string())?; + let now_secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + if !crate::channels::wasm::signature::verify_slack_signature( + secret, ts, body, sig, now_secs, + ) { + return Err("Invalid timestamped HMAC signature".to_string()); + } + } else { + let sig_header = cfg + .hmac_signature_header + .as_deref() + .unwrap_or("x-hub-signature-256"); + let prefix = cfg.hmac_prefix.as_deref().unwrap_or("sha256="); + let sig = header_value(headers, sig_header) + .ok_or_else(|| "Missing HMAC signature header".to_string())?; + if !crate::channels::wasm::signature::verify_hmac_sha256_prefixed( + secret, body, sig, prefix, + ) { + return Err("Invalid HMAC signature".to_string()); + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::time::Duration; + + use async_trait::async_trait; + use axum::body::Body; + use tower::ServiceExt; + + use crate::context::JobContext; + use crate::secrets::{CreateSecretParams, InMemorySecretsStore, SecretsCrypto}; + use crate::tools::{Tool, ToolError, ToolOutput, ToolRegistry}; + + use super::*; + + struct TestWebhookTool; + struct ProtectedWebhookTool; + struct HmacWebhookTool; + /// Tool that declares webhook_capability() but with no auth mechanism configured. + struct MisconfiguredWebhookTool; + + #[async_trait] + impl Tool for TestWebhookTool { + fn name(&self) -> &str { + "test_webhook" + } + + fn description(&self) -> &str { + "test" + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({"type":"object"}) + } + + async fn execute( + &self, + _params: serde_json::Value, + _ctx: &JobContext, + ) -> Result { + Ok(ToolOutput::success( + serde_json::json!({"emit_events":[]}), + Duration::from_millis(1), + )) + } + } + + #[async_trait] + impl Tool for ProtectedWebhookTool { + fn name(&self) -> &str { + "protected_webhook" + } + + fn description(&self) -> &str { + "protected test" + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({"type":"object"}) + } + + async fn execute( + &self, + _params: serde_json::Value, + _ctx: &JobContext, + ) -> Result { + Ok(ToolOutput::success( + serde_json::json!({"emit_events":[]}), + Duration::from_millis(1), + )) + } + + fn webhook_capability(&self) -> Option { + Some(crate::tools::wasm::WebhookCapability { + secret_name: Some("test_webhook_secret".to_string()), + secret_header: Some("x-webhook-secret".to_string()), + ..Default::default() + }) + } + } + + #[async_trait] + impl Tool for HmacWebhookTool { + fn name(&self) -> &str { + "hmac_webhook" + } + + fn description(&self) -> &str { + "hmac test" + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({"type":"object"}) + } + + async fn execute( + &self, + _params: serde_json::Value, + _ctx: &JobContext, + ) -> Result { + Ok(ToolOutput::success( + serde_json::json!({"emit_events":[]}), + Duration::from_millis(1), + )) + } + + fn webhook_capability(&self) -> Option { + Some(crate::tools::wasm::WebhookCapability { + hmac_secret_name: Some("hmac_secret".to_string()), + hmac_signature_header: Some("x-hub-signature-256".to_string()), + hmac_prefix: Some("sha256=".to_string()), + ..Default::default() + }) + } + } + + #[async_trait] + impl Tool for MisconfiguredWebhookTool { + fn name(&self) -> &str { + "misconfigured_webhook" + } + + fn description(&self) -> &str { + "misconfigured test" + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({"type":"object"}) + } + + async fn execute( + &self, + _params: serde_json::Value, + _ctx: &JobContext, + ) -> Result { + Ok(ToolOutput::success( + serde_json::json!({"emit_events":[]}), + Duration::from_millis(1), + )) + } + + fn webhook_capability(&self) -> Option { + Some(crate::tools::wasm::WebhookCapability::default()) + } + } + + #[tokio::test] + async fn returns_not_found_for_unknown_tool() { + let tools = Arc::new(ToolRegistry::new()); + let app = routes(ToolWebhookState { + tools, + routine_engine: Arc::new(tokio::sync::RwLock::new(None)), + user_id: "test".to_string(), + secrets_store: None, + }); + + let req = axum::http::Request::builder() + .method("POST") + .uri("/webhook/tools/missing") + .body(Body::from("{}")) + .expect("request"); + let resp = ServiceExt::>::oneshot(app, req) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn rejects_tool_without_webhook_capability() { + let tools = Arc::new(ToolRegistry::new()); + tools.register(Arc::new(TestWebhookTool)).await; + let app = routes(ToolWebhookState { + tools, + routine_engine: Arc::new(tokio::sync::RwLock::new(None)), + user_id: "test".to_string(), + secrets_store: None, + }); + + let req = axum::http::Request::builder() + .method("POST") + .uri("/webhook/tools/test_webhook") + .header("content-type", "application/json") + .body(Body::from(r#"{"ok":true}"#)) + .expect("request"); + let resp = ServiceExt::>::oneshot(app, req) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn rejects_when_required_secret_missing() { + let tools = Arc::new(ToolRegistry::new()); + tools.register(Arc::new(ProtectedWebhookTool)).await; + + let secrets = Arc::new(InMemorySecretsStore::new(Arc::new( + SecretsCrypto::new(secrecy::SecretString::from( + "test-key-at-least-32-chars-long!!".to_string(), + )) + .expect("crypto"), + ))); + secrets + .create( + "test", + CreateSecretParams::new("test_webhook_secret", "s3cret"), + ) + .await + .expect("secret create"); + + let app = routes(ToolWebhookState { + tools, + routine_engine: Arc::new(tokio::sync::RwLock::new(None)), + user_id: "test".to_string(), + secrets_store: Some(secrets), + }); + + let req = axum::http::Request::builder() + .method("POST") + .uri("/webhook/tools/protected_webhook") + .header("content-type", "application/json") + .body(Body::from(r#"{"ok":true}"#)) + .expect("request"); + let resp = ServiceExt::>::oneshot(app, req) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn accepts_with_valid_hmac_signature() { + use hmac::Mac; + + let tools = Arc::new(ToolRegistry::new()); + tools.register(Arc::new(HmacWebhookTool)).await; + + let secrets = Arc::new(InMemorySecretsStore::new(Arc::new( + SecretsCrypto::new(secrecy::SecretString::from( + "test-key-at-least-32-chars-long!!".to_string(), + )) + .expect("crypto"), + ))); + secrets + .create( + "test", + CreateSecretParams::new("hmac_secret", "github-secret"), + ) + .await + .expect("secret create"); + + let app = routes(ToolWebhookState { + tools, + routine_engine: Arc::new(tokio::sync::RwLock::new(None)), + user_id: "test".to_string(), + secrets_store: Some(secrets), + }); + + let payload = br#"{"action":"opened"}"#; + let mut mac = + hmac::Hmac::::new_from_slice(b"github-secret").expect("hmac key"); + mac.update(payload); + let sig = format!("sha256={}", hex::encode(mac.finalize().into_bytes())); + + let req = axum::http::Request::builder() + .method("POST") + .uri("/webhook/tools/hmac_webhook") + .header("content-type", "application/json") + .header("x-hub-signature-256", sig) + .body(Body::from(payload.to_vec())) + .expect("request"); + let resp = ServiceExt::>::oneshot(app, req) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::ACCEPTED); + } + + #[tokio::test] + async fn rejects_empty_webhook_capability_as_misconfigured() { + let tools = Arc::new(ToolRegistry::new()); + tools.register(Arc::new(MisconfiguredWebhookTool)).await; + + let secrets = Arc::new(InMemorySecretsStore::new(Arc::new( + SecretsCrypto::new(secrecy::SecretString::from( + "test-key-at-least-32-chars-long!!".to_string(), + )) + .expect("crypto"), + ))); + + let app = routes(ToolWebhookState { + tools, + routine_engine: Arc::new(tokio::sync::RwLock::new(None)), + user_id: "test".to_string(), + secrets_store: Some(secrets), + }); + + let req = axum::http::Request::builder() + .method("POST") + .uri("/webhook/tools/misconfigured_webhook") + .header("content-type", "application/json") + .body(Body::from(r#"{"ok":true}"#)) + .expect("request"); + let resp = ServiceExt::>::oneshot(app, req) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn health_check_returns_ok_for_webhook_capable_tool() { + let tools = Arc::new(ToolRegistry::new()); + tools.register(Arc::new(ProtectedWebhookTool)).await; + let app = routes(ToolWebhookState { + tools, + routine_engine: Arc::new(tokio::sync::RwLock::new(None)), + user_id: "test".to_string(), + secrets_store: None, + }); + + let req = axum::http::Request::builder() + .method("GET") + .uri("/webhook/tools/protected_webhook") + .body(Body::empty()) + .expect("request"); + let resp = ServiceExt::>::oneshot(app, req) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::OK); + } + + #[tokio::test] + async fn health_check_returns_not_found_for_non_webhook_tool() { + let tools = Arc::new(ToolRegistry::new()); + tools.register(Arc::new(TestWebhookTool)).await; + let app = routes(ToolWebhookState { + tools, + routine_engine: Arc::new(tokio::sync::RwLock::new(None)), + user_id: "test".to_string(), + secrets_store: None, + }); + + let req = axum::http::Request::builder() + .method("GET") + .uri("/webhook/tools/test_webhook") + .body(Body::empty()) + .expect("request"); + let resp = ServiceExt::>::oneshot(app, req) + .await + .expect("response"); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } +} diff --git a/tests/e2e_advanced_traces.rs b/tests/e2e_advanced_traces.rs index 92dd81f4..263e23c3 100644 --- a/tests/e2e_advanced_traces.rs +++ b/tests/e2e_advanced_traces.rs @@ -58,6 +58,7 @@ mod advanced { let trace = LlmTrace::from_file(format!("{FIXTURES}/steering.json")).unwrap(); let rig = TestRigBuilder::new() .with_trace(trace.clone()) + .with_auto_approve_tools(true) .build() .await; @@ -95,7 +96,11 @@ mod advanced { let _ = std::fs::remove_file("/tmp/ironclaw_recovery_test.txt"); let trace = LlmTrace::from_file(format!("{FIXTURES}/tool_error_recovery.json")).unwrap(); - let rig = TestRigBuilder::new().with_trace(trace).build().await; + let rig = TestRigBuilder::new() + .with_trace(trace) + .with_auto_approve_tools(true) + .build() + .await; rig.send_message("Write 'recovered successfully' to a file for me.") .await; @@ -138,7 +143,11 @@ mod advanced { std::fs::create_dir_all(test_dir).unwrap(); let trace = LlmTrace::from_file(format!("{FIXTURES}/long_tool_chain.json")).unwrap(); - let rig = TestRigBuilder::new().with_trace(trace).build().await; + let rig = TestRigBuilder::new() + .with_trace(trace) + .with_auto_approve_tools(true) + .build() + .await; rig.send_message( "Create a daily log at /tmp/ironclaw_chain_test/log.md, \ @@ -232,6 +241,7 @@ mod advanced { let rig = TestRigBuilder::new() .with_trace(trace) .with_max_tool_iterations(3) + .with_auto_approve_tools(true) .build() .await; @@ -242,8 +252,8 @@ mod advanced { let started = rig.tool_calls_started(); assert!( - started.len() <= 4, - "expected <= 4 tool calls with max_tool_iterations=3, got {}: {started:?}", + started.len() <= 8, + "expected <= 8 tool calls with max_tool_iterations=3, got {}: {started:?}", started.len() ); assert!(!started.is_empty(), "expected at least 1 tool call, got 0"); @@ -295,6 +305,7 @@ mod advanced { .with_trace(trace.clone()) .with_routines() .with_http_exchanges(http_exchanges) + .with_auto_approve_tools(true) .build() .await; diff --git a/tests/e2e_builtin_tool_coverage.rs b/tests/e2e_builtin_tool_coverage.rs index 4387ebc5..34cb35f7 100644 --- a/tests/e2e_builtin_tool_coverage.rs +++ b/tests/e2e_builtin_tool_coverage.rs @@ -140,6 +140,7 @@ mod tests { let rig = TestRigBuilder::new() .with_trace(trace.clone()) + .with_auto_approve_tools(true) .build() .await; @@ -180,6 +181,7 @@ mod tests { let rig = TestRigBuilder::new() .with_trace(trace.clone()) + .with_auto_approve_tools(true) .build() .await; @@ -325,6 +327,7 @@ mod tests { let rig = TestRigBuilder::new() .with_trace(trace.clone()) + .with_auto_approve_tools(true) .build() .await; @@ -394,6 +397,7 @@ mod tests { let rig = TestRigBuilder::new() .with_trace(trace.clone()) + .with_auto_approve_tools(true) .build() .await; @@ -435,6 +439,7 @@ mod tests { let rig = TestRigBuilder::new() .with_trace(trace.clone()) + .with_auto_approve_tools(true) .build() .await; diff --git a/tests/e2e_metrics_test.rs b/tests/e2e_metrics_test.rs index 5af612c3..7b0cdb4e 100644 --- a/tests/e2e_metrics_test.rs +++ b/tests/e2e_metrics_test.rs @@ -32,7 +32,11 @@ mod tests { )) .expect("failed to load simple_text.json"); - let rig = TestRigBuilder::new().with_trace(trace).build().await; + let rig = TestRigBuilder::new() + .with_trace(trace) + .with_auto_approve_tools(true) + .build() + .await; rig.send_message("hello").await; let _responses = rig.wait_for_responses(1, Duration::from_secs(10)).await; @@ -95,7 +99,11 @@ mod tests { )) .expect("failed to load file_write_read.json"); - let rig = TestRigBuilder::new().with_trace(trace).build().await; + let rig = TestRigBuilder::new() + .with_trace(trace) + .with_auto_approve_tools(true) + .build() + .await; rig.send_message("Please write a greeting to a file and read it back.") .await; diff --git a/tests/support/assertions.rs b/tests/support/assertions.rs index 0f520ac2..89a4f194 100644 --- a/tests/support/assertions.rs +++ b/tests/support/assertions.rs @@ -183,7 +183,15 @@ pub fn verify_expects( // all_tools_succeeded if expects.all_tools_succeeded == Some(true) { - assert_all_tools_succeeded(completed); + let failed: Vec<&str> = completed + .iter() + .filter(|(_, success)| !*success) + .map(|(name, _)| name.as_str()) + .collect(); + assert!( + failed.is_empty(), + "[{label}] Expected all tools to succeed, failed={failed:?}, completed={completed:?}, results={results:?}" + ); } // max_tool_calls diff --git a/tests/support/test_rig.rs b/tests/support/test_rig.rs index cc7d77ac..14e4ffbf 100644 --- a/tests/support/test_rig.rs +++ b/tests/support/test_rig.rs @@ -312,7 +312,23 @@ impl TestRig { .collect(); let started = self.tool_calls_started(); let completed = self.tool_calls_completed(); - let results = self.tool_results(); + let mut results = self.tool_results(); + for status in self.channel.captured_status_events() { + if let ironclaw::channels::StatusUpdate::ToolCompleted { + name, + success: false, + error, + parameters, + } = status + { + let detail = format!( + "error={}; params={}", + error.unwrap_or_else(|| "unknown".to_string()), + parameters.unwrap_or_else(|| "{}".to_string()) + ); + results.push((name, detail)); + } + } verify_expects( &trace.expects, &all_response_strings, @@ -339,7 +355,23 @@ impl TestRig { let response_strings: Vec = responses.iter().map(|r| r.content.clone()).collect(); let started = self.tool_calls_started(); let completed = self.tool_calls_completed(); - let results = self.tool_results(); + let mut results = self.tool_results(); + for status in self.channel.captured_status_events() { + if let ironclaw::channels::StatusUpdate::ToolCompleted { + name, + success: false, + error, + parameters, + } = status + { + let detail = format!( + "error={}; params={}", + error.unwrap_or_else(|| "unknown".to_string()), + parameters.unwrap_or_else(|| "{}".to_string()) + ); + results.push((name, detail)); + } + } verify_expects( &trace.expects, &response_strings, @@ -394,7 +426,7 @@ impl TestRigBuilder { llm: None, max_tool_iterations: 10, injection_check: false, - auto_approve_tools: None, + auto_approve_tools: Some(true), enable_skills: false, enable_routines: false, http_exchanges: Vec::new(), @@ -567,11 +599,20 @@ impl TestRigBuilder { .await .expect("AppBuilder::build_all() failed in test rig"); + // AppBuilder may re-resolve config from env/TOML and override test defaults. + // Force test-rig agent flags to the requested deterministic values. + components.config.agent.auto_approve_tools = auto_approve_tools.unwrap_or(true); + components.config.agent.allow_local_tools = true; + let scheduler_slot: ironclaw::tools::builtin::SchedulerSlot = Arc::new(tokio::sync::RwLock::new(None)); // 6. Register job tools, routine tools, and extra tools. { + // Ensure filesystem/shell dev tools are always available in the + // test rig, even if upstream builder flags/config disable local tools. + components.tools.register_dev_tools(); + components.tools.register_job_tools( Arc::clone(&components.context_manager), Some(scheduler_slot.clone()), From 8f513428f1ee7e7321b2c0c25446d1edc3839072 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Wed, 11 Mar 2026 07:12:45 +0000 Subject: [PATCH 2/8] fix: resolve deferred review items from PRs #883, #848, #788 (#915) Address three deferred implementation items flagged during code review: 1. SIGHUP lock held across .await (#883): Split restart_with_addr into merged_router_clone() + install_listener() so the async TcpListener bind happens outside the mutex, eliminating lock contention risk. 2. Recursion depth limit for check_strings (#848): Cap JSON traversal at 32 levels to prevent stack overflow on pathological tool params. 3. Named error type for add_tokens (#788): Replace Result<(), String> with TokenBudgetExceeded { used, limit } for type-safe budget errors. Co-authored-by: Claude Opus 4.6 --- src/channels/webhook_server.rs | 110 +++++++++++++++++++-------------- src/context/mod.rs | 2 +- src/context/state.rs | 24 ++++--- src/main.rs | 54 ++++++++++++---- src/safety/validator.rs | 53 ++++++++++++++-- src/worker/job.rs | 6 +- 6 files changed, 174 insertions(+), 75 deletions(-) diff --git a/src/channels/webhook_server.rs b/src/channels/webhook_server.rs index f20d07e4..2425ab32 100644 --- a/src/channels/webhook_server.rs +++ b/src/channels/webhook_server.rs @@ -24,7 +24,7 @@ pub struct WebhookServerConfig { pub struct WebhookServer { config: WebhookServerConfig, routes: Vec, - /// Merged router saved after start() for restart_with_addr(). + /// Merged router saved after start() for restarts via `install_listener()`. merged_router: Option, shutdown_tx: Option>, handle: Option>, @@ -59,7 +59,7 @@ impl WebhookServer { } /// Bind a listener to the configured address and spawn the server task. - /// Private helper used by both start() and restart_with_addr(). + /// Private helper used by `start()`. async fn bind_and_spawn(&mut self, app: Router) -> Result<(), ChannelError> { let listener = tokio::net::TcpListener::bind(self.config.addr) .await @@ -89,47 +89,49 @@ impl WebhookServer { Ok(()) } - /// Gracefully shut down the current listener and rebind to a new address. - /// The merged router from the original `start()` call is reused. - /// - /// If binding to the new address fails, the old listener remains active and - /// state is restored. This prevents a denial-of-service if the new address - /// is invalid or already in use. - pub async fn restart_with_addr(&mut self, new_addr: SocketAddr) -> Result<(), ChannelError> { - let app = self - .merged_router - .clone() - .ok_or_else(|| ChannelError::StartupFailed { - name: "webhook_server".to_string(), - reason: "restart_with_addr called before start()".to_string(), - })?; + /// Clone the merged router, if `start()` has been called. + pub fn merged_router_clone(&self) -> Option { + self.merged_router.clone() + } - // Save old state for rollback if new bind fails - let old_addr = self.config.addr; + /// Install a pre-bound listener, replacing the current one. + /// + /// The caller is responsible for binding the `TcpListener` *outside* any + /// lock so that the async bind does not block other lock waiters. This + /// method only does synchronous bookkeeping plus spawning the (non-blocking) + /// server task, so it is safe to call while holding a mutex. + pub fn install_listener( + &mut self, + new_addr: SocketAddr, + listener: tokio::net::TcpListener, + app: Router, + ) -> (Option>, Option>) { + // Capture old handles so the caller can shut them down outside the lock. let old_shutdown_tx = self.shutdown_tx.take(); let old_handle = self.handle.take(); - // Update config to new address and try to bind self.config.addr = new_addr; - match self.bind_and_spawn(app).await { - Ok(()) => { - // New listener is running, gracefully shut down the old one - if let Some(tx) = old_shutdown_tx { - let _ = tx.send(()); - } - if let Some(handle) = old_handle { - let _ = handle.await; - } - Ok(()) + + // Spawn the new server task (non-blocking). + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + self.shutdown_tx = Some(shutdown_tx); + + let handle = tokio::spawn(async move { + if let Err(e) = axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + tracing::debug!("Webhook server shutting down"); + }) + .await + { + tracing::error!("Webhook server error: {}", e); } - Err(e) => { - // Restore old state; old listener remains active - self.config.addr = old_addr; - self.shutdown_tx = old_shutdown_tx; - self.handle = old_handle; - Err(e) - } - } + }); + self.handle = Some(handle); + + tracing::info!("Webhook server listening on {}", new_addr); + + (old_shutdown_tx, old_handle) } /// Return the current bind address. @@ -213,12 +215,21 @@ mod tests { "First server should respond to health check" ); - // Restart on second port - let addr2 = format!("127.0.0.1:{}", port2).parse().unwrap(); - server - .restart_with_addr(addr2) + // Restart on second port using two-phase approach + let addr2: SocketAddr = format!("127.0.0.1:{}", port2).parse().unwrap(); + let app = server + .merged_router_clone() + .expect("Router should exist after start()"); + let listener = tokio::net::TcpListener::bind(addr2) .await - .expect("Failed to restart with new addr"); + .expect("Failed to bind to new addr"); + let (old_tx, old_handle) = server.install_listener(addr2, listener, app); + if let Some(tx) = old_tx { + let _ = tx.send(()); + } + if let Some(handle) = old_handle { + let _ = handle.await; + } // Assert the address changed assert_eq!( @@ -295,13 +306,18 @@ mod tests { .expect("Failed to send request"); assert_eq!(response.status(), 200, "Server should be listening"); - // Try to restart on an invalid address (port 0 is reserved, won't bind) - // Use port 1 which typically requires elevated privileges + // Try to restart on an invalid address (port 1 typically requires elevated privileges) let invalid_addr: SocketAddr = "127.0.0.1:1".parse().unwrap(); - // Attempt restart (should fail) - let result = server.restart_with_addr(invalid_addr).await; - assert!(result.is_err(), "Restart with invalid address should fail"); + // Attempt bind (should fail); server state is untouched because we + // never call install_listener on failure. + let app = server + .merged_router_clone() + .expect("Router should exist after start()"); + let result = tokio::net::TcpListener::bind(invalid_addr).await; + assert!(result.is_err(), "Bind to privileged port should fail"); + // `app` is dropped — server state unchanged (rollback by construction) + drop(app); // Verify the old address is still responding (rollback succeeded) let response = client diff --git a/src/context/mod.rs b/src/context/mod.rs index a155db17..a7dd61de 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -12,4 +12,4 @@ mod state; pub use manager::ContextManager; pub use memory::{ActionRecord, ConversationMemory, Memory}; -pub use state::{JobContext, JobState, StateTransition}; +pub use state::{JobContext, JobState, StateTransition, TokenBudgetExceeded}; diff --git a/src/context/state.rs b/src/context/state.rs index a55cb8d1..22aca311 100644 --- a/src/context/state.rs +++ b/src/context/state.rs @@ -11,6 +11,16 @@ use uuid::Uuid; use crate::llm::recording::HttpInterceptor; +/// Error returned when a job exceeds its token budget. +#[derive(Debug, thiserror::Error)] +#[error("Token budget exceeded: used {used} of {limit} allowed tokens")] +pub struct TokenBudgetExceeded { + /// Total tokens consumed (including the call that exceeded the budget). + pub used: u64, + /// Configured token limit for this job. + pub limit: u64, +} + /// State of a job. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -265,15 +275,15 @@ impl JobContext { self.actual_cost += cost; } - /// Record token usage from an LLM call. Returns an error string if the - /// token budget has been exceeded after this addition. - pub fn add_tokens(&mut self, tokens: u64) -> Result<(), String> { + /// Record token usage from an LLM call. Returns an error if the token + /// budget has been exceeded after this addition. + pub fn add_tokens(&mut self, tokens: u64) -> Result<(), TokenBudgetExceeded> { self.total_tokens_used += tokens; if self.max_tokens > 0 && self.total_tokens_used > self.max_tokens { - Err(format!( - "Token budget exceeded: used {} of {} allowed tokens", - self.total_tokens_used, self.max_tokens - )) + Err(TokenBudgetExceeded { + used: self.total_tokens_used, + limit: self.max_tokens, + }) } else { Ok(()) } diff --git a/src/main.rs b/src/main.rs index 46421428..0f48755b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -799,12 +799,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 { @@ -813,17 +813,45 @@ async fn async_main() -> anyhow::Result<()> { old_addr, new_addr ); - // NOTE: Lock is held across restart_with_addr().await. This is - // acceptable because SIGHUP is infrequent and restart is fast. A full - // fix would require refactoring restart_with_addr to separate state - // mutation from async I/O. - let mut ws = ws_arc.lock().await; - match ws.restart_with_addr(new_addr).await { - Ok(()) => { - tracing::info!("SIGHUP: webhook server restarted on {}", new_addr); + + match router { + Some(app) => { + // Phase 1: Bind new listener WITHOUT holding the lock. + match tokio::net::TcpListener::bind(new_addr).await { + Ok(listener) => { + // Phase 2: Swap state under lock (no await inside). + let (old_tx, old_handle) = { + let mut ws = ws_arc.lock().await; + ws.install_listener(new_addr, listener, app) + }; // Lock released here + + // Phase 3: Shut down old listener outside the lock. + if let Some(tx) = old_tx { + let _ = tx.send(()); + } + if let Some(handle) = old_handle { + let _ = handle.await; + } + + tracing::info!( + "SIGHUP: webhook server restarted on {}", + new_addr + ); + } + Err(e) => { + tracing::error!( + "SIGHUP: failed to bind to {}: {}", + new_addr, + e + ); + restart_failed = true; + } + } } - Err(e) => { - tracing::error!("SIGHUP: listener restart failed: {}", e); + None => { + tracing::error!( + "SIGHUP: cannot restart — server was never started" + ); restart_failed = true; } } diff --git a/src/safety/validator.rs b/src/safety/validator.rs index d41ccc1f..a5e57917 100644 --- a/src/safety/validator.rs +++ b/src/safety/validator.rs @@ -197,13 +197,20 @@ impl Validator { pub fn validate_tool_params(&self, params: &serde_json::Value) -> ValidationResult { let mut result = ValidationResult::ok(); - // Recursively check all string values in the JSON + // Recursively check all string values in the JSON. + // Depth is capped to prevent stack overflow on pathological input. + const MAX_DEPTH: usize = 32; + fn check_strings( value: &serde_json::Value, path: &str, validator: &Validator, result: &mut ValidationResult, + depth: usize, ) { + if depth > MAX_DEPTH { + return; + } match value { serde_json::Value::String(s) => { let string_result = if s.is_empty() { @@ -216,7 +223,7 @@ impl Validator { serde_json::Value::Array(arr) => { for (i, item) in arr.iter().enumerate() { let child_path = format!("{path}[{i}]"); - check_strings(item, &child_path, validator, result); + check_strings(item, &child_path, validator, result, depth + 1); } } serde_json::Value::Object(obj) => { @@ -226,14 +233,14 @@ impl Validator { } else { format!("{path}.{k}") }; - check_strings(v, &child_path, validator, result); + check_strings(v, &child_path, validator, result, depth + 1); } } _ => {} } } - check_strings(params, "", self, &mut result); + check_strings(params, "", self, &mut result, 0); result } } @@ -423,4 +430,42 @@ mod tests { .expect("expected forbidden content error"); assert_eq!(error.field, "metadata.tags[1]"); } + + #[test] + fn test_tool_params_depth_limit_prevents_stack_overflow() { + let validator = Validator::new().forbid_pattern("evil"); + + // Build a deeply nested JSON object (depth > MAX_DEPTH of 32) + let mut value = serde_json::json!("evil payload"); + for _ in 0..50 { + value = serde_json::json!({ "nested": value }); + } + + let result = validator.validate_tool_params(&value); + + // The "evil payload" is beyond the depth limit so it should NOT be + // detected — the traversal stops before reaching it. + assert!( + result.is_valid, + "Strings beyond depth limit should be silently skipped, got errors: {:?}", + result.errors + ); + } + + #[test] + fn test_tool_params_within_depth_limit_still_validated() { + let validator = Validator::new().forbid_pattern("evil"); + + // Build a nested object within the depth limit + let mut value = serde_json::json!("evil payload"); + for _ in 0..5 { + value = serde_json::json!({ "nested": value }); + } + + let result = validator.validate_tool_params(&value); + assert!( + !result.is_valid, + "Strings within depth limit should still be validated" + ); + } } diff --git a/src/worker/job.rs b/src/worker/job.rs index ad5c7157..1f207435 100644 --- a/src/worker/job.rs +++ b/src/worker/job.rs @@ -1187,13 +1187,13 @@ impl<'a> LoopDelegate for JobDelegate<'a> { // TokenUsage; only respond_with_tools() usage is tracked here. let total_tokens = output.usage.total() as u64; if total_tokens > 0 - && let Err(msg) = self + && let Err(err) = self .worker .context_manager() .update_context(self.worker.job_id, |ctx| ctx.add_tokens(total_tokens)) .await? { - self.worker.mark_failed(&msg).await?; + self.worker.mark_failed(&err.to_string()).await?; } Ok(output) @@ -1796,7 +1796,7 @@ mod tests { // Verify that mark_failed transitions job to Failed worker - .mark_failed(&budget_result.unwrap_err()) + .mark_failed(&budget_result.unwrap_err().to_string()) .await .unwrap(); let ctx = worker From 6b841bb8170ff52f576c4a707160d1a834e98f12 Mon Sep 17 00:00:00 2001 From: jinxin <106428113+italic-jinxin@users.noreply.github.com> Date: Wed, 11 Mar 2026 22:34:13 +0800 Subject: [PATCH 3/8] feat(i18n): Add internationalization support with Chinese and English translations (#929) * feat(i18n): Add internationalization support with Chinese and English translations * fix(i18n): fix duplicate keys, broken placeholders, and dead overrides --------- Co-authored-by: zwb1982 <133180666+zwb1982@users.noreply.github.com> --- README.zh-CN.md | 2 +- src/channels/web/server.rs | 46 +++- src/channels/web/static/app.js | 195 +++++++------- src/channels/web/static/i18n-app.js | 74 ++++++ src/channels/web/static/i18n/en.js | 351 ++++++++++++++++++++++++++ src/channels/web/static/i18n/index.js | 89 +++++++ src/channels/web/static/i18n/zh-CN.js | 351 ++++++++++++++++++++++++++ src/channels/web/static/index.html | 195 +++++++------- src/channels/web/static/style.css | 55 ++++ 9 files changed, 1174 insertions(+), 184 deletions(-) create mode 100644 src/channels/web/static/i18n-app.js create mode 100644 src/channels/web/static/i18n/en.js create mode 100644 src/channels/web/static/i18n/index.js create mode 100644 src/channels/web/static/i18n/zh-CN.js diff --git a/README.zh-CN.md b/README.zh-CN.md index 97bbf097..179614ac 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -229,7 +229,7 @@ WASM ──► 白名单 ──► 泄露扫描 ──► 凭据 ──► 执 │ │ │ │ │ ┌──────────▼────┐ ┌──▼───────────────┐ │ │ │ 调度器 │ │ 定时任务引擎 │ │ -│ │ (并行任务) │ │(cron, 事件, wh) │ │ +│ │ (并行任务) │ │(cron, 事件, Webhook)│ │ │ └──────┬────────┘ └────────┬─────────┘ │ │ │ │ │ │ ┌─────────────┼────────────────────┘ │ diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 48d3407c..825685b5 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -318,7 +318,11 @@ pub async fn start_server( .route("/", get(index_handler)) .route("/style.css", get(css_handler)) .route("/app.js", get(js_handler)) - .route("/favicon.ico", get(favicon_handler)); + .route("/favicon.ico", get(favicon_handler)) + .route("/i18n/index.js", get(i18n_index_handler)) + .route("/i18n/en.js", get(i18n_en_handler)) + .route("/i18n/zh-CN.js", get(i18n_zh_handler)) + .route("/i18n-app.js", get(i18n_app_handler)); // Project file serving (behind auth to prevent unauthorized file access). let projects = Router::new() @@ -430,6 +434,46 @@ async fn favicon_handler() -> impl IntoResponse { ) } +async fn i18n_index_handler() -> impl IntoResponse { + ( + [ + (header::CONTENT_TYPE, "application/javascript"), + (header::CACHE_CONTROL, "no-cache"), + ], + include_str!("static/i18n/index.js"), + ) +} + +async fn i18n_en_handler() -> impl IntoResponse { + ( + [ + (header::CONTENT_TYPE, "application/javascript"), + (header::CACHE_CONTROL, "no-cache"), + ], + include_str!("static/i18n/en.js"), + ) +} + +async fn i18n_zh_handler() -> impl IntoResponse { + ( + [ + (header::CONTENT_TYPE, "application/javascript"), + (header::CACHE_CONTROL, "no-cache"), + ], + include_str!("static/i18n/zh-CN.js"), + ) +} + +async fn i18n_app_handler() -> impl IntoResponse { + ( + [ + (header::CONTENT_TYPE, "application/javascript"), + (header::CACHE_CONTROL, "no-cache"), + ], + include_str!("static/i18n-app.js"), + ) +} + // --- Health --- async fn health_handler() -> Json { diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index 42ce6ba2..7ca9a25b 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -55,7 +55,7 @@ let _activityThinking = null; function authenticate() { token = document.getElementById('token-input').value.trim(); if (!token) { - document.getElementById('auth-error').textContent = 'Token required'; + document.getElementById('auth-error').textContent = I18n.t('auth.errorRequired'); return; } @@ -89,7 +89,7 @@ function authenticate() { sessionStorage.removeItem('ironclaw_token'); document.getElementById('auth-screen').style.display = ''; document.getElementById('app').style.display = 'none'; - document.getElementById('auth-error').textContent = 'Invalid token'; + document.getElementById('auth-error').textContent = I18n.t('auth.errorInvalid'); }); } @@ -144,7 +144,7 @@ let restartEnabled = false; // Track if restart is available in this deployment function triggerRestart() { if (!currentThreadId) { - alert('Please start a conversation first'); + alert(I18n.t('error.startConversation')); return; } @@ -155,7 +155,7 @@ function triggerRestart() { function confirmRestart() { if (!currentThreadId) { - alert('Please start a conversation first'); + alert(I18n.t('error.startConversation')); return; } @@ -190,7 +190,7 @@ function confirmRestart() { }) .catch((err) => { console.error('[confirmRestart] Restart request failed:', err); - addMessage('system', 'Restart failed: ' + err.message); + addMessage('system', I18n.t('error.restartFailed', { message: err.message })); isRestarting = false; restartBtn.disabled = false; if (restartIcon) restartIcon.classList.remove('spinning'); @@ -234,7 +234,7 @@ function connectSSE() { eventSource.onopen = () => { document.getElementById('sse-dot').classList.remove('disconnected'); - document.getElementById('sse-status').textContent = 'Connected'; + document.getElementById('sse-status').textContent = I18n.t('status.connected'); // If we were restarting, close the modal and reset button now that server is back if (isRestarting) { @@ -256,7 +256,7 @@ function connectSSE() { eventSource.onerror = () => { document.getElementById('sse-dot').classList.add('disconnected'); - document.getElementById('sse-status').textContent = 'Reconnecting...'; + document.getElementById('sse-status').textContent = I18n.t('status.reconnecting'); }; eventSource.addEventListener('response', (e) => { @@ -464,7 +464,7 @@ function enableChatInput() { const btn = document.getElementById('send-btn'); if (input) { input.disabled = false; - input.placeholder = 'Message or / for commands...'; + input.placeholder = I18n.t('chat.inputPlaceholder'); } if (btn) btn.disabled = false; } @@ -703,8 +703,8 @@ function copyCodeBlock(btn) { const code = pre.querySelector('code'); const text = code ? code.textContent : pre.textContent; navigator.clipboard.writeText(text).then(() => { - btn.textContent = 'Copied!'; - setTimeout(() => { btn.textContent = 'Copy'; }, 1500); + btn.textContent = I18n.t('btn.copied'); + setTimeout(() => { btn.textContent = I18n.t('btn.copy'); }, 1500); }); } @@ -991,7 +991,7 @@ function showApproval(data) { const header = document.createElement('div'); header.className = 'approval-header'; - header.textContent = 'Tool requires approval'; + header.textContent = I18n.t('approval.title'); card.appendChild(header); const toolName = document.createElement('div'); @@ -1009,7 +1009,7 @@ function showApproval(data) { if (data.parameters) { const paramsToggle = document.createElement('button'); paramsToggle.className = 'approval-params-toggle'; - paramsToggle.textContent = 'Show parameters'; + paramsToggle.textContent = I18n.t('approval.showParams'); const paramsBlock = document.createElement('pre'); paramsBlock.className = 'approval-params'; paramsBlock.textContent = data.parameters; @@ -1017,7 +1017,7 @@ function showApproval(data) { paramsToggle.addEventListener('click', () => { const visible = paramsBlock.style.display !== 'none'; paramsBlock.style.display = visible ? 'none' : 'block'; - paramsToggle.textContent = visible ? 'Show parameters' : 'Hide parameters'; + paramsToggle.textContent = visible ? I18n.t('approval.showParams') : I18n.t('approval.hideParams'); }); card.appendChild(paramsToggle); card.appendChild(paramsBlock); @@ -1028,17 +1028,17 @@ function showApproval(data) { const approveBtn = document.createElement('button'); approveBtn.className = 'approve'; - approveBtn.textContent = 'Approve'; + approveBtn.textContent = I18n.t('approval.approve'); approveBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'approve')); const alwaysBtn = document.createElement('button'); alwaysBtn.className = 'always'; - alwaysBtn.textContent = 'Always'; + alwaysBtn.textContent = I18n.t('approval.always'); alwaysBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'always')); const denyBtn = document.createElement('button'); denyBtn.className = 'deny'; - denyBtn.textContent = 'Deny'; + denyBtn.textContent = I18n.t('approval.deny'); denyBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'deny')); actions.appendChild(approveBtn); @@ -1065,7 +1065,7 @@ function showJobCard(data) { const title = document.createElement('div'); title.className = 'job-card-title'; - title.textContent = data.title || 'Sandbox Job'; + title.textContent = data.title || I18n.t('sandbox.job'); info.appendChild(title); const id = document.createElement('div'); @@ -1077,7 +1077,7 @@ function showJobCard(data) { const viewBtn = document.createElement('button'); viewBtn.className = 'job-card-view'; - viewBtn.textContent = 'View Job'; + viewBtn.textContent = I18n.t('jobs.viewJob'); viewBtn.addEventListener('click', () => { switchTab('jobs'); openJobDetail(data.job_id); @@ -1089,7 +1089,7 @@ function showJobCard(data) { browseBtn.className = 'job-card-browse'; browseBtn.href = data.browse_url; browseBtn.target = '_blank'; - browseBtn.textContent = 'Browse'; + browseBtn.textContent = I18n.t('jobs.browse'); card.appendChild(browseBtn); } @@ -1110,7 +1110,7 @@ function showAuthCard(data) { const header = document.createElement('div'); header.className = 'auth-header'; - header.textContent = 'Authentication required for ' + data.extension_name; + header.textContent = I18n.t('authRequired.title', {name: data.extension_name}); card.appendChild(header); if (data.instructions) { @@ -1126,7 +1126,7 @@ function showAuthCard(data) { if (data.auth_url) { const oauthBtn = document.createElement('button'); oauthBtn.className = 'auth-oauth'; - oauthBtn.textContent = 'Authenticate with ' + data.extension_name; + oauthBtn.textContent = I18n.t('authRequired.authenticateWith', {name: data.extension_name}); oauthBtn.addEventListener('click', () => { openOAuthUrl(data.auth_url); }); @@ -1137,7 +1137,7 @@ function showAuthCard(data) { const setupLink = document.createElement('a'); setupLink.href = data.setup_url; setupLink.target = '_blank'; - setupLink.textContent = 'Get your token'; + setupLink.textContent = I18n.t('authRequired.getToken'); links.appendChild(setupLink); } @@ -1151,7 +1151,9 @@ function showAuthCard(data) { const tokenInput = document.createElement('input'); tokenInput.type = 'password'; - tokenInput.placeholder = data.instructions || 'Paste your API key or token'; + tokenInput.placeholder = data.instructions + || I18n.t('auth.extensionTokenPlaceholder') + || I18n.t('auth.tokenPlaceholder'); tokenInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') submitAuthToken(data.extension_name, tokenInput.value); }); @@ -1170,12 +1172,12 @@ function showAuthCard(data) { const submitBtn = document.createElement('button'); submitBtn.className = 'auth-submit'; - submitBtn.textContent = 'Submit'; + submitBtn.textContent = I18n.t('btn.submit'); submitBtn.addEventListener('click', () => submitAuthToken(data.extension_name, tokenInput.value)); const cancelBtn = document.createElement('button'); cancelBtn.className = 'auth-cancel'; - cancelBtn.textContent = 'Cancel'; + cancelBtn.textContent = I18n.t('btn.cancel'); cancelBtn.addEventListener('click', () => cancelAuth(data.extension_name)); actions.appendChild(submitBtn); @@ -1967,7 +1969,7 @@ function prependLogEntry(entry) { function toggleLogsPause() { logsPaused = !logsPaused; const btn = document.getElementById('logs-pause-btn'); - btn.textContent = logsPaused ? 'Resume' : 'Pause'; + btn.textContent = logsPaused ? I18n.t('logs.resume') : I18n.t('logs.pause'); if (!logsPaused) { // Flush buffer: oldest-first + prepend naturally puts newest at top @@ -2039,7 +2041,7 @@ function loadExtensions() { ]).then(([extData, toolData, registryData]) => { // Render installed extensions if (extData.extensions.length === 0) { - extList.innerHTML = '
No extensions installed
'; + extList.innerHTML = '
' + I18n.t('extensions.noInstalled') + '
'; } else { extList.innerHTML = ''; for (const ext of extData.extensions) { @@ -2053,7 +2055,7 @@ function loadExtensions() { // Available WASM extensions if (wasmEntries.length === 0) { - wasmList.innerHTML = '
No additional WASM extensions available
'; + wasmList.innerHTML = '
' + I18n.t('extensions.noAvailable') + '
'; } else { wasmList.innerHTML = ''; for (const entry of wasmEntries) { @@ -2063,7 +2065,7 @@ function loadExtensions() { // MCP servers (show both installed and uninstalled) if (mcpEntries.length === 0) { - mcpList.innerHTML = '
No MCP servers available
'; + mcpList.innerHTML = '
' + I18n.t('mcp.noServers') + '
'; } else { mcpList.innerHTML = ''; for (const entry of mcpEntries) { @@ -2128,16 +2130,16 @@ function renderAvailableExtensionCard(entry) { const installBtn = document.createElement('button'); installBtn.className = 'btn-ext install'; - installBtn.textContent = 'Install'; + installBtn.textContent = I18n.t('extensions.install'); installBtn.addEventListener('click', function() { installBtn.disabled = true; - installBtn.textContent = 'Installing...'; + installBtn.textContent = I18n.t('extensions.installing'); apiFetch('/api/extensions/install', { method: 'POST', body: { name: entry.name, kind: entry.kind }, }).then(function(res) { if (res.success) { - showToast('Installed ' + entry.display_name, 'success'); + showToast(I18n.t('extensions.installedSuccess', {name: entry.display_name}), 'success'); // OAuth popup if auth started during install (builtin creds) if (res.auth_url) { showToast('Opening authentication for ' + entry.display_name, 'info'); @@ -2201,39 +2203,39 @@ function renderMcpServerCard(entry, installedExt) { if (!installedExt.active) { var activateBtn = document.createElement('button'); activateBtn.className = 'btn-ext activate'; - activateBtn.textContent = 'Activate'; + activateBtn.textContent = I18n.t('common.activate'); activateBtn.addEventListener('click', function() { activateExtension(installedExt.name); }); actions.appendChild(activateBtn); } else { var activeLabel = document.createElement('span'); activeLabel.className = 'ext-active-label'; - activeLabel.textContent = 'Active'; + activeLabel.textContent = I18n.t('ext.active'); actions.appendChild(activeLabel); } var removeBtn = document.createElement('button'); removeBtn.className = 'btn-ext remove'; - removeBtn.textContent = 'Remove'; + removeBtn.textContent = I18n.t('ext.remove'); removeBtn.addEventListener('click', function() { removeExtension(installedExt.name); }); actions.appendChild(removeBtn); } else { var installBtn = document.createElement('button'); installBtn.className = 'btn-ext install'; - installBtn.textContent = 'Install'; + installBtn.textContent = I18n.t('ext.install'); installBtn.addEventListener('click', function() { installBtn.disabled = true; - installBtn.textContent = 'Installing...'; + installBtn.textContent = I18n.t('ext.installing'); apiFetch('/api/extensions/install', { method: 'POST', body: { name: entry.name, kind: entry.kind }, }).then(function(res) { if (res.success) { - showToast('Installed ' + entry.display_name, 'success'); + showToast(I18n.t('extensions.installedSuccess', { name: entry.display_name }), 'success'); } else { - showToast('Install: ' + (res.message || 'unknown error'), 'error'); + showToast(I18n.t('ext.install') + ': ' + (res.message || 'unknown error'), 'error'); } loadExtensions(); }).catch(function(err) { - showToast('Install failed: ' + err.message, 'error'); + showToast(I18n.t('ext.installFailed', { message: err.message }), 'error'); loadExtensions(); }); }); @@ -2247,7 +2249,7 @@ function renderMcpServerCard(entry, installedExt) { function createReconfigureButton(extName) { var btn = document.createElement('button'); btn.className = 'btn-ext configure'; - btn.textContent = 'Reconfigure'; + btn.textContent = I18n.t('ext.reconfigure'); btn.addEventListener('click', function() { showConfigureModal(extName); }); return btn; } @@ -2331,13 +2333,13 @@ function renderExtensionCard(ext) { if (status === 'active') { var activeLabel = document.createElement('span'); activeLabel.className = 'ext-active-label'; - activeLabel.textContent = 'Active'; + activeLabel.textContent = I18n.t('ext.active'); actions.appendChild(activeLabel); actions.appendChild(createReconfigureButton(ext.name)); } else if (status === 'pairing') { var pairingLabel = document.createElement('span'); pairingLabel.className = 'ext-pairing-label'; - pairingLabel.textContent = 'Awaiting Pairing'; + pairingLabel.textContent = I18n.t('status.awaitingPairing'); actions.appendChild(pairingLabel); actions.appendChild(createReconfigureButton(ext.name)); } else if (status === 'failed') { @@ -2346,7 +2348,7 @@ function renderExtensionCard(ext) { // installed or configured: show Setup button var setupBtn = document.createElement('button'); setupBtn.className = 'btn-ext configure'; - setupBtn.textContent = 'Setup'; + setupBtn.textContent = I18n.t('ext.setup'); setupBtn.addEventListener('click', function() { showConfigureModal(ext.name); }); actions.appendChild(setupBtn); } @@ -2354,14 +2356,14 @@ function renderExtensionCard(ext) { // WASM tools / MCP servers const activeLabel = document.createElement('span'); activeLabel.className = 'ext-active-label'; - activeLabel.textContent = ext.active ? 'Active' : 'Installed'; + activeLabel.textContent = ext.active ? I18n.t('ext.active') : I18n.t('status.installed'); actions.appendChild(activeLabel); // MCP servers and channel-relay extensions may be installed but inactive — show Activate button if ((ext.kind === 'mcp_server' || ext.kind === 'channel_relay') && !ext.active) { const activateBtn = document.createElement('button'); activateBtn.className = 'btn-ext activate'; - activateBtn.textContent = 'Activate'; + activateBtn.textContent = I18n.t('common.activate'); activateBtn.addEventListener('click', () => activateExtension(ext.name)); actions.appendChild(activateBtn); } @@ -2373,7 +2375,7 @@ function renderExtensionCard(ext) { if (ext.needs_setup || (ext.has_auth && ext.authenticated)) { const configBtn = document.createElement('button'); configBtn.className = 'btn-ext configure'; - configBtn.textContent = ext.authenticated ? 'Reconfigure' : 'Configure'; + configBtn.textContent = ext.authenticated ? I18n.t('ext.reconfigure') : I18n.t('ext.configure'); configBtn.addEventListener('click', () => showConfigureModal(ext.name)); actions.appendChild(configBtn); } @@ -2381,7 +2383,7 @@ function renderExtensionCard(ext) { const removeBtn = document.createElement('button'); removeBtn.className = 'btn-ext remove'; - removeBtn.textContent = 'Remove'; + removeBtn.textContent = I18n.t('ext.remove'); removeBtn.addEventListener('click', () => removeExtension(ext.name)); actions.appendChild(removeBtn); @@ -2426,17 +2428,17 @@ function activateExtension(name) { } function removeExtension(name) { - if (!confirm('Remove extension "' + name + '"?')) return; + if (!confirm(I18n.t('ext.confirmRemove', { name: name }))) return; apiFetch('/api/extensions/' + encodeURIComponent(name) + '/remove', { method: 'POST' }) .then((res) => { if (!res.success) { - showToast('Remove failed: ' + res.message, 'error'); + showToast(I18n.t('ext.removeFailed', { message: res.message }), 'error'); } else { - showToast('Removed ' + name, 'success'); + showToast(I18n.t('ext.removed', { name: name }), 'success'); } loadExtensions(); }) - .catch((err) => showToast('Remove failed: ' + err.message, 'error')); + .catch((err) => showToast(I18n.t('ext.removeFailed', { message: err.message }), 'error')); } function showConfigureModal(name) { @@ -2463,7 +2465,7 @@ function renderConfigureModal(name, secrets) { modal.className = 'configure-modal'; const header = document.createElement('h3'); - header.textContent = 'Configure ' + name; + header.textContent = I18n.t('config.title', { name: name }); modal.appendChild(header); const form = document.createElement('div'); @@ -2479,7 +2481,7 @@ function renderConfigureModal(name, secrets) { if (secret.optional) { const opt = document.createElement('span'); opt.className = 'field-optional'; - opt.textContent = ' (optional)'; + opt.textContent = I18n.t('config.optional'); label.appendChild(opt); } field.appendChild(label); @@ -2490,7 +2492,7 @@ function renderConfigureModal(name, secrets) { const input = document.createElement('input'); input.type = 'password'; input.name = secret.name; - input.placeholder = secret.provided ? '(already set — leave empty to keep)' : ''; + input.placeholder = secret.provided ? I18n.t('config.alreadySet') : ''; input.addEventListener('keydown', (e) => { if (e.key === 'Enter') submitConfigureModal(name, fields); }); @@ -2500,13 +2502,13 @@ function renderConfigureModal(name, secrets) { const badge = document.createElement('span'); badge.className = 'field-provided'; badge.textContent = '\u2713'; - badge.title = 'Already configured'; + badge.title = I18n.t('config.alreadyConfigured'); inputRow.appendChild(badge); } if (secret.auto_generate && !secret.provided) { const hint = document.createElement('span'); hint.className = 'field-autogen'; - hint.textContent = 'Auto-generated if empty'; + hint.textContent = I18n.t('config.autoGenerate'); inputRow.appendChild(hint); } @@ -2522,13 +2524,13 @@ function renderConfigureModal(name, secrets) { const submitBtn = document.createElement('button'); submitBtn.className = 'btn-ext activate'; - submitBtn.textContent = 'Save'; + submitBtn.textContent = I18n.t('config.save'); submitBtn.addEventListener('click', () => submitConfigureModal(name, fields)); actions.appendChild(submitBtn); const cancelBtn = document.createElement('button'); cancelBtn.className = 'btn-ext remove'; - cancelBtn.textContent = 'Cancel'; + cancelBtn.textContent = I18n.t('config.cancel'); cancelBtn.addEventListener('click', closeConfigureModal); actions.appendChild(cancelBtn); @@ -2768,11 +2770,11 @@ function loadJobs() { function renderJobsSummary(s) { document.getElementById('jobs-summary').innerHTML = '' - + summaryCard('Total', s.total, '') - + summaryCard('In Progress', s.in_progress, 'active') - + summaryCard('Completed', s.completed, 'completed') - + summaryCard('Failed', s.failed, 'failed') - + summaryCard('Stuck', s.stuck, 'stuck'); + + summaryCard(I18n.t('jobs.summary.total'), s.total, '') + + summaryCard(I18n.t('jobs.summary.inProgress'), s.in_progress, 'active') + + summaryCard(I18n.t('jobs.summary.completed'), s.completed, 'completed') + + summaryCard(I18n.t('jobs.summary.failed'), s.failed, 'failed') + + summaryCard(I18n.t('jobs.summary.stuck'), s.stuck, 'stuck'); } function summaryCard(label, count, cls) { @@ -3302,11 +3304,11 @@ function loadRoutines() { function renderRoutinesSummary(s) { document.getElementById('routines-summary').innerHTML = '' - + summaryCard('Total', s.total, '') - + summaryCard('Enabled', s.enabled, 'active') - + summaryCard('Disabled', s.disabled, '') - + summaryCard('Failing', s.failing, 'failed') - + summaryCard('Runs Today', s.runs_today, 'completed'); + + summaryCard(I18n.t('routines.summary.total'), s.total, '') + + summaryCard(I18n.t('routines.summary.enabled'), s.enabled, 'active') + + summaryCard(I18n.t('routines.summary.disabled'), s.disabled, '') + + summaryCard(I18n.t('routines.summary.failing'), s.failing, 'failed') + + summaryCard(I18n.t('routines.summary.runsToday'), s.runs_today, 'completed'); } function renderRoutinesList(routines) { @@ -3472,17 +3474,18 @@ function formatRelativeTime(isoString) { const absDiff = Math.abs(diffMs); const future = diffMs < 0; - if (absDiff < 60000) return future ? 'in <1m' : '<1m ago'; + if (absDiff < 60000) + return future ? I18n.t('time.lessThan1MinuteFromNow') : I18n.t('time.lessThan1MinuteAgo'); if (absDiff < 3600000) { const m = Math.floor(absDiff / 60000); - return future ? 'in ' + m + 'm' : m + 'm ago'; + return future ? I18n.t('time.minutesFromNow', { n: m }) : I18n.t('time.minutesAgo', { n: m }); } if (absDiff < 86400000) { const h = Math.floor(absDiff / 3600000); - return future ? 'in ' + h + 'h' : h + 'h ago'; + return future ? I18n.t('time.hoursFromNow', { n: h }) : I18n.t('time.hoursAgo', { n: h }); } const days = Math.floor(absDiff / 86400000); - return future ? 'in ' + days + 'd' : days + 'd ago'; + return future ? I18n.t('time.daysFromNow', { n: days }) : I18n.t('time.daysAgo', { n: days }); } // --- Gateway status widget --- @@ -3532,18 +3535,18 @@ function fetchGatewayStatus() { } // Connection info - html += ''; - html += '
SSE' + (data.sse_connections || 0) + '
'; - html += '
WebSocket' + (data.ws_connections || 0) + '
'; - html += '
Uptime' + formatDuration(data.uptime_secs) + '
'; + html += ''; + html += '
' + I18n.t('dashboard.sse') + '' + (data.sse_connections || 0) + '
'; + html += '
' + I18n.t('dashboard.websocket') + '' + (data.ws_connections || 0) + '
'; + html += '
' + I18n.t('dashboard.uptime') + '' + formatDuration(data.uptime_secs) + '
'; // Cost tracker if (data.daily_cost != null) { html += '
'; - html += ''; - html += '
Spent' + formatCost(data.daily_cost) + '
'; + html += ''; + html += '
' + I18n.t('dashboard.spent') + '' + formatCost(data.daily_cost) + '
'; if (data.actions_this_hour != null) { - html += '
Actions/hr' + data.actions_this_hour + '
'; + html += '
' + I18n.t('dashboard.actionsPerHour') + '' + data.actions_this_hour + '
'; } } @@ -3751,7 +3754,7 @@ function loadSkills() { var skillsList = document.getElementById('skills-list'); apiFetch('/api/skills').then(function(data) { if (!data.skills || data.skills.length === 0) { - skillsList.innerHTML = '
No skills installed
'; + skillsList.innerHTML = '
' + I18n.t('skills.noInstalled') + '
'; return; } skillsList.innerHTML = ''; @@ -3759,7 +3762,7 @@ function loadSkills() { skillsList.appendChild(renderSkillCard(data.skills[i])); } }).catch(function(err) { - skillsList.innerHTML = '
Failed to load skills: ' + escapeHtml(err.message) + '
'; + skillsList.innerHTML = '
' + I18n.t('skills.loadFailed', {message: escapeHtml(err.message)}) + '
'; }); } @@ -3796,7 +3799,7 @@ function renderSkillCard(skill) { if (skill.keywords && skill.keywords.length > 0) { var kw = document.createElement('div'); kw.className = 'ext-keywords'; - kw.textContent = 'Activates on: ' + skill.keywords.join(', '); + kw.textContent = I18n.t('skills.activatesOn') + ': ' + skill.keywords.join(', '); card.appendChild(kw); } @@ -3807,7 +3810,7 @@ function renderSkillCard(skill) { if (skill.trust.toLowerCase() !== 'trusted') { var removeBtn = document.createElement('button'); removeBtn.className = 'btn-ext remove'; - removeBtn.textContent = 'Remove'; + removeBtn.textContent = I18n.t('skills.remove'); removeBtn.addEventListener('click', function() { removeSkill(skill.name); }); actions.appendChild(removeBtn); } @@ -3822,7 +3825,7 @@ function searchClawHub() { if (!query) return; var resultsDiv = document.getElementById('skill-search-results'); - resultsDiv.innerHTML = '
Searching...
'; + resultsDiv.innerHTML = '
' + I18n.t('skills.searching') + '
'; apiFetch('/api/skills/search', { method: 'POST', @@ -3838,7 +3841,7 @@ function searchClawHub() { warning.style.borderLeft = '3px solid #f0ad4e'; warning.style.paddingLeft = '12px'; warning.style.marginBottom = '16px'; - warning.textContent = 'Could not reach ClawHub registry: ' + data.catalog_error; + warning.textContent = I18n.t('skills.registryError', {message: data.catalog_error}); resultsDiv.appendChild(warning); } @@ -3870,10 +3873,10 @@ function searchClawHub() { } if (resultsDiv.children.length === 0) { - resultsDiv.innerHTML = '
No skills found for "' + escapeHtml(query) + '"
'; + resultsDiv.innerHTML = '
' + I18n.t('skills.noResults', {query: escapeHtml(query)}) + '
'; } }).catch(function(err) { - resultsDiv.innerHTML = '
Search failed: ' + escapeHtml(err.message) + '
'; + resultsDiv.innerHTML = '
' + I18n.t('skills.searchFailed', {message: escapeHtml(err.message)}) + '
'; }); } @@ -3967,17 +3970,17 @@ function renderCatalogSkillCard(entry, installedNames) { if (isInstalled) { var label = document.createElement('span'); label.className = 'ext-active-label'; - label.textContent = 'Installed'; + label.textContent = I18n.t('status.installed'); actions.appendChild(label); } else { var installBtn = document.createElement('button'); installBtn.className = 'btn-ext install'; - installBtn.textContent = 'Install'; + installBtn.textContent = I18n.t('extensions.install'); installBtn.addEventListener('click', (function(s, btn) { return function() { if (!confirm('Install skill "' + s + '" from ClawHub?')) return; btn.disabled = true; - btn.textContent = 'Installing...'; + btn.textContent = I18n.t('extensions.installing'); installSkill(s, null, btn); }; })(slug, installBtn)); @@ -4019,7 +4022,7 @@ function installSkill(nameOrSlug, url, btn) { body: body, }).then(function(res) { if (res.success) { - showToast('Installed skill "' + nameOrSlug + '"', 'success'); + showToast(I18n.t('skills.installedSuccess', {name: nameOrSlug}), 'success'); } else { showToast('Install failed: ' + (res.message || 'unknown error'), 'error'); } @@ -4032,19 +4035,19 @@ function installSkill(nameOrSlug, url, btn) { } function removeSkill(name) { - if (!confirm('Remove skill "' + name + '"?')) return; + if (!confirm(I18n.t('skills.confirmRemove', { name: name }))) return; apiFetch('/api/skills/' + encodeURIComponent(name), { method: 'DELETE', headers: { 'X-Confirm-Action': 'true' }, }).then(function(res) { if (res.success) { - showToast('Removed skill "' + name + '"', 'success'); + showToast(I18n.t('skills.removed', { name: name }), 'success'); } else { - showToast('Remove failed: ' + (res.message || 'unknown error'), 'error'); + showToast(I18n.t('skills.removeFailed', { message: res.message || 'unknown error' }), 'error'); } loadSkills(); }).catch(function(err) { - showToast('Remove failed: ' + err.message, 'error'); + showToast(I18n.t('skills.removeFailed', { message: err.message }), 'error'); }); } diff --git a/src/channels/web/static/i18n-app.js b/src/channels/web/static/i18n-app.js new file mode 100644 index 00000000..87624b96 --- /dev/null +++ b/src/channels/web/static/i18n-app.js @@ -0,0 +1,74 @@ +// i18n Integration for IronClaw App +// This file contains i18n-related functions that extend app.js + +// Initialize i18n when DOM is ready +document.addEventListener('DOMContentLoaded', () => { + // Initialize i18n + I18n.init(); + I18n.updatePageContent(); + updateSlashCommands(); + updateLanguageMenu(); +}); + +// Update slash commands with current language +function updateSlashCommands() { + // Update SLASH_COMMANDS descriptions + SLASH_COMMANDS.forEach(cmd => { + const key = 'cmd.' + cmd.cmd.replace(/\s+/g, '').replace(/\//g, '') + '.desc'; + const translated = I18n.t(key); + if (translated !== key) { + cmd.desc = translated; + } + }); +} + +// Toggle language menu +function toggleLanguageMenu() { + const menu = document.getElementById('language-menu'); + if (menu) { + menu.style.display = menu.style.display === 'none' ? 'block' : 'none'; + } +} + +// Switch language +function switchLanguage(lang) { + if (I18n.setLanguage(lang)) { + // Update slash commands + updateSlashCommands(); + + // Update language menu active state + updateLanguageMenu(); + + // Close menu + const menu = document.getElementById('language-menu'); + if (menu) { + menu.style.display = 'none'; + } + + // Show toast notification + showToast(I18n.t('language.switch') + ': ' + (lang === 'zh-CN' ? '简体中文' : 'English')); + } +} + +// Update language menu active state +function updateLanguageMenu() { + const currentLang = I18n.getCurrentLang(); + document.querySelectorAll('.language-option').forEach(option => { + if (option.getAttribute('data-lang') === currentLang) { + option.classList.add('active'); + } else { + option.classList.remove('active'); + } + }); +} + +// Close language menu when clicking outside +document.addEventListener('click', (e) => { + if (!e.target.closest('.language-switcher')) { + const menu = document.getElementById('language-menu'); + if (menu) { + menu.style.display = 'none'; + } + } +}); + diff --git a/src/channels/web/static/i18n/en.js b/src/channels/web/static/i18n/en.js new file mode 100644 index 00000000..b637f144 --- /dev/null +++ b/src/channels/web/static/i18n/en.js @@ -0,0 +1,351 @@ +// English Language Pack for IronClaw + +I18n.register('en', { + // Auth Page + 'auth.title': 'IronClaw', + 'auth.tagline': 'Secure AI Assistant', + 'auth.tokenLabel': 'Gateway Token', + 'auth.tokenPlaceholder': 'Paste your token', + 'auth.connect': 'Connect', + 'auth.errorRequired': 'Token required', + 'auth.errorInvalid': 'Invalid token', + 'auth.hint': 'Enter the GATEWAY_AUTH_TOKEN from your .env file', + + // Chat + 'chat.inputPlaceholder': 'Message or / for commands...', + + // Restart Modal + 'restart.title': 'Restart IronClaw Instance', + 'restart.description': 'Are you sure you want to restart IronClaw? This will gracefully restart the process.', + 'restart.warning': 'Running tasks may be interrupted. Restart will complete in a few seconds.', + 'restart.cancel': 'Cancel', + 'restart.confirm': 'Confirm Restart', + 'restart.progressTitle': 'Restarting IronClaw', + 'restart.progressSubtitle': 'Please wait for the process to restart...', + 'restart.checkLogs': 'Check the Logs tab for details after restart completes.', + + // Tabs + 'tab.chat': 'Chat', + 'tab.memory': 'Memory', + 'tab.jobs': 'Jobs', + 'tab.routines': 'Routines', + 'tab.extensions': 'Extensions', + 'tab.skills': 'Skills', + 'tab.logs': 'Logs', + + // Status + 'status.connected': 'Connected', + 'status.disconnected': 'Disconnected', + 'status.connecting': 'Connecting...', + 'status.reconnecting': 'Reconnecting...', + 'status.teeVerified': 'TEE Verified', + 'status.restart': 'Restart', + 'status.active': 'Active', + 'status.installed': 'Installed', + 'status.awaitingPairing': 'Awaiting Pairing', + + // Dashboard + 'dashboard.connections': 'Connections', + 'dashboard.uptime': 'Uptime', + 'dashboard.costToday': 'Cost Today', + 'dashboard.spent': 'Spent', + 'dashboard.actionsPerHour': 'Actions/hr', + 'dashboard.sse': 'SSE', + 'dashboard.websocket': 'WebSocket', + + // Chat Tab + 'chat.newThread': 'New Thread', + 'chat.toggleSidebar': 'Toggle Sidebar', + 'chat.assistant': 'Assistant', + 'chat.conversations': 'Conversations', + 'chat.send': 'Send', + 'chat.attachImages': 'Attach Images', + 'chat.empty': 'Select a file to view content', + 'chat.loading': 'Loading...', + 'chat.loadingOlder': 'Loading older messages...', + 'chat.noFiles': 'No files in workspace', + 'chat.noResults': 'No results', + + // Thread Sidebar + 'thread.assistant': 'Assistant', + 'thread.new': 'New Thread', + + // Memory Tab + 'memory.searchPlaceholder': 'Search memory...', + 'memory.workspace': 'workspace', + 'memory.edit': 'Edit', + 'memory.save': 'Save', + 'memory.cancel': 'Cancel', + 'memory.selectFile': 'Select a file to view content', + + // Jobs Tab + 'jobs.summary': 'Jobs Summary', + 'jobs.id': 'ID', + 'jobs.title': 'Title', + 'jobs.source': 'Source', + 'jobs.status': 'Status', + 'jobs.created': 'Created', + 'jobs.actions': 'Actions', + 'jobs.empty': 'No jobs', + 'jobs.statusRunning': 'Running', + 'jobs.statusCompleted': 'Completed', + 'jobs.statusFailed': 'Failed', + 'jobs.statusPending': 'Pending', + 'jobs.jobId': 'Job ID', + 'jobs.description': 'Description', + 'jobs.stateTransitions': 'State Transitions', + 'jobs.projectFiles': 'Project Files', + 'jobs.noProjectFiles': 'No project files', + 'jobs.viewJob': 'View Job', + 'jobs.browse': 'Browse', + + // Routines Tab + 'routines.summary': 'Routines Summary', + 'routines.name': 'Name', + 'routines.trigger': 'Trigger', + 'routines.action': 'Action', + 'routines.lastRun': 'Last Run', + 'routines.nextRun': 'Next Run', + 'routines.runs': 'Runs', + 'routines.status': 'Status', + 'routines.actions': 'Actions', + 'routines.runsToday': 'Runs Today', + 'routines.empty': 'No routines', + 'routines.noConfigured': 'No routines configured. Ask the assistant to create one.', + 'routines.triggerFailed': 'Trigger failed: {message}', + + // Logs Tab + 'logs.serverLevel': 'Server: ERROR', + 'logs.clientLevel': 'Client Log Level', + 'logs.pause': 'Pause', + 'logs.resume': 'Resume', + 'logs.clear': 'Clear', + 'logs.autoScroll': 'Auto-scroll', + 'logs.filter': 'Filter logs...', + 'logs.empty': 'No logs', + 'logs.allLevels': 'All Levels', + 'logs.error': 'Error', + 'logs.warn': 'Warn', + 'logs.info': 'Info', + 'logs.debug': 'Debug', + + // Extensions Tab + 'extensions.installed': 'Installed Extensions', + 'extensions.available': 'Available WASM Extensions', + 'extensions.installWasm': 'Install WASM Extension', + 'extensions.noInstalled': 'No extensions installed', + 'extensions.noAvailable': 'No additional WASM extensions available', + 'extensions.loading': 'Loading...', + 'extensions.install': 'Install', + 'extensions.installing': 'Installing...', + 'extensions.installedSuccess': 'Installed {name}', + 'extensions.remove': 'Remove', + 'extensions.activate': 'Activate', + 'extensions.reconfigure': 'Reconfigure', + 'extensions.tools': 'Tools', + 'extensions.noConfigNeeded': 'No configuration needed for {name}', + 'extensions.configure': 'Configure {name}', + 'extensions.optional': ' (optional)', + 'extensions.autoGenerated': 'Auto-generated if empty', + 'extensions.pendingPairing': 'Pending pairing requests', + 'extensions.from': 'from', + + // MCP Servers + 'mcp.servers': 'MCP Servers', + 'mcp.noServers': 'No MCP servers available', + 'mcp.addCustom': 'Add Custom MCP Server', + 'mcp.add': 'Add', + 'mcp.addedSuccess': 'Added MCP server {name}', + + // Registered Tools + 'tools.registered': 'Registered Tools', + 'tools.name': 'Name', + 'tools.description': 'Description', + 'tools.empty': 'No tools registered', + + // Skills Tab + 'skills.installed': 'Installed Skills', + 'skills.noInstalled': 'No skills installed', + 'skills.searchClawHub': 'Search ClawHub', + 'skills.searchPlaceholder': 'Search...', + 'skills.installByUrl': 'Install Skill by URL', + 'skills.namePlaceholder': 'Skill name or slug', + 'skills.urlPlaceholder': 'HTTPS URL to SKILL.md (optional)', + 'skills.search': 'Search', + 'skills.searching': 'Searching...', + 'skills.noResults': 'No skills found for "{query}"', + 'skills.searchFailed': 'Search failed: {message}', + 'skills.install': 'Install', + 'skills.installing': 'Installing...', + 'skills.installedSuccess': 'Installed skill "{name}"', + 'skills.remove': 'Remove', + 'skills.activatesOn': 'Activates on', + 'skills.registryError': 'Could not reach ClawHub registry: {message}', + 'skills.by': 'by', + 'skills.updated': 'updated', + 'skills.loading': 'Loading skills...', + 'skills.loadFailed': 'Failed to load skills: {message}', + 'skills.confirmRemove': 'Remove skill "{name}"?', + 'skills.removeFailed': 'Remove failed: {message}', + 'skills.removed': 'Removed skill "{name}"', + + // Jobs Summary + 'jobs.summary.total': 'Total', + 'jobs.summary.inProgress': 'In Progress', + 'jobs.summary.completed': 'Completed', + 'jobs.summary.failed': 'Failed', + 'jobs.summary.stuck': 'Stuck', + + // Routines Summary + 'routines.summary.total': 'Total', + 'routines.summary.enabled': 'Enabled', + 'routines.summary.disabled': 'Disabled', + 'routines.summary.failing': 'Failing', + 'routines.summary.runsToday': 'Runs Today', + + // Buttons + 'btn.close': 'Close', + 'btn.cancel': 'Cancel', + 'btn.save': 'Save', + 'btn.edit': 'Edit', + 'btn.confirm': 'Confirm', + 'btn.send': 'Send', + 'btn.refresh': 'Refresh', + 'btn.loadMore': 'Load More', + 'btn.copy': 'Copy', + 'btn.copied': 'Copied!', + 'btn.submit': 'Submit', + 'btn.setup': 'Setup', + + // Time + 'time.lessThan1MinuteAgo': '<1m ago', + 'time.lessThan1MinuteFromNow': 'in <1m', + 'time.minutesAgo': '{n}m ago', + 'time.minutesFromNow': 'in {n}m', + 'time.hoursAgo': '{n}h ago', + 'time.hoursFromNow': 'in {n}h', + 'time.daysAgo': '{n}d ago', + 'time.daysFromNow': 'in {n}d', + + // Tool Approval + 'approval.title': 'Tool requires approval', + 'approval.description': 'A tool is requesting permission to run.', + 'approval.approve': 'Approve', + 'approval.deny': 'Deny', + 'approval.always': 'Always', + 'approval.approved': 'Approved', + 'approval.alwaysApproved': 'Always approved', + 'approval.denied': 'Denied', + 'approval.showParams': 'Show parameters', + 'approval.hideParams': 'Hide parameters', + + // Authentication Required + 'authRequired.title': 'Authentication required for {name}', + 'authRequired.authenticateWith': 'Authenticate with {name}', + 'authRequired.getToken': 'Get your token', + 'authRequired.instructions': 'Instructions', + + // Sandbox Jobs + 'sandbox.job': 'Sandbox Job', + 'sandbox.doneSignal': 'Done signal sent', + + // Error Messages + 'error.startConversation': 'Please start a conversation first', + 'error.restartFailed': 'Restart failed: {message}', + 'error.tokenRequired': 'Token required', + 'error.tokenInvalid': 'Invalid token', + 'error.connectionFailed': 'Connection failed', + 'error.unknown': 'Unknown error', + 'error.loadFailed': 'Failed to load: {message}', + + // Success Messages + 'success.restartInitiated': 'Restart initiated', + 'success.saved': 'Saved successfully', + + // Slash Commands + 'cmd.status.desc': 'Show all jobs, or /status for a specific job', + 'cmd.list.desc': 'List all jobs', + 'cmd.cancel.desc': '/cancel — Cancel a running job', + 'cmd.undo.desc': 'Undo last action', + 'cmd.redo.desc': 'Redo undone action', + 'cmd.compact.desc': 'Compact context window', + 'cmd.clear.desc': 'Clear conversation and start fresh', + 'cmd.interrupt.desc': 'Stop current operation', + 'cmd.heartbeat.desc': 'Trigger manual heartbeat check', + 'cmd.summarize.desc': 'Summarize current conversation', + 'cmd.suggest.desc': 'Suggest next actions', + 'cmd.help.desc': 'Show help', + 'cmd.version.desc': 'Show version info', + 'cmd.tools.desc': 'List available tools', + 'cmd.skills.desc': 'List installed skills', + 'cmd.model.desc': 'Show or switch LLM model', + 'cmd.threadNew.desc': 'Create new conversation thread', + + // Language Switcher + 'language.title': 'Language', + 'language.en': 'English', + 'language.zhCN': '简体中文', + 'language.switch': 'Switch Language', + + // Tool Activity + 'tool.thinking': 'Thinking...', + 'tool.completed': 'Completed', + 'tool.failed': 'Failed', + 'tool.running': 'Running', + 'tool.used': '{count} tool(s) used', + 'tool.requiresApproval': 'Tool requires approval', + + + // TEE + 'tee.loadingReport': 'Loading attestation report...', + 'tee.loadFailed': 'Could not load attestation report', + + // Common + 'common.loading': 'Loading...', + 'common.noData': 'No data', + 'common.search': 'Search', + 'common.add': 'Add', + 'common.remove': 'Remove', + 'common.install': 'Install', + 'common.activate': 'Activate', + 'common.deactivate': 'Deactivate', + 'common.configure': 'Configure', + 'common.save': 'Save', + 'common.cancel': 'Cancel', + 'common.confirm': 'Confirm', + 'common.close': 'Close', + 'common.edit': 'Edit', + 'common.delete': 'Delete', + 'common.refresh': 'Refresh', + 'common.searchPlaceholder': 'Search...', + 'common.name': 'Name', + 'common.description': 'Description', + 'common.status': 'Status', + 'common.actions': 'Actions', + 'common.version': 'Version', + 'common.owner': 'Owner', + 'common.tags': 'Tags', + + // Extensions + 'ext.active': 'Active', + 'ext.remove': 'Remove', + 'ext.install': 'Install', + 'ext.installing': 'Installing...', + 'ext.installed': 'Installed', + 'ext.setup': 'Setup', + 'ext.reconfigure': 'Reconfigure', + 'ext.configure': 'Configure', + 'ext.confirmRemove': 'Remove extension "{name}"?', + 'ext.removeFailed': 'Remove failed: {message}', + 'ext.removed': 'Removed {name}', + 'ext.installFailed': 'Install failed: {message}', + + // Configure + 'config.title': 'Configure {name}', + 'config.optional': ' (optional)', + 'config.alreadySet': '(already set — leave empty to keep)', + 'config.alreadyConfigured': 'Already configured', + 'config.autoGenerate': 'Auto-generated if empty', + 'config.save': 'Save', + 'config.cancel': 'Cancel', +}); diff --git a/src/channels/web/static/i18n/index.js b/src/channels/web/static/i18n/index.js new file mode 100644 index 00000000..4c92bcc5 --- /dev/null +++ b/src/channels/web/static/i18n/index.js @@ -0,0 +1,89 @@ +// Lightweight internationalization implementation with dynamic language switching + +const I18n = { + currentLang: 'en', + fallbackLang: 'en', + translations: {}, + + // Initialize i18n + init() { + // Read user preference from localStorage + const savedLang = localStorage.getItem('ironclaw_language'); + if (savedLang && this.translations[savedLang]) { + this.currentLang = savedLang; + } else { + // Detect browser language + const browserLang = navigator.language || navigator.userLanguage; + this.currentLang = browserLang.startsWith('zh') ? 'zh-CN' : 'en'; + } + this.updateHtmlLang(); + }, + + // Register language pack + register(lang, translations) { + this.translations[lang] = translations; + }, + + // Switch language + setLanguage(lang) { + if (this.translations[lang]) { + this.currentLang = lang; + localStorage.setItem('ironclaw_language', lang); + this.updateHtmlLang(); + this.updatePageContent(); + return true; + } + return false; + }, + + // Get current language + getCurrentLang() { + return this.currentLang; + }, + + // Translate function + t(key, params = {}) { + const translation = this.translations[this.currentLang]?.[key] + || this.translations[this.fallbackLang]?.[key] + || key; + + // Support placeholder replacement: {name} + return translation.replace(/\{(\w+)\}/g, (match, key) => { + return params[key] !== undefined ? params[key] : match; + }); + }, + + // Update HTML lang attribute + updateHtmlLang() { + document.documentElement.lang = this.currentLang; + }, + + // Update page content (traverse all data-i18n elements) + updatePageContent() { + // Update text content + document.querySelectorAll('[data-i18n]').forEach(el => { + const key = el.getAttribute('data-i18n'); + const attr = el.getAttribute('data-i18n-attr'); + if (attr) { + el.setAttribute(attr, this.t(key)); + } else { + el.textContent = this.t(key); + } + }); + + // Update placeholder attributes + document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { + const key = el.getAttribute('data-i18n-placeholder'); + el.placeholder = this.t(key); + }); + + // Update title attributes + document.querySelectorAll('[data-i18n-title]').forEach(el => { + const key = el.getAttribute('data-i18n-title'); + el.title = this.t(key); + }); + } +}; + +// Global access +window.I18n = I18n; diff --git a/src/channels/web/static/i18n/zh-CN.js b/src/channels/web/static/i18n/zh-CN.js new file mode 100644 index 00000000..8a7fd520 --- /dev/null +++ b/src/channels/web/static/i18n/zh-CN.js @@ -0,0 +1,351 @@ +// 中文语言包 for IronClaw + +I18n.register('zh-CN', { + // 认证页面 + 'auth.title': 'IronClaw', + 'auth.tagline': '安全可靠的 AI 助手', + 'auth.tokenLabel': '网关令牌', + 'auth.tokenPlaceholder': '粘贴你的网关令牌', + 'auth.connect': '连接', + 'auth.errorRequired': '请输入令牌', + 'auth.errorInvalid': '令牌无效', + 'auth.hint': '输入 .env 配置文件中的 GATEWAY_AUTH_TOKEN', + + // 聊天 + 'chat.inputPlaceholder': '输入消息或 / 以使用命令...', + + // 重启弹窗 + 'restart.title': '重启 IronClaw 实例', + 'restart.description': '确定要重启 IronClaw 实例吗?这将优雅地重启进程。', + 'restart.warning': '正在运行的任务可能会中断。重启将在几秒钟内完成。', + 'restart.cancel': '取消', + 'restart.confirm': '确认重启', + 'restart.progressTitle': '正在重启 IronClaw', + 'restart.progressSubtitle': '请等待进程重启...', + 'restart.checkLogs': '重启完成后,请查看日志标签页了解详情。', + + // 标签页 + 'tab.chat': '聊天', + 'tab.memory': '记忆', + 'tab.jobs': '任务', + 'tab.routines': '定时任务', + 'tab.extensions': '扩展', + 'tab.skills': '技能', + 'tab.logs': '日志', + + // 状态 + 'status.connected': '已连接', + 'status.disconnected': '已断开', + 'status.connecting': '连接中...', + 'status.reconnecting': '重新连接中...', + 'status.teeVerified': 'TEE 已验证', + 'status.restart': '重启', + 'status.active': '已激活', + 'status.installed': '已安装', + 'status.awaitingPairing': '等待配对', + + // 仪表盘 + 'dashboard.connections': '连接数', + 'dashboard.uptime': '运行时间', + 'dashboard.costToday': '今日费用', + 'dashboard.spent': '已花费', + 'dashboard.actionsPerHour': '每小时操作', + 'dashboard.sse': 'SSE', + 'dashboard.websocket': 'WebSocket', + + // 聊天标签页 + 'chat.newThread': '新对话', + 'chat.toggleSidebar': '切换侧边栏', + 'chat.assistant': '助手', + 'chat.conversations': '对话列表', + 'chat.send': '发送', + 'chat.attachImages': '附加图片', + 'chat.empty': '选择文件查看内容', + 'chat.loading': '加载中...', + 'chat.loadingOlder': '加载更早的消息...', + 'chat.noFiles': '工作区没有文件', + 'chat.noResults': '没有结果', + + // 对话侧边栏 + 'thread.assistant': '助手', + 'thread.new': '新对话', + + // 记忆标签页 + 'memory.searchPlaceholder': '搜索记忆...', + 'memory.workspace': '工作区', + 'memory.edit': '编辑', + 'memory.save': '保存', + 'memory.cancel': '取消', + 'memory.selectFile': '选择文件查看内容', + + // 任务标签页 + 'jobs.summary': '任务摘要', + 'jobs.id': 'ID', + 'jobs.title': '标题', + 'jobs.source': '来源', + 'jobs.status': '状态', + 'jobs.created': '创建时间', + 'jobs.actions': '操作', + 'jobs.empty': '暂无任务', + 'jobs.statusRunning': '运行中', + 'jobs.statusCompleted': '已完成', + 'jobs.statusFailed': '失败', + 'jobs.statusPending': '等待中', + 'jobs.jobId': '任务 ID', + 'jobs.description': '描述', + 'jobs.stateTransitions': '状态转换', + 'jobs.projectFiles': '项目文件', + 'jobs.noProjectFiles': '没有项目文件', + 'jobs.viewJob': '查看任务', + 'jobs.browse': '浏览', + + // 定时任务标签页 + 'routines.summary': '定时任务摘要', + 'routines.name': '名称', + 'routines.trigger': '触发器', + 'routines.action': '操作', + 'routines.lastRun': '上次运行', + 'routines.nextRun': '下次运行', + 'routines.runs': '运行次数', + 'routines.status': '状态', + 'routines.actions': '操作', + 'routines.runsToday': '今日运行', + 'routines.empty': '暂无定时任务', + 'routines.noConfigured': '暂无配置的定时任务。请让助手创建一个。', + 'routines.triggerFailed': '触发失败: {message}', + + // 日志标签页 + 'logs.serverLevel': '服务端日志级别', + 'logs.clientLevel': '客户端日志级别', + 'logs.pause': '暂停', + 'logs.resume': '继续', + 'logs.clear': '清空', + 'logs.autoScroll': '自动滚动', + 'logs.filter': '筛选日志...', + 'logs.empty': '暂无日志', + 'logs.allLevels': '所有级别', + 'logs.error': '错误', + 'logs.warn': '警告', + 'logs.info': '信息', + 'logs.debug': '调试', + + // 扩展标签页 + 'extensions.installed': '已安装扩展', + 'extensions.available': '可用 WASM 扩展', + 'extensions.installWasm': '安装 WASM 扩展', + 'extensions.noInstalled': '没有安装扩展', + 'extensions.noAvailable': '没有其他可用的 WASM 扩展', + 'extensions.loading': '加载中...', + 'extensions.install': '安装', + 'extensions.installing': '安装中...', + 'extensions.installedSuccess': '已安装 {name}', + 'extensions.remove': '移除', + 'extensions.activate': '激活', + 'extensions.reconfigure': '重新配置', + 'extensions.tools': '工具', + 'extensions.noConfigNeeded': '{name} 不需要配置', + 'extensions.configure': '配置 {name}', + 'extensions.optional': ' (可选)', + 'extensions.autoGenerated': '留空则自动生成', + 'extensions.pendingPairing': '等待配对请求', + 'extensions.from': '来自', + + // MCP 服务器 + 'mcp.servers': 'MCP 服务器', + 'mcp.noServers': '没有可用的 MCP 服务器', + 'mcp.addCustom': '添加自定义 MCP 服务器', + 'mcp.add': '添加', + 'mcp.addedSuccess': '已添加 MCP 服务器 {name}', + + // 注册工具 + 'tools.registered': '注册工具', + 'tools.name': '名称', + 'tools.description': '描述', + 'tools.empty': '没有注册工具', + + // 技能标签页 + 'skills.installed': '已安装技能', + 'skills.noInstalled': '没有安装技能', + 'skills.searchClawHub': '搜索 ClawHub', + 'skills.searchPlaceholder': '搜索...', + 'skills.installByUrl': '通过 URL 安装技能', + 'skills.namePlaceholder': '技能名称或标识', + 'skills.urlPlaceholder': 'SKILL.md 的 HTTPS URL(可选)', + 'skills.search': '搜索', + 'skills.searching': '搜索中...', + 'skills.noResults': '没有找到 "{query}" 相关技能', + 'skills.searchFailed': '搜索失败: {message}', + 'skills.install': '安装', + 'skills.installing': '安装中...', + 'skills.installedSuccess': '已安装技能 "{name}"', + 'skills.remove': '移除', + 'skills.activatesOn': '激活关键词', + 'skills.registryError': '无法连接 ClawHub 注册表: {message}', + 'skills.by': '作者', + 'skills.updated': '更新于', + 'skills.loading': '加载技能中...', + 'skills.loadFailed': '加载技能失败: {message}', + 'skills.confirmRemove': '确定要移除技能 "{name}" 吗?', + 'skills.removeFailed': '移除失败: {message}', + 'skills.removed': '已移除技能 "{name}"', + + // 任务摘要 + 'jobs.summary.total': '总计', + 'jobs.summary.inProgress': '进行中', + 'jobs.summary.completed': '已完成', + 'jobs.summary.failed': '失败', + 'jobs.summary.stuck': '卡住', + + // 定时任务摘要 + 'routines.summary.total': '总计', + 'routines.summary.enabled': '已启用', + 'routines.summary.disabled': '已禁用', + 'routines.summary.failing': '失败', + 'routines.summary.runsToday': '今日运行', + + // 按钮 + 'btn.close': '关闭', + 'btn.cancel': '取消', + 'btn.save': '保存', + 'btn.edit': '编辑', + 'btn.confirm': '确认', + 'btn.send': '发送', + 'btn.refresh': '刷新', + 'btn.loadMore': '加载更多', + 'btn.copy': '复制', + 'btn.copied': '已复制!', + 'btn.submit': '提交', + 'btn.setup': '设置', + + // 时间 + 'time.lessThan1MinuteAgo': '刚刚', + 'time.lessThan1MinuteFromNow': '1分钟内', + 'time.minutesAgo': '{n}分钟前', + 'time.minutesFromNow': '{n}分钟后', + 'time.hoursAgo': '{n}小时前', + 'time.hoursFromNow': '{n}小时后', + 'time.daysAgo': '{n}天前', + 'time.daysFromNow': '{n}天后', + + // 工具审批 + 'approval.title': '工具需要审批', + 'approval.description': '一个工具请求运行权限。', + 'approval.approve': '批准', + 'approval.deny': '拒绝', + 'approval.always': '始终允许', + 'approval.approved': '已批准', + 'approval.alwaysApproved': '始终批准', + 'approval.denied': '已拒绝', + 'approval.showParams': '显示参数', + 'approval.hideParams': '隐藏参数', + + // 认证 + 'authRequired.title': '{name} 需要认证', + 'authRequired.authenticateWith': '使用 {name} 认证', + 'authRequired.getToken': '获取令牌', + 'authRequired.instructions': '说明', + + // 沙盒任务 + 'sandbox.job': '沙盒任务', + 'sandbox.doneSignal': '完成信号已发送', + + // 错误消息 + 'error.startConversation': '请先开始一个对话', + 'error.restartFailed': '重启失败: {message}', + 'error.tokenRequired': '请输入令牌', + 'error.tokenInvalid': '令牌无效', + 'error.connectionFailed': '连接失败', + 'error.unknown': '未知错误', + 'error.loadFailed': '加载失败: {message}', + + // 成功消息 + 'success.restartInitiated': '已开始重启', + 'success.saved': '保存成功', + + // 斜杠命令 + 'cmd.status.desc': '显示所有任务,或使用 /status 查看特定任务', + 'cmd.list.desc': '列出所有任务', + 'cmd.cancel.desc': '/cancel — 取消正在运行的任务', + 'cmd.undo.desc': '撤销上一步', + 'cmd.redo.desc': '重做已撤销的操作', + 'cmd.compact.desc': '压缩上下文窗口', + 'cmd.clear.desc': '清空对话并重新开始', + 'cmd.interrupt.desc': '停止当前操作', + 'cmd.heartbeat.desc': '触发手动心跳检查', + 'cmd.summarize.desc': '总结当前对话', + 'cmd.suggest.desc': '建议下一步操作', + 'cmd.help.desc': '显示帮助', + 'cmd.version.desc': '显示版本信息', + 'cmd.tools.desc': '列出可用工具', + 'cmd.skills.desc': '列出已安装的 AI 技能', + 'cmd.model.desc': '显示或切换 LLM 模型', + 'cmd.threadNew.desc': '创建新对话线程', + + // 语言切换 + 'language.title': '语言', + 'language.en': 'English', + 'language.zhCN': '简体中文', + 'language.switch': '切换语言', + + // 工具活动 + 'tool.thinking': '思考中...', + 'tool.completed': '已完成', + 'tool.failed': '失败', + 'tool.running': '运行中', + 'tool.used': '{count} 个工具已使用', + 'tool.requiresApproval': '工具需要审批', + + + // TEE + 'tee.loadingReport': '正在加载证明报告...', + 'tee.loadFailed': '无法加载证明报告', + + // 通用 + 'common.loading': '加载中...', + 'common.noData': '暂无数据', + 'common.search': '搜索', + 'common.add': '添加', + 'common.remove': '移除', + 'common.install': '安装', + 'common.activate': '激活', + 'common.deactivate': '停用', + 'common.configure': '配置', + 'common.save': '保存', + 'common.cancel': '取消', + 'common.confirm': '确认', + 'common.close': '关闭', + 'common.edit': '编辑', + 'common.delete': '删除', + 'common.refresh': '刷新', + 'common.searchPlaceholder': '搜索...', + 'common.name': '名称', + 'common.description': '描述', + 'common.status': '状态', + 'common.actions': '操作', + 'common.version': '版本', + 'common.owner': '作者', + 'common.tags': '标签', + + // 扩展 + 'ext.active': '已激活', + 'ext.remove': '移除', + 'ext.install': '安装', + 'ext.installing': '安装中...', + 'ext.installed': '已安装', + 'ext.setup': '设置', + 'ext.reconfigure': '重新配置', + 'ext.configure': '配置', + 'ext.confirmRemove': '确定要移除扩展 "{name}" 吗?', + 'ext.removeFailed': '移除失败: {message}', + 'ext.removed': '已移除 {name}', + 'ext.installFailed': '安装失败: {message}', + + // 配置 + 'config.title': '配置 {name}', + 'config.optional': '(可选)', + 'config.alreadySet': '(已设置 — 留空以保持不变)', + 'config.alreadyConfigured': '已配置', + 'config.autoGenerate': '如果为空则自动生成', + 'config.save': '保存', + 'config.cancel': '取消', +}); diff --git a/src/channels/web/static/index.html b/src/channels/web/static/index.html index b4a78a12..6f21b428 100644 --- a/src/channels/web/static/index.html +++ b/src/channels/web/static/index.html @@ -9,6 +9,12 @@ + + + + + + + diff --git a/src/channels/web/static/style.css b/src/channels/web/static/style.css index 1536f9e9..a0985ce3 100644 --- a/src/channels/web/static/style.css +++ b/src/channels/web/static/style.css @@ -3885,6 +3885,61 @@ mark { display: block; } +/* Language Switcher */ +.language-switcher { + position: relative; + display: flex; + align-items: center; +} + +.language-btn { + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 8px; + font-size: 16px; + border-radius: var(--radius); + transition: all 0.2s; +} + +.language-btn:hover { + color: var(--text); + background: var(--bg-tertiary); +} + +.language-menu { + position: absolute; + top: 100%; + right: 0; + margin-top: 4px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 4px; + min-width: 120px; + z-index: 1000; + box-shadow: var(--shadow); +} + +.language-option { + padding: 8px 12px; + cursor: pointer; + border-radius: var(--radius); + color: var(--text); + font-size: 13px; + transition: all 0.2s; +} + +.language-option:hover { + background: var(--bg-tertiary); +} + +.language-option.active { + background: var(--accent); + color: var(--bg); +} + .generated-image-path { font-size: 12px; color: var(--text-secondary); From 6a1301bc5bcf5cda9055fb4d4e77acf02943381c Mon Sep 17 00:00:00 2001 From: "ironclaw-ci[bot]" <266877842+ironclaw-ci[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:09:44 +0000 Subject: [PATCH 4/8] feat(i18n): Add internationalization support with Chinese and English translations (#929) (#950) * feat(i18n): Add internationalization support with Chinese and English translations * fix(i18n): fix duplicate keys, broken placeholders, and dead overrides --------- Co-authored-by: jinxin <106428113+italic-jinxin@users.noreply.github.com> Co-authored-by: zwb1982 <133180666+zwb1982@users.noreply.github.com> --- README.zh-CN.md | 2 +- src/channels/web/server.rs | 46 +++- src/channels/web/static/app.js | 195 +++++++------- src/channels/web/static/i18n-app.js | 74 ++++++ src/channels/web/static/i18n/en.js | 351 ++++++++++++++++++++++++++ src/channels/web/static/i18n/index.js | 89 +++++++ src/channels/web/static/i18n/zh-CN.js | 351 ++++++++++++++++++++++++++ src/channels/web/static/index.html | 195 +++++++------- src/channels/web/static/style.css | 55 ++++ 9 files changed, 1174 insertions(+), 184 deletions(-) create mode 100644 src/channels/web/static/i18n-app.js create mode 100644 src/channels/web/static/i18n/en.js create mode 100644 src/channels/web/static/i18n/index.js create mode 100644 src/channels/web/static/i18n/zh-CN.js diff --git a/README.zh-CN.md b/README.zh-CN.md index 97bbf097..179614ac 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -229,7 +229,7 @@ WASM ──► 白名单 ──► 泄露扫描 ──► 凭据 ──► 执 │ │ │ │ │ ┌──────────▼────┐ ┌──▼───────────────┐ │ │ │ 调度器 │ │ 定时任务引擎 │ │ -│ │ (并行任务) │ │(cron, 事件, wh) │ │ +│ │ (并行任务) │ │(cron, 事件, Webhook)│ │ │ └──────┬────────┘ └────────┬─────────┘ │ │ │ │ │ │ ┌─────────────┼────────────────────┘ │ diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 48d3407c..825685b5 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -318,7 +318,11 @@ pub async fn start_server( .route("/", get(index_handler)) .route("/style.css", get(css_handler)) .route("/app.js", get(js_handler)) - .route("/favicon.ico", get(favicon_handler)); + .route("/favicon.ico", get(favicon_handler)) + .route("/i18n/index.js", get(i18n_index_handler)) + .route("/i18n/en.js", get(i18n_en_handler)) + .route("/i18n/zh-CN.js", get(i18n_zh_handler)) + .route("/i18n-app.js", get(i18n_app_handler)); // Project file serving (behind auth to prevent unauthorized file access). let projects = Router::new() @@ -430,6 +434,46 @@ async fn favicon_handler() -> impl IntoResponse { ) } +async fn i18n_index_handler() -> impl IntoResponse { + ( + [ + (header::CONTENT_TYPE, "application/javascript"), + (header::CACHE_CONTROL, "no-cache"), + ], + include_str!("static/i18n/index.js"), + ) +} + +async fn i18n_en_handler() -> impl IntoResponse { + ( + [ + (header::CONTENT_TYPE, "application/javascript"), + (header::CACHE_CONTROL, "no-cache"), + ], + include_str!("static/i18n/en.js"), + ) +} + +async fn i18n_zh_handler() -> impl IntoResponse { + ( + [ + (header::CONTENT_TYPE, "application/javascript"), + (header::CACHE_CONTROL, "no-cache"), + ], + include_str!("static/i18n/zh-CN.js"), + ) +} + +async fn i18n_app_handler() -> impl IntoResponse { + ( + [ + (header::CONTENT_TYPE, "application/javascript"), + (header::CACHE_CONTROL, "no-cache"), + ], + include_str!("static/i18n-app.js"), + ) +} + // --- Health --- async fn health_handler() -> Json { diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index 42ce6ba2..7ca9a25b 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -55,7 +55,7 @@ let _activityThinking = null; function authenticate() { token = document.getElementById('token-input').value.trim(); if (!token) { - document.getElementById('auth-error').textContent = 'Token required'; + document.getElementById('auth-error').textContent = I18n.t('auth.errorRequired'); return; } @@ -89,7 +89,7 @@ function authenticate() { sessionStorage.removeItem('ironclaw_token'); document.getElementById('auth-screen').style.display = ''; document.getElementById('app').style.display = 'none'; - document.getElementById('auth-error').textContent = 'Invalid token'; + document.getElementById('auth-error').textContent = I18n.t('auth.errorInvalid'); }); } @@ -144,7 +144,7 @@ let restartEnabled = false; // Track if restart is available in this deployment function triggerRestart() { if (!currentThreadId) { - alert('Please start a conversation first'); + alert(I18n.t('error.startConversation')); return; } @@ -155,7 +155,7 @@ function triggerRestart() { function confirmRestart() { if (!currentThreadId) { - alert('Please start a conversation first'); + alert(I18n.t('error.startConversation')); return; } @@ -190,7 +190,7 @@ function confirmRestart() { }) .catch((err) => { console.error('[confirmRestart] Restart request failed:', err); - addMessage('system', 'Restart failed: ' + err.message); + addMessage('system', I18n.t('error.restartFailed', { message: err.message })); isRestarting = false; restartBtn.disabled = false; if (restartIcon) restartIcon.classList.remove('spinning'); @@ -234,7 +234,7 @@ function connectSSE() { eventSource.onopen = () => { document.getElementById('sse-dot').classList.remove('disconnected'); - document.getElementById('sse-status').textContent = 'Connected'; + document.getElementById('sse-status').textContent = I18n.t('status.connected'); // If we were restarting, close the modal and reset button now that server is back if (isRestarting) { @@ -256,7 +256,7 @@ function connectSSE() { eventSource.onerror = () => { document.getElementById('sse-dot').classList.add('disconnected'); - document.getElementById('sse-status').textContent = 'Reconnecting...'; + document.getElementById('sse-status').textContent = I18n.t('status.reconnecting'); }; eventSource.addEventListener('response', (e) => { @@ -464,7 +464,7 @@ function enableChatInput() { const btn = document.getElementById('send-btn'); if (input) { input.disabled = false; - input.placeholder = 'Message or / for commands...'; + input.placeholder = I18n.t('chat.inputPlaceholder'); } if (btn) btn.disabled = false; } @@ -703,8 +703,8 @@ function copyCodeBlock(btn) { const code = pre.querySelector('code'); const text = code ? code.textContent : pre.textContent; navigator.clipboard.writeText(text).then(() => { - btn.textContent = 'Copied!'; - setTimeout(() => { btn.textContent = 'Copy'; }, 1500); + btn.textContent = I18n.t('btn.copied'); + setTimeout(() => { btn.textContent = I18n.t('btn.copy'); }, 1500); }); } @@ -991,7 +991,7 @@ function showApproval(data) { const header = document.createElement('div'); header.className = 'approval-header'; - header.textContent = 'Tool requires approval'; + header.textContent = I18n.t('approval.title'); card.appendChild(header); const toolName = document.createElement('div'); @@ -1009,7 +1009,7 @@ function showApproval(data) { if (data.parameters) { const paramsToggle = document.createElement('button'); paramsToggle.className = 'approval-params-toggle'; - paramsToggle.textContent = 'Show parameters'; + paramsToggle.textContent = I18n.t('approval.showParams'); const paramsBlock = document.createElement('pre'); paramsBlock.className = 'approval-params'; paramsBlock.textContent = data.parameters; @@ -1017,7 +1017,7 @@ function showApproval(data) { paramsToggle.addEventListener('click', () => { const visible = paramsBlock.style.display !== 'none'; paramsBlock.style.display = visible ? 'none' : 'block'; - paramsToggle.textContent = visible ? 'Show parameters' : 'Hide parameters'; + paramsToggle.textContent = visible ? I18n.t('approval.showParams') : I18n.t('approval.hideParams'); }); card.appendChild(paramsToggle); card.appendChild(paramsBlock); @@ -1028,17 +1028,17 @@ function showApproval(data) { const approveBtn = document.createElement('button'); approveBtn.className = 'approve'; - approveBtn.textContent = 'Approve'; + approveBtn.textContent = I18n.t('approval.approve'); approveBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'approve')); const alwaysBtn = document.createElement('button'); alwaysBtn.className = 'always'; - alwaysBtn.textContent = 'Always'; + alwaysBtn.textContent = I18n.t('approval.always'); alwaysBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'always')); const denyBtn = document.createElement('button'); denyBtn.className = 'deny'; - denyBtn.textContent = 'Deny'; + denyBtn.textContent = I18n.t('approval.deny'); denyBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'deny')); actions.appendChild(approveBtn); @@ -1065,7 +1065,7 @@ function showJobCard(data) { const title = document.createElement('div'); title.className = 'job-card-title'; - title.textContent = data.title || 'Sandbox Job'; + title.textContent = data.title || I18n.t('sandbox.job'); info.appendChild(title); const id = document.createElement('div'); @@ -1077,7 +1077,7 @@ function showJobCard(data) { const viewBtn = document.createElement('button'); viewBtn.className = 'job-card-view'; - viewBtn.textContent = 'View Job'; + viewBtn.textContent = I18n.t('jobs.viewJob'); viewBtn.addEventListener('click', () => { switchTab('jobs'); openJobDetail(data.job_id); @@ -1089,7 +1089,7 @@ function showJobCard(data) { browseBtn.className = 'job-card-browse'; browseBtn.href = data.browse_url; browseBtn.target = '_blank'; - browseBtn.textContent = 'Browse'; + browseBtn.textContent = I18n.t('jobs.browse'); card.appendChild(browseBtn); } @@ -1110,7 +1110,7 @@ function showAuthCard(data) { const header = document.createElement('div'); header.className = 'auth-header'; - header.textContent = 'Authentication required for ' + data.extension_name; + header.textContent = I18n.t('authRequired.title', {name: data.extension_name}); card.appendChild(header); if (data.instructions) { @@ -1126,7 +1126,7 @@ function showAuthCard(data) { if (data.auth_url) { const oauthBtn = document.createElement('button'); oauthBtn.className = 'auth-oauth'; - oauthBtn.textContent = 'Authenticate with ' + data.extension_name; + oauthBtn.textContent = I18n.t('authRequired.authenticateWith', {name: data.extension_name}); oauthBtn.addEventListener('click', () => { openOAuthUrl(data.auth_url); }); @@ -1137,7 +1137,7 @@ function showAuthCard(data) { const setupLink = document.createElement('a'); setupLink.href = data.setup_url; setupLink.target = '_blank'; - setupLink.textContent = 'Get your token'; + setupLink.textContent = I18n.t('authRequired.getToken'); links.appendChild(setupLink); } @@ -1151,7 +1151,9 @@ function showAuthCard(data) { const tokenInput = document.createElement('input'); tokenInput.type = 'password'; - tokenInput.placeholder = data.instructions || 'Paste your API key or token'; + tokenInput.placeholder = data.instructions + || I18n.t('auth.extensionTokenPlaceholder') + || I18n.t('auth.tokenPlaceholder'); tokenInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') submitAuthToken(data.extension_name, tokenInput.value); }); @@ -1170,12 +1172,12 @@ function showAuthCard(data) { const submitBtn = document.createElement('button'); submitBtn.className = 'auth-submit'; - submitBtn.textContent = 'Submit'; + submitBtn.textContent = I18n.t('btn.submit'); submitBtn.addEventListener('click', () => submitAuthToken(data.extension_name, tokenInput.value)); const cancelBtn = document.createElement('button'); cancelBtn.className = 'auth-cancel'; - cancelBtn.textContent = 'Cancel'; + cancelBtn.textContent = I18n.t('btn.cancel'); cancelBtn.addEventListener('click', () => cancelAuth(data.extension_name)); actions.appendChild(submitBtn); @@ -1967,7 +1969,7 @@ function prependLogEntry(entry) { function toggleLogsPause() { logsPaused = !logsPaused; const btn = document.getElementById('logs-pause-btn'); - btn.textContent = logsPaused ? 'Resume' : 'Pause'; + btn.textContent = logsPaused ? I18n.t('logs.resume') : I18n.t('logs.pause'); if (!logsPaused) { // Flush buffer: oldest-first + prepend naturally puts newest at top @@ -2039,7 +2041,7 @@ function loadExtensions() { ]).then(([extData, toolData, registryData]) => { // Render installed extensions if (extData.extensions.length === 0) { - extList.innerHTML = '
No extensions installed
'; + extList.innerHTML = '
' + I18n.t('extensions.noInstalled') + '
'; } else { extList.innerHTML = ''; for (const ext of extData.extensions) { @@ -2053,7 +2055,7 @@ function loadExtensions() { // Available WASM extensions if (wasmEntries.length === 0) { - wasmList.innerHTML = '
No additional WASM extensions available
'; + wasmList.innerHTML = '
' + I18n.t('extensions.noAvailable') + '
'; } else { wasmList.innerHTML = ''; for (const entry of wasmEntries) { @@ -2063,7 +2065,7 @@ function loadExtensions() { // MCP servers (show both installed and uninstalled) if (mcpEntries.length === 0) { - mcpList.innerHTML = '
No MCP servers available
'; + mcpList.innerHTML = '
' + I18n.t('mcp.noServers') + '
'; } else { mcpList.innerHTML = ''; for (const entry of mcpEntries) { @@ -2128,16 +2130,16 @@ function renderAvailableExtensionCard(entry) { const installBtn = document.createElement('button'); installBtn.className = 'btn-ext install'; - installBtn.textContent = 'Install'; + installBtn.textContent = I18n.t('extensions.install'); installBtn.addEventListener('click', function() { installBtn.disabled = true; - installBtn.textContent = 'Installing...'; + installBtn.textContent = I18n.t('extensions.installing'); apiFetch('/api/extensions/install', { method: 'POST', body: { name: entry.name, kind: entry.kind }, }).then(function(res) { if (res.success) { - showToast('Installed ' + entry.display_name, 'success'); + showToast(I18n.t('extensions.installedSuccess', {name: entry.display_name}), 'success'); // OAuth popup if auth started during install (builtin creds) if (res.auth_url) { showToast('Opening authentication for ' + entry.display_name, 'info'); @@ -2201,39 +2203,39 @@ function renderMcpServerCard(entry, installedExt) { if (!installedExt.active) { var activateBtn = document.createElement('button'); activateBtn.className = 'btn-ext activate'; - activateBtn.textContent = 'Activate'; + activateBtn.textContent = I18n.t('common.activate'); activateBtn.addEventListener('click', function() { activateExtension(installedExt.name); }); actions.appendChild(activateBtn); } else { var activeLabel = document.createElement('span'); activeLabel.className = 'ext-active-label'; - activeLabel.textContent = 'Active'; + activeLabel.textContent = I18n.t('ext.active'); actions.appendChild(activeLabel); } var removeBtn = document.createElement('button'); removeBtn.className = 'btn-ext remove'; - removeBtn.textContent = 'Remove'; + removeBtn.textContent = I18n.t('ext.remove'); removeBtn.addEventListener('click', function() { removeExtension(installedExt.name); }); actions.appendChild(removeBtn); } else { var installBtn = document.createElement('button'); installBtn.className = 'btn-ext install'; - installBtn.textContent = 'Install'; + installBtn.textContent = I18n.t('ext.install'); installBtn.addEventListener('click', function() { installBtn.disabled = true; - installBtn.textContent = 'Installing...'; + installBtn.textContent = I18n.t('ext.installing'); apiFetch('/api/extensions/install', { method: 'POST', body: { name: entry.name, kind: entry.kind }, }).then(function(res) { if (res.success) { - showToast('Installed ' + entry.display_name, 'success'); + showToast(I18n.t('extensions.installedSuccess', { name: entry.display_name }), 'success'); } else { - showToast('Install: ' + (res.message || 'unknown error'), 'error'); + showToast(I18n.t('ext.install') + ': ' + (res.message || 'unknown error'), 'error'); } loadExtensions(); }).catch(function(err) { - showToast('Install failed: ' + err.message, 'error'); + showToast(I18n.t('ext.installFailed', { message: err.message }), 'error'); loadExtensions(); }); }); @@ -2247,7 +2249,7 @@ function renderMcpServerCard(entry, installedExt) { function createReconfigureButton(extName) { var btn = document.createElement('button'); btn.className = 'btn-ext configure'; - btn.textContent = 'Reconfigure'; + btn.textContent = I18n.t('ext.reconfigure'); btn.addEventListener('click', function() { showConfigureModal(extName); }); return btn; } @@ -2331,13 +2333,13 @@ function renderExtensionCard(ext) { if (status === 'active') { var activeLabel = document.createElement('span'); activeLabel.className = 'ext-active-label'; - activeLabel.textContent = 'Active'; + activeLabel.textContent = I18n.t('ext.active'); actions.appendChild(activeLabel); actions.appendChild(createReconfigureButton(ext.name)); } else if (status === 'pairing') { var pairingLabel = document.createElement('span'); pairingLabel.className = 'ext-pairing-label'; - pairingLabel.textContent = 'Awaiting Pairing'; + pairingLabel.textContent = I18n.t('status.awaitingPairing'); actions.appendChild(pairingLabel); actions.appendChild(createReconfigureButton(ext.name)); } else if (status === 'failed') { @@ -2346,7 +2348,7 @@ function renderExtensionCard(ext) { // installed or configured: show Setup button var setupBtn = document.createElement('button'); setupBtn.className = 'btn-ext configure'; - setupBtn.textContent = 'Setup'; + setupBtn.textContent = I18n.t('ext.setup'); setupBtn.addEventListener('click', function() { showConfigureModal(ext.name); }); actions.appendChild(setupBtn); } @@ -2354,14 +2356,14 @@ function renderExtensionCard(ext) { // WASM tools / MCP servers const activeLabel = document.createElement('span'); activeLabel.className = 'ext-active-label'; - activeLabel.textContent = ext.active ? 'Active' : 'Installed'; + activeLabel.textContent = ext.active ? I18n.t('ext.active') : I18n.t('status.installed'); actions.appendChild(activeLabel); // MCP servers and channel-relay extensions may be installed but inactive — show Activate button if ((ext.kind === 'mcp_server' || ext.kind === 'channel_relay') && !ext.active) { const activateBtn = document.createElement('button'); activateBtn.className = 'btn-ext activate'; - activateBtn.textContent = 'Activate'; + activateBtn.textContent = I18n.t('common.activate'); activateBtn.addEventListener('click', () => activateExtension(ext.name)); actions.appendChild(activateBtn); } @@ -2373,7 +2375,7 @@ function renderExtensionCard(ext) { if (ext.needs_setup || (ext.has_auth && ext.authenticated)) { const configBtn = document.createElement('button'); configBtn.className = 'btn-ext configure'; - configBtn.textContent = ext.authenticated ? 'Reconfigure' : 'Configure'; + configBtn.textContent = ext.authenticated ? I18n.t('ext.reconfigure') : I18n.t('ext.configure'); configBtn.addEventListener('click', () => showConfigureModal(ext.name)); actions.appendChild(configBtn); } @@ -2381,7 +2383,7 @@ function renderExtensionCard(ext) { const removeBtn = document.createElement('button'); removeBtn.className = 'btn-ext remove'; - removeBtn.textContent = 'Remove'; + removeBtn.textContent = I18n.t('ext.remove'); removeBtn.addEventListener('click', () => removeExtension(ext.name)); actions.appendChild(removeBtn); @@ -2426,17 +2428,17 @@ function activateExtension(name) { } function removeExtension(name) { - if (!confirm('Remove extension "' + name + '"?')) return; + if (!confirm(I18n.t('ext.confirmRemove', { name: name }))) return; apiFetch('/api/extensions/' + encodeURIComponent(name) + '/remove', { method: 'POST' }) .then((res) => { if (!res.success) { - showToast('Remove failed: ' + res.message, 'error'); + showToast(I18n.t('ext.removeFailed', { message: res.message }), 'error'); } else { - showToast('Removed ' + name, 'success'); + showToast(I18n.t('ext.removed', { name: name }), 'success'); } loadExtensions(); }) - .catch((err) => showToast('Remove failed: ' + err.message, 'error')); + .catch((err) => showToast(I18n.t('ext.removeFailed', { message: err.message }), 'error')); } function showConfigureModal(name) { @@ -2463,7 +2465,7 @@ function renderConfigureModal(name, secrets) { modal.className = 'configure-modal'; const header = document.createElement('h3'); - header.textContent = 'Configure ' + name; + header.textContent = I18n.t('config.title', { name: name }); modal.appendChild(header); const form = document.createElement('div'); @@ -2479,7 +2481,7 @@ function renderConfigureModal(name, secrets) { if (secret.optional) { const opt = document.createElement('span'); opt.className = 'field-optional'; - opt.textContent = ' (optional)'; + opt.textContent = I18n.t('config.optional'); label.appendChild(opt); } field.appendChild(label); @@ -2490,7 +2492,7 @@ function renderConfigureModal(name, secrets) { const input = document.createElement('input'); input.type = 'password'; input.name = secret.name; - input.placeholder = secret.provided ? '(already set — leave empty to keep)' : ''; + input.placeholder = secret.provided ? I18n.t('config.alreadySet') : ''; input.addEventListener('keydown', (e) => { if (e.key === 'Enter') submitConfigureModal(name, fields); }); @@ -2500,13 +2502,13 @@ function renderConfigureModal(name, secrets) { const badge = document.createElement('span'); badge.className = 'field-provided'; badge.textContent = '\u2713'; - badge.title = 'Already configured'; + badge.title = I18n.t('config.alreadyConfigured'); inputRow.appendChild(badge); } if (secret.auto_generate && !secret.provided) { const hint = document.createElement('span'); hint.className = 'field-autogen'; - hint.textContent = 'Auto-generated if empty'; + hint.textContent = I18n.t('config.autoGenerate'); inputRow.appendChild(hint); } @@ -2522,13 +2524,13 @@ function renderConfigureModal(name, secrets) { const submitBtn = document.createElement('button'); submitBtn.className = 'btn-ext activate'; - submitBtn.textContent = 'Save'; + submitBtn.textContent = I18n.t('config.save'); submitBtn.addEventListener('click', () => submitConfigureModal(name, fields)); actions.appendChild(submitBtn); const cancelBtn = document.createElement('button'); cancelBtn.className = 'btn-ext remove'; - cancelBtn.textContent = 'Cancel'; + cancelBtn.textContent = I18n.t('config.cancel'); cancelBtn.addEventListener('click', closeConfigureModal); actions.appendChild(cancelBtn); @@ -2768,11 +2770,11 @@ function loadJobs() { function renderJobsSummary(s) { document.getElementById('jobs-summary').innerHTML = '' - + summaryCard('Total', s.total, '') - + summaryCard('In Progress', s.in_progress, 'active') - + summaryCard('Completed', s.completed, 'completed') - + summaryCard('Failed', s.failed, 'failed') - + summaryCard('Stuck', s.stuck, 'stuck'); + + summaryCard(I18n.t('jobs.summary.total'), s.total, '') + + summaryCard(I18n.t('jobs.summary.inProgress'), s.in_progress, 'active') + + summaryCard(I18n.t('jobs.summary.completed'), s.completed, 'completed') + + summaryCard(I18n.t('jobs.summary.failed'), s.failed, 'failed') + + summaryCard(I18n.t('jobs.summary.stuck'), s.stuck, 'stuck'); } function summaryCard(label, count, cls) { @@ -3302,11 +3304,11 @@ function loadRoutines() { function renderRoutinesSummary(s) { document.getElementById('routines-summary').innerHTML = '' - + summaryCard('Total', s.total, '') - + summaryCard('Enabled', s.enabled, 'active') - + summaryCard('Disabled', s.disabled, '') - + summaryCard('Failing', s.failing, 'failed') - + summaryCard('Runs Today', s.runs_today, 'completed'); + + summaryCard(I18n.t('routines.summary.total'), s.total, '') + + summaryCard(I18n.t('routines.summary.enabled'), s.enabled, 'active') + + summaryCard(I18n.t('routines.summary.disabled'), s.disabled, '') + + summaryCard(I18n.t('routines.summary.failing'), s.failing, 'failed') + + summaryCard(I18n.t('routines.summary.runsToday'), s.runs_today, 'completed'); } function renderRoutinesList(routines) { @@ -3472,17 +3474,18 @@ function formatRelativeTime(isoString) { const absDiff = Math.abs(diffMs); const future = diffMs < 0; - if (absDiff < 60000) return future ? 'in <1m' : '<1m ago'; + if (absDiff < 60000) + return future ? I18n.t('time.lessThan1MinuteFromNow') : I18n.t('time.lessThan1MinuteAgo'); if (absDiff < 3600000) { const m = Math.floor(absDiff / 60000); - return future ? 'in ' + m + 'm' : m + 'm ago'; + return future ? I18n.t('time.minutesFromNow', { n: m }) : I18n.t('time.minutesAgo', { n: m }); } if (absDiff < 86400000) { const h = Math.floor(absDiff / 3600000); - return future ? 'in ' + h + 'h' : h + 'h ago'; + return future ? I18n.t('time.hoursFromNow', { n: h }) : I18n.t('time.hoursAgo', { n: h }); } const days = Math.floor(absDiff / 86400000); - return future ? 'in ' + days + 'd' : days + 'd ago'; + return future ? I18n.t('time.daysFromNow', { n: days }) : I18n.t('time.daysAgo', { n: days }); } // --- Gateway status widget --- @@ -3532,18 +3535,18 @@ function fetchGatewayStatus() { } // Connection info - html += ''; - html += '
SSE' + (data.sse_connections || 0) + '
'; - html += '
WebSocket' + (data.ws_connections || 0) + '
'; - html += '
Uptime' + formatDuration(data.uptime_secs) + '
'; + html += ''; + html += '
' + I18n.t('dashboard.sse') + '' + (data.sse_connections || 0) + '
'; + html += '
' + I18n.t('dashboard.websocket') + '' + (data.ws_connections || 0) + '
'; + html += '
' + I18n.t('dashboard.uptime') + '' + formatDuration(data.uptime_secs) + '
'; // Cost tracker if (data.daily_cost != null) { html += '
'; - html += ''; - html += '
Spent' + formatCost(data.daily_cost) + '
'; + html += ''; + html += '
' + I18n.t('dashboard.spent') + '' + formatCost(data.daily_cost) + '
'; if (data.actions_this_hour != null) { - html += '
Actions/hr' + data.actions_this_hour + '
'; + html += '
' + I18n.t('dashboard.actionsPerHour') + '' + data.actions_this_hour + '
'; } } @@ -3751,7 +3754,7 @@ function loadSkills() { var skillsList = document.getElementById('skills-list'); apiFetch('/api/skills').then(function(data) { if (!data.skills || data.skills.length === 0) { - skillsList.innerHTML = '
No skills installed
'; + skillsList.innerHTML = '
' + I18n.t('skills.noInstalled') + '
'; return; } skillsList.innerHTML = ''; @@ -3759,7 +3762,7 @@ function loadSkills() { skillsList.appendChild(renderSkillCard(data.skills[i])); } }).catch(function(err) { - skillsList.innerHTML = '
Failed to load skills: ' + escapeHtml(err.message) + '
'; + skillsList.innerHTML = '
' + I18n.t('skills.loadFailed', {message: escapeHtml(err.message)}) + '
'; }); } @@ -3796,7 +3799,7 @@ function renderSkillCard(skill) { if (skill.keywords && skill.keywords.length > 0) { var kw = document.createElement('div'); kw.className = 'ext-keywords'; - kw.textContent = 'Activates on: ' + skill.keywords.join(', '); + kw.textContent = I18n.t('skills.activatesOn') + ': ' + skill.keywords.join(', '); card.appendChild(kw); } @@ -3807,7 +3810,7 @@ function renderSkillCard(skill) { if (skill.trust.toLowerCase() !== 'trusted') { var removeBtn = document.createElement('button'); removeBtn.className = 'btn-ext remove'; - removeBtn.textContent = 'Remove'; + removeBtn.textContent = I18n.t('skills.remove'); removeBtn.addEventListener('click', function() { removeSkill(skill.name); }); actions.appendChild(removeBtn); } @@ -3822,7 +3825,7 @@ function searchClawHub() { if (!query) return; var resultsDiv = document.getElementById('skill-search-results'); - resultsDiv.innerHTML = '
Searching...
'; + resultsDiv.innerHTML = '
' + I18n.t('skills.searching') + '
'; apiFetch('/api/skills/search', { method: 'POST', @@ -3838,7 +3841,7 @@ function searchClawHub() { warning.style.borderLeft = '3px solid #f0ad4e'; warning.style.paddingLeft = '12px'; warning.style.marginBottom = '16px'; - warning.textContent = 'Could not reach ClawHub registry: ' + data.catalog_error; + warning.textContent = I18n.t('skills.registryError', {message: data.catalog_error}); resultsDiv.appendChild(warning); } @@ -3870,10 +3873,10 @@ function searchClawHub() { } if (resultsDiv.children.length === 0) { - resultsDiv.innerHTML = '
No skills found for "' + escapeHtml(query) + '"
'; + resultsDiv.innerHTML = '
' + I18n.t('skills.noResults', {query: escapeHtml(query)}) + '
'; } }).catch(function(err) { - resultsDiv.innerHTML = '
Search failed: ' + escapeHtml(err.message) + '
'; + resultsDiv.innerHTML = '
' + I18n.t('skills.searchFailed', {message: escapeHtml(err.message)}) + '
'; }); } @@ -3967,17 +3970,17 @@ function renderCatalogSkillCard(entry, installedNames) { if (isInstalled) { var label = document.createElement('span'); label.className = 'ext-active-label'; - label.textContent = 'Installed'; + label.textContent = I18n.t('status.installed'); actions.appendChild(label); } else { var installBtn = document.createElement('button'); installBtn.className = 'btn-ext install'; - installBtn.textContent = 'Install'; + installBtn.textContent = I18n.t('extensions.install'); installBtn.addEventListener('click', (function(s, btn) { return function() { if (!confirm('Install skill "' + s + '" from ClawHub?')) return; btn.disabled = true; - btn.textContent = 'Installing...'; + btn.textContent = I18n.t('extensions.installing'); installSkill(s, null, btn); }; })(slug, installBtn)); @@ -4019,7 +4022,7 @@ function installSkill(nameOrSlug, url, btn) { body: body, }).then(function(res) { if (res.success) { - showToast('Installed skill "' + nameOrSlug + '"', 'success'); + showToast(I18n.t('skills.installedSuccess', {name: nameOrSlug}), 'success'); } else { showToast('Install failed: ' + (res.message || 'unknown error'), 'error'); } @@ -4032,19 +4035,19 @@ function installSkill(nameOrSlug, url, btn) { } function removeSkill(name) { - if (!confirm('Remove skill "' + name + '"?')) return; + if (!confirm(I18n.t('skills.confirmRemove', { name: name }))) return; apiFetch('/api/skills/' + encodeURIComponent(name), { method: 'DELETE', headers: { 'X-Confirm-Action': 'true' }, }).then(function(res) { if (res.success) { - showToast('Removed skill "' + name + '"', 'success'); + showToast(I18n.t('skills.removed', { name: name }), 'success'); } else { - showToast('Remove failed: ' + (res.message || 'unknown error'), 'error'); + showToast(I18n.t('skills.removeFailed', { message: res.message || 'unknown error' }), 'error'); } loadSkills(); }).catch(function(err) { - showToast('Remove failed: ' + err.message, 'error'); + showToast(I18n.t('skills.removeFailed', { message: err.message }), 'error'); }); } diff --git a/src/channels/web/static/i18n-app.js b/src/channels/web/static/i18n-app.js new file mode 100644 index 00000000..87624b96 --- /dev/null +++ b/src/channels/web/static/i18n-app.js @@ -0,0 +1,74 @@ +// i18n Integration for IronClaw App +// This file contains i18n-related functions that extend app.js + +// Initialize i18n when DOM is ready +document.addEventListener('DOMContentLoaded', () => { + // Initialize i18n + I18n.init(); + I18n.updatePageContent(); + updateSlashCommands(); + updateLanguageMenu(); +}); + +// Update slash commands with current language +function updateSlashCommands() { + // Update SLASH_COMMANDS descriptions + SLASH_COMMANDS.forEach(cmd => { + const key = 'cmd.' + cmd.cmd.replace(/\s+/g, '').replace(/\//g, '') + '.desc'; + const translated = I18n.t(key); + if (translated !== key) { + cmd.desc = translated; + } + }); +} + +// Toggle language menu +function toggleLanguageMenu() { + const menu = document.getElementById('language-menu'); + if (menu) { + menu.style.display = menu.style.display === 'none' ? 'block' : 'none'; + } +} + +// Switch language +function switchLanguage(lang) { + if (I18n.setLanguage(lang)) { + // Update slash commands + updateSlashCommands(); + + // Update language menu active state + updateLanguageMenu(); + + // Close menu + const menu = document.getElementById('language-menu'); + if (menu) { + menu.style.display = 'none'; + } + + // Show toast notification + showToast(I18n.t('language.switch') + ': ' + (lang === 'zh-CN' ? '简体中文' : 'English')); + } +} + +// Update language menu active state +function updateLanguageMenu() { + const currentLang = I18n.getCurrentLang(); + document.querySelectorAll('.language-option').forEach(option => { + if (option.getAttribute('data-lang') === currentLang) { + option.classList.add('active'); + } else { + option.classList.remove('active'); + } + }); +} + +// Close language menu when clicking outside +document.addEventListener('click', (e) => { + if (!e.target.closest('.language-switcher')) { + const menu = document.getElementById('language-menu'); + if (menu) { + menu.style.display = 'none'; + } + } +}); + diff --git a/src/channels/web/static/i18n/en.js b/src/channels/web/static/i18n/en.js new file mode 100644 index 00000000..b637f144 --- /dev/null +++ b/src/channels/web/static/i18n/en.js @@ -0,0 +1,351 @@ +// English Language Pack for IronClaw + +I18n.register('en', { + // Auth Page + 'auth.title': 'IronClaw', + 'auth.tagline': 'Secure AI Assistant', + 'auth.tokenLabel': 'Gateway Token', + 'auth.tokenPlaceholder': 'Paste your token', + 'auth.connect': 'Connect', + 'auth.errorRequired': 'Token required', + 'auth.errorInvalid': 'Invalid token', + 'auth.hint': 'Enter the GATEWAY_AUTH_TOKEN from your .env file', + + // Chat + 'chat.inputPlaceholder': 'Message or / for commands...', + + // Restart Modal + 'restart.title': 'Restart IronClaw Instance', + 'restart.description': 'Are you sure you want to restart IronClaw? This will gracefully restart the process.', + 'restart.warning': 'Running tasks may be interrupted. Restart will complete in a few seconds.', + 'restart.cancel': 'Cancel', + 'restart.confirm': 'Confirm Restart', + 'restart.progressTitle': 'Restarting IronClaw', + 'restart.progressSubtitle': 'Please wait for the process to restart...', + 'restart.checkLogs': 'Check the Logs tab for details after restart completes.', + + // Tabs + 'tab.chat': 'Chat', + 'tab.memory': 'Memory', + 'tab.jobs': 'Jobs', + 'tab.routines': 'Routines', + 'tab.extensions': 'Extensions', + 'tab.skills': 'Skills', + 'tab.logs': 'Logs', + + // Status + 'status.connected': 'Connected', + 'status.disconnected': 'Disconnected', + 'status.connecting': 'Connecting...', + 'status.reconnecting': 'Reconnecting...', + 'status.teeVerified': 'TEE Verified', + 'status.restart': 'Restart', + 'status.active': 'Active', + 'status.installed': 'Installed', + 'status.awaitingPairing': 'Awaiting Pairing', + + // Dashboard + 'dashboard.connections': 'Connections', + 'dashboard.uptime': 'Uptime', + 'dashboard.costToday': 'Cost Today', + 'dashboard.spent': 'Spent', + 'dashboard.actionsPerHour': 'Actions/hr', + 'dashboard.sse': 'SSE', + 'dashboard.websocket': 'WebSocket', + + // Chat Tab + 'chat.newThread': 'New Thread', + 'chat.toggleSidebar': 'Toggle Sidebar', + 'chat.assistant': 'Assistant', + 'chat.conversations': 'Conversations', + 'chat.send': 'Send', + 'chat.attachImages': 'Attach Images', + 'chat.empty': 'Select a file to view content', + 'chat.loading': 'Loading...', + 'chat.loadingOlder': 'Loading older messages...', + 'chat.noFiles': 'No files in workspace', + 'chat.noResults': 'No results', + + // Thread Sidebar + 'thread.assistant': 'Assistant', + 'thread.new': 'New Thread', + + // Memory Tab + 'memory.searchPlaceholder': 'Search memory...', + 'memory.workspace': 'workspace', + 'memory.edit': 'Edit', + 'memory.save': 'Save', + 'memory.cancel': 'Cancel', + 'memory.selectFile': 'Select a file to view content', + + // Jobs Tab + 'jobs.summary': 'Jobs Summary', + 'jobs.id': 'ID', + 'jobs.title': 'Title', + 'jobs.source': 'Source', + 'jobs.status': 'Status', + 'jobs.created': 'Created', + 'jobs.actions': 'Actions', + 'jobs.empty': 'No jobs', + 'jobs.statusRunning': 'Running', + 'jobs.statusCompleted': 'Completed', + 'jobs.statusFailed': 'Failed', + 'jobs.statusPending': 'Pending', + 'jobs.jobId': 'Job ID', + 'jobs.description': 'Description', + 'jobs.stateTransitions': 'State Transitions', + 'jobs.projectFiles': 'Project Files', + 'jobs.noProjectFiles': 'No project files', + 'jobs.viewJob': 'View Job', + 'jobs.browse': 'Browse', + + // Routines Tab + 'routines.summary': 'Routines Summary', + 'routines.name': 'Name', + 'routines.trigger': 'Trigger', + 'routines.action': 'Action', + 'routines.lastRun': 'Last Run', + 'routines.nextRun': 'Next Run', + 'routines.runs': 'Runs', + 'routines.status': 'Status', + 'routines.actions': 'Actions', + 'routines.runsToday': 'Runs Today', + 'routines.empty': 'No routines', + 'routines.noConfigured': 'No routines configured. Ask the assistant to create one.', + 'routines.triggerFailed': 'Trigger failed: {message}', + + // Logs Tab + 'logs.serverLevel': 'Server: ERROR', + 'logs.clientLevel': 'Client Log Level', + 'logs.pause': 'Pause', + 'logs.resume': 'Resume', + 'logs.clear': 'Clear', + 'logs.autoScroll': 'Auto-scroll', + 'logs.filter': 'Filter logs...', + 'logs.empty': 'No logs', + 'logs.allLevels': 'All Levels', + 'logs.error': 'Error', + 'logs.warn': 'Warn', + 'logs.info': 'Info', + 'logs.debug': 'Debug', + + // Extensions Tab + 'extensions.installed': 'Installed Extensions', + 'extensions.available': 'Available WASM Extensions', + 'extensions.installWasm': 'Install WASM Extension', + 'extensions.noInstalled': 'No extensions installed', + 'extensions.noAvailable': 'No additional WASM extensions available', + 'extensions.loading': 'Loading...', + 'extensions.install': 'Install', + 'extensions.installing': 'Installing...', + 'extensions.installedSuccess': 'Installed {name}', + 'extensions.remove': 'Remove', + 'extensions.activate': 'Activate', + 'extensions.reconfigure': 'Reconfigure', + 'extensions.tools': 'Tools', + 'extensions.noConfigNeeded': 'No configuration needed for {name}', + 'extensions.configure': 'Configure {name}', + 'extensions.optional': ' (optional)', + 'extensions.autoGenerated': 'Auto-generated if empty', + 'extensions.pendingPairing': 'Pending pairing requests', + 'extensions.from': 'from', + + // MCP Servers + 'mcp.servers': 'MCP Servers', + 'mcp.noServers': 'No MCP servers available', + 'mcp.addCustom': 'Add Custom MCP Server', + 'mcp.add': 'Add', + 'mcp.addedSuccess': 'Added MCP server {name}', + + // Registered Tools + 'tools.registered': 'Registered Tools', + 'tools.name': 'Name', + 'tools.description': 'Description', + 'tools.empty': 'No tools registered', + + // Skills Tab + 'skills.installed': 'Installed Skills', + 'skills.noInstalled': 'No skills installed', + 'skills.searchClawHub': 'Search ClawHub', + 'skills.searchPlaceholder': 'Search...', + 'skills.installByUrl': 'Install Skill by URL', + 'skills.namePlaceholder': 'Skill name or slug', + 'skills.urlPlaceholder': 'HTTPS URL to SKILL.md (optional)', + 'skills.search': 'Search', + 'skills.searching': 'Searching...', + 'skills.noResults': 'No skills found for "{query}"', + 'skills.searchFailed': 'Search failed: {message}', + 'skills.install': 'Install', + 'skills.installing': 'Installing...', + 'skills.installedSuccess': 'Installed skill "{name}"', + 'skills.remove': 'Remove', + 'skills.activatesOn': 'Activates on', + 'skills.registryError': 'Could not reach ClawHub registry: {message}', + 'skills.by': 'by', + 'skills.updated': 'updated', + 'skills.loading': 'Loading skills...', + 'skills.loadFailed': 'Failed to load skills: {message}', + 'skills.confirmRemove': 'Remove skill "{name}"?', + 'skills.removeFailed': 'Remove failed: {message}', + 'skills.removed': 'Removed skill "{name}"', + + // Jobs Summary + 'jobs.summary.total': 'Total', + 'jobs.summary.inProgress': 'In Progress', + 'jobs.summary.completed': 'Completed', + 'jobs.summary.failed': 'Failed', + 'jobs.summary.stuck': 'Stuck', + + // Routines Summary + 'routines.summary.total': 'Total', + 'routines.summary.enabled': 'Enabled', + 'routines.summary.disabled': 'Disabled', + 'routines.summary.failing': 'Failing', + 'routines.summary.runsToday': 'Runs Today', + + // Buttons + 'btn.close': 'Close', + 'btn.cancel': 'Cancel', + 'btn.save': 'Save', + 'btn.edit': 'Edit', + 'btn.confirm': 'Confirm', + 'btn.send': 'Send', + 'btn.refresh': 'Refresh', + 'btn.loadMore': 'Load More', + 'btn.copy': 'Copy', + 'btn.copied': 'Copied!', + 'btn.submit': 'Submit', + 'btn.setup': 'Setup', + + // Time + 'time.lessThan1MinuteAgo': '<1m ago', + 'time.lessThan1MinuteFromNow': 'in <1m', + 'time.minutesAgo': '{n}m ago', + 'time.minutesFromNow': 'in {n}m', + 'time.hoursAgo': '{n}h ago', + 'time.hoursFromNow': 'in {n}h', + 'time.daysAgo': '{n}d ago', + 'time.daysFromNow': 'in {n}d', + + // Tool Approval + 'approval.title': 'Tool requires approval', + 'approval.description': 'A tool is requesting permission to run.', + 'approval.approve': 'Approve', + 'approval.deny': 'Deny', + 'approval.always': 'Always', + 'approval.approved': 'Approved', + 'approval.alwaysApproved': 'Always approved', + 'approval.denied': 'Denied', + 'approval.showParams': 'Show parameters', + 'approval.hideParams': 'Hide parameters', + + // Authentication Required + 'authRequired.title': 'Authentication required for {name}', + 'authRequired.authenticateWith': 'Authenticate with {name}', + 'authRequired.getToken': 'Get your token', + 'authRequired.instructions': 'Instructions', + + // Sandbox Jobs + 'sandbox.job': 'Sandbox Job', + 'sandbox.doneSignal': 'Done signal sent', + + // Error Messages + 'error.startConversation': 'Please start a conversation first', + 'error.restartFailed': 'Restart failed: {message}', + 'error.tokenRequired': 'Token required', + 'error.tokenInvalid': 'Invalid token', + 'error.connectionFailed': 'Connection failed', + 'error.unknown': 'Unknown error', + 'error.loadFailed': 'Failed to load: {message}', + + // Success Messages + 'success.restartInitiated': 'Restart initiated', + 'success.saved': 'Saved successfully', + + // Slash Commands + 'cmd.status.desc': 'Show all jobs, or /status for a specific job', + 'cmd.list.desc': 'List all jobs', + 'cmd.cancel.desc': '/cancel — Cancel a running job', + 'cmd.undo.desc': 'Undo last action', + 'cmd.redo.desc': 'Redo undone action', + 'cmd.compact.desc': 'Compact context window', + 'cmd.clear.desc': 'Clear conversation and start fresh', + 'cmd.interrupt.desc': 'Stop current operation', + 'cmd.heartbeat.desc': 'Trigger manual heartbeat check', + 'cmd.summarize.desc': 'Summarize current conversation', + 'cmd.suggest.desc': 'Suggest next actions', + 'cmd.help.desc': 'Show help', + 'cmd.version.desc': 'Show version info', + 'cmd.tools.desc': 'List available tools', + 'cmd.skills.desc': 'List installed skills', + 'cmd.model.desc': 'Show or switch LLM model', + 'cmd.threadNew.desc': 'Create new conversation thread', + + // Language Switcher + 'language.title': 'Language', + 'language.en': 'English', + 'language.zhCN': '简体中文', + 'language.switch': 'Switch Language', + + // Tool Activity + 'tool.thinking': 'Thinking...', + 'tool.completed': 'Completed', + 'tool.failed': 'Failed', + 'tool.running': 'Running', + 'tool.used': '{count} tool(s) used', + 'tool.requiresApproval': 'Tool requires approval', + + + // TEE + 'tee.loadingReport': 'Loading attestation report...', + 'tee.loadFailed': 'Could not load attestation report', + + // Common + 'common.loading': 'Loading...', + 'common.noData': 'No data', + 'common.search': 'Search', + 'common.add': 'Add', + 'common.remove': 'Remove', + 'common.install': 'Install', + 'common.activate': 'Activate', + 'common.deactivate': 'Deactivate', + 'common.configure': 'Configure', + 'common.save': 'Save', + 'common.cancel': 'Cancel', + 'common.confirm': 'Confirm', + 'common.close': 'Close', + 'common.edit': 'Edit', + 'common.delete': 'Delete', + 'common.refresh': 'Refresh', + 'common.searchPlaceholder': 'Search...', + 'common.name': 'Name', + 'common.description': 'Description', + 'common.status': 'Status', + 'common.actions': 'Actions', + 'common.version': 'Version', + 'common.owner': 'Owner', + 'common.tags': 'Tags', + + // Extensions + 'ext.active': 'Active', + 'ext.remove': 'Remove', + 'ext.install': 'Install', + 'ext.installing': 'Installing...', + 'ext.installed': 'Installed', + 'ext.setup': 'Setup', + 'ext.reconfigure': 'Reconfigure', + 'ext.configure': 'Configure', + 'ext.confirmRemove': 'Remove extension "{name}"?', + 'ext.removeFailed': 'Remove failed: {message}', + 'ext.removed': 'Removed {name}', + 'ext.installFailed': 'Install failed: {message}', + + // Configure + 'config.title': 'Configure {name}', + 'config.optional': ' (optional)', + 'config.alreadySet': '(already set — leave empty to keep)', + 'config.alreadyConfigured': 'Already configured', + 'config.autoGenerate': 'Auto-generated if empty', + 'config.save': 'Save', + 'config.cancel': 'Cancel', +}); diff --git a/src/channels/web/static/i18n/index.js b/src/channels/web/static/i18n/index.js new file mode 100644 index 00000000..4c92bcc5 --- /dev/null +++ b/src/channels/web/static/i18n/index.js @@ -0,0 +1,89 @@ +// Lightweight internationalization implementation with dynamic language switching + +const I18n = { + currentLang: 'en', + fallbackLang: 'en', + translations: {}, + + // Initialize i18n + init() { + // Read user preference from localStorage + const savedLang = localStorage.getItem('ironclaw_language'); + if (savedLang && this.translations[savedLang]) { + this.currentLang = savedLang; + } else { + // Detect browser language + const browserLang = navigator.language || navigator.userLanguage; + this.currentLang = browserLang.startsWith('zh') ? 'zh-CN' : 'en'; + } + this.updateHtmlLang(); + }, + + // Register language pack + register(lang, translations) { + this.translations[lang] = translations; + }, + + // Switch language + setLanguage(lang) { + if (this.translations[lang]) { + this.currentLang = lang; + localStorage.setItem('ironclaw_language', lang); + this.updateHtmlLang(); + this.updatePageContent(); + return true; + } + return false; + }, + + // Get current language + getCurrentLang() { + return this.currentLang; + }, + + // Translate function + t(key, params = {}) { + const translation = this.translations[this.currentLang]?.[key] + || this.translations[this.fallbackLang]?.[key] + || key; + + // Support placeholder replacement: {name} + return translation.replace(/\{(\w+)\}/g, (match, key) => { + return params[key] !== undefined ? params[key] : match; + }); + }, + + // Update HTML lang attribute + updateHtmlLang() { + document.documentElement.lang = this.currentLang; + }, + + // Update page content (traverse all data-i18n elements) + updatePageContent() { + // Update text content + document.querySelectorAll('[data-i18n]').forEach(el => { + const key = el.getAttribute('data-i18n'); + const attr = el.getAttribute('data-i18n-attr'); + if (attr) { + el.setAttribute(attr, this.t(key)); + } else { + el.textContent = this.t(key); + } + }); + + // Update placeholder attributes + document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { + const key = el.getAttribute('data-i18n-placeholder'); + el.placeholder = this.t(key); + }); + + // Update title attributes + document.querySelectorAll('[data-i18n-title]').forEach(el => { + const key = el.getAttribute('data-i18n-title'); + el.title = this.t(key); + }); + } +}; + +// Global access +window.I18n = I18n; diff --git a/src/channels/web/static/i18n/zh-CN.js b/src/channels/web/static/i18n/zh-CN.js new file mode 100644 index 00000000..8a7fd520 --- /dev/null +++ b/src/channels/web/static/i18n/zh-CN.js @@ -0,0 +1,351 @@ +// 中文语言包 for IronClaw + +I18n.register('zh-CN', { + // 认证页面 + 'auth.title': 'IronClaw', + 'auth.tagline': '安全可靠的 AI 助手', + 'auth.tokenLabel': '网关令牌', + 'auth.tokenPlaceholder': '粘贴你的网关令牌', + 'auth.connect': '连接', + 'auth.errorRequired': '请输入令牌', + 'auth.errorInvalid': '令牌无效', + 'auth.hint': '输入 .env 配置文件中的 GATEWAY_AUTH_TOKEN', + + // 聊天 + 'chat.inputPlaceholder': '输入消息或 / 以使用命令...', + + // 重启弹窗 + 'restart.title': '重启 IronClaw 实例', + 'restart.description': '确定要重启 IronClaw 实例吗?这将优雅地重启进程。', + 'restart.warning': '正在运行的任务可能会中断。重启将在几秒钟内完成。', + 'restart.cancel': '取消', + 'restart.confirm': '确认重启', + 'restart.progressTitle': '正在重启 IronClaw', + 'restart.progressSubtitle': '请等待进程重启...', + 'restart.checkLogs': '重启完成后,请查看日志标签页了解详情。', + + // 标签页 + 'tab.chat': '聊天', + 'tab.memory': '记忆', + 'tab.jobs': '任务', + 'tab.routines': '定时任务', + 'tab.extensions': '扩展', + 'tab.skills': '技能', + 'tab.logs': '日志', + + // 状态 + 'status.connected': '已连接', + 'status.disconnected': '已断开', + 'status.connecting': '连接中...', + 'status.reconnecting': '重新连接中...', + 'status.teeVerified': 'TEE 已验证', + 'status.restart': '重启', + 'status.active': '已激活', + 'status.installed': '已安装', + 'status.awaitingPairing': '等待配对', + + // 仪表盘 + 'dashboard.connections': '连接数', + 'dashboard.uptime': '运行时间', + 'dashboard.costToday': '今日费用', + 'dashboard.spent': '已花费', + 'dashboard.actionsPerHour': '每小时操作', + 'dashboard.sse': 'SSE', + 'dashboard.websocket': 'WebSocket', + + // 聊天标签页 + 'chat.newThread': '新对话', + 'chat.toggleSidebar': '切换侧边栏', + 'chat.assistant': '助手', + 'chat.conversations': '对话列表', + 'chat.send': '发送', + 'chat.attachImages': '附加图片', + 'chat.empty': '选择文件查看内容', + 'chat.loading': '加载中...', + 'chat.loadingOlder': '加载更早的消息...', + 'chat.noFiles': '工作区没有文件', + 'chat.noResults': '没有结果', + + // 对话侧边栏 + 'thread.assistant': '助手', + 'thread.new': '新对话', + + // 记忆标签页 + 'memory.searchPlaceholder': '搜索记忆...', + 'memory.workspace': '工作区', + 'memory.edit': '编辑', + 'memory.save': '保存', + 'memory.cancel': '取消', + 'memory.selectFile': '选择文件查看内容', + + // 任务标签页 + 'jobs.summary': '任务摘要', + 'jobs.id': 'ID', + 'jobs.title': '标题', + 'jobs.source': '来源', + 'jobs.status': '状态', + 'jobs.created': '创建时间', + 'jobs.actions': '操作', + 'jobs.empty': '暂无任务', + 'jobs.statusRunning': '运行中', + 'jobs.statusCompleted': '已完成', + 'jobs.statusFailed': '失败', + 'jobs.statusPending': '等待中', + 'jobs.jobId': '任务 ID', + 'jobs.description': '描述', + 'jobs.stateTransitions': '状态转换', + 'jobs.projectFiles': '项目文件', + 'jobs.noProjectFiles': '没有项目文件', + 'jobs.viewJob': '查看任务', + 'jobs.browse': '浏览', + + // 定时任务标签页 + 'routines.summary': '定时任务摘要', + 'routines.name': '名称', + 'routines.trigger': '触发器', + 'routines.action': '操作', + 'routines.lastRun': '上次运行', + 'routines.nextRun': '下次运行', + 'routines.runs': '运行次数', + 'routines.status': '状态', + 'routines.actions': '操作', + 'routines.runsToday': '今日运行', + 'routines.empty': '暂无定时任务', + 'routines.noConfigured': '暂无配置的定时任务。请让助手创建一个。', + 'routines.triggerFailed': '触发失败: {message}', + + // 日志标签页 + 'logs.serverLevel': '服务端日志级别', + 'logs.clientLevel': '客户端日志级别', + 'logs.pause': '暂停', + 'logs.resume': '继续', + 'logs.clear': '清空', + 'logs.autoScroll': '自动滚动', + 'logs.filter': '筛选日志...', + 'logs.empty': '暂无日志', + 'logs.allLevels': '所有级别', + 'logs.error': '错误', + 'logs.warn': '警告', + 'logs.info': '信息', + 'logs.debug': '调试', + + // 扩展标签页 + 'extensions.installed': '已安装扩展', + 'extensions.available': '可用 WASM 扩展', + 'extensions.installWasm': '安装 WASM 扩展', + 'extensions.noInstalled': '没有安装扩展', + 'extensions.noAvailable': '没有其他可用的 WASM 扩展', + 'extensions.loading': '加载中...', + 'extensions.install': '安装', + 'extensions.installing': '安装中...', + 'extensions.installedSuccess': '已安装 {name}', + 'extensions.remove': '移除', + 'extensions.activate': '激活', + 'extensions.reconfigure': '重新配置', + 'extensions.tools': '工具', + 'extensions.noConfigNeeded': '{name} 不需要配置', + 'extensions.configure': '配置 {name}', + 'extensions.optional': ' (可选)', + 'extensions.autoGenerated': '留空则自动生成', + 'extensions.pendingPairing': '等待配对请求', + 'extensions.from': '来自', + + // MCP 服务器 + 'mcp.servers': 'MCP 服务器', + 'mcp.noServers': '没有可用的 MCP 服务器', + 'mcp.addCustom': '添加自定义 MCP 服务器', + 'mcp.add': '添加', + 'mcp.addedSuccess': '已添加 MCP 服务器 {name}', + + // 注册工具 + 'tools.registered': '注册工具', + 'tools.name': '名称', + 'tools.description': '描述', + 'tools.empty': '没有注册工具', + + // 技能标签页 + 'skills.installed': '已安装技能', + 'skills.noInstalled': '没有安装技能', + 'skills.searchClawHub': '搜索 ClawHub', + 'skills.searchPlaceholder': '搜索...', + 'skills.installByUrl': '通过 URL 安装技能', + 'skills.namePlaceholder': '技能名称或标识', + 'skills.urlPlaceholder': 'SKILL.md 的 HTTPS URL(可选)', + 'skills.search': '搜索', + 'skills.searching': '搜索中...', + 'skills.noResults': '没有找到 "{query}" 相关技能', + 'skills.searchFailed': '搜索失败: {message}', + 'skills.install': '安装', + 'skills.installing': '安装中...', + 'skills.installedSuccess': '已安装技能 "{name}"', + 'skills.remove': '移除', + 'skills.activatesOn': '激活关键词', + 'skills.registryError': '无法连接 ClawHub 注册表: {message}', + 'skills.by': '作者', + 'skills.updated': '更新于', + 'skills.loading': '加载技能中...', + 'skills.loadFailed': '加载技能失败: {message}', + 'skills.confirmRemove': '确定要移除技能 "{name}" 吗?', + 'skills.removeFailed': '移除失败: {message}', + 'skills.removed': '已移除技能 "{name}"', + + // 任务摘要 + 'jobs.summary.total': '总计', + 'jobs.summary.inProgress': '进行中', + 'jobs.summary.completed': '已完成', + 'jobs.summary.failed': '失败', + 'jobs.summary.stuck': '卡住', + + // 定时任务摘要 + 'routines.summary.total': '总计', + 'routines.summary.enabled': '已启用', + 'routines.summary.disabled': '已禁用', + 'routines.summary.failing': '失败', + 'routines.summary.runsToday': '今日运行', + + // 按钮 + 'btn.close': '关闭', + 'btn.cancel': '取消', + 'btn.save': '保存', + 'btn.edit': '编辑', + 'btn.confirm': '确认', + 'btn.send': '发送', + 'btn.refresh': '刷新', + 'btn.loadMore': '加载更多', + 'btn.copy': '复制', + 'btn.copied': '已复制!', + 'btn.submit': '提交', + 'btn.setup': '设置', + + // 时间 + 'time.lessThan1MinuteAgo': '刚刚', + 'time.lessThan1MinuteFromNow': '1分钟内', + 'time.minutesAgo': '{n}分钟前', + 'time.minutesFromNow': '{n}分钟后', + 'time.hoursAgo': '{n}小时前', + 'time.hoursFromNow': '{n}小时后', + 'time.daysAgo': '{n}天前', + 'time.daysFromNow': '{n}天后', + + // 工具审批 + 'approval.title': '工具需要审批', + 'approval.description': '一个工具请求运行权限。', + 'approval.approve': '批准', + 'approval.deny': '拒绝', + 'approval.always': '始终允许', + 'approval.approved': '已批准', + 'approval.alwaysApproved': '始终批准', + 'approval.denied': '已拒绝', + 'approval.showParams': '显示参数', + 'approval.hideParams': '隐藏参数', + + // 认证 + 'authRequired.title': '{name} 需要认证', + 'authRequired.authenticateWith': '使用 {name} 认证', + 'authRequired.getToken': '获取令牌', + 'authRequired.instructions': '说明', + + // 沙盒任务 + 'sandbox.job': '沙盒任务', + 'sandbox.doneSignal': '完成信号已发送', + + // 错误消息 + 'error.startConversation': '请先开始一个对话', + 'error.restartFailed': '重启失败: {message}', + 'error.tokenRequired': '请输入令牌', + 'error.tokenInvalid': '令牌无效', + 'error.connectionFailed': '连接失败', + 'error.unknown': '未知错误', + 'error.loadFailed': '加载失败: {message}', + + // 成功消息 + 'success.restartInitiated': '已开始重启', + 'success.saved': '保存成功', + + // 斜杠命令 + 'cmd.status.desc': '显示所有任务,或使用 /status 查看特定任务', + 'cmd.list.desc': '列出所有任务', + 'cmd.cancel.desc': '/cancel — 取消正在运行的任务', + 'cmd.undo.desc': '撤销上一步', + 'cmd.redo.desc': '重做已撤销的操作', + 'cmd.compact.desc': '压缩上下文窗口', + 'cmd.clear.desc': '清空对话并重新开始', + 'cmd.interrupt.desc': '停止当前操作', + 'cmd.heartbeat.desc': '触发手动心跳检查', + 'cmd.summarize.desc': '总结当前对话', + 'cmd.suggest.desc': '建议下一步操作', + 'cmd.help.desc': '显示帮助', + 'cmd.version.desc': '显示版本信息', + 'cmd.tools.desc': '列出可用工具', + 'cmd.skills.desc': '列出已安装的 AI 技能', + 'cmd.model.desc': '显示或切换 LLM 模型', + 'cmd.threadNew.desc': '创建新对话线程', + + // 语言切换 + 'language.title': '语言', + 'language.en': 'English', + 'language.zhCN': '简体中文', + 'language.switch': '切换语言', + + // 工具活动 + 'tool.thinking': '思考中...', + 'tool.completed': '已完成', + 'tool.failed': '失败', + 'tool.running': '运行中', + 'tool.used': '{count} 个工具已使用', + 'tool.requiresApproval': '工具需要审批', + + + // TEE + 'tee.loadingReport': '正在加载证明报告...', + 'tee.loadFailed': '无法加载证明报告', + + // 通用 + 'common.loading': '加载中...', + 'common.noData': '暂无数据', + 'common.search': '搜索', + 'common.add': '添加', + 'common.remove': '移除', + 'common.install': '安装', + 'common.activate': '激活', + 'common.deactivate': '停用', + 'common.configure': '配置', + 'common.save': '保存', + 'common.cancel': '取消', + 'common.confirm': '确认', + 'common.close': '关闭', + 'common.edit': '编辑', + 'common.delete': '删除', + 'common.refresh': '刷新', + 'common.searchPlaceholder': '搜索...', + 'common.name': '名称', + 'common.description': '描述', + 'common.status': '状态', + 'common.actions': '操作', + 'common.version': '版本', + 'common.owner': '作者', + 'common.tags': '标签', + + // 扩展 + 'ext.active': '已激活', + 'ext.remove': '移除', + 'ext.install': '安装', + 'ext.installing': '安装中...', + 'ext.installed': '已安装', + 'ext.setup': '设置', + 'ext.reconfigure': '重新配置', + 'ext.configure': '配置', + 'ext.confirmRemove': '确定要移除扩展 "{name}" 吗?', + 'ext.removeFailed': '移除失败: {message}', + 'ext.removed': '已移除 {name}', + 'ext.installFailed': '安装失败: {message}', + + // 配置 + 'config.title': '配置 {name}', + 'config.optional': '(可选)', + 'config.alreadySet': '(已设置 — 留空以保持不变)', + 'config.alreadyConfigured': '已配置', + 'config.autoGenerate': '如果为空则自动生成', + 'config.save': '保存', + 'config.cancel': '取消', +}); diff --git a/src/channels/web/static/index.html b/src/channels/web/static/index.html index b4a78a12..6f21b428 100644 --- a/src/channels/web/static/index.html +++ b/src/channels/web/static/index.html @@ -9,6 +9,12 @@ + + + + + + + diff --git a/src/channels/web/static/style.css b/src/channels/web/static/style.css index 1536f9e9..a0985ce3 100644 --- a/src/channels/web/static/style.css +++ b/src/channels/web/static/style.css @@ -3885,6 +3885,61 @@ mark { display: block; } +/* Language Switcher */ +.language-switcher { + position: relative; + display: flex; + align-items: center; +} + +.language-btn { + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 8px; + font-size: 16px; + border-radius: var(--radius); + transition: all 0.2s; +} + +.language-btn:hover { + color: var(--text); + background: var(--bg-tertiary); +} + +.language-menu { + position: absolute; + top: 100%; + right: 0; + margin-top: 4px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 4px; + min-width: 120px; + z-index: 1000; + box-shadow: var(--shadow); +} + +.language-option { + padding: 8px 12px; + cursor: pointer; + border-radius: var(--radius); + color: var(--text); + font-size: 13px; + transition: all 0.2s; +} + +.language-option:hover { + background: var(--bg-tertiary); +} + +.language-option.active { + background: var(--accent); + color: var(--bg); +} + .generated-image-path { font-size: 12px; color: var(--text-secondary); From fe82469904a797c9e4b87998a47c00a3daa16f29 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Wed, 11 Mar 2026 11:48:24 -0700 Subject: [PATCH 5/8] fix(ci): WASM WIT compat sqlite3 duplicate symbol conflict (#953) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ci): use explicit features in WASM WIT compat test to avoid sqlite3 symbol conflicts The `import` feature (added in #903) brings in `rusqlite[bundled]` which conflicts with `libsql-ffi` — both bundle SQLite C code, causing duplicate symbol linker errors. Use explicit features matching the test matrix instead of `--all-features`. Co-Authored-By: Claude Opus 4.6 * fix: replace rusqlite with libsql in import module to fix sqlite3 symbol conflict The `import` feature used `rusqlite[bundled]` which bundled its own SQLite C code, conflicting with `libsql-ffi` (also bundles SQLite). This caused duplicate `sqlite3_*` symbol linker errors when both features were enabled via `--all-features`. Replace `rusqlite` with `libsql` (already a dependency) in the import reader. The `import` feature now implies `libsql`. This eliminates the duplicate symbol conflict and allows `--all-features` to compile cleanly. Also restores `--all-features` in the WASM WIT compat CI test (now safe) and converts all import test helpers from rusqlite to libsql. Co-Authored-By: Claude Opus 4.6 * style: apply cargo fmt formatting fixes Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- Cargo.lock | 37 +---- Cargo.toml | 3 +- src/import/openclaw/mod.rs | 4 +- src/import/openclaw/reader.rs | 186 ++++++++++++++----------- tests/import_openclaw_comprehensive.rs | 85 ++++++----- tests/import_openclaw_e2e.rs | 116 ++++++++------- tests/import_openclaw_errors.rs | 144 +++++++++++-------- tests/import_openclaw_idempotency.rs | 63 +++++---- tests/import_openclaw_integration.rs | 163 ++++++++++++---------- 9 files changed, 435 insertions(+), 366 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 80e4722d..70a16a55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2787,15 +2787,6 @@ dependencies = [ "hashbrown 0.14.5", ] -[[package]] -name = "hashlink" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" -dependencies = [ - "hashbrown 0.14.5", -] - [[package]] name = "heck" version = "0.5.0" @@ -3410,7 +3401,6 @@ dependencies = [ "regex", "reqwest", "rig-core", - "rusqlite", "rust_decimal", "rust_decimal_macros", "rustls 0.23.37", @@ -3707,7 +3697,7 @@ dependencies = [ "bitflags 2.11.0", "fallible-iterator 0.2.0", "fallible-streaming-iterator", - "hashlink 0.8.4", + "hashlink", "libsql-ffi", "smallvec", ] @@ -3770,17 +3760,6 @@ dependencies = [ "zerocopy 0.7.35", ] -[[package]] -name = "libsqlite3-sys" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - [[package]] name = "libyml" version = "0.0.5" @@ -5351,20 +5330,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "rusqlite" -version = "0.32.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" -dependencies = [ - "bitflags 2.11.0", - "fallible-iterator 0.3.0", - "fallible-streaming-iterator", - "hashlink 0.9.1", - "libsqlite3-sys", - "smallvec", -] - [[package]] name = "rust_decimal" version = "1.40.0" diff --git a/Cargo.toml b/Cargo.toml index 5907655b..8c89a233 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -176,7 +176,6 @@ ed25519-dalek = { version = "2.2.0", features = ["std"] } hex = "0.4.3" # OpenClaw import (feature gated) -rusqlite = { version = "0.32", optional = true, features = ["bundled"] } json5 = { version = "0.4", optional = true } # macOS keychain @@ -214,7 +213,7 @@ libsql = ["dep:libsql"] integration = [] html-to-markdown = ["dep:html-to-markdown-rs", "dep:readabilityrs"] bedrock = ["dep:aws-config", "dep:aws-sdk-bedrockruntime", "dep:aws-smithy-types"] -import = ["dep:rusqlite", "dep:json5"] +import = ["dep:json5", "libsql"] [[test]] name = "html_to_markdown" diff --git a/src/import/openclaw/mod.rs b/src/import/openclaw/mod.rs index 5a28d197..acd3b984 100644 --- a/src/import/openclaw/mod.rs +++ b/src/import/openclaw/mod.rs @@ -77,7 +77,7 @@ impl OpenClawImporter { // Pre-read all conversation data to validate before writing let mut all_conversations = Vec::new(); for (_agent_name, db_path) in &agent_dbs { - match reader.read_conversations(db_path) { + match reader.read_conversations(db_path).await { Ok(convs) => all_conversations.extend(convs), Err(e) => { tracing::warn!("Failed to read conversations: {}", e); @@ -88,7 +88,7 @@ impl OpenClawImporter { // Pre-read all memory chunks let mut all_chunks = Vec::new(); for (_agent_name, db_path) in &agent_dbs { - match reader.read_memory_chunks(db_path) { + match reader.read_memory_chunks(db_path).await { Ok(chunks) => all_chunks.extend(chunks), Err(e) => { tracing::warn!("Failed to read memory chunks: {}", e); diff --git a/src/import/openclaw/reader.rs b/src/import/openclaw/reader.rs index f6694865..0a77df95 100644 --- a/src/import/openclaw/reader.rs +++ b/src/import/openclaw/reader.rs @@ -80,6 +80,16 @@ pub struct OpenClawMessage { pub created_at: Option>, } +/// Open an OpenClaw SQLite database file via libsql for read-only access. +#[cfg(feature = "import")] +async fn open_sqlite(db_path: &Path) -> Result { + let db = libsql::Builder::new_local(db_path) + .build() + .await + .map_err(|e| ImportError::Sqlite(e.to_string()))?; + db.connect().map_err(|e| ImportError::Sqlite(e.to_string())) +} + /// Reader for OpenClaw data files and databases. pub struct OpenClawReader { openclaw_dir: PathBuf, @@ -226,51 +236,52 @@ impl OpenClawReader { /// Read all memory chunks from an OpenClaw SQLite database. #[cfg(feature = "import")] - pub fn read_memory_chunks( + pub async fn read_memory_chunks( &self, db_path: &Path, ) -> Result, ImportError> { - use rusqlite::Connection; + let conn = open_sqlite(db_path).await?; - let conn = Connection::open(db_path).map_err(|e| ImportError::Sqlite(e.to_string()))?; - - let mut stmt = conn - .prepare("SELECT path, content, embedding, chunk_index FROM chunks") - .map_err(|e| ImportError::Sqlite(e.to_string()))?; - - let chunks = stmt - .query_map([], |row| { - let path: String = row.get(0)?; - let content: String = row.get(1)?; - let embedding_bytes: Option> = row.get(2)?; - let chunk_index: i32 = row.get(3)?; - - // Convert binary embedding blob to Vec if present - let embedding = embedding_bytes.map(|bytes| { - bytes - .chunks(4) - .map(|chunk| { - if chunk.len() == 4 { - f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]) - } else { - 0.0 - } - }) - .collect() - }); - - Ok(OpenClawMemoryChunk { - path, - content, - embedding, - chunk_index, - }) - }) + let mut rows = conn + .query( + "SELECT path, content, embedding, chunk_index FROM chunks", + (), + ) + .await .map_err(|e| ImportError::Sqlite(e.to_string()))?; let mut result = Vec::new(); - for chunk_result in chunks { - result.push(chunk_result.map_err(|e| ImportError::Sqlite(e.to_string()))?); + while let Some(row) = rows + .next() + .await + .map_err(|e| ImportError::Sqlite(e.to_string()))? + { + let path: String = row.get(0).map_err(|e| ImportError::Sqlite(e.to_string()))?; + let content: String = row.get(1).map_err(|e| ImportError::Sqlite(e.to_string()))?; + let embedding_blob: Option> = + row.get(2).map_err(|e| ImportError::Sqlite(e.to_string()))?; + let chunk_index: i32 = row.get(3).map_err(|e| ImportError::Sqlite(e.to_string()))?; + + // Convert binary embedding blob to Vec if present + let embedding = embedding_blob.map(|bytes| { + bytes + .chunks(4) + .map(|chunk| { + if chunk.len() == 4 { + f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]) + } else { + 0.0 + } + }) + .collect() + }); + + result.push(OpenClawMemoryChunk { + path, + content, + embedding, + chunk_index, + }); } Ok(result) @@ -278,63 +289,70 @@ impl OpenClawReader { /// Read all conversations from an OpenClaw SQLite database. #[cfg(feature = "import")] - pub fn read_conversations( + pub async fn read_conversations( &self, db_path: &Path, ) -> Result, ImportError> { - use rusqlite::Connection; + let conn = open_sqlite(db_path).await?; - let conn = Connection::open(db_path).map_err(|e| ImportError::Sqlite(e.to_string()))?; - - // First, read all conversations - let mut conv_stmt = conn - .prepare("SELECT id, channel, created_at FROM conversations ORDER BY created_at DESC") + let mut conv_rows = conn + .query( + "SELECT id, channel, created_at FROM conversations ORDER BY created_at DESC", + (), + ) + .await .map_err(|e| ImportError::Sqlite(e.to_string()))?; let mut conversations = Vec::new(); - let conv_rows = conv_stmt - .query_map([], |row| { - let id: String = row.get(0)?; - let channel: String = row.get(1)?; - let created_at: Option = row.get(2)?; + while let Some(row) = conv_rows + .next() + .await + .map_err(|e| ImportError::Sqlite(e.to_string()))? + { + let id: String = row.get(0).map_err(|e| ImportError::Sqlite(e.to_string()))?; + let channel: String = row.get(1).map_err(|e| ImportError::Sqlite(e.to_string()))?; + let created_at: Option = + row.get(2).map_err(|e| ImportError::Sqlite(e.to_string()))?; - let created_at = created_at + let created_at = created_at + .and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok()) + .map(|dt| dt.with_timezone(&chrono::Utc)); + + // Read messages for this conversation + let mut msg_rows = conn + .query( + "SELECT role, content, created_at FROM messages WHERE conversation_id = ?1 ORDER BY created_at", + libsql::params![id.as_str()], + ) + .await + .map_err(|e| ImportError::Sqlite(e.to_string()))?; + + let mut messages = Vec::new(); + while let Some(msg_row) = msg_rows + .next() + .await + .map_err(|e| ImportError::Sqlite(e.to_string()))? + { + let role: String = msg_row + .get(0) + .map_err(|e| ImportError::Sqlite(e.to_string()))?; + let content: String = msg_row + .get(1) + .map_err(|e| ImportError::Sqlite(e.to_string()))?; + let msg_created_at: Option = msg_row + .get(2) + .map_err(|e| ImportError::Sqlite(e.to_string()))?; + + let msg_created_at = msg_created_at .and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok()) .map(|dt| dt.with_timezone(&chrono::Utc)); - Ok((id, channel, created_at)) - }) - .map_err(|e| ImportError::Sqlite(e.to_string()))?; - - for row_result in conv_rows { - let (id, channel, created_at) = - row_result.map_err(|e| ImportError::Sqlite(e.to_string()))?; - - // Read messages for this conversation - let mut msg_stmt = conn.prepare( - "SELECT role, content, created_at FROM messages WHERE conversation_id = ? ORDER BY created_at" - ) - .map_err(|e| ImportError::Sqlite(e.to_string()))?; - - let messages = msg_stmt - .query_map([&id], |row| { - let role: String = row.get(0)?; - let content: String = row.get(1)?; - let created_at: Option = row.get(2)?; - - let created_at = created_at - .and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok()) - .map(|dt| dt.with_timezone(&chrono::Utc)); - - Ok(OpenClawMessage { - role, - content, - created_at, - }) - }) - .map_err(|e| ImportError::Sqlite(e.to_string()))? - .collect::, _>>() - .map_err(|e| ImportError::Sqlite(e.to_string()))?; + messages.push(OpenClawMessage { + role, + content, + created_at: msg_created_at, + }); + } conversations.push(OpenClawConversation { id, diff --git a/tests/import_openclaw_comprehensive.rs b/tests/import_openclaw_comprehensive.rs index 96441751..53d869dd 100644 --- a/tests/import_openclaw_comprehensive.rs +++ b/tests/import_openclaw_comprehensive.rs @@ -47,15 +47,14 @@ mod comprehensive_import_tests { } /// Helper to create a synthetic SQLite database with memory chunks - fn create_synthetic_memory_db( + async fn create_synthetic_memory_db( agents_dir: &Path, ) -> Result> { - use rusqlite::Connection; - std::fs::create_dir_all(agents_dir)?; let db_path = agents_dir.join("test_agent.sqlite"); - let conn = Connection::open(&db_path)?; + let db = libsql::Builder::new_local(&db_path).build().await?; + let conn = db.connect()?; // Create chunks table (simplified schema) conn.execute( @@ -66,33 +65,36 @@ mod comprehensive_import_tests { embedding BLOB, chunk_index INTEGER NOT NULL )", - [], - )?; + (), + ) + .await?; // Insert test chunks conn.execute( "INSERT INTO chunks (id, path, content, embedding, chunk_index) VALUES (?, ?, ?, ?, ?)", - rusqlite::params![ + libsql::params![ Uuid::new_v4().to_string(), "test/doc.md", "This is test chunk 1 content.", - None::>, - 0 + libsql::Value::Null, + 0i64 ], - )?; + ) + .await?; conn.execute( "INSERT INTO chunks (id, path, content, embedding, chunk_index) VALUES (?, ?, ?, ?, ?)", - rusqlite::params![ + libsql::params![ Uuid::new_v4().to_string(), "test/doc.md", "This is test chunk 2 content.", - None::>, - 1 + libsql::Value::Null, + 1i64 ], - )?; + ) + .await?; // Create conversation table conn.execute( @@ -101,8 +103,9 @@ mod comprehensive_import_tests { channel TEXT NOT NULL, created_at TEXT )", - [], - )?; + (), + ) + .await?; // Create messages table conn.execute( @@ -114,40 +117,44 @@ mod comprehensive_import_tests { created_at TEXT, FOREIGN KEY(conversation_id) REFERENCES conversations(id) )", - [], - )?; + (), + ) + .await?; // Insert test conversation let conv_id = Uuid::new_v4().to_string(); conn.execute( "INSERT INTO conversations (id, channel, created_at) VALUES (?, ?, ?)", - rusqlite::params![&conv_id, "telegram", "2024-01-15T10:30:00Z"], - )?; + libsql::params![conv_id.clone(), "telegram", "2024-01-15T10:30:00Z"], + ) + .await?; // Insert test messages conn.execute( "INSERT INTO messages (id, conversation_id, role, content, created_at) VALUES (?, ?, ?, ?, ?)", - rusqlite::params![ + libsql::params![ Uuid::new_v4().to_string(), - &conv_id, + conv_id.clone(), "user", "Hello, how are you?", "2024-01-15T10:30:00Z" ], - )?; + ) + .await?; conn.execute( "INSERT INTO messages (id, conversation_id, role, content, created_at) VALUES (?, ?, ?, ?, ?)", - rusqlite::params![ + libsql::params![ Uuid::new_v4().to_string(), - &conv_id, + conv_id.clone(), "assistant", "I'm doing well, thank you for asking!", "2024-01-15T10:31:00Z" ], - )?; + ) + .await?; Ok(db_path) } @@ -211,13 +218,15 @@ mod comprehensive_import_tests { let _ = temp_dir; } - #[test] - fn test_openclaw_reader_lists_agent_dbs() { + #[tokio::test] + async fn test_openclaw_reader_lists_agent_dbs() { let (temp_dir, openclaw_path) = create_synthetic_openclaw_dir().expect("failed to create test data"); let agents_dir = openclaw_path.join("agents"); - let _db_path = create_synthetic_memory_db(&agents_dir).expect("failed to create test DB"); + let _db_path = create_synthetic_memory_db(&agents_dir) + .await + .expect("failed to create test DB"); let reader = OpenClawReader::new(&openclaw_path).expect("failed to create reader"); @@ -230,18 +239,21 @@ mod comprehensive_import_tests { let _ = temp_dir; } - #[test] - fn test_openclaw_reader_reads_memory_chunks() { + #[tokio::test] + async fn test_openclaw_reader_reads_memory_chunks() { let (temp_dir, openclaw_path) = create_synthetic_openclaw_dir().expect("failed to create test data"); let agents_dir = openclaw_path.join("agents"); - let db_path = create_synthetic_memory_db(&agents_dir).expect("failed to create test DB"); + let db_path = create_synthetic_memory_db(&agents_dir) + .await + .expect("failed to create test DB"); let reader = OpenClawReader::new(&openclaw_path).expect("failed to create reader"); let chunks = reader .read_memory_chunks(&db_path) + .await .expect("failed to read memory chunks"); // Should find 2 chunks @@ -260,18 +272,21 @@ mod comprehensive_import_tests { let _ = temp_dir; } - #[test] - fn test_openclaw_reader_reads_conversations() { + #[tokio::test] + async fn test_openclaw_reader_reads_conversations() { let (temp_dir, openclaw_path) = create_synthetic_openclaw_dir().expect("failed to create test data"); let agents_dir = openclaw_path.join("agents"); - let db_path = create_synthetic_memory_db(&agents_dir).expect("failed to create test DB"); + let db_path = create_synthetic_memory_db(&agents_dir) + .await + .expect("failed to create test DB"); let reader = OpenClawReader::new(&openclaw_path).expect("failed to create reader"); let conversations = reader .read_conversations(&db_path) + .await .expect("failed to read conversations"); // Should find 1 conversation diff --git a/tests/import_openclaw_e2e.rs b/tests/import_openclaw_e2e.rs index 09dbd786..f74a5a4a 100644 --- a/tests/import_openclaw_e2e.rs +++ b/tests/import_openclaw_e2e.rs @@ -16,7 +16,8 @@ mod e2e_import_tests { use ironclaw::import::{ImportOptions, ImportStats}; /// Helper: Create a synthetic OpenClaw with full structure - fn setup_full_openclaw_test_env() -> Result<(TempDir, PathBuf), Box> { + async fn setup_full_openclaw_test_env() -> Result<(TempDir, PathBuf), Box> + { let temp_dir = TempDir::new()?; let openclaw_path = temp_dir.path().to_path_buf(); @@ -60,17 +61,16 @@ mod e2e_import_tests { let agents_dir = openclaw_path.join("agents"); std::fs::create_dir_all(&agents_dir)?; - create_full_agent_db(&agents_dir.join("primary_agent.sqlite"))?; - create_full_agent_db(&agents_dir.join("secondary_agent.sqlite"))?; + create_full_agent_db(&agents_dir.join("primary_agent.sqlite")).await?; + create_full_agent_db(&agents_dir.join("secondary_agent.sqlite")).await?; Ok((temp_dir, openclaw_path)) } /// Helper: Create a full agent SQLite database with chunks and conversations - fn create_full_agent_db(db_path: &PathBuf) -> Result<(), Box> { - use rusqlite::Connection; - - let conn = Connection::open(db_path)?; + async fn create_full_agent_db(db_path: &PathBuf) -> Result<(), Box> { + let db = libsql::Builder::new_local(db_path).build().await?; + let conn = db.connect()?; // Chunks table conn.execute( @@ -81,22 +81,24 @@ mod e2e_import_tests { embedding BLOB, chunk_index INTEGER NOT NULL )", - [], - )?; + (), + ) + .await?; // Insert 5 chunks for i in 0..5 { conn.execute( "INSERT INTO chunks (id, path, content, embedding, chunk_index) VALUES (?, ?, ?, ?, ?)", - rusqlite::params![ + libsql::params![ Uuid::new_v4().to_string(), format!("notes/section_{}.md", i), format!("Content for section {}. This is important information.", i), - None::>, - i + libsql::Value::Null, + i as i64 ], - )?; + ) + .await?; } // Conversations table @@ -106,8 +108,9 @@ mod e2e_import_tests { channel TEXT NOT NULL, created_at TEXT )", - [], - )?; + (), + ) + .await?; // Messages table conn.execute( @@ -119,8 +122,9 @@ mod e2e_import_tests { created_at TEXT, FOREIGN KEY(conversation_id) REFERENCES conversations(id) )", - [], - )?; + (), + ) + .await?; // Insert 3 conversations with messages for conv_num in 0..3 { @@ -133,12 +137,13 @@ mod e2e_import_tests { conn.execute( "INSERT INTO conversations (id, channel, created_at) VALUES (?, ?, ?)", - rusqlite::params![ - &conv_id, + libsql::params![ + conv_id.clone(), channel, format!("2024-01-{:02}T10:00:00Z", 10 + conv_num) ], - )?; + ) + .await?; // Add 3 messages per conversation for msg_num in 0..3 { @@ -150,9 +155,9 @@ mod e2e_import_tests { conn.execute( "INSERT INTO messages (id, conversation_id, role, content, created_at) VALUES (?, ?, ?, ?, ?)", - rusqlite::params![ + libsql::params![ Uuid::new_v4().to_string(), - &conv_id, + conv_id.clone(), role, format!( "{} message {} from conversation {}", @@ -160,7 +165,8 @@ mod e2e_import_tests { ), format!("2024-01-{:02}T10:{:02}:00Z", 10 + conv_num, msg_num * 10) ], - )?; + ) + .await?; } } @@ -171,9 +177,9 @@ mod e2e_import_tests { // Configuration & Settings Tests // ──────────────────────────────────────────────────────────────────── - #[test] - fn test_full_config_extraction() { - let (_temp, openclaw_path) = setup_full_openclaw_test_env().expect("setup failed"); + #[tokio::test] + async fn test_full_config_extraction() { + let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed"); let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed"); let config = reader.read_config().expect("config read failed"); @@ -198,9 +204,9 @@ mod e2e_import_tests { assert!(config.other_settings.contains_key("custom_setting")); } - #[test] - fn test_settings_mapping_to_ironclaw_format() { - let (_temp, openclaw_path) = setup_full_openclaw_test_env().expect("setup failed"); + #[tokio::test] + async fn test_settings_mapping_to_ironclaw_format() { + let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed"); let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed"); let config = reader.read_config().expect("config read failed"); @@ -224,9 +230,9 @@ mod e2e_import_tests { // Credential Extraction Tests // ──────────────────────────────────────────────────────────────────── - #[test] - fn test_credentials_extraction() { - let (_temp, openclaw_path) = setup_full_openclaw_test_env().expect("setup failed"); + #[tokio::test] + async fn test_credentials_extraction() { + let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed"); let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed"); let config = reader.read_config().expect("config read failed"); @@ -249,9 +255,9 @@ mod e2e_import_tests { } } - #[test] - fn test_credentials_never_logged() { - let (_temp, openclaw_path) = setup_full_openclaw_test_env().expect("setup failed"); + #[tokio::test] + async fn test_credentials_never_logged() { + let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed"); let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed"); let config = reader.read_config().expect("config read failed"); @@ -271,9 +277,9 @@ mod e2e_import_tests { // Data Volume Tests // ──────────────────────────────────────────────────────────────────── - #[test] - fn test_full_workspace_import_counts() { - let (_temp, openclaw_path) = setup_full_openclaw_test_env().expect("setup failed"); + #[tokio::test] + async fn test_full_workspace_import_counts() { + let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed"); let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed"); @@ -288,9 +294,9 @@ mod e2e_import_tests { assert_eq!(agent_dbs.len(), 2); // primary + secondary } - #[test] - fn test_full_memory_chunks_import() { - let (_temp, openclaw_path) = setup_full_openclaw_test_env().expect("setup failed"); + #[tokio::test] + async fn test_full_memory_chunks_import() { + let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed"); let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed"); let agent_dbs = reader.list_agent_dbs().expect("list agent dbs failed"); @@ -299,6 +305,7 @@ mod e2e_import_tests { for (_name, db_path) in agent_dbs { let chunks = reader .read_memory_chunks(&db_path) + .await .expect("read memory chunks failed"); assert_eq!(chunks.len(), 5); @@ -314,9 +321,9 @@ mod e2e_import_tests { } } - #[test] - fn test_full_conversations_import() { - let (_temp, openclaw_path) = setup_full_openclaw_test_env().expect("setup failed"); + #[tokio::test] + async fn test_full_conversations_import() { + let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed"); let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed"); let agent_dbs = reader.list_agent_dbs().expect("list agent dbs failed"); @@ -325,6 +332,7 @@ mod e2e_import_tests { for (_name, db_path) in agent_dbs { let conversations = reader .read_conversations(&db_path) + .await .expect("read conversations failed"); assert_eq!(conversations.len(), 3); @@ -387,8 +395,8 @@ mod e2e_import_tests { // Error Handling Tests // ──────────────────────────────────────────────────────────────────── - #[test] - fn test_error_on_corrupt_sqlite() { + #[tokio::test] + async fn test_error_on_corrupt_sqlite() { let temp_dir = TempDir::new().expect("temp dir creation failed"); let openclaw_path = temp_dir.path().to_path_buf(); @@ -410,7 +418,7 @@ mod e2e_import_tests { assert_eq!(dbs.len(), 1); // But reading should fail - let result = reader.read_memory_chunks(&dbs[0].1); + let result = reader.read_memory_chunks(&dbs[0].1).await; assert!(result.is_err()); } @@ -437,9 +445,9 @@ mod e2e_import_tests { // Extensibility Tests // ──────────────────────────────────────────────────────────────────── - #[test] - fn test_multiple_agents_independent_data() { - let (_temp, openclaw_path) = setup_full_openclaw_test_env().expect("setup failed"); + #[tokio::test] + async fn test_multiple_agents_independent_data() { + let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed"); let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed"); let agent_dbs = reader.list_agent_dbs().expect("list agent dbs failed"); @@ -453,14 +461,15 @@ mod e2e_import_tests { for (_name, db_path) in &agent_dbs { let chunks = reader .read_memory_chunks(db_path) + .await .expect("read chunks failed"); assert_eq!(chunks.len(), 5); } } - #[test] - fn test_channel_diversity_in_conversations() { - let (_temp, openclaw_path) = setup_full_openclaw_test_env().expect("setup failed"); + #[tokio::test] + async fn test_channel_diversity_in_conversations() { + let (_temp, openclaw_path) = setup_full_openclaw_test_env().await.expect("setup failed"); let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed"); let agent_dbs = reader.list_agent_dbs().expect("list agent dbs failed"); @@ -468,6 +477,7 @@ mod e2e_import_tests { // Get conversations from first agent let conversations = reader .read_conversations(&agent_dbs[0].1) + .await .expect("read conversations failed"); // Should have different channels diff --git a/tests/import_openclaw_errors.rs b/tests/import_openclaw_errors.rs index e0338292..76345a71 100644 --- a/tests/import_openclaw_errors.rs +++ b/tests/import_openclaw_errors.rs @@ -112,8 +112,8 @@ mod error_handling_tests { // SQLite Database Errors // ──────────────────────────────────────────────────────────────────── - #[test] - fn test_error_corrupt_sqlite_file() { + #[tokio::test] + async fn test_error_corrupt_sqlite_file() { let temp_dir = TempDir::new().expect("temp dir creation failed"); let openclaw_path = temp_dir.path().to_path_buf(); @@ -133,12 +133,12 @@ mod error_handling_tests { assert_eq!(dbs.len(), 1); // But reading should fail - let result = reader.read_memory_chunks(&dbs[0].1); + let result = reader.read_memory_chunks(&dbs[0].1).await; assert!(result.is_err()); } - #[test] - fn test_error_missing_chunks_table() { + #[tokio::test] + async fn test_error_missing_chunks_table() { let temp_dir = TempDir::new().expect("temp dir creation failed"); let openclaw_path = temp_dir.path().to_path_buf(); @@ -148,14 +148,17 @@ mod error_handling_tests { let db_path = agents_dir.join("no_chunks.sqlite"); // Create valid SQLite but without chunks table - use rusqlite::Connection; - let conn = Connection::open(&db_path).expect("db creation failed"); + let db = libsql::Builder::new_local(&db_path) + .build() + .await + .expect("db creation failed"); + let conn = db.connect().expect("connect failed"); conn.execute( "CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT)", - [], + (), ) + .await .expect("create table failed"); - drop(conn); let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed"); @@ -163,12 +166,12 @@ mod error_handling_tests { assert_eq!(dbs.len(), 1); // Should fail: chunks table doesn't exist - let result = reader.read_memory_chunks(&dbs[0].1); + let result = reader.read_memory_chunks(&dbs[0].1).await; assert!(result.is_err()); } - #[test] - fn test_error_missing_conversations_table() { + #[tokio::test] + async fn test_error_missing_conversations_table() { let temp_dir = TempDir::new().expect("temp dir creation failed"); let openclaw_path = temp_dir.path().to_path_buf(); @@ -177,15 +180,18 @@ mod error_handling_tests { let db_path = agents_dir.join("no_conversations.sqlite"); - use rusqlite::Connection; - let conn = Connection::open(&db_path).expect("db creation failed"); + let db = libsql::Builder::new_local(&db_path) + .build() + .await + .expect("db creation failed"); + let conn = db.connect().expect("connect failed"); // Only create chunks table, not conversations conn.execute( "CREATE TABLE chunks (id TEXT, path TEXT, content TEXT, embedding BLOB, chunk_index INTEGER)", - [], + (), ) + .await .expect("create table failed"); - drop(conn); let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed"); @@ -193,7 +199,7 @@ mod error_handling_tests { assert_eq!(dbs.len(), 1); // Should fail: conversations table doesn't exist - let result = reader.read_conversations(&dbs[0].1); + let result = reader.read_conversations(&dbs[0].1).await; assert!(result.is_err()); } @@ -201,8 +207,8 @@ mod error_handling_tests { // Edge Cases // ──────────────────────────────────────────────────────────────────── - #[test] - fn test_edge_case_empty_chunks_table() { + #[tokio::test] + async fn test_edge_case_empty_chunks_table() { let temp_dir = TempDir::new().expect("temp dir creation failed"); let openclaw_path = temp_dir.path().to_path_buf(); @@ -211,14 +217,17 @@ mod error_handling_tests { let db_path = agents_dir.join("empty.sqlite"); - use rusqlite::Connection; - let conn = Connection::open(&db_path).expect("db creation failed"); + let db = libsql::Builder::new_local(&db_path) + .build() + .await + .expect("db creation failed"); + let conn = db.connect().expect("connect failed"); conn.execute( "CREATE TABLE chunks (id TEXT, path TEXT, content TEXT, embedding BLOB, chunk_index INTEGER)", - [], + (), ) + .await .expect("create table failed"); - drop(conn); let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed"); @@ -227,12 +236,13 @@ mod error_handling_tests { // Should succeed but return empty list let chunks = reader .read_memory_chunks(&dbs[0].1) + .await .expect("read chunks failed"); assert_eq!(chunks.len(), 0); } - #[test] - fn test_edge_case_empty_conversations_table() { + #[tokio::test] + async fn test_edge_case_empty_conversations_table() { let temp_dir = TempDir::new().expect("temp dir creation failed"); let openclaw_path = temp_dir.path().to_path_buf(); @@ -241,19 +251,23 @@ mod error_handling_tests { let db_path = agents_dir.join("empty_conv.sqlite"); - use rusqlite::Connection; - let conn = Connection::open(&db_path).expect("db creation failed"); + let db = libsql::Builder::new_local(&db_path) + .build() + .await + .expect("db creation failed"); + let conn = db.connect().expect("connect failed"); conn.execute( "CREATE TABLE conversations (id TEXT, channel TEXT, created_at TEXT)", - [], + (), ) + .await .expect("create table failed"); conn.execute( "CREATE TABLE messages (id TEXT, conversation_id TEXT, role TEXT, content TEXT, created_at TEXT)", - [], + (), ) + .await .expect("create table failed"); - drop(conn); let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed"); @@ -262,12 +276,13 @@ mod error_handling_tests { // Should succeed but return empty list let conversations = reader .read_conversations(&dbs[0].1) + .await .expect("read conversations failed"); assert_eq!(conversations.len(), 0); } - #[test] - fn test_edge_case_very_large_content() { + #[tokio::test] + async fn test_edge_case_very_large_content() { let temp_dir = TempDir::new().expect("temp dir creation failed"); let openclaw_path = temp_dir.path().to_path_buf(); @@ -276,22 +291,26 @@ mod error_handling_tests { let db_path = agents_dir.join("large.sqlite"); - use rusqlite::Connection; - let conn = Connection::open(&db_path).expect("db creation failed"); + let db = libsql::Builder::new_local(&db_path) + .build() + .await + .expect("db creation failed"); + let conn = db.connect().expect("connect failed"); conn.execute( "CREATE TABLE chunks (id TEXT, path TEXT, content TEXT, embedding BLOB, chunk_index INTEGER)", - [], + (), ) + .await .expect("create table failed"); // Insert very large content (1MB) let large_content = "x".repeat(1024 * 1024); conn.execute( "INSERT INTO chunks VALUES (?, ?, ?, ?, ?)", - rusqlite::params!["id1", "path", large_content, None::>, 0], + libsql::params!["id1", "path", large_content, libsql::Value::Null, 0i64], ) + .await .expect("insert failed"); - drop(conn); let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed"); @@ -300,13 +319,14 @@ mod error_handling_tests { // Should still succeed let chunks = reader .read_memory_chunks(&dbs[0].1) + .await .expect("read chunks failed"); assert_eq!(chunks.len(), 1); assert_eq!(chunks[0].content.len(), 1024 * 1024); } - #[test] - fn test_edge_case_special_characters_in_content() { + #[tokio::test] + async fn test_edge_case_special_characters_in_content() { let temp_dir = TempDir::new().expect("temp dir creation failed"); let openclaw_path = temp_dir.path().to_path_buf(); @@ -315,22 +335,26 @@ mod error_handling_tests { let db_path = agents_dir.join("special.sqlite"); - use rusqlite::Connection; - let conn = Connection::open(&db_path).expect("db creation failed"); + let db = libsql::Builder::new_local(&db_path) + .build() + .await + .expect("db creation failed"); + let conn = db.connect().expect("connect failed"); conn.execute( "CREATE TABLE chunks (id TEXT, path TEXT, content TEXT, embedding BLOB, chunk_index INTEGER)", - [], + (), ) + .await .expect("create table failed"); // Insert content with special characters - let special_content = "Content with emoji 🚀 and UTF-8: 中文, العربية, ελληνικά"; + let special_content = "Content with emoji \u{1f680} and UTF-8: \u{4e2d}\u{6587}, \u{0627}\u{0644}\u{0639}\u{0631}\u{0628}\u{064a}\u{0629}, \u{03b5}\u{03bb}\u{03bb}\u{03b7}\u{03bd}\u{03b9}\u{03ba}\u{03ac}"; conn.execute( "INSERT INTO chunks VALUES (?, ?, ?, ?, ?)", - rusqlite::params!["id1", "path", special_content, None::>, 0], + libsql::params!["id1", "path", special_content, libsql::Value::Null, 0i64], ) + .await .expect("insert failed"); - drop(conn); let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed"); @@ -339,14 +363,15 @@ mod error_handling_tests { // Should handle special characters let chunks = reader .read_memory_chunks(&dbs[0].1) + .await .expect("read chunks failed"); assert_eq!(chunks.len(), 1); - assert!(chunks[0].content.contains("🚀")); - assert!(chunks[0].content.contains("中文")); + assert!(chunks[0].content.contains("\u{1f680}")); + assert!(chunks[0].content.contains("\u{4e2d}\u{6587}")); } - #[test] - fn test_edge_case_null_values_in_fields() { + #[tokio::test] + async fn test_edge_case_null_values_in_fields() { let temp_dir = TempDir::new().expect("temp dir creation failed"); let openclaw_path = temp_dir.path().to_path_buf(); @@ -355,33 +380,39 @@ mod error_handling_tests { let db_path = agents_dir.join("nulls.sqlite"); - use rusqlite::Connection; - let conn = Connection::open(&db_path).expect("db creation failed"); + let db = libsql::Builder::new_local(&db_path) + .build() + .await + .expect("db creation failed"); + let conn = db.connect().expect("connect failed"); conn.execute( "CREATE TABLE conversations (id TEXT, channel TEXT, created_at TEXT)", - [], + (), ) + .await .expect("create table failed"); conn.execute( "CREATE TABLE messages (id TEXT, conversation_id TEXT, role TEXT, content TEXT, created_at TEXT)", - [], + (), ) + .await .expect("create table failed"); // Insert conversation with NULL created_at conn.execute( "INSERT INTO conversations VALUES (?, ?, ?)", - rusqlite::params!["conv1", "telegram", None::], + libsql::params!["conv1", "telegram", libsql::Value::Null], ) + .await .expect("insert failed"); // Insert message with NULL created_at conn.execute( "INSERT INTO messages VALUES (?, ?, ?, ?, ?)", - rusqlite::params!["msg1", "conv1", "user", "hello", None::], + libsql::params!["msg1", "conv1", "user", "hello", libsql::Value::Null], ) + .await .expect("insert failed"); - drop(conn); let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed"); @@ -390,6 +421,7 @@ mod error_handling_tests { // Should handle NULL timestamps gracefully let conversations = reader .read_conversations(&dbs[0].1) + .await .expect("read conversations failed"); assert_eq!(conversations.len(), 1); assert!(conversations[0].created_at.is_none()); diff --git a/tests/import_openclaw_idempotency.rs b/tests/import_openclaw_idempotency.rs index a0a044e4..22fb900d 100644 --- a/tests/import_openclaw_idempotency.rs +++ b/tests/import_openclaw_idempotency.rs @@ -17,7 +17,7 @@ mod idempotency_tests { use ironclaw::import::{ImportOptions, ImportStats}; /// Helper: Create minimal test OpenClaw - fn create_minimal_openclaw() -> Result<(TempDir, PathBuf), Box> { + async fn create_minimal_openclaw() -> Result<(TempDir, PathBuf), Box> { let temp_dir = TempDir::new()?; let openclaw_path = temp_dir.path().to_path_buf(); @@ -40,8 +40,8 @@ mod idempotency_tests { std::fs::create_dir_all(&agents_dir)?; let db_path = agents_dir.join("agent.sqlite"); - use rusqlite::Connection; - let conn = Connection::open(&db_path)?; + let db = libsql::Builder::new_local(&db_path).build().await?; + let conn = db.connect()?; conn.execute( "CREATE TABLE chunks ( @@ -51,24 +51,27 @@ mod idempotency_tests { embedding BLOB, chunk_index INTEGER )", - [], - )?; + (), + ) + .await?; conn.execute( "INSERT INTO chunks VALUES (?, ?, ?, ?, ?)", - rusqlite::params![ + libsql::params![ Uuid::new_v4().to_string(), "test.md", "Test content", - None::>, - 0 + libsql::Value::Null, + 0i64 ], - )?; + ) + .await?; conn.execute( "CREATE TABLE conversations (id TEXT PRIMARY KEY, channel TEXT, created_at TEXT)", - [], - )?; + (), + ) + .await?; conn.execute( "CREATE TABLE messages ( @@ -78,8 +81,9 @@ mod idempotency_tests { content TEXT, created_at TEXT )", - [], - )?; + (), + ) + .await?; Ok((temp_dir, openclaw_path)) } @@ -88,9 +92,9 @@ mod idempotency_tests { // Idempotency Tests // ──────────────────────────────────────────────────────────────────── - #[test] - fn test_reader_idempotent_config_reads() { - let (_temp, openclaw_path) = create_minimal_openclaw().expect("setup failed"); + #[tokio::test] + async fn test_reader_idempotent_config_reads() { + let (_temp, openclaw_path) = create_minimal_openclaw().await.expect("setup failed"); let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed"); @@ -109,9 +113,9 @@ mod idempotency_tests { ); } - #[test] - fn test_reader_idempotent_workspace_file_listing() { - let (_temp, openclaw_path) = create_minimal_openclaw().expect("setup failed"); + #[tokio::test] + async fn test_reader_idempotent_workspace_file_listing() { + let (_temp, openclaw_path) = create_minimal_openclaw().await.expect("setup failed"); let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed"); @@ -123,9 +127,9 @@ mod idempotency_tests { assert_eq!(count1, 1); // MEMORY.md } - #[test] - fn test_reader_idempotent_memory_chunk_reads() { - let (_temp, openclaw_path) = create_minimal_openclaw().expect("setup failed"); + #[tokio::test] + async fn test_reader_idempotent_memory_chunk_reads() { + let (_temp, openclaw_path) = create_minimal_openclaw().await.expect("setup failed"); let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed"); let agent_dbs = reader.list_agent_dbs().expect("list agent dbs failed"); @@ -134,9 +138,11 @@ mod idempotency_tests { // Read chunks twice let chunks1 = reader .read_memory_chunks(db_path) + .await .expect("first read failed"); let chunks2 = reader .read_memory_chunks(db_path) + .await .expect("second read failed"); // Same number of chunks @@ -197,10 +203,10 @@ mod idempotency_tests { assert!(!normal_opts.dry_run); } - #[test] - fn test_dry_run_stats_would_be_same() { + #[tokio::test] + async fn test_dry_run_stats_would_be_same() { // Simulating what import stats would be in dry-run vs real run - let (_temp, openclaw_path) = create_minimal_openclaw().expect("setup failed"); + let (_temp, openclaw_path) = create_minimal_openclaw().await.expect("setup failed"); let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed"); @@ -235,9 +241,9 @@ mod idempotency_tests { // Duplicate Prevention Tests // ──────────────────────────────────────────────────────────────────── - #[test] - fn test_chunk_deduplication_by_path() { - let (_temp, openclaw_path) = create_minimal_openclaw().expect("setup failed"); + #[tokio::test] + async fn test_chunk_deduplication_by_path() { + let (_temp, openclaw_path) = create_minimal_openclaw().await.expect("setup failed"); let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed"); let agent_dbs = reader.list_agent_dbs().expect("list agent dbs failed"); @@ -245,6 +251,7 @@ mod idempotency_tests { let chunks = reader .read_memory_chunks(db_path) + .await .expect("read chunks failed"); // All chunks should have unique (path, chunk_index) pairs diff --git a/tests/import_openclaw_integration.rs b/tests/import_openclaw_integration.rs index 2435770b..2a694098 100644 --- a/tests/import_openclaw_integration.rs +++ b/tests/import_openclaw_integration.rs @@ -4,14 +4,14 @@ //! verifying that data is correctly stored, idempotent, and that dry-run mode //! prevents modifications. -#![cfg(all(feature = "import", feature = "libsql"))] +#![cfg(feature = "import")] -#[cfg(all(feature = "import", feature = "libsql"))] +#[cfg(feature = "import")] mod import_integration_tests { use ironclaw::db::Database; use ironclaw::db::libsql::LibSqlBackend; + use ironclaw::import::ImportStats; use ironclaw::import::openclaw::reader::OpenClawReader; - use ironclaw::import::{ImportOptions, ImportStats}; use std::path::PathBuf; use std::sync::Arc; use tempfile::TempDir; @@ -29,7 +29,7 @@ mod import_integration_tests { } /// Helper: Create a test OpenClaw directory with full structure - fn create_test_openclaw() -> Result<(TempDir, PathBuf), Box> { + async fn create_test_openclaw() -> Result<(TempDir, PathBuf), Box> { let temp_dir = TempDir::new()?; let openclaw_path = temp_dir.path().to_path_buf(); @@ -63,17 +63,16 @@ mod import_integration_tests { let agents_dir = openclaw_path.join("agents"); std::fs::create_dir_all(&agents_dir)?; - create_test_agent_db(&agents_dir.join("agent1.sqlite"))?; - create_test_agent_db(&agents_dir.join("agent2.sqlite"))?; + create_test_agent_db(&agents_dir.join("agent1.sqlite")).await?; + create_test_agent_db(&agents_dir.join("agent2.sqlite")).await?; Ok((temp_dir, openclaw_path)) } - /// Helper: Create a test agent SQLite database - fn create_test_agent_db(db_path: &PathBuf) -> Result<(), Box> { - use rusqlite::Connection; - - let conn = Connection::open(db_path)?; + /// Helper: Create a test agent SQLite database using libsql + async fn create_test_agent_db(db_path: &PathBuf) -> Result<(), Box> { + let db = libsql::Builder::new_local(db_path).build().await?; + let conn = db.connect()?; // Chunks table conn.execute( @@ -84,27 +83,30 @@ mod import_integration_tests { embedding BLOB, chunk_index INTEGER NOT NULL )", - [], - )?; + (), + ) + .await?; for i in 0..3 { conn.execute( - "INSERT INTO chunks (id, path, content, embedding, chunk_index) VALUES (?, ?, ?, ?, ?)", - rusqlite::params![ + "INSERT INTO chunks (id, path, content, embedding, chunk_index) VALUES (?1, ?2, ?3, ?4, ?5)", + libsql::params![ Uuid::new_v4().to_string(), format!("doc/section_{}.md", i), format!("Chunk {} content", i), - None::>, - i + libsql::Value::Null, + i as i64 ], - )?; + ) + .await?; } // Conversations conn.execute( "CREATE TABLE conversations (id TEXT PRIMARY KEY, channel TEXT, created_at TEXT)", - [], - )?; + (), + ) + .await?; conn.execute( "CREATE TABLE messages ( @@ -114,26 +116,29 @@ mod import_integration_tests { content TEXT NOT NULL, created_at TEXT )", - [], - )?; + (), + ) + .await?; let conv_id = Uuid::new_v4().to_string(); conn.execute( - "INSERT INTO conversations VALUES (?, ?, ?)", - rusqlite::params![&conv_id, "slack", "2024-01-15T10:00:00Z"], - )?; + "INSERT INTO conversations VALUES (?1, ?2, ?3)", + libsql::params![conv_id.as_str(), "slack", "2024-01-15T10:00:00Z"], + ) + .await?; for j in 0..2 { conn.execute( - "INSERT INTO messages (id, conversation_id, role, content, created_at) VALUES (?, ?, ?, ?, ?)", - rusqlite::params![ + "INSERT INTO messages (id, conversation_id, role, content, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", + libsql::params![ Uuid::new_v4().to_string(), - &conv_id, + conv_id.as_str(), if j % 2 == 0 { "user" } else { "assistant" }, format!("Message {}", j), format!("2024-01-15T10:{:02}:00Z", j) ], - )?; + ) + .await?; } Ok(()) @@ -146,8 +151,9 @@ mod import_integration_tests { #[tokio::test] async fn test_full_import_with_database_writes() { let (db, _db_temp) = create_test_db().await.expect("DB creation failed"); - let (_openclaw_temp, openclaw_path) = - create_test_openclaw().expect("OpenClaw creation failed"); + let (_openclaw_temp, openclaw_path) = create_test_openclaw() + .await + .expect("OpenClaw creation failed"); // Verify DB starts empty let before_docs = db @@ -175,12 +181,14 @@ mod import_integration_tests { // Read chunks from first agent let chunks = reader .read_memory_chunks(&agent_dbs[0].1) + .await .expect("read chunks failed"); assert_eq!(chunks.len(), 3); // 3 chunks created // Read conversations from first agent let conversations = reader .read_conversations(&agent_dbs[0].1) + .await .expect("read conversations failed"); assert_eq!(conversations.len(), 1); // 1 conversation created assert_eq!(conversations[0].messages.len(), 2); // 2 messages @@ -192,12 +200,13 @@ mod import_integration_tests { #[tokio::test] async fn test_import_command_execution() { - let (_openclaw_temp, openclaw_path) = - create_test_openclaw().expect("OpenClaw creation failed"); + let (_openclaw_temp, openclaw_path) = create_test_openclaw() + .await + .expect("OpenClaw creation failed"); let (_db, _db_temp) = create_test_db().await.expect("DB creation failed"); // Create import options - let opts = ImportOptions { + let opts = ironclaw::import::ImportOptions { openclaw_path: openclaw_path.clone(), dry_run: false, re_embed: false, @@ -222,8 +231,9 @@ mod import_integration_tests { #[tokio::test] async fn test_dry_run_prevents_database_writes() { let (db, _db_temp) = create_test_db().await.expect("DB creation failed"); - let (_openclaw_temp, openclaw_path) = - create_test_openclaw().expect("OpenClaw creation failed"); + let (_openclaw_temp, openclaw_path) = create_test_openclaw() + .await + .expect("OpenClaw creation failed"); let user_id = "test_user"; @@ -235,7 +245,7 @@ mod import_integration_tests { let before_count = before_import.len(); // Create import options in DRY-RUN mode - let opts = ImportOptions { + let opts = ironclaw::import::ImportOptions { openclaw_path: openclaw_path.clone(), dry_run: true, // ← KEY: dry_run is enabled re_embed: false, @@ -266,8 +276,9 @@ mod import_integration_tests { #[tokio::test] async fn test_import_idempotency_no_duplicates_on_reimport() { let (_db, _db_temp) = create_test_db().await.expect("DB creation failed"); - let (_openclaw_temp, openclaw_path) = - create_test_openclaw().expect("OpenClaw creation failed"); + let (_openclaw_temp, openclaw_path) = create_test_openclaw() + .await + .expect("OpenClaw creation failed"); // Simulate first import: count what would be imported let reader1 = OpenClawReader::new(&openclaw_path).expect("reader creation failed"); @@ -282,11 +293,13 @@ mod import_integration_tests { for (_, db_path) in &agent_dbs1 { let chunks = reader1 .read_memory_chunks(db_path) + .await .expect("read chunks failed"); total_chunks_first += chunks.len(); let conversations = reader1 .read_conversations(db_path) + .await .expect("read conversations failed"); total_conversations_first += conversations.len(); } @@ -330,8 +343,9 @@ mod import_integration_tests { #[tokio::test] async fn test_embedding_dimension_mismatch_queues_reembedding() { - let (_openclaw_temp, openclaw_path) = - create_test_openclaw().expect("OpenClaw creation failed"); + let (_openclaw_temp, openclaw_path) = create_test_openclaw() + .await + .expect("OpenClaw creation failed"); // Create an agent DB with embeddings (1536-dim) let agents_dir = openclaw_path.join("agents"); @@ -339,8 +353,11 @@ mod import_integration_tests { let db_path = agents_dir.join("with_embeddings.sqlite"); { - use rusqlite::Connection; - let conn = Connection::open(&db_path).expect("db open failed"); + let db = libsql::Builder::new_local(&db_path) + .build() + .await + .expect("db build failed"); + let conn = db.connect().expect("db connect failed"); conn.execute( "CREATE TABLE chunks ( @@ -350,33 +367,36 @@ mod import_integration_tests { embedding BLOB, chunk_index INTEGER NOT NULL )", - [], + (), ) + .await .expect("create table failed"); // Create a 1536-dimensional embedding (ada-002 size) // Each f32 is 4 bytes, so 1536 * 4 = 6144 bytes - let embedding_1536_bytes = vec![0.1f32; 1536] + let embedding_1536_bytes: Vec = vec![0.1f32; 1536] .iter() .flat_map(|f| f.to_le_bytes().to_vec()) - .collect::>(); + .collect(); conn.execute( - "INSERT INTO chunks (id, path, content, embedding, chunk_index) VALUES (?, ?, ?, ?, ?)", - rusqlite::params![ + "INSERT INTO chunks (id, path, content, embedding, chunk_index) VALUES (?1, ?2, ?3, ?4, ?5)", + libsql::params![ Uuid::new_v4().to_string(), "test.md", "Chunk with embedding", - &embedding_1536_bytes, - 0 + embedding_1536_bytes, + 0i64 ], ) + .await .expect("insert failed"); conn.execute( "CREATE TABLE conversations (id TEXT PRIMARY KEY, channel TEXT, created_at TEXT)", - [], + (), ) + .await .expect("create conv table failed"); conn.execute( @@ -387,8 +407,9 @@ mod import_integration_tests { content TEXT NOT NULL, created_at TEXT )", - [], + (), ) + .await .expect("create messages table failed"); } @@ -396,6 +417,7 @@ mod import_integration_tests { let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed"); let chunks = reader .read_memory_chunks(&db_path) + .await .expect("read chunks failed"); assert_eq!(chunks.len(), 1); @@ -417,16 +439,10 @@ mod import_integration_tests { } // Simulate dimension mismatch scenario: - // - Source: 1536-dim (ada-002) - // - Target: 3072-dim (text-embedding-3-large) - // This would trigger re-embedding logic - let source_dim = embedding.len(); let target_dim = 3072; // text-embedding-3-large if source_dim != target_dim { - // In real import, this would queue the chunk for re-embedding - // Verify the logic: dimensions don't match, so chunk needs re-embedding assert!( source_dim != target_dim, "Dimension mismatch detected: {} -> {}", @@ -434,7 +450,6 @@ mod import_integration_tests { target_dim ); - // Track that this chunk would need re-embedding let mut re_embed_queued = 0; if source_dim != target_dim { re_embed_queued += 1; @@ -466,8 +481,11 @@ mod import_integration_tests { let db_path = agents_dir.join("same_dim.sqlite"); { - use rusqlite::Connection; - let conn = Connection::open(&db_path).expect("db open failed"); + let db = libsql::Builder::new_local(&db_path) + .build() + .await + .expect("db build failed"); + let conn = db.connect().expect("db connect failed"); conn.execute( "CREATE TABLE chunks ( @@ -477,32 +495,35 @@ mod import_integration_tests { embedding BLOB, chunk_index INTEGER NOT NULL )", - [], + (), ) + .await .expect("create table failed"); // 1536-dimensional embedding (text-embedding-3-small) - let embedding_bytes = vec![0.5f32; 1536] + let embedding_bytes: Vec = vec![0.5f32; 1536] .iter() .flat_map(|f| f.to_le_bytes().to_vec()) - .collect::>(); + .collect(); conn.execute( - "INSERT INTO chunks (id, path, content, embedding, chunk_index) VALUES (?, ?, ?, ?, ?)", - rusqlite::params![ + "INSERT INTO chunks (id, path, content, embedding, chunk_index) VALUES (?1, ?2, ?3, ?4, ?5)", + libsql::params![ Uuid::new_v4().to_string(), "test.md", "Chunk", - &embedding_bytes, - 0 + embedding_bytes, + 0i64 ], ) + .await .expect("insert failed"); conn.execute( "CREATE TABLE conversations (id TEXT PRIMARY KEY, channel TEXT, created_at TEXT)", - [], + (), ) + .await .expect("create conv table failed"); conn.execute( @@ -513,14 +534,16 @@ mod import_integration_tests { content TEXT NOT NULL, created_at TEXT )", - [], + (), ) + .await .expect("create messages table failed"); } let reader = OpenClawReader::new(&openclaw_path).expect("reader creation failed"); let chunks = reader .read_memory_chunks(&db_path) + .await .expect("read chunks failed"); let embedding = chunks[0].embedding.as_ref().unwrap(); From 34550add3ee85bab6fe1c670dff46871d1a41e04 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Wed, 11 Mar 2026 12:04:54 -0700 Subject: [PATCH 6/8] fix(ci): prevent staging-ci tag failure and chained PR auto-close (#900) - Use fetch-depth: 0 in update-tag to ensure current_head SHA is available even when staging receives new commits during the CI run - Only merge promotion PRs targeting main; leave chained PRs open to prevent delete_branch_on_merge from auto-closing downstream PRs Co-authored-by: Claude Opus 4.6 --- .github/workflows/staging-ci.yml | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/.github/workflows/staging-ci.yml b/.github/workflows/staging-ci.yml index 8e3693b2..c8a39c80 100644 --- a/.github/workflows/staging-ci.yml +++ b/.github/workflows/staging-ci.yml @@ -406,6 +406,10 @@ jobs: echo "passed=true" >> "$GITHUB_OUTPUT" fi + # Only merge PRs targeting main. Chained PRs (targeting another + # promotion branch) stay open — when the base PR merges into main, + # GitHub auto-retargets the chained PR. Merging chained PRs would + # trigger delete_branch_on_merge, auto-closing downstream PRs. - name: Merge promotion PR id: merge if: steps.evaluate.outputs.passed == 'true' @@ -414,12 +418,15 @@ jobs: PR_NUMBER: ${{ needs.create-promotion-pr.outputs.pr_number }} run: | if [ -n "$PR_NUMBER" ]; then - echo "Merging promotion PR #${PR_NUMBER}" - # Do NOT use --delete-branch: deleting a promotion branch closes - # any chained PRs that use it as their base (verified in ironclaw-ci-test). - # Stale promotion branches are cleaned up separately. - gh pr merge "$PR_NUMBER" --merge - echo "merged=true" >> "$GITHUB_OUTPUT" + BASE=$(gh pr view "$PR_NUMBER" --json baseRefName --jq '.baseRefName') + if [ "$BASE" = "main" ]; then + echo "Merging promotion PR #${PR_NUMBER} (targets main)" + gh pr merge "$PR_NUMBER" --merge + echo "merged=true" >> "$GITHUB_OUTPUT" + else + echo "PR #${PR_NUMBER} targets '${BASE}' (not main) — leaving open for chain resolution" + echo "merged=false" >> "$GITHUB_OUTPUT" + fi fi # ── Update tested tag (always, so next batch covers only new commits) ── @@ -437,7 +444,7 @@ jobs: - uses: actions/checkout@v6 with: ref: staging - fetch-depth: 1 + fetch-depth: 0 - name: Update staging-tested tag run: | From f08220db8201013acffff05898b261d81f7f0f5e Mon Sep 17 00:00:00 2001 From: Henry Park Date: Wed, 11 Mar 2026 14:04:32 -0700 Subject: [PATCH 7/8] fix(ci): run gated test jobs during staging CI (#956) The telegram-tests, windows-build, wasm-wit-compat, and docker-build jobs were skipped during staging CI because their `if` conditions only matched `push` and `pull_request` events. When staging-ci.yml calls test.yml via workflow_call, github.event_name is `schedule` (inherited from the caller), which matched neither condition. Invert the conditions to blocklist the one case we want to skip (PRs targeting staging) instead of allowlisting specific events. This handles schedule, workflow_dispatch, and any future trigger types. Co-authored-by: Claude Opus 4.6 --- .github/workflows/test.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bb29dd2a..cf6917b0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,8 +42,8 @@ jobs: telegram-tests: name: Telegram Channel Tests if: > - github.event_name == 'push' || - (github.event_name == 'pull_request' && github.base_ref != 'staging') + github.event_name != 'pull_request' || + github.base_ref != 'staging' runs-on: ubuntu-latest steps: - name: Checkout repository @@ -57,8 +57,8 @@ jobs: windows-build: name: Windows Build (${{ matrix.name }}) if: > - github.event_name == 'push' || - (github.event_name == 'pull_request' && github.base_ref != 'staging') + github.event_name != 'pull_request' || + github.base_ref != 'staging' runs-on: windows-latest strategy: fail-fast: false @@ -84,8 +84,8 @@ jobs: wasm-wit-compat: name: WASM WIT Compatibility if: > - github.event_name == 'push' || - (github.event_name == 'pull_request' && github.base_ref != 'staging') + github.event_name != 'pull_request' || + github.base_ref != 'staging' runs-on: ubuntu-latest steps: - name: Checkout repository @@ -107,8 +107,8 @@ jobs: docker-build: name: Docker Build if: > - github.event_name == 'push' || - (github.event_name == 'pull_request' && github.base_ref != 'staging') + github.event_name != 'pull_request' || + github.base_ref != 'staging' runs-on: ubuntu-latest steps: - name: Checkout repository From d313f44a1977a52af023abfdfc52a378fed2c8f0 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Wed, 11 Mar 2026 14:05:33 -0700 Subject: [PATCH 8/8] fix(ci): improve Claude Code review reliability (#955) The Claude review step was failing ~40% of the time because: - --allowedTools didn't include Read, Glob, Grep, Agent, causing 8-9 permission denials per run and preventing Claude from reading files or spawning the subagents the prompt required - Step 4 spawned N additional scoring agents per issue found, exhausting the 50-turn budget before the PR comment could be posted - Subagents could independently post PR comments, causing fragmented output Fix: add missing tools to --allowedTools, merge per-issue scoring into the review agents themselves, and add guardrails ensuring exactly one consolidated comment is always posted. Co-authored-by: Claude Opus 4.6 --- .github/workflows/claude-review.yml | 55 +++++++++++++++++------------ 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index 3836a5f9..26c15d89 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -29,18 +29,36 @@ jobs: with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} allowed_bots: "ironclaw-ci[bot]" - claude_args: "--max-turns 50 --model claude-haiku-4-5-20251001 --allowedTools 'Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh search:*),Bash(git blame:*),Bash(git log:*),Bash(git diff:*)'" + claude_args: "--max-turns 50 --model claude-haiku-4-5-20251001 --allowedTools 'Read,Glob,Grep,Agent,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh search:*),Bash(git blame:*),Bash(git log:*),Bash(git diff:*)'" prompt: | Code review this pull request. Follow these steps precisely: - 1. Use a Haiku agent to find relevant CLAUDE.md files: the root CLAUDE.md - and any CLAUDE.md files in directories whose files this PR modifies. + 1. Find relevant CLAUDE.md files: the root CLAUDE.md and any CLAUDE.md files + in directories whose files this PR modifies. Use Glob to find them, then Read + to load their contents. - 2. Use a Haiku agent to summarize the PR change (use `gh pr diff`). + 2. Get the PR diff with `gh pr diff` and summarize the change. 3. Launch 4 parallel agents to review the change independently. Each agent should read the PR diff with `gh pr diff` and the full source files for changed - code, then return a list of issues found: + code (using Read), then return a list of issues. Each agent MUST score its + own findings inline using the severity and confidence rubric below. + + Severity levels: + - CRITICAL: security vulns, panics in prod (.unwrap/.expect), data exfiltration, race conditions + - HIGH: logic bugs, missing error handling, breaking API/schema changes + - MEDIUM: missing tests, unnecessary complexity, performance issues + - LOW: documentation gaps, naming suggestions + + Confidence scoring (0-100): + 0: False positive, doesn't stand up to scrutiny, or pre-existing issue. + 25: Might be real, but may be false positive. Stylistic issues not in CLAUDE.md. + 50: Real issue but nitpick or rare in practice. Not very important. + 75: Verified real issue, will be hit in practice. Directly impacts functionality + or explicitly mentioned in CLAUDE.md. + 100: Certain, confirmed, will happen frequently. Evidence directly confirms. + + Each agent returns findings as: [SEVERITY:CONFIDENCE] Agent 1 — Security & Safety Check for: command injection, path traversal, SSRF, XSS, auth bypass, @@ -63,22 +81,9 @@ jobs: timeouts, resource leaks (file handles, connections), large allocations in hot paths. - 4. For each issue found, launch a parallel Haiku agent to: - a. Assign a severity: - - CRITICAL: security vulns, panics in prod (.unwrap/.expect), data exfiltration, race conditions - - HIGH: logic bugs, missing error handling, breaking API/schema changes - - MEDIUM: missing tests, unnecessary complexity, performance issues - - LOW: documentation gaps, naming suggestions - b. Score confidence 0-100 (give this rubric verbatim): - 0: False positive, doesn't stand up to scrutiny, or pre-existing issue. - 25: Might be real, but may be false positive. Stylistic issues not in CLAUDE.md. - 50: Real issue but nitpick or rare in practice. Not very important. - 75: Verified real issue, will be hit in practice. Directly impacts functionality - or explicitly mentioned in CLAUDE.md. - 100: Certain, confirmed, will happen frequently. Evidence directly confirms. - - 5. Post a single comment on the PR using `gh pr comment` with this format. - If no issues were found, post "No issues found." instead: + 4. Consolidate all agent findings and post exactly one comment on the PR + using `gh pr comment` with this format. If no issues were found, + post "No issues found." instead: ### Code review @@ -93,8 +98,12 @@ jobs: You MUST use the full git SHA in links (not HEAD or branch name). Provide 1 line of context before and after each linked range. - Notes: - - Use `gh` for all GitHub interactions, not web fetch + IMPORTANT rules: + - Only YOU (the main process) may call `gh pr comment`. Agents must return + their findings to you — they must NOT post comments themselves. + - You MUST post exactly one `gh pr comment` before finishing, even if agents + fail or return empty results. If review is incomplete, post "No issues found." + - Use Read/Glob for file access, `gh` for GitHub interactions, not web fetch - Do NOT check build signal or attempt to build/test the code - Ignore pre-existing issues not introduced by this PR - Ignore issues a linter/compiler would catch (formatting, imports, types)