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/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
+
+
+ あなたの味方になる、安全なパーソナルAIアシスタント
+
+
+
+
+
+
+
+
+
+ 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 |
简体中文 |
- Русский
+ Русский |
+ 日本語
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
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/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]
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/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/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/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/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]
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/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"));
+ }
}
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;
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!({
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)))
}
}