From e9b0823db90f3229ca4a064ef0f1ae799e9bf6db Mon Sep 17 00:00:00 2001 From: Nige Date: Wed, 18 Mar 2026 18:33:31 +0000 Subject: [PATCH 1/8] fix(setup): remove nonexistent webhook secret command hint (#1349) * fix(setup): remove nonexistent webhook secret command hint * test(setup): cover webhook secret onboarding hint --- src/setup/channels.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/setup/channels.rs b/src/setup/channels.rs index 1c184b0b..2612076d 100644 --- a/src/setup/channels.rs +++ b/src/setup/channels.rs @@ -518,7 +518,7 @@ pub async fn setup_http(secrets: &SecretsContext) -> Result String { generate_secret_with_length(32) } +fn http_webhook_secret_hint() -> &'static str { + "The secret is stored in the encrypted secrets database and will be loaded automatically on startup." +} + fn validate_e164(account: &str) -> Result<(), String> { if !account.starts_with('+') { return Err("E.164 account must start with '+'".to_string()); @@ -1136,8 +1140,9 @@ mod tests { use crate::secrets::{InMemorySecretsStore, SecretsCrypto, SecretsStore}; use crate::setup::channels::{ - SecretsContext, generate_webhook_secret, substitute_validation_placeholders, - validate_cloudflare_token_format, validate_public_https_url, + SecretsContext, generate_webhook_secret, http_webhook_secret_hint, + substitute_validation_placeholders, validate_cloudflare_token_format, + validate_public_https_url, }; fn test_secrets_context() -> SecretsContext { @@ -1337,4 +1342,12 @@ mod tests { .to_string(); assert!(err.contains("DNS resolution failed")); } + + #[test] + fn test_http_webhook_secret_hint_reflects_current_behavior() { + let hint = http_webhook_secret_hint(); + assert!(hint.contains("encrypted secrets database")); + assert!(hint.contains("loaded automatically on startup")); + assert!(!hint.contains("ironclaw secret get")); + } } From bedc71ebdcdc93a605f3bce8e724c78893d090ff Mon Sep 17 00:00:00 2001 From: Nige Date: Wed, 18 Mar 2026 18:33:38 +0000 Subject: [PATCH 2/8] fix(llm): cap retry-after delays (#1351) * fix(llm): cap retry-after delays * Update src/llm/retry.rs Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/llm/anthropic_oauth.rs | 12 ++++++++++-- src/llm/nearai_chat.rs | 28 ++++++++++++++++++---------- src/llm/retry.rs | 23 +++++++++++++++++++++++ src/workspace/embeddings.rs | 5 +++++ 4 files changed, 56 insertions(+), 12 deletions(-) diff --git a/src/llm/anthropic_oauth.rs b/src/llm/anthropic_oauth.rs index ae6674dc..8c701101 100644 --- a/src/llm/anthropic_oauth.rs +++ b/src/llm/anthropic_oauth.rs @@ -22,6 +22,7 @@ use crate::llm::provider::{ ToolCompletionRequest, ToolCompletionResponse, strip_unsupported_completion_params, strip_unsupported_tool_params, }; +use crate::llm::retry::cap_retry_after; const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages"; /// OAuth beta requires 2023-06-01; the 2024-10-22 version is not valid with the beta flag. @@ -150,6 +151,7 @@ impl AnthropicOAuthProvider { .and_then(|v| v.to_str().ok()) .and_then(|v| v.parse::().ok()) .map(std::time::Duration::from_secs) + .map(cap_retry_after) .or(Some(std::time::Duration::from_secs(60))); let response_text = response @@ -766,9 +768,14 @@ mod tests { #[test] fn test_retry_after_large_number() { - // Verify large numbers are accepted + // Verify large numbers are capped to the safe maximum let duration = parse_retry_after_anthropic_for_test("7200"); // 2 hours - assert_eq!(duration, Some(std::time::Duration::from_secs(7200))); + assert_eq!( + duration, + Some(std::time::Duration::from_secs( + crate::llm::retry::MAX_RETRY_AFTER_SECS + )) + ); } /// Helper function to test Retry-After header parsing logic for Anthropic @@ -779,6 +786,7 @@ mod tests { .parse::() .ok() .map(std::time::Duration::from_secs) + .map(cap_retry_after) .or(Some(std::time::Duration::from_secs(60))) } } diff --git a/src/llm/nearai_chat.rs b/src/llm/nearai_chat.rs index 0a9e1fdc..f0d711a9 100644 --- a/src/llm/nearai_chat.rs +++ b/src/llm/nearai_chat.rs @@ -22,7 +22,7 @@ use crate::llm::provider::{ ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse, }; -use crate::llm::{costs, session::SessionManager}; +use crate::llm::{costs, retry::cap_retry_after, session::SessionManager}; /// Information about an available model from NEAR AI API. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -252,7 +252,7 @@ impl NearAiChatProvider { .and_then(|v| { // Try delay-seconds first (most common from API providers) if let Ok(secs) = v.trim().parse::() { - return Some(std::time::Duration::from_secs(secs)); + return Some(cap_retry_after(std::time::Duration::from_secs(secs))); } // Try HTTP-date (e.g. "Mon, 02 Mar 2026 18:00:00 GMT") if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(v.trim()) { @@ -260,9 +260,9 @@ impl NearAiChatProvider { let delta = dt.signed_duration_since(now); // Use max(0) so past/present dates yield Duration::ZERO // rather than None (which would cause an immediate retry). - return Some(std::time::Duration::from_secs( - delta.num_seconds().max(0) as u64 - )); + return Some(cap_retry_after(std::time::Duration::from_secs( + delta.num_seconds().max(0) as u64, + ))); } None }) @@ -2306,9 +2306,17 @@ mod tests { #[test] fn test_retry_after_large_number() { - // Verify large numbers are accepted + // Verify large numbers are capped to the safe maximum let duration = parse_retry_after_for_test("3600"); // 1 hour assert_eq!(duration, Some(std::time::Duration::from_secs(3600))); + + let huge = parse_retry_after_for_test("18446744073709551615"); + assert_eq!( + huge, + Some(std::time::Duration::from_secs( + crate::llm::retry::MAX_RETRY_AFTER_SECS + )) + ); } /// Helper function to test Retry-After header parsing logic @@ -2316,13 +2324,13 @@ mod tests { fn parse_retry_after_for_test(header_value: &str) -> Option { let trimmed = header_value.trim(); let parsed = if let Ok(secs) = trimmed.parse::() { - Some(std::time::Duration::from_secs(secs)) + Some(cap_retry_after(std::time::Duration::from_secs(secs))) } else if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(trimmed) { let now = chrono::Utc::now(); let delta = dt.signed_duration_since(now); - Some(std::time::Duration::from_secs( - delta.num_seconds().max(0) as u64 - )) + Some(cap_retry_after(std::time::Duration::from_secs( + delta.num_seconds().max(0) as u64, + ))) } else { None }; diff --git a/src/llm/retry.rs b/src/llm/retry.rs index 2875fbd3..6250de33 100644 --- a/src/llm/retry.rs +++ b/src/llm/retry.rs @@ -19,6 +19,12 @@ use crate::llm::provider::{ ToolCompletionResponse, }; +/// Upper bound for provider-suggested `Retry-After` delays. +/// +/// This prevents malicious or malformed headers from turning a retryable +/// response into an effectively unbounded sleep. +pub(crate) const MAX_RETRY_AFTER_SECS: u64 = 3600; + /// Returns `true` if the `LlmError` is transient and the request should be retried. /// /// Used by `RetryProvider` (retry the same provider) and `FailoverProvider` @@ -67,6 +73,11 @@ pub(crate) fn retry_backoff_delay(attempt: u32) -> Duration { Duration::from_millis(delay_ms) } +/// Clamp a provider-suggested retry delay to a safe maximum. +pub(crate) fn cap_retry_after(duration: Duration) -> Duration { + duration.min(Duration::from_secs(MAX_RETRY_AFTER_SECS)) +} + /// Configuration for the retry decorator. #[derive(Debug, Clone)] pub struct RetryConfig { @@ -421,4 +432,16 @@ mod tests { panic!("Expected RateLimited error"); } } + + #[test] + fn cap_retry_after_clamps_huge_delays() { + assert_eq!( + cap_retry_after(Duration::from_secs(u64::MAX)), + Duration::from_secs(MAX_RETRY_AFTER_SECS) + ); + assert_eq!( + cap_retry_after(Duration::from_secs(0)), + Duration::from_secs(0) + ); + } } diff --git a/src/workspace/embeddings.rs b/src/workspace/embeddings.rs index 96fe144b..a8ed0a3e 100644 --- a/src/workspace/embeddings.rs +++ b/src/workspace/embeddings.rs @@ -6,6 +6,8 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; +use crate::llm::retry::cap_retry_after; + /// Error type for embedding operations. #[derive(Debug, thiserror::Error)] pub enum EmbeddingError { @@ -232,6 +234,7 @@ impl EmbeddingProvider for OpenAiEmbeddings { .and_then(|v| v.to_str().ok()) .and_then(|s| s.parse::().ok()) .map(std::time::Duration::from_secs) + .map(cap_retry_after) .or(Some(std::time::Duration::from_secs(60))); return Err(EmbeddingError::RateLimited { retry_after }); } @@ -374,6 +377,7 @@ impl EmbeddingProvider for NearAiEmbeddings { .and_then(|v| v.to_str().ok()) .and_then(|s| s.parse::().ok()) .map(std::time::Duration::from_secs) + .map(cap_retry_after) .or(Some(std::time::Duration::from_secs(60))); return Err(EmbeddingError::RateLimited { retry_after }); } @@ -690,6 +694,7 @@ mod tests { .parse::() .ok() .map(std::time::Duration::from_secs) + .map(cap_retry_after) .or(Some(std::time::Duration::from_secs(60))) } } From 33a2dd2c78b25b3f333b9924ae7186bf637ac83f Mon Sep 17 00:00:00 2001 From: Nige Date: Wed, 18 Mar 2026 18:33:45 +0000 Subject: [PATCH 3/8] fix(telegram): preserve polling after secret-blocked updates (#1353) * fix(telegram): preserve polling after secret-blocked updates * style(telegram): simplify polling leak-scan guard * style(telegram): satisfy clippy for poll leak guard --- src/channels/wasm/wrapper.rs | 41 ++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs index 6ca79831..65f978ac 100644 --- a/src/channels/wasm/wrapper.rs +++ b/src/channels/wasm/wrapper.rs @@ -492,8 +492,16 @@ impl near::agent::channel_host::Host for ChannelStoreData { tracing::debug!(body = %truncated, "Response body"); } - // Leak detection on response body (best-effort) - if let Ok(body_str) = std::str::from_utf8(&body) { + // Leak detection on response body (best-effort). + // + // Telegram `getUpdates` is special: it is inbound polling data, so + // user-pasted secrets can legitimately appear in the response body. + // Those messages are still checked later by the inbound message + // safety layer before they reach the LLM, so we allow the polling + // response to continue here to avoid poisoning the offset state. + if let Ok(body_str) = std::str::from_utf8(&body) + && !should_skip_response_leak_scan(&url) + { leak_detector .scan_and_clean(body_str) .map_err(|e| format!("Potential secret leak in response: {}", e))?; @@ -3122,6 +3130,19 @@ fn extract_host_from_url(url: &str) -> Option { }) } +fn should_skip_response_leak_scan(url: &str) -> bool { + url::Url::parse(url).is_ok_and(|parsed| { + matches!(parsed.scheme(), "http" | "https") + && parsed + .host_str() + .is_some_and(|host| host.eq_ignore_ascii_case("api.telegram.org")) + && parsed + .path_segments() + .and_then(|segments| segments.rev().find(|segment| !segment.is_empty())) + .is_some_and(|segment| segment == "getUpdates") + }) +} + /// Pre-resolve host credentials for all HTTP capability mappings. /// /// Called once per callback (in async context, before spawn_blocking) so the @@ -4386,6 +4407,22 @@ mod tests { assert_eq!(store.redact_credentials(input), input); } + #[test] + fn test_should_skip_response_leak_scan_only_for_telegram_getupdates() { + use super::should_skip_response_leak_scan; + + assert!(should_skip_response_leak_scan( + "https://api.telegram.org/bot123/getUpdates?offset=1" + )); + assert!(!should_skip_response_leak_scan( + "https://api.telegram.org/bot123/sendMessage" + )); + assert!(!should_skip_response_leak_scan( + "https://api.example.com/getUpdates" + )); + assert!(!should_skip_response_leak_scan("not a url")); + } + /// Verify that WASM HTTP host functions work using a dedicated /// current-thread runtime inside spawn_blocking. #[tokio::test] From 0be591028add18965ce142bf195f38f33fc11d64 Mon Sep 17 00:00:00 2001 From: Nige Date: Wed, 18 Mar 2026 18:33:51 +0000 Subject: [PATCH 4/8] fix(mcp): retry after missing session id errors (#1355) --- src/tools/mcp/client.rs | 247 +++++++++++++++++++++++++++++++++------- 1 file changed, 205 insertions(+), 42 deletions(-) diff --git a/src/tools/mcp/client.rs b/src/tools/mcp/client.rs index c299ac49..148f5a86 100644 --- a/src/tools/mcp/client.rs +++ b/src/tools/mcp/client.rs @@ -288,6 +288,71 @@ impl McpClient { Ok(headers) } + /// Re-run the MCP initialize handshake outside the OnceCell cache. + /// + /// This is used for recoverable session-expiry failures when an MCP server + /// reports that the current session ID is no longer valid. + async fn reinitialize_session(&self) -> Result { + if let Some(ref session_manager) = self.session_manager { + session_manager.terminate(&self.server_name).await; + session_manager + .get_or_create(&self.server_name, &self.server_url) + .await; + } + + let request = McpRequest::initialize(self.next_request_id()); + let response = self + .transport + .send(&request, &self.build_request_headers().await?) + .await?; + + if let Some(error) = response.error { + return Err(ToolError::ExternalService(format!( + "MCP initialization error: {} (code {})", + error.message, error.code + ))); + } + + let init_result: InitializeResult = response + .result + .ok_or_else(|| { + ToolError::ExternalService("No result in initialize response".to_string()) + }) + .and_then(|r| { + serde_json::from_value(r).map_err(|e| { + ToolError::ExternalService(format!("Invalid initialize result: {}", e)) + }) + })?; + + if let Some(ref session_manager) = self.session_manager { + session_manager.mark_initialized(&self.server_name).await; + } + + let notification = McpRequest::initialized_notification(); + if let Err(e) = self + .transport + .send(¬ification, &self.build_request_headers().await?) + .await + { + tracing::debug!( + "Failed to send initialized notification to '{}': {}", + self.server_name, + e + ); + } + + Ok(init_result) + } + + /// Return true when the error looks like a recoverable MCP session expiry. + fn is_session_expiry_error(message: &str) -> bool { + let lower = message.to_ascii_lowercase(); + lower.contains("session") + && (lower.contains("400") + || lower.contains("missing session id") + || lower.contains("no valid session id")) + } + /// Send a request to the MCP server with auth and session headers. /// Automatically attempts token refresh on 401 errors (HTTP transports only). async fn send_request(&self, request: McpRequest) -> Result { @@ -297,13 +362,26 @@ impl McpClient { return self.transport.send(&request, &headers).await; } - // HTTP transport: try up to 2 times (first attempt, then retry after token refresh) + // HTTP transport: try up to 2 times (first attempt, then retry after token refresh + // or recoverable session reinitialization). for attempt in 0..2 { let headers = self.build_request_headers().await?; let result = self.transport.send(&request, &headers).await; match result { Ok(response) => return Ok(response), + Err(ToolError::ExternalService(ref msg)) + if attempt == 0 + && self.session_manager.is_some() + && Self::is_session_expiry_error(msg) => + { + tracing::debug!( + "MCP session expired, attempting reinitialize for '{}'", + self.server_name + ); + self.reinitialize_session().await?; + continue; + } Err(ToolError::ExternalService(ref msg)) if msg.contains("401") || msg.contains("Unauthorized") @@ -362,47 +440,7 @@ impl McpClient { { return Ok(InitializeResult::default()); } - if let Some(ref session_manager) = self.session_manager { - session_manager - .get_or_create(&self.server_name, &self.server_url) - .await; - } - - let request = McpRequest::initialize(self.next_request_id()); - let response = self.send_request(request).await?; - - if let Some(error) = response.error { - return Err(ToolError::ExternalService(format!( - "MCP initialization error: {} (code {})", - error.message, error.code - ))); - } - - let init_result: InitializeResult = response - .result - .ok_or_else(|| { - ToolError::ExternalService("No result in initialize response".to_string()) - }) - .and_then(|r| { - serde_json::from_value(r).map_err(|e| { - ToolError::ExternalService(format!("Invalid initialize result: {}", e)) - }) - })?; - - if let Some(ref session_manager) = self.session_manager { - session_manager.mark_initialized(&self.server_name).await; - } - - let notification = McpRequest::initialized_notification(); - if let Err(e) = self.send_request(notification).await { - tracing::debug!( - "Failed to send initialized notification to '{}': {}", - self.server_name, - e - ); - } - - Ok(init_result) + self.reinitialize_session().await }) .await?; @@ -865,6 +903,54 @@ mod tests { } } + /// Mock transport that can return errors and successful responses in a + /// controlled sequence. + struct RetryMockTransport { + supports_http: bool, + outcomes: std::sync::Mutex>>, + recorded_headers: std::sync::Mutex>>, + } + + impl RetryMockTransport { + fn new(supports_http: bool, outcomes: Vec>) -> Self { + Self { + supports_http, + outcomes: std::sync::Mutex::new(outcomes.into()), + recorded_headers: std::sync::Mutex::new(Vec::new()), + } + } + + fn recorded_headers(&self) -> Vec> { + self.recorded_headers.lock().unwrap().clone() + } + } + + #[async_trait] + impl McpTransport for RetryMockTransport { + async fn send( + &self, + _request: &McpRequest, + headers: &HashMap, + ) -> Result { + self.recorded_headers.lock().unwrap().push(headers.clone()); + let mut outcomes = self.outcomes.lock().unwrap(); + if outcomes.is_empty() { + return Err(ToolError::ExternalService( + "No more mock outcomes".to_string(), + )); + } + outcomes.pop_front().unwrap() + } + + async fn shutdown(&self) -> Result<(), ToolError> { + Ok(()) + } + + fn supports_http_features(&self) -> bool { + self.supports_http + } + } + #[tokio::test] async fn test_non_http_transport_skips_401_retry() { // initialize response, then notification ack (consumed but ignored), @@ -965,6 +1051,83 @@ mod tests { assert_eq!(transport.recorded_headers().len(), 2); // no additional sends } + #[tokio::test] + async fn test_http_session_error_triggers_reinitialize_and_retry() { + let init_response = McpResponse { + jsonrpc: "2.0".to_string(), + id: Some(1), + result: Some(serde_json::json!({ + "protocolVersion": "2024-11-05", + "capabilities": {}, + "serverInfo": {"name": "test", "version": "1.0"} + })), + error: None, + }; + let notification_ack = McpResponse { + jsonrpc: "2.0".to_string(), + id: None, + result: None, + error: None, + }; + let notification_ack2 = notification_ack.clone(); + let session_error = Err(ToolError::ExternalService( + "[test] MCP server returned status: 400 - No valid session ID provided".to_string(), + )); + let reinit_response = McpResponse { + jsonrpc: "2.0".to_string(), + id: Some(2), + result: Some(serde_json::json!({ + "protocolVersion": "2024-11-05", + "capabilities": {}, + "serverInfo": {"name": "test", "version": "1.0"} + })), + error: None, + }; + let call_response = McpResponse { + jsonrpc: "2.0".to_string(), + id: Some(3), + result: Some(serde_json::json!({ + "content": [{"type": "text", "text": "pong"}], + "is_error": false + })), + error: None, + }; + + let transport = Arc::new(RetryMockTransport::new( + true, + vec![ + Ok(init_response), + Ok(notification_ack), + session_error, + Ok(reinit_response), + Ok(notification_ack2), + Ok(call_response), + ], + )); + let session_manager = Arc::new(McpSessionManager::new()); + let client = McpClient::new_with_transport( + "test-http", + transport.clone(), + Some(session_manager), + None, + "default", + None, + ); + + client.initialize().await.expect("initial handshake"); + + let result = client + .call_tool("echo", serde_json::json!({"input": "hello"})) + .await + .expect("call should recover after session expiry"); + assert!(!result.is_error); + assert_eq!(result.content.len(), 1); + assert_eq!(result.content[0].as_text(), Some("pong")); + + let headers = transport.recorded_headers(); + assert_eq!(headers.len(), 6); + } + #[test] fn test_strip_top_level_nulls_removes_null_fields() { let input = serde_json::json!({ From 92869785474f4fe5fea9d95fedddb8f728ceaa19 Mon Sep 17 00:00:00 2001 From: CPU-216 <3125034290@stu.cpu.edu.cn> Date: Thu, 19 Mar 2026 02:33:58 +0800 Subject: [PATCH 5/8] chore(ci): add coverage gates via codecov.yml (#1228) (#1291) - Project target: 80% with 2% threshold (was: auto with 1%) - Patch target: 90% (was: 80% with 5% threshold) - Add PR comment config with reach/diff/flags layout - Enable require_changes to reduce comment noise --- codecov.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/codecov.yml b/codecov.yml index 3e31b00a..723c1175 100644 --- a/codecov.yml +++ b/codecov.yml @@ -2,9 +2,13 @@ coverage: status: project: default: - target: auto - threshold: 1% + target: 80% + threshold: 2% patch: default: - target: 80% - threshold: 5% \ No newline at end of file + target: 90% + +comment: + layout: "reach,diff,flags" + behavior: default + require_changes: true From 2d0b195321531618fdb728f0db178edf9cf2745c Mon Sep 17 00:00:00 2001 From: Octopus Date: Wed, 18 Mar 2026 13:34:05 -0500 Subject: [PATCH 6/8] feat: upgrade MiniMax default model to M2.7 (#1357) * feat: upgrade MiniMax default model to M2.7 - Add MiniMax-M2.7 and MiniMax-M2.7-highspeed to model list - Set MiniMax-M2.7 as default model - Keep all previous models as alternatives - Update related tests * fix: use canonical model name in test per review Use MiniMax-M2.7-highspeed (canonical casing) in the reasoning models test for consistency with the documentation and provider configuration. [skip-regression-check] --- .env.example | 2 +- docs/LLM_PROVIDERS.md | 4 ++-- providers.json | 4 ++-- src/llm/reasoning_models.rs | 2 ++ 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 55c3adb5..8fd44c5a 100644 --- a/.env.example +++ b/.env.example @@ -78,7 +78,7 @@ NEARAI_AUTH_URL=https://private.near.ai # === MiniMax === # LLM_BACKEND=minimax # MINIMAX_API_KEY=... -# MINIMAX_MODEL=MiniMax-M2.5 +# MINIMAX_MODEL=MiniMax-M2.7 # MINIMAX_BASE_URL=https://api.minimax.io/v1 # default (global); use https://api.minimaxi.com/v1 for China # === Anthropic Direct === diff --git a/docs/LLM_PROVIDERS.md b/docs/LLM_PROVIDERS.md index a581a56b..0623ce25 100644 --- a/docs/LLM_PROVIDERS.md +++ b/docs/LLM_PROVIDERS.md @@ -15,7 +15,7 @@ configurations. | io.net | `ionet` | `IONET_API_KEY` | Intelligence API | | Mistral | `mistral` | `MISTRAL_API_KEY` | Mistral models | | Yandex AI Studio | `yandex` | `YANDEX_API_KEY` | YandexGPT models | -| MiniMax | `minimax` | `MINIMAX_API_KEY` | MiniMax-M2.5 models | +| MiniMax | `minimax` | `MINIMAX_API_KEY` | MiniMax-M2.7 models | | Cloudflare Workers AI | `cloudflare` | `CLOUDFLARE_API_KEY` | Access to Workers AI | | Ollama | `ollama` | No | Local inference | | AWS Bedrock | `bedrock` | AWS credentials | Native Converse API | @@ -84,7 +84,7 @@ LLM_BACKEND=minimax MINIMAX_API_KEY=... ``` -Available models: `MiniMax-M2.5` (default), `MiniMax-M2.5-highspeed` +Available models: `MiniMax-M2.7` (default), `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed` To use the China mainland endpoint, set: diff --git a/providers.json b/providers.json index 12723a6f..550edd64 100644 --- a/providers.json +++ b/providers.json @@ -393,8 +393,8 @@ "api_key_required": true, "base_url_env": "MINIMAX_BASE_URL", "model_env": "MINIMAX_MODEL", - "default_model": "MiniMax-M2.5", - "description": "MiniMax API (MiniMax-M2.5 and MiniMax-M2.5-highspeed models)", + "default_model": "MiniMax-M2.7", + "description": "MiniMax API (MiniMax-M2.7, MiniMax-M2.7-highspeed, MiniMax-M2.5 and MiniMax-M2.5-highspeed models)", "setup": { "kind": "api_key", "secret_name": "llm_minimax_api_key", diff --git a/src/llm/reasoning_models.rs b/src/llm/reasoning_models.rs index 307cb0a3..ab691086 100644 --- a/src/llm/reasoning_models.rs +++ b/src/llm/reasoning_models.rs @@ -108,6 +108,8 @@ mod tests { assert!(has_native_thinking("nanbeige-4.1-3b")); assert!(has_native_thinking("step-3.5-flash-197b")); assert!(has_native_thinking("minimax-m2.5-139b")); + assert!(has_native_thinking("MiniMax-M2.7")); + assert!(has_native_thinking("MiniMax-M2.7-highspeed")); } #[test] From 07e6e30ee3e6dd1ecbdbf46a65e08e50d16e82fe Mon Sep 17 00:00:00 2001 From: Nitanshu Lokhande <56120084+nlok5923@users.noreply.github.com> Date: Thu, 19 Mar 2026 00:04:11 +0530 Subject: [PATCH 7/8] fix: add debug_assert invariant guards to critical code paths (#1312) * fix: add debug_assert invariant guards to critical code paths (closes #1215) Add three debug_assert! calls to catch impossible-in-correct-code states early in debug builds without affecting release performance: - execute_tool_with_safety: assert tool_name is non-empty at entry - JobContext::transition_to: assert state machine transition is valid - CircuitBreakerProvider::record_success: assert circuit is not Open (check_allowed() must gate all calls before record_success()) Co-Authored-By: Claude Sonnet 4.6 * test: add regression test for empty tool name invariant guard Covers the debug_assert!(!tool_name.is_empty()) added in execute_tool_with_safety. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- src/context/state.rs | 7 +++++++ src/llm/circuit_breaker.rs | 6 ++++++ src/tools/execute.rs | 23 +++++++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/src/context/state.rs b/src/context/state.rs index f5307947..bae5bdf1 100644 --- a/src/context/state.rs +++ b/src/context/state.rs @@ -258,6 +258,13 @@ impl JobContext { new_state: JobState, reason: Option, ) -> Result<(), String> { + debug_assert!( + self.state.can_transition_to(new_state), + "BUG: invalid job state transition {} -> {} for job {}", + self.state, + new_state, + self.job_id + ); if !self.state.can_transition_to(new_state) { return Err(format!( "Cannot transition from {} to {}", diff --git a/src/llm/circuit_breaker.rs b/src/llm/circuit_breaker.rs index db47647e..46f29ded 100644 --- a/src/llm/circuit_breaker.rs +++ b/src/llm/circuit_breaker.rs @@ -167,6 +167,12 @@ impl CircuitBreakerProvider { } } CircuitState::Open => { + debug_assert!( + false, + "BUG: record_success() called while circuit breaker is Open — \ + check_allowed() was bypassed for provider {}", + self.inner.model_name() + ); // Shouldn't get here (check_allowed blocks Open), but recover state.state = CircuitState::Closed; state.consecutive_failures = 0; diff --git a/src/tools/execute.rs b/src/tools/execute.rs index c6c20dc1..fa52c59c 100644 --- a/src/tools/execute.rs +++ b/src/tools/execute.rs @@ -22,6 +22,10 @@ pub async fn execute_tool_with_safety( params: &serde_json::Value, job_ctx: &JobContext, ) -> Result { + debug_assert!( + !tool_name.is_empty(), + "BUG: execute_tool_with_safety called with empty tool_name" + ); let tool = tools .get(tool_name) .await @@ -291,6 +295,25 @@ mod tests { registry } + #[tokio::test] + async fn test_execute_empty_tool_name_returns_not_found() { + // Regression: execute_tool_with_safety must reject empty tool names before + // even attempting a registry lookup (the debug_assert guards this invariant). + let registry = registry_with(vec![]).await; + let safety = test_safety(); + + let result = execute_tool_with_safety( + ®istry, + &safety, + "", + &serde_json::json!({}), + &test_job_ctx(), + ) + .await; + + assert!(result.is_err(), "Empty tool name should return an error"); // safety: test-only assertion + } + #[tokio::test] async fn test_execute_success() { let registry = registry_with(vec![Arc::new(EchoTool)]).await; From f2cd1d37bc34f1017d617b1902a32fd1078ea23e Mon Sep 17 00:00:00 2001 From: Ikko Eltociear Ashimine Date: Thu, 19 Mar 2026 03:34:19 +0900 Subject: [PATCH 8/8] docs: add Japanese README (#1306) * docs: add Japanese README * Update README.ja.md Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update README.ja.md Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- README.ja.md | 330 ++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 3 +- README.ru.md | 3 +- README.zh-CN.md | 3 +- 4 files changed, 336 insertions(+), 3 deletions(-) create mode 100644 README.ja.md diff --git a/README.ja.md b/README.ja.md new file mode 100644 index 00000000..887cf67e --- /dev/null +++ b/README.ja.md @@ -0,0 +1,330 @@ +

+ IronClaw +

+ +

IronClaw

+ +

+ あなたの味方になる、安全なパーソナルAIアシスタント +

+ +

+ License: MIT OR Apache-2.0 + Telegram: @ironclawAI + Reddit: r/ironclawAI +

+ +

+ English | + 简体中文 | + Русский | + 日本語 +

+ +

+ フィロソフィー • + 機能 • + インストール • + 設定 • + セキュリティ • + アーキテクチャ +

+ +--- + +## フィロソフィー + +IronClawはシンプルな原則に基づいて構築されています:**あなたのAIアシスタントは、あなたのために働くべきであり、あなたに不利益をもたらすべきではありません。** + +AIシステムがデータの取り扱いについて不透明になり、企業の利益に沿って調整されることが増えている世界で、IronClawは異なるアプローチを取ります: + +- **あなたのデータはあなたのもの** - すべての情報はローカルに保存・暗号化され、あなたの管理下から離れることはありません +- **設計段階からの透明性** - オープンソース、監査可能、隠れたテレメトリやデータ収集なし +- **自己拡張する能力** - ベンダーのアップデートを待たずに、新しいツールをその場で構築 +- **多層防御** - 複数のセキュリティレイヤーがプロンプトインジェクションやデータ流出から保護 + +IronClawは、個人生活にも仕事にも本当に信頼できるAIアシスタントです。 + +## 機能 + +### セキュリティファースト + +- **WASMサンドボックス** - 信頼されていないツールは、機能ベースの権限を持つ隔離されたWebAssemblyコンテナで実行 +- **認証情報の保護** - シークレットはツールに公開されず、リーク検出付きでホスト境界で注入 +- **プロンプトインジェクション防御** - パターン検出、コンテンツサニタイズ、ポリシー適用 +- **エンドポイントの許可リスト** - HTTPリクエストは明示的に許可されたホストとパスのみに制限 + +### 常時利用可能 + +- **マルチチャネル** - REPL、HTTPウェブフック、WASMチャネル(Telegram、Slack)、Webゲートウェイ +- **Dockerサンドボックス** - ジョブごとのトークンとオーケストレーター/ワーカーパターンによる隔離されたコンテナ実行 +- **Webゲートウェイ** - リアルタイムSSE/WebSocketストリーミング対応のブラウザUI +- **ルーティン** - cronスケジュール、イベントトリガー、ウェブフックハンドラーによるバックグラウンド自動化 +- **ハートビートシステム** - 監視・保守タスクのためのプロアクティブなバックグラウンド実行 +- **並列ジョブ** - 隔離されたコンテキストで複数のリクエストを同時に処理 +- **自己修復** - スタックした操作の自動検出と復旧 + +### 自己拡張 + +- **動的ツール構築** - 必要なものを説明すると、IronClawがWASMツールとして構築 +- **MCPプロトコル** - Model Context Protocolサーバーに接続して追加機能を利用 +- **プラグインアーキテクチャ** - 再起動なしで新しいWASMツールやチャネルを追加 + +### 永続メモリ + +- **ハイブリッド検索** - Reciprocal Rank Fusionを使用した全文検索+ベクトル検索 +- **ワークスペースファイルシステム** - メモ、ログ、コンテキストのための柔軟なパスベースストレージ +- **アイデンティティファイル** - セッション間で一貫した人格と設定を維持 + +## インストール + +### 前提条件 + +- Rust 1.85+ +- PostgreSQL 15+ ([pgvector](https://github.com/pgvector/pgvector)拡張機能を含む) +- NEAR AIアカウント(セットアップウィザードで認証を処理) + +## ダウンロードまたはビルド + +最新のアップデートは[リリースページ](https://github.com/nearai/ironclaw/releases/)をご覧ください。 + +
+ Windowsインストーラーでインストール(Windows) + +[Windowsインストーラー](https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-x86_64-pc-windows-msvc.msi)をダウンロードして実行してください。 + +
+ +
+ PowerShellスクリプトでインストール(Windows) + +```sh +irm https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.ps1 | iex +``` + +
+ +
+ シェルスクリプトでインストール(macOS、Linux、Windows/WSL) + +```sh +curl --proto '=https' --tlsv1.2 -LsSf https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.sh | sh +``` +
+ +
+ Homebrewでインストール(macOS/Linux) + +```sh +brew install ironclaw +``` + +
+ +
+ ソースコードからコンパイル(Windows、Linux、macOSでCargo) + +`cargo`でインストールします。コンピューターに[Rust](https://rustup.rs)がインストールされていることを確認してください。 + +```bash +# リポジトリをクローン +git clone https://github.com/nearai/ironclaw.git +cd ironclaw + +# ビルド +cargo build --release + +# テストを実行 +cargo test +``` + +**フルリリース**(チャネルソースを変更した後)の場合、まず`./scripts/build-all.sh`を実行してチャネルを再ビルドしてください。 + +
+ +### データベースのセットアップ + +```bash +# データベースを作成 +createdb ironclaw + +# pgvectorを有効化 +psql ironclaw -c "CREATE EXTENSION IF NOT EXISTS vector;" +``` + +## 設定 + +セットアップウィザードを実行してIronClawを設定します: + +```bash +ironclaw onboard +``` + +ウィザードは、データベース接続、NEAR AI認証(ブラウザOAuth経由)、シークレットの暗号化(システムキーチェーンを使用)を処理します。設定は接続されたデータベースに永続化されます。ブートストラップ変数(例:`DATABASE_URL`、`LLM_BACKEND`)は、データベース接続前に利用できるよう`~/.ironclaw/.env`に書き込まれます。 + +### 代替LLMプロバイダー + +IronClawはデフォルトでNEAR AIを使用しますが、多くのLLMプロバイダーをすぐに利用できます。組み込みプロバイダーには**Anthropic**、**OpenAI**、**Google Gemini**、**MiniMax**、**Mistral**、**Ollama**(ローカル)が含まれます。**OpenRouter**(300以上のモデル)、**Together AI**、**Fireworks AI**、セルフホストサーバー(**vLLM**、**LiteLLM**)などのOpenAI互換サービスもサポートされています。 + +ウィザードでプロバイダーを選択するか、環境変数を直接設定してください: + +```env +# 例:MiniMax(組み込み、204Kコンテキスト) +LLM_BACKEND=minimax +MINIMAX_API_KEY=... + +# 例:OpenAI互換エンドポイント +LLM_BACKEND=openai_compatible +LLM_BASE_URL=https://openrouter.ai/api/v1 +LLM_API_KEY=sk-or-... +LLM_MODEL=anthropic/claude-sonnet-4 +``` + +完全なプロバイダーガイドは[docs/LLM_PROVIDERS.md](docs/LLM_PROVIDERS.md)をご覧ください。 + +## セキュリティ + +IronClawは、データを保護し悪用を防ぐために多層防御を実装しています。 + +### WASMサンドボックス + +すべての信頼されていないツールは、隔離されたWebAssemblyコンテナで実行されます: + +- **機能ベースの権限** - HTTP、シークレット、ツール呼び出しの明示的なオプトイン +- **エンドポイントの許可リスト** - 許可されたホスト/パスへのHTTPリクエストのみ +- **認証情報の注入** - シークレットはホスト境界で注入され、WASMコードに公開されない +- **リーク検出** - リクエストとレスポンスのシークレット流出試行をスキャン +- **レート制限** - 悪用防止のためのツールごとのリクエスト制限 +- **リソース制限** - メモリ、CPU、実行時間の制約 + +``` +WASM ──► 許可リスト ──► リーク ──► 認証情報 ──► リクエスト ──► リーク ──► WASM + バリデーター スキャン 注入 実行 スキャン + (リクエスト) (レスポンス) +``` + +### プロンプトインジェクション防御 + +外部コンテンツは複数のセキュリティレイヤーを通過します: + +- パターンベースのインジェクション試行検出 +- コンテンツのサニタイズとエスケープ +- 重要度レベル付きポリシールール(ブロック/警告/レビュー/サニタイズ) +- 安全なLLMコンテキスト注入のためのツール出力ラッピング + +### データ保護 + +- すべてのデータはローカルのPostgreSQLデータベースに保存 +- AES-256-GCMでシークレットを暗号化 +- テレメトリ、分析、データ共有なし +- すべてのツール実行の完全な監査ログ + +## アーキテクチャ + +``` +┌────────────────────────────────────────────────────────────────┐ +│ チャネル │ +│ ┌──────┐ ┌──────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ REPL │ │ HTTP │ │WASMチャネル │ │ Web │ │ +│ └──┬───┘ └──┬───┘ └──────┬──────┘ │ ゲートウェイ│ │ +│ │ │ │ │(SSE + WS) │ │ +│ │ │ │ └──────┬──────┘ │ +│ └─────────┴──────────────┴────────────────┘ │ +│ │ │ +│ ┌─────────▼─────────┐ │ +│ │ エージェントループ │ インテントルーティング│ +│ └────┬──────────┬───┘ │ +│ │ │ │ +│ ┌──────────▼────┐ ┌──▼───────────────┐ │ +│ │ スケジューラー │ │ ルーティン │ │ +│ │ (並列ジョブ) │ │ エンジン │ │ +│ └──────┬────────┘ │(cron,event,wh) │ │ +│ │ └────────┬─────────┘ │ +│ ┌─────────────┼────────────────────┘ │ +│ │ │ │ +│ ┌───▼─────┐ ┌────▼────────────────┐ │ +│ │ ローカル │ │ オーケストレーター │ │ +│ │ ワーカー │ │ ┌───────────────┐ │ │ +│ │(プロセス │ │ │ Docker │ │ │ +│ │ 内) │ │ │ サンドボックス│ │ │ +│ └───┬─────┘ │ │ コンテナ │ │ │ +│ │ │ │ ┌───────────┐ │ │ │ +│ │ │ │ │Worker / CC│ │ │ │ +│ │ │ │ └───────────┘ │ │ │ +│ │ │ └───────────────┘ │ │ +│ │ └─────────┬───────────┘ │ +│ └──────────────────┤ │ +│ │ │ +│ ┌───────────▼──────────┐ │ +│ │ ツールレジストリ │ │ +│ │ 組み込み, MCP, WASM │ │ +│ └──────────────────────┘ │ +└────────────────────────────────────────────────────────────────┘ +``` + +### コアコンポーネント + +| コンポーネント | 目的 | +|---------------|------| +| **エージェントループ** | メインのメッセージ処理とジョブの調整 | +| **ルーター** | ユーザーの意図を分類(コマンド、クエリ、タスク) | +| **スケジューラー** | 優先度付きの並列ジョブ実行を管理 | +| **ワーカー** | LLM推論とツール呼び出しでジョブを実行 | +| **オーケストレーター** | コンテナのライフサイクル、LLMプロキシ、ジョブごとの認証 | +| **Webゲートウェイ** | チャット、メモリ、ジョブ、ログ、拡張機能、ルーティンのブラウザUI | +| **ルーティンエンジン** | スケジュール(cron)とリアクティブ(イベント、ウェブフック)のバックグラウンドタスク | +| **ワークスペース** | ハイブリッド検索付き永続メモリ | +| **セーフティレイヤー** | プロンプトインジェクション防御とコンテンツサニタイズ | + +## 使い方 + +```bash +# 初回セットアップ(データベース、認証などを設定) +ironclaw onboard + +# インタラクティブREPLを起動 +cargo run + +# デバッグログ付き +RUST_LOG=ironclaw=debug cargo run +``` + +## 開発 + +```bash +# コードフォーマット +cargo fmt + +# リント +cargo clippy --all --benches --tests --examples --all-features + +# テスト実行 +createdb ironclaw_test +cargo test + +# 特定のテストを実行 +cargo test test_name +``` + +- **Telegramチャネル**: セットアップとDMペアリングについては[docs/TELEGRAM_SETUP.md](docs/TELEGRAM_SETUP.md)を参照してください。 +- **チャネルソースの変更**: `cargo build`の前に`./channels-src/telegram/build.sh`を実行して、更新されたWASMをバンドルしてください。 + +## OpenClawの系譜 + +IronClawは[OpenClaw](https://github.com/openclaw/openclaw)にインスパイアされたRust再実装です。完全な対応表は[FEATURE_PARITY.md](FEATURE_PARITY.md)をご覧ください。 + +主な違い: + +- **Rust vs TypeScript** - ネイティブパフォーマンス、メモリ安全性、シングルバイナリ +- **WASMサンドボックス vs Docker** - 軽量、機能ベースのセキュリティ +- **PostgreSQL vs SQLite** - 本番環境対応の永続化 +- **セキュリティファースト設計** - 複数の防御レイヤー、認証情報の保護 + +## ライセンス + +以下のいずれかのライセンスの下で提供されています: + +- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE)) +- MIT License ([LICENSE-MIT](LICENSE-MIT)) + +お好みに応じて選択してください。 diff --git a/README.md b/README.md index 9684ee4d..fa73dc45 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@

English | 简体中文 | - Русский + Русский | + 日本語

diff --git a/README.ru.md b/README.ru.md index c64770a9..0546e7f4 100644 --- a/README.ru.md +++ b/README.ru.md @@ -17,7 +17,8 @@

English | 简体中文 | - Русский + Русский | + 日本語

diff --git a/README.zh-CN.md b/README.zh-CN.md index 34023822..a337d713 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -17,7 +17,8 @@

English | 简体中文 | - Русский + Русский | + 日本語