Merge pull request #1376 from nearai/staging-promote/f2cd1d37-23262791325

chore: promote staging to staging-promote/428303af-23255149035 (2026-03-18 19:20 UTC)
This commit is contained in:
Henry Park
2026-03-18 14:16:32 -07:00
committed by GitHub
19 changed files with 703 additions and 71 deletions
+1 -1
View File
@@ -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 ===
+330
View File
@@ -0,0 +1,330 @@
<p align="center">
<img src="ironclaw.png?v=2" alt="IronClaw" width="200"/>
</p>
<h1 align="center">IronClaw</h1>
<p align="center">
<strong>あなたの味方になる、安全なパーソナルAIアシスタント</strong>
</p>
<p align="center">
<a href="#license"><img src="https://img.shields.io/badge/license-MIT%20OR%20Apache%202.0-blue.svg" alt="License: MIT OR Apache-2.0" /></a>
<a href="https://t.me/ironclawAI"><img src="https://img.shields.io/badge/Telegram-%40ironclawAI-26A5E4?style=flat&logo=telegram&logoColor=white" alt="Telegram: @ironclawAI" /></a>
<a href="https://www.reddit.com/r/ironclawAI/"><img src="https://img.shields.io/badge/Reddit-r%2FironclawAI-FF4500?style=flat&logo=reddit&logoColor=white" alt="Reddit: r/ironclawAI" /></a>
</p>
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh-CN.md">简体中文</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.ja.md">日本語</a>
</p>
<p align="center">
<a href="#フィロソフィー">フィロソフィー</a> •
<a href="#機能">機能</a> •
<a href="#インストール">インストール</a> •
<a href="#設定">設定</a> •
<a href="#セキュリティ">セキュリティ</a> •
<a href="#アーキテクチャ">アーキテクチャ</a>
</p>
---
## フィロソフィー
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/)をご覧ください。
<details>
<summary>Windowsインストーラーでインストール(Windows</summary>
[Windowsインストーラー](https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-x86_64-pc-windows-msvc.msi)をダウンロードして実行してください。
</details>
<details>
<summary>PowerShellスクリプトでインストール(Windows</summary>
```sh
irm https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.ps1 | iex
```
</details>
<details>
<summary>シェルスクリプトでインストール(macOS、Linux、Windows/WSL</summary>
```sh
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.sh | sh
```
</details>
<details>
<summary>Homebrewでインストール(macOS/Linux</summary>
```sh
brew install ironclaw
```
</details>
<details>
<summary>ソースコードからコンパイル(Windows、Linux、macOSでCargo</summary>
`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`を実行してチャネルを再ビルドしてください。
</details>
### データベースのセットアップ
```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))
お好みに応じて選択してください。
+2 -1
View File
@@ -17,7 +17,8 @@
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh-CN.md">简体中文</a> |
<a href="README.ru.md">Русский</a>
<a href="README.ru.md">Русский</a> |
<a href="README.ja.md">日本語</a>
</p>
<p align="center">
+2 -1
View File
@@ -17,7 +17,8 @@
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh-CN.md">简体中文</a> |
<a href="README.ru.md">Русский</a>
<a href="README.ru.md">Русский</a> |
<a href="README.ja.md">日本語</a>
</p>
<p align="center">
+2 -1
View File
@@ -17,7 +17,8 @@
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh-CN.md">简体中文</a> |
<a href="README.ru.md">Русский</a>
<a href="README.ru.md">Русский</a> |
<a href="README.ja.md">日本語</a>
</p>
<p align="center">
+8 -4
View File
@@ -2,9 +2,13 @@ coverage:
status:
project:
default:
target: auto
threshold: 1%
target: 80%
threshold: 2%
patch:
default:
target: 80%
threshold: 5%
target: 90%
comment:
layout: "reach,diff,flags"
behavior: default
require_changes: true
+2 -2
View File
@@ -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:
+2 -2
View File
@@ -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",
+39 -2
View File
@@ -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<String> {
})
}
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]
+7
View File
@@ -258,6 +258,13 @@ impl JobContext {
new_state: JobState,
reason: Option<String>,
) -> 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 {}",
+10 -2
View File
@@ -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::<u64>().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::<u64>()
.ok()
.map(std::time::Duration::from_secs)
.map(cap_retry_after)
.or(Some(std::time::Duration::from_secs(60)))
}
}
+6
View File
@@ -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;
+18 -10
View File
@@ -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::<u64>() {
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<std::time::Duration> {
let trimmed = header_value.trim();
let parsed = if let Ok(secs) = trimmed.parse::<u64>() {
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
};
+2
View File
@@ -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]
+23
View File
@@ -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)
);
}
}
+16 -3
View File
@@ -518,7 +518,7 @@ pub async fn setup_http(secrets: &SecretsContext) -> Result<HttpSetupResult, Cha
.save_secret("http_webhook_secret", &SecretString::from(secret))
.await?;
print_success("Webhook secret generated and saved to database");
print_info("Retrieve it later with: ironclaw secret get http_webhook_secret");
print_info(http_webhook_secret_hint());
}
print_success(&format!("HTTP webhook will listen on {}:{}", host, port));
@@ -535,6 +535,10 @@ pub fn generate_webhook_secret() -> 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"));
}
}
+23
View File
@@ -22,6 +22,10 @@ pub async fn execute_tool_with_safety(
params: &serde_json::Value,
job_ctx: &JobContext,
) -> Result<String, Error> {
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(
&registry,
&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;
+205 -42
View File
@@ -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<InitializeResult, ToolError> {
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(&notification, &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<McpResponse, ToolError> {
@@ -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<std::collections::VecDeque<Result<McpResponse, ToolError>>>,
recorded_headers: std::sync::Mutex<Vec<HashMap<String, String>>>,
}
impl RetryMockTransport {
fn new(supports_http: bool, outcomes: Vec<Result<McpResponse, ToolError>>) -> Self {
Self {
supports_http,
outcomes: std::sync::Mutex::new(outcomes.into()),
recorded_headers: std::sync::Mutex::new(Vec::new()),
}
}
fn recorded_headers(&self) -> Vec<HashMap<String, String>> {
self.recorded_headers.lock().unwrap().clone()
}
}
#[async_trait]
impl McpTransport for RetryMockTransport {
async fn send(
&self,
_request: &McpRequest,
headers: &HashMap<String, String>,
) -> Result<McpResponse, ToolError> {
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!({
+5
View File
@@ -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::<u64>().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::<u64>().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::<u64>()
.ok()
.map(std::time::Duration::from_secs)
.map(cap_retry_after)
.or(Some(std::time::Duration::from_secs(60)))
}
}