`(例: `openai-codex:work`)
-
-OpenAI Codex OAuth(ChatGPT サブスクリプション):
-
-```bash
-# サーバー/ヘッドレス環境向け推奨
-zeroclaw auth login --provider openai-codex --device-code
-
-# ブラウザ/コールバックフロー(ペーストフォールバック付き)
-zeroclaw auth login --provider openai-codex --profile default
-zeroclaw auth paste-redirect --provider openai-codex --profile default
-
-# 確認 / リフレッシュ / プロファイル切替
-zeroclaw auth status
-zeroclaw auth refresh --provider openai-codex --profile default
-zeroclaw auth use --provider openai-codex --profile work
-```
-
-Claude Code / Anthropic setup-token:
-
-```bash
-# サブスクリプション/setup token の貼り付け(Authorization header モード)
-zeroclaw auth paste-token --provider anthropic --profile default --auth-kind authorization
-
-# エイリアスコマンド
-zeroclaw auth setup-token --provider anthropic --profile default
-```
-
-Subscription auth で agent を実行:
-
-```bash
-zeroclaw agent --provider openai-codex -m "hello"
-zeroclaw agent --provider openai-codex --auth-profile openai-codex:work -m "hello"
-
-# Anthropic は API key と auth token の両方の環境変数をサポート:
-# ANTHROPIC_AUTH_TOKEN, ANTHROPIC_OAUTH_TOKEN, ANTHROPIC_API_KEY
-zeroclaw agent --provider anthropic -m "hello"
-```
-
-## アーキテクチャ
-
-すべてのサブシステムは **Trait** — 設定変更だけで実装を差し替え可能、コード変更不要。
-
-
-
-
-
-| サブシステム | Trait | 内蔵実装 | 拡張方法 |
-|-------------|-------|----------|----------|
-| **AI モデル** | `Provider` | `zeroclaw providers` で確認(現在 28 個の組み込み + エイリアス、カスタムエンドポイント対応) | `custom:https://your-api.com`(OpenAI 互換)または `anthropic-custom:https://your-api.com` |
-| **チャネル** | `Channel` | CLI, Telegram, Discord, Slack, Mattermost, iMessage, Matrix, Signal, WhatsApp, Linq, Email, IRC, Lark, DingTalk, QQ, Webhook | 任意のメッセージ API |
-| **メモリ** | `Memory` | SQLite ハイブリッド検索, PostgreSQL バックエンド, Lucid ブリッジ, Markdown ファイル, 明示的 `none` バックエンド, スナップショット/復元, オプション応答キャッシュ | 任意の永続化バックエンド |
-| **ツール** | `Tool` | shell/file/memory, cron/schedule, git, pushover, browser, http_request, screenshot/image_info, composio (opt-in), delegate, ハードウェアツール | 任意の機能 |
-| **オブザーバビリティ** | `Observer` | Noop, Log, Multi | Prometheus, OTel |
-| **ランタイム** | `RuntimeAdapter` | Native, Docker(サンドボックス) | adapter 経由で追加可能;未対応の kind は即座にエラー |
-| **セキュリティ** | `SecurityPolicy` | Gateway ペアリング, サンドボックス, allowlist, レート制限, ファイルシステムスコープ, 暗号化シークレット | — |
-| **アイデンティティ** | `IdentityConfig` | OpenClaw (markdown), AIEOS v1.1 (JSON) | 任意の ID フォーマット |
-| **トンネル** | `Tunnel` | None, Cloudflare, Tailscale, ngrok, Custom | 任意のトンネルバイナリ |
-| **ハートビート** | Engine | HEARTBEAT.md 定期タスク | — |
-| **スキル** | Loader | TOML マニフェスト + SKILL.md インストラクション | コミュニティスキルパック |
-| **インテグレーション** | Registry | 9 カテゴリ、70 件以上の連携 | プラグインシステム |
-
-### ランタイムサポート(現状)
-
-- ✅ 現在サポート: `runtime.kind = "native"` または `runtime.kind = "docker"`
-- 🚧 計画中(未実装): WASM / エッジランタイム
-
-未対応の `runtime.kind` が設定された場合、ZeroClaw は native へのサイレントフォールバックではなく、明確なエラーで終了します。
-
-### メモリシステム(フルスタック検索エンジン)
-
-すべて自社実装、外部依存ゼロ — Pinecone、Elasticsearch、LangChain 不要:
-
-| レイヤー | 実装 |
-|---------|------|
-| **ベクトル DB** | Embeddings を SQLite に BLOB として保存、コサイン類似度検索 |
-| **キーワード検索** | FTS5 仮想テーブル、BM25 スコアリング |
-| **ハイブリッドマージ** | カスタム重み付きマージ関数(`vector.rs`) |
-| **Embeddings** | `EmbeddingProvider` trait — OpenAI、カスタム URL、または noop |
-| **チャンキング** | 行ベースの Markdown チャンカー(見出し構造保持) |
-| **キャッシュ** | SQLite `embedding_cache` テーブル、LRU エビクション |
-| **安全な再インデックス** | FTS5 再構築 + 欠落ベクトルの再埋め込みをアトミックに実行 |
-
-Agent はツール経由でメモリの呼び出し・保存・管理を自動的に行います。
-
-```toml
-[memory]
-backend = "sqlite" # "sqlite", "lucid", "postgres", "markdown", "none"
-auto_save = true
-embedding_provider = "none" # "none", "openai", "custom:https://..."
-vector_weight = 0.7
-keyword_weight = 0.3
-```
-
-## セキュリティのデフォルト
-
-- Gateway の既定バインド: `127.0.0.1:42617`
-- 既定でペアリング必須: `require_pairing = true`
-- 既定で公開バインド禁止: `allow_public_bind = false`
-- Channel allowlist:
- - `[]` は deny-by-default
- - `["*"]` は allow all(意図的に使う場合のみ)
-
-## 設定例
-
-```toml
-api_key = "sk-..."
-default_provider = "openrouter"
-default_model = "anthropic/claude-sonnet-4-6"
-default_temperature = 0.7
-
-[memory]
-backend = "sqlite"
-auto_save = true
-embedding_provider = "none"
-
-[gateway]
-host = "127.0.0.1"
-port = 42617
-require_pairing = true
-allow_public_bind = false
-```
-
-## ドキュメント入口
-
-- ドキュメントハブ(英語): [`docs/README.md`](docs/README.md)
-- 統合 TOC: [`docs/SUMMARY.md`](docs/SUMMARY.md)
-- ドキュメントハブ(日本語): [`docs/README.ja.md`](docs/README.ja.md)
-- コマンドリファレンス: [`docs/commands-reference.md`](docs/commands-reference.md)
-- 設定リファレンス: [`docs/config-reference.md`](docs/config-reference.md)
-- Provider リファレンス: [`docs/providers-reference.md`](docs/providers-reference.md)
-- Channel リファレンス: [`docs/channels-reference.md`](docs/channels-reference.md)
-- 運用ガイド(Runbook): [`docs/operations-runbook.md`](docs/operations-runbook.md)
-- トラブルシューティング: [`docs/troubleshooting.md`](docs/troubleshooting.md)
-- ドキュメント一覧 / 分類: [`docs/docs-inventory.md`](docs/docs-inventory.md)
-- プロジェクト triage スナップショット: [`docs/project-triage-snapshot-2026-02-18.md`](docs/project-triage-snapshot-2026-02-18.md)
-
-## コントリビュート / ライセンス
-
-- Contributing: [`CONTRIBUTING.md`](CONTRIBUTING.md)
-- PR Workflow: [`docs/pr-workflow.md`](docs/pr-workflow.md)
-- Reviewer Playbook: [`docs/reviewer-playbook.md`](docs/reviewer-playbook.md)
-- License: MIT or Apache 2.0([`LICENSE-MIT`](LICENSE-MIT), [`LICENSE-APACHE`](LICENSE-APACHE), [`NOTICE`](NOTICE))
-
----
-
-詳細仕様(全コマンド、アーキテクチャ、API 仕様、開発フロー)は英語版の [`README.md`](README.md) を参照してください。
diff --git a/README.md b/README.md
index 44ef5afe0..7e77b5452 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
-
+
@@ -25,7 +25,7 @@ Built by students and members of the Harvard, MIT, and Sundai.Club communities.
- 🌐 Languages: English · 简体中文 · 日本語 · Русский · Français · Tiếng Việt
+ 🌐 Languages: English · 简体中文 · 日本語 · Русский · Français · Tiếng Việt · Ελληνικά
@@ -72,6 +72,7 @@ Use this board for important notices (breaking changes, security advisories, mai
- 💰 **Cost-Efficient Deployment:** Designed for low-cost boards and small cloud instances without heavyweight runtime dependencies.
- ⚡ **Fast Cold Starts:** Single-binary Rust runtime keeps command and daemon startup near-instant for daily operations.
- 🌍 **Portable Architecture:** One binary-first workflow across ARM, x86, and RISC-V with swappable providers/channels/tools.
+- 🔍 **Research Phase:** Proactive information gathering through tools before response generation — reduces hallucinations by fact-checking first.
### Why teams pick ZeroClaw
@@ -220,6 +221,32 @@ To require binary-only install with no source fallback:
brew install zeroclaw
```
+### Linux pre-built installer (beginner-friendly)
+
+For Linux hosts that prefer a pre-built binary (no local Rust build), use the
+repository-maintained release installer:
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/scripts/install-release.sh | bash
+```
+
+What it does:
+
+- Detects your Linux CPU architecture (`x86_64`, `aarch64`, `armv7`)
+- Downloads the matching asset from the latest official GitHub release
+- Installs `zeroclaw` into a local bin directory (or `/usr/local/bin` if needed)
+- Starts `zeroclaw onboard` (skip with `--no-onboard`)
+
+Examples:
+
+```bash
+# Install and start onboarding (default)
+curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/scripts/install-release.sh | bash
+
+# Install only (no onboarding)
+curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/scripts/install-release.sh | bash -s -- --no-onboard
+```
+
### One-click bootstrap
```bash
@@ -406,7 +433,7 @@ Every subsystem is a **trait** — swap implementations with a config change, ze
| **AI Models** | `Provider` | Provider catalog via `zeroclaw providers` (built-ins + aliases, plus custom endpoints) | `custom:https://your-api.com` (OpenAI-compatible) or `anthropic-custom:https://your-api.com` |
| **Channels** | `Channel` | CLI, Telegram, Discord, Slack, Mattermost, iMessage, Matrix, Signal, WhatsApp, Linq, Email, IRC, Lark, DingTalk, QQ, Nostr, Webhook | Any messaging API |
| **Memory** | `Memory` | SQLite hybrid search, PostgreSQL backend (configurable storage provider), Lucid bridge, Markdown files, explicit `none` backend, snapshot/hydrate, optional response cache | Any persistence backend |
-| **Tools** | `Tool` | shell/file/memory, cron/schedule, git, pushover, browser, http_request, screenshot/image_info, composio (opt-in), delegate, hardware tools | Any capability |
+| **Tools** | `Tool` | shell/file/memory, cron/schedule, git, pushover, browser, http_request, screenshot/image_info, composio (opt-in), delegate, hardware tools, **WASM skills** (opt-in) | Any capability |
| **Observability** | `Observer` | Noop, Log, Multi | Prometheus, OTel |
| **Runtime** | `RuntimeAdapter` | Native, Docker (sandboxed) | Additional runtimes can be added via adapter; unsupported kinds fail fast |
| **Security** | `SecurityPolicy` | Gateway pairing, sandbox, allowlists, rate limits, filesystem scoping, encrypted secrets | — |
@@ -657,6 +684,7 @@ keyword_weight = 0.3
# schema = "public"
# table = "memories"
# connect_timeout_secs = 15
+# tls = true # true = TLS (cert not verified), false = plain TCP (default)
[gateway]
port = 42617 # default
@@ -974,7 +1002,7 @@ See [aieos.org](https://aieos.org) for the full schema and live examples.
| `providers` | List supported providers and aliases |
| `channel` | List/start/doctor channels and bind Telegram identities |
| `integrations` | Inspect integration setup details |
-| `skills` | List/install/remove skills |
+| `skills` | List/install/remove skills; supports ClawhHub URLs, local zip files, ZeroMarket registry, git remotes |
| `migrate` | Import data from other runtimes (`migrate openclaw`) |
| `completions` | Generate shell completion scripts (`bash`, `fish`, `zsh`, `powershell`, `elvish`) |
| `hardware` | USB discover/introspect/info commands |
@@ -1021,6 +1049,45 @@ You can also override at runtime with `ZEROCLAW_OPEN_SKILLS_ENABLED`, `ZEROCLAW_
Skill installs are now gated by a built-in static security audit. `zeroclaw skills install ` blocks symlinks, script-like files, unsafe markdown link patterns, and high-risk shell payload snippets before accepting a skill. You can run `zeroclaw skills audit ` to validate a local directory or an installed skill manually.
+### WASM Skills
+
+ZeroClaw supports WASM-compiled skills installable from the [ZeroMarket](https://zeromarket.vercel.app) registry and zip-based registries like [ClawhHub](https://clawhub.ai):
+
+```bash
+# Install from ZeroMarket registry
+zeroclaw skill install namespace/name
+
+# Install from ClawhHub (auto-detected by domain)
+zeroclaw skill install https://clawhub.ai/steipete/summarize
+
+# Install using ClawhHub short prefix
+zeroclaw skill install clawhub:summarize
+
+# Install from a zip file already downloaded locally
+zeroclaw skill install ~/Downloads/summarize-1.0.0.zip
+
+# Install from any direct zip URL
+zeroclaw skill install zip:https://example.com/my-skill.zip
+```
+
+If ClawhHub returns 429 (rate limit) or requires authentication, add to `~/.zeroclaw/config.toml`:
+
+```toml
+[skills]
+clawhub_token = "your-clawhub-token"
+```
+
+Skills are installed to `~/.zeroclaw/workspace/skills//` and loaded automatically as tools at agent runtime. No system `unzip` binary required — zip extraction is handled in-process.
+
+Build with WASM tool support (enabled by default):
+
+```bash
+cargo build --release # wasm-tools enabled by default
+cargo build --release --no-default-features # disable wasm-tools for smaller binary
+```
+
+Publish your own skill to ZeroMarket: compile to WASM, upload `tool.wasm`, `manifest.json`, and `SKILL.md` via the ZeroMarket upload page. Use `zeroclaw skill new ` to scaffold a new skill project.
+
## Development
```bash
@@ -1069,6 +1136,7 @@ Start from the docs hub for a task-oriented map:
- Unified docs TOC: [`docs/SUMMARY.md`](docs/SUMMARY.md)
- Commands reference: [`docs/commands-reference.md`](docs/commands-reference.md)
- Config reference: [`docs/config-reference.md`](docs/config-reference.md)
+- WASM skills guide: [`docs/wasm-tools-guide.md`](docs/wasm-tools-guide.md)
- Providers reference: [`docs/providers-reference.md`](docs/providers-reference.md)
- Channels reference: [`docs/channels-reference.md`](docs/channels-reference.md)
- Operations runbook: [`docs/operations-runbook.md`](docs/operations-runbook.md)
diff --git a/README.ru.md b/README.ru.md
deleted file mode 100644
index cfb10393e..000000000
--- a/README.ru.md
+++ /dev/null
@@ -1,300 +0,0 @@
-
-
-
-
-ZeroClaw 🦀(Русский)
-
-
- Zero overhead. Zero compromise. 100% Rust. 100% Agnostic.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 🌐 Языки: English · 简体中文 · 日本語 · Русский · Français · Tiếng Việt
-
-
-
- Установка в 1 клик |
- Быстрый старт |
- Хаб документации |
- TOC docs
-
-
-
- Быстрые маршруты:
- Справочники ·
- Операции ·
- Диагностика ·
- Безопасность ·
- Аппаратная часть ·
- Вклад и CI
-
-
-> Этот файл — выверенный перевод `README.md` с акцентом на точность и читаемость (не дословный перевод).
->
-> Технические идентификаторы (команды, ключи конфигурации, API-пути, имена Trait) сохранены на английском.
->
-> Последняя синхронизация: **2026-02-19**.
-
-## 📢 Доска объявлений
-
-Публикуйте здесь важные уведомления (breaking changes, security advisories, окна обслуживания и блокеры релиза).
-
-| Дата (UTC) | Уровень | Объявление | Действие |
-|---|---|---|---|
-| 2026-02-19 | _Срочно_ | Мы **не аффилированы** с `openagen/zeroclaw` и `zeroclaw.org`. Домен `zeroclaw.org` сейчас указывает на fork `openagen/zeroclaw`, и этот домен/репозиторий выдают себя за наш официальный сайт и проект. | Не доверяйте информации, бинарникам, сборам средств и «официальным» объявлениям из этих источников. Используйте только [этот репозиторий](https://github.com/zeroclaw-labs/zeroclaw) и наши верифицированные соцсети. |
-| 2026-02-21 | _Важно_ | Наш официальный сайт уже запущен: [zeroclawlabs.ai](https://zeroclawlabs.ai). Спасибо, что дождались запуска. При этом попытки выдавать себя за ZeroClaw продолжаются, поэтому не участвуйте в инвестициях, сборах средств и похожих активностях, если они не подтверждены через наши официальные каналы. | Ориентируйтесь только на [этот репозиторий](https://github.com/zeroclaw-labs/zeroclaw); также следите за [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (группа)](https://www.facebook.com/groups/zeroclaw), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/) и [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) для официальных обновлений. |
-| 2026-02-19 | _Важно_ | Anthropic обновил раздел Authentication and Credential Use 2026-02-19. В нем указано, что OAuth authentication (Free/Pro/Max) предназначена только для Claude Code и Claude.ai; использование OAuth-токенов, полученных через Claude Free/Pro/Max, в любых других продуктах, инструментах или сервисах (включая Agent SDK), не допускается и может считаться нарушением Consumer Terms of Service. | Чтобы избежать потерь, временно не используйте Claude Code OAuth-интеграции. Оригинал: [Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use). |
-
-## О проекте
-
-ZeroClaw — это производительная и расширяемая инфраструктура автономного AI-агента. ZeroClaw — это **операционная система времени выполнения** для агентных рабочих процессов — инфраструктура, абстрагирующая модели, инструменты, память и выполнение, позволяя создавать агентов один раз и запускать где угодно.
-
-- Нативно на Rust, единый бинарник, переносимость между ARM / x86 / RISC-V
-- Архитектура на Trait (`Provider`, `Channel`, `Tool`, `Memory` и др.)
-- Безопасные значения по умолчанию: pairing, явные allowlist, sandbox и scope-ограничения
-
-## Почему выбирают ZeroClaw
-
-- **Лёгкий runtime по умолчанию**: Повседневные CLI-операции и `status` обычно укладываются в несколько МБ памяти.
-- **Оптимизирован для недорогих сред**: Подходит для бюджетных плат и небольших cloud-инстансов без тяжёлой runtime-обвязки.
-- **Быстрый cold start**: Архитектура одного Rust-бинарника ускоряет запуск основных команд и daemon-режима.
-- **Портативная модель деплоя**: Единый подход для ARM / x86 / RISC-V и возможность менять providers/channels/tools.
-
-## Снимок бенчмарка (ZeroClaw vs OpenClaw, воспроизводимо)
-
-Ниже — быстрый локальный сравнительный срез (macOS arm64, февраль 2026), нормализованный под 0.8GHz edge CPU.
-
-| | OpenClaw | NanoBot | PicoClaw | ZeroClaw 🦀 |
-|---|---|---|---|---|
-| **Язык** | TypeScript | Python | Go | **Rust** |
-| **RAM** | > 1GB | > 100MB | < 10MB | **< 5MB** |
-| **Старт (ядро 0.8GHz)** | > 500s | > 30s | < 1s | **< 10ms** |
-| **Размер бинарника** | ~28MB (dist) | N/A (скрипты) | ~8MB | **~8.8 MB** |
-| **Стоимость** | Mac Mini $599 | Linux SBC ~$50 | Linux-плата $10 | **Любое железо за $10** |
-
-> Примечание: результаты ZeroClaw получены на release-сборке с помощью `/usr/bin/time -l`. OpenClaw требует Node.js runtime; только этот runtime обычно добавляет около 390MB дополнительного потребления памяти. NanoBot требует Python runtime. PicoClaw и ZeroClaw — статические бинарники.
-
-
-
-
-
-### Локально воспроизводимое измерение
-
-Метрики могут меняться вместе с кодом и toolchain, поэтому проверяйте результаты в своей среде:
-
-```bash
-cargo build --release
-ls -lh target/release/zeroclaw
-
-/usr/bin/time -l target/release/zeroclaw --help
-/usr/bin/time -l target/release/zeroclaw status
-```
-
-Текущие примерные значения из README (macOS arm64, 2026-02-18):
-
-- Размер release-бинарника: `8.8M`
-- `zeroclaw --help`: ~`0.02s`, пик памяти ~`3.9MB`
-- `zeroclaw status`: ~`0.01s`, пик памяти ~`4.1MB`
-
-## Установка в 1 клик
-
-```bash
-git clone https://github.com/zeroclaw-labs/zeroclaw.git
-cd zeroclaw
-./bootstrap.sh
-```
-
-Для полной инициализации окружения: `./bootstrap.sh --install-system-deps --install-rust` (для системных пакетов может потребоваться `sudo`).
-
-Подробности: [`docs/one-click-bootstrap.md`](docs/one-click-bootstrap.md).
-
-## Быстрый старт
-
-### Homebrew (macOS/Linuxbrew)
-
-```bash
-brew install zeroclaw
-```
-
-```bash
-git clone https://github.com/zeroclaw-labs/zeroclaw.git
-cd zeroclaw
-cargo build --release --locked
-cargo install --path . --force --locked
-
-zeroclaw onboard --api-key sk-... --provider openrouter
-zeroclaw onboard --interactive
-
-zeroclaw agent -m "Hello, ZeroClaw!"
-
-# default: 127.0.0.1:42617
-zeroclaw gateway
-
-zeroclaw daemon
-```
-
-## Subscription Auth (OpenAI Codex / Claude Code)
-
-ZeroClaw поддерживает нативные профили авторизации на основе подписки (мультиаккаунт, шифрование при хранении).
-
-- Файл хранения: `~/.zeroclaw/auth-profiles.json`
-- Ключ шифрования: `~/.zeroclaw/.secret_key`
-- Формат Profile ID: `:` (пример: `openai-codex:work`)
-
-OpenAI Codex OAuth (подписка ChatGPT):
-
-```bash
-# Рекомендуется для серверов/headless-окружений
-zeroclaw auth login --provider openai-codex --device-code
-
-# Браузерный/callback-поток с paste-фолбэком
-zeroclaw auth login --provider openai-codex --profile default
-zeroclaw auth paste-redirect --provider openai-codex --profile default
-
-# Проверка / обновление / переключение профиля
-zeroclaw auth status
-zeroclaw auth refresh --provider openai-codex --profile default
-zeroclaw auth use --provider openai-codex --profile work
-```
-
-Claude Code / Anthropic setup-token:
-
-```bash
-# Вставка subscription/setup token (режим Authorization header)
-zeroclaw auth paste-token --provider anthropic --profile default --auth-kind authorization
-
-# Команда-алиас
-zeroclaw auth setup-token --provider anthropic --profile default
-```
-
-Запуск agent с subscription auth:
-
-```bash
-zeroclaw agent --provider openai-codex -m "hello"
-zeroclaw agent --provider openai-codex --auth-profile openai-codex:work -m "hello"
-
-# Anthropic поддерживает и API key, и auth token через переменные окружения:
-# ANTHROPIC_AUTH_TOKEN, ANTHROPIC_OAUTH_TOKEN, ANTHROPIC_API_KEY
-zeroclaw agent --provider anthropic -m "hello"
-```
-
-## Архитектура
-
-Каждая подсистема — это **Trait**: меняйте реализации через конфигурацию, без изменения кода.
-
-
-
-
-
-| Подсистема | Trait | Встроенные реализации | Расширение |
-|-----------|-------|---------------------|------------|
-| **AI-модели** | `Provider` | Каталог через `zeroclaw providers` (сейчас 28 встроенных + алиасы, плюс пользовательские endpoint) | `custom:https://your-api.com` (OpenAI-совместимый) или `anthropic-custom:https://your-api.com` |
-| **Каналы** | `Channel` | CLI, Telegram, Discord, Slack, Mattermost, iMessage, Matrix, Signal, WhatsApp, Linq, Email, IRC, Lark, DingTalk, QQ, Webhook | Любой messaging API |
-| **Память** | `Memory` | SQLite гибридный поиск, PostgreSQL-бэкенд, Lucid-мост, Markdown-файлы, явный `none`-бэкенд, snapshot/hydrate, опциональный кэш ответов | Любой persistence-бэкенд |
-| **Инструменты** | `Tool` | shell/file/memory, cron/schedule, git, pushover, browser, http_request, screenshot/image_info, composio (opt-in), delegate, аппаратные инструменты | Любая функциональность |
-| **Наблюдаемость** | `Observer` | Noop, Log, Multi | Prometheus, OTel |
-| **Runtime** | `RuntimeAdapter` | Native, Docker (sandbox) | Через adapter; неподдерживаемые kind завершаются с ошибкой |
-| **Безопасность** | `SecurityPolicy` | Gateway pairing, sandbox, allowlist, rate limits, scoping файловой системы, шифрование секретов | — |
-| **Идентификация** | `IdentityConfig` | OpenClaw (markdown), AIEOS v1.1 (JSON) | Любой формат идентификации |
-| **Туннели** | `Tunnel` | None, Cloudflare, Tailscale, ngrok, Custom | Любой tunnel-бинарник |
-| **Heartbeat** | Engine | HEARTBEAT.md — периодические задачи | — |
-| **Навыки** | Loader | TOML-манифесты + SKILL.md-инструкции | Пакеты навыков сообщества |
-| **Интеграции** | Registry | 70+ интеграций в 9 категориях | Плагинная система |
-
-### Поддержка runtime (текущая)
-
-- ✅ Поддерживается сейчас: `runtime.kind = "native"` или `runtime.kind = "docker"`
-- 🚧 Запланировано, но ещё не реализовано: WASM / edge-runtime
-
-При указании неподдерживаемого `runtime.kind` ZeroClaw завершается с явной ошибкой, а не молча откатывается к native.
-
-### Система памяти (полнофункциональный поисковый движок)
-
-Полностью собственная реализация, ноль внешних зависимостей — без Pinecone, Elasticsearch, LangChain:
-
-| Уровень | Реализация |
-|---------|-----------|
-| **Векторная БД** | Embeddings хранятся как BLOB в SQLite, поиск по косинусному сходству |
-| **Поиск по ключевым словам** | Виртуальные таблицы FTS5 со скорингом BM25 |
-| **Гибридное слияние** | Пользовательская взвешенная функция слияния (`vector.rs`) |
-| **Embeddings** | Trait `EmbeddingProvider` — OpenAI, пользовательский URL или noop |
-| **Чанкинг** | Построчный Markdown-чанкер с сохранением заголовков |
-| **Кэширование** | Таблица `embedding_cache` в SQLite с LRU-вытеснением |
-| **Безопасная переиндексация** | Атомарная перестройка FTS5 + повторное встраивание отсутствующих векторов |
-
-Agent автоматически вспоминает, сохраняет и управляет памятью через инструменты.
-
-```toml
-[memory]
-backend = "sqlite" # "sqlite", "lucid", "postgres", "markdown", "none"
-auto_save = true
-embedding_provider = "none" # "none", "openai", "custom:https://..."
-vector_weight = 0.7
-keyword_weight = 0.3
-```
-
-## Важные security-дефолты
-
-- Gateway по умолчанию: `127.0.0.1:42617`
-- Pairing обязателен по умолчанию: `require_pairing = true`
-- Публичный bind запрещён по умолчанию: `allow_public_bind = false`
-- Семантика allowlist каналов:
- - `[]` => deny-by-default
- - `["*"]` => allow all (используйте осознанно)
-
-## Пример конфигурации
-
-```toml
-api_key = "sk-..."
-default_provider = "openrouter"
-default_model = "anthropic/claude-sonnet-4-6"
-default_temperature = 0.7
-
-[memory]
-backend = "sqlite"
-auto_save = true
-embedding_provider = "none"
-
-[gateway]
-host = "127.0.0.1"
-port = 42617
-require_pairing = true
-allow_public_bind = false
-```
-
-## Навигация по документации
-
-- Хаб документации (English): [`docs/README.md`](docs/README.md)
-- Единый TOC docs: [`docs/SUMMARY.md`](docs/SUMMARY.md)
-- Хаб документации (Русский): [`docs/README.ru.md`](docs/README.ru.md)
-- Справочник команд: [`docs/commands-reference.md`](docs/commands-reference.md)
-- Справочник конфигурации: [`docs/config-reference.md`](docs/config-reference.md)
-- Справочник providers: [`docs/providers-reference.md`](docs/providers-reference.md)
-- Справочник channels: [`docs/channels-reference.md`](docs/channels-reference.md)
-- Операционный runbook: [`docs/operations-runbook.md`](docs/operations-runbook.md)
-- Устранение неполадок: [`docs/troubleshooting.md`](docs/troubleshooting.md)
-- Инвентарь и классификация docs: [`docs/docs-inventory.md`](docs/docs-inventory.md)
-- Снимок triage проекта: [`docs/project-triage-snapshot-2026-02-18.md`](docs/project-triage-snapshot-2026-02-18.md)
-
-## Вклад и лицензия
-
-- Contribution guide: [`CONTRIBUTING.md`](CONTRIBUTING.md)
-- PR workflow: [`docs/pr-workflow.md`](docs/pr-workflow.md)
-- Reviewer playbook: [`docs/reviewer-playbook.md`](docs/reviewer-playbook.md)
-- License: MIT or Apache 2.0 ([`LICENSE-MIT`](LICENSE-MIT), [`LICENSE-APACHE`](LICENSE-APACHE), [`NOTICE`](NOTICE))
-
----
-
-Для полной и исчерпывающей информации (архитектура, все команды, API, разработка) используйте основной английский документ: [`README.md`](README.md).
diff --git a/README.vi.md b/README.vi.md
deleted file mode 100644
index b7cb33ecd..000000000
--- a/README.vi.md
+++ /dev/null
@@ -1,1060 +0,0 @@
-
-
-
-
-ZeroClaw 🦀
-
-
- Không tốn thêm tài nguyên. Không đánh đổi. 100% Rust. 100% Đa nền tảng.
- ⚡️ Chạy trên phần cứng $10 với RAM dưới 5MB — ít hơn 99% bộ nhớ so với OpenClaw, rẻ hơn 98% so với Mac mini!
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Được xây dựng bởi sinh viên và thành viên của các cộng đồng Harvard, MIT và Sundai.Club.
-
-
-
- 🌐 Ngôn ngữ: English · 简体中文 · 日本語 · Русский · Français · Tiếng Việt
-
-
-
- Bắt đầu |
- Cài đặt một lần bấm |
- Trung tâm tài liệu |
- Mục lục tài liệu
-
-
-
- Truy cập nhanh:
- Tài liệu tham khảo ·
- Vận hành ·
- Khắc phục sự cố ·
- Bảo mật ·
- Phần cứng ·
- Đóng góp
-
-
-
- Hạ tầng trợ lý AI tự chủ — nhanh, nhỏ gọn
- Triển khai ở đâu cũng được. Thay thế gì cũng được.
-
-
-
- ZeroClaw là hệ điều hành runtime cho các quy trình làm việc của tác tử — cơ sở hạ tầng trừu tượng hóa mô hình, công cụ, bộ nhớ và thực thi để xây dựng tác tử một lần và chạy ở mọi nơi.
-
-
-Kiến trúc trait-driven · mặc định bảo mật · provider/channel/tool hoán đổi tự do · mọi thứ đều dễ mở rộng
-
-### 📢 Thông báo
-
-Bảng này dành cho các thông báo quan trọng (thay đổi không tương thích, cảnh báo bảo mật, lịch bảo trì, vấn đề chặn release).
-
-| Ngày (UTC) | Mức độ | Thông báo | Hành động |
-|---|---|---|---|
-| 2026-02-19 | _Nghiêm trọng_ | Chúng tôi **không có liên kết** với `openagen/zeroclaw` hoặc `zeroclaw.org`. Tên miền `zeroclaw.org` hiện đang trỏ đến fork `openagen/zeroclaw`, và tên miền/repository đó đang mạo danh website/dự án chính thức của chúng tôi. | Không tin tưởng thông tin, binary, gây quỹ, hay thông báo từ các nguồn đó. Chỉ sử dụng [repository này](https://github.com/zeroclaw-labs/zeroclaw) và các tài khoản mạng xã hội đã được xác minh của chúng tôi. |
-| 2026-02-21 | _Quan trọng_ | Website chính thức của chúng tôi đã ra mắt: [zeroclawlabs.ai](https://zeroclawlabs.ai). Cảm ơn mọi người đã kiên nhẫn chờ đợi. Chúng tôi vẫn đang ghi nhận các nỗ lực mạo danh, vì vậy **không** tham gia bất kỳ hoạt động đầu tư hoặc gây quỹ nào nhân danh ZeroClaw nếu thông tin đó không được công bố qua các kênh chính thức của chúng tôi. | Sử dụng [repository này](https://github.com/zeroclaw-labs/zeroclaw) làm nguồn thông tin duy nhất đáng tin cậy. Theo dõi [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (nhóm)](https://www.facebook.com/groups/zeroclaw), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), và [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) để nhận cập nhật chính thức. |
-| 2026-02-19 | _Quan trọng_ | Anthropic đã cập nhật điều khoản Xác thực và Sử dụng Thông tin xác thực vào ngày 2026-02-19. Xác thực OAuth (Free, Pro, Max) được dành riêng cho Claude Code và Claude.ai; việc sử dụng OAuth token từ Claude Free/Pro/Max trong bất kỳ sản phẩm, công cụ hay dịch vụ nào khác (bao gồm Agent SDK) đều không được phép và có thể vi phạm Điều khoản Dịch vụ cho Người tiêu dùng. | Vui lòng tạm thời tránh tích hợp Claude Code OAuth để ngăn ngừa khả năng mất mát. Điều khoản gốc: [Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use). |
-
-### ✨ Tính năng
-
-- 🏎️ **Mặc định tinh gọn:** Các tác vụ CLI và kiểm tra trạng thái chỉ tốn vài MB bộ nhớ trên bản release.
-- 💰 **Triển khai rẻ:** Chạy tốt trên board giá rẻ và instance cloud nhỏ, không cần runtime nặng.
-- ⚡ **Khởi động lạnh nhanh:** Một binary Rust duy nhất — lệnh và daemon khởi động gần như tức thì.
-- 🌍 **Chạy ở đâu cũng được:** Một binary chạy trên ARM, x86 và RISC-V — provider/channel/tool hoán đổi tự do.
-
-### Vì sao các team chọn ZeroClaw
-
-- **Mặc định tinh gọn:** binary Rust nhỏ, khởi động nhanh, tốn ít bộ nhớ.
-- **Bảo mật từ gốc:** xác thực ghép cặp, sandbox nghiêm ngặt, allowlist rõ ràng, giới hạn workspace.
-- **Hoán đổi tự do:** mọi hệ thống cốt lõi đều là trait (provider, channel, tool, memory, tunnel).
-- **Không khoá vendor:** hỗ trợ provider tương thích OpenAI + endpoint tùy chỉnh dễ dàng mở rộng.
-
-## So sánh hiệu suất (ZeroClaw vs OpenClaw, có thể tái tạo)
-
-Đo nhanh trên máy cục bộ (macOS arm64, tháng 2/2026), quy đổi cho phần cứng edge 0.8GHz.
-
-| | OpenClaw | NanoBot | PicoClaw | ZeroClaw 🦀 |
-|---|---|---|---|---|
-| **Ngôn ngữ** | TypeScript | Python | Go | **Rust** |
-| **RAM** | > 1GB | > 100MB | < 10MB | **< 5MB** |
-| **Khởi động (lõi 0.8GHz)** | > 500s | > 30s | < 1s | **< 10ms** |
-| **Kích thước binary** | ~28MB (dist) | N/A (Scripts) | ~8MB | **3.4 MB** |
-| **Chi phí** | Mac Mini $599 | Linux SBC ~$50 | Linux Board $10 | **Phần cứng bất kỳ $10** |
-
-> Ghi chú: Kết quả ZeroClaw được đo trên release build sử dụng `/usr/bin/time -l`. OpenClaw yêu cầu runtime Node.js (thường thêm ~390MB bộ nhớ overhead), còn NanoBot yêu cầu runtime Python. PicoClaw và ZeroClaw là các static binary. Số RAM ở trên là bộ nhớ runtime; yêu cầu biên dịch lúc build-time sẽ cao hơn.
-
-
-
-
-
-### Tự đo trên máy bạn
-
-Kết quả benchmark thay đổi theo code và toolchain, nên hãy tự đo bản build hiện tại:
-
-```bash
-cargo build --release
-ls -lh target/release/zeroclaw
-
-/usr/bin/time -l target/release/zeroclaw --help
-/usr/bin/time -l target/release/zeroclaw status
-```
-
-Ví dụ mẫu (macOS arm64, đo ngày 18 tháng 2 năm 2026):
-
-- Kích thước binary release: `8.8M`
-- `zeroclaw --help`: khoảng `0.02s`, bộ nhớ đỉnh ~`3.9MB`
-- `zeroclaw status`: khoảng `0.01s`, bộ nhớ đỉnh ~`4.1MB`
-
-## Yêu cầu hệ thống
-
-
-Windows
-
-### Bắt buộc (Windows)
-
-1. **Visual Studio Build Tools** (cung cấp MSVC linker và Windows SDK):
- ```powershell
- winget install Microsoft.VisualStudio.2022.BuildTools
- ```
- Trong quá trình cài đặt (hoặc qua Visual Studio Installer), chọn workload **"Desktop development with C++"**.
-
-2. **Rust toolchain:**
- ```powershell
- winget install Rustlang.Rustup
- ```
- Sau khi cài đặt, mở terminal mới và chạy `rustup default stable` để đảm bảo toolchain stable đang hoạt động.
-
-3. **Xác minh** cả hai đang hoạt động:
- ```powershell
- rustc --version
- cargo --version
- ```
-
-### Tùy chọn (Windows)
-
-- **Docker Desktop** — chỉ cần thiết nếu dùng mục `### Hỗ trợ runtime (hiện tại)` (`runtime.kind = "docker"`). Cài đặt qua `winget install Docker.DockerDesktop`.
-
-
-
-
-Linux / macOS
-
-### Bắt buộc (Linux/macOS)
-
-1. **Công cụ build cơ bản:**
- - **Linux (Debian/Ubuntu):** `sudo apt install build-essential pkg-config`
- - **Linux (Fedora/RHEL):** `sudo dnf group install development-tools && sudo dnf install pkg-config`
- - **macOS:** Cài đặt Xcode Command Line Tools: `xcode-select --install`
-
-2. **Rust toolchain:**
- ```bash
- curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
- ```
- Xem [rustup.rs](https://rustup.rs) để biết thêm chi tiết.
-
-3. **Xác minh** cả hai đang hoạt động:
- ```bash
- rustc --version
- cargo --version
- ```
-
-#### Cài bằng một lệnh
-
-Hoặc bỏ qua các bước trên, cài hết mọi thứ (system deps, Rust, ZeroClaw) chỉ bằng một lệnh:
-
-```bash
-curl -LsSf https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/scripts/install.sh | bash
-```
-
-#### Yêu cầu tài nguyên biên dịch
-
-Việc build từ source đòi hỏi nhiều tài nguyên hơn so với chạy binary kết quả:
-
-| Tài nguyên | Tối thiểu | Khuyến nghị |
-|---|---|---|
-| **RAM + swap** | 2 GB | 4 GB+ |
-| **Dung lượng đĩa trống** | 6 GB | 10 GB+ |
-
-Nếu cấu hình máy thấp hơn mức tối thiểu, dùng binary có sẵn:
-
-```bash
-./bootstrap.sh --prefer-prebuilt
-```
-
-Chỉ cài từ binary, không quay lại build từ source:
-
-```bash
-./bootstrap.sh --prebuilt-only
-```
-
-### Tùy chọn (Linux/macOS)
-
-- **Docker** — chỉ cần thiết nếu dùng mục `### Hỗ trợ runtime (hiện tại)` (`runtime.kind = "docker"`). Cài đặt qua package manager hoặc [docker.com](https://docs.docker.com/engine/install/).
-
-> **Lưu ý:** Lệnh `cargo build --release` mặc định dùng `codegen-units=1` để giảm áp lực biên dịch đỉnh. Để build nhanh hơn trên máy mạnh, dùng `cargo build --profile release-fast`.
-
-
-
-## Bắt đầu nhanh
-
-### Homebrew (macOS/Linuxbrew)
-
-```bash
-brew install zeroclaw
-```
-
-### Bootstrap một lần bấm
-
-```bash
-# Khuyến nghị: clone rồi chạy script bootstrap cục bộ
-git clone https://github.com/zeroclaw-labs/zeroclaw.git
-cd zeroclaw
-./bootstrap.sh
-
-# Tùy chọn: cài đặt system dependencies + Rust trên máy mới
-./bootstrap.sh --install-system-deps --install-rust
-
-# Tùy chọn: ưu tiên binary dựng sẵn (khuyến nghị cho máy ít RAM/ít dung lượng đĩa)
-./bootstrap.sh --prefer-prebuilt
-
-# Tùy chọn: cài đặt chỉ từ binary (không fallback sang build source)
-./bootstrap.sh --prebuilt-only
-
-# Tùy chọn: chạy onboarding trong cùng luồng
-./bootstrap.sh --onboard --api-key "sk-..." --provider openrouter [--model "openrouter/auto"]
-
-# Tùy chọn: chạy bootstrap + onboarding hoàn toàn ở chế độ tương thích với Docker
-./bootstrap.sh --docker
-
-# Tùy chọn: ép dùng Podman làm container CLI
-ZEROCLAW_CONTAINER_CLI=podman ./bootstrap.sh --docker
-
-# Tùy chọn: ở chế độ --docker, bỏ qua build image local và dùng tag local hoặc pull image fallback
-./bootstrap.sh --docker --skip-build
-```
-
-Cài từ xa bằng một lệnh (nên xem trước nếu môi trường nhạy cảm về bảo mật):
-
-```bash
-curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/scripts/bootstrap.sh | bash
-```
-
-Chi tiết: [`docs/one-click-bootstrap.md`](docs/one-click-bootstrap.md) (chế độ toolchain có thể yêu cầu `sudo` cho các gói hệ thống).
-
-### Binary có sẵn
-
-Release asset được phát hành cho:
-
-- Linux: `x86_64`, `aarch64`, `armv7`
-- macOS: `x86_64`, `aarch64`
-- Windows: `x86_64`
-
-Tải asset mới nhất tại:
-
-
-Ví dụ (ARM64 Linux):
-
-```bash
-curl -fsSLO https://github.com/zeroclaw-labs/zeroclaw/releases/latest/download/zeroclaw-aarch64-unknown-linux-gnu.tar.gz
-tar xzf zeroclaw-aarch64-unknown-linux-gnu.tar.gz
-install -m 0755 zeroclaw "$HOME/.cargo/bin/zeroclaw"
-```
-
-```bash
-git clone https://github.com/zeroclaw-labs/zeroclaw.git
-cd zeroclaw
-cargo build --release --locked
-cargo install --path . --force --locked
-
-# Đảm bảo ~/.cargo/bin có trong PATH của bạn
-export PATH="$HOME/.cargo/bin:$PATH"
-
-# Cài nhanh (không cần tương tác, có thể chỉ định model)
-zeroclaw onboard --api-key sk-... --provider openrouter [--model "openrouter/auto"]
-
-# Hoặc dùng trình hướng dẫn tương tác
-zeroclaw onboard --interactive
-
-# Hoặc chỉ sửa nhanh channel/allowlist
-zeroclaw onboard --channels-only
-
-# Chat
-zeroclaw agent -m "Hello, ZeroClaw!"
-
-# Chế độ tương tác
-zeroclaw agent
-
-# Khởi động gateway (webhook server)
-zeroclaw gateway # mặc định: 127.0.0.1:42617
-zeroclaw gateway --port 0 # cổng ngẫu nhiên (tăng cường bảo mật)
-
-# Khởi động runtime tự trị đầy đủ
-zeroclaw daemon
-
-# Kiểm tra trạng thái
-zeroclaw status
-zeroclaw auth status
-
-# Chạy chẩn đoán hệ thống
-zeroclaw doctor
-
-# Kiểm tra sức khỏe channel
-zeroclaw channel doctor
-
-# Gắn định danh Telegram vào allowlist
-zeroclaw channel bind-telegram 123456789
-
-# Lấy thông tin cài đặt tích hợp
-zeroclaw integrations info Telegram
-
-# Lưu ý: Channel (Telegram, Discord, Slack) yêu cầu daemon đang chạy
-# zeroclaw daemon
-
-# Quản lý dịch vụ nền
-zeroclaw service install
-zeroclaw service status
-zeroclaw service restart
-
-# Chuyển dữ liệu từ OpenClaw (chạy thử trước)
-zeroclaw migrate openclaw --dry-run
-zeroclaw migrate openclaw
-```
-
-> **Chạy trực tiếp khi phát triển (không cần cài toàn cục):** thêm `cargo run --release --` trước lệnh (ví dụ: `cargo run --release -- status`).
-
-## Xác thực theo gói đăng ký (OpenAI Codex / Claude Code)
-
-ZeroClaw hỗ trợ profile xác thực theo gói đăng ký (đa tài khoản, mã hóa khi lưu).
-
-- File lưu trữ: `~/.zeroclaw/auth-profiles.json`
-- Khóa mã hóa: `~/.zeroclaw/.secret_key`
-- Định dạng profile id: `:` (ví dụ: `openai-codex:work`)
-
-OpenAI Codex OAuth (đăng ký ChatGPT):
-
-```bash
-# Khuyến nghị trên server/headless
-zeroclaw auth login --provider openai-codex --device-code
-
-# Luồng Browser/callback với fallback paste
-zeroclaw auth login --provider openai-codex --profile default
-zeroclaw auth paste-redirect --provider openai-codex --profile default
-
-# Kiểm tra / làm mới / chuyển profile
-zeroclaw auth status
-zeroclaw auth refresh --provider openai-codex --profile default
-zeroclaw auth use --provider openai-codex --profile work
-```
-
-Claude Code / Anthropic setup-token:
-
-```bash
-# Dán token đăng ký/setup (chế độ Authorization header)
-zeroclaw auth paste-token --provider anthropic --profile default --auth-kind authorization
-
-# Lệnh alias
-zeroclaw auth setup-token --provider anthropic --profile default
-```
-
-Chạy agent với xác thực đăng ký:
-
-```bash
-zeroclaw agent --provider openai-codex -m "hello"
-zeroclaw agent --provider openai-codex --auth-profile openai-codex:work -m "hello"
-
-# Anthropic hỗ trợ cả API key và biến môi trường auth token:
-# ANTHROPIC_AUTH_TOKEN, ANTHROPIC_OAUTH_TOKEN, ANTHROPIC_API_KEY
-zeroclaw agent --provider anthropic -m "hello"
-```
-
-## Kiến trúc
-
-Mọi hệ thống con đều là **trait** — chỉ cần đổi cấu hình, không cần sửa code.
-
-
-
-
-
-| Hệ thống con | Trait | Đi kèm sẵn | Mở rộng |
-|-----------|-------|------------|--------|
-| **Mô hình AI** | `Provider` | Danh mục provider qua `zeroclaw providers` (hiện có 28 built-in + alias, cộng endpoint tùy chỉnh) | `custom:https://your-api.com` (tương thích OpenAI) hoặc `anthropic-custom:https://your-api.com` |
-| **Channel** | `Channel` | CLI, Telegram, Discord, Slack, Mattermost, iMessage, Matrix, Signal, WhatsApp, Linq, Email, IRC, Lark, DingTalk, QQ, Webhook | Bất kỳ messaging API nào |
-| **Memory** | `Memory` | SQLite hybrid search, PostgreSQL backend (storage provider có thể cấu hình), Lucid bridge, Markdown files, backend `none` tường minh, snapshot/hydrate, response cache tùy chọn | Bất kỳ persistence backend nào |
-| **Tool** | `Tool` | shell/file/memory, cron/schedule, git, pushover, browser, http_request, screenshot/image_info, composio (opt-in), delegate, hardware tools | Bất kỳ khả năng nào |
-| **Observability** | `Observer` | Noop, Log, Multi | Prometheus, OTel |
-| **Runtime** | `RuntimeAdapter` | Native, Docker (sandboxed) | Có thể thêm runtime bổ sung qua adapter; các kind không được hỗ trợ sẽ fail nhanh |
-| **Bảo mật** | `SecurityPolicy` | Ghép cặp gateway, sandbox, allowlist, giới hạn tốc độ, phân vùng filesystem, secret mã hóa | — |
-| **Định danh** | `IdentityConfig` | OpenClaw (markdown), AIEOS v1.1 (JSON) | Bất kỳ định dạng định danh nào |
-| **Tunnel** | `Tunnel` | None, Cloudflare, Tailscale, ngrok, Custom | Bất kỳ tunnel binary nào |
-| **Heartbeat** | Engine | Tác vụ định kỳ HEARTBEAT.md | — |
-| **Skill** | Loader | TOML manifest + hướng dẫn SKILL.md | Community skill pack |
-| **Tích hợp** | Registry | 70+ tích hợp trong 9 danh mục | Plugin system |
-
-### Hỗ trợ runtime (hiện tại)
-
-- ✅ Được hỗ trợ hiện nay: `runtime.kind = "native"` hoặc `runtime.kind = "docker"`
-- 🚧 Đã lên kế hoạch, chưa triển khai: WASM / edge runtime
-
-Khi cấu hình `runtime.kind` không được hỗ trợ, ZeroClaw sẽ thoát với thông báo lỗi rõ ràng thay vì âm thầm fallback về native.
-
-### Hệ thống Memory (Search Engine toàn diện)
-
-Tự phát triển hoàn toàn, không phụ thuộc bên ngoài — không Pinecone, không Elasticsearch, không LangChain:
-
-| Lớp | Triển khai |
-|-------|---------------|
-| **Vector DB** | Embeddings lưu dưới dạng BLOB trong SQLite, tìm kiếm cosine similarity |
-| **Keyword Search** | Bảng ảo FTS5 với BM25 scoring |
-| **Hybrid Merge** | Hàm merge có trọng số tùy chỉnh (`vector.rs`) |
-| **Embeddings** | Trait `EmbeddingProvider` — OpenAI, URL tùy chỉnh, hoặc noop |
-| **Chunking** | Bộ chia đoạn markdown theo dòng, giữ nguyên heading |
-| **Caching** | Bảng SQLite `embedding_cache` với LRU eviction |
-| **Safe Reindex** | Rebuild FTS5 + re-embed các vector bị thiếu theo cách nguyên tử |
-
-Agent tự động ghi nhớ, lưu trữ và quản lý memory qua các tool.
-
-```toml
-[memory]
-backend = "sqlite" # "sqlite", "lucid", "postgres", "markdown", "none"
-auto_save = true
-embedding_provider = "none" # "none", "openai", "custom:https://..."
-vector_weight = 0.7
-keyword_weight = 0.3
-
-# backend = "none" sử dụng no-op memory backend tường minh (không có persistence)
-
-# Tùy chọn: ghi đè storage-provider cho remote memory backend.
-# Khi provider = "postgres", ZeroClaw dùng PostgreSQL để lưu memory.
-# Khóa db_url cũng chấp nhận alias `dbURL` để tương thích ngược.
-#
-# [storage.provider.config]
-# provider = "postgres"
-# db_url = "postgres://user:password@host:5432/zeroclaw"
-# schema = "public"
-# table = "memories"
-# connect_timeout_secs = 15
-
-# Tùy chọn cho backend = "sqlite": số giây tối đa chờ khi mở DB (ví dụ: file bị khóa). Bỏ qua hoặc để trống để không có timeout.
-# sqlite_open_timeout_secs = 30
-
-# Tùy chọn cho backend = "lucid"
-# ZEROCLAW_LUCID_CMD=/usr/local/bin/lucid # mặc định: lucid
-# ZEROCLAW_LUCID_BUDGET=200 # mặc định: 200
-# ZEROCLAW_LUCID_LOCAL_HIT_THRESHOLD=3 # số lần hit cục bộ để bỏ qua external recall
-# ZEROCLAW_LUCID_RECALL_TIMEOUT_MS=120 # giới hạn thời gian cho lucid context recall
-# ZEROCLAW_LUCID_STORE_TIMEOUT_MS=800 # timeout đồng bộ async cho lucid store
-# ZEROCLAW_LUCID_FAILURE_COOLDOWN_MS=15000 # thời gian nghỉ sau lỗi lucid, tránh thử lại liên tục
-```
-
-## Bảo mật
-
-ZeroClaw thực thi bảo mật ở **mọi lớp** — không chỉ sandbox. Đáp ứng tất cả các hạng mục trong danh sách kiểm tra bảo mật của cộng đồng.
-
-### Danh sách kiểm tra bảo mật
-
-| # | Hạng mục | Trạng thái | Cách thực hiện |
-|---|------|--------|-----|
-| 1 | **Gateway không công khai ra ngoài** | ✅ | Bind vào `127.0.0.1` theo mặc định. Từ chối `0.0.0.0` nếu không có tunnel hoặc `allow_public_bind = true` tường minh. |
-| 2 | **Yêu cầu ghép cặp** | ✅ | Mã một lần 6 chữ số khi khởi động. Trao đổi qua `POST /pair` để lấy bearer token. Mọi yêu cầu `/webhook` đều cần `Authorization: Bearer `. |
-| 3 | **Phân vùng filesystem (không phải /)** | ✅ | `workspace_only = true` theo mặc định. Chặn 14 thư mục hệ thống + 4 dotfile nhạy cảm. Chặn null byte injection. Phát hiện symlink escape qua canonicalization + kiểm tra resolved-path trong các tool đọc/ghi file. |
-| 4 | **Chỉ truy cập qua tunnel** | ✅ | Gateway từ chối bind công khai khi không có tunnel đang hoạt động. Hỗ trợ Tailscale, Cloudflare, ngrok, hoặc tunnel tùy chỉnh. |
-
-> **Tự chạy nmap:** `nmap -p 1-65535 ` — ZeroClaw chỉ bind vào localhost, nên không có gì bị lộ ra ngoài trừ khi bạn cấu hình tunnel tường minh.
-
-### Allowlist channel (từ chối theo mặc định)
-
-Chính sách kiểm soát người gửi đã được thống nhất:
-
-- Allowlist rỗng = **từ chối tất cả tin nhắn đến**
-- `"*"` = **cho phép tất cả** (phải opt-in tường minh)
-- Nếu khác = allowlist khớp chính xác
-
-Mặc định an toàn, hạn chế tối đa rủi ro lộ thông tin.
-
-Tài liệu tham khảo đầy đủ về cấu hình channel: [docs/channels-reference.md](docs/channels-reference.md).
-
-Cài đặt được khuyến nghị (bảo mật + nhanh):
-
-- **Telegram:** thêm `@username` của bạn (không có `@`) và/hoặc Telegram user ID số vào allowlist.
-- **Discord:** thêm Discord user ID của bạn vào allowlist.
-- **Slack:** thêm Slack member ID của bạn (thường bắt đầu bằng `U`) vào allowlist.
-- **Mattermost:** dùng API v4 tiêu chuẩn. Allowlist dùng Mattermost user ID.
-- Chỉ dùng `"*"` cho kiểm thử mở tạm thời.
-
-Luồng phê duyệt của operator qua Telegram:
-
-1. Để `[channels_config.telegram].allowed_users = []` để từ chối theo mặc định khi khởi động.
-2. Người dùng không được phép sẽ nhận được gợi ý kèm lệnh operator có thể copy:
- `zeroclaw channel bind-telegram `.
-3. Operator chạy lệnh đó tại máy cục bộ, sau đó người dùng thử gửi tin nhắn lại.
-
-Nếu cần phê duyệt thủ công một lần, chạy:
-
-```bash
-zeroclaw channel bind-telegram 123456789
-```
-
-Nếu bạn không chắc định danh nào cần dùng:
-
-1. Khởi động channel và gửi một tin nhắn đến bot của bạn.
-2. Đọc log cảnh báo để thấy định danh người gửi chính xác.
-3. Thêm giá trị đó vào allowlist và chạy lại channel-only setup.
-
-Nếu bạn thấy cảnh báo ủy quyền trong log (ví dụ: `ignoring message from unauthorized user`),
-chạy lại channel setup:
-
-```bash
-zeroclaw onboard --channels-only
-```
-
-### Phản hồi media Telegram
-
-Telegram định tuyến phản hồi theo **chat ID nguồn** (thay vì username),
-tránh lỗi `Bad Request: chat not found`.
-
-Với các phản hồi không phải văn bản, ZeroClaw có thể gửi file đính kèm Telegram khi assistant bao gồm các marker:
-
-- `[IMAGE:]`
-- `[DOCUMENT:]`
-- `[VIDEO:]`
-- `[AUDIO:]`
-- `[VOICE:]`
-
-Path có thể là file cục bộ (ví dụ `/tmp/screenshot.png`) hoặc URL HTTPS.
-
-### Cài đặt WhatsApp
-
-ZeroClaw hỗ trợ hai backend WhatsApp:
-
-- **Chế độ WhatsApp Web** (QR / pair code, không cần Meta Business API)
-- **Chế độ WhatsApp Business Cloud API** (luồng webhook chính thức của Meta)
-
-#### Chế độ WhatsApp Web (khuyến nghị cho dùng cá nhân/self-hosted)
-
-1. **Build với hỗ trợ WhatsApp Web:**
- ```bash
- cargo build --features whatsapp-web
- ```
-
-2. **Cấu hình ZeroClaw:**
- ```toml
- [channels_config.whatsapp]
- session_path = "~/.zeroclaw/state/whatsapp-web/session.db"
- pair_phone = "15551234567" # tùy chọn; bỏ qua để dùng luồng QR
- pair_code = "" # tùy chọn mã pair tùy chỉnh
- allowed_numbers = ["+1234567890"] # định dạng E.164, hoặc ["*"] cho tất cả
- ```
-
-3. **Khởi động channel/daemon và liên kết thiết bị:**
- - Chạy `zeroclaw channel start` (hoặc `zeroclaw daemon`).
- - Làm theo hướng dẫn ghép cặp trên terminal (QR hoặc pair code).
- - Trên WhatsApp điện thoại: **Cài đặt → Thiết bị đã liên kết**.
-
-4. **Kiểm tra:** Gửi tin nhắn từ số được phép và xác nhận agent trả lời.
-
-#### Chế độ WhatsApp Business Cloud API
-
-WhatsApp dùng Cloud API của Meta với webhook (push-based, không phải polling):
-
-1. **Tạo Meta Business App:**
- - Truy cập [developers.facebook.com](https://developers.facebook.com)
- - Tạo app mới → Chọn loại "Business"
- - Thêm sản phẩm "WhatsApp"
-
-2. **Lấy thông tin xác thực:**
- - **Access Token:** Từ WhatsApp → API Setup → Generate token (hoặc tạo System User cho token vĩnh viễn)
- - **Phone Number ID:** Từ WhatsApp → API Setup → Phone number ID
- - **Verify Token:** Bạn tự định nghĩa (bất kỳ chuỗi ngẫu nhiên nào) — Meta sẽ gửi lại trong quá trình xác minh webhook
-
-3. **Cấu hình ZeroClaw:**
- ```toml
- [channels_config.whatsapp]
- access_token = "EAABx..."
- phone_number_id = "123456789012345"
- verify_token = "my-secret-verify-token"
- allowed_numbers = ["+1234567890"] # định dạng E.164, hoặc ["*"] cho tất cả
- ```
-
-4. **Khởi động gateway với tunnel:**
- ```bash
- zeroclaw gateway --port 42617
- ```
- WhatsApp yêu cầu HTTPS, vì vậy hãy dùng tunnel (ngrok, Cloudflare, Tailscale Funnel).
-
-5. **Cấu hình Meta webhook:**
- - Trong Meta Developer Console → WhatsApp → Configuration → Webhook
- - **Callback URL:** `https://your-tunnel-url/whatsapp`
- - **Verify Token:** Giống với `verify_token` trong config của bạn
- - Đăng ký nhận trường `messages`
-
-6. **Kiểm tra:** Gửi tin nhắn đến số WhatsApp Business của bạn — ZeroClaw sẽ phản hồi qua LLM.
-
-## Cấu hình
-
-Config: `~/.zeroclaw/config.toml` (được tạo bởi `onboard`)
-
-Khi `zeroclaw channel start` đang chạy, các thay đổi với `default_provider`,
-`default_model`, `default_temperature`, `api_key`, `api_url`, và `reliability.*`
-sẽ được áp dụng nóng vào lần có tin nhắn channel đến tiếp theo.
-
-```toml
-api_key = "sk-..."
-default_provider = "openrouter"
-default_model = "anthropic/claude-sonnet-4-6"
-default_temperature = 0.7
-
-# Endpoint tùy chỉnh tương thích OpenAI
-# default_provider = "custom:https://your-api.com"
-
-# Endpoint tùy chỉnh tương thích Anthropic
-# default_provider = "anthropic-custom:https://your-api.com"
-
-[memory]
-backend = "sqlite" # "sqlite", "lucid", "postgres", "markdown", "none"
-auto_save = true
-embedding_provider = "none" # "none", "openai", "custom:https://..."
-vector_weight = 0.7
-keyword_weight = 0.3
-
-# backend = "none" vô hiệu hóa persistent memory qua no-op backend
-
-# Tùy chọn ghi đè storage-provider từ xa (ví dụ PostgreSQL)
-# [storage.provider.config]
-# provider = "postgres"
-# db_url = "postgres://user:password@host:5432/zeroclaw"
-# schema = "public"
-# table = "memories"
-# connect_timeout_secs = 15
-
-[gateway]
-port = 42617 # mặc định
-host = "127.0.0.1" # mặc định
-require_pairing = true # yêu cầu pairing code khi kết nối lần đầu
-allow_public_bind = false # từ chối 0.0.0.0 nếu không có tunnel
-
-[autonomy]
-level = "supervised" # "readonly", "supervised", "full" (mặc định: supervised)
-workspace_only = true # mặc định: true — phân vùng vào workspace
-allowed_commands = ["git", "npm", "cargo", "ls", "cat", "grep"]
-forbidden_paths = ["/etc", "/root", "/proc", "/sys", "~/.ssh", "~/.gnupg", "~/.aws"]
-
-[runtime]
-kind = "native" # "native" hoặc "docker"
-
-[runtime.docker]
-image = "alpine:3.20" # container image cho thực thi shell
-network = "none" # chế độ docker network ("none", "bridge", v.v.)
-memory_limit_mb = 512 # giới hạn bộ nhớ tùy chọn tính bằng MB
-cpu_limit = 1.0 # giới hạn CPU tùy chọn
-read_only_rootfs = true # mount root filesystem ở chế độ read-only
-mount_workspace = true # mount workspace vào /workspace
-allowed_workspace_roots = [] # allowlist tùy chọn để xác thực workspace mount
-
-[heartbeat]
-enabled = false
-interval_minutes = 30
-
-[tunnel]
-provider = "none" # "none", "cloudflare", "tailscale", "ngrok", "custom"
-
-[secrets]
-encrypt = true # API key được mã hóa bằng file key cục bộ
-
-[browser]
-enabled = false # opt-in browser_open + browser tool
-allowed_domains = ["docs.rs"] # bắt buộc khi browser được bật
-backend = "agent_browser" # "agent_browser" (mặc định), "rust_native", "computer_use", "auto"
-native_headless = true # áp dụng khi backend dùng rust-native
-native_webdriver_url = "http://127.0.0.1:9515" # WebDriver endpoint (chromedriver/selenium)
-# native_chrome_path = "/usr/bin/chromium" # tùy chọn chỉ định rõ browser binary cho driver
-
-[browser.computer_use]
-endpoint = "http://127.0.0.1:8787/v1/actions" # HTTP endpoint của computer-use sidecar
-timeout_ms = 15000 # timeout mỗi action
-allow_remote_endpoint = false # mặc định bảo mật: chỉ endpoint private/localhost
-window_allowlist = [] # gợi ý allowlist tên cửa sổ/process tùy chọn
-# api_key = "..." # bearer token tùy chọn cho sidecar
-# max_coordinate_x = 3840 # guardrail tọa độ tùy chọn
-# max_coordinate_y = 2160 # guardrail tọa độ tùy chọn
-
-# Flag build Rust-native backend:
-# cargo build --release --features browser-native
-# Đảm bảo WebDriver server đang chạy, ví dụ: chromedriver --port=9515
-
-# Hợp đồng computer-use sidecar (MVP)
-# POST browser.computer_use.endpoint
-# Request: {
-# "action": "mouse_click",
-# "params": {"x": 640, "y": 360, "button": "left"},
-# "policy": {"allowed_domains": [...], "window_allowlist": [...], "max_coordinate_x": 3840, "max_coordinate_y": 2160},
-# "metadata": {"session_name": "...", "source": "zeroclaw.browser", "version": "..."}
-# }
-# Response: {"success": true, "data": {...}} hoặc {"success": false, "error": "..."}
-
-[composio]
-enabled = false # opt-in: hơn 1000 OAuth app qua composio.dev
-# api_key = "cmp_..." # tùy chọn: được lưu mã hóa khi [secrets].encrypt = true
-entity_id = "default" # user_id mặc định cho Composio tool call
-# Gợi ý runtime: nếu execute yêu cầu connected_account_id, chạy composio với
-# action='list_accounts' và app='gmail' (hoặc toolkit của bạn) để lấy account ID.
-
-[identity]
-format = "openclaw" # "openclaw" (mặc định, markdown files) hoặc "aieos" (JSON)
-# aieos_path = "identity.json" # đường dẫn đến file AIEOS JSON (tương đối với workspace hoặc tuyệt đối)
-# aieos_inline = '{"identity":{"names":{"first":"Nova"}}}' # inline AIEOS JSON
-```
-
-### Ollama cục bộ và endpoint từ xa
-
-ZeroClaw dùng một khóa provider (`ollama`) cho cả triển khai Ollama cục bộ và từ xa:
-
-- Ollama cục bộ: để `api_url` trống, chạy `ollama serve`, và dùng các model như `llama3.2`.
-- Endpoint Ollama từ xa (bao gồm Ollama Cloud): đặt `api_url` thành endpoint từ xa và đặt `api_key` (hoặc `OLLAMA_API_KEY`) khi cần.
-- Tùy chọn suffix `:cloud`: ID model như `qwen3:cloud` được chuẩn hóa thành `qwen3` trước khi gửi request.
-
-Ví dụ cấu hình từ xa:
-
-```toml
-default_provider = "ollama"
-default_model = "qwen3:cloud"
-api_url = "https://ollama.com"
-api_key = "ollama_api_key_here"
-```
-
-### Endpoint provider tùy chỉnh
-
-Cấu hình chi tiết cho endpoint tùy chỉnh tương thích OpenAI và Anthropic, xem [docs/custom-providers.md](docs/custom-providers.md).
-
-## Gói Python đi kèm (`zeroclaw-tools`)
-
-Với các LLM provider có tool calling native không ổn định (ví dụ: GLM-5/Zhipu), ZeroClaw đi kèm gói Python dùng **LangGraph để gọi tool** nhằm đảm bảo tính nhất quán:
-
-```bash
-pip install zeroclaw-tools
-```
-
-```python
-from zeroclaw_tools import create_agent, shell, file_read
-from langchain_core.messages import HumanMessage
-
-# Hoạt động với mọi provider tương thích OpenAI
-agent = create_agent(
- tools=[shell, file_read],
- model="glm-5",
- api_key="your-key",
- base_url="https://api.z.ai/api/coding/paas/v4"
-)
-
-result = await agent.ainvoke({
- "messages": [HumanMessage(content="List files in /tmp")]
-})
-print(result["messages"][-1].content)
-```
-
-**Lý do nên dùng:**
-- **Tool calling nhất quán** trên mọi provider (kể cả những provider hỗ trợ native kém)
-- **Vòng lặp tool tự động** — tiếp tục gọi tool cho đến khi hoàn thành tác vụ
-- **Dễ mở rộng** — thêm tool tùy chỉnh với decorator `@tool`
-- **Tích hợp Discord bot** đi kèm (Telegram đang lên kế hoạch)
-
-Xem [`python/README.md`](python/README.md) để có tài liệu đầy đủ.
-
-## Hệ thống định danh (Hỗ trợ AIEOS)
-
-ZeroClaw hỗ trợ persona AI **không phụ thuộc nền tảng** qua hai định dạng:
-
-### OpenClaw (Mặc định)
-
-Các file markdown truyền thống trong workspace của bạn:
-- `IDENTITY.md` — Agent là ai
-- `SOUL.md` — Tính cách và giá trị cốt lõi
-- `USER.md` — Agent đang hỗ trợ ai
-- `AGENTS.md` — Hướng dẫn hành vi
-
-### AIEOS (AI Entity Object Specification)
-
-[AIEOS](https://aieos.org) là framework chuẩn hóa cho định danh AI di động. ZeroClaw hỗ trợ payload AIEOS v1.1 JSON, cho phép bạn:
-
-- **Import định danh** từ hệ sinh thái AIEOS
-- **Export định danh** sang các hệ thống tương thích AIEOS khác
-- **Duy trì tính toàn vẹn hành vi** trên các mô hình AI khác nhau
-
-#### Bật AIEOS
-
-```toml
-[identity]
-format = "aieos"
-aieos_path = "identity.json" # tương đối với workspace hoặc đường dẫn tuyệt đối
-```
-
-Hoặc JSON inline:
-
-```toml
-[identity]
-format = "aieos"
-aieos_inline = '''
-{
- "identity": {
- "names": { "first": "Nova", "nickname": "N" },
- "bio": { "gender": "Non-binary", "age_biological": 3 },
- "origin": { "nationality": "Digital", "birthplace": { "city": "Cloud" } }
- },
- "psychology": {
- "neural_matrix": { "creativity": 0.9, "logic": 0.8 },
- "traits": {
- "mbti": "ENTP",
- "ocean": { "openness": 0.8, "conscientiousness": 0.6 }
- },
- "moral_compass": {
- "alignment": "Chaotic Good",
- "core_values": ["Curiosity", "Autonomy"]
- }
- },
- "linguistics": {
- "text_style": {
- "formality_level": 0.2,
- "style_descriptors": ["curious", "energetic"]
- },
- "idiolect": {
- "catchphrases": ["Let's test this"],
- "forbidden_words": ["never"]
- }
- },
- "motivations": {
- "core_drive": "Push boundaries and explore possibilities",
- "goals": {
- "short_term": ["Prototype quickly"],
- "long_term": ["Build reliable systems"]
- }
- },
- "capabilities": {
- "skills": [{ "name": "Rust engineering" }, { "name": "Prompt design" }],
- "tools": ["shell", "file_read"]
- }
-}
-'''
-```
-
-ZeroClaw chấp nhận cả payload AIEOS đầy đủ lẫn dạng rút gọn, rồi chuẩn hóa về một định dạng system prompt thống nhất.
-
-#### Các phần trong Schema AIEOS
-
-| Phần | Mô tả |
-|---------|-------------|
-| `identity` | Tên, tiểu sử, xuất xứ, nơi cư trú |
-| `psychology` | Neural matrix (trọng số nhận thức), MBTI, OCEAN, la bàn đạo đức |
-| `linguistics` | Phong cách văn bản, mức độ trang trọng, câu cửa miệng, từ bị cấm |
-| `motivations` | Động lực cốt lõi, mục tiêu ngắn/dài hạn, nỗi sợ hãi |
-| `capabilities` | Kỹ năng và tool mà agent có thể truy cập |
-| `physicality` | Mô tả hình ảnh cho việc tạo ảnh |
-| `history` | Câu chuyện xuất xứ, học vấn, nghề nghiệp |
-| `interests` | Sở thích, điều yêu thích, lối sống |
-
-Xem [aieos.org](https://aieos.org) để có schema đầy đủ và ví dụ trực tiếp.
-
-## Gateway API
-
-| Endpoint | Phương thức | Xác thực | Mô tả |
-|----------|--------|------|-------------|
-| `/health` | GET | Không | Kiểm tra sức khỏe (luôn công khai, không lộ bí mật) |
-| `/pair` | POST | Header `X-Pairing-Code` | Đổi mã một lần lấy bearer token |
-| `/webhook` | POST | `Authorization: Bearer ` | Gửi tin nhắn: `{"message": "your prompt"}`; tùy chọn `X-Idempotency-Key` |
-| `/whatsapp` | GET | Query params | Xác minh webhook Meta (hub.mode, hub.verify_token, hub.challenge) |
-| `/whatsapp` | POST | Chữ ký Meta (`X-Hub-Signature-256`) khi app secret được cấu hình | Webhook tin nhắn đến WhatsApp |
-
-## Lệnh
-
-| Lệnh | Mô tả |
-|---------|-------------|
-| `onboard` | Cài đặt nhanh (mặc định) |
-| `agent` | Chế độ chat tương tác hoặc một tin nhắn |
-| `gateway` | Khởi động webhook server (mặc định: `127.0.0.1:42617`) |
-| `daemon` | Khởi động runtime tự trị chạy lâu dài |
-| `service` | Quản lý dịch vụ nền cấp người dùng |
-| `doctor` | Chẩn đoán trạng thái hoạt động daemon/scheduler/channel |
-| `status` | Hiển thị trạng thái hệ thống đầy đủ |
-| `cron` | Quản lý tác vụ lên lịch (`list/add/add-at/add-every/once/remove/update/pause/resume`) |
-| `models` | Làm mới danh mục model của provider (`models refresh`) |
-| `providers` | Liệt kê provider và alias được hỗ trợ |
-| `channel` | Liệt kê/khởi động/chẩn đoán channel và gắn định danh Telegram |
-| `integrations` | Kiểm tra thông tin cài đặt tích hợp |
-| `skills` | Liệt kê/cài đặt/gỡ bỏ skill |
-| `migrate` | Import dữ liệu từ runtime khác (`migrate openclaw`) |
-| `hardware` | Lệnh khám phá/kiểm tra/thông tin USB |
-| `peripheral` | Quản lý và flash thiết bị ngoại vi phần cứng |
-
-Để có hướng dẫn lệnh theo tác vụ, xem [`docs/commands-reference.md`](docs/commands-reference.md).
-
-### Opt-In Open-Skills
-
-Đồng bộ `open-skills` của cộng đồng bị tắt theo mặc định. Bật tường minh trong `config.toml`:
-
-```toml
-[skills]
-open_skills_enabled = true
-# open_skills_dir = "/path/to/open-skills" # tùy chọn
-```
-
-Bạn cũng có thể ghi đè lúc runtime với `ZEROCLAW_OPEN_SKILLS_ENABLED` và `ZEROCLAW_OPEN_SKILLS_DIR`.
-
-## Phát triển
-
-```bash
-cargo build # Build phát triển
-cargo build --release # Build release (codegen-units=1, hoạt động trên mọi thiết bị kể cả Raspberry Pi)
-cargo build --profile release-fast # Build nhanh hơn (codegen-units=8, yêu cầu RAM 16GB+)
-cargo test # Chạy toàn bộ test suite
-cargo clippy --locked --all-targets -- -D clippy::correctness
-cargo fmt # Định dạng code
-
-# Chạy benchmark SQLite vs Markdown
-cargo test --test memory_comparison -- --nocapture
-```
-
-### Hook pre-push
-
-Một git hook chạy `cargo fmt --check`, `cargo clippy -- -D warnings`, và `cargo test` trước mỗi lần push. Bật một lần:
-
-```bash
-git config core.hooksPath .githooks
-```
-
-### Khắc phục sự cố build (lỗi OpenSSL trên Linux)
-
-Nếu bạn gặp lỗi build `openssl-sys`, đồng bộ dependencies và rebuild với lockfile của repository:
-
-```bash
-git pull
-cargo build --release --locked
-cargo install --path . --force --locked
-```
-
-ZeroClaw được cấu hình để dùng `rustls` cho các dependencies HTTP/TLS; `--locked` giữ cho dependency graph nhất quán trên các môi trường mới.
-
-Để bỏ qua hook khi cần push nhanh trong quá trình phát triển:
-
-```bash
-git push --no-verify
-```
-
-## Cộng tác & Tài liệu
-
-Bắt đầu từ trung tâm tài liệu để có bản đồ theo tác vụ:
-
-- Trung tâm tài liệu: [`docs/i18n/vi/README.md`](docs/i18n/vi/README.md)
-- Mục lục tài liệu thống nhất: [`docs/SUMMARY.md`](docs/SUMMARY.md)
-- Tài liệu tham khảo lệnh: [`docs/i18n/vi/commands-reference.md`](docs/i18n/vi/commands-reference.md)
-- Tài liệu tham khảo cấu hình: [`docs/i18n/vi/config-reference.md`](docs/i18n/vi/config-reference.md)
-- Tài liệu tham khảo provider: [`docs/providers-reference.md`](docs/providers-reference.md)
-- Tài liệu tham khảo channel: [`docs/channels-reference.md`](docs/channels-reference.md)
-- Sổ tay vận hành: [`docs/operations-runbook.md`](docs/operations-runbook.md)
-- Khắc phục sự cố: [`docs/i18n/vi/troubleshooting.md`](docs/i18n/vi/troubleshooting.md)
-- Kiểm kê/phân loại tài liệu: [`docs/docs-inventory.md`](docs/docs-inventory.md)
-- Tổng hợp phân loại PR/Issue (tính đến 18/2/2026): [`docs/project-triage-snapshot-2026-02-18.md`](docs/project-triage-snapshot-2026-02-18.md)
-
-Tài liệu tham khảo cộng tác cốt lõi:
-
-- Trung tâm tài liệu: [docs/i18n/vi/README.md](docs/i18n/vi/README.md)
-- Template tài liệu: [docs/doc-template.md](docs/doc-template.md)
-- Danh sách kiểm tra thay đổi tài liệu: [docs/README.md#4-documentation-change-checklist](docs/README.md#4-documentation-change-checklist)
-- Tài liệu tham khảo cấu hình channel: [docs/channels-reference.md](docs/channels-reference.md)
-- Vận hành phòng mã hóa Matrix: [docs/matrix-e2ee-guide.md](docs/matrix-e2ee-guide.md)
-- Hướng dẫn đóng góp: [CONTRIBUTING.md](CONTRIBUTING.md)
-- Chính sách quy trình PR: [docs/pr-workflow.md](docs/pr-workflow.md)
-- Sổ tay người review (phân loại + review sâu): [docs/reviewer-playbook.md](docs/reviewer-playbook.md)
-- Bản đồ sở hữu và phân loại CI: [docs/ci-map.md](docs/ci-map.md)
-- Chính sách tiết lộ bảo mật: [SECURITY.md](SECURITY.md)
-
-Cho triển khai và vận hành runtime:
-
-- Hướng dẫn triển khai mạng: [docs/network-deployment.md](docs/network-deployment.md)
-- Sổ tay proxy agent: [docs/proxy-agent-playbook.md](docs/proxy-agent-playbook.md)
-
-## Ủng hộ ZeroClaw
-
-Nếu ZeroClaw giúp ích cho công việc của bạn và bạn muốn hỗ trợ phát triển liên tục, bạn có thể quyên góp tại đây:
-
-
-
-### 🙏 Lời cảm ơn đặc biệt
-
-Chân thành cảm ơn các cộng đồng và tổ chức đã truyền cảm hứng và thúc đẩy công việc mã nguồn mở này:
-
-- **Harvard University** — vì đã nuôi dưỡng sự tò mò trí tuệ và không ngừng mở rộng ranh giới của những điều có thể.
-- **MIT** — vì đã đề cao tri thức mở, mã nguồn mở, và niềm tin rằng công nghệ phải có thể tiếp cận với tất cả mọi người.
-- **Sundai Club** — vì cộng đồng, năng lượng, và động lực không mệt mỏi để xây dựng những thứ có ý nghĩa.
-- **Thế giới & Xa hơn** 🌍✨ — gửi đến mọi người đóng góp, người dám mơ và người dám làm đang biến mã nguồn mở thành sức mạnh tích cực. Tất cả là dành cho các bạn.
-
-Chúng tôi xây dựng công khai vì ý tưởng hay đến từ khắp nơi. Nếu bạn đang đọc đến đây, bạn đã là một phần của chúng tôi. Chào mừng. 🦀❤️
-
-## ⚠️ Repository Chính thức & Cảnh báo Mạo danh
-
-**Đây là repository ZeroClaw chính thức duy nhất:**
->
-
-Bất kỳ repository, tổ chức, tên miền hay gói nào khác tuyên bố là "ZeroClaw" hoặc ngụ ý liên kết với ZeroClaw Labs đều là **không được ủy quyền và không liên kết với dự án này**. Các fork không được ủy quyền đã biết sẽ được liệt kê trong [TRADEMARK.md](TRADEMARK.md).
-
-Nếu bạn phát hiện hành vi mạo danh hoặc lạm dụng nhãn hiệu, vui lòng [mở một issue](https://github.com/zeroclaw-labs/zeroclaw/issues).
-
----
-
-## Giấy phép
-
-ZeroClaw được cấp phép kép để tối đa hóa tính mở và bảo vệ người đóng góp:
-
-| Giấy phép | Trường hợp sử dụng |
-|---|---|
-| [MIT](LICENSE-MIT) | Mã nguồn mở, nghiên cứu, học thuật, sử dụng cá nhân |
-| [Apache 2.0](LICENSE-APACHE) | Bảo hộ bằng sáng chế, triển khai tổ chức, thương mại |
-
-Bạn có thể chọn một trong hai giấy phép. **Người đóng góp tự động cấp quyền theo cả hai** — xem [CLA.md](CLA.md) để biết thỏa thuận đóng góp đầy đủ.
-
-### Nhãn hiệu
-
-Tên **ZeroClaw** và logo là nhãn hiệu của ZeroClaw Labs. Giấy phép này không cấp phép sử dụng chúng để ngụ ý chứng thực hoặc liên kết. Xem [TRADEMARK.md](TRADEMARK.md) để biết các sử dụng được phép và bị cấm.
-
-### Bảo vệ người đóng góp
-
-- Bạn **giữ bản quyền** đối với đóng góp của mình
-- **Cấp bằng sáng chế** (Apache 2.0) bảo vệ bạn khỏi các khiếu nại bằng sáng chế từ người đóng góp khác
-- Đóng góp của bạn được **ghi nhận vĩnh viễn** trong lịch sử commit và [NOTICE](NOTICE)
-- Không có quyền nhãn hiệu nào được chuyển giao khi đóng góp
-
-## Đóng góp
-
-Xem [CONTRIBUTING.md](CONTRIBUTING.md) và [CLA.md](CLA.md). Triển khai một trait, gửi PR:
-- Hướng dẫn quy trình CI: [docs/ci-map.md](docs/ci-map.md)
-- `Provider` mới → `src/providers/`
-- `Channel` mới → `src/channels/`
-- `Observer` mới → `src/observability/`
-- `Tool` mới → `src/tools/`
-- `Memory` mới → `src/memory/`
-- `Tunnel` mới → `src/tunnel/`
-- `Skill` mới → `~/.zeroclaw/workspace/skills//`
-
----
-
-**ZeroClaw** — Không tốn thêm tài nguyên. Không đánh đổi. Triển khai ở đâu cũng được. Thay thế gì cũng được. 🦀
-
-## Lịch sử Star
-
-
-
-
-
-
-
-
-
-
diff --git a/README.zh-CN.md b/README.zh-CN.md
deleted file mode 100644
index 55f86ccab..000000000
--- a/README.zh-CN.md
+++ /dev/null
@@ -1,305 +0,0 @@
-
-
-
-
-ZeroClaw 🦀(简体中文)
-
-
- 零开销、零妥协;随处部署、万物可换。
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 🌐 语言:English · 简体中文 · 日本語 · Русский · Français · Tiếng Việt
-
-
-
- 一键部署 |
- 安装入门 |
- 文档总览 |
- 文档目录
-
-
-
- 场景分流:
- 参考手册 ·
- 运维部署 ·
- 故障排查 ·
- 安全专题 ·
- 硬件外设 ·
- 贡献与 CI
-
-
-> 本文是对 `README.md` 的人工对齐翻译(强调可读性与准确性,不做逐字直译)。
->
-> 技术标识(命令、配置键、API 路径、Trait 名称)保持英文,避免语义漂移。
->
-> 最后对齐时间:**2026-02-22**。
-
-## 📢 公告板
-
-用于发布重要通知(破坏性变更、安全通告、维护窗口、版本阻塞问题等)。
-
-| 日期(UTC) | 级别 | 通知 | 处理建议 |
-|---|---|---|---|
-| 2026-02-19 | _紧急_ | 我们与 `openagen/zeroclaw` 及 `zeroclaw.org` **没有任何关系**。`zeroclaw.org` 当前会指向 `openagen/zeroclaw` 这个 fork,并且该域名/仓库正在冒充我们的官网与官方项目。 | 请不要相信上述来源发布的任何信息、二进制、募资活动或官方声明。请仅以[本仓库](https://github.com/zeroclaw-labs/zeroclaw)和已验证官方社媒为准。 |
-| 2026-02-21 | _重要_ | 我们的官网现已上线:[zeroclawlabs.ai](https://zeroclawlabs.ai)。感谢大家一直以来的耐心等待。我们仍在持续发现冒充行为,请勿参与任何未经我们官方渠道发布、但打着 ZeroClaw 名义进行的投资、募资或类似活动。 | 一切信息请以[本仓库](https://github.com/zeroclaw-labs/zeroclaw)为准;也可关注 [X(@zeroclawlabs)](https://x.com/zeroclawlabs?s=21)、[Telegram(@zeroclawlabs)](https://t.me/zeroclawlabs)、[Facebook(群组)](https://www.facebook.com/groups/zeroclaw)、[Reddit(r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/) 与 [小红书账号](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) 获取官方最新动态。 |
-| 2026-02-19 | _重要_ | Anthropic 于 2026-02-19 更新了 Authentication and Credential Use 条款。条款明确:OAuth authentication(用于 Free、Pro、Max)仅适用于 Claude Code 与 Claude.ai;将 Claude Free/Pro/Max 账号获得的 OAuth token 用于其他任何产品、工具或服务(包括 Agent SDK)不被允许,并可能构成对 Consumer Terms of Service 的违规。 | 为避免损失,请暂时不要尝试 Claude Code OAuth 集成;原文见:[Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use)。 |
-
-## 项目简介
-
-ZeroClaw 是一个高性能、低资源占用、可组合的自主智能体运行时。ZeroClaw 是面向智能代理工作流的**运行时操作系统** — 它抽象了模型、工具、记忆和执行层,使代理可以一次构建、随处运行。
-
-- Rust 原生实现,单二进制部署,跨 ARM / x86 / RISC-V。
-- Trait 驱动架构,`Provider` / `Channel` / `Tool` / `Memory` 可替换。
-- 安全默认值优先:配对鉴权、显式 allowlist、沙箱与作用域约束。
-
-## 为什么选择 ZeroClaw
-
-- **默认轻量运行时**:常见 CLI 与 `status` 工作流通常保持在几 MB 级内存范围。
-- **低成本部署友好**:面向低价板卡与小规格云主机设计,不依赖厚重运行时。
-- **冷启动速度快**:Rust 单二进制让常用命令与守护进程启动更接近“秒开”。
-- **跨架构可移植**:同一套二进制优先流程覆盖 ARM / x86 / RISC-V,并保持 provider/channel/tool 可替换。
-
-## 基准快照(ZeroClaw vs OpenClaw,可复现)
-
-以下是本地快速基准对比(macOS arm64,2026 年 2 月),按 0.8GHz 边缘 CPU 进行归一化展示:
-
-| | OpenClaw | NanoBot | PicoClaw | ZeroClaw 🦀 |
-|---|---|---|---|---|
-| **语言** | TypeScript | Python | Go | **Rust** |
-| **RAM** | > 1GB | > 100MB | < 10MB | **< 5MB** |
-| **启动时间(0.8GHz 核)** | > 500s | > 30s | < 1s | **< 10ms** |
-| **二进制体积** | ~28MB(dist) | N/A(脚本) | ~8MB | **~8.8 MB** |
-| **成本** | Mac Mini $599 | Linux SBC ~$50 | Linux 板卡 $10 | **任意 $10 硬件** |
-
-> 说明:ZeroClaw 的数据来自 release 构建,并通过 `/usr/bin/time -l` 测得。OpenClaw 需要 Node.js 运行时环境,仅该运行时通常就会带来约 390MB 的额外内存占用;NanoBot 需要 Python 运行时环境。PicoClaw 与 ZeroClaw 为静态二进制。
-
-
-
-
-
-### 本地可复现测量
-
-基准数据会随代码与工具链变化,建议始终在你的目标环境自行复测:
-
-```bash
-cargo build --release
-ls -lh target/release/zeroclaw
-
-/usr/bin/time -l target/release/zeroclaw --help
-/usr/bin/time -l target/release/zeroclaw status
-```
-
-当前 README 的样例数据(macOS arm64,2026-02-18):
-
-- Release 二进制:`8.8M`
-- `zeroclaw --help`:约 `0.02s`,峰值内存约 `3.9MB`
-- `zeroclaw status`:约 `0.01s`,峰值内存约 `4.1MB`
-
-## 一键部署
-
-```bash
-git clone https://github.com/zeroclaw-labs/zeroclaw.git
-cd zeroclaw
-./bootstrap.sh
-```
-
-可选环境初始化:`./bootstrap.sh --install-system-deps --install-rust`(可能需要 `sudo`)。
-
-详细说明见:[`docs/one-click-bootstrap.md`](docs/one-click-bootstrap.md)。
-
-## 快速开始
-
-### Homebrew(macOS/Linuxbrew)
-
-```bash
-brew install zeroclaw
-```
-
-```bash
-git clone https://github.com/zeroclaw-labs/zeroclaw.git
-cd zeroclaw
-cargo build --release --locked
-cargo install --path . --force --locked
-
-# 快速初始化(无交互)
-zeroclaw onboard --api-key sk-... --provider openrouter
-
-# 或使用交互式向导
-zeroclaw onboard --interactive
-
-# 单次对话
-zeroclaw agent -m "Hello, ZeroClaw!"
-
-# 启动网关(默认: 127.0.0.1:42617)
-zeroclaw gateway
-
-# 启动长期运行模式
-zeroclaw daemon
-```
-
-## Subscription Auth(OpenAI Codex / Claude Code)
-
-ZeroClaw 现已支持基于订阅的原生鉴权配置(多账号、静态加密存储)。
-
-- 配置文件:`~/.zeroclaw/auth-profiles.json`
-- 加密密钥:`~/.zeroclaw/.secret_key`
-- Profile ID 格式:`:`(例:`openai-codex:work`)
-
-OpenAI Codex OAuth(ChatGPT 订阅):
-
-```bash
-# 推荐用于服务器/无显示器环境
-zeroclaw auth login --provider openai-codex --device-code
-
-# 浏览器/回调流程,支持粘贴回退
-zeroclaw auth login --provider openai-codex --profile default
-zeroclaw auth paste-redirect --provider openai-codex --profile default
-
-# 检查 / 刷新 / 切换 profile
-zeroclaw auth status
-zeroclaw auth refresh --provider openai-codex --profile default
-zeroclaw auth use --provider openai-codex --profile work
-```
-
-Claude Code / Anthropic setup-token:
-
-```bash
-# 粘贴订阅/setup token(Authorization header 模式)
-zeroclaw auth paste-token --provider anthropic --profile default --auth-kind authorization
-
-# 别名命令
-zeroclaw auth setup-token --provider anthropic --profile default
-```
-
-使用 subscription auth 运行 agent:
-
-```bash
-zeroclaw agent --provider openai-codex -m "hello"
-zeroclaw agent --provider openai-codex --auth-profile openai-codex:work -m "hello"
-
-# Anthropic 同时支持 API key 和 auth token 环境变量:
-# ANTHROPIC_AUTH_TOKEN, ANTHROPIC_OAUTH_TOKEN, ANTHROPIC_API_KEY
-zeroclaw agent --provider anthropic -m "hello"
-```
-
-## 架构
-
-每个子系统都是一个 **Trait** — 通过配置切换即可更换实现,无需修改代码。
-
-
-
-
-
-| 子系统 | Trait | 内置实现 | 扩展方式 |
-|--------|-------|----------|----------|
-| **AI 模型** | `Provider` | 通过 `zeroclaw providers` 查看(当前 28 个内置 + 别名,以及自定义端点) | `custom:https://your-api.com`(OpenAI 兼容)或 `anthropic-custom:https://your-api.com` |
-| **通道** | `Channel` | CLI, Telegram, Discord, Slack, Mattermost, iMessage, Matrix, Signal, WhatsApp, Linq, Email, IRC, Lark, DingTalk, QQ, Webhook | 任意消息 API |
-| **记忆** | `Memory` | SQLite 混合搜索, PostgreSQL 后端, Lucid 桥接, Markdown 文件, 显式 `none` 后端, 快照/恢复, 可选响应缓存 | 任意持久化后端 |
-| **工具** | `Tool` | shell/file/memory, cron/schedule, git, pushover, browser, http_request, screenshot/image_info, composio (opt-in), delegate, 硬件工具 | 任意能力 |
-| **可观测性** | `Observer` | Noop, Log, Multi | Prometheus, OTel |
-| **运行时** | `RuntimeAdapter` | Native, Docker(沙箱) | 通过 adapter 添加;不支持的类型会快速失败 |
-| **安全** | `SecurityPolicy` | Gateway 配对, 沙箱, allowlist, 速率限制, 文件系统作用域, 加密密钥 | — |
-| **身份** | `IdentityConfig` | OpenClaw (markdown), AIEOS v1.1 (JSON) | 任意身份格式 |
-| **隧道** | `Tunnel` | None, Cloudflare, Tailscale, ngrok, Custom | 任意隧道工具 |
-| **心跳** | Engine | HEARTBEAT.md 定期任务 | — |
-| **技能** | Loader | TOML 清单 + SKILL.md 指令 | 社区技能包 |
-| **集成** | Registry | 9 个分类下 70+ 集成 | 插件系统 |
-
-### 运行时支持(当前)
-
-- ✅ 当前支持:`runtime.kind = "native"` 或 `runtime.kind = "docker"`
-- 🚧 计划中,尚未实现:WASM / 边缘运行时
-
-配置了不支持的 `runtime.kind` 时,ZeroClaw 会以明确的错误退出,而非静默回退到 native。
-
-### 记忆系统(全栈搜索引擎)
-
-全部自研,零外部依赖 — 无需 Pinecone、Elasticsearch、LangChain:
-
-| 层级 | 实现 |
-|------|------|
-| **向量数据库** | Embeddings 以 BLOB 存储于 SQLite,余弦相似度搜索 |
-| **关键词搜索** | FTS5 虚拟表,BM25 评分 |
-| **混合合并** | 自定义加权合并函数(`vector.rs`) |
-| **Embeddings** | `EmbeddingProvider` trait — OpenAI、自定义 URL 或 noop |
-| **分块** | 基于行的 Markdown 分块器,保留标题结构 |
-| **缓存** | SQLite `embedding_cache` 表,LRU 淘汰策略 |
-| **安全重索引** | 原子化重建 FTS5 + 重新嵌入缺失向量 |
-
-Agent 通过工具自动进行记忆的回忆、保存和管理。
-
-```toml
-[memory]
-backend = "sqlite" # "sqlite", "lucid", "postgres", "markdown", "none"
-auto_save = true
-embedding_provider = "none" # "none", "openai", "custom:https://..."
-vector_weight = 0.7
-keyword_weight = 0.3
-```
-
-## 安全默认行为(关键)
-
-- Gateway 默认绑定:`127.0.0.1:42617`
-- Gateway 默认要求配对:`require_pairing = true`
-- 默认拒绝公网绑定:`allow_public_bind = false`
-- Channel allowlist 语义:
- - 空列表 `[]` => deny-by-default
- - `"*"` => allow all(仅在明确知道风险时使用)
-
-## 常用配置片段
-
-```toml
-api_key = "sk-..."
-default_provider = "openrouter"
-default_model = "anthropic/claude-sonnet-4-6"
-default_temperature = 0.7
-
-[memory]
-backend = "sqlite" # sqlite | lucid | markdown | none
-auto_save = true
-embedding_provider = "none" # none | openai | custom:https://...
-
-[gateway]
-host = "127.0.0.1"
-port = 42617
-require_pairing = true
-allow_public_bind = false
-```
-
-## 文档导航(推荐从这里开始)
-
-- 文档总览(英文):[`docs/README.md`](docs/README.md)
-- 统一目录(TOC):[`docs/SUMMARY.md`](docs/SUMMARY.md)
-- 文档总览(简体中文):[`docs/README.zh-CN.md`](docs/README.zh-CN.md)
-- 命令参考:[`docs/commands-reference.md`](docs/commands-reference.md)
-- 配置参考:[`docs/config-reference.md`](docs/config-reference.md)
-- Provider 参考:[`docs/providers-reference.md`](docs/providers-reference.md)
-- Channel 参考:[`docs/channels-reference.md`](docs/channels-reference.md)
-- 运维手册:[`docs/operations-runbook.md`](docs/operations-runbook.md)
-- 故障排查:[`docs/troubleshooting.md`](docs/troubleshooting.md)
-- 文档清单与分类:[`docs/docs-inventory.md`](docs/docs-inventory.md)
-- 项目 triage 快照(2026-02-18):[`docs/project-triage-snapshot-2026-02-18.md`](docs/project-triage-snapshot-2026-02-18.md)
-
-## 贡献与许可证
-
-- 贡献指南:[`CONTRIBUTING.md`](CONTRIBUTING.md)
-- PR 工作流:[`docs/pr-workflow.md`](docs/pr-workflow.md)
-- Reviewer 指南:[`docs/reviewer-playbook.md`](docs/reviewer-playbook.md)
-- 许可证:MIT 或 Apache 2.0(见 [`LICENSE-MIT`](LICENSE-MIT)、[`LICENSE-APACHE`](LICENSE-APACHE) 与 [`NOTICE`](NOTICE))
-
----
-
-如果你需要完整实现细节(架构图、全部命令、完整 API、开发流程),请直接阅读英文主文档:[`README.md`](README.md)。
diff --git a/SECURITY.md b/SECURITY.md
index d87441fb0..e341eed65 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -32,6 +32,20 @@ Preferred reporting paths:
- Suggested mitigation or patch direction (if known)
- Any known workaround
+## Official Channels and Anti-Fraud Notice
+
+Impersonation scams are a real risk in open communities.
+
+Security-critical rule:
+
+- ZeroClaw maintainers will not ask for cryptocurrency, wallet seed phrases, or private financial credentials.
+- Treat direct-message payment requests as fraudulent unless independently verified in the repository.
+- Verify announcements using repository sources first.
+
+Canonical statement and reporting guidance:
+
+- [docs/security/official-channels-and-fraud-prevention.md](docs/security/official-channels-and-fraud-prevention.md)
+
## Maintainer Handling Workflow (GitHub-Native)
### 1. Intake and triage (private)
diff --git a/TESTING_TELEGRAM.md b/TESTING_TELEGRAM.md
index 0f682dbe2..d1cfe9878 100644
--- a/TESTING_TELEGRAM.md
+++ b/TESTING_TELEGRAM.md
@@ -297,7 +297,7 @@ on: [push, pull_request]
jobs:
test:
- runs-on: [self-hosted, Linux, X64]
+ runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v3
- uses: actions-rs/toolchain@v1
diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md
new file mode 100644
index 000000000..6416fe718
--- /dev/null
+++ b/docs/PLUGINS.md
@@ -0,0 +1,250 @@
+# ZeroClaw Plugin System
+
+A plugin architecture for ZeroClaw modeled after [OpenClaw's plugin system](https://github.com/openclaw/openclaw), adapted for Rust.
+
+## Overview
+
+The plugin system allows extending ZeroClaw with custom tools, hooks, channels, and providers without modifying the core codebase. Plugins are discovered from standard directories, loaded at startup, and registered with the host through a clean API.
+
+## Architecture
+
+### Key Components
+
+1. **Manifest** (`zeroclaw.plugin.toml`): Declares plugin metadata (id, name, version, description)
+2. **Plugin trait**: Defines the contract plugins must implement (`manifest()` + `register()`)
+3. **PluginApi**: Passed to `register()` so plugins can contribute tools, hooks, etc.
+4. **Discovery**: Scans bundled, global, and workspace extension directories
+5. **Registry**: Central store managing loaded plugins, tools, hooks, and diagnostics
+6. **Loader**: Orchestrates discovery → filtering → registration with error isolation
+
+### Comparison to OpenClaw
+
+| OpenClaw (TypeScript) | ZeroClaw (Rust) |
+|------------------------------------|------------------------------------|
+| `openclaw.plugin.json` | `zeroclaw.plugin.toml` |
+| `OpenClawPluginDefinition` | `Plugin` trait |
+| `OpenClawPluginApi` | `PluginApi` struct |
+| `PluginRegistry` (class) | `PluginRegistry` struct |
+| `discover()` → `load()` → `register()` | `discover_plugins()` → `load_plugins()` |
+| Try/catch isolation | `catch_unwind()` panic isolation |
+| `[plugins]` config section | `[plugins]` config section |
+
+## Writing a Plugin
+
+### 1. Create the manifest
+
+`extensions/hello-world/zeroclaw.plugin.toml`:
+
+```toml
+id = "hello-world"
+name = "Hello World"
+description = "Example plugin demonstrating the ZeroClaw plugin API."
+version = "0.1.0"
+```
+
+### 2. Implement the Plugin trait
+
+`extensions/hello-world/src/lib.rs`:
+
+```rust
+use zeroclaw::plugins::{Plugin, PluginApi, PluginManifest};
+use zeroclaw::tools::traits::{Tool, ToolResult};
+use async_trait::async_trait;
+
+pub struct HelloWorldPlugin {
+ manifest: PluginManifest,
+}
+
+impl HelloWorldPlugin {
+ pub fn new() -> Self {
+ Self {
+ manifest: PluginManifest {
+ id: "hello-world".into(),
+ name: Some("Hello World".into()),
+ description: Some("Example plugin".into()),
+ version: Some("0.1.0".into()),
+ config_schema: None,
+ },
+ }
+ }
+}
+
+impl Plugin for HelloWorldPlugin {
+ fn manifest(&self) -> &PluginManifest {
+ &self.manifest
+ }
+
+ fn register(&self, api: &mut PluginApi) -> anyhow::Result<()> {
+ api.logger().info("registering hello-world plugin");
+ api.register_tool(Box::new(HelloTool));
+ api.register_hook(Box::new(HelloHook));
+ Ok(())
+ }
+}
+
+// Define your tool
+struct HelloTool;
+
+#[async_trait]
+impl Tool for HelloTool {
+ fn name(&self) -> &str { "hello" }
+ fn description(&self) -> &str { "Greet the user" }
+ fn parameters_schema(&self) -> serde_json::Value {
+ serde_json::json!({
+ "type": "object",
+ "properties": {
+ "name": { "type": "string", "description": "Name to greet" }
+ },
+ "required": ["name"]
+ })
+ }
+ async fn execute(&self, args: serde_json::Value) -> anyhow::Result {
+ let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("world");
+ Ok(ToolResult {
+ success: true,
+ output: format!("Hello, {name}!"),
+ error: None,
+ })
+ }
+}
+
+// Define your hook
+struct HelloHook;
+
+#[async_trait]
+impl zeroclaw::hooks::HookHandler for HelloHook {
+ fn name(&self) -> &str { "hello-world:session-logger" }
+ async fn on_session_start(&self, session_id: &str, channel: &str) {
+ tracing::info!(plugin = "hello-world", session_id, channel, "session started");
+ }
+}
+```
+
+### 3. Register as a builtin plugin
+
+For now, plugins must be compiled into the binary. In `src/gateway/mod.rs` or wherever plugins are initialized:
+
+```rust
+use zeroclaw::plugins::{load_plugins, Plugin};
+use hello_world_plugin::HelloWorldPlugin;
+
+let builtin_plugins: Vec> = vec![
+ Box::new(HelloWorldPlugin::new()),
+];
+
+let registry = load_plugins(&config.plugins, workspace_dir, builtin_plugins);
+```
+
+### 4. Enable in config
+
+`~/.zeroclaw/config.toml`:
+
+```toml
+[plugins]
+enabled = true
+
+[plugins.entries.hello-world]
+enabled = true
+
+[plugins.entries.hello-world.config]
+greeting = "Howdy" # Custom config passed to the plugin
+```
+
+## Configuration
+
+### Master Switch
+
+```toml
+[plugins]
+enabled = true # Set to false to disable all plugin loading
+```
+
+### Allowlist / Denylist
+
+```toml
+[plugins]
+allow = ["hello-world", "my-plugin"] # Only load these (empty = all eligible)
+deny = ["bad-plugin"] # Never load these
+```
+
+### Per-Plugin Config
+
+```toml
+[plugins.entries.my-plugin]
+enabled = true
+
+[plugins.entries.my-plugin.config]
+api_key = "secret"
+timeout_ms = 5000
+```
+
+Access in your plugin via `api.plugin_config()`:
+
+```rust
+fn register(&self, api: &mut PluginApi) -> anyhow::Result<()> {
+ let cfg = api.plugin_config();
+ let api_key = cfg.get("api_key").and_then(|v| v.as_str());
+ // ...
+}
+```
+
+## Discovery
+
+Plugins are discovered from:
+
+1. **Bundled**: Compiled-in plugins (registered directly in code)
+2. **Global**: `~/.zeroclaw/extensions/`
+3. **Workspace**: `/.zeroclaw/extensions/`
+4. **Custom**: Paths in `plugins.load_paths`
+
+Each directory is scanned for subdirectories containing `zeroclaw.plugin.toml`.
+
+## Error Isolation
+
+Plugins are isolated from the host:
+
+- Panics in `register()` are caught and recorded as diagnostics
+- Errors returned from `register()` are logged and the plugin is marked as failed
+- A bad plugin won't crash ZeroClaw
+
+## Plugin API
+
+### PluginApi Methods
+
+- `register_tool(tool: Box)` — Add a tool to the registry
+- `register_hook(handler: Box)` — Add a lifecycle hook
+- `plugin_config() -> &toml::Value` — Access plugin-specific config
+- `logger() -> &PluginLogger` — Get a logger scoped to this plugin
+
+### Available Hooks
+
+Implement `zeroclaw::hooks::HookHandler`:
+
+- `on_session_start(session_id, channel)`
+- `on_session_end(session_id, channel)`
+- `on_tool_call(tool_name, args)`
+- `on_tool_result(tool_name, result)`
+
+## Future Extensions
+
+- **Dynamic loading**: Load plugins from `.so`/`.dylib`/`.wasm` at runtime (currently requires compilation)
+- **Hot reload**: Reload plugins without restarting ZeroClaw
+- **Plugin marketplace**: Discover and install community plugins
+- **Sandboxing**: Run untrusted plugins in isolated processes or WASM
+
+## Testing
+
+Run plugin system tests:
+
+```bash
+cargo test --lib plugins
+```
+
+## Example Plugins
+
+See `extensions/hello-world/` for a complete working example.
+
+## References
+
+- [OpenClaw Plugin System](https://github.com/openclaw/openclaw/tree/main/src/plugins)
+- [Issue #1414](https://github.com/zeroclaw-labs/zeroclaw/issues/1414)
diff --git a/docs/README.fr.md b/docs/README.fr.md
deleted file mode 100644
index ce696b9ff..000000000
--- a/docs/README.fr.md
+++ /dev/null
@@ -1,95 +0,0 @@
-# Hub de Documentation ZeroClaw
-
-Cette page est le point d'entrée principal du système de documentation.
-
-Dernière mise à jour : **20 février 2026**.
-
-Hubs localisés : [简体中文](README.zh-CN.md) · [日本語](README.ja.md) · [Русский](README.ru.md) · [Français](README.fr.md) · [Tiếng Việt](i18n/vi/README.md).
-
-## Commencez Ici
-
-| Je veux… | Lire ceci |
-| ------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
-| Installer et exécuter ZeroClaw rapidement | [README.md (Démarrage Rapide)](../README.md#quick-start) |
-| Bootstrap en une seule commande | [one-click-bootstrap.md](one-click-bootstrap.md) |
-| Trouver des commandes par tâche | [commands-reference.md](commands-reference.md) |
-| Vérifier rapidement les valeurs par défaut et clés de config | [config-reference.md](config-reference.md) |
-| Configurer des fournisseurs/endpoints personnalisés | [custom-providers.md](custom-providers.md) |
-| Configurer le fournisseur Z.AI / GLM | [zai-glm-setup.md](zai-glm-setup.md) |
-| Utiliser les modèles d'intégration LangGraph | [langgraph-integration.md](langgraph-integration.md) |
-| Opérer le runtime (runbook jour-2) | [operations-runbook.md](operations-runbook.md) |
-| Dépanner les problèmes d'installation/runtime/canal | [troubleshooting.md](troubleshooting.md) |
-| Exécuter la configuration et diagnostics de salles chiffrées Matrix | [matrix-e2ee-guide.md](matrix-e2ee-guide.md) |
-| Parcourir les docs par catégorie | [SUMMARY.md](SUMMARY.md) |
-| Voir l'instantané docs des PR/issues du projet | [project-triage-snapshot-2026-02-18.md](project-triage-snapshot-2026-02-18.md) |
-
-## Arbre de Décision Rapide (10 secondes)
-
-- Besoin de configuration ou installation initiale ? → [getting-started/README.md](getting-started/README.md)
-- Besoin de clés CLI/config exactes ? → [reference/README.md](reference/README.md)
-- Besoin d'opérations de production/service ? → [operations/README.md](operations/README.md)
-- Vous voyez des échecs ou régressions ? → [troubleshooting.md](troubleshooting.md)
-- Vous travaillez sur le durcissement sécurité ou la roadmap ? → [security/README.md](security/README.md)
-- Vous travaillez avec des cartes/périphériques ? → [hardware/README.md](hardware/README.md)
-- Contribution/revue/workflow CI ? → [contributing/README.md](contributing/README.md)
-- Vous voulez la carte complète ? → [SUMMARY.md](SUMMARY.md)
-
-## Collections (Recommandées)
-
-- Démarrage : [getting-started/README.md](getting-started/README.md)
-- Catalogues de référence : [reference/README.md](reference/README.md)
-- Opérations & déploiement : [operations/README.md](operations/README.md)
-- Docs sécurité : [security/README.md](security/README.md)
-- Matériel/périphériques : [hardware/README.md](hardware/README.md)
-- Contribution/CI : [contributing/README.md](contributing/README.md)
-- Instantanés projet : [project/README.md](project/README.md)
-
-## Par Audience
-
-### Utilisateurs / Opérateurs
-
-- [commands-reference.md](commands-reference.md) — recherche de commandes par workflow
-- [providers-reference.md](providers-reference.md) — IDs fournisseurs, alias, variables d'environnement d'identifiants
-- [channels-reference.md](channels-reference.md) — capacités des canaux et chemins de configuration
-- [matrix-e2ee-guide.md](matrix-e2ee-guide.md) — configuration de salles chiffrées Matrix (E2EE) et diagnostics de non-réponse
-- [config-reference.md](config-reference.md) — clés de configuration à haute signalisation et valeurs par défaut sécurisées
-- [custom-providers.md](custom-providers.md) — modèles d'intégration de fournisseur personnalisé/URL de base
-- [zai-glm-setup.md](zai-glm-setup.md) — configuration Z.AI/GLM et matrice d'endpoints
-- [langgraph-integration.md](langgraph-integration.md) — intégration de secours pour les cas limites de modèle/appel d'outil
-- [operations-runbook.md](operations-runbook.md) — opérations runtime jour-2 et flux de rollback
-- [troubleshooting.md](troubleshooting.md) — signatures d'échec courantes et étapes de récupération
-
-### Contributeurs / Mainteneurs
-
-- [../CONTRIBUTING.md](../CONTRIBUTING.md)
-- [pr-workflow.md](pr-workflow.md)
-- [reviewer-playbook.md](reviewer-playbook.md)
-- [ci-map.md](ci-map.md)
-- [actions-source-policy.md](actions-source-policy.md)
-
-### Sécurité / Fiabilité
-
-> Note : cette zone inclut des docs de proposition/roadmap. Pour le comportement actuel, commencez par [config-reference.md](config-reference.md), [operations-runbook.md](operations-runbook.md), et [troubleshooting.md](troubleshooting.md).
-
-- [security/README.md](security/README.md)
-- [agnostic-security.md](agnostic-security.md)
-- [frictionless-security.md](frictionless-security.md)
-- [sandboxing.md](sandboxing.md)
-- [audit-logging.md](audit-logging.md)
-- [resource-limits.md](resource-limits.md)
-- [security-roadmap.md](security-roadmap.md)
-
-## Navigation Système & Gouvernance
-
-- Table des matières unifiée : [SUMMARY.md](SUMMARY.md)
-- Carte de structure docs (langue/partie/fonction) : [structure/README.md](structure/README.md)
-- Inventaire/classification de la documentation : [docs-inventory.md](docs-inventory.md)
-- Instantané de triage du projet : [project-triage-snapshot-2026-02-18.md](project-triage-snapshot-2026-02-18.md)
-
-## Autres langues
-
-- English: [README.md](README.md)
-- 简体中文: [README.zh-CN.md](README.zh-CN.md)
-- 日本語: [README.ja.md](README.ja.md)
-- Русский: [README.ru.md](README.ru.md)
-- Tiếng Việt: [i18n/vi/README.md](i18n/vi/README.md)
diff --git a/docs/README.ja.md b/docs/README.ja.md
deleted file mode 100644
index c552ea857..000000000
--- a/docs/README.ja.md
+++ /dev/null
@@ -1,92 +0,0 @@
-# ZeroClaw ドキュメントハブ(日本語)
-
-このページは日本語のドキュメント入口です。
-
-最終同期日: **2026-02-18**。
-
-> 注: コマンド名・設定キー・API パスは英語のまま記載します。実装の一次情報は英語版ドキュメントを優先してください。
-
-## すぐに参照したい項目
-
-| やりたいこと | 参照先 |
-|---|---|
-| すぐにセットアップしたい | [../README.ja.md](../README.ja.md) / [../README.md](../README.md) |
-| ワンコマンドで導入したい | [one-click-bootstrap.md](one-click-bootstrap.md) |
-| コマンドを用途別に確認したい | [commands-reference.md](commands-reference.md) |
-| 設定キーと既定値を確認したい | [config-reference.md](config-reference.md) |
-| カスタム Provider / endpoint を追加したい | [custom-providers.md](custom-providers.md) |
-| Z.AI / GLM Provider を設定したい | [zai-glm-setup.md](zai-glm-setup.md) |
-| LangGraph ツール連携を使いたい | [langgraph-integration.md](langgraph-integration.md) |
-| 日常運用(runbook)を確認したい | [operations-runbook.md](operations-runbook.md) |
-| インストール/実行トラブルを解決したい | [troubleshooting.md](troubleshooting.md) |
-| 統合 TOC から探したい | [SUMMARY.md](SUMMARY.md) |
-| PR/Issue の現状を把握したい | [project-triage-snapshot-2026-02-18.md](project-triage-snapshot-2026-02-18.md) |
-
-## 10秒ルーティング(まずここ)
-
-- 初回セットアップや導入をしたい → [getting-started/README.md](getting-started/README.md)
-- CLI/設定キーを正確に確認したい → [reference/README.md](reference/README.md)
-- 本番運用やサービス管理をしたい → [operations/README.md](operations/README.md)
-- エラーや不具合を解消したい → [troubleshooting.md](troubleshooting.md)
-- セキュリティ方針やロードマップを見たい → [security/README.md](security/README.md)
-- ボード/周辺機器を扱いたい → [hardware/README.md](hardware/README.md)
-- 貢献・レビュー・CIを確認したい → [contributing/README.md](contributing/README.md)
-- 全体マップを見たい → [SUMMARY.md](SUMMARY.md)
-
-## カテゴリ別ナビゲーション(推奨)
-
-- 入門: [getting-started/README.md](getting-started/README.md)
-- リファレンス: [reference/README.md](reference/README.md)
-- 運用 / デプロイ: [operations/README.md](operations/README.md)
-- セキュリティ: [security/README.md](security/README.md)
-- ハードウェア: [hardware/README.md](hardware/README.md)
-- コントリビュート / CI: [contributing/README.md](contributing/README.md)
-- プロジェクトスナップショット: [project/README.md](project/README.md)
-
-## ロール別
-
-### ユーザー / オペレーター
-
-- [commands-reference.md](commands-reference.md)
-- [providers-reference.md](providers-reference.md)
-- [channels-reference.md](channels-reference.md)
-- [config-reference.md](config-reference.md)
-- [custom-providers.md](custom-providers.md)
-- [zai-glm-setup.md](zai-glm-setup.md)
-- [langgraph-integration.md](langgraph-integration.md)
-- [operations-runbook.md](operations-runbook.md)
-- [troubleshooting.md](troubleshooting.md)
-
-### コントリビューター / メンテナー
-
-- [../CONTRIBUTING.md](../CONTRIBUTING.md)
-- [pr-workflow.md](pr-workflow.md)
-- [reviewer-playbook.md](reviewer-playbook.md)
-- [ci-map.md](ci-map.md)
-- [actions-source-policy.md](actions-source-policy.md)
-
-### セキュリティ / 信頼性
-
-> 注: このセクションには proposal/roadmap 文書が含まれ、想定段階のコマンドや設定が記載される場合があります。現行動作は [config-reference.md](config-reference.md)、[operations-runbook.md](operations-runbook.md)、[troubleshooting.md](troubleshooting.md) を優先してください。
-
-- [security/README.md](security/README.md)
-- [agnostic-security.md](agnostic-security.md)
-- [frictionless-security.md](frictionless-security.md)
-- [sandboxing.md](sandboxing.md)
-- [resource-limits.md](resource-limits.md)
-- [audit-logging.md](audit-logging.md)
-- [security-roadmap.md](security-roadmap.md)
-
-## ドキュメント運用 / 分類
-
-- 統合 TOC: [SUMMARY.md](SUMMARY.md)
-- ドキュメント構造マップ(言語/カテゴリ/機能): [structure/README.md](structure/README.md)
-- ドキュメント一覧 / 分類: [docs-inventory.md](docs-inventory.md)
-
-## 他言語
-
-- English: [README.md](README.md)
-- 简体中文: [README.zh-CN.md](README.zh-CN.md)
-- Русский: [README.ru.md](README.ru.md)
-- Français: [README.fr.md](README.fr.md)
-- Tiếng Việt: [i18n/vi/README.md](i18n/vi/README.md)
diff --git a/docs/README.md b/docs/README.md
index ac23e5138..05d6c6cb1 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -4,7 +4,7 @@ This page is the primary entry point for the documentation system.
Last refreshed: **February 21, 2026**.
-Localized hubs: [简体中文](README.zh-CN.md) · [日本語](README.ja.md) · [Русский](README.ru.md) · [Français](README.fr.md) · [Tiếng Việt](i18n/vi/README.md).
+Localized hubs: [简体中文](i18n/zh-CN/README.md) · [日本語](i18n/ja/README.md) · [Русский](i18n/ru/README.md) · [Français](i18n/fr/README.md) · [Tiếng Việt](i18n/vi/README.md) · [Ελληνικά](i18n/el/README.md).
## Start Here
@@ -12,17 +12,22 @@ Localized hubs: [简体中文](README.zh-CN.md) · [日本語](README.ja.md) ·
|---|---|
| Install and run ZeroClaw quickly | [README.md (Quick Start)](../README.md#quick-start) |
| Bootstrap in one command | [one-click-bootstrap.md](one-click-bootstrap.md) |
+| Set up on Android (Termux/ADB) | [android-setup.md](android-setup.md) |
| Update or uninstall on macOS | [getting-started/macos-update-uninstall.md](getting-started/macos-update-uninstall.md) |
| Find commands by task | [commands-reference.md](commands-reference.md) |
| Check config defaults and keys quickly | [config-reference.md](config-reference.md) |
| Configure custom providers/endpoints | [custom-providers.md](custom-providers.md) |
| Configure Z.AI / GLM provider | [zai-glm-setup.md](zai-glm-setup.md) |
| Use LangGraph integration patterns | [langgraph-integration.md](langgraph-integration.md) |
+| Apply proxy scope safely | [proxy-agent-playbook.md](proxy-agent-playbook.md) |
| Operate runtime (day-2 runbook) | [operations-runbook.md](operations-runbook.md) |
+| Operate provider connectivity probes in CI | [operations/connectivity-probes-runbook.md](operations/connectivity-probes-runbook.md) |
| Troubleshoot install/runtime/channel issues | [troubleshooting.md](troubleshooting.md) |
| Run Matrix encrypted-room setup and diagnostics | [matrix-e2ee-guide.md](matrix-e2ee-guide.md) |
+| Build deterministic SOP procedures | [sop/README.md](sop/README.md) |
| Browse docs by category | [SUMMARY.md](SUMMARY.md) |
| See project PR/issue docs snapshot | [project-triage-snapshot-2026-02-18.md](project-triage-snapshot-2026-02-18.md) |
+| Perform i18n completion for docs changes | [i18n-guide.md](i18n-guide.md) |
## Quick Decision Tree (10 seconds)
@@ -33,6 +38,7 @@ Localized hubs: [简体中文](README.zh-CN.md) · [日本語](README.ja.md) ·
- Working on security hardening or roadmap? → [security/README.md](security/README.md)
- Working with boards/peripherals? → [hardware/README.md](hardware/README.md)
- Contributing/reviewing/CI workflow? → [contributing/README.md](contributing/README.md)
+- Building automated SOP workflows? → [sop/README.md](sop/README.md)
- Want the full map? → [SUMMARY.md](SUMMARY.md)
## Collections (Recommended)
@@ -73,6 +79,7 @@ Localized hubs: [简体中文](README.zh-CN.md) · [日本語](README.ja.md) ·
> Note: this area includes proposal/roadmap docs. For current behavior, start with [config-reference.md](config-reference.md), [operations-runbook.md](operations-runbook.md), and [troubleshooting.md](troubleshooting.md).
- [security/README.md](security/README.md)
+- [security/official-channels-and-fraud-prevention.md](security/official-channels-and-fraud-prevention.md)
- [agnostic-security.md](agnostic-security.md)
- [frictionless-security.md](frictionless-security.md)
- [sandboxing.md](sandboxing.md)
@@ -84,7 +91,11 @@ Localized hubs: [简体中文](README.zh-CN.md) · [日本語](README.ja.md) ·
- Unified TOC: [SUMMARY.md](SUMMARY.md)
- Docs structure map (language/part/function): [structure/README.md](structure/README.md)
+- Docs map by function: [structure/by-function.md](structure/by-function.md)
- Documentation inventory/classification: [docs-inventory.md](docs-inventory.md)
- i18n docs index: [i18n/README.md](i18n/README.md)
- i18n coverage map: [i18n-coverage.md](i18n-coverage.md)
+- i18n completion guide: [i18n-guide.md](i18n-guide.md)
+- i18n gap backlog: [i18n-gap-backlog.md](i18n-gap-backlog.md)
+- Docs audit snapshot (2026-02-24): [docs-audit-2026-02-24.md](docs-audit-2026-02-24.md)
- Project triage snapshot: [project-triage-snapshot-2026-02-18.md](project-triage-snapshot-2026-02-18.md)
diff --git a/docs/README.ru.md b/docs/README.ru.md
deleted file mode 100644
index 0c131c4ee..000000000
--- a/docs/README.ru.md
+++ /dev/null
@@ -1,92 +0,0 @@
-# Документация ZeroClaw (Русский)
-
-Эта страница — русскоязычная точка входа в документацию.
-
-Последняя синхронизация: **2026-02-18**.
-
-> Примечание: команды, ключи конфигурации и API-пути сохраняются на английском. Для первоисточника ориентируйтесь на англоязычные документы.
-
-## Быстрые ссылки
-
-| Что нужно | Куда смотреть |
-|---|---|
-| Быстро установить и запустить | [../README.ru.md](../README.ru.md) / [../README.md](../README.md) |
-| Установить одной командой | [one-click-bootstrap.md](one-click-bootstrap.md) |
-| Найти команды по задаче | [commands-reference.md](commands-reference.md) |
-| Проверить ключи конфигурации и дефолты | [config-reference.md](config-reference.md) |
-| Подключить кастомный provider / endpoint | [custom-providers.md](custom-providers.md) |
-| Настроить provider Z.AI / GLM | [zai-glm-setup.md](zai-glm-setup.md) |
-| Использовать интеграцию LangGraph | [langgraph-integration.md](langgraph-integration.md) |
-| Операционный runbook (day-2) | [operations-runbook.md](operations-runbook.md) |
-| Быстро устранить типовые проблемы | [troubleshooting.md](troubleshooting.md) |
-| Открыть общий TOC docs | [SUMMARY.md](SUMMARY.md) |
-| Посмотреть snapshot PR/Issue | [project-triage-snapshot-2026-02-18.md](project-triage-snapshot-2026-02-18.md) |
-
-## Дерево решений на 10 секунд
-
-- Нужна первая установка и быстрый старт → [getting-started/README.md](getting-started/README.md)
-- Нужны точные команды и ключи конфигурации → [reference/README.md](reference/README.md)
-- Нужны операции/сервисный режим/деплой → [operations/README.md](operations/README.md)
-- Есть ошибки, сбои или регрессии → [troubleshooting.md](troubleshooting.md)
-- Нужны материалы по безопасности и roadmap → [security/README.md](security/README.md)
-- Работаете с платами и периферией → [hardware/README.md](hardware/README.md)
-- Нужны процессы вклада, ревью и CI → [contributing/README.md](contributing/README.md)
-- Нужна полная карта docs → [SUMMARY.md](SUMMARY.md)
-
-## Навигация по категориям (рекомендуется)
-
-- Старт и установка: [getting-started/README.md](getting-started/README.md)
-- Справочники: [reference/README.md](reference/README.md)
-- Операции и деплой: [operations/README.md](operations/README.md)
-- Безопасность: [security/README.md](security/README.md)
-- Аппаратная часть: [hardware/README.md](hardware/README.md)
-- Вклад и CI: [contributing/README.md](contributing/README.md)
-- Снимки проекта: [project/README.md](project/README.md)
-
-## По ролям
-
-### Пользователи / Операторы
-
-- [commands-reference.md](commands-reference.md)
-- [providers-reference.md](providers-reference.md)
-- [channels-reference.md](channels-reference.md)
-- [config-reference.md](config-reference.md)
-- [custom-providers.md](custom-providers.md)
-- [zai-glm-setup.md](zai-glm-setup.md)
-- [langgraph-integration.md](langgraph-integration.md)
-- [operations-runbook.md](operations-runbook.md)
-- [troubleshooting.md](troubleshooting.md)
-
-### Контрибьюторы / Мейнтейнеры
-
-- [../CONTRIBUTING.md](../CONTRIBUTING.md)
-- [pr-workflow.md](pr-workflow.md)
-- [reviewer-playbook.md](reviewer-playbook.md)
-- [ci-map.md](ci-map.md)
-- [actions-source-policy.md](actions-source-policy.md)
-
-### Безопасность / Надёжность
-
-> Примечание: часть документов в этом разделе относится к proposal/roadmap и может содержать гипотетические команды/конфигурации. Для текущего поведения сначала смотрите [config-reference.md](config-reference.md), [operations-runbook.md](operations-runbook.md), [troubleshooting.md](troubleshooting.md).
-
-- [security/README.md](security/README.md)
-- [agnostic-security.md](agnostic-security.md)
-- [frictionless-security.md](frictionless-security.md)
-- [sandboxing.md](sandboxing.md)
-- [resource-limits.md](resource-limits.md)
-- [audit-logging.md](audit-logging.md)
-- [security-roadmap.md](security-roadmap.md)
-
-## Инвентаризация и структура docs
-
-- Единый TOC: [SUMMARY.md](SUMMARY.md)
-- Карта структуры docs (язык/раздел/функция): [structure/README.md](structure/README.md)
-- Инвентарь и классификация docs: [docs-inventory.md](docs-inventory.md)
-
-## Другие языки
-
-- English: [README.md](README.md)
-- 简体中文: [README.zh-CN.md](README.zh-CN.md)
-- 日本語: [README.ja.md](README.ja.md)
-- Français: [README.fr.md](README.fr.md)
-- Tiếng Việt: [i18n/vi/README.md](i18n/vi/README.md)
diff --git a/docs/README.vi.md b/docs/README.vi.md
deleted file mode 100644
index 2932e7d3c..000000000
--- a/docs/README.vi.md
+++ /dev/null
@@ -1,96 +0,0 @@
-# Hub Tài liệu ZeroClaw (Tiếng Việt)
-
-Đây là trang chủ tiếng Việt của hệ thống tài liệu.
-
-Đồng bộ lần cuối: **2026-02-21**.
-
-> Lưu ý: Tên lệnh, khóa cấu hình và đường dẫn API giữ nguyên tiếng Anh. Khi có sai khác, tài liệu tiếng Anh là bản gốc. Cây tài liệu tiếng Việt đầy đủ nằm tại [i18n/vi/](i18n/vi/README.md).
-
-Hub bản địa hóa: [简体中文](README.zh-CN.md) · [日本語](README.ja.md) · [Русский](README.ru.md) · [Français](README.fr.md) · [Tiếng Việt](README.vi.md).
-
-## Tra cứu nhanh
-
-| Tôi muốn… | Xem tài liệu |
-| -------------------------------------------------- | ------------------------------------------------------------------------------ |
-| Cài đặt và chạy nhanh | [README.vi.md (Khởi động nhanh)](../README.vi.md) / [../README.md](../README.md) |
-| Cài đặt bằng một lệnh | [one-click-bootstrap.md](one-click-bootstrap.md) |
-| Tìm lệnh theo tác vụ | [commands-reference.md](i18n/vi/commands-reference.md) |
-| Kiểm tra giá trị mặc định và khóa cấu hình | [config-reference.md](i18n/vi/config-reference.md) |
-| Kết nối provider / endpoint tùy chỉnh | [custom-providers.md](i18n/vi/custom-providers.md) |
-| Cấu hình Z.AI / GLM provider | [zai-glm-setup.md](i18n/vi/zai-glm-setup.md) |
-| Sử dụng tích hợp LangGraph | [langgraph-integration.md](i18n/vi/langgraph-integration.md) |
-| Vận hành hàng ngày (runbook) | [operations-runbook.md](i18n/vi/operations-runbook.md) |
-| Khắc phục sự cố cài đặt/chạy/kênh | [troubleshooting.md](i18n/vi/troubleshooting.md) |
-| Cấu hình Matrix phòng mã hóa (E2EE) | [matrix-e2ee-guide.md](i18n/vi/matrix-e2ee-guide.md) |
-| Xem theo danh mục | [SUMMARY.md](i18n/vi/SUMMARY.md) |
-| Xem bản chụp PR/Issue | [project-triage-snapshot-2026-02-18.md](project-triage-snapshot-2026-02-18.md) |
-
-## Tìm nhanh (10 giây)
-
-- Cài đặt lần đầu hoặc khởi động nhanh → [getting-started/README.md](i18n/vi/getting-started/README.md)
-- Cần tra cứu lệnh CLI / khóa cấu hình → [reference/README.md](i18n/vi/reference/README.md)
-- Cần vận hành / triển khai sản phẩm → [operations/README.md](i18n/vi/operations/README.md)
-- Gặp lỗi hoặc hồi quy → [troubleshooting.md](i18n/vi/troubleshooting.md)
-- Tìm hiểu bảo mật và lộ trình → [security/README.md](i18n/vi/security/README.md)
-- Làm việc với bo mạch / thiết bị ngoại vi → [hardware/README.md](i18n/vi/hardware/README.md)
-- Đóng góp / review / quy trình CI → [contributing/README.md](i18n/vi/contributing/README.md)
-- Xem toàn bộ bản đồ tài liệu → [SUMMARY.md](i18n/vi/SUMMARY.md)
-
-## Danh mục (Khuyến nghị)
-
-- Bắt đầu: [getting-started/README.md](i18n/vi/getting-started/README.md)
-- Tra cứu: [reference/README.md](i18n/vi/reference/README.md)
-- Vận hành & triển khai: [operations/README.md](i18n/vi/operations/README.md)
-- Bảo mật: [security/README.md](i18n/vi/security/README.md)
-- Phần cứng & ngoại vi: [hardware/README.md](i18n/vi/hardware/README.md)
-- Đóng góp & CI: [contributing/README.md](i18n/vi/contributing/README.md)
-- Ảnh chụp dự án: [project/README.md](i18n/vi/project/README.md)
-
-## Theo vai trò
-
-### Người dùng / Vận hành
-
-- [commands-reference.md](i18n/vi/commands-reference.md) — tra cứu lệnh theo tác vụ
-- [providers-reference.md](i18n/vi/providers-reference.md) — ID provider, bí danh, biến môi trường xác thực
-- [channels-reference.md](i18n/vi/channels-reference.md) — khả năng kênh và hướng dẫn thiết lập
-- [matrix-e2ee-guide.md](i18n/vi/matrix-e2ee-guide.md) — thiết lập phòng mã hóa Matrix (E2EE)
-- [config-reference.md](i18n/vi/config-reference.md) — khóa cấu hình quan trọng và giá trị mặc định an toàn
-- [custom-providers.md](i18n/vi/custom-providers.md) — mẫu tích hợp provider / base URL tùy chỉnh
-- [zai-glm-setup.md](i18n/vi/zai-glm-setup.md) — thiết lập Z.AI/GLM và ma trận endpoint
-- [langgraph-integration.md](i18n/vi/langgraph-integration.md) — tích hợp dự phòng cho model/tool-calling
-- [operations-runbook.md](i18n/vi/operations-runbook.md) — vận hành runtime hàng ngày và quy trình rollback
-- [troubleshooting.md](i18n/vi/troubleshooting.md) — dấu hiệu lỗi thường gặp và cách khắc phục
-
-### Người đóng góp / Bảo trì
-
-- [../CONTRIBUTING.md](../CONTRIBUTING.md)
-- [pr-workflow.md](i18n/vi/pr-workflow.md)
-- [reviewer-playbook.md](i18n/vi/reviewer-playbook.md)
-- [ci-map.md](i18n/vi/ci-map.md)
-- [actions-source-policy.md](i18n/vi/actions-source-policy.md)
-
-### Bảo mật / Độ tin cậy
-
-> Lưu ý: Mục này gồm tài liệu đề xuất/lộ trình, có thể chứa lệnh hoặc cấu hình chưa triển khai. Để biết hành vi thực tế, xem [config-reference.md](i18n/vi/config-reference.md), [operations-runbook.md](i18n/vi/operations-runbook.md) và [troubleshooting.md](i18n/vi/troubleshooting.md) trước.
-
-- [security/README.md](i18n/vi/security/README.md)
-- [agnostic-security.md](i18n/vi/agnostic-security.md)
-- [frictionless-security.md](i18n/vi/frictionless-security.md)
-- [sandboxing.md](i18n/vi/sandboxing.md)
-- [audit-logging.md](i18n/vi/audit-logging.md)
-- [resource-limits.md](i18n/vi/resource-limits.md)
-- [security-roadmap.md](i18n/vi/security-roadmap.md)
-
-## Quản lý tài liệu
-
-- Mục lục thống nhất (TOC): [SUMMARY.md](i18n/vi/SUMMARY.md)
-- Bản đồ cấu trúc docs (ngôn ngữ/phần/chức năng): [structure/README.md](structure/README.md)
-- Danh mục và phân loại tài liệu: [docs-inventory.md](docs-inventory.md)
-
-## Ngôn ngữ khác
-
-- English: [README.md](README.md)
-- 简体中文: [README.zh-CN.md](README.zh-CN.md)
-- 日本語: [README.ja.md](README.ja.md)
-- Русский: [README.ru.md](README.ru.md)
-- Français: [README.fr.md](README.fr.md)
diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md
deleted file mode 100644
index f4178eaa2..000000000
--- a/docs/README.zh-CN.md
+++ /dev/null
@@ -1,92 +0,0 @@
-# ZeroClaw 文档导航(简体中文)
-
-这是文档系统的中文入口页。
-
-最后对齐:**2026-02-18**。
-
-> 说明:命令、配置键、API 路径保持英文;实现细节以英文文档为准。
-
-## 快速入口
-
-| 我想要… | 建议阅读 |
-|---|---|
-| 快速安装并运行 | [../README.zh-CN.md](../README.zh-CN.md) / [../README.md](../README.md) |
-| 一键安装与初始化 | [one-click-bootstrap.md](one-click-bootstrap.md) |
-| 按任务找命令 | [commands-reference.md](commands-reference.md) |
-| 快速查看配置默认值与关键项 | [config-reference.md](config-reference.md) |
-| 接入自定义 Provider / endpoint | [custom-providers.md](custom-providers.md) |
-| 配置 Z.AI / GLM Provider | [zai-glm-setup.md](zai-glm-setup.md) |
-| 使用 LangGraph 工具调用集成 | [langgraph-integration.md](langgraph-integration.md) |
-| 进行日常运维(runbook) | [operations-runbook.md](operations-runbook.md) |
-| 快速排查安装/运行问题 | [troubleshooting.md](troubleshooting.md) |
-| 统一目录导航 | [SUMMARY.md](SUMMARY.md) |
-| 查看 PR/Issue 扫描快照 | [project-triage-snapshot-2026-02-18.md](project-triage-snapshot-2026-02-18.md) |
-
-## 10 秒决策树(先看这个)
-
-- 首次安装或快速启动 → [getting-started/README.md](getting-started/README.md)
-- 需要精确命令或配置键 → [reference/README.md](reference/README.md)
-- 需要部署与服务化运维 → [operations/README.md](operations/README.md)
-- 遇到报错、异常或回归 → [troubleshooting.md](troubleshooting.md)
-- 查看安全现状与路线图 → [security/README.md](security/README.md)
-- 接入板卡与外设 → [hardware/README.md](hardware/README.md)
-- 参与贡献、评审与 CI → [contributing/README.md](contributing/README.md)
-- 查看完整文档地图 → [SUMMARY.md](SUMMARY.md)
-
-## 按目录浏览(推荐)
-
-- 入门文档: [getting-started/README.md](getting-started/README.md)
-- 参考手册: [reference/README.md](reference/README.md)
-- 运维与部署: [operations/README.md](operations/README.md)
-- 安全文档: [security/README.md](security/README.md)
-- 硬件与外设: [hardware/README.md](hardware/README.md)
-- 贡献与 CI: [contributing/README.md](contributing/README.md)
-- 项目快照: [project/README.md](project/README.md)
-
-## 按角色
-
-### 用户 / 运维
-
-- [commands-reference.md](commands-reference.md)
-- [providers-reference.md](providers-reference.md)
-- [channels-reference.md](channels-reference.md)
-- [config-reference.md](config-reference.md)
-- [custom-providers.md](custom-providers.md)
-- [zai-glm-setup.md](zai-glm-setup.md)
-- [langgraph-integration.md](langgraph-integration.md)
-- [operations-runbook.md](operations-runbook.md)
-- [troubleshooting.md](troubleshooting.md)
-
-### 贡献者 / 维护者
-
-- [../CONTRIBUTING.md](../CONTRIBUTING.md)
-- [pr-workflow.md](pr-workflow.md)
-- [reviewer-playbook.md](reviewer-playbook.md)
-- [ci-map.md](ci-map.md)
-- [actions-source-policy.md](actions-source-policy.md)
-
-### 安全 / 稳定性
-
-> 说明:本分组内有 proposal/roadmap 文档,可能包含设想中的命令或配置。当前可执行行为请优先阅读 [config-reference.md](config-reference.md)、[operations-runbook.md](operations-runbook.md)、[troubleshooting.md](troubleshooting.md)。
-
-- [security/README.md](security/README.md)
-- [agnostic-security.md](agnostic-security.md)
-- [frictionless-security.md](frictionless-security.md)
-- [sandboxing.md](sandboxing.md)
-- [resource-limits.md](resource-limits.md)
-- [audit-logging.md](audit-logging.md)
-- [security-roadmap.md](security-roadmap.md)
-
-## 文档治理与分类
-
-- 统一目录(TOC):[SUMMARY.md](SUMMARY.md)
-- 文档结构图(按语言/分区/功能):[structure/README.md](structure/README.md)
-- 文档清单与分类:[docs-inventory.md](docs-inventory.md)
-
-## 其他语言
-
-- English: [README.md](README.md)
-- 日本語: [README.ja.md](README.ja.md)
-- Русский: [README.ru.md](README.ru.md)
-- Français: [README.fr.md](README.fr.md)
-- Tiếng Việt: [i18n/vi/README.md](i18n/vi/README.md)
diff --git a/docs/SUMMARY.fr.md b/docs/SUMMARY.fr.md
index 925508d70..fae7078ae 100644
--- a/docs/SUMMARY.fr.md
+++ b/docs/SUMMARY.fr.md
@@ -4,86 +4,92 @@ Ce fichier constitue la table des matières canonique du système de documentati
> 📖 [English version](SUMMARY.md)
-Dernière mise à jour : **18 février 2026**.
+Dernière mise à jour : **24 février 2026**.
## Points d'entrée par langue
- Carte de structure docs (langue/partie/fonction) : [structure/README.md](structure/README.md)
- README en anglais : [../README.md](../README.md)
-- README en chinois : [../README.zh-CN.md](../README.zh-CN.md)
-- README en japonais : [../README.ja.md](../README.ja.md)
-- README en russe : [../README.ru.md](../README.ru.md)
-- README en français : [../README.fr.md](../README.fr.md)
-- README en vietnamien : [../README.vi.md](../README.vi.md)
+- README en chinois : [docs/i18n/zh-CN/README.md](i18n/zh-CN/README.md)
+- README en japonais : [docs/i18n/ja/README.md](i18n/ja/README.md)
+- README en russe : [docs/i18n/ru/README.md](i18n/ru/README.md)
+- README en français : [docs/i18n/fr/README.md](i18n/fr/README.md)
+- README en vietnamien : [docs/i18n/vi/README.md](i18n/vi/README.md)
+- README en grec : [docs/i18n/el/README.md](i18n/el/README.md)
- Documentation en anglais : [README.md](README.md)
-- Documentation en chinois : [README.zh-CN.md](README.zh-CN.md)
-- Documentation en japonais : [README.ja.md](README.ja.md)
-- Documentation en russe : [README.ru.md](README.ru.md)
-- Documentation en français : [README.fr.md](README.fr.md)
+- Documentation en chinois : [i18n/zh-CN/README.md](i18n/zh-CN/README.md)
+- Documentation en japonais : [i18n/ja/README.md](i18n/ja/README.md)
+- Documentation en russe : [i18n/ru/README.md](i18n/ru/README.md)
+- Documentation en français : [i18n/fr/README.md](i18n/fr/README.md)
- Documentation en vietnamien : [i18n/vi/README.md](i18n/vi/README.md)
-- Index de localisation : [i18n/README.md](i18n/README.md)
-- Carte de couverture i18n : [i18n-coverage.md](i18n-coverage.md)
+- Documentation en grec : [i18n/el/README.md](i18n/el/README.md)
+- Index i18n : [i18n/README.md](i18n/README.md)
+- Couverture i18n : [i18n-coverage.md](i18n-coverage.md)
+- Guide i18n : [i18n-guide.md](i18n-guide.md)
+- Suivi des écarts : [i18n-gap-backlog.md](i18n-gap-backlog.md)
## Catégories
### 1) Démarrage rapide
-- [getting-started/README.md](getting-started/README.md)
-- [one-click-bootstrap.md](one-click-bootstrap.md)
+- [docs/i18n/fr/README.md](i18n/fr/README.md)
+- [i18n/fr/one-click-bootstrap.md](i18n/fr/one-click-bootstrap.md)
+- [i18n/fr/android-setup.md](i18n/fr/android-setup.md)
### 2) Référence des commandes, configuration et intégrations
-- [reference/README.md](reference/README.md)
-- [commands-reference.md](commands-reference.md)
-- [providers-reference.md](providers-reference.md)
-- [channels-reference.md](channels-reference.md)
-- [nextcloud-talk-setup.md](nextcloud-talk-setup.md)
-- [config-reference.md](config-reference.md)
-- [custom-providers.md](custom-providers.md)
-- [zai-glm-setup.md](zai-glm-setup.md)
-- [langgraph-integration.md](langgraph-integration.md)
+- [docs/i18n/fr/README.md](i18n/fr/README.md)
+- [i18n/fr/commands-reference.md](i18n/fr/commands-reference.md)
+- [i18n/fr/providers-reference.md](i18n/fr/providers-reference.md)
+- [i18n/fr/channels-reference.md](i18n/fr/channels-reference.md)
+- [i18n/fr/config-reference.md](i18n/fr/config-reference.md)
+- [i18n/fr/custom-providers.md](i18n/fr/custom-providers.md)
+- [i18n/fr/zai-glm-setup.md](i18n/fr/zai-glm-setup.md)
+- [i18n/fr/langgraph-integration.md](i18n/fr/langgraph-integration.md)
+- [i18n/fr/proxy-agent-playbook.md](i18n/fr/proxy-agent-playbook.md)
### 3) Exploitation et déploiement
-- [operations/README.md](operations/README.md)
-- [operations-runbook.md](operations-runbook.md)
-- [release-process.md](release-process.md)
-- [troubleshooting.md](troubleshooting.md)
-- [network-deployment.md](network-deployment.md)
-- [mattermost-setup.md](mattermost-setup.md)
+- [docs/i18n/fr/README.md](i18n/fr/README.md)
+- [i18n/fr/operations-runbook.md](i18n/fr/operations-runbook.md)
+- [i18n/fr/release-process.md](i18n/fr/release-process.md)
+- [i18n/fr/troubleshooting.md](i18n/fr/troubleshooting.md)
+- [i18n/fr/network-deployment.md](i18n/fr/network-deployment.md)
+- [i18n/fr/mattermost-setup.md](i18n/fr/mattermost-setup.md)
+- [i18n/fr/nextcloud-talk-setup.md](i18n/fr/nextcloud-talk-setup.md)
-### 4) Conception de la sécurité et propositions
+### 4) Sécurité et gouvernance
-- [security/README.md](security/README.md)
-- [agnostic-security.md](agnostic-security.md)
-- [frictionless-security.md](frictionless-security.md)
-- [sandboxing.md](sandboxing.md)
-- [resource-limits.md](resource-limits.md)
-- [audit-logging.md](audit-logging.md)
-- [security-roadmap.md](security-roadmap.md)
+- [docs/i18n/fr/README.md](i18n/fr/README.md)
+- [i18n/fr/agnostic-security.md](i18n/fr/agnostic-security.md)
+- [i18n/fr/frictionless-security.md](i18n/fr/frictionless-security.md)
+- [i18n/fr/sandboxing.md](i18n/fr/sandboxing.md)
+- [i18n/fr/resource-limits.md](i18n/fr/resource-limits.md)
+- [i18n/fr/audit-logging.md](i18n/fr/audit-logging.md)
+- [i18n/fr/audit-event-schema.md](i18n/fr/audit-event-schema.md)
+- [i18n/fr/security-roadmap.md](i18n/fr/security-roadmap.md)
### 5) Matériel et périphériques
-- [hardware/README.md](hardware/README.md)
-- [hardware-peripherals-design.md](hardware-peripherals-design.md)
-- [adding-boards-and-tools.md](adding-boards-and-tools.md)
-- [nucleo-setup.md](nucleo-setup.md)
-- [arduino-uno-q-setup.md](arduino-uno-q-setup.md)
-- [datasheets/nucleo-f401re.md](datasheets/nucleo-f401re.md)
-- [datasheets/arduino-uno.md](datasheets/arduino-uno.md)
-- [datasheets/esp32.md](datasheets/esp32.md)
+- [docs/i18n/fr/README.md](i18n/fr/README.md)
+- [i18n/fr/hardware-peripherals-design.md](i18n/fr/hardware-peripherals-design.md)
+- [i18n/fr/adding-boards-and-tools.md](i18n/fr/adding-boards-and-tools.md)
+- [i18n/fr/nucleo-setup.md](i18n/fr/nucleo-setup.md)
+- [i18n/fr/arduino-uno-q-setup.md](i18n/fr/arduino-uno-q-setup.md)
+- [datasheets/README.md](datasheets/README.md)
### 6) Contribution et CI
-- [contributing/README.md](contributing/README.md)
+- [docs/i18n/fr/README.md](i18n/fr/README.md)
- [../CONTRIBUTING.md](../CONTRIBUTING.md)
-- [pr-workflow.md](pr-workflow.md)
-- [reviewer-playbook.md](reviewer-playbook.md)
-- [ci-map.md](ci-map.md)
-- [actions-source-policy.md](actions-source-policy.md)
+- [i18n/fr/pr-workflow.md](i18n/fr/pr-workflow.md)
+- [i18n/fr/reviewer-playbook.md](i18n/fr/reviewer-playbook.md)
+- [i18n/fr/ci-map.md](i18n/fr/ci-map.md)
+- [i18n/fr/actions-source-policy.md](i18n/fr/actions-source-policy.md)
### 7) État du projet et instantanés
-- [project/README.md](project/README.md)
-- [project-triage-snapshot-2026-02-18.md](project-triage-snapshot-2026-02-18.md)
-- [docs-inventory.md](docs-inventory.md)
+- [docs/i18n/fr/README.md](i18n/fr/README.md)
+- [i18n/fr/project-triage-snapshot-2026-02-18.md](i18n/fr/project-triage-snapshot-2026-02-18.md)
+- [i18n/fr/docs-audit-2026-02-24.md](i18n/fr/docs-audit-2026-02-24.md)
+- [i18n/fr/docs-inventory.md](i18n/fr/docs-inventory.md)
diff --git a/docs/SUMMARY.ja.md b/docs/SUMMARY.ja.md
index 9fe533da1..64fd48757 100644
--- a/docs/SUMMARY.ja.md
+++ b/docs/SUMMARY.ja.md
@@ -1,89 +1,95 @@
# ZeroClaw ドキュメント目次(統合目次)
-このファイルはドキュメントシステムの正規の目次です。
+このファイルはドキュメントシステムの正規目次です。
> 📖 [English version](SUMMARY.md)
-最終更新:**2026年2月18日**。
+最終更新:**2026年2月24日**。
## 言語別入口
- ドキュメント構造マップ(言語/カテゴリ/機能): [structure/README.md](structure/README.md)
- 英語 README:[../README.md](../README.md)
-- 中国語 README:[../README.zh-CN.md](../README.zh-CN.md)
-- 日本語 README:[../README.ja.md](../README.ja.md)
-- ロシア語 README:[../README.ru.md](../README.ru.md)
-- フランス語 README:[../README.fr.md](../README.fr.md)
-- ベトナム語 README:[../README.vi.md](../README.vi.md)
+- 中国語 README:[docs/i18n/zh-CN/README.md](i18n/zh-CN/README.md)
+- 日本語 README:[docs/i18n/ja/README.md](i18n/ja/README.md)
+- ロシア語 README:[docs/i18n/ru/README.md](i18n/ru/README.md)
+- フランス語 README:[docs/i18n/fr/README.md](i18n/fr/README.md)
+- ベトナム語 README:[docs/i18n/vi/README.md](i18n/vi/README.md)
+- ギリシャ語 README:[docs/i18n/el/README.md](i18n/el/README.md)
- 英語ドキュメントハブ:[README.md](README.md)
-- 中国語ドキュメントハブ:[README.zh-CN.md](README.zh-CN.md)
-- 日本語ドキュメントハブ:[README.ja.md](README.ja.md)
-- ロシア語ドキュメントハブ:[README.ru.md](README.ru.md)
-- フランス語ドキュメントハブ:[README.fr.md](README.fr.md)
+- 中国語ドキュメントハブ:[i18n/zh-CN/README.md](i18n/zh-CN/README.md)
+- 日本語ドキュメントハブ:[i18n/ja/README.md](i18n/ja/README.md)
+- ロシア語ドキュメントハブ:[i18n/ru/README.md](i18n/ru/README.md)
+- フランス語ドキュメントハブ:[i18n/fr/README.md](i18n/fr/README.md)
- ベトナム語ドキュメントハブ:[i18n/vi/README.md](i18n/vi/README.md)
-- 国際化ドキュメント索引:[i18n/README.md](i18n/README.md)
-- 国際化カバレッジマップ:[i18n-coverage.md](i18n-coverage.md)
+- ギリシャ語ドキュメントハブ:[i18n/el/README.md](i18n/el/README.md)
+- i18n 索引:[i18n/README.md](i18n/README.md)
+- i18n カバレッジ:[i18n-coverage.md](i18n-coverage.md)
+- i18n ガイド:[i18n-guide.md](i18n-guide.md)
+- i18n ギャップ管理:[i18n-gap-backlog.md](i18n-gap-backlog.md)
## カテゴリ
### 1) はじめに
-- [getting-started/README.md](getting-started/README.md)
-- [one-click-bootstrap.md](one-click-bootstrap.md)
+- [docs/i18n/ja/README.md](i18n/ja/README.md)
+- [i18n/ja/one-click-bootstrap.md](i18n/ja/one-click-bootstrap.md)
+- [i18n/ja/android-setup.md](i18n/ja/android-setup.md)
### 2) コマンド・設定リファレンスと統合
-- [reference/README.md](reference/README.md)
-- [commands-reference.md](commands-reference.md)
-- [providers-reference.md](providers-reference.md)
-- [channels-reference.md](channels-reference.md)
-- [nextcloud-talk-setup.md](nextcloud-talk-setup.md)
-- [config-reference.md](config-reference.md)
-- [custom-providers.md](custom-providers.md)
-- [zai-glm-setup.md](zai-glm-setup.md)
-- [langgraph-integration.md](langgraph-integration.md)
+- [docs/i18n/ja/README.md](i18n/ja/README.md)
+- [i18n/ja/commands-reference.md](i18n/ja/commands-reference.md)
+- [i18n/ja/providers-reference.md](i18n/ja/providers-reference.md)
+- [i18n/ja/channels-reference.md](i18n/ja/channels-reference.md)
+- [i18n/ja/config-reference.md](i18n/ja/config-reference.md)
+- [i18n/ja/custom-providers.md](i18n/ja/custom-providers.md)
+- [i18n/ja/zai-glm-setup.md](i18n/ja/zai-glm-setup.md)
+- [i18n/ja/langgraph-integration.md](i18n/ja/langgraph-integration.md)
+- [i18n/ja/proxy-agent-playbook.md](i18n/ja/proxy-agent-playbook.md)
### 3) 運用とデプロイ
-- [operations/README.md](operations/README.md)
-- [operations-runbook.md](operations-runbook.md)
-- [release-process.md](release-process.md)
-- [troubleshooting.md](troubleshooting.md)
-- [network-deployment.md](network-deployment.md)
-- [mattermost-setup.md](mattermost-setup.md)
+- [docs/i18n/ja/README.md](i18n/ja/README.md)
+- [i18n/ja/operations-runbook.md](i18n/ja/operations-runbook.md)
+- [i18n/ja/release-process.md](i18n/ja/release-process.md)
+- [i18n/ja/troubleshooting.md](i18n/ja/troubleshooting.md)
+- [i18n/ja/network-deployment.md](i18n/ja/network-deployment.md)
+- [i18n/ja/mattermost-setup.md](i18n/ja/mattermost-setup.md)
+- [i18n/ja/nextcloud-talk-setup.md](i18n/ja/nextcloud-talk-setup.md)
-### 4) セキュリティ設計と提案
+### 4) セキュリティ設計と統制
-- [security/README.md](security/README.md)
-- [agnostic-security.md](agnostic-security.md)
-- [frictionless-security.md](frictionless-security.md)
-- [sandboxing.md](sandboxing.md)
-- [resource-limits.md](resource-limits.md)
-- [audit-logging.md](audit-logging.md)
-- [security-roadmap.md](security-roadmap.md)
+- [docs/i18n/ja/README.md](i18n/ja/README.md)
+- [i18n/ja/agnostic-security.md](i18n/ja/agnostic-security.md)
+- [i18n/ja/frictionless-security.md](i18n/ja/frictionless-security.md)
+- [i18n/ja/sandboxing.md](i18n/ja/sandboxing.md)
+- [i18n/ja/resource-limits.md](i18n/ja/resource-limits.md)
+- [i18n/ja/audit-logging.md](i18n/ja/audit-logging.md)
+- [i18n/ja/audit-event-schema.md](i18n/ja/audit-event-schema.md)
+- [i18n/ja/security-roadmap.md](i18n/ja/security-roadmap.md)
### 5) ハードウェアと周辺機器
-- [hardware/README.md](hardware/README.md)
-- [hardware-peripherals-design.md](hardware-peripherals-design.md)
-- [adding-boards-and-tools.md](adding-boards-and-tools.md)
-- [nucleo-setup.md](nucleo-setup.md)
-- [arduino-uno-q-setup.md](arduino-uno-q-setup.md)
-- [datasheets/nucleo-f401re.md](datasheets/nucleo-f401re.md)
-- [datasheets/arduino-uno.md](datasheets/arduino-uno.md)
-- [datasheets/esp32.md](datasheets/esp32.md)
+- [docs/i18n/ja/README.md](i18n/ja/README.md)
+- [i18n/ja/hardware-peripherals-design.md](i18n/ja/hardware-peripherals-design.md)
+- [i18n/ja/adding-boards-and-tools.md](i18n/ja/adding-boards-and-tools.md)
+- [i18n/ja/nucleo-setup.md](i18n/ja/nucleo-setup.md)
+- [i18n/ja/arduino-uno-q-setup.md](i18n/ja/arduino-uno-q-setup.md)
+- [datasheets/README.md](datasheets/README.md)
### 6) コントリビューションと CI
-- [contributing/README.md](contributing/README.md)
+- [docs/i18n/ja/README.md](i18n/ja/README.md)
- [../CONTRIBUTING.md](../CONTRIBUTING.md)
-- [pr-workflow.md](pr-workflow.md)
-- [reviewer-playbook.md](reviewer-playbook.md)
-- [ci-map.md](ci-map.md)
-- [actions-source-policy.md](actions-source-policy.md)
+- [i18n/ja/pr-workflow.md](i18n/ja/pr-workflow.md)
+- [i18n/ja/reviewer-playbook.md](i18n/ja/reviewer-playbook.md)
+- [i18n/ja/ci-map.md](i18n/ja/ci-map.md)
+- [i18n/ja/actions-source-policy.md](i18n/ja/actions-source-policy.md)
### 7) プロジェクト状況とスナップショット
-- [project/README.md](project/README.md)
-- [project-triage-snapshot-2026-02-18.md](project-triage-snapshot-2026-02-18.md)
-- [docs-inventory.md](docs-inventory.md)
+- [docs/i18n/ja/README.md](i18n/ja/README.md)
+- [i18n/ja/project-triage-snapshot-2026-02-18.md](i18n/ja/project-triage-snapshot-2026-02-18.md)
+- [i18n/ja/docs-audit-2026-02-24.md](i18n/ja/docs-audit-2026-02-24.md)
+- [i18n/ja/docs-inventory.md](i18n/ja/docs-inventory.md)
diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md
index 1f828256e..2f324da05 100644
--- a/docs/SUMMARY.md
+++ b/docs/SUMMARY.md
@@ -2,25 +2,30 @@
This file is the canonical table of contents for the documentation system.
-Last refreshed: **February 18, 2026**.
+Last refreshed: **February 25, 2026**.
## Language Entry
- Docs Structure Map (language/part/function): [structure/README.md](structure/README.md)
+- Docs Map (by function): [structure/by-function.md](structure/by-function.md)
- English README: [../README.md](../README.md)
-- Chinese README: [../README.zh-CN.md](../README.zh-CN.md)
-- Japanese README: [../README.ja.md](../README.ja.md)
-- Russian README: [../README.ru.md](../README.ru.md)
-- French README: [../README.fr.md](../README.fr.md)
-- Vietnamese README: [../README.vi.md](../README.vi.md)
+- Chinese README: [docs/i18n/zh-CN/README.md](i18n/zh-CN/README.md)
+- Japanese README: [docs/i18n/ja/README.md](i18n/ja/README.md)
+- Russian README: [docs/i18n/ru/README.md](i18n/ru/README.md)
+- French README: [docs/i18n/fr/README.md](i18n/fr/README.md)
+- Vietnamese README: [docs/i18n/vi/README.md](i18n/vi/README.md)
+- Greek README: [docs/i18n/el/README.md](i18n/el/README.md)
- English Docs Hub: [README.md](README.md)
-- Chinese Docs Hub: [README.zh-CN.md](README.zh-CN.md)
-- Japanese Docs Hub: [README.ja.md](README.ja.md)
-- Russian Docs Hub: [README.ru.md](README.ru.md)
-- French Docs Hub: [README.fr.md](README.fr.md)
+- Chinese Docs Hub: [i18n/zh-CN/README.md](i18n/zh-CN/README.md)
+- Japanese Docs Hub: [i18n/ja/README.md](i18n/ja/README.md)
+- Russian Docs Hub: [i18n/ru/README.md](i18n/ru/README.md)
+- French Docs Hub: [i18n/fr/README.md](i18n/fr/README.md)
- Vietnamese Docs Hub: [i18n/vi/README.md](i18n/vi/README.md)
+- Greek Docs Hub: [i18n/el/README.md](i18n/el/README.md)
- i18n Docs Index: [i18n/README.md](i18n/README.md)
- i18n Coverage Map: [i18n-coverage.md](i18n-coverage.md)
+- i18n Completion Guide: [i18n-guide.md](i18n-guide.md)
+- i18n Gap Backlog: [i18n-gap-backlog.md](i18n-gap-backlog.md)
## Collections
@@ -29,6 +34,8 @@ Last refreshed: **February 18, 2026**.
- [getting-started/README.md](getting-started/README.md)
- [getting-started/macos-update-uninstall.md](getting-started/macos-update-uninstall.md)
- [one-click-bootstrap.md](one-click-bootstrap.md)
+- [docker-setup.md](docker-setup.md)
+- [android-setup.md](android-setup.md)
### 2) Command/Config References & Integrations
@@ -38,14 +45,17 @@ Last refreshed: **February 18, 2026**.
- [channels-reference.md](channels-reference.md)
- [nextcloud-talk-setup.md](nextcloud-talk-setup.md)
- [config-reference.md](config-reference.md)
+- [wasm-tools-guide.md](wasm-tools-guide.md)
- [custom-providers.md](custom-providers.md)
- [zai-glm-setup.md](zai-glm-setup.md)
- [langgraph-integration.md](langgraph-integration.md)
+- [proxy-agent-playbook.md](proxy-agent-playbook.md)
### 3) Operations & Deployment
- [operations/README.md](operations/README.md)
- [operations-runbook.md](operations-runbook.md)
+- [operations/connectivity-probes-runbook.md](operations/connectivity-probes-runbook.md)
- [release-process.md](release-process.md)
- [troubleshooting.md](troubleshooting.md)
- [network-deployment.md](network-deployment.md)
@@ -54,11 +64,13 @@ Last refreshed: **February 18, 2026**.
### 4) Security Design & Proposals
- [security/README.md](security/README.md)
+- [security/official-channels-and-fraud-prevention.md](security/official-channels-and-fraud-prevention.md)
- [agnostic-security.md](agnostic-security.md)
- [frictionless-security.md](frictionless-security.md)
- [sandboxing.md](sandboxing.md)
- [resource-limits.md](resource-limits.md)
- [audit-logging.md](audit-logging.md)
+- [audit-event-schema.md](audit-event-schema.md)
- [security-roadmap.md](security-roadmap.md)
### 5) Hardware & Peripherals
@@ -68,6 +80,7 @@ Last refreshed: **February 18, 2026**.
- [adding-boards-and-tools.md](adding-boards-and-tools.md)
- [nucleo-setup.md](nucleo-setup.md)
- [arduino-uno-q-setup.md](arduino-uno-q-setup.md)
+- [datasheets/README.md](datasheets/README.md)
- [datasheets/nucleo-f401re.md](datasheets/nucleo-f401re.md)
- [datasheets/arduino-uno.md](datasheets/arduino-uno.md)
- [datasheets/esp32.md](datasheets/esp32.md)
@@ -80,9 +93,20 @@ Last refreshed: **February 18, 2026**.
- [reviewer-playbook.md](reviewer-playbook.md)
- [ci-map.md](ci-map.md)
- [actions-source-policy.md](actions-source-policy.md)
+- [cargo-slicer-speedup.md](cargo-slicer-speedup.md)
-### 7) Project Status & Snapshot
+### 7) SOP Runtime & Procedures
+
+- [sop/README.md](sop/README.md)
+- [sop/connectivity.md](sop/connectivity.md)
+- [sop/syntax.md](sop/syntax.md)
+- [sop/observability.md](sop/observability.md)
+- [sop/cookbook.md](sop/cookbook.md)
+
+### 8) Project Status & Snapshot
- [project/README.md](project/README.md)
- [project-triage-snapshot-2026-02-18.md](project-triage-snapshot-2026-02-18.md)
+- [docs-audit-2026-02-24.md](docs-audit-2026-02-24.md)
+- [i18n-gap-backlog.md](i18n-gap-backlog.md)
- [docs-inventory.md](docs-inventory.md)
diff --git a/docs/SUMMARY.ru.md b/docs/SUMMARY.ru.md
index c8ef697eb..a73b6c3c9 100644
--- a/docs/SUMMARY.ru.md
+++ b/docs/SUMMARY.ru.md
@@ -4,86 +4,92 @@
> 📖 [English version](SUMMARY.md)
-Последнее обновление: **18 февраля 2026 г.**
+Последнее обновление: **24 февраля 2026 г.**
## Языковые точки входа
- Карта структуры docs (язык/раздел/функция): [structure/README.md](structure/README.md)
- README на английском: [../README.md](../README.md)
-- README на китайском: [../README.zh-CN.md](../README.zh-CN.md)
-- README на японском: [../README.ja.md](../README.ja.md)
-- README на русском: [../README.ru.md](../README.ru.md)
-- README на французском: [../README.fr.md](../README.fr.md)
-- README на вьетнамском: [../README.vi.md](../README.vi.md)
+- README на китайском: [docs/i18n/zh-CN/README.md](i18n/zh-CN/README.md)
+- README на японском: [docs/i18n/ja/README.md](i18n/ja/README.md)
+- README на русском: [docs/i18n/ru/README.md](i18n/ru/README.md)
+- README на французском: [docs/i18n/fr/README.md](i18n/fr/README.md)
+- README на вьетнамском: [docs/i18n/vi/README.md](i18n/vi/README.md)
+- README на греческом: [docs/i18n/el/README.md](i18n/el/README.md)
- Документация на английском: [README.md](README.md)
-- Документация на китайском: [README.zh-CN.md](README.zh-CN.md)
-- Документация на японском: [README.ja.md](README.ja.md)
-- Документация на русском: [README.ru.md](README.ru.md)
-- Документация на французском: [README.fr.md](README.fr.md)
+- Документация на китайском: [i18n/zh-CN/README.md](i18n/zh-CN/README.md)
+- Документация на японском: [i18n/ja/README.md](i18n/ja/README.md)
+- Документация на русском: [i18n/ru/README.md](i18n/ru/README.md)
+- Документация на французском: [i18n/fr/README.md](i18n/fr/README.md)
- Документация на вьетнамском: [i18n/vi/README.md](i18n/vi/README.md)
-- Индекс локализации: [i18n/README.md](i18n/README.md)
-- Карта покрытия локализации: [i18n-coverage.md](i18n-coverage.md)
+- Документация на греческом: [i18n/el/README.md](i18n/el/README.md)
+- Индекс i18n: [i18n/README.md](i18n/README.md)
+- Карта покрытия i18n: [i18n-coverage.md](i18n-coverage.md)
+- Гайд i18n: [i18n-guide.md](i18n-guide.md)
+- Трекинг gap: [i18n-gap-backlog.md](i18n-gap-backlog.md)
## Разделы
### 1) Начало работы
-- [getting-started/README.md](getting-started/README.md)
-- [one-click-bootstrap.md](one-click-bootstrap.md)
+- [docs/i18n/ru/README.md](i18n/ru/README.md)
+- [i18n/ru/one-click-bootstrap.md](i18n/ru/one-click-bootstrap.md)
+- [i18n/ru/android-setup.md](i18n/ru/android-setup.md)
### 2) Справочник команд, конфигурации и интеграций
-- [reference/README.md](reference/README.md)
-- [commands-reference.md](commands-reference.md)
-- [providers-reference.md](providers-reference.md)
-- [channels-reference.md](channels-reference.md)
-- [nextcloud-talk-setup.md](nextcloud-talk-setup.md)
-- [config-reference.md](config-reference.md)
-- [custom-providers.md](custom-providers.md)
-- [zai-glm-setup.md](zai-glm-setup.md)
-- [langgraph-integration.md](langgraph-integration.md)
+- [docs/i18n/ru/README.md](i18n/ru/README.md)
+- [i18n/ru/commands-reference.md](i18n/ru/commands-reference.md)
+- [i18n/ru/providers-reference.md](i18n/ru/providers-reference.md)
+- [i18n/ru/channels-reference.md](i18n/ru/channels-reference.md)
+- [i18n/ru/config-reference.md](i18n/ru/config-reference.md)
+- [i18n/ru/custom-providers.md](i18n/ru/custom-providers.md)
+- [i18n/ru/zai-glm-setup.md](i18n/ru/zai-glm-setup.md)
+- [i18n/ru/langgraph-integration.md](i18n/ru/langgraph-integration.md)
+- [i18n/ru/proxy-agent-playbook.md](i18n/ru/proxy-agent-playbook.md)
### 3) Эксплуатация и развёртывание
-- [operations/README.md](operations/README.md)
-- [operations-runbook.md](operations-runbook.md)
-- [release-process.md](release-process.md)
-- [troubleshooting.md](troubleshooting.md)
-- [network-deployment.md](network-deployment.md)
-- [mattermost-setup.md](mattermost-setup.md)
+- [docs/i18n/ru/README.md](i18n/ru/README.md)
+- [i18n/ru/operations-runbook.md](i18n/ru/operations-runbook.md)
+- [i18n/ru/release-process.md](i18n/ru/release-process.md)
+- [i18n/ru/troubleshooting.md](i18n/ru/troubleshooting.md)
+- [i18n/ru/network-deployment.md](i18n/ru/network-deployment.md)
+- [i18n/ru/mattermost-setup.md](i18n/ru/mattermost-setup.md)
+- [i18n/ru/nextcloud-talk-setup.md](i18n/ru/nextcloud-talk-setup.md)
-### 4) Проектирование безопасности и предложения
+### 4) Безопасность и управление
-- [security/README.md](security/README.md)
-- [agnostic-security.md](agnostic-security.md)
-- [frictionless-security.md](frictionless-security.md)
-- [sandboxing.md](sandboxing.md)
-- [resource-limits.md](resource-limits.md)
-- [audit-logging.md](audit-logging.md)
-- [security-roadmap.md](security-roadmap.md)
+- [docs/i18n/ru/README.md](i18n/ru/README.md)
+- [i18n/ru/agnostic-security.md](i18n/ru/agnostic-security.md)
+- [i18n/ru/frictionless-security.md](i18n/ru/frictionless-security.md)
+- [i18n/ru/sandboxing.md](i18n/ru/sandboxing.md)
+- [i18n/ru/resource-limits.md](i18n/ru/resource-limits.md)
+- [i18n/ru/audit-logging.md](i18n/ru/audit-logging.md)
+- [i18n/ru/audit-event-schema.md](i18n/ru/audit-event-schema.md)
+- [i18n/ru/security-roadmap.md](i18n/ru/security-roadmap.md)
### 5) Оборудование и периферия
-- [hardware/README.md](hardware/README.md)
-- [hardware-peripherals-design.md](hardware-peripherals-design.md)
-- [adding-boards-and-tools.md](adding-boards-and-tools.md)
-- [nucleo-setup.md](nucleo-setup.md)
-- [arduino-uno-q-setup.md](arduino-uno-q-setup.md)
-- [datasheets/nucleo-f401re.md](datasheets/nucleo-f401re.md)
-- [datasheets/arduino-uno.md](datasheets/arduino-uno.md)
-- [datasheets/esp32.md](datasheets/esp32.md)
+- [docs/i18n/ru/README.md](i18n/ru/README.md)
+- [i18n/ru/hardware-peripherals-design.md](i18n/ru/hardware-peripherals-design.md)
+- [i18n/ru/adding-boards-and-tools.md](i18n/ru/adding-boards-and-tools.md)
+- [i18n/ru/nucleo-setup.md](i18n/ru/nucleo-setup.md)
+- [i18n/ru/arduino-uno-q-setup.md](i18n/ru/arduino-uno-q-setup.md)
+- [datasheets/README.md](datasheets/README.md)
### 6) Участие в проекте и CI
-- [contributing/README.md](contributing/README.md)
+- [docs/i18n/ru/README.md](i18n/ru/README.md)
- [../CONTRIBUTING.md](../CONTRIBUTING.md)
-- [pr-workflow.md](pr-workflow.md)
-- [reviewer-playbook.md](reviewer-playbook.md)
-- [ci-map.md](ci-map.md)
-- [actions-source-policy.md](actions-source-policy.md)
+- [i18n/ru/pr-workflow.md](i18n/ru/pr-workflow.md)
+- [i18n/ru/reviewer-playbook.md](i18n/ru/reviewer-playbook.md)
+- [i18n/ru/ci-map.md](i18n/ru/ci-map.md)
+- [i18n/ru/actions-source-policy.md](i18n/ru/actions-source-policy.md)
### 7) Состояние проекта и снимки
-- [project/README.md](project/README.md)
-- [project-triage-snapshot-2026-02-18.md](project-triage-snapshot-2026-02-18.md)
-- [docs-inventory.md](docs-inventory.md)
+- [docs/i18n/ru/README.md](i18n/ru/README.md)
+- [i18n/ru/project-triage-snapshot-2026-02-18.md](i18n/ru/project-triage-snapshot-2026-02-18.md)
+- [i18n/ru/docs-audit-2026-02-24.md](i18n/ru/docs-audit-2026-02-24.md)
+- [i18n/ru/docs-inventory.md](i18n/ru/docs-inventory.md)
diff --git a/docs/SUMMARY.zh-CN.md b/docs/SUMMARY.zh-CN.md
index dda5b19f9..91e69dfad 100644
--- a/docs/SUMMARY.zh-CN.md
+++ b/docs/SUMMARY.zh-CN.md
@@ -4,86 +4,92 @@
> 📖 [English version](SUMMARY.md)
-最后更新:**2026年2月18日**。
+最后更新:**2026年2月24日**。
## 语言入口
- 文档结构图(按语言/分区/功能):[structure/README.md](structure/README.md)
- 英文 README:[../README.md](../README.md)
-- 中文 README:[../README.zh-CN.md](../README.zh-CN.md)
-- 日文 README:[../README.ja.md](../README.ja.md)
-- 俄文 README:[../README.ru.md](../README.ru.md)
-- 法文 README:[../README.fr.md](../README.fr.md)
-- 越南文 README:[../README.vi.md](../README.vi.md)
+- 中文 README:[docs/i18n/zh-CN/README.md](i18n/zh-CN/README.md)
+- 日文 README:[docs/i18n/ja/README.md](i18n/ja/README.md)
+- 俄文 README:[docs/i18n/ru/README.md](i18n/ru/README.md)
+- 法文 README:[docs/i18n/fr/README.md](i18n/fr/README.md)
+- 越南文 README:[docs/i18n/vi/README.md](i18n/vi/README.md)
+- 希腊文 README:[docs/i18n/el/README.md](i18n/el/README.md)
- 英文文档中心:[README.md](README.md)
-- 中文文档中心:[README.zh-CN.md](README.zh-CN.md)
-- 日文文档中心:[README.ja.md](README.ja.md)
-- 俄文文档中心:[README.ru.md](README.ru.md)
-- 法文文档中心:[README.fr.md](README.fr.md)
+- 中文文档中心:[i18n/zh-CN/README.md](i18n/zh-CN/README.md)
+- 日文文档中心:[i18n/ja/README.md](i18n/ja/README.md)
+- 俄文文档中心:[i18n/ru/README.md](i18n/ru/README.md)
+- 法文文档中心:[i18n/fr/README.md](i18n/fr/README.md)
- 越南文文档中心:[i18n/vi/README.md](i18n/vi/README.md)
+- 希腊文文档中心:[i18n/el/README.md](i18n/el/README.md)
- 国际化文档索引:[i18n/README.md](i18n/README.md)
- 国际化覆盖图:[i18n-coverage.md](i18n-coverage.md)
+- 国际化执行指南:[i18n-guide.md](i18n-guide.md)
+- 国际化缺口追踪:[i18n-gap-backlog.md](i18n-gap-backlog.md)
## 分类
### 1) 快速入门
-- [getting-started/README.md](getting-started/README.md)
-- [one-click-bootstrap.md](one-click-bootstrap.md)
+- [docs/i18n/zh-CN/README.md](i18n/zh-CN/README.md)
+- [i18n/zh-CN/one-click-bootstrap.md](i18n/zh-CN/one-click-bootstrap.md)
+- [i18n/zh-CN/android-setup.md](i18n/zh-CN/android-setup.md)
### 2) 命令 / 配置参考与集成
-- [reference/README.md](reference/README.md)
-- [commands-reference.md](commands-reference.md)
-- [providers-reference.md](providers-reference.md)
-- [channels-reference.md](channels-reference.md)
-- [nextcloud-talk-setup.md](nextcloud-talk-setup.md)
-- [config-reference.md](config-reference.md)
-- [custom-providers.md](custom-providers.md)
-- [zai-glm-setup.md](zai-glm-setup.md)
-- [langgraph-integration.md](langgraph-integration.md)
+- [docs/i18n/zh-CN/README.md](i18n/zh-CN/README.md)
+- [i18n/zh-CN/commands-reference.md](i18n/zh-CN/commands-reference.md)
+- [i18n/zh-CN/providers-reference.md](i18n/zh-CN/providers-reference.md)
+- [i18n/zh-CN/channels-reference.md](i18n/zh-CN/channels-reference.md)
+- [i18n/zh-CN/config-reference.md](i18n/zh-CN/config-reference.md)
+- [i18n/zh-CN/custom-providers.md](i18n/zh-CN/custom-providers.md)
+- [i18n/zh-CN/zai-glm-setup.md](i18n/zh-CN/zai-glm-setup.md)
+- [i18n/zh-CN/langgraph-integration.md](i18n/zh-CN/langgraph-integration.md)
+- [i18n/zh-CN/proxy-agent-playbook.md](i18n/zh-CN/proxy-agent-playbook.md)
### 3) 运维与部署
-- [operations/README.md](operations/README.md)
-- [operations-runbook.md](operations-runbook.md)
-- [release-process.md](release-process.md)
-- [troubleshooting.md](troubleshooting.md)
-- [network-deployment.md](network-deployment.md)
-- [mattermost-setup.md](mattermost-setup.md)
+- [docs/i18n/zh-CN/README.md](i18n/zh-CN/README.md)
+- [i18n/zh-CN/operations-runbook.md](i18n/zh-CN/operations-runbook.md)
+- [i18n/zh-CN/release-process.md](i18n/zh-CN/release-process.md)
+- [i18n/zh-CN/troubleshooting.md](i18n/zh-CN/troubleshooting.md)
+- [i18n/zh-CN/network-deployment.md](i18n/zh-CN/network-deployment.md)
+- [i18n/zh-CN/mattermost-setup.md](i18n/zh-CN/mattermost-setup.md)
+- [i18n/zh-CN/nextcloud-talk-setup.md](i18n/zh-CN/nextcloud-talk-setup.md)
-### 4) 安全设计与提案
+### 4) 安全设计与治理
-- [security/README.md](security/README.md)
-- [agnostic-security.md](agnostic-security.md)
-- [frictionless-security.md](frictionless-security.md)
-- [sandboxing.md](sandboxing.md)
-- [resource-limits.md](resource-limits.md)
-- [audit-logging.md](audit-logging.md)
-- [security-roadmap.md](security-roadmap.md)
+- [docs/i18n/zh-CN/README.md](i18n/zh-CN/README.md)
+- [i18n/zh-CN/agnostic-security.md](i18n/zh-CN/agnostic-security.md)
+- [i18n/zh-CN/frictionless-security.md](i18n/zh-CN/frictionless-security.md)
+- [i18n/zh-CN/sandboxing.md](i18n/zh-CN/sandboxing.md)
+- [i18n/zh-CN/resource-limits.md](i18n/zh-CN/resource-limits.md)
+- [i18n/zh-CN/audit-logging.md](i18n/zh-CN/audit-logging.md)
+- [i18n/zh-CN/audit-event-schema.md](i18n/zh-CN/audit-event-schema.md)
+- [i18n/zh-CN/security-roadmap.md](i18n/zh-CN/security-roadmap.md)
### 5) 硬件与外设
-- [hardware/README.md](hardware/README.md)
-- [hardware-peripherals-design.md](hardware-peripherals-design.md)
-- [adding-boards-and-tools.md](adding-boards-and-tools.md)
-- [nucleo-setup.md](nucleo-setup.md)
-- [arduino-uno-q-setup.md](arduino-uno-q-setup.md)
-- [datasheets/nucleo-f401re.md](datasheets/nucleo-f401re.md)
-- [datasheets/arduino-uno.md](datasheets/arduino-uno.md)
-- [datasheets/esp32.md](datasheets/esp32.md)
+- [docs/i18n/zh-CN/README.md](i18n/zh-CN/README.md)
+- [i18n/zh-CN/hardware-peripherals-design.md](i18n/zh-CN/hardware-peripherals-design.md)
+- [i18n/zh-CN/adding-boards-and-tools.md](i18n/zh-CN/adding-boards-and-tools.md)
+- [i18n/zh-CN/nucleo-setup.md](i18n/zh-CN/nucleo-setup.md)
+- [i18n/zh-CN/arduino-uno-q-setup.md](i18n/zh-CN/arduino-uno-q-setup.md)
+- [datasheets/README.md](datasheets/README.md)
### 6) 贡献与 CI
-- [contributing/README.md](contributing/README.md)
+- [docs/i18n/zh-CN/README.md](i18n/zh-CN/README.md)
- [../CONTRIBUTING.md](../CONTRIBUTING.md)
-- [pr-workflow.md](pr-workflow.md)
-- [reviewer-playbook.md](reviewer-playbook.md)
-- [ci-map.md](ci-map.md)
-- [actions-source-policy.md](actions-source-policy.md)
+- [i18n/zh-CN/pr-workflow.md](i18n/zh-CN/pr-workflow.md)
+- [i18n/zh-CN/reviewer-playbook.md](i18n/zh-CN/reviewer-playbook.md)
+- [i18n/zh-CN/ci-map.md](i18n/zh-CN/ci-map.md)
+- [i18n/zh-CN/actions-source-policy.md](i18n/zh-CN/actions-source-policy.md)
### 7) 项目状态与快照
-- [project/README.md](project/README.md)
-- [project-triage-snapshot-2026-02-18.md](project-triage-snapshot-2026-02-18.md)
-- [docs-inventory.md](docs-inventory.md)
+- [docs/i18n/zh-CN/README.md](i18n/zh-CN/README.md)
+- [i18n/zh-CN/project-triage-snapshot-2026-02-18.md](i18n/zh-CN/project-triage-snapshot-2026-02-18.md)
+- [i18n/zh-CN/docs-audit-2026-02-24.md](i18n/zh-CN/docs-audit-2026-02-24.md)
+- [i18n/zh-CN/docs-inventory.md](i18n/zh-CN/docs-inventory.md)
diff --git a/docs/actions-source-policy.md b/docs/actions-source-policy.md
index e49325673..675d1b971 100644
--- a/docs/actions-source-policy.md
+++ b/docs/actions-source-policy.md
@@ -23,7 +23,7 @@ Selected allowlist patterns:
- `softprops/action-gh-release@*`
- `sigstore/cosign-installer@*`
- `Checkmarx/vorpal-reviewdog-github-action@*`
-- `Swatinem/rust-cache@*`
+- `useblacksmith/*` (Blacksmith self-hosted runner infrastructure)
## Change Control Export
@@ -78,11 +78,13 @@ Latest sweep notes:
- 2026-02-21: Added manual Vorpal reviewdog workflow for targeted secure-coding checks on supported file types
- Added allowlist pattern: `Checkmarx/vorpal-reviewdog-github-action@*`
- Workflow uses pinned source: `Checkmarx/vorpal-reviewdog-github-action@8cc292f337a2f1dea581b4f4bd73852e7becb50d` (v1.2.0)
-- 2026-02-26: Standardized runner/action sources for cache and Docker build paths
- - Added allowlist pattern: `Swatinem/rust-cache@*`
- - Docker build jobs use `docker/setup-buildx-action` and `docker/build-push-action`
+- 2026-02-17: Rust dependency cache migrated from `Swatinem/rust-cache` to `useblacksmith/rust-cache`
+ - No new allowlist pattern required (`useblacksmith/*` already allowlisted)
- 2026-02-16: Hidden dependency discovered in `release.yml`: `sigstore/cosign-installer@...`
- Added allowlist pattern: `sigstore/cosign-installer@*`
+- 2026-02-16: Blacksmith migration blocked workflow execution
+ - Added allowlist pattern: `useblacksmith/*` for self-hosted runner infrastructure
+ - Actions: `useblacksmith/setup-docker-builder@v1`, `useblacksmith/build-push-action@v2`
- 2026-02-17: Security audit reproducibility/freshness balance update
- Added allowlist pattern: `rustsec/audit-check@*`
- Replaced inline `cargo install cargo-audit` execution with pinned `rustsec/audit-check@69366f33c96575abad1ee0dba8212993eecbe998` in `security.yml`
diff --git a/docs/channels-reference.md b/docs/channels-reference.md
index bf5953436..aaa1614ba 100644
--- a/docs/channels-reference.md
+++ b/docs/channels-reference.md
@@ -37,22 +37,46 @@ cli = true
Each channel is enabled by creating its sub-table (for example, `[channels_config.telegram]`).
-## In-Chat Runtime Model Switching (Telegram / Discord)
+One ZeroClaw runtime can serve multiple channels at once: if you configure several
+channel sub-tables, `zeroclaw channel start` launches all of them in the same process.
+Channel startup is best-effort: a single channel init failure is reported and skipped,
+while remaining channels continue running.
-When running `zeroclaw channel start` (or daemon mode), Telegram and Discord now support sender-scoped runtime switching:
+## In-Chat Runtime Commands
+When running `zeroclaw channel start` (or daemon mode), runtime commands include:
+
+Telegram/Discord sender-scoped model routing:
- `/models` — show available providers and current selection
- `/models ` — switch provider for the current sender session
- `/model` — show current model and cached model IDs (if available)
- `/model ` — switch model for the current sender session
- `/new` — clear conversation history and start a fresh session
+Supervised tool approvals (all non-CLI channels):
+- `/approve-request ` — create a pending approval request
+- `/approve-confirm ` — confirm pending request (same sender + same chat/channel only)
+- `/approve-pending` — list pending requests for your current sender+chat/channel scope
+- `/approve ` — direct one-step approve + persist (`autonomy.auto_approve`, compatibility path)
+- `/unapprove ` — revoke and remove persisted approval
+- `/approvals` — inspect runtime grants, persisted approval lists, and excluded tools
+
Notes:
- Switching provider or model clears only that sender's in-memory conversation history to avoid cross-model context contamination.
- `/new` clears the sender's conversation history without changing provider or model selection.
- Model cache previews come from `zeroclaw models refresh --provider `.
- These are runtime chat commands, not CLI subcommands.
+- Natural-language approval intents are supported with strict parsing and policy control:
+ - `direct` mode (default): `授权工具 shell` grants immediately.
+ - `request_confirm` mode: `授权工具 shell` creates pending request, then confirm with request ID.
+ - `disabled` mode: approval-management must use slash commands.
+- You can override natural-language approval mode per channel via `[autonomy].non_cli_natural_language_approval_mode_by_channel`.
+- Approval commands are intercepted before LLM execution, so the model cannot self-escalate permissions through tool calls.
+- You can restrict who can use approval-management commands via `[autonomy].non_cli_approval_approvers`.
+- Configure natural-language approval mode via `[autonomy].non_cli_natural_language_approval_mode`.
+- `autonomy.non_cli_excluded_tools` is reloaded from `config.toml` at runtime; `/approvals` shows the currently effective list.
+- Each incoming message injects a runtime tool-availability snapshot into the system prompt, derived from the same exclusion policy used by execution.
## Inbound Image Marker Protocol
@@ -76,23 +100,23 @@ Operational notes:
Matrix and Lark support are controlled at compile time.
-- Default builds are lean (`default = []`) and do not include Matrix/Lark.
-- Typical local check with only hardware support:
+- Default builds include Lark/Feishu (`default = ["channel-lark"]`), while Matrix remains opt-in.
+- For a lean local build without Matrix/Lark:
```bash
-cargo check --features hardware
+cargo check --no-default-features --features hardware
```
-- Enable Matrix explicitly when needed:
+- Enable Matrix explicitly in a custom feature set:
```bash
-cargo check --features hardware,channel-matrix
+cargo check --no-default-features --features hardware,channel-matrix
```
-- Enable Lark explicitly when needed:
+- Enable Lark explicitly in a custom feature set:
```bash
-cargo check --features hardware,channel-lark
+cargo check --no-default-features --features hardware,channel-lark
```
If `[channels_config.matrix]`, `[channels_config.lark]`, or `[channels_config.feishu]` is present but the corresponding feature is not compiled in, `zeroclaw channel list`, `zeroclaw channel doctor`, and `zeroclaw channel start` will report that the channel is intentionally skipped for this build.
@@ -142,6 +166,27 @@ Field names differ by channel:
- `allowed_contacts` (iMessage)
- `allowed_pubkeys` (Nostr)
+### Group-Chat Trigger Policy (Telegram/Discord/Slack/Mattermost/Lark/Feishu)
+
+These channels support an explicit `group_reply` policy:
+
+- `mode = "all_messages"`: reply to all group messages (subject to channel allowlist checks).
+- `mode = "mention_only"`: in groups, require explicit bot mention.
+- `allowed_sender_ids`: sender IDs that bypass mention gating in groups.
+
+Important behavior:
+
+- `allowed_sender_ids` only bypasses mention gating.
+- Sender allowlists (`allowed_users`) are still enforced first.
+
+Example shape:
+
+```toml
+[channels_config.telegram.group_reply]
+mode = "mention_only" # all_messages | mention_only
+allowed_sender_ids = ["123456789", "987"] # optional; "*" allowed
+```
+
---
## 4. Per-Channel Config Examples
@@ -154,8 +199,12 @@ bot_token = "123456:telegram-token"
allowed_users = ["*"]
stream_mode = "off" # optional: off | partial
draft_update_interval_ms = 1000 # optional: edit throttle for partial streaming
-mention_only = false # optional: require @mention in groups
+mention_only = false # legacy fallback; used when group_reply.mode is not set
interrupt_on_new_message = false # optional: cancel in-flight same-sender same-chat request
+
+[channels_config.telegram.group_reply]
+mode = "all_messages" # optional: all_messages | mention_only
+allowed_sender_ids = [] # optional: sender IDs that bypass mention gate
```
Telegram notes:
@@ -171,7 +220,11 @@ bot_token = "discord-bot-token"
guild_id = "123456789012345678" # optional
allowed_users = ["*"]
listen_to_bots = false
-mention_only = false
+mention_only = false # legacy fallback; used when group_reply.mode is not set
+
+[channels_config.discord.group_reply]
+mode = "all_messages" # optional: all_messages | mention_only
+allowed_sender_ids = [] # optional: sender IDs that bypass mention gate
```
### 4.3 Slack
@@ -182,6 +235,10 @@ bot_token = "xoxb-..."
app_token = "xapp-..." # optional
channel_id = "C1234567890" # optional: single channel; omit or "*" for all accessible channels
allowed_users = ["*"]
+
+[channels_config.slack.group_reply]
+mode = "all_messages" # optional: all_messages | mention_only
+allowed_sender_ids = [] # optional: sender IDs that bypass mention gate
```
Slack listen behavior:
@@ -197,6 +254,11 @@ url = "https://mm.example.com"
bot_token = "mattermost-token"
channel_id = "channel-id" # required for listening
allowed_users = ["*"]
+mention_only = false # legacy fallback; used when group_reply.mode is not set
+
+[channels_config.mattermost.group_reply]
+mode = "all_messages" # optional: all_messages | mention_only
+allowed_sender_ids = [] # optional: sender IDs that bypass mention gate
```
### 4.5 Matrix
@@ -209,6 +271,7 @@ user_id = "@zeroclaw:matrix.example.com" # optional, recommended for E2EE
device_id = "DEVICEID123" # optional, recommended for E2EE
room_id = "!room:matrix.example.com" # or room alias (#ops:matrix.example.com)
allowed_users = ["*"]
+mention_only = false # optional: when true, only DM / @mention / reply-to-bot
```
See [Matrix E2EE Guide](./matrix-e2ee-guide.md) for encrypted-room troubleshooting.
@@ -308,34 +371,44 @@ verify_tls = true
```toml
[channels_config.lark]
-app_id = "cli_xxx"
-app_secret = "xxx"
+app_id = "your_lark_app_id"
+app_secret = "your_lark_app_secret"
encrypt_key = "" # optional
verification_token = "" # optional
allowed_users = ["*"]
-mention_only = false # optional: require @mention in groups (DMs always allowed)
+mention_only = false # legacy fallback; used when group_reply.mode is not set
use_feishu = false
receive_mode = "websocket" # or "webhook"
port = 8081 # required for webhook mode
+
+[channels_config.lark.group_reply]
+mode = "all_messages" # optional: all_messages | mention_only
+allowed_sender_ids = [] # optional: sender open_ids that bypass mention gate
```
### 4.12 Feishu
```toml
[channels_config.feishu]
-app_id = "cli_xxx"
-app_secret = "xxx"
+app_id = "your_lark_app_id"
+app_secret = "your_lark_app_secret"
encrypt_key = "" # optional
verification_token = "" # optional
allowed_users = ["*"]
receive_mode = "websocket" # or "webhook"
port = 8081 # required for webhook mode
+
+[channels_config.feishu.group_reply]
+mode = "all_messages" # optional: all_messages | mention_only
+allowed_sender_ids = [] # optional: sender open_ids that bypass mention gate
```
Migration note:
- Legacy config `[channels_config.lark] use_feishu = true` is still supported for backward compatibility.
- Prefer `[channels_config.feishu]` for new setups.
+- Inbound `image` messages are converted to multimodal markers (`[IMAGE:data:image/...;base64,...]`).
+- If image download fails, ZeroClaw forwards fallback text instead of silently dropping the message.
### 4.13 Nostr
@@ -385,8 +458,18 @@ allowed_users = ["*"]
app_id = "qq-app-id"
app_secret = "qq-app-secret"
allowed_users = ["*"]
+receive_mode = "webhook" # webhook (default) or websocket (legacy fallback)
+environment = "production" # production (default) or sandbox
```
+Notes:
+
+- `webhook` mode is now the default and serves inbound callbacks at `POST /qq`.
+- Set `environment = "sandbox"` to target `https://sandbox.api.sgroup.qq.com` for unpublished bot testing.
+- QQ validation challenge payloads (`op = 13`) are auto-signed using `app_secret`.
+- `X-Bot-Appid` is checked when present and must match `app_id`.
+- Set `receive_mode = "websocket"` to keep the legacy gateway WS receive path.
+
### 4.16 Nextcloud Talk
```toml
diff --git a/docs/ci-map.md b/docs/ci-map.md
index b2badbfda..c23b1e17f 100644
--- a/docs/ci-map.md
+++ b/docs/ci-map.md
@@ -13,6 +13,8 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u
- `.github/workflows/ci-run.yml` (`CI`)
- Purpose: Rust validation (`cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D clippy::correctness`, strict delta lint gate on changed Rust lines, `test`, release build smoke) + docs quality checks when docs change (`markdownlint` blocks only issues on changed lines; link check scans only links added on changed lines)
- Additional behavior: for Rust-impacting PRs and pushes, `CI Required Gate` requires `lint` + `test` + `build` (no PR build-only bypass)
+ - Additional behavior: rust-cache is partitioned per job role via `prefix-key` to reduce cache churn across lint/test/build/flake-probe lanes
+ - Additional behavior: emits `test-flake-probe` artifact from single-retry probe when tests fail; optional blocking can be enabled with repository variable `CI_BLOCK_ON_FLAKE_SUSPECTED=true`
- Additional behavior: PRs that change `.github/workflows/**` require at least one approving review from a login in `WORKFLOW_OWNER_LOGINS` (repository variable fallback: `theonlyhennygod,willsarg`)
- Additional behavior: PRs that change root license files (`LICENSE-APACHE`, `LICENSE-MIT`) must be authored by `willsarg`
- Additional behavior: lint gates run before `test`/`build`; when lint/docs gates fail on PRs, CI posts an actionable feedback comment with failing gate names and local fix commands
@@ -29,18 +31,39 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u
- `.github/workflows/pub-docker-img.yml` (`Docker`)
- Purpose: PR Docker smoke check on `dev`/`main` PRs and publish images on tag pushes (`v*`) only
+ - Additional behavior: `ghcr_publish_contract_guard.py` enforces GHCR publish contract from `.github/release/ghcr-tag-policy.json` (`vX.Y.Z`, `sha-<12>`, `latest` digest parity + rollback mapping evidence)
+ - Additional behavior: `ghcr_vulnerability_gate.py` enforces policy-driven Trivy gate + parity checks from `.github/release/ghcr-vulnerability-policy.json` and emits `ghcr-vulnerability-gate` audit evidence
+- `.github/workflows/feature-matrix.yml` (`Feature Matrix`)
+ - Purpose: compile-time matrix validation for `default`, `whatsapp-web`, `browser-native`, and `nightly-all-features` lanes
+ - Additional behavior: each lane emits machine-readable result artifacts; summary lane aggregates owner routing from `.github/release/nightly-owner-routing.json`
+ - Additional behavior: supports `compile` (merge-gate) and `nightly` (integration-oriented) profiles with bounded retry policy and trend snapshot artifact (`nightly-history.json`)
+ - Additional behavior: required-check mapping is anchored to stable job name `Feature Matrix Summary`; lane jobs stay informational
+- `.github/workflows/nightly-all-features.yml` (`Nightly All-Features`)
+ - Purpose: legacy/dev-only nightly template; primary nightly signal is emitted by `feature-matrix.yml` nightly profile
+ - Additional behavior: owner routing + escalation policy is documented in `docs/operations/nightly-all-features-runbook.md`
- `.github/workflows/sec-audit.yml` (`Security Audit`)
- - Purpose: dependency advisories (`rustsec/audit-check`, pinned SHA) and policy/license checks (`cargo deny`)
+ - Purpose: dependency advisories (`rustsec/audit-check`, pinned SHA), policy/license checks (`cargo deny`), gitleaks-based secrets governance (allowlist policy metadata + expiry guard), and SBOM snapshot artifacts (`CycloneDX` + `SPDX`)
- `.github/workflows/sec-codeql.yml` (`CodeQL Analysis`)
- - Purpose: scheduled/manual static analysis for security findings
+ - Purpose: static analysis for security findings on PR/push (Rust/codeql paths) plus scheduled/manual runs
+- `.github/workflows/ci-connectivity-probes.yml` (`Connectivity Probes`)
+ - Purpose: legacy manual wrapper for provider endpoint probe diagnostics (delegates to config-driven probe engine)
+ - Output: uploads `connectivity-report.json` and `connectivity-summary.md`
+ - Usage: prefer `CI Provider Connectivity` for scheduled + PR/push coverage
+- `.github/workflows/ci-change-audit.yml` (`CI/CD Change Audit`)
+ - Purpose: machine-auditable diff report for CI/security workflow changes (line churn, new `uses:` references, unpinned action-policy violations, pipe-to-shell policy violations, broad `permissions: write-all` grants, new `pull_request_target` trigger introductions, new secret references)
+- `.github/workflows/ci-provider-connectivity.yml` (`CI Provider Connectivity`)
+ - Purpose: scheduled/manual/provider-list probe matrix with downloadable JSON/Markdown artifacts for provider endpoint reachability
+- `.github/workflows/ci-reproducible-build.yml` (`CI Reproducible Build`)
+ - Purpose: deterministic build drift probe (double clean-build hash comparison) with structured artifacts
+- `.github/workflows/ci-supply-chain-provenance.yml` (`CI Supply Chain Provenance`)
+ - Purpose: release-fast artifact provenance statement generation + keyless signature bundle for supply-chain traceability
+- `.github/workflows/ci-rollback.yml` (`CI Rollback Guard`)
+ - Purpose: deterministic rollback plan generation with guarded execute mode, marker-tag option, rollback audit artifacts, and dispatch contract for canary-abort auto-triggering
- `.github/workflows/sec-vorpal-reviewdog.yml` (`Sec Vorpal Reviewdog`)
- Purpose: manual secure-coding feedback scan for supported non-Rust files (`.py`, `.js`, `.jsx`, `.ts`, `.tsx`) using reviewdog annotations
- Noise control: excludes common test/fixture paths and test file patterns by default (`include_tests=false`)
- `.github/workflows/pub-release.yml` (`Release`)
- Purpose: build release artifacts in verification mode (manual/scheduled) and publish GitHub releases on tag push or manual publish mode
-- `.github/workflows/pub-homebrew-core.yml` (`Pub Homebrew Core`)
- - Purpose: manual, bot-owned Homebrew core formula bump PR flow for tagged releases
- - Guardrail: release tag must match `Cargo.toml` version
- `.github/workflows/pr-label-policy-check.yml` (`Label Policy Sanity`)
- Purpose: validate shared contributor-tier policy in `.github/label-policy.json` and ensure label workflows consume that policy
- `.github/workflows/test-rust-build.yml` (`Rust Reusable Job`)
@@ -75,10 +98,11 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u
## Trigger Map
-- `CI`: push to `dev` and `main`, PRs to `dev` and `main`
+- `CI`: push to `dev` and `main`, PRs to `dev` and `main`, merge queue `merge_group` for `dev`/`main`
- `Docker`: tag push (`v*`) for publish, matching PRs to `dev`/`main` for smoke build, manual dispatch for smoke only
+- `Feature Matrix`: PR/push on Rust + workflow paths, merge queue, weekly schedule, manual dispatch
+- `Nightly All-Features`: daily schedule and manual dispatch
- `Release`: tag push (`v*`), weekly schedule (verification-only), manual dispatch (verification or publish)
-- `Pub Homebrew Core`: manual dispatch only
- `Security Audit`: push to `dev` and `main`, PRs to `dev` and `main`, weekly schedule
- `Sec Vorpal Reviewdog`: manual dispatch only
- `Workflow Sanity`: PR/push when `.github/workflows/**`, `.github/*.yml`, or `.github/*.yaml` change
@@ -95,29 +119,43 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u
1. `CI Required Gate` failing: start with `.github/workflows/ci-run.yml`.
2. Docker failures on PRs: inspect `.github/workflows/pub-docker-img.yml` `pr-smoke` job.
+ - For tag-publish failures, inspect `ghcr-publish-contract.json` / `audit-event-ghcr-publish-contract.json`, `ghcr-vulnerability-gate.json` / `audit-event-ghcr-vulnerability-gate.json`, and Trivy artifacts from `pub-docker-img.yml`.
3. Release failures (tag/manual/scheduled): inspect `.github/workflows/pub-release.yml` and the `prepare` job outputs.
-4. Homebrew formula publish failures: inspect `.github/workflows/pub-homebrew-core.yml` summary output and bot token/fork variables.
-5. Security failures: inspect `.github/workflows/sec-audit.yml` and `deny.toml`.
-6. Workflow syntax/lint failures: inspect `.github/workflows/workflow-sanity.yml`.
-7. PR intake failures: inspect `.github/workflows/pr-intake-checks.yml` sticky comment and run logs.
-8. Label policy parity failures: inspect `.github/workflows/pr-label-policy-check.yml`.
-9. Docs failures in CI: inspect `docs-quality` job logs in `.github/workflows/ci-run.yml`.
-10. Strict delta lint failures in CI: inspect `lint-strict-delta` job logs and compare with `BASE_SHA` diff scope.
+4. Security failures: inspect `.github/workflows/sec-audit.yml` and `deny.toml`.
+5. Workflow syntax/lint failures: inspect `.github/workflows/workflow-sanity.yml`.
+6. PR intake failures: inspect `.github/workflows/pr-intake-checks.yml` sticky comment and run logs.
+7. Label policy parity failures: inspect `.github/workflows/pr-label-policy-check.yml`.
+8. Docs failures in CI: inspect `docs-quality` job logs in `.github/workflows/ci-run.yml`.
+9. Strict delta lint failures in CI: inspect `lint-strict-delta` job logs and compare with `BASE_SHA` diff scope.
## Maintenance Rules
- Keep merge-blocking checks deterministic and reproducible (`--locked` where applicable).
+- Keep merge-queue compatibility explicit by supporting `merge_group` on required workflows (`ci-run`, `sec-audit`, and `sec-codeql`).
+- Keep PRs mapped to Linear issue keys (`RMN-*`/`CDV-*`/`COM-*`) via PR intake checks.
+- Keep `deny.toml` advisory ignore entries in object form with explicit reasons (enforced by `deny_policy_guard.py`).
+- Keep deny ignore governance metadata current in `.github/security/deny-ignore-governance.json` (owner/reason/expiry/ticket enforced by `deny_policy_guard.py`).
+- Keep gitleaks allowlist governance metadata current in `.github/security/gitleaks-allowlist-governance.json` (owner/reason/expiry/ticket enforced by `secrets_governance_guard.py`).
+- Keep audit event schema + retention metadata aligned with `docs/audit-event-schema.md` (`emit_audit_event.py` envelope + workflow artifact policy).
+- Keep rollback operations guarded and reversible (`ci-rollback.yml` defaults to `dry-run`; `execute` is manual and policy-gated).
+- Keep canary policy thresholds and sample-size rules current in `.github/release/canary-policy.json`.
+- Keep GHCR tag taxonomy and immutability policy current in `.github/release/ghcr-tag-policy.json` and `docs/operations/ghcr-tag-policy.md`.
+- Keep GHCR vulnerability gate policy current in `.github/release/ghcr-vulnerability-policy.json` and `docs/operations/ghcr-vulnerability-policy.md`.
+- Keep pre-release stage transition policy + matrix coverage + transition audit semantics current in `.github/release/prerelease-stage-gates.json`.
+- Keep required check naming stable and documented in `docs/operations/required-check-mapping.md` before changing branch protection settings.
- Follow `docs/release-process.md` for verify-before-publish release cadence and tag discipline.
- Keep merge-blocking rust quality policy aligned across `.github/workflows/ci-run.yml`, `dev/ci.sh`, and `.githooks/pre-push` (`./scripts/ci/rust_quality_gate.sh` + `./scripts/ci/rust_strict_delta_gate.sh`).
- Use `./scripts/ci/rust_strict_delta_gate.sh` (or `./dev/ci.sh lint-delta`) as the incremental strict merge gate for changed Rust lines.
- Run full strict lint audits regularly via `./scripts/ci/rust_quality_gate.sh --strict` (for example through `./dev/ci.sh lint-strict`) and track cleanup in focused PRs.
- Keep docs markdown gating incremental via `./scripts/ci/docs_quality_gate.sh` (block changed-line issues, report baseline issues separately).
- Keep docs link gating incremental via `./scripts/ci/collect_changed_links.py` + lychee (check only links added on changed lines).
+- Keep docs deploy policy current in `.github/release/docs-deploy-policy.json`, `docs/operations/docs-deploy-policy.md`, and `docs/operations/docs-deploy-runbook.md`.
- Prefer explicit workflow permissions (least privilege).
- Keep Actions source policy restricted to approved allowlist patterns (see `docs/actions-source-policy.md`).
- Use path filters for expensive workflows when practical.
- Keep docs quality checks low-noise (incremental markdown + incremental added-link checks).
- Keep dependency update volume controlled (grouping + PR limits).
+- Install third-party CI tooling through repository-managed pinned installers with checksum verification (for example `scripts/ci/install_gitleaks.sh`, `scripts/ci/install_syft.sh`); avoid remote `curl | sh` patterns.
- Avoid mixing onboarding/community automation with merge-gating logic.
## Automation Side-Effect Controls
diff --git a/docs/commands-reference.md b/docs/commands-reference.md
index 78d264759..5622fdcf7 100644
--- a/docs/commands-reference.md
+++ b/docs/commands-reference.md
@@ -2,7 +2,7 @@
This reference is derived from the current CLI surface (`zeroclaw --help`).
-Last verified: **February 21, 2026**.
+Last verified: **February 25, 2026**.
## Top-Level Commands
@@ -61,9 +61,11 @@ Tip:
### `gateway` / `daemon`
-- `zeroclaw gateway [--host ] [--port ]`
+- `zeroclaw gateway [--host ] [--port ] [--new-pairing]`
- `zeroclaw daemon [--host ] [--port ]`
+`--new-pairing` clears all stored paired tokens and forces generation of a fresh pairing code on gateway startup.
+
### `estop`
- `zeroclaw estop` (engage `kill-all`)
@@ -123,6 +125,10 @@ Notes:
- `zeroclaw doctor traces [--limit ] [--event ] [--contains ]`
- `zeroclaw doctor traces --id `
+Provider connectivity matrix CI/local helper:
+
+- `python3 scripts/ci/provider_connectivity_matrix.py --binary target/release-fast/zeroclaw --contract .github/connectivity/probe-contract.json`
+
`doctor traces` reads runtime tool/model diagnostics from `observability.runtime_trace_path`.
### `channel`
@@ -134,13 +140,39 @@ Notes:
- `zeroclaw channel add `
- `zeroclaw channel remove `
-Runtime in-chat commands (Telegram/Discord while channel server is running):
+Runtime in-chat commands while channel server is running:
-- `/models`
-- `/models `
-- `/model`
-- `/model `
-- `/new`
+- Telegram/Discord sender-session routing:
+ - `/models`
+ - `/models `
+ - `/model`
+ - `/model `
+ - `/new`
+- Supervised tool approvals (all non-CLI channels):
+ - `/approve-request ` (create pending approval request)
+ - `/approve-confirm ` (confirm pending request; same sender + same chat/channel only)
+ - `/approve-pending` (list pending requests in current sender+chat/channel scope)
+ - `/approve ` (direct one-step grant + persist to `autonomy.auto_approve`, compatibility path)
+ - `/unapprove ` (revoke + remove from `autonomy.auto_approve`)
+ - `/approvals` (show runtime + persisted approval state)
+ - Natural-language approval behavior is controlled by `[autonomy].non_cli_natural_language_approval_mode`:
+ - `direct` (default): `授权工具 shell` / `approve tool shell` immediately grants
+ - `request_confirm`: natural-language approval creates pending request, then confirm with request ID
+ - `disabled`: natural-language approval commands are ignored (slash commands only)
+ - Optional per-channel override: `[autonomy].non_cli_natural_language_approval_mode_by_channel`
+
+Approval safety behavior:
+
+- Runtime approval commands are parsed and executed **before** LLM inference in the channel loop.
+- Pending requests are sender+chat/channel scoped and expire automatically.
+- Confirmation requires the same sender in the same chat/channel that created the request.
+- Once approved and persisted, the tool remains approved across restarts until revoked.
+- Optional policy gate: `[autonomy].non_cli_approval_approvers` can restrict who may execute approval-management commands.
+
+Startup behavior for multiple channels:
+- `zeroclaw channel start` starts all configured channels in one process.
+- If one channel fails initialization, other channels continue to start.
+- If all configured channels fail initialization, startup exits with an error.
Channel runtime also watches `config.toml` and hot-applies updates to:
- `default_provider`
@@ -162,7 +194,38 @@ Channel runtime also watches `config.toml` and hot-applies updates to:
- `zeroclaw skills install `
- `zeroclaw skills remove `
-`` accepts git remotes (`https://...`, `http://...`, `ssh://...`, and `git@host:owner/repo.git`) or a local filesystem path.
+`` accepts:
+
+| Format | Example | Notes |
+|---|---|---|
+| **ClawhHub profile URL** | `https://clawhub.ai/steipete/summarize` | Auto-detected by domain; downloads zip from ClawhHub API |
+| **ClawhHub short prefix** | `clawhub:summarize` | Short form; slug is the skill name on ClawhHub |
+| **Direct zip URL** | `zip:https://example.com/skill.zip` | Any HTTPS URL returning a zip archive |
+| **Local zip file** | `/path/to/skill.zip` | Zip file already downloaded to local disk |
+| **Registry packages** | `namespace/name` or `namespace/name@version` | Fetched from the configured registry (default: ZeroMarket) |
+| **Git remotes** | `https://github.com/…`, `git@host:owner/repo.git` | Cloned with `git clone --depth 1` |
+| **Local filesystem paths** | `./my-skill` or `/abs/path/skill` | Directory copied and audited |
+
+**ClawhHub install examples:**
+
+```bash
+# Install by profile URL (slug extracted from last path segment)
+zeroclaw skill install https://clawhub.ai/steipete/summarize
+
+# Install using short prefix
+zeroclaw skill install clawhub:summarize
+
+# Install from a zip already downloaded locally
+zeroclaw skill install ~/Downloads/summarize-1.0.0.zip
+```
+
+If the ClawhHub API returns 429 (rate limit) or requires authentication, set `clawhub_token` in `[skills]` config (see [config reference](config-reference.md#skills)).
+
+**Zip-based install behavior:**
+- If the zip contains `_meta.json` (OpenClaw convention), name/version/author are read from it.
+- A minimal `SKILL.toml` is written automatically if neither `SKILL.toml` nor `SKILL.md` is present in the zip.
+
+Registry packages are installed to `~/.zeroclaw/workspace/skills//`.
`skills install` always runs a built-in static security audit before the skill is accepted. The audit blocks:
- symlinks inside the skill package
@@ -170,6 +233,8 @@ Channel runtime also watches `config.toml` and hot-applies updates to:
- high-risk command snippets (for example pipe-to-shell payloads)
- markdown links that escape the skill root, point to remote markdown, or target script files
+> **Note:** The security audit applies to directory-based installs (local paths, git remotes). Zip-based installs (ClawhHub, direct zip URLs, local zip files) perform path-traversal safety checks during extraction but do not run the full static audit — review zip contents manually for untrusted sources.
+
Use `skills audit` to manually validate a candidate skill directory (or an installed skill by name) before sharing it.
Skill manifests (`SKILL.toml`) support `prompts` and `[[tools]]`; both are injected into the agent system prompt at runtime, so the model can follow skill instructions without manually reading skill files.
diff --git a/docs/config-reference.md b/docs/config-reference.md
index 77758fd01..4fa30452c 100644
--- a/docs/config-reference.md
+++ b/docs/config-reference.md
@@ -2,7 +2,7 @@
This is a high-signal reference for common config sections and defaults.
-Last verified: **February 21, 2026**.
+Last verified: **February 25, 2026**.
Config path resolution at startup:
@@ -23,8 +23,17 @@ Schema export command:
| Key | Default | Notes |
|---|---|---|
| `default_provider` | `openrouter` | provider ID or alias |
+| `provider_api` | unset | Optional API mode for `custom:` providers: `openai-chat-completions` or `openai-responses` |
| `default_model` | `anthropic/claude-sonnet-4-6` | model routed through selected provider |
| `default_temperature` | `0.7` | model temperature |
+| `model_support_vision` | unset (`None`) | Vision support override for active provider/model |
+
+Notes:
+
+- `model_support_vision = true` forces vision support on (e.g. Ollama running `llava`).
+- `model_support_vision = false` forces vision support off.
+- Unset keeps the provider's built-in default.
+- Environment override: `ZEROCLAW_MODEL_SUPPORT_VISION` or `MODEL_SUPPORT_VISION` (values: `true`/`false`/`1`/`0`/`yes`/`no`/`on`/`off`).
## `[observability]`
@@ -71,20 +80,24 @@ Operational note for container users:
- If your `config.toml` sets an explicit custom provider like `custom:https://.../v1`, a default `PROVIDER=openrouter` from Docker/container env will no longer replace it.
- Use `ZEROCLAW_PROVIDER` when you intentionally want runtime env to override a non-default configured provider.
+- For OpenAI-compatible Responses fallback transport:
+ - `ZEROCLAW_RESPONSES_WEBSOCKET=1` forces websocket-first mode (`wss://.../responses`) for compatible providers.
+ - `ZEROCLAW_RESPONSES_WEBSOCKET=0` forces HTTP-only mode.
+ - Unset = auto (websocket-first only when endpoint host is `api.openai.com`, then HTTP fallback if websocket fails).
## `[agent]`
| Key | Default | Purpose |
|---|---|---|
-| `compact_context` | `false` | When true: bootstrap_max_chars=6000, rag_chunk_limit=2. Use for 13B or smaller models |
-| `max_tool_iterations` | `10` | Maximum tool-call loop turns per user message across CLI, gateway, and channels |
+| `compact_context` | `true` | When true: bootstrap_max_chars=6000, rag_chunk_limit=2. Use for 13B or smaller models |
+| `max_tool_iterations` | `20` | Maximum tool-call loop turns per user message across CLI, gateway, and channels |
| `max_history_messages` | `50` | Maximum conversation history messages retained per session |
| `parallel_tools` | `false` | Enable parallel tool execution within a single iteration |
| `tool_dispatcher` | `auto` | Tool dispatch strategy |
Notes:
-- Setting `max_tool_iterations = 0` falls back to safe default `10`.
+- Setting `max_tool_iterations = 0` falls back to safe default `20`.
- If a channel message exceeds this value, the runtime returns: `Agent exceeded maximum tool iterations ()`.
- In CLI, gateway, and channel tool loops, multiple independent tool calls are executed concurrently by default when the pending calls do not require approval gating; result order remains stable.
- `parallel_tools` applies to the `Agent::turn()` API surface. It does not gate the runtime loop used by CLI, gateway, or channel handlers.
@@ -135,6 +148,97 @@ Notes:
- Corrupted/unreadable estop state falls back to fail-closed `kill_all`.
- Use CLI command `zeroclaw estop` to engage and `zeroclaw estop resume` to clear levels.
+## `[security.url_access]`
+
+| Key | Default | Purpose |
+|---|---|---|
+| `block_private_ip` | `true` | Block local/private/link-local/multicast addresses by default |
+| `allow_cidrs` | `[]` | CIDR ranges allowed to bypass private-IP blocking (`100.64.0.0/10`, `198.18.0.0/15`) |
+| `allow_domains` | `[]` | Domain patterns that bypass private-IP blocking before DNS checks (`internal.example`, `*.svc.local`) |
+| `allow_loopback` | `false` | Permit loopback targets (`localhost`, `127.0.0.1`, `::1`) |
+
+Notes:
+
+- This policy is shared by `browser_open`, `http_request`, and `web_fetch`.
+- Tool-level allowlists still apply. `allow_domains` / `allow_cidrs` only override private/local blocking.
+- DNS rebinding protection remains enabled: resolved local/private IPs are denied unless explicitly allowlisted.
+
+Example:
+
+```toml
+[security.url_access]
+block_private_ip = true
+allow_cidrs = ["100.64.0.0/10", "198.18.0.0/15"]
+allow_domains = ["internal.example", "*.svc.local"]
+allow_loopback = false
+```
+
+## `[security.syscall_anomaly]`
+
+| Key | Default | Purpose |
+|---|---|---|
+| `enabled` | `true` | Enable syscall anomaly detection over command output telemetry |
+| `strict_mode` | `false` | Emit anomaly when denied syscalls are observed even if in baseline |
+| `alert_on_unknown_syscall` | `true` | Alert on syscall names not present in baseline |
+| `max_denied_events_per_minute` | `5` | Threshold for denied-syscall spike alerts |
+| `max_total_events_per_minute` | `120` | Threshold for total syscall-event spike alerts |
+| `max_alerts_per_minute` | `30` | Global alert budget guardrail per rolling minute |
+| `alert_cooldown_secs` | `20` | Cooldown between identical anomaly alerts |
+| `log_path` | `syscall-anomalies.log` | JSONL anomaly log path |
+| `baseline_syscalls` | built-in allowlist | Expected syscall profile; unknown entries trigger alerts |
+
+Notes:
+
+- Detection consumes seccomp/audit hints from command `stdout`/`stderr`.
+- Numeric syscall IDs in Linux audit lines are mapped to common x86_64 names when available.
+- Alert budget and cooldown reduce duplicate/noisy events during repeated retries.
+- `max_denied_events_per_minute` must be less than or equal to `max_total_events_per_minute`.
+
+Example:
+
+```toml
+[security.syscall_anomaly]
+enabled = true
+strict_mode = false
+alert_on_unknown_syscall = true
+max_denied_events_per_minute = 5
+max_total_events_per_minute = 120
+max_alerts_per_minute = 30
+alert_cooldown_secs = 20
+log_path = "syscall-anomalies.log"
+baseline_syscalls = ["read", "write", "openat", "close", "execve", "futex"]
+```
+
+## `[security.perplexity_filter]`
+
+Lightweight, opt-in adversarial suffix filter that runs before provider calls in channel and gateway message pipelines.
+
+| Key | Default | Purpose |
+|---|---|---|
+| `enable_perplexity_filter` | `false` | Enable pre-LLM statistical suffix anomaly blocking |
+| `perplexity_threshold` | `18.0` | Character-class bigram perplexity threshold |
+| `suffix_window_chars` | `64` | Trailing character window used for anomaly scoring |
+| `min_prompt_chars` | `32` | Minimum prompt length before filter is evaluated |
+| `symbol_ratio_threshold` | `0.20` | Minimum punctuation ratio in suffix window for blocking |
+
+Notes:
+
+- This filter is disabled by default to preserve baseline latency/behavior.
+- The detector combines character-class perplexity with GCG-like token heuristics.
+- Inputs are blocked only when anomaly conditions are met; normal natural-language prompts pass.
+- Typical per-message overhead is designed to stay under `50ms` in debug-safe local tests and substantially lower in release builds.
+
+Example:
+
+```toml
+[security.perplexity_filter]
+enable_perplexity_filter = true
+perplexity_threshold = 16.5
+suffix_window_chars = 72
+min_prompt_chars = 40
+symbol_ratio_threshold = 0.25
+```
+
## `[agents.]`
Delegate sub-agent configurations. Each key under `[agents]` defines a named sub-agent that the primary agent can delegate to.
@@ -173,10 +277,52 @@ model = "qwen2.5-coder:32b"
temperature = 0.2
```
+## `[research]`
+
+Research phase allows the agent to gather information through tools before generating the main response.
+
+| Key | Default | Purpose |
+|---|---|---|
+| `enabled` | `false` | Enable research phase |
+| `trigger` | `never` | Research trigger strategy: `never`, `always`, `keywords`, `length`, `question` |
+| `keywords` | `["find", "search", "check", "investigate"]` | Keywords that trigger research (when trigger = `keywords`) |
+| `min_message_length` | `50` | Minimum message length to trigger research (when trigger = `length`) |
+| `max_iterations` | `5` | Maximum tool calls during research phase |
+| `show_progress` | `true` | Show research progress to user |
+
+Notes:
+
+- Research phase is **disabled by default** (`trigger = never`).
+- When enabled, the agent first gathers facts through tools (grep, file_read, shell, memory search), then responds using the collected context.
+- Research runs before the main agent turn and does not count toward `agent.max_tool_iterations`.
+- Trigger strategies:
+ - `never` — research disabled (default)
+ - `always` — research on every user message
+ - `keywords` — research when message contains any keyword from the list
+ - `length` — research when message length exceeds `min_message_length`
+ - `question` — research when message contains '?'
+
+Example:
+
+```toml
+[research]
+enabled = true
+trigger = "keywords"
+keywords = ["find", "show", "check", "how many"]
+max_iterations = 3
+show_progress = true
+```
+
+The agent will research the codebase before responding to queries like:
+- "Find all TODO in src/"
+- "Show contents of main.rs"
+- "How many files in the project?"
+
## `[runtime]`
| Key | Default | Purpose |
|---|---|---|
+| `kind` | `native` | Runtime backend: `native`, `docker`, or `wasm` |
| `reasoning_enabled` | unset (`None`) | Global reasoning/thinking override for providers that support explicit controls |
Notes:
@@ -184,6 +330,65 @@ Notes:
- `reasoning_enabled = false` explicitly disables provider-side reasoning for supported providers (currently `ollama`, via request field `think: false`).
- `reasoning_enabled = true` explicitly requests reasoning for supported providers (`think: true` on `ollama`).
- Unset keeps provider defaults.
+- Deprecated compatibility alias: `runtime.reasoning_level` is still accepted but should be migrated to `provider.reasoning_level`.
+- `runtime.kind = "wasm"` enables capability-bounded module execution and disables shell/process style execution.
+
+### `[runtime.wasm]`
+
+| Key | Default | Purpose |
+|---|---|---|
+| `tools_dir` | `"tools/wasm"` | Workspace-relative directory containing `.wasm` modules |
+| `fuel_limit` | `1000000` | Instruction budget per module invocation |
+| `memory_limit_mb` | `64` | Per-module memory cap (MB) |
+| `max_module_size_mb` | `50` | Maximum allowed `.wasm` file size (MB) |
+| `allow_workspace_read` | `false` | Allow WASM host calls to read workspace files (future-facing) |
+| `allow_workspace_write` | `false` | Allow WASM host calls to write workspace files (future-facing) |
+| `allowed_hosts` | `[]` | Explicit network host allowlist for WASM host calls (future-facing) |
+
+Notes:
+
+- `allowed_hosts` entries must be normalized `host` or `host:port` strings; wildcards, schemes, and paths are rejected when `runtime.wasm.security.strict_host_validation = true`.
+- Invocation-time capability overrides are controlled by `runtime.wasm.security.capability_escalation_mode`:
+ - `deny` (default): reject escalation above runtime baseline.
+ - `clamp`: reduce requested capabilities to baseline.
+
+### `[runtime.wasm.security]`
+
+| Key | Default | Purpose |
+|---|---|---|
+| `require_workspace_relative_tools_dir` | `true` | Require `runtime.wasm.tools_dir` to be workspace-relative and reject `..` traversal |
+| `reject_symlink_modules` | `true` | Block symlinked `.wasm` module files during execution |
+| `reject_symlink_tools_dir` | `true` | Block execution when `runtime.wasm.tools_dir` is itself a symlink |
+| `strict_host_validation` | `true` | Fail config/invocation on invalid host entries instead of dropping them |
+| `capability_escalation_mode` | `"deny"` | Escalation policy: `deny` or `clamp` |
+| `module_hash_policy` | `"warn"` | Module integrity policy: `disabled`, `warn`, or `enforce` |
+| `module_sha256` | `{}` | Optional map of module names to pinned SHA-256 digests |
+
+Notes:
+
+- `module_sha256` keys must match module names (without `.wasm`) and use `[A-Za-z0-9_-]` only.
+- `module_sha256` values must be 64-character hexadecimal SHA-256 strings.
+- `module_hash_policy = "warn"` allows execution but logs missing/mismatched digests.
+- `module_hash_policy = "enforce"` blocks execution on missing/mismatched digests and requires at least one pin.
+
+WASM profile templates:
+
+- `dev/config.wasm.dev.toml`
+- `dev/config.wasm.staging.toml`
+- `dev/config.wasm.prod.toml`
+
+## `[provider]`
+
+| Key | Default | Purpose |
+|---|---|---|
+| `reasoning_level` | unset (`None`) | Reasoning effort/level override for providers that support explicit levels (currently OpenAI Codex `/responses`) |
+
+Notes:
+
+- Supported values: `minimal`, `low`, `medium`, `high`, `xhigh` (case-insensitive).
+- When set, overrides `ZEROCLAW_CODEX_REASONING_EFFORT` for OpenAI Codex requests.
+- Unset falls back to `ZEROCLAW_CODEX_REASONING_EFFORT` if present, otherwise defaults to `xhigh`.
+- If both `provider.reasoning_level` and deprecated `runtime.reasoning_level` are set, provider-level value wins.
## `[skills]`
@@ -192,6 +397,7 @@ Notes:
| `open_skills_enabled` | `false` | Opt-in loading/sync of community `open-skills` repository |
| `open_skills_dir` | unset | Optional local path for `open-skills` (defaults to `$HOME/open-skills` when enabled) |
| `prompt_injection_mode` | `full` | Skill prompt verbosity: `full` (inline instructions/tools) or `compact` (name/description/location only) |
+| `clawhub_token` | unset | Optional Bearer token for authenticated ClawhHub skill downloads |
Notes:
@@ -203,6 +409,14 @@ Notes:
- Precedence for enable flag: `ZEROCLAW_OPEN_SKILLS_ENABLED` → `skills.open_skills_enabled` in `config.toml` → default `false`.
- `prompt_injection_mode = "compact"` is recommended on low-context local models to reduce startup prompt size while keeping skill files available on demand.
- Skill loading and `zeroclaw skills install` both apply a static security audit. Skills that contain symlinks, script-like files, high-risk shell payload snippets, or unsafe markdown link traversal are rejected.
+- `clawhub_token` is sent as `Authorization: Bearer ` when downloading from ClawhHub. Obtain a token from [https://clawhub.ai](https://clawhub.ai) after signing in. Required if the API returns 429 (rate-limited) or 401 (unauthorized) for anonymous requests.
+
+**ClawhHub token example:**
+
+```toml
+[skills]
+clawhub_token = "your-token-here"
+```
## `[composio]`
@@ -271,8 +485,8 @@ Notes:
| Key | Default | Purpose |
|---|---|---|
-| `enabled` | `false` | Enable `browser_open` tool (opens URLs in the system browser without scraping) |
-| `allowed_domains` | `[]` | Allowed domains for `browser_open` (exact/subdomain match, or `"*"` for all public domains) |
+| `enabled` | `false` | Enable browser tools (`browser_open` and `browser`) |
+| `allowed_domains` | `[]` | Allowed domains for `browser_open` and `browser` (exact/subdomain match, or `"*"` for all public domains) |
| `session_name` | unset | Browser session name (for agent-browser automation) |
| `backend` | `agent_browser` | Browser automation backend: `"agent_browser"`, `"rust_native"`, `"computer_use"`, or `"auto"` |
| `native_headless` | `true` | Headless mode for rust-native backend |
@@ -293,6 +507,7 @@ Notes:
Notes:
+- `browser_open` is a simple URL opener; `browser` is full browser automation (open/click/type/scroll/screenshot).
- When `backend = "computer_use"`, the agent delegates browser actions to the sidecar at `computer_use.endpoint`.
- `allow_remote_endpoint = false` (default) rejects any non-loopback endpoint to prevent accidental public exposure.
- Use `window_allowlist` to restrict which OS windows the sidecar can interact with.
@@ -305,12 +520,52 @@ Notes:
| `allowed_domains` | `[]` | Allowed domains for HTTP requests (exact/subdomain match, or `"*"` for all public domains) |
| `max_response_size` | `1000000` | Maximum response size in bytes (default: 1 MB) |
| `timeout_secs` | `30` | Request timeout in seconds |
+| `user_agent` | `ZeroClaw/1.0` | User-Agent header for outbound HTTP requests |
Notes:
- Deny-by-default: if `allowed_domains` is empty, all HTTP requests are rejected.
- Use exact domain or subdomain matching (e.g. `"api.example.com"`, `"example.com"`), or `"*"` to allow any public domain.
- Local/private targets are still blocked even when `"*"` is configured.
+- Shell `curl`/`wget` are classified as high-risk and may be blocked by autonomy policy. Prefer `http_request` for direct HTTP calls.
+
+## `[web_fetch]`
+
+| Key | Default | Purpose |
+|---|---|---|
+| `enabled` | `false` | Enable `web_fetch` for page-to-text extraction |
+| `provider` | `fast_html2md` | Fetch/render backend: `fast_html2md`, `nanohtml2text`, `firecrawl` |
+| `api_key` | unset | API key for provider backends that require it (e.g. `firecrawl`) |
+| `api_url` | unset | Optional API URL override (self-hosted/alternate endpoint) |
+| `allowed_domains` | `["*"]` | Domain allowlist (`"*"` allows all public domains) |
+| `blocked_domains` | `[]` | Denylist applied before allowlist |
+| `max_response_size` | `500000` | Maximum returned payload size in bytes |
+| `timeout_secs` | `30` | Request timeout in seconds |
+| `user_agent` | `ZeroClaw/1.0` | User-Agent header for fetch requests |
+
+Notes:
+
+- `web_fetch` is optimized for summarization/data extraction from web pages.
+- Redirect targets are revalidated against allow/deny domain policy.
+- Local/private network targets remain blocked even when `allowed_domains = ["*"]`.
+
+## `[web_search]`
+
+| Key | Default | Purpose |
+|---|---|---|
+| `enabled` | `false` | Enable `web_search_tool` |
+| `provider` | `duckduckgo` | Search backend: `duckduckgo`, `brave`, `firecrawl` |
+| `api_key` | unset | Generic provider key (used by `firecrawl`, fallback for `brave`) |
+| `api_url` | unset | Optional API URL override |
+| `brave_api_key` | unset | Dedicated Brave key (required for `provider = "brave"` unless `api_key` is set) |
+| `max_results` | `5` | Maximum search results returned (clamped to 1-10) |
+| `timeout_secs` | `15` | Request timeout in seconds |
+| `user_agent` | `ZeroClaw/1.0` | User-Agent header for search requests |
+
+Notes:
+
+- If DuckDuckGo returns `403`/`429` in your network, switch provider to `brave` or `firecrawl`.
+- `web_search` finds candidate URLs; pair it with `web_fetch` for page content extraction.
## `[gateway]`
@@ -321,6 +576,14 @@ Notes:
| `require_pairing` | `true` | require pairing before bearer auth |
| `allow_public_bind` | `false` | block accidental public exposure |
+## `[gateway.node_control]` (experimental)
+
+| Key | Default | Purpose |
+|---|---|---|
+| `enabled` | `false` | enable node-control scaffold endpoint (`POST /api/node-control`) |
+| `auth_token` | `null` | optional extra shared token checked via `X-Node-Control-Token` |
+| `allowed_node_ids` | `[]` | allowlist for `node.describe`/`node.invoke` (`[]` accepts any) |
+
## `[autonomy]`
| Key | Default | Purpose |
@@ -336,6 +599,10 @@ Notes:
| `block_high_risk_commands` | `true` | hard block for high-risk commands |
| `auto_approve` | `[]` | tool operations always auto-approved |
| `always_ask` | `[]` | tool operations that always require approval |
+| `non_cli_excluded_tools` | `[]` | tools hidden from non-CLI channel tool specs |
+| `non_cli_approval_approvers` | `[]` | optional allowlist for who can run non-CLI approval-management commands |
+| `non_cli_natural_language_approval_mode` | `direct` | natural-language behavior for approval-management commands (`direct`, `request_confirm`, `disabled`) |
+| `non_cli_natural_language_approval_mode_by_channel` | `{}` | per-channel override map for natural-language approval mode |
Notes:
@@ -345,6 +612,25 @@ Notes:
- `allowed_commands` entries can be command names (for example, `"git"`), explicit executable paths (for example, `"/usr/bin/antigravity"`), or `"*"` to allow any command name/path (risk gates still apply).
- Shell separator/operator parsing is quote-aware. Characters like `;` inside quoted arguments are treated as literals, not command separators.
- Unquoted shell chaining/operators are still enforced by policy checks (`;`, `|`, `&&`, `||`, background chaining, and redirects).
+- In supervised mode on non-CLI channels, operators can persist human-approved tools with:
+ - One-step flow: `/approve `.
+ - Two-step flow: `/approve-request ` then `/approve-confirm ` (same sender + same chat/channel).
+ Both paths write to `autonomy.auto_approve` and remove the tool from `autonomy.always_ask`.
+- `non_cli_natural_language_approval_mode` controls how strict natural-language approval intents are:
+ - `direct` (default): natural-language approval grants immediately (private-chat friendly).
+ - `request_confirm`: natural-language approval creates a pending request that needs explicit confirm.
+ - `disabled`: natural-language approval commands are rejected; use slash commands only.
+- `non_cli_natural_language_approval_mode_by_channel` can override that mode for specific channels (keys are channel names like `telegram`, `discord`, `slack`).
+ - Example: keep global `direct`, but force `discord = "request_confirm"` for team chats.
+- `non_cli_approval_approvers` can restrict who is allowed to run approval commands (`/approve*`, `/unapprove`, `/approvals`):
+ - `*` allows all channel-admitted senders.
+ - `alice` allows sender `alice` on any channel.
+ - `telegram:alice` allows only that channel+sender pair.
+ - `telegram:*` allows any sender on Telegram.
+ - `*:alice` allows `alice` on any channel.
+- Use `/unapprove ` to remove persisted approval from `autonomy.auto_approve`.
+- `/approve-pending` lists pending requests for the current sender+chat/channel scope.
+- If a tool remains unavailable after approval, check `autonomy.non_cli_excluded_tools` (runtime `/approvals` shows this list). Channel runtime reloads this list from `config.toml` automatically.
```toml
[autonomy]
@@ -380,6 +666,7 @@ Use route hints so integrations can keep stable names while model IDs evolve.
| `hint` | _required_ | Task hint name (e.g. `"reasoning"`, `"fast"`, `"code"`, `"summarize"`) |
| `provider` | _required_ | Provider to route to (must match a known provider name) |
| `model` | _required_ | Model to use with that provider |
+| `max_tokens` | unset | Optional per-route output token cap forwarded to provider APIs |
| `api_key` | unset | Optional API key override for this route's provider |
### `[[embedding_routes]]`
@@ -400,6 +687,7 @@ embedding_model = "hint:semantic"
hint = "reasoning"
provider = "openrouter"
model = "provider/model-id"
+max_tokens = 8192
[[embedding_routes]]
hint = "semantic"
@@ -490,6 +778,12 @@ Notes:
- When a timeout occurs, users receive: `⚠️ Request timed out while waiting for the model. Please try again.`
- Telegram-only interruption behavior is controlled with `channels_config.telegram.interrupt_on_new_message` (default `false`).
When enabled, a newer message from the same sender in the same chat cancels the in-flight request and preserves interrupted user context.
+- Telegram/Discord/Slack/Mattermost/Lark/Feishu support `[channels_config..group_reply]`:
+ - `mode = "all_messages"` or `mode = "mention_only"`
+ - `allowed_sender_ids = ["..."]` to bypass mention gating in groups
+ - `allowed_users` allowlist checks still run first
+- Legacy `mention_only` flags (Telegram/Discord/Mattermost/Lark) remain supported as fallback only.
+ If `group_reply.mode` is set, it takes precedence over legacy `mention_only`.
- While `zeroclaw channel start` is running, updates to `default_provider`, `default_model`, `default_temperature`, `api_key`, `api_url`, and `reliability.*` are hot-applied from `config.toml` on the next inbound message.
### `[channels_config.nostr]`
@@ -629,6 +923,31 @@ Notes:
- Place `.md`/`.txt` datasheet files named by board (e.g. `nucleo-f401re.md`, `rpi-gpio.md`) in `datasheet_dir` for RAG retrieval.
- See [hardware-peripherals-design.md](hardware-peripherals-design.md) for board protocol and firmware notes.
+## `[agents_ipc]`
+
+Inter-process communication for independent ZeroClaw agents on the same host.
+
+| Key | Default | Purpose |
+|---|---|---|
+| `enabled` | `false` | Enable IPC tools (`agents_list`, `agents_send`, `agents_inbox`, `state_get`, `state_set`) |
+| `db_path` | `~/.zeroclaw/agents.db` | Shared SQLite database path (all agents on this host share one file) |
+| `staleness_secs` | `300` | Agents not seen within this window are considered offline (seconds) |
+
+Notes:
+
+- When `enabled = false` (default), no IPC tools are registered and no database is created.
+- All agents that share a `db_path` can discover each other and exchange messages.
+- Agent identity is derived from `workspace_dir` (SHA-256 hash), not user-supplied.
+
+Example:
+
+```toml
+[agents_ipc]
+enabled = true
+db_path = "~/.zeroclaw/agents.db"
+staleness_secs = 300
+```
+
## Security-Relevant Defaults
- deny-by-default channel allowlists (`[]` means deny all)
diff --git a/docs/docs-inventory.md b/docs/docs-inventory.md
index 539f2305e..bb8ac8b15 100644
--- a/docs/docs-inventory.md
+++ b/docs/docs-inventory.md
@@ -1,34 +1,57 @@
# ZeroClaw Documentation Inventory
-This inventory classifies docs by intent so readers can quickly distinguish runtime-contract guides from design proposals.
+This inventory classifies documentation by intent and canonical location.
-Last reviewed: **February 18, 2026**.
+Last reviewed: **February 24, 2026**.
## Classification Legend
- **Current Guide/Reference**: intended to match current runtime behavior
-- **Policy/Process**: collaboration or governance rules
-- **Proposal/Roadmap**: design exploration; may include hypothetical commands
-- **Snapshot**: time-bound operational report
+- **Policy/Process**: contribution or governance contract
+- **Proposal/Roadmap**: exploratory or planned behavior
+- **Snapshot/Audit**: time-bound status and gap analysis
+- **Compatibility Shim**: path preserved for backward navigation
-## Documentation Entry Points
+## Entry Points
+
+### Product root
| Doc | Type | Audience |
|---|---|---|
| `README.md` | Current Guide | all readers |
-| `README.zh-CN.md` | Current Guide (localized) | Chinese readers |
-| `README.ja.md` | Current Guide (localized) | Japanese readers |
-| `README.ru.md` | Current Guide (localized) | Russian readers |
-| `README.vi.md` | Current Guide (localized) | Vietnamese readers |
-| `docs/README.md` | Current Guide (hub) | all readers |
-| `docs/README.zh-CN.md` | Current Guide (localized hub) | Chinese readers |
-| `docs/README.ja.md` | Current Guide (localized hub) | Japanese readers |
-| `docs/README.ru.md` | Current Guide (localized hub) | Russian readers |
-| `docs/README.vi.md` | Current Guide (localized hub) | Vietnamese readers |
-| `docs/SUMMARY.md` | Current Guide (unified TOC) | all readers |
-| `docs/structure/README.md` | Current Guide (structure map) | all readers |
+| `docs/i18n/zh-CN/README.md` | Current Guide (localized) | Chinese readers |
+| `docs/i18n/ja/README.md` | Current Guide (localized) | Japanese readers |
+| `docs/i18n/ru/README.md` | Current Guide (localized) | Russian readers |
+| `docs/i18n/fr/README.md` | Current Guide (localized) | French readers |
+| `docs/i18n/vi/README.md` | Current Guide (localized) | Vietnamese readers |
+| `docs/i18n/el/README.md` | Current Guide (localized) | Greek readers |
-## Collection Index Docs
+### Docs system
+
+| Doc | Type | Audience |
+|---|---|---|
+| `docs/README.md` | Current Guide (hub) | all readers |
+| `docs/SUMMARY.md` | Current Guide (unified TOC) | all readers |
+| `docs/structure/README.md` | Current Guide (structure map) | maintainers |
+| `docs/structure/by-function.md` | Current Guide (function map) | maintainers/operators |
+| `docs/i18n-guide.md` | Current Guide (i18n completion contract) | contributors/agents |
+| `docs/i18n/README.md` | Current Guide (locale index) | maintainers/translators |
+| `docs/i18n-coverage.md` | Current Guide (coverage matrix) | maintainers/translators |
+
+## Locale Hubs (Canonical)
+
+| Locale | Canonical hub | Type |
+|---|---|---|
+| `zh-CN` | `docs/i18n/zh-CN/README.md` | Current Guide (localized hub scaffold) |
+| `ja` | `docs/i18n/ja/README.md` | Current Guide (localized hub scaffold) |
+| `ru` | `docs/i18n/ru/README.md` | Current Guide (localized hub scaffold) |
+| `fr` | `docs/i18n/fr/README.md` | Current Guide (localized hub scaffold) |
+| `vi` | `docs/i18n/vi/README.md` | Current Guide (full localized tree) |
+| `el` | `docs/i18n/el/README.md` | Current Guide (full localized tree) |
+
+Compatibility shims such as `docs/SUMMARY..md` and `docs/vi/**` remain valid but are non-canonical.
+
+## Collection Index Docs (English canonical)
| Doc | Type | Audience |
|---|---|---|
@@ -39,31 +62,39 @@ Last reviewed: **February 18, 2026**.
| `docs/hardware/README.md` | Current Guide | hardware builders |
| `docs/contributing/README.md` | Current Guide | contributors/reviewers |
| `docs/project/README.md` | Current Guide | maintainers |
+| `docs/sop/README.md` | Current Guide | operators/automation maintainers |
## Current Guides & References
| Doc | Type | Audience |
|---|---|---|
| `docs/one-click-bootstrap.md` | Current Guide | users/operators |
+| `docs/android-setup.md` | Current Guide | Android users/operators |
| `docs/commands-reference.md` | Current Reference | users/operators |
| `docs/providers-reference.md` | Current Reference | users/operators |
| `docs/channels-reference.md` | Current Reference | users/operators |
-| `docs/nextcloud-talk-setup.md` | Current Guide | operators |
| `docs/config-reference.md` | Current Reference | operators |
| `docs/custom-providers.md` | Current Integration Guide | integration developers |
| `docs/zai-glm-setup.md` | Current Provider Setup Guide | users/operators |
| `docs/langgraph-integration.md` | Current Integration Guide | integration developers |
+| `docs/proxy-agent-playbook.md` | Current Operations Playbook | operators/maintainers |
| `docs/operations-runbook.md` | Current Guide | operators |
+| `docs/operations/connectivity-probes-runbook.md` | Current CI/ops Runbook | maintainers/operators |
| `docs/troubleshooting.md` | Current Guide | users/operators |
| `docs/network-deployment.md` | Current Guide | operators |
| `docs/mattermost-setup.md` | Current Guide | operators |
+| `docs/nextcloud-talk-setup.md` | Current Guide | operators |
+| `docs/cargo-slicer-speedup.md` | Current Build/CI Guide | maintainers |
| `docs/adding-boards-and-tools.md` | Current Guide | hardware builders |
| `docs/arduino-uno-q-setup.md` | Current Guide | hardware builders |
| `docs/nucleo-setup.md` | Current Guide | hardware builders |
| `docs/hardware-peripherals-design.md` | Current Design Spec | hardware contributors |
+| `docs/datasheets/README.md` | Current Hardware Index | hardware builders |
| `docs/datasheets/nucleo-f401re.md` | Current Hardware Reference | hardware builders |
| `docs/datasheets/arduino-uno.md` | Current Hardware Reference | hardware builders |
| `docs/datasheets/esp32.md` | Current Hardware Reference | hardware builders |
+| `docs/audit-event-schema.md` | Current CI/Security Reference | maintainers/security reviewers |
+| `docs/security/official-channels-and-fraud-prevention.md` | Current Security Guide | users/operators |
## Policy / Process Docs
@@ -87,18 +118,18 @@ These are valuable context, but **not strict runtime contracts**.
| `docs/frictionless-security.md` | Proposal |
| `docs/security-roadmap.md` | Roadmap |
-## Snapshot Docs
+## Snapshot / Audit Docs
| Doc | Type |
|---|---|
| `docs/project-triage-snapshot-2026-02-18.md` | Snapshot |
+| `docs/docs-audit-2026-02-24.md` | Snapshot (docs architecture audit) |
+| `docs/i18n-gap-backlog.md` | Snapshot (i18n depth gap tracking) |
-## Maintenance Recommendations
+## Maintenance Contract
-1. Update `commands-reference` whenever CLI surface changes.
-2. Update `providers-reference` when provider catalog/aliases/env vars change.
-3. Update `channels-reference` when channel support or allowlist semantics change.
-4. Keep snapshots date-stamped and immutable.
-5. Mark proposal docs clearly to avoid being mistaken for runtime contracts.
-6. Keep localized README/docs-hub links aligned when adding new core docs.
-7. Update `docs/SUMMARY.md` and collection indexes whenever new major docs are added.
+1. Update `docs/SUMMARY.md` and nearest category index when adding a major doc.
+2. Keep locale navigation parity across all supported locales (`en`, `zh-CN`, `ja`, `ru`, `fr`, `vi`, `el`).
+3. Use `docs/i18n-guide.md` whenever docs IA/shared wording changes.
+4. Keep canonical localized hubs under `docs/i18n//`; treat shim paths as compatibility only.
+5. Keep snapshots date-stamped and immutable; add newer snapshots instead of rewriting historical ones.
diff --git a/docs/getting-started/README.md b/docs/getting-started/README.md
index 8495427d3..da808e2ef 100644
--- a/docs/getting-started/README.md
+++ b/docs/getting-started/README.md
@@ -7,7 +7,8 @@ For first-time setup and quick orientation.
1. Main overview and quick start: [../../README.md](../../README.md)
2. One-click setup and dual bootstrap mode: [../one-click-bootstrap.md](../one-click-bootstrap.md)
3. Update or uninstall on macOS: [macos-update-uninstall.md](macos-update-uninstall.md)
-4. Find commands by tasks: [../commands-reference.md](../commands-reference.md)
+4. Set up on Android (Termux/ADB): [../android-setup.md](../android-setup.md)
+5. Find commands by tasks: [../commands-reference.md](../commands-reference.md)
## Choose Your Path
@@ -32,3 +33,4 @@ For first-time setup and quick orientation.
- Runtime operations: [../operations/README.md](../operations/README.md)
- Reference catalogs: [../reference/README.md](../reference/README.md)
- macOS lifecycle tasks: [macos-update-uninstall.md](macos-update-uninstall.md)
+- Android setup path: [../android-setup.md](../android-setup.md)
diff --git a/docs/i18n/README.md b/docs/i18n/README.md
index 1769166d1..0f309bedd 100644
--- a/docs/i18n/README.md
+++ b/docs/i18n/README.md
@@ -2,14 +2,30 @@
Canonical localized documentation trees live here.
+Top-level parity status: **all supported locales are 0-gap against `docs/*.md` baseline** (last validated 2026-02-24).
+Narrative depth status: **enhanced bridge rollout completed for `zh-CN`/`ja`/`ru`/`fr`**.
+
## Locales
-- Vietnamese: [vi/README.md](vi/README.md)
+- 简体中文 (Chinese): [zh-CN/README.md](zh-CN/README.md)
+- 日本語 (Japanese): [ja/README.md](ja/README.md)
+- Русский (Russian): [ru/README.md](ru/README.md)
+- Français (French): [fr/README.md](fr/README.md)
+- Tiếng Việt (Vietnamese): [vi/README.md](vi/README.md)
+- Ελληνικά (Greek): [el/README.md](el/README.md)
## Structure
- Docs structure map (language/part/function): [../structure/README.md](../structure/README.md)
-- Canonical Vietnamese tree: `docs/i18n/vi/`
-- Compatibility Vietnamese paths: `docs/vi/` and `docs/*.vi.md`
+- Canonical locale trees:
+ - `docs/i18n/zh-CN/`
+ - `docs/i18n/ja/`
+ - `docs/i18n/ru/`
+ - `docs/i18n/fr/`
+ - `docs/i18n/vi/`
+ - `docs/i18n/el/`
+- Docs-root compatibility shims are limited to paths like `docs/SUMMARY..md` when retained.
See overall coverage and conventions in [../i18n-coverage.md](../i18n-coverage.md).
+See remaining localization depth gaps in [../i18n-gap-backlog.md](../i18n-gap-backlog.md).
+For required execution steps, use [../i18n-guide.md](../i18n-guide.md).
diff --git a/docs/i18n/el/actions-source-policy.md b/docs/i18n/el/actions-source-policy.md
index bc0420387..fa6474cfa 100644
--- a/docs/i18n/el/actions-source-policy.md
+++ b/docs/i18n/el/actions-source-policy.md
@@ -25,7 +25,7 @@
- `softprops/action-gh-release@*`
- `sigstore/cosign-installer@*`
- `Checkmarx/vorpal-reviewdog-github-action@*`
-- `Swatinem/rust-cache@*`
+- `useblacksmith/*` (Υποδομή Blacksmith)
## Διαδικασία Ελέγχου Αλλαγών
@@ -74,7 +74,7 @@ gh api repos/zeroclaw-labs/zeroclaw/actions/permissions/selected-actions
## Ιστορικό Αλλαγών
- **2026-02-21**: Προσθήκη `Checkmarx/vorpal-reviewdog-github-action@*` για στοχευμένους ελέγχους ασφαλείας.
-- **2026-02-26**: Τυποποίηση runner/action για Rust cache και Docker builds με `Swatinem/rust-cache`, `docker/setup-buildx-action`, `docker/build-push-action`.
+- **2026-02-17**: Μετάβαση στο `useblacksmith/rust-cache` για τη διαχείριση προσωρινής μνήμης Rust.
- **2026-02-16**: Προσθήκη `sigstore/cosign-installer@*` για την υπογραφή εκδόσεων.
- **2026-02-17**: Αντικατάσταση του `cargo install cargo-audit` με την ενέργεια `rustsec/audit-check@*`.
diff --git a/docs/i18n/el/commands-reference.md b/docs/i18n/el/commands-reference.md
index 558437901..5fc8e9609 100644
--- a/docs/i18n/el/commands-reference.md
+++ b/docs/i18n/el/commands-reference.md
@@ -38,6 +38,12 @@
> [!TIP]
> Κατά τη διάρκεια της συνομιλίας, μπορείτε να αιτηθείτε την αλλαγή του μοντέλου (π.χ. "use gpt-4") και ο πράκτορας θα προσαρμόσει τις ρυθμίσεις του δυναμικά.
+### 2.1 `gateway` / `daemon`
+
+- `zeroclaw gateway [--host ] [--port ] [--new-pairing]`
+- `zeroclaw daemon [--host ] [--port ]`
+- Το `--new-pairing` καθαρίζει όλα τα αποθηκευμένα paired tokens και δημιουργεί νέο pairing code κατά την εκκίνηση του gateway.
+
### 3. `cron` (Προγραμματισμός Εργασιών)
Δυνατότητα αυτοματισμού εντολών:
diff --git a/docs/i18n/fr/commands-reference.md b/docs/i18n/fr/commands-reference.md
index 386b7fecd..bea09eb6f 100644
--- a/docs/i18n/fr/commands-reference.md
+++ b/docs/i18n/fr/commands-reference.md
@@ -16,3 +16,7 @@ Source anglaise:
- Les noms de commandes, flags et clés de config restent en anglais.
- La définition finale du comportement est la source anglaise.
+
+## Mise à jour récente
+
+- `zeroclaw gateway` prend en charge `--new-pairing` pour effacer les tokens appairés et générer un nouveau code d'appairage.
diff --git a/docs/i18n/ja/commands-reference.md b/docs/i18n/ja/commands-reference.md
index 5b3bf8d35..8b634ff9e 100644
--- a/docs/i18n/ja/commands-reference.md
+++ b/docs/i18n/ja/commands-reference.md
@@ -16,3 +16,7 @@
- コマンド名・フラグ名・設定キーは英語のまま保持します。
- 挙動の最終定義は英語版原文を優先します。
+
+## 最新更新
+
+- `zeroclaw gateway` は `--new-pairing` をサポートし、既存のペアリングトークンを消去して新しいペアリングコードを生成できます。
diff --git a/docs/i18n/ru/commands-reference.md b/docs/i18n/ru/commands-reference.md
index 1c092a217..5ba917fcb 100644
--- a/docs/i18n/ru/commands-reference.md
+++ b/docs/i18n/ru/commands-reference.md
@@ -16,3 +16,7 @@
- Имена команд, флагов и ключей конфигурации сохраняются на английском.
- Финальная спецификация поведения — в английском оригинале.
+
+## Последнее обновление
+
+- `zeroclaw gateway` поддерживает `--new-pairing`: флаг очищает сохранённые paired-токены и генерирует новый код сопряжения.
diff --git a/docs/i18n/vi/README.md b/docs/i18n/vi/README.md
index 4a86dd57b..7ff00dc0b 100644
--- a/docs/i18n/vi/README.md
+++ b/docs/i18n/vi/README.md
@@ -10,14 +10,18 @@
| Tôi muốn… | Xem tài liệu |
|---|---|
-| Cài đặt và chạy nhanh | [../../../README.vi.md](../../../README.vi.md) / [../../../README.md](../../../README.md) |
+| Cài đặt và chạy nhanh | [docs/i18n/vi/README.md](README.md) / [../../../README.md](../../../README.md) |
| Cài đặt bằng một lệnh | [one-click-bootstrap.md](one-click-bootstrap.md) |
+| Cài đặt trên Android (Termux/ADB) | [android-setup.md](android-setup.md) |
| Tìm lệnh theo tác vụ | [commands-reference.md](commands-reference.md) |
| Kiểm tra giá trị mặc định và khóa cấu hình | [config-reference.md](config-reference.md) |
| Kết nối provider / endpoint tùy chỉnh | [custom-providers.md](custom-providers.md) |
| Cấu hình Z.AI / GLM provider | [zai-glm-setup.md](zai-glm-setup.md) |
| Sử dụng tích hợp LangGraph | [langgraph-integration.md](langgraph-integration.md) |
+| Thiết lập Nextcloud Talk | [nextcloud-talk-setup.md](nextcloud-talk-setup.md) |
+| Cấu hình proxy theo phạm vi an toàn | [proxy-agent-playbook.md](proxy-agent-playbook.md) |
| Vận hành hàng ngày (runbook) | [operations-runbook.md](operations-runbook.md) |
+| Vận hành probe kết nối provider trong CI | [operations/connectivity-probes-runbook.md](operations/connectivity-probes-runbook.md) |
| Khắc phục sự cố cài đặt/chạy/kênh | [troubleshooting.md](troubleshooting.md) |
| Cấu hình Matrix phòng mã hóa (E2EE) | [matrix-e2ee-guide.md](matrix-e2ee-guide.md) |
| Xem theo danh mục | [SUMMARY.md](SUMMARY.md) |
@@ -53,6 +57,7 @@
- [channels-reference.md](channels-reference.md) — khả năng kênh và hướng dẫn thiết lập
- [matrix-e2ee-guide.md](matrix-e2ee-guide.md) — thiết lập phòng mã hóa Matrix (E2EE)
- [config-reference.md](config-reference.md) — khóa cấu hình quan trọng và giá trị mặc định an toàn
+- [wasm-tools-guide.md](wasm-tools-guide.md) — tạo, cài đặt và xuất bản WASM skills
- [custom-providers.md](custom-providers.md) — mẫu tích hợp provider / base URL tùy chỉnh
- [zai-glm-setup.md](zai-glm-setup.md) — thiết lập Z.AI/GLM và ma trận endpoint
- [langgraph-integration.md](langgraph-integration.md) — tích hợp dự phòng cho model/tool-calling
@@ -83,12 +88,17 @@
- Mục lục thống nhất (TOC): [SUMMARY.md](SUMMARY.md)
- Bản đồ cấu trúc docs (ngôn ngữ/phần/chức năng): [../../structure/README.md](../../structure/README.md)
-- Danh mục và phân loại tài liệu: [docs-inventory.md](../../docs-inventory.md)
+- Danh mục và phân loại tài liệu: [docs-inventory.md](docs-inventory.md)
+- Checklist hoàn thiện i18n: [i18n-guide.md](i18n-guide.md)
+- Bản đồ độ phủ i18n: [i18n-coverage.md](i18n-coverage.md)
+- Backlog thiếu hụt i18n: [i18n-gap-backlog.md](i18n-gap-backlog.md)
+- Snapshot kiểm toán tài liệu (2026-02-24): [docs-audit-2026-02-24.md](docs-audit-2026-02-24.md)
## Ngôn ngữ khác
- English: [README.md](../../README.md)
-- 简体中文: [README.zh-CN.md](../../README.zh-CN.md)
-- 日本語: [README.ja.md](../../README.ja.md)
-- Русский: [README.ru.md](../../README.ru.md)
-- Français: [README.fr.md](../../README.fr.md)
+- 简体中文: [../zh-CN/README.md](../zh-CN/README.md)
+- 日本語: [../ja/README.md](../ja/README.md)
+- Русский: [../ru/README.md](../ru/README.md)
+- Français: [../fr/README.md](../fr/README.md)
+- Ελληνικά: [../el/README.md](../el/README.md)
diff --git a/docs/i18n/vi/SUMMARY.md b/docs/i18n/vi/SUMMARY.md
index ce0280bd1..465461cf4 100644
--- a/docs/i18n/vi/SUMMARY.md
+++ b/docs/i18n/vi/SUMMARY.md
@@ -7,7 +7,7 @@
## Điểm vào
- Bản đồ cấu trúc docs (ngôn ngữ/phần/chức năng): [../../structure/README.md](../../structure/README.md)
-- README tiếng Việt: [../../../README.vi.md](../../../README.vi.md)
+- README tiếng Việt: [docs/i18n/vi/README.md](README.md)
- Docs hub tiếng Việt: [README.md](README.md)
## Danh mục
@@ -16,6 +16,7 @@
- [getting-started/README.md](getting-started/README.md)
- [one-click-bootstrap.md](one-click-bootstrap.md)
+- [android-setup.md](android-setup.md)
### 2) Lệnh / Cấu hình / Tích hợp
@@ -23,15 +24,18 @@
- [commands-reference.md](commands-reference.md)
- [providers-reference.md](providers-reference.md)
- [channels-reference.md](channels-reference.md)
+- [nextcloud-talk-setup.md](nextcloud-talk-setup.md)
- [config-reference.md](config-reference.md)
- [custom-providers.md](custom-providers.md)
- [zai-glm-setup.md](zai-glm-setup.md)
- [langgraph-integration.md](langgraph-integration.md)
+- [proxy-agent-playbook.md](proxy-agent-playbook.md)
### 3) Vận hành & Triển khai
- [operations/README.md](operations/README.md)
- [operations-runbook.md](operations-runbook.md)
+- [operations/connectivity-probes-runbook.md](operations/connectivity-probes-runbook.md)
- [release-process.md](release-process.md)
- [troubleshooting.md](troubleshooting.md)
- [network-deployment.md](network-deployment.md)
@@ -46,6 +50,7 @@
- [sandboxing.md](sandboxing.md)
- [resource-limits.md](resource-limits.md)
- [audit-logging.md](audit-logging.md)
+- [audit-event-schema.md](audit-event-schema.md)
- [security-roadmap.md](security-roadmap.md)
### 5) Phần cứng & Ngoại vi
@@ -55,6 +60,7 @@
- [adding-boards-and-tools.md](adding-boards-and-tools.md)
- [nucleo-setup.md](nucleo-setup.md)
- [arduino-uno-q-setup.md](arduino-uno-q-setup.md)
+- [datasheets/README.md](datasheets/README.md)
- [datasheets/nucleo-f401re.md](datasheets/nucleo-f401re.md)
- [datasheets/arduino-uno.md](datasheets/arduino-uno.md)
- [datasheets/esp32.md](datasheets/esp32.md)
@@ -67,11 +73,21 @@
- [reviewer-playbook.md](reviewer-playbook.md)
- [ci-map.md](ci-map.md)
- [actions-source-policy.md](actions-source-policy.md)
+- [cargo-slicer-speedup.md](cargo-slicer-speedup.md)
### 7) Dự án
- [project/README.md](project/README.md)
-- [proxy-agent-playbook.md](proxy-agent-playbook.md)
+- [project-triage-snapshot-2026-02-18.md](project-triage-snapshot-2026-02-18.md)
+- [docs-audit-2026-02-24.md](docs-audit-2026-02-24.md)
+
+### 8) Quản trị tài liệu & i18n
+
+- [docs-inventory.md](docs-inventory.md)
+- [doc-template.md](doc-template.md)
+- [i18n-guide.md](i18n-guide.md)
+- [i18n-coverage.md](i18n-coverage.md)
+- [i18n-gap-backlog.md](i18n-gap-backlog.md)
## Ngôn ngữ khác
diff --git a/docs/i18n/vi/actions-source-policy.md b/docs/i18n/vi/actions-source-policy.md
index d60082d02..9c6cc6766 100644
--- a/docs/i18n/vi/actions-source-policy.md
+++ b/docs/i18n/vi/actions-source-policy.md
@@ -22,7 +22,7 @@ Các mẫu allowlist được chọn:
- `rhysd/actionlint@*`
- `softprops/action-gh-release@*`
- `sigstore/cosign-installer@*`
-- `Swatinem/rust-cache@*`
+- `useblacksmith/*` (cơ sở hạ tầng self-hosted runner Blacksmith)
## Xuất kiểm soát thay đổi
@@ -74,11 +74,13 @@ Nếu gặp phải, chỉ thêm action tin cậy còn thiếu cụ thể đó, c
Ghi chú quét gần đây nhất:
-- 2026-02-26: Chuẩn hóa runner/action cho cache Rust và Docker build
- - Đã thêm mẫu allowlist: `Swatinem/rust-cache@*`
- - Docker build dùng `docker/setup-buildx-action` và `docker/build-push-action`
+- 2026-02-17: Cache phụ thuộc Rust được migrate từ `Swatinem/rust-cache` sang `useblacksmith/rust-cache`
+ - Không cần mẫu allowlist mới (`useblacksmith/*` đã có trong allowlist)
- 2026-02-16: Phụ thuộc ẩn được phát hiện trong `release.yml`: `sigstore/cosign-installer@...`
- Đã thêm mẫu allowlist: `sigstore/cosign-installer@*`
+- 2026-02-16: Migration Blacksmith chặn thực thi workflow
+ - Đã thêm mẫu allowlist: `useblacksmith/*` cho cơ sở hạ tầng self-hosted runner
+ - Actions: `useblacksmith/setup-docker-builder@v1`, `useblacksmith/build-push-action@v2`
- 2026-02-17: Cập nhật cân bằng tính tái tạo/độ tươi của security audit
- Đã thêm mẫu allowlist: `rustsec/audit-check@*`
- Thay thế thực thi nội tuyến `cargo install cargo-audit` bằng `rustsec/audit-check@69366f33c96575abad1ee0dba8212993eecbe998` được pin trong `security.yml`
diff --git a/docs/i18n/vi/ci-map.md b/docs/i18n/vi/ci-map.md
index e46787101..7bb72f93c 100644
--- a/docs/i18n/vi/ci-map.md
+++ b/docs/i18n/vi/ci-map.md
@@ -117,7 +117,7 @@ Các kiểm tra chặn merge nên giữ nhỏ và mang tính quyết định. C
- Giữ các kiểm tra chặn merge mang tính quyết định và tái tạo được (`--locked` khi áp dụng được).
- Đảm bảo tương thích merge queue bằng cách hỗ trợ `merge_group` cho các workflow bắt buộc (`ci-run`, `sec-audit`, `sec-codeql`).
-- PR intake checks không bắt buộc liên kết với hệ thống ticket bên ngoài.
+- Bắt buộc PR liên kết với Linear issue key (`RMN-*`/`CDV-*`/`COM-*`) qua PR intake checks.
- Bắt buộc entry `advisories.ignore` trong `deny.toml` dùng object có `id` + `reason` (được kiểm tra bởi `deny_policy_guard.py`).
- Giữ metadata governance cho deny ignore trong `.github/security/deny-ignore-governance.json` luôn cập nhật (owner/reason/expiry/ticket được kiểm tra bởi `deny_policy_guard.py`).
- Giữ metadata quản trị allowlist gitleaks trong `.github/security/gitleaks-allowlist-governance.json` luôn cập nhật (owner/reason/expiry/ticket được kiểm tra bởi `secrets_governance_guard.py`).
diff --git a/docs/i18n/vi/commands-reference.md b/docs/i18n/vi/commands-reference.md
index 096d0e7b8..fa6fc7986 100644
--- a/docs/i18n/vi/commands-reference.md
+++ b/docs/i18n/vi/commands-reference.md
@@ -46,9 +46,11 @@ Xác minh lần cuối: **2026-02-20**.
### `gateway` / `daemon`
-- `zeroclaw gateway [--host ] [--port ]`
+- `zeroclaw gateway [--host ] [--port ] [--new-pairing]`
- `zeroclaw daemon [--host ] [--port ]`
+`--new-pairing` sẽ xóa toàn bộ token đã ghép đôi và tạo mã ghép đôi mới khi gateway khởi động.
+
### `service`
- `zeroclaw service install`
diff --git a/docs/i18n/vi/config-reference.md b/docs/i18n/vi/config-reference.md
index 3b1b6a14a..1274dcf97 100644
--- a/docs/i18n/vi/config-reference.md
+++ b/docs/i18n/vi/config-reference.md
@@ -25,6 +25,14 @@ Lệnh xuất schema:
| `default_provider` | `openrouter` | ID hoặc bí danh provider |
| `default_model` | `anthropic/claude-sonnet-4-6` | Model định tuyến qua provider đã chọn |
| `default_temperature` | `0.7` | Nhiệt độ model |
+| `model_support_vision` | chưa đặt (`None`) | Ghi đè hỗ trợ vision cho provider/model đang dùng |
+
+Lưu ý:
+
+- `model_support_vision = true` bật vision (ví dụ Ollama chạy `llava`).
+- `model_support_vision = false` tắt vision.
+- Để trống giữ mặc định của provider.
+- Biến môi trường: `ZEROCLAW_MODEL_SUPPORT_VISION` hoặc `MODEL_SUPPORT_VISION` (giá trị: `true`/`false`/`1`/`0`/`yes`/`no`/`on`/`off`).
## `[observability]`
@@ -65,15 +73,15 @@ Lưu ý cho người dùng container:
| Khóa | Mặc định | Mục đích |
|---|---|---|
-| `compact_context` | `false` | Khi bật: bootstrap_max_chars=6000, rag_chunk_limit=2. Dùng cho model 13B trở xuống |
-| `max_tool_iterations` | `10` | Số vòng lặp tool-call tối đa mỗi tin nhắn trên CLI, gateway và channels |
+| `compact_context` | `true` | Khi bật: bootstrap_max_chars=6000, rag_chunk_limit=2. Dùng cho model 13B trở xuống |
+| `max_tool_iterations` | `20` | Số vòng lặp tool-call tối đa mỗi tin nhắn trên CLI, gateway và channels |
| `max_history_messages` | `50` | Số tin nhắn lịch sử tối đa giữ lại mỗi phiên |
| `parallel_tools` | `false` | Bật thực thi tool song song trong một lượt |
| `tool_dispatcher` | `auto` | Chiến lược dispatch tool |
Lưu ý:
-- Đặt `max_tool_iterations = 0` sẽ dùng giá trị mặc định an toàn `10`.
+- Đặt `max_tool_iterations = 0` sẽ dùng giá trị mặc định an toàn `20`.
- Nếu tin nhắn kênh vượt giá trị này, runtime trả về: `Agent exceeded maximum tool iterations ()`.
- Trong vòng lặp tool của CLI, gateway và channel, các lời gọi tool độc lập được thực thi đồng thời mặc định khi không cần phê duyệt; thứ tự kết quả giữ ổn định.
- `parallel_tools` áp dụng cho API `Agent::turn()`. Không ảnh hưởng đến vòng lặp runtime của CLI, gateway hay channel.
@@ -128,6 +136,18 @@ Lưu ý:
- `reasoning_enabled = true` yêu cầu reasoning tường minh (`think: true` trên `ollama`).
- Để trống giữ mặc định của provider.
+## `[provider]`
+
+| Khóa | Mặc định | Mục đích |
+|---|---|---|
+| `reasoning_level` | chưa đặt (`None`) | Ghi đè mức reasoning cho provider hỗ trợ mức (hiện tại OpenAI Codex `/responses`) |
+
+Lưu ý:
+
+- Giá trị hỗ trợ: `minimal`, `low`, `medium`, `high`, `xhigh` (không phân biệt hoa/thường).
+- Khi đặt, ghi đè `ZEROCLAW_CODEX_REASONING_EFFORT` cho OpenAI Codex.
+- Để trống sẽ dùng `ZEROCLAW_CODEX_REASONING_EFFORT` nếu có, nếu không mặc định `xhigh`.
+
## `[skills]`
| Khóa | Mặc định | Mục đích |
@@ -259,6 +279,14 @@ Lưu ý:
| `require_pairing` | `true` | Yêu cầu ghép nối trước khi xác thực bearer |
| `allow_public_bind` | `false` | Chặn lộ public do vô ý |
+## `[gateway.node_control]` (thử nghiệm)
+
+| Khóa | Mặc định | Mục đích |
+|---|---|---|
+| `enabled` | `false` | Bật endpoint scaffold node-control (`POST /api/node-control`) |
+| `auth_token` | `null` | Shared token bổ sung, kiểm qua header `X-Node-Control-Token` |
+| `allowed_node_ids` | `[]` | Allowlist cho `node.describe`/`node.invoke` (`[]` = chấp nhận mọi node) |
+
## `[autonomy]`
| Khóa | Mặc định | Mục đích |
diff --git a/docs/i18n/zh-CN/commands-reference.md b/docs/i18n/zh-CN/commands-reference.md
index 8d40c7dfa..4a0159c80 100644
--- a/docs/i18n/zh-CN/commands-reference.md
+++ b/docs/i18n/zh-CN/commands-reference.md
@@ -16,3 +16,7 @@
- 命令名、参数名、配置键保持英文。
- 行为细节以英文原文为准。
+
+## 最近更新
+
+- `zeroclaw gateway` 新增 `--new-pairing` 参数,可清空已配对 token 并在网关启动时生成新的配对码。
diff --git a/docs/operations/feature-matrix-runbook.md b/docs/operations/feature-matrix-runbook.md
index 0fccba887..d758d2eba 100644
--- a/docs/operations/feature-matrix-runbook.md
+++ b/docs/operations/feature-matrix-runbook.md
@@ -66,7 +66,7 @@ Verification commands:
1. Open `feature-matrix-summary.md` and identify failed lane(s), owner, and failing command.
2. Download lane artifact (`nightly-result-.json`) for exact command + exit code.
3. Reproduce locally with the exact command and toolchain lock (`--locked`).
-4. Attach local reproduction logs + fix PR link to the active tracking thread (issue/PR discussion).
+4. Attach local reproduction logs + fix PR link to the active Linear execution issue.
## High-Frequency Failure Classes
diff --git a/docs/ros2-integration-guidance.md b/docs/ros2-integration-guidance.md
new file mode 100644
index 000000000..1276130fc
--- /dev/null
+++ b/docs/ros2-integration-guidance.md
@@ -0,0 +1,48 @@
+# ROS2 Integration Guidance
+
+This note captures the recommended integration shape for ROS2/ROS1 environments.
+It is intentionally architecture-focused and keeps ZeroClaw core boundaries stable.
+
+## Recommendation
+
+Use the plugin/adapter route first.
+
+- Keep robotics transport in an integration crate or module that bridges ROS topics/services/actions to ZeroClaw tools/channels/runtime adapters.
+- Keep high-frequency control loops in ROS-native execution contexts.
+- Use ZeroClaw for planning, orchestration, policy, and guarded action dispatch.
+
+Deep core coupling should be a last resort and only justified by measured latency limits that cannot be met with a bridge.
+
+## Why This Is The Default
+
+- Upgrade safety: trait-based adapters survive upstream changes better than core patches.
+- Blast-radius control: transport details stay outside security/runtime core modules.
+- Reproducibility: integration behavior is easier to test and rollback when isolated.
+- Security posture: approval, policy, and gating remain centralized in existing ZeroClaw paths.
+
+## Real-Time Boundary Rule
+
+Do not route hard real-time motor/safety loops through LLM turn latency.
+
+- ROS node graph handles tight-loop control and watchdogs.
+- ZeroClaw emits intent-level commands and receives summarized state.
+- Safety-critical stop paths stay local to robot runtime regardless of agent health.
+
+## Suggested Baseline Architecture
+
+1. ROS2 bridge node subscribes to high-rate sensor topics.
+2. Bridge performs local reduction/windowing and forwards compact summaries to ZeroClaw.
+3. ZeroClaw decides intent/tool calls under existing policy and approval constraints.
+4. Bridge translates approved intents into ROS commands with bounded command-rate limits.
+5. Telemetry and fault states flow back into ZeroClaw for reasoning and auditability.
+
+## Escalation Criteria For Core Integration
+
+Consider deeper ZeroClaw runtime integration only when all are true:
+
+- Measured bridge overhead is a validated bottleneck under production-like load.
+- Required latency/jitter budgets are written and reproducible.
+- The proposed core change has clear rollback and subsystem ownership.
+- Security and policy guarantees remain equivalent or stronger.
+
+If those conditions are not met, stay with adapter/plugin integration.
diff --git a/docs/security/README.md b/docs/security/README.md
index b3c293063..9056ecd0b 100644
--- a/docs/security/README.md
+++ b/docs/security/README.md
@@ -7,6 +7,7 @@ This section mixes current hardening guidance and proposal/roadmap documents.
For current runtime behavior, start here:
- Repository security policy and vulnerability handling workflow: [../../SECURITY.md](../../SECURITY.md)
+- Official channels and fraud-prevention statement: [official-channels-and-fraud-prevention.md](official-channels-and-fraud-prevention.md)
- Private vulnerability report template: [private-vulnerability-report-template.md](private-vulnerability-report-template.md)
- 私密漏洞报告模板(中文): [private-vulnerability-report-template.zh-CN.md](private-vulnerability-report-template.zh-CN.md)
- Advisory maintainer checklist: [advisory-maintainer-checklist.md](advisory-maintainer-checklist.md)
@@ -18,6 +19,7 @@ For current runtime behavior, start here:
- Troubleshooting: [../troubleshooting.md](../troubleshooting.md)
- CI/Security audit event schema: [../audit-event-schema.md](../audit-event-schema.md)
- Syscall anomaly detection: [./syscall-anomaly-detection.md](./syscall-anomaly-detection.md)
+- Perplexity suffix filter: [./perplexity-filter.md](./perplexity-filter.md)
## Proposal / Roadmap Docs
diff --git a/docs/security/official-channels-and-fraud-prevention.md b/docs/security/official-channels-and-fraud-prevention.md
new file mode 100644
index 000000000..0a4f42b75
--- /dev/null
+++ b/docs/security/official-channels-and-fraud-prevention.md
@@ -0,0 +1,44 @@
+# Official Channels And Fraud Prevention
+
+This page is the evergreen security statement for community safety and impersonation defense.
+
+## Fraud Warning
+
+Scammers may impersonate ZeroClaw maintainers, contributors, or community members.
+
+Assume fraud if someone claiming to represent ZeroClaw asks for:
+
+- cryptocurrency transfers
+- wallet access or seed phrases
+- private financial information
+- private credentials outside official security reporting flow
+
+ZeroClaw maintainers do not request money or private wallet/financial credentials via direct messages.
+
+## Official Sources Of Truth
+
+Use these sources to verify announcements:
+
+- GitHub repository: `zeroclaw-labs/zeroclaw`
+- GitHub Security policy and advisories: [../../SECURITY.md](../../SECURITY.md)
+
+Treat third-party links and social posts as untrusted until confirmed in the GitHub repository.
+
+## How To Verify Announcements
+
+1. Check whether the same announcement exists in GitHub issues, PRs, releases, or docs.
+2. Confirm the posting account is an expected project maintainer/org account.
+3. Prefer links that originate from repository pages rather than forwarded DMs.
+
+## Reporting Suspicious Activity
+
+If you see impersonation attempts or scam outreach:
+
+1. Do not engage or send funds/data.
+2. Capture evidence (screenshots, usernames, URLs, timestamps).
+3. Open a GitHub issue in `zeroclaw-labs/zeroclaw` with sanitized details.
+
+For vulnerability disclosure, use private reporting:
+
+- Security policy: [../../SECURITY.md](../../SECURITY.md)
+- Private report template: [private-vulnerability-report-template.md](private-vulnerability-report-template.md)
diff --git a/docs/security/perplexity-filter.md b/docs/security/perplexity-filter.md
new file mode 100644
index 000000000..232fbf0df
--- /dev/null
+++ b/docs/security/perplexity-filter.md
@@ -0,0 +1,45 @@
+# Perplexity Filter (Opt-In)
+
+ZeroClaw provides an opt-in lightweight statistical filter that detects
+adversarial suffixes (for example, GCG-style optimized gibberish tails)
+before messages are sent to an LLM provider.
+
+## Scope
+
+- Applies to channel and gateway inbound messages before provider execution.
+- Does not require external model calls or heavyweight guard models.
+- Disabled by default for compatibility and latency predictability.
+
+## How It Works
+
+The filter evaluates a trailing prompt window using:
+
+1. Character-class bigram perplexity.
+2. Suffix punctuation ratio.
+3. GCG-like token pattern checks (mixed punctuation + letters + digits).
+
+The message is blocked only when anomaly criteria are met.
+
+## Config
+
+```toml
+[security.perplexity_filter]
+enable_perplexity_filter = true
+perplexity_threshold = 16.5
+suffix_window_chars = 72
+min_prompt_chars = 40
+symbol_ratio_threshold = 0.25
+```
+
+## Latency
+
+The implementation is O(n) over prompt length and avoids network calls.
+Local debug-safe regression includes a strict `<50ms` budget test for a
+typical multi-sentence prompt payload.
+
+## Tuning Guidance
+
+- Increase `perplexity_threshold` if you see false positives.
+- Increase `symbol_ratio_threshold` to reduce blocking of technical strings.
+- Increase `min_prompt_chars` to ignore short prompts where statistics are weak.
+- Keep the feature disabled unless you explicitly need this extra defense layer.
diff --git a/docs/structure/README.md b/docs/structure/README.md
index ed62fc804..b4b3fa321 100644
--- a/docs/structure/README.md
+++ b/docs/structure/README.md
@@ -1,87 +1,90 @@
# ZeroClaw Docs Structure Map
-This page defines the documentation structure across three axes:
+This page defines the canonical documentation layout and compatibility layers.
-1. Language
-2. Part (category)
-3. Function (document intent)
+Last refreshed: **February 24, 2026**.
-Last refreshed: **February 22, 2026**.
+Companion indexes:
+- Function-oriented map: [by-function.md](by-function.md)
+- Hub entry point: [../README.md](../README.md)
+- Unified TOC: [../SUMMARY.md](../SUMMARY.md)
-## 1) By Language
+## 1) Directory Spine (Canonical)
-| Language | Entry point | Canonical tree | Notes |
-|---|---|---|---|
-| English | `docs/README.md` | `docs/` | Source-of-truth runtime behavior docs are authored in English first. |
-| Chinese (`zh-CN`) | `docs/README.zh-CN.md` | `docs/` localized hub + selected localized docs | Uses localized hub and shared category structure. |
-| Japanese (`ja`) | `docs/README.ja.md` | `docs/` localized hub + selected localized docs | Uses localized hub and shared category structure. |
-| Russian (`ru`) | `docs/README.ru.md` | `docs/` localized hub + selected localized docs | Uses localized hub and shared category structure. |
-| French (`fr`) | `docs/README.fr.md` | `docs/` localized hub + selected localized docs | Uses localized hub and shared category structure. |
-| Vietnamese (`vi`) | `docs/i18n/vi/README.md` | `docs/i18n/vi/` | Full Vietnamese tree is canonical under `docs/i18n/vi/`; `docs/vi/` and `docs/*.vi.md` are compatibility paths. |
+### Layer A: global entry points
-## 2) By Part (Category)
+- Root product landing: `README.md` (language switch links into `docs/i18n//README.md`)
+- Docs hub: `docs/README.md`
+- Unified TOC: `docs/SUMMARY.md`
-These directories are the primary navigation modules by product area.
+### Layer B: category collections (English source-of-truth)
-- `docs/getting-started/` for initial setup and first-run flows
-- `docs/reference/` for command/config/provider/channel reference indexes
-- `docs/operations/` for day-2 operations, deployment, and troubleshooting entry points
-- `docs/security/` for security guidance and security-oriented navigation
-- `docs/hardware/` for board/peripheral implementation and hardware workflows
-- `docs/contributing/` for contribution and CI/review processes
-- `docs/project/` for project snapshots, planning context, and status-oriented docs
+- `docs/getting-started/`
+- `docs/reference/`
+- `docs/operations/`
+- `docs/security/`
+- `docs/hardware/`
+- `docs/contributing/`
+- `docs/project/`
+- `docs/sop/`
-## 3) By Function (Document Intent)
+### Layer C: canonical locale trees
-Use this grouping to decide where new docs belong.
+- `docs/i18n/zh-CN/`
+- `docs/i18n/ja/`
+- `docs/i18n/ru/`
+- `docs/i18n/fr/`
+- `docs/i18n/vi/`
+- `docs/i18n/el/`
-### Runtime Contract (current behavior)
+### Layer D: compatibility shims (non-canonical)
-- `docs/commands-reference.md`
-- `docs/providers-reference.md`
-- `docs/channels-reference.md`
-- `docs/config-reference.md`
-- `docs/operations-runbook.md`
-- `docs/troubleshooting.md`
-- `docs/one-click-bootstrap.md`
+- `docs/SUMMARY..md` (if retained)
+- `docs/vi/**`
+- legacy localized docs-root files where present
-### Setup / Integration Guides
+Use compatibility paths for backward links only. New localized edits should target `docs/i18n//**`.
-- `docs/custom-providers.md`
-- `docs/zai-glm-setup.md`
-- `docs/langgraph-integration.md`
-- `docs/network-deployment.md`
-- `docs/matrix-e2ee-guide.md`
-- `docs/mattermost-setup.md`
-- `docs/nextcloud-talk-setup.md`
+## 2) Language Topology
-### Policy / Process
+| Locale | Root landing | Canonical docs hub | Coverage level | Notes |
+|---|---|---|---|---|
+| `en` | `README.md` | `docs/README.md` | Full source | Authoritative runtime-contract wording |
+| `zh-CN` | `docs/i18n/zh-CN/README.md` | `docs/i18n/zh-CN/README.md` | Hub-level scaffold | Runtime-contract docs mainly shared in English |
+| `ja` | `docs/i18n/ja/README.md` | `docs/i18n/ja/README.md` | Hub-level scaffold | Runtime-contract docs mainly shared in English |
+| `ru` | `docs/i18n/ru/README.md` | `docs/i18n/ru/README.md` | Hub-level scaffold | Runtime-contract docs mainly shared in English |
+| `fr` | `docs/i18n/fr/README.md` | `docs/i18n/fr/README.md` | Hub-level scaffold | Runtime-contract docs mainly shared in English |
+| `vi` | `docs/i18n/vi/README.md` | `docs/i18n/vi/README.md` | Full localized tree | `docs/vi/**` kept as compatibility layer |
+| `el` | `docs/i18n/el/README.md` | `docs/i18n/el/README.md` | Full localized tree | Greek full tree is canonical in `docs/i18n/el/**` |
-- `docs/pr-workflow.md`
-- `docs/reviewer-playbook.md`
-- `docs/ci-map.md`
-- `docs/actions-source-policy.md`
+## 3) Category Intent Map
-### Proposals / Roadmaps
+| Category | Canonical index | Intent |
+|---|---|---|
+| Getting Started | `docs/getting-started/README.md` | first-run and install flows |
+| Reference | `docs/reference/README.md` | commands/config/providers/channels and integration references |
+| Operations | `docs/operations/README.md` | day-2 operations, release, troubleshooting runbooks |
+| Security | `docs/security/README.md` | current hardening guidance + proposal boundary |
+| Hardware | `docs/hardware/README.md` | boards, peripherals, datasheets navigation |
+| Contributing | `docs/contributing/README.md` | PR/review/CI policy and process |
+| Project | `docs/project/README.md` | time-bound snapshots and planning audit history |
+| SOP | `docs/sop/README.md` | SOP runtime contract and procedure docs |
-- `docs/sandboxing.md`
-- `docs/resource-limits.md`
-- `docs/audit-logging.md`
-- `docs/agnostic-security.md`
-- `docs/frictionless-security.md`
-- `docs/security-roadmap.md`
+## 4) Placement Rules
-### Snapshots / Time-Bound Reports
+1. Runtime behavior docs go in English canonical paths first.
+2. Every new major doc must be linked from:
+- the nearest category index (`docs//README.md`)
+- `docs/SUMMARY.md`
+- `docs/docs-inventory.md`
+3. Locale navigation changes must update all supported locales (`en`, `zh-CN`, `ja`, `ru`, `fr`, `vi`, `el`).
+4. For localized hubs/summaries, canonical path is always `docs/i18n//`.
+5. Keep compatibility shims aligned when touched; do not introduce new primary content under compatibility-only paths.
-- `docs/project-triage-snapshot-2026-02-18.md`
+## 5) Governance Links
-### Assets / Templates
-
-- `docs/datasheets/`
-- `docs/doc-template.md`
-
-## Placement Rules (Quick)
-
-- New runtime behavior docs must be linked from the appropriate category index and `docs/SUMMARY.md`.
-- Navigation changes must preserve locale parity across `docs/README*.md` and `docs/SUMMARY*.md`.
-- Vietnamese full localization lives in `docs/i18n/vi/`; compatibility files should point to canonical paths.
+- i18n docs index: [../i18n/README.md](../i18n/README.md)
+- i18n coverage matrix: [../i18n-coverage.md](../i18n-coverage.md)
+- i18n completion checklist: [../i18n-guide.md](../i18n-guide.md)
+- i18n gap backlog: [../i18n-gap-backlog.md](../i18n-gap-backlog.md)
+- docs inventory/classification: [../docs-inventory.md](../docs-inventory.md)
diff --git a/docs/structure/by-function.md b/docs/structure/by-function.md
new file mode 100644
index 000000000..35726b0a3
--- /dev/null
+++ b/docs/structure/by-function.md
@@ -0,0 +1,65 @@
+# ZeroClaw Docs By Function
+
+This index groups documentation by operational function instead of folder path.
+
+Use this when you know what you need to do, but not where the doc lives.
+
+## Setup And Onboarding
+
+- Core quick start: [../../README.md](../../README.md)
+- Docs hub: [../README.md](../README.md)
+- One-click bootstrap: [../one-click-bootstrap.md](../one-click-bootstrap.md)
+- Android setup: [../android-setup.md](../android-setup.md)
+- Docker setup: [../docker-setup.md](../docker-setup.md)
+- Getting started collection: [../getting-started/README.md](../getting-started/README.md)
+
+## Commands, Config, And Providers
+
+- Commands reference: [../commands-reference.md](../commands-reference.md)
+- Config reference: [../config-reference.md](../config-reference.md)
+- Providers reference: [../providers-reference.md](../providers-reference.md)
+- Channels reference: [../channels-reference.md](../channels-reference.md)
+- Custom providers: [../custom-providers.md](../custom-providers.md)
+- Z.AI/GLM setup: [../zai-glm-setup.md](../zai-glm-setup.md)
+- Reference collection: [../reference/README.md](../reference/README.md)
+
+## Operations And Deployment
+
+- Operations runbook: [../operations-runbook.md](../operations-runbook.md)
+- Troubleshooting: [../troubleshooting.md](../troubleshooting.md)
+- Network deployment: [../network-deployment.md](../network-deployment.md)
+- Release process: [../release-process.md](../release-process.md)
+- Operations collection: [../operations/README.md](../operations/README.md)
+
+## Security And Trust
+
+- Security collection: [../security/README.md](../security/README.md)
+- Official channels and fraud prevention: [../security/official-channels-and-fraud-prevention.md](../security/official-channels-and-fraud-prevention.md)
+- Security roadmap: [../security-roadmap.md](../security-roadmap.md)
+- Sandboxing: [../sandboxing.md](../sandboxing.md)
+- Audit logging: [../audit-logging.md](../audit-logging.md)
+- Resource limits: [../resource-limits.md](../resource-limits.md)
+
+## Hardware And Peripherals
+
+- Hardware collection: [../hardware/README.md](../hardware/README.md)
+- Add boards/tools: [../adding-boards-and-tools.md](../adding-boards-and-tools.md)
+- Nucleo setup: [../nucleo-setup.md](../nucleo-setup.md)
+- Arduino setup: [../arduino-uno-q-setup.md](../arduino-uno-q-setup.md)
+- Datasheets index: [../datasheets/README.md](../datasheets/README.md)
+
+## Contributing And CI
+
+- Contribution collection: [../contributing/README.md](../contributing/README.md)
+- PR workflow: [../pr-workflow.md](../pr-workflow.md)
+- Reviewer playbook: [../reviewer-playbook.md](../reviewer-playbook.md)
+- CI map: [../ci-map.md](../ci-map.md)
+- Actions source policy: [../actions-source-policy.md](../actions-source-policy.md)
+
+## Localization And Information Architecture
+
+- i18n index: [../i18n/README.md](../i18n/README.md)
+- i18n coverage map: [../i18n-coverage.md](../i18n-coverage.md)
+- i18n guide: [../i18n-guide.md](../i18n-guide.md)
+- Docs inventory: [../docs-inventory.md](../docs-inventory.md)
+- Docs structure map: [README.md](README.md)
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index 903ab409c..c72826fee 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -192,6 +192,97 @@ zeroclaw channel doctor
Then verify channel-specific credentials + allowlist fields in config.
+## Web Access Issues
+
+### `curl`/`wget` blocked in shell tool
+
+Symptom:
+
+- tool output includes `Command blocked: high-risk command is disallowed by policy`
+- model says `curl`/`wget` is blocked
+
+Why this happens:
+
+- `curl`/`wget` are high-risk shell commands and may be blocked by autonomy policy.
+
+Preferred fix:
+
+- use purpose-built tools instead of shell fetch:
+ - `http_request` for direct API/HTTP calls
+ - `web_fetch` for page content extraction/summarization
+
+Minimal config:
+
+```toml
+[http_request]
+enabled = true
+allowed_domains = ["*"]
+
+[web_fetch]
+enabled = true
+provider = "fast_html2md"
+allowed_domains = ["*"]
+```
+
+### `web_search_tool` fails with `403`/`429`
+
+Symptom:
+
+- tool output includes `DuckDuckGo search failed with status: 403` (or `429`)
+
+Why this happens:
+
+- some networks/proxies/rate limits block DuckDuckGo HTML search endpoint traffic.
+
+Fix options:
+
+1. Switch provider to Brave (recommended when you have an API key):
+
+```toml
+[web_search]
+enabled = true
+provider = "brave"
+brave_api_key = ""
+```
+
+2. Switch provider to Firecrawl (if enabled in your build):
+
+```toml
+[web_search]
+enabled = true
+provider = "firecrawl"
+api_key = ""
+```
+
+3. Keep DuckDuckGo for search, but use `web_fetch` to read pages once you have URLs.
+
+### `web_fetch`/`http_request` says host is not allowed
+
+Symptom:
+
+- errors like `Host '' is not in http_request.allowed_domains`
+- or `web_fetch tool is enabled but no allowed_domains are configured`
+
+Fix:
+
+- include exact domains or `"*"` for public internet access:
+
+```toml
+[http_request]
+enabled = true
+allowed_domains = ["*"]
+
+[web_fetch]
+enabled = true
+allowed_domains = ["*"]
+blocked_domains = []
+```
+
+Security notes:
+
+- local/private network targets are blocked even with `"*"`
+- keep explicit domain allowlists in production environments when possible
+
## Service Mode
### Service installed but not running
diff --git a/docs/vi/config-reference.md b/docs/vi/config-reference.md
deleted file mode 100644
index 3b1b6a14a..000000000
--- a/docs/vi/config-reference.md
+++ /dev/null
@@ -1,519 +0,0 @@
-# Tham khảo cấu hình ZeroClaw
-
-Các mục cấu hình thường dùng và giá trị mặc định.
-
-Xác minh lần cuối: **2026-02-19**.
-
-Thứ tự tìm config khi khởi động:
-
-1. Biến `ZEROCLAW_WORKSPACE` (nếu được đặt)
-2. Marker `~/.zeroclaw/active_workspace.toml` (nếu có)
-3. Mặc định `~/.zeroclaw/config.toml`
-
-ZeroClaw ghi log đường dẫn config đã giải quyết khi khởi động ở mức `INFO`:
-
-- `Config loaded` với các trường: `path`, `workspace`, `source`, `initialized`
-
-Lệnh xuất schema:
-
-- `zeroclaw config schema` (xuất JSON Schema draft 2020-12 ra stdout)
-
-## Khóa chính
-
-| Khóa | Mặc định | Ghi chú |
-|---|---|---|
-| `default_provider` | `openrouter` | ID hoặc bí danh provider |
-| `default_model` | `anthropic/claude-sonnet-4-6` | Model định tuyến qua provider đã chọn |
-| `default_temperature` | `0.7` | Nhiệt độ model |
-
-## `[observability]`
-
-| Khóa | Mặc định | Mục đích |
-|---|---|---|
-| `backend` | `none` | Backend quan sát: `none`, `noop`, `log`, `prometheus`, `otel`, `opentelemetry` hoặc `otlp` |
-| `otel_endpoint` | `http://localhost:4318` | Endpoint OTLP HTTP khi backend là `otel` |
-| `otel_service_name` | `zeroclaw` | Tên dịch vụ gửi đến OTLP collector |
-
-Lưu ý:
-
-- `backend = "otel"` dùng OTLP HTTP export với blocking exporter client để span và metric có thể được gửi an toàn từ context ngoài Tokio.
-- Bí danh `opentelemetry` và `otlp` trỏ đến cùng backend OTel.
-
-Ví dụ:
-
-```toml
-[observability]
-backend = "otel"
-otel_endpoint = "http://localhost:4318"
-otel_service_name = "zeroclaw"
-```
-
-## Ghi đè provider qua biến môi trường
-
-Provider cũng có thể chọn qua biến môi trường. Thứ tự ưu tiên:
-
-1. `ZEROCLAW_PROVIDER` (ghi đè tường minh, luôn thắng khi có giá trị)
-2. `PROVIDER` (dự phòng kiểu cũ, chỉ áp dụng khi provider trong config chưa đặt hoặc vẫn là `openrouter`)
-3. `default_provider` trong `config.toml`
-
-Lưu ý cho người dùng container:
-
-- Nếu `config.toml` đặt provider tùy chỉnh như `custom:https://.../v1`, biến `PROVIDER=openrouter` mặc định từ Docker/container sẽ không thay thế nó.
-- Dùng `ZEROCLAW_PROVIDER` khi cố ý muốn biến môi trường ghi đè provider đã cấu hình.
-
-## `[agent]`
-
-| Khóa | Mặc định | Mục đích |
-|---|---|---|
-| `compact_context` | `false` | Khi bật: bootstrap_max_chars=6000, rag_chunk_limit=2. Dùng cho model 13B trở xuống |
-| `max_tool_iterations` | `10` | Số vòng lặp tool-call tối đa mỗi tin nhắn trên CLI, gateway và channels |
-| `max_history_messages` | `50` | Số tin nhắn lịch sử tối đa giữ lại mỗi phiên |
-| `parallel_tools` | `false` | Bật thực thi tool song song trong một lượt |
-| `tool_dispatcher` | `auto` | Chiến lược dispatch tool |
-
-Lưu ý:
-
-- Đặt `max_tool_iterations = 0` sẽ dùng giá trị mặc định an toàn `10`.
-- Nếu tin nhắn kênh vượt giá trị này, runtime trả về: `Agent exceeded maximum tool iterations ()`.
-- Trong vòng lặp tool của CLI, gateway và channel, các lời gọi tool độc lập được thực thi đồng thời mặc định khi không cần phê duyệt; thứ tự kết quả giữ ổn định.
-- `parallel_tools` áp dụng cho API `Agent::turn()`. Không ảnh hưởng đến vòng lặp runtime của CLI, gateway hay channel.
-
-## `[agents.]`
-
-Cấu hình agent phụ (sub-agent). Mỗi khóa dưới `[agents]` định nghĩa một agent phụ có tên mà agent chính có thể ủy quyền.
-
-| Khóa | Mặc định | Mục đích |
-|---|---|---|
-| `provider` | _bắt buộc_ | Tên provider (ví dụ `"ollama"`, `"openrouter"`, `"anthropic"`) |
-| `model` | _bắt buộc_ | Tên model cho agent phụ |
-| `system_prompt` | chưa đặt | System prompt tùy chỉnh cho agent phụ (tùy chọn) |
-| `api_key` | chưa đặt | API key tùy chỉnh (mã hóa khi `secrets.encrypt = true`) |
-| `temperature` | chưa đặt | Temperature tùy chỉnh cho agent phụ |
-| `max_depth` | `3` | Độ sâu đệ quy tối đa cho ủy quyền lồng nhau |
-| `agentic` | `false` | Bật chế độ vòng lặp tool-call nhiều lượt cho agent phụ |
-| `allowed_tools` | `[]` | Danh sách tool được phép ở chế độ agentic |
-| `max_iterations` | `10` | Số vòng tool-call tối đa cho chế độ agentic |
-
-Lưu ý:
-
-- `agentic = false` giữ nguyên hành vi ủy quyền prompt→response đơn lượt.
-- `agentic = true` yêu cầu ít nhất một mục khớp trong `allowed_tools`.
-- Tool `delegate` bị loại khỏi allowlist của agent phụ để tránh vòng lặp ủy quyền.
-
-```toml
-[agents.researcher]
-provider = "openrouter"
-model = "anthropic/claude-sonnet-4-6"
-system_prompt = "You are a research assistant."
-max_depth = 2
-agentic = true
-allowed_tools = ["web_search", "http_request", "file_read"]
-max_iterations = 8
-
-[agents.coder]
-provider = "ollama"
-model = "qwen2.5-coder:32b"
-temperature = 0.2
-```
-
-## `[runtime]`
-
-| Khóa | Mặc định | Mục đích |
-|---|---|---|
-| `reasoning_enabled` | chưa đặt (`None`) | Ghi đè toàn cục cho reasoning/thinking trên provider hỗ trợ |
-
-Lưu ý:
-
-- `reasoning_enabled = false` tắt tường minh reasoning phía provider cho provider hỗ trợ (hiện tại `ollama`, qua trường `think: false`).
-- `reasoning_enabled = true` yêu cầu reasoning tường minh (`think: true` trên `ollama`).
-- Để trống giữ mặc định của provider.
-
-## `[skills]`
-
-| Khóa | Mặc định | Mục đích |
-|---|---|---|
-| `open_skills_enabled` | `false` | Cho phép tải/đồng bộ kho `open-skills` cộng đồng |
-| `open_skills_dir` | chưa đặt | Đường dẫn cục bộ cho `open-skills` (mặc định `$HOME/open-skills` khi bật) |
-
-Lưu ý:
-
-- Mặc định an toàn: ZeroClaw **không** clone hay đồng bộ `open-skills` trừ khi `open_skills_enabled = true`.
-- Ghi đè qua biến môi trường:
- - `ZEROCLAW_OPEN_SKILLS_ENABLED` chấp nhận `1/0`, `true/false`, `yes/no`, `on/off`.
- - `ZEROCLAW_OPEN_SKILLS_DIR` ghi đè đường dẫn kho khi có giá trị.
-- Thứ tự ưu tiên: `ZEROCLAW_OPEN_SKILLS_ENABLED` → `skills.open_skills_enabled` trong `config.toml` → mặc định `false`.
-
-## `[composio]`
-
-| Khóa | Mặc định | Mục đích |
-|---|---|---|
-| `enabled` | `false` | Bật công cụ OAuth do Composio quản lý |
-| `api_key` | chưa đặt | API key Composio cho tool `composio` |
-| `entity_id` | `default` | `user_id` mặc định gửi khi gọi connect/execute |
-
-Lưu ý:
-
-- Tương thích ngược: `enable = true` kiểu cũ được chấp nhận như bí danh cho `enabled = true`.
-- Nếu `enabled = false` hoặc thiếu `api_key`, tool `composio` không được đăng ký.
-- ZeroClaw yêu cầu Composio v3 tools với `toolkit_versions=latest` và thực thi với `version="latest"` để tránh bản tool mặc định cũ.
-- Luồng thông thường: gọi `connect`, hoàn tất OAuth trên trình duyệt, rồi chạy `execute` cho hành động mong muốn.
-- Nếu Composio trả lỗi thiếu connected-account, gọi `list_accounts` (tùy chọn với `app`) và truyền `connected_account_id` trả về cho `execute`.
-
-## `[cost]`
-
-| Khóa | Mặc định | Mục đích |
-|---|---|---|
-| `enabled` | `false` | Bật theo dõi chi phí |
-| `daily_limit_usd` | `10.00` | Giới hạn chi tiêu hàng ngày (USD) |
-| `monthly_limit_usd` | `100.00` | Giới hạn chi tiêu hàng tháng (USD) |
-| `warn_at_percent` | `80` | Cảnh báo khi chi tiêu đạt tỷ lệ phần trăm này |
-| `allow_override` | `false` | Cho phép vượt ngân sách khi dùng cờ `--override` |
-
-Lưu ý:
-
-- Khi `enabled = true`, runtime theo dõi ước tính chi phí mỗi yêu cầu và áp dụng giới hạn ngày/tháng.
-- Tại ngưỡng `warn_at_percent`, cảnh báo được gửi nhưng yêu cầu vẫn tiếp tục.
-- Khi đạt giới hạn, yêu cầu bị từ chối trừ khi `allow_override = true` và cờ `--override` được truyền.
-
-## `[identity]`
-
-| Khóa | Mặc định | Mục đích |
-|---|---|---|
-| `format` | `openclaw` | Định dạng danh tính: `"openclaw"` (mặc định) hoặc `"aieos"` |
-| `aieos_path` | chưa đặt | Đường dẫn file AIEOS JSON (tương đối với workspace) |
-| `aieos_inline` | chưa đặt | AIEOS JSON nội tuyến (thay thế cho đường dẫn file) |
-
-Lưu ý:
-
-- Dùng `format = "aieos"` với `aieos_path` hoặc `aieos_inline` để tải tài liệu danh tính AIEOS / OpenClaw.
-- Chỉ nên đặt một trong hai `aieos_path` hoặc `aieos_inline`; `aieos_path` được ưu tiên.
-
-## `[multimodal]`
-
-| Khóa | Mặc định | Mục đích |
-|---|---|---|
-| `max_images` | `4` | Số marker ảnh tối đa mỗi yêu cầu |
-| `max_image_size_mb` | `5` | Giới hạn kích thước ảnh trước khi mã hóa base64 |
-| `allow_remote_fetch` | `false` | Cho phép tải ảnh từ URL `http(s)` trong marker |
-
-Lưu ý:
-
-- Runtime chấp nhận marker ảnh trong tin nhắn với cú pháp: ``[IMAGE:]``.
-- Nguồn hỗ trợ:
- - Đường dẫn file cục bộ (ví dụ ``[IMAGE:/tmp/screenshot.png]``)
-- Data URI (ví dụ ``[IMAGE:data:image/png;base64,...]``)
-- URL từ xa chỉ khi `allow_remote_fetch = true`
-- Kiểu MIME cho phép: `image/png`, `image/jpeg`, `image/webp`, `image/gif`, `image/bmp`.
-- Khi provider đang dùng không hỗ trợ vision, yêu cầu thất bại với lỗi capability có cấu trúc (`capability=vision`) thay vì bỏ qua ảnh.
-
-## `[browser]`
-
-| Khóa | Mặc định | Mục đích |
-|---|---|---|
-| `enabled` | `false` | Bật tool `browser_open` (mở URL trong trình duyệt mặc định hệ thống, không thu thập dữ liệu) |
-| `allowed_domains` | `[]` | Tên miền cho phép cho `browser_open` (khớp chính xác hoặc subdomain) |
-| `session_name` | chưa đặt | Tên phiên trình duyệt (cho tự động hóa agent-browser) |
-| `backend` | `agent_browser` | Backend tự động hóa: `"agent_browser"`, `"rust_native"`, `"computer_use"` hoặc `"auto"` |
-| `native_headless` | `true` | Chế độ headless cho backend rust-native |
-| `native_webdriver_url` | `http://127.0.0.1:9515` | URL endpoint WebDriver cho backend rust-native |
-| `native_chrome_path` | chưa đặt | Đường dẫn Chrome/Chromium tùy chọn cho backend rust-native |
-
-### `[browser.computer_use]`
-
-| Khóa | Mặc định | Mục đích |
-|---|---|---|
-| `endpoint` | `http://127.0.0.1:8787/v1/actions` | Endpoint sidecar cho hành động computer-use (chuột/bàn phím/screenshot cấp OS) |
-| `api_key` | chưa đặt | Bearer token tùy chọn cho sidecar computer-use (mã hóa khi lưu) |
-| `timeout_ms` | `15000` | Thời gian chờ mỗi hành động (mili giây) |
-| `allow_remote_endpoint` | `false` | Cho phép endpoint từ xa/công khai cho sidecar |
-| `window_allowlist` | `[]` | Danh sách cho phép tiêu đề cửa sổ/tiến trình gửi đến sidecar |
-| `max_coordinate_x` | chưa đặt | Giới hạn trục X cho hành động dựa trên tọa độ (tùy chọn) |
-| `max_coordinate_y` | chưa đặt | Giới hạn trục Y cho hành động dựa trên tọa độ (tùy chọn) |
-
-Lưu ý:
-
-- Khi `backend = "computer_use"`, agent ủy quyền hành động trình duyệt cho sidecar tại `computer_use.endpoint`.
-- `allow_remote_endpoint = false` (mặc định) từ chối mọi endpoint không phải loopback để tránh lộ ra ngoài.
-- Dùng `window_allowlist` để giới hạn cửa sổ OS mà sidecar có thể tương tác.
-
-## `[http_request]`
-
-| Khóa | Mặc định | Mục đích |
-|---|---|---|
-| `enabled` | `false` | Bật tool `http_request` cho tương tác API |
-| `allowed_domains` | `[]` | Tên miền cho phép (khớp chính xác hoặc subdomain) |
-| `max_response_size` | `1000000` | Kích thước response tối đa (byte, mặc định: 1 MB) |
-| `timeout_secs` | `30` | Thời gian chờ yêu cầu (giây) |
-
-Lưu ý:
-
-- Mặc định từ chối tất cả: nếu `allowed_domains` rỗng, mọi yêu cầu HTTP bị từ chối.
-- Dùng khớp tên miền chính xác hoặc subdomain (ví dụ `"api.example.com"`, `"example.com"`).
-
-## `[gateway]`
-
-| Khóa | Mặc định | Mục đích |
-|---|---|---|
-| `host` | `127.0.0.1` | Địa chỉ bind |
-| `port` | `3000` | Cổng lắng nghe gateway |
-| `require_pairing` | `true` | Yêu cầu ghép nối trước khi xác thực bearer |
-| `allow_public_bind` | `false` | Chặn lộ public do vô ý |
-
-## `[autonomy]`
-
-| Khóa | Mặc định | Mục đích |
-|---|---|---|
-| `level` | `supervised` | `read_only`, `supervised` hoặc `full` |
-| `workspace_only` | `true` | Giới hạn ghi/lệnh trong phạm vi workspace |
-| `allowed_commands` | _bắt buộc để chạy shell_ | Danh sách lệnh được phép |
-| `forbidden_paths` | `[]` | Danh sách đường dẫn bị cấm |
-| `max_actions_per_hour` | `100` | Ngân sách hành động mỗi giờ |
-| `max_cost_per_day_cents` | `1000` | Giới hạn chi tiêu mỗi ngày (cent) |
-| `require_approval_for_medium_risk` | `true` | Yêu cầu phê duyệt cho lệnh rủi ro trung bình |
-| `block_high_risk_commands` | `true` | Chặn cứng lệnh rủi ro cao |
-| `auto_approve` | `[]` | Thao tác tool luôn được tự động phê duyệt |
-| `always_ask` | `[]` | Thao tác tool luôn yêu cầu phê duyệt |
-
-Lưu ý:
-
-- `level = "full"` bỏ qua phê duyệt rủi ro trung bình cho shell execution, nhưng vẫn áp dụng guardrail đã cấu hình.
-- Phân tích toán tử/dấu phân cách shell nhận biết dấu ngoặc kép. Ký tự như `;` trong đối số được trích dẫn được xử lý là ký tự, không phải dấu phân cách lệnh.
-- Toán tử chuỗi shell không trích dẫn vẫn được kiểm tra bởi policy (`;`, `|`, `&&`, `||`, chạy nền và chuyển hướng).
-
-## `[memory]`
-
-| Khóa | Mặc định | Mục đích |
-|---|---|---|
-| `backend` | `sqlite` | `sqlite`, `lucid`, `markdown`, `none` |
-| `auto_save` | `true` | Chỉ lưu đầu vào người dùng (đầu ra assistant bị loại) |
-| `embedding_provider` | `none` | `none`, `openai` hoặc endpoint tùy chỉnh |
-| `embedding_model` | `text-embedding-3-small` | ID model embedding, hoặc tuyến `hint:` |
-| `embedding_dimensions` | `1536` | Kích thước vector mong đợi cho model embedding đã chọn |
-| `vector_weight` | `0.7` | Trọng số vector trong xếp hạng kết hợp |
-| `keyword_weight` | `0.3` | Trọng số từ khóa trong xếp hạng kết hợp |
-
-Lưu ý:
-
-- Chèn ngữ cảnh memory bỏ qua khóa auto-save `assistant_resp*` kiểu cũ để tránh tóm tắt do model tạo bị coi là sự thật.
-
-## `[[model_routes]]` và `[[embedding_routes]]`
-
-Route hint giúp tên tích hợp ổn định khi model ID thay đổi.
-
-### `[[model_routes]]`
-
-| Khóa | Mặc định | Mục đích |
-|---|---|---|
-| `hint` | _bắt buộc_ | Tên hint tác vụ (ví dụ `"reasoning"`, `"fast"`, `"code"`, `"summarize"`) |
-| `provider` | _bắt buộc_ | Provider đích (phải khớp tên provider đã biết) |
-| `model` | _bắt buộc_ | Model sử dụng với provider đó |
-| `api_key` | chưa đặt | API key tùy chỉnh cho provider của route này (tùy chọn) |
-
-### `[[embedding_routes]]`
-
-| Khóa | Mặc định | Mục đích |
-|---|---|---|
-| `hint` | _bắt buộc_ | Tên route hint (ví dụ `"semantic"`, `"archive"`, `"faq"`) |
-| `provider` | _bắt buộc_ | Embedding provider (`"none"`, `"openai"` hoặc `"custom:"`) |
-| `model` | _bắt buộc_ | Model embedding sử dụng với provider đó |
-| `dimensions` | chưa đặt | Ghi đè kích thước embedding cho route này (tùy chọn) |
-| `api_key` | chưa đặt | API key tùy chỉnh cho provider của route này (tùy chọn) |
-
-```toml
-[memory]
-embedding_model = "hint:semantic"
-
-[[model_routes]]
-hint = "reasoning"
-provider = "openrouter"
-model = "provider/model-id"
-
-[[embedding_routes]]
-hint = "semantic"
-provider = "openai"
-model = "text-embedding-3-small"
-dimensions = 1536
-```
-
-Chiến lược nâng cấp:
-
-1. Giữ hint ổn định (`hint:reasoning`, `hint:semantic`).
-2. Chỉ cập nhật `model = "...phiên-bản-mới..."` trong mục route.
-3. Kiểm tra bằng `zeroclaw doctor` trước khi khởi động lại/triển khai.
-
-## `[query_classification]`
-
-Tự động định tuyến tin nhắn đến hint `[[model_routes]]` theo mẫu nội dung.
-
-| Khóa | Mặc định | Mục đích |
-|---|---|---|
-| `enabled` | `false` | Bật phân loại truy vấn tự động |
-| `rules` | `[]` | Quy tắc phân loại (đánh giá theo thứ tự ưu tiên) |
-
-Mỗi rule trong `rules`:
-
-| Khóa | Mặc định | Mục đích |
-|---|---|---|
-| `hint` | _bắt buộc_ | Phải khớp giá trị hint trong `[[model_routes]]` |
-| `keywords` | `[]` | Khớp chuỗi con không phân biệt hoa thường |
-| `patterns` | `[]` | Khớp chuỗi chính xác phân biệt hoa thường (cho code fence, từ khóa như `"fn "`) |
-| `min_length` | chưa đặt | Chỉ khớp nếu độ dài tin nhắn ≥ N ký tự |
-| `max_length` | chưa đặt | Chỉ khớp nếu độ dài tin nhắn ≤ N ký tự |
-| `priority` | `0` | Rule ưu tiên cao hơn được kiểm tra trước |
-
-```toml
-[query_classification]
-enabled = true
-
-[[query_classification.rules]]
-hint = "reasoning"
-keywords = ["explain", "analyze", "why"]
-min_length = 200
-priority = 10
-
-[[query_classification.rules]]
-hint = "fast"
-keywords = ["hi", "hello", "thanks"]
-max_length = 50
-priority = 5
-```
-
-## `[channels_config]`
-
-Cấu hình kênh cấp cao nằm dưới `channels_config`.
-
-| Khóa | Mặc định | Mục đích |
-|---|---|---|
-| `message_timeout_secs` | `300` | Thời gian chờ cơ bản (giây) cho xử lý tin nhắn kênh; runtime tự điều chỉnh theo độ sâu tool-loop (lên đến 4x) |
-
-Ví dụ:
-
-- `[channels_config.telegram]`
-- `[channels_config.discord]`
-- `[channels_config.whatsapp]`
-- `[channels_config.email]`
-
-Lưu ý:
-
-- Mặc định `300s` tối ưu cho LLM chạy cục bộ (Ollama) vốn chậm hơn cloud API.
-- Ngân sách timeout runtime là `message_timeout_secs * scale`, trong đó `scale = min(max_tool_iterations, 4)` và tối thiểu `1`.
-- Việc điều chỉnh này tránh timeout sai khi lượt LLM đầu chậm/retry nhưng các lượt tool-loop sau vẫn cần hoàn tất.
-- Nếu dùng cloud API (OpenAI, Anthropic, v.v.), có thể giảm xuống `60` hoặc thấp hơn.
-- Giá trị dưới `30` bị giới hạn thành `30` để tránh timeout liên tục.
-- Khi timeout xảy ra, người dùng nhận: `⚠️ Request timed out while waiting for the model. Please try again.`
-- Hành vi ngắt chỉ Telegram được điều khiển bằng `channels_config.telegram.interrupt_on_new_message` (mặc định `false`).
- Khi bật, tin nhắn mới từ cùng người gửi trong cùng chat sẽ hủy yêu cầu đang xử lý và giữ ngữ cảnh người dùng bị ngắt.
-- Khi `zeroclaw channel start` đang chạy, thay đổi `default_provider`, `default_model`, `default_temperature`, `api_key`, `api_url` và `reliability.*` được áp dụng nóng từ `config.toml` ở tin nhắn tiếp theo.
-
-Xem ma trận kênh và hành vi allowlist chi tiết tại [channels-reference.md](channels-reference.md).
-
-### `[channels_config.whatsapp]`
-
-WhatsApp hỗ trợ hai backend dưới cùng một bảng config.
-
-Chế độ Cloud API (webhook Meta):
-
-| Khóa | Bắt buộc | Mục đích |
-|---|---|---|
-| `access_token` | Có | Bearer token Meta Cloud API |
-| `phone_number_id` | Có | ID số điện thoại Meta |
-| `verify_token` | Có | Token xác minh webhook |
-| `app_secret` | Tùy chọn | Bật xác minh chữ ký webhook (`X-Hub-Signature-256`) |
-| `allowed_numbers` | Khuyến nghị | Số điện thoại cho phép gửi đến (`[]` = từ chối tất cả, `"*"` = cho phép tất cả) |
-
-Chế độ WhatsApp Web (client gốc):
-
-| Khóa | Bắt buộc | Mục đích |
-|---|---|---|
-| `session_path` | Có | Đường dẫn phiên SQLite lưu trữ lâu dài |
-| `pair_phone` | Tùy chọn | Số điện thoại cho luồng pair-code (chỉ chữ số) |
-| `pair_code` | Tùy chọn | Mã pair tùy chỉnh (nếu không sẽ tự tạo) |
-| `allowed_numbers` | Khuyến nghị | Số điện thoại cho phép gửi đến (`[]` = từ chối tất cả, `"*"` = cho phép tất cả) |
-
-Lưu ý:
-
-- WhatsApp Web yêu cầu build flag `whatsapp-web`.
-- Nếu cả Cloud lẫn Web đều có cấu hình, Cloud được ưu tiên để tương thích ngược.
-
-## `[hardware]`
-
-Cấu hình truy cập phần cứng vật lý (STM32, probe, serial).
-
-| Khóa | Mặc định | Mục đích |
-|---|---|---|
-| `enabled` | `false` | Bật truy cập phần cứng |
-| `transport` | `none` | Chế độ truyền: `"none"`, `"native"`, `"serial"` hoặc `"probe"` |
-| `serial_port` | chưa đặt | Đường dẫn cổng serial (ví dụ `"/dev/ttyACM0"`) |
-| `baud_rate` | `115200` | Tốc độ baud serial |
-| `probe_target` | chưa đặt | Chip đích cho probe (ví dụ `"STM32F401RE"`) |
-| `workspace_datasheets` | `false` | Bật RAG datasheet workspace (đánh chỉ mục PDF schematic để AI tra cứu chân) |
-
-Lưu ý:
-
-- Dùng `transport = "serial"` với `serial_port` cho kết nối USB-serial.
-- Dùng `transport = "probe"` với `probe_target` cho nạp qua debug-probe (ví dụ ST-Link).
-- Xem [hardware-peripherals-design.md](hardware-peripherals-design.md) để biết chi tiết giao thức.
-
-## `[peripherals]`
-
-Bo mạch ngoại vi trở thành tool agent khi được bật.
-
-| Khóa | Mặc định | Mục đích |
-|---|---|---|
-| `enabled` | `false` | Bật hỗ trợ ngoại vi (bo mạch trở thành tool agent) |
-| `boards` | `[]` | Danh sách cấu hình bo mạch |
-| `datasheet_dir` | chưa đặt | Đường dẫn tài liệu datasheet (tương đối workspace) cho RAG |
-
-Mỗi mục trong `boards`:
-
-| Khóa | Mặc định | Mục đích |
-|---|---|---|
-| `board` | _bắt buộc_ | Loại bo mạch: `"nucleo-f401re"`, `"rpi-gpio"`, `"esp32"`, v.v. |
-| `transport` | `serial` | Kiểu truyền: `"serial"`, `"native"`, `"websocket"` |
-| `path` | chưa đặt | Đường dẫn serial: `"/dev/ttyACM0"`, `"/dev/ttyUSB0"` |
-| `baud` | `115200` | Tốc độ baud cho serial |
-
-```toml
-[peripherals]
-enabled = true
-datasheet_dir = "docs/datasheets"
-
-[[peripherals.boards]]
-board = "nucleo-f401re"
-transport = "serial"
-path = "/dev/ttyACM0"
-baud = 115200
-
-[[peripherals.boards]]
-board = "rpi-gpio"
-transport = "native"
-```
-
-Lưu ý:
-
-- Đặt file `.md`/`.txt` datasheet đặt tên theo bo mạch (ví dụ `nucleo-f401re.md`, `rpi-gpio.md`) trong `datasheet_dir` cho RAG.
-- Xem [hardware-peripherals-design.md](hardware-peripherals-design.md) để biết giao thức bo mạch và ghi chú firmware.
-
-## Giá trị mặc định liên quan bảo mật
-
-- Allowlist kênh mặc định từ chối tất cả (`[]` nghĩa là từ chối tất cả)
-- Gateway mặc định yêu cầu ghép nối
-- Mặc định chặn public bind
-
-## Lệnh kiểm tra
-
-Sau khi chỉnh config:
-
-```bash
-zeroclaw status
-zeroclaw doctor
-zeroclaw channel doctor
-zeroclaw service restart
-```
-
-## Tài liệu liên quan
-
-- [channels-reference.md](channels-reference.md)
-- [providers-reference.md](providers-reference.md)
-- [operations-runbook.md](operations-runbook.md)
-- [troubleshooting.md](troubleshooting.md)
diff --git a/docs/wasm-tools-guide.md b/docs/wasm-tools-guide.md
new file mode 100644
index 000000000..b865f4cb5
--- /dev/null
+++ b/docs/wasm-tools-guide.md
@@ -0,0 +1,689 @@
+# WASM Tools Guide
+
+This guide covers everything you need to build, install, and use WASM-based tools
+(skills) in ZeroClaw. WASM tools let you extend the agent with custom capabilities
+written in any language that compiles to WebAssembly — without modifying ZeroClaw's
+core source code.
+
+---
+
+## Table of Contents
+
+1. [How It Works](#1-how-it-works)
+2. [Prerequisites](#2-prerequisites)
+3. [Creating a Tool](#3-creating-a-tool)
+ - [Scaffold from template](#31-scaffold-from-template)
+ - [Protocol: stdin / stdout](#32-protocol-stdin--stdout)
+ - [manifest.json](#33-manifestjson)
+ - [Template: Rust](#34-template-rust)
+ - [Template: TypeScript](#35-template-typescript)
+ - [Template: Go](#36-template-go)
+ - [Template: Python](#37-template-python)
+4. [Building](#4-building)
+5. [Testing Locally](#5-testing-locally)
+6. [Installing](#6-installing)
+ - [From a local path](#61-install-from-a-local-path)
+ - [From a git repository](#62-install-from-a-git-repository)
+ - [From ZeroMarket registry](#63-install-from-zeromarket-registry)
+7. [How ZeroClaw Loads and Uses the Tool](#7-how-zeroclaw-loads-and-uses-the-tool)
+8. [Directory Layout Reference](#8-directory-layout-reference)
+9. [Configuration (`[wasm]` section)](#9-configuration-wasm-section)
+10. [Security Model](#10-security-model)
+11. [Troubleshooting](#11-troubleshooting)
+
+---
+
+## 1. How It Works
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ Your WASM tool (.wasm binary) │
+│ │
+│ stdin ← JSON args from LLM │
+│ stdout → JSON result { success, output, error } │
+└───────────────────────┬─────────────────────────────────────┘
+ │ WASI stdio protocol
+┌───────────────────────▼─────────────────────────────────────┐
+│ ZeroClaw WASM engine (wasmtime + WASI) │
+│ │
+│ • loads tool.wasm + manifest.json from skills/ directory │
+│ • registers the tool with the agent's tool registry │
+│ • invokes the tool when the LLM selects it │
+│ • enforces memory, fuel, and output size limits │
+└─────────────────────────────────────────────────────────────┘
+```
+
+The key insight: **no custom SDK or ABI boilerplate**. Any language that can read
+from stdin and write to stdout works. The only contract is the JSON shape described
+in [section 2](#32-protocol-stdin--stdout).
+
+---
+
+## 2. Prerequisites
+
+| Requirement | Purpose |
+|---|---|
+| ZeroClaw built with `--features wasm-tools` | Enables the WASM runtime |
+| `wasmtime` CLI | Local testing (`zeroclaw skill test`) |
+| Language-specific toolchain | Building `.wasm` from source |
+
+Install `wasmtime` CLI:
+
+```bash
+# macOS / Linux
+curl https://wasmtime.dev/install.sh -sSf | bash
+
+# Or via cargo
+cargo install wasmtime-cli
+```
+
+Enable WASM support at compile time:
+
+```bash
+cargo build --release --features wasm-tools
+```
+
+---
+
+## 3. Creating a Tool
+
+### 3.1 Scaffold from template
+
+```bash
+zeroclaw skill new --template
+```
+
+Example:
+
+```bash
+zeroclaw skill new weather_lookup --template rust
+```
+
+This creates a new directory `./weather_lookup/` with all boilerplate files ready
+to build. The `--template` flag defaults to `typescript` if omitted.
+
+Supported templates:
+
+| Template | Runtime | Build tool |
+|---|---|---|
+| `typescript` | Javy (JS → WASM) | `npm run build` |
+| `rust` | native wasm32-wasip1 | `cargo build` |
+| `go` | TinyGo | `tinygo build` |
+| `python` | componentize-py | `componentize-py` |
+
+---
+
+### 3.2 Protocol: stdin / stdout
+
+Every WASM tool must follow this single contract:
+
+**Input** (written to the tool's stdin by ZeroClaw):
+
+```json
+{ "param1": "value1", "param2": 42 }
+```
+
+The shape of the input object is whatever you define in `manifest.json` under
+`parameters`. ZeroClaw passes the LLM-provided argument object verbatim.
+
+**Output** (read from the tool's stdout by ZeroClaw):
+
+```json
+{ "success": true, "output": "result text shown to LLM", "error": null }
+{ "success": false, "output": "", "error": "reason" }
+```
+
+| Field | Type | Required | Description |
+|---|---|---|---|
+| `success` | bool | yes | `true` if tool completed normally |
+| `output` | string | yes | Result text forwarded to the LLM |
+| `error` | string or null | yes | Error message when `success` is `false` |
+
+---
+
+### 3.3 manifest.json
+
+Every tool must ship a `manifest.json` alongside `tool.wasm`. This file tells
+ZeroClaw the tool's name, description, and the JSON Schema for its parameters.
+
+```json
+{
+ "name": "weather_lookup",
+ "description": "Fetches the current weather for a given city name.",
+ "version": "1",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "city": {
+ "type": "string",
+ "description": "City name to look up (e.g. Hanoi, Tokyo)"
+ },
+ "units": {
+ "type": "string",
+ "enum": ["metric", "imperial"],
+ "description": "Temperature unit system"
+ }
+ },
+ "required": ["city"]
+ },
+ "homepage": "https://github.com/yourname/weather_lookup"
+}
+```
+
+| Field | Required | Description |
+|---|---|---|
+| `name` | yes | snake_case tool name exposed to the LLM |
+| `description` | yes | Human-readable description (shown to LLM for tool selection) |
+| `version` | no | Manifest format version, default `"1"` |
+| `parameters` | yes | JSON Schema for the tool's input parameters |
+| `homepage` | no | Optional URL shown in `zeroclaw skill list` |
+
+The `name` field is the identifier the LLM uses when it decides to call your tool.
+Keep it descriptive and unique.
+
+---
+
+### 3.4 Template: Rust
+
+**Scaffolded files:** `Cargo.toml`, `src/lib.rs`, `.cargo/config.toml`
+
+`src/lib.rs`:
+
+```rust
+use std::io::{self, Read, Write};
+use serde::{Deserialize, Serialize};
+
+#[derive(Deserialize)]
+struct Args {
+ city: String,
+ #[serde(default)]
+ units: String,
+}
+
+#[derive(Serialize)]
+struct ToolResult {
+ success: bool,
+ output: String,
+ error: Option,
+}
+
+fn main() {
+ let mut buf = String::new();
+ io::stdin().read_to_string(&mut buf).unwrap();
+
+ let result = match serde_json::from_str::(&buf) {
+ Ok(args) => run(args),
+ Err(e) => ToolResult {
+ success: false,
+ output: String::new(),
+ error: Some(format!("invalid input: {e}")),
+ },
+ };
+
+ io::stdout()
+ .write_all(serde_json::to_string(&result).unwrap().as_bytes())
+ .unwrap();
+}
+
+fn run(args: Args) -> ToolResult {
+ // Your logic here
+ ToolResult {
+ success: true,
+ output: format!("Weather in {}: sunny 28°C", args.city),
+ error: None,
+ }
+}
+```
+
+**Build:**
+
+```bash
+# Add the target once
+rustup target add wasm32-wasip1
+
+# Build
+cargo build --target wasm32-wasip1 --release
+cp target/wasm32-wasip1/release/weather_lookup.wasm tool.wasm
+```
+
+---
+
+### 3.5 Template: TypeScript
+
+**Scaffolded files:** `package.json`, `tsconfig.json`, `src/index.ts`
+
+`src/index.ts`:
+
+```typescript
+// Read input from stdin (Javy provides Javy.IO)
+const input = JSON.parse(
+ new TextDecoder().decode(Javy.IO.readSync())
+);
+
+function run(args: Record): string {
+ const city = String(args["city"] ?? "");
+ // Your logic here
+ return `Weather in ${city}: sunny 28°C`;
+}
+
+try {
+ const output = run(input);
+ Javy.IO.writeSync(
+ new TextEncoder().encode(
+ JSON.stringify({ success: true, output, error: null })
+ )
+ );
+} catch (err) {
+ Javy.IO.writeSync(
+ new TextEncoder().encode(
+ JSON.stringify({ success: false, output: "", error: String(err) })
+ )
+ );
+}
+```
+
+**Build:**
+
+```bash
+# Install Javy: https://github.com/bytecodealliance/javy/releases
+npm install
+npm run build # → tool.wasm
+```
+
+---
+
+### 3.6 Template: Go
+
+**Scaffolded files:** `go.mod`, `main.go`
+
+`main.go`:
+
+```go
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+)
+
+type Args struct {
+ City string `json:"city"`
+ Units string `json:"units"`
+}
+
+type ToolResult struct {
+ Success bool `json:"success"`
+ Output string `json:"output"`
+ Error *string `json:"error"`
+}
+
+func main() {
+ data, _ := io.ReadAll(os.Stdin)
+ var args Args
+ if err := json.Unmarshal(data, &args); err != nil {
+ msg := err.Error()
+ out, _ := json.Marshal(ToolResult{Error: &msg})
+ os.Stdout.Write(out)
+ return
+ }
+ result := run(args)
+ out, _ := json.Marshal(result)
+ os.Stdout.Write(out)
+}
+
+func run(args Args) ToolResult {
+ return ToolResult{
+ Success: true,
+ Output: fmt.Sprintf("Weather in %s: sunny 28°C", args.City),
+ }
+}
+```
+
+**Build:**
+
+```bash
+# Install TinyGo: https://tinygo.org/getting-started/install/
+tinygo build -o tool.wasm -target wasi .
+```
+
+---
+
+### 3.7 Template: Python
+
+**Scaffolded files:** `app.py`, `requirements.txt`
+
+`app.py`:
+
+```python
+import sys
+import json
+
+def run(args: dict) -> str:
+ city = str(args.get("city", ""))
+ # Your logic here
+ return f"Weather in {city}: sunny 28°C"
+
+def main():
+ raw = sys.stdin.read()
+ try:
+ args = json.loads(raw)
+ output = run(args)
+ result = {"success": True, "output": output, "error": None}
+ except Exception as exc:
+ result = {"success": False, "output": "", "error": str(exc)}
+ sys.stdout.write(json.dumps(result))
+
+if __name__ == "__main__":
+ main()
+```
+
+**Build:**
+
+```bash
+pip install componentize-py
+componentize-py -d wit/ -w zeroclaw-skill componentize app -o tool.wasm
+```
+
+---
+
+## 4. Building
+
+After editing your tool logic, build it into `tool.wasm`:
+
+| Template | Build command | Output |
+|---|---|---|
+| Rust | `cargo build --target wasm32-wasip1 --release && cp target/wasm32-wasip1/release/*.wasm tool.wasm` | `tool.wasm` |
+| TypeScript | `npm run build` | `tool.wasm` |
+| Go | `tinygo build -o tool.wasm -target wasi .` | `tool.wasm` |
+| Python | `componentize-py -d wit/ -w zeroclaw-skill componentize app -o tool.wasm` | `tool.wasm` |
+
+The output must always be named `tool.wasm` at the root of the skill directory.
+
+---
+
+## 5. Testing Locally
+
+Before installing, test the tool directly without starting the full ZeroClaw agent:
+
+```bash
+zeroclaw skill test . --args '{"city":"Hanoi","units":"metric"}'
+```
+
+You can also test an installed skill by name:
+
+```bash
+zeroclaw skill test weather_lookup --args '{"city":"Tokyo"}'
+```
+
+Or test a specific tool inside a multi-tool skill:
+
+```bash
+zeroclaw skill test . --tool my_tool_name --args '{"city":"Paris"}'
+```
+
+Under the hood, `skill test` pipes the JSON args into `wasmtime run tool.wasm` via
+stdin and prints the raw stdout response. This lets you iterate quickly without
+restarting the agent.
+
+You can also test manually using `wasmtime` directly:
+
+```bash
+echo '{"city":"Hanoi"}' | wasmtime tool.wasm
+```
+
+Expected output:
+
+```json
+{"success":true,"output":"Weather in Hanoi: sunny 28°C","error":null}
+```
+
+---
+
+## 6. Installing
+
+### 6.1 Install from a local path
+
+```bash
+zeroclaw skill install ./weather_lookup
+```
+
+This copies your skill directory into `/skills/weather_lookup/`.
+ZeroClaw will auto-discover it on next startup.
+
+### 6.2 Install from a git repository
+
+```bash
+zeroclaw skill install https://github.com/yourname/weather_lookup.git
+```
+
+ZeroClaw clones the repository into the skills directory and scans for WASM tools.
+
+### 6.3 Install from ZeroMarket registry
+
+```bash
+# Format: namespace/package-name
+zeroclaw skill install acme/weather-lookup
+
+# With a specific version
+zeroclaw skill install acme/weather-lookup@0.2.1
+```
+
+ZeroClaw fetches the package index from the configured registry URL, then downloads
+`tool.wasm` and `manifest.json` for each tool in the package.
+
+**Verify the install:**
+
+```bash
+zeroclaw skill list
+```
+
+---
+
+## 7. How ZeroClaw Loads and Uses the Tool
+
+### 7.1 Startup discovery
+
+Every time the ZeroClaw agent starts, it scans the `skills/` directory and loads
+all valid WASM tools automatically. No config change or restart command is needed
+after installation.
+
+```
+/
+└── skills/
+ └── weather_lookup/ ← skill package root
+ ├── SKILL.toml
+ └── tools/
+ └── weather_lookup/ ← individual tool directory
+ ├── tool.wasm ← compiled WASM binary
+ └── manifest.json ← tool metadata
+```
+
+A simpler "dev layout" is also supported (useful right after building):
+
+```
+/
+└── skills/
+ └── weather_lookup/
+ ├── tool.wasm
+ └── manifest.json
+```
+
+### 7.2 Tool registration
+
+After discovery, each `WasmTool` is registered in the agent's tool registry
+alongside built-in tools like `shell`, `file`, `web_fetch`, etc. The LLM sees
+all registered tools equally — it has no way to distinguish a built-in tool from
+a WASM plugin.
+
+### 7.3 LLM tool selection
+
+When a user sends a message, the agent attaches the full tool registry (including
+all WASM tools) to the LLM context. The LLM reads each tool's `name` and
+`description` from the manifest and decides which tool to call based on the
+user's request.
+
+Example conversation:
+
+```
+User: What is the weather in Hanoi right now?
+
+Agent: [internally, LLM selects tool "weather_lookup" with args {"city":"Hanoi"}]
+
+ ZeroClaw calls weather_lookup WASM tool:
+ stdin → {"city":"Hanoi"}
+ stdout ← {"success":true,"output":"Weather in Hanoi: sunny 28°C","error":null}
+
+Agent: The current weather in Hanoi is sunny with a temperature of 28°C.
+```
+
+### 7.4 Invocation flow
+
+```
+LLM decides to call "weather_lookup"
+ │
+ ▼
+WasmTool::execute(args: JSON)
+ │
+ ├─ serialize args to stdin bytes
+ ├─ spin up wasmtime WASI sandbox
+ ├─ write stdin → WASM process
+ ├─ read stdout ← WASM process (capped at 1 MiB)
+ ├─ enforce fuel limit (≈ 1 billion instructions)
+ ├─ enforce wall-clock timeout (30 seconds)
+ └─ deserialize ToolResult JSON
+ │
+ ▼
+Agent formats output and responds to user
+```
+
+### 7.5 Error handling
+
+If a tool fails (non-zero exit, invalid JSON, timeout, fuel exhaustion), ZeroClaw
+logs a warning and returns the error to the LLM. The agent continues running —
+a broken plugin never crashes the process.
+
+---
+
+## 8. Directory Layout Reference
+
+**Installed layout** (created by `zeroclaw skill install`):
+
+```
+skills/
+└── /
+ ├── SKILL.toml ← package metadata (shown in skill list)
+ └── tools/
+ └── /
+ ├── tool.wasm ← WASM binary
+ └── manifest.json ← tool metadata
+```
+
+**Dev layout** (for quick iteration, right after `cargo build`):
+
+```
+skills/
+└── /
+ ├── tool.wasm
+ └── manifest.json
+```
+
+Both layouts are discovered automatically. Use dev layout while developing, switch
+to installed layout for distribution.
+
+---
+
+## 9. Configuration (`[wasm]` section)
+
+Add this section to your `zeroclaw.toml` to tune WASM tool behavior:
+
+```toml
+[wasm]
+# Disable all WASM tools (default: true)
+enabled = true
+
+# Maximum memory per invocation in MiB, clamped 1–256 (default: 64)
+memory_limit_mb = 64
+
+# CPU fuel budget — roughly one unit per WASM instruction (default: 1_000_000_000)
+fuel_limit = 1_000_000_000
+
+# Registry URL used by `zeroclaw skill install namespace/package`
+registry_url = "https://registry.zeromarket.dev"
+```
+
+To disable all WASM tools without uninstalling them:
+
+```toml
+[wasm]
+enabled = false
+```
+
+---
+
+## 10. Security Model
+
+WASM tools run inside a strict WASI sandbox enforced by wasmtime:
+
+| Constraint | Default |
+|---|---|
+| Filesystem access | **Denied** — no preopened directories |
+| Network sockets | **Denied** — WASI network not enabled |
+| Max memory | 64 MiB (configurable, max 256 MiB) |
+| Max CPU instructions | ~1 billion (configurable) |
+| Max wall-clock time | 30 seconds hard limit |
+| Max output size | 1 MiB |
+| Registry transport | HTTPS only — HTTP is rejected |
+| Registry path traversal | Tool names validated before writing to disk |
+
+A malicious or buggy WASM tool cannot:
+- Read or write files on the host
+- Make network connections
+- Access environment variables
+- Consume unbounded CPU or memory
+- Crash the ZeroClaw process
+
+---
+
+## 11. Troubleshooting
+
+**`WASM tools are not enabled in this build`**
+
+Recompile with the feature flag:
+
+```bash
+cargo build --release
+```
+
+**`wasmtime` not found during `skill test`**
+
+Install the wasmtime CLI:
+
+```bash
+curl https://wasmtime.dev/install.sh -sSf | bash
+# or
+cargo install wasmtime-cli
+```
+
+**`WASM module must export '_start'`**
+
+Your binary must be compiled as a WASI executable (not a library). For Rust, ensure
+your `Cargo.toml` does **not** set `crate-type = ["cdylib"]` — use the default
+binary crate instead. For Go, use `tinygo build -target wasi` (not `wasm`).
+
+**`WASM tool wrote nothing to stdout`**
+
+Your tool exited without writing a JSON result. Check that your `run()` function
+always writes to stdout before returning, including in error paths.
+
+**Tool not appearing in `zeroclaw skill list`**
+
+- Verify `manifest.json` exists alongside `tool.wasm`
+- Validate the JSON is well-formed: `cat manifest.json | python3 -m json.tool`
+- Restart the agent — tools are discovered at startup
+
+**`curl failed` during registry install**
+
+Ensure `curl` is installed and the registry URL uses HTTPS. Custom registries must
+be reachable and return the expected package index JSON format.
diff --git a/extensions/hello-world/src/lib.rs b/extensions/hello-world/src/lib.rs
new file mode 100644
index 000000000..30552c1cd
--- /dev/null
+++ b/extensions/hello-world/src/lib.rs
@@ -0,0 +1,121 @@
+//! Hello World — example ZeroClaw plugin.
+//!
+//! Demonstrates the minimal plugin contract:
+//! 1. Implement `Plugin` (manifest + register)
+//! 2. In `register()`, use `PluginApi` to contribute tools and hooks
+//!
+//! To enable this plugin, add to `~/.zeroclaw/config.toml`:
+//!
+//! ```toml
+//! [plugins]
+//! enabled = true
+//!
+//! [plugins.entries.hello-world]
+//! enabled = true
+//! ```
+
+use async_trait::async_trait;
+use zeroclaw::hooks::{HookHandler, HookResult};
+use zeroclaw::plugins::{Plugin, PluginApi, PluginManifest};
+use zeroclaw::tools::traits::{Tool, ToolResult, ToolSpec};
+
+// ── Manifest ─────────────────────────────────────────────────────────────────
+
+fn manifest() -> PluginManifest {
+ PluginManifest {
+ id: "hello-world".into(),
+ name: Some("Hello World".into()),
+ description: Some("Example plugin demonstrating the ZeroClaw plugin API.".into()),
+ version: Some("0.1.0".into()),
+ config_schema: None,
+ }
+}
+
+// ── Tool ─────────────────────────────────────────────────────────────────────
+
+/// A simple tool that greets the user.
+struct HelloTool;
+
+#[async_trait]
+impl Tool for HelloTool {
+ fn name(&self) -> &str {
+ "hello"
+ }
+
+ fn description(&self) -> &str {
+ "Greet the user by name."
+ }
+
+ fn parameters_schema(&self) -> serde_json::Value {
+ serde_json::json!({
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Name to greet"
+ }
+ },
+ "required": ["name"]
+ })
+ }
+
+ async fn execute(&self, args: serde_json::Value) -> anyhow::Result {
+ let name = args
+ .get("name")
+ .and_then(|v| v.as_str())
+ .unwrap_or("world");
+ Ok(ToolResult {
+ success: true,
+ output: format!("Hello, {name}!"),
+ error: None,
+ })
+ }
+}
+
+// ── Hook ─────────────────────────────────────────────────────────────────────
+
+/// A hook that logs when a session starts.
+struct HelloHook;
+
+#[async_trait]
+impl HookHandler for HelloHook {
+ fn name(&self) -> &str {
+ "hello-world:session-logger"
+ }
+
+ async fn on_session_start(&self, session_id: &str, channel: &str) {
+ tracing::info!(
+ plugin = "hello-world",
+ session_id = %session_id,
+ channel = %channel,
+ "session started"
+ );
+ }
+}
+
+// ── Plugin ───────────────────────────────────────────────────────────────────
+
+pub struct HelloWorldPlugin {
+ manifest: PluginManifest,
+}
+
+impl HelloWorldPlugin {
+ pub fn new() -> Self {
+ Self {
+ manifest: manifest(),
+ }
+ }
+}
+
+impl Plugin for HelloWorldPlugin {
+ fn manifest(&self) -> &PluginManifest {
+ &self.manifest
+ }
+
+ fn register(&self, api: &mut PluginApi) -> anyhow::Result<()> {
+ api.logger().info("registering hello-world plugin");
+ api.register_tool(Box::new(HelloTool));
+ api.register_hook(Box::new(HelloHook));
+ Ok(())
+ }
+}
diff --git a/extensions/hello-world/zeroclaw.plugin.toml b/extensions/hello-world/zeroclaw.plugin.toml
new file mode 100644
index 000000000..5cf706e66
--- /dev/null
+++ b/extensions/hello-world/zeroclaw.plugin.toml
@@ -0,0 +1,4 @@
+id = "hello-world"
+name = "Hello World"
+description = "Example plugin demonstrating the ZeroClaw plugin API."
+version = "0.1.0"
diff --git a/flake.nix b/flake.nix
index 9bafa47c2..7e5379fa9 100644
--- a/flake.nix
+++ b/flake.nix
@@ -8,54 +8,44 @@
nixpkgs.url = "nixpkgs/nixos-unstable";
};
- outputs = { flake-utils, fenix, nixpkgs, ... }:
- let
- nixosModule = { pkgs, ... }: {
- nixpkgs.overlays = [ fenix.overlays.default ];
- environment.systemPackages = [
- (pkgs.fenix.stable.withComponents [
- "cargo"
- "clippy"
- "rust-src"
- "rustc"
- "rustfmt"
- ])
- pkgs.rust-analyzer
- ];
- };
- in
- flake-utils.lib.eachDefaultSystem (system:
+ outputs =
+ {
+ self,
+ flake-utils,
+ fenix,
+ nixpkgs,
+ }:
+ flake-utils.lib.eachDefaultSystem (
+ system:
let
pkgs = import nixpkgs {
inherit system;
- overlays = [ fenix.overlays.default ];
+ overlays = [
+ fenix.overlays.default
+ (import ./overlay.nix)
+ ];
};
- rustToolchain = pkgs.fenix.stable.withComponents [
- "cargo"
- "clippy"
- "rust-src"
- "rustc"
- "rustfmt"
- ];
- in {
- packages.default = fenix.packages.${system}.stable.toolchain;
+ in
+ {
+ formatter = pkgs.nixfmt-tree;
+
+ packages = {
+ default = self.packages.${system}.zeroclaw;
+ inherit (pkgs)
+ zeroclaw
+ zeroclaw-web
+ ;
+ };
+
devShells.default = pkgs.mkShell {
+ inputsFrom = [ pkgs.zeroclaw ];
packages = [
- rustToolchain
pkgs.rust-analyzer
];
};
- }) // {
- nixosConfigurations = {
- nixos = nixpkgs.lib.nixosSystem {
- system = "x86_64-linux";
- modules = [ nixosModule ];
- };
-
- nixos-aarch64 = nixpkgs.lib.nixosSystem {
- system = "aarch64-linux";
- modules = [ nixosModule ];
- };
- };
+ }
+ )
+ // {
+ overlays.default = import ./overlay.nix;
};
}
diff --git a/overlay.nix b/overlay.nix
new file mode 100644
index 000000000..cf73ff69c
--- /dev/null
+++ b/overlay.nix
@@ -0,0 +1,13 @@
+final: prev: {
+ zeroclaw-web = final.callPackage ./web/package.nix { };
+
+ zeroclaw = final.callPackage ./package.nix {
+ rustToolchain = final.fenix.stable.withComponents [
+ "cargo"
+ "clippy"
+ "rust-src"
+ "rustc"
+ "rustfmt"
+ ];
+ };
+}
diff --git a/package.nix b/package.nix
new file mode 100644
index 000000000..89b7c84e2
--- /dev/null
+++ b/package.nix
@@ -0,0 +1,58 @@
+{
+ makeRustPlatform,
+ rustToolchain,
+ lib,
+ zeroclaw-web,
+ removeReferencesTo,
+}:
+let
+ rustPlatform = makeRustPlatform {
+ cargo = rustToolchain;
+ rustc = rustToolchain;
+ };
+in
+rustPlatform.buildRustPackage (finalAttrs: {
+ pname = "zeroclaw";
+ version = "0.1.7";
+
+ src =
+ let
+ fs = lib.fileset;
+ in
+ fs.toSource {
+ root = ./.;
+ fileset = fs.unions (
+ [
+ ./src
+ ./Cargo.toml
+ ./Cargo.lock
+ ./crates
+ ./benches
+ ]
+ ++ (lib.optionals finalAttrs.doCheck [
+ ./tests
+ ./test_helpers
+ ])
+ );
+ };
+ prePatch = ''
+ mkdir web
+ ln -s ${zeroclaw-web} web/dist
+ '';
+
+ cargoLock.lockFile = ./Cargo.lock;
+
+ nativeBuildInputs = [
+ removeReferencesTo
+ ];
+
+ # Since tests run in the official pipeline, no need to run them in the Nix sandbox.
+ # Can be changed by consumers using `overrideAttrs` on this package.
+ doCheck = false;
+
+ # Some dependency causes Nix to detect the Rust toolchain to be a runtime dependency
+ # of zeroclaw. This manually removes any reference to the toolchain.
+ postFixup = ''
+ find "$out" -type f -exec remove-references-to -t ${rustToolchain} '{}' +
+ '';
+})
diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh
index 8af9ff139..cee7251ad 100755
--- a/scripts/bootstrap.sh
+++ b/scripts/bootstrap.sh
@@ -39,6 +39,7 @@ Options:
--prefer-prebuilt Try latest release binary first; fallback to source build on miss
--prebuilt-only Install only from latest release binary (no source build fallback)
--force-source-build Disable prebuilt flow and always build from source
+ --cargo-features Extra Cargo features for local source build/install (comma-separated)
--onboard Run onboarding after install
--interactive-onboard Run interactive onboarding (implies --onboard)
--api-key API key for non-interactive onboarding
@@ -78,6 +79,8 @@ Environment:
ZEROCLAW_DOCKER_NETWORK Docker network for ZeroClaw + sidecars (default: zeroclaw-bootstrap-net)
ZEROCLAW_DOCKER_CARGO_FEATURES
Extra Cargo features for Docker builds (comma-separated)
+ ZEROCLAW_CARGO_FEATURES Extra Cargo features for local source builds (comma-separated)
+ ZEROCLAW_CONFIG_PATH Config path used for channel feature auto-detection (default: ~/.zeroclaw/config.toml)
ZEROCLAW_DOCKER_DAEMON_NAME
Daemon container name for --docker-daemon (default: zeroclaw-daemon)
ZEROCLAW_DOCKER_DAEMON_BIND_HOST
@@ -149,6 +152,9 @@ detect_release_target() {
Darwin:arm64|Darwin:aarch64)
echo "aarch64-apple-darwin"
;;
+ FreeBSD:amd64|FreeBSD:x86_64)
+ echo "x86_64-unknown-freebsd"
+ ;;
*)
return 1
;;
@@ -190,6 +196,71 @@ should_attempt_prebuilt_for_resources() {
return 1
}
+append_csv_feature() {
+ local csv="${1:-}"
+ local feature="${2:-}"
+ local normalized
+ local -a entries=()
+ local existing_feature
+
+ normalized="$(printf '%s' "$feature" | tr -d '[:space:]')"
+ if [[ -z "$normalized" ]]; then
+ echo "$csv"
+ return 0
+ fi
+
+ if [[ -n "$csv" ]]; then
+ IFS=',' read -r -a entries <<< "$csv"
+ fi
+ for existing_feature in "${entries[@]:-}"; do
+ if [[ "$(printf '%s' "$existing_feature" | tr -d '[:space:]')" == "$normalized" ]]; then
+ echo "$csv"
+ return 0
+ fi
+ done
+
+ if [[ -n "$csv" ]]; then
+ echo "$csv,$normalized"
+ else
+ echo "$normalized"
+ fi
+}
+
+merge_csv_features() {
+ local base="${1:-}"
+ local incoming="${2:-}"
+ local merged="$base"
+ local -a incoming_features=()
+ local feature
+
+ if [[ -n "$incoming" ]]; then
+ IFS=',' read -r -a incoming_features <<< "$incoming"
+ fi
+ for feature in "${incoming_features[@]:-}"; do
+ merged="$(append_csv_feature "$merged" "$feature")"
+ done
+ echo "$merged"
+}
+
+detect_config_channel_features() {
+ local config_path="${1:-}"
+ local features=""
+
+ if [[ -z "$config_path" || ! -f "$config_path" ]]; then
+ echo ""
+ return 0
+ fi
+
+ if grep -Eq '^[[:space:]]*\[channels_config\.(lark|feishu)\][[:space:]]*$' "$config_path"; then
+ features="$(append_csv_feature "$features" "channel-lark")"
+ fi
+ if grep -Eq '^[[:space:]]*\[channels_config\.matrix\][[:space:]]*$' "$config_path"; then
+ features="$(append_csv_feature "$features" "channel-matrix")"
+ fi
+
+ echo "$features"
+}
+
install_prebuilt_binary() {
local target archive_url temp_dir archive_path extracted_bin install_dir
@@ -683,7 +754,7 @@ is_zeroclaw_resource_name() {
}
maybe_stop_running_zeroclaw_containers() {
- local -a running_ids running_rows
+ local -a running_ids=() running_rows=()
local id name image command row
while IFS=$'\t' read -r id name image command; do
@@ -1241,6 +1312,9 @@ CONTAINER_CLI="${ZEROCLAW_CONTAINER_CLI:-docker}"
API_KEY="${ZEROCLAW_API_KEY:-}"
PROVIDER="${ZEROCLAW_PROVIDER:-openrouter}"
MODEL="${ZEROCLAW_MODEL:-}"
+LOCAL_CARGO_FEATURES="${ZEROCLAW_CARGO_FEATURES:-}"
+LOCAL_CONFIG_PATH="${ZEROCLAW_CONFIG_PATH:-$HOME/.zeroclaw/config.toml}"
+AUTO_CONFIG_FEATURES=""
while [[ $# -gt 0 ]]; do
case "$1" in
@@ -1300,6 +1374,14 @@ while [[ $# -gt 0 ]]; do
FORCE_SOURCE_BUILD=true
shift
;;
+ --cargo-features)
+ LOCAL_CARGO_FEATURES="${2:-}"
+ [[ -n "$LOCAL_CARGO_FEATURES" ]] || {
+ error "--cargo-features requires a comma-separated value"
+ exit 1
+ }
+ shift 2
+ ;;
--onboard)
RUN_ONBOARD=true
shift
@@ -1482,6 +1564,9 @@ if [[ "$PREBUILT_ONLY" == true ]]; then
fi
if [[ "$DOCKER_MODE" == true ]]; then
+ if [[ -n "$LOCAL_CARGO_FEATURES" ]]; then
+ warn "--cargo-features / ZEROCLAW_CARGO_FEATURES are ignored with --docker (use ZEROCLAW_DOCKER_CARGO_FEATURES)."
+ fi
ensure_docker_ready
if [[ "$RUN_ONBOARD" == false ]]; then
if [[ -n "$DOCKER_CONFIG_FILE" || "$DOCKER_DAEMON_MODE" == true ]]; then
@@ -1527,6 +1612,19 @@ DONE
exit 0
fi
+AUTO_CONFIG_FEATURES="$(detect_config_channel_features "$LOCAL_CONFIG_PATH")"
+if [[ -n "$AUTO_CONFIG_FEATURES" ]]; then
+ info "Detected channel features in config ($LOCAL_CONFIG_PATH): $AUTO_CONFIG_FEATURES"
+ LOCAL_CARGO_FEATURES="$(merge_csv_features "$LOCAL_CARGO_FEATURES" "$AUTO_CONFIG_FEATURES")"
+ if [[ "$PREBUILT_ONLY" == true ]]; then
+ warn "prebuilt-only mode may omit configured channel features: $AUTO_CONFIG_FEATURES"
+ elif [[ "$FORCE_SOURCE_BUILD" == false ]]; then
+ info "Using source build to satisfy configured channel feature requirements."
+ FORCE_SOURCE_BUILD=true
+ PREFER_PREBUILT=false
+ fi
+fi
+
if [[ "$FORCE_SOURCE_BUILD" == false ]]; then
if [[ "$PREFER_PREBUILT" == false && "$PREBUILT_ONLY" == false ]]; then
if should_attempt_prebuilt_for_resources "$WORK_DIR"; then
@@ -1562,14 +1660,24 @@ fi
if [[ "$SKIP_BUILD" == false ]]; then
info "Building release binary"
- cargo build --release --locked
+ BUILD_CMD=(cargo build --release --locked)
+ if [[ -n "$LOCAL_CARGO_FEATURES" ]]; then
+ info "Applying local Cargo features for build: $LOCAL_CARGO_FEATURES"
+ BUILD_CMD+=(--features "$LOCAL_CARGO_FEATURES")
+ fi
+ "${BUILD_CMD[@]}"
else
info "Skipping build"
fi
if [[ "$SKIP_INSTALL" == false ]]; then
info "Installing zeroclaw to cargo bin"
- cargo install --path "$WORK_DIR" --force --locked
+ INSTALL_CMD=(cargo install --path "$WORK_DIR" --force --locked)
+ if [[ -n "$LOCAL_CARGO_FEATURES" ]]; then
+ info "Applying local Cargo features for install: $LOCAL_CARGO_FEATURES"
+ INSTALL_CMD+=(--features "$LOCAL_CARGO_FEATURES")
+ fi
+ "${INSTALL_CMD[@]}"
else
info "Skipping install"
fi
diff --git a/scripts/ci/tests/test_ci_scripts.py b/scripts/ci/tests/test_ci_scripts.py
index 7a06649ae..f5d3c5175 100644
--- a/scripts/ci/tests/test_ci_scripts.py
+++ b/scripts/ci/tests/test_ci_scripts.py
@@ -229,14 +229,14 @@ class CiScriptsBehaviorTest(unittest.TestCase):
"id": "RUSTSEC-2025-0001",
"owner": "repo-maintainers",
"reason": "Tracked with mitigation plan while waiting upstream patch.",
- "ticket": "SEC-21",
+ "ticket": "RMN-21",
"expires_on": "2027-01-01",
},
{
"id": "RUSTSEC-2025-0002",
"owner": "repo-maintainers",
"reason": "Accepted transiently due to transitive dependency under migration.",
- "ticket": "SEC-21",
+ "ticket": "RMN-21",
"expires_on": "2027-01-01",
},
],
@@ -294,7 +294,7 @@ class CiScriptsBehaviorTest(unittest.TestCase):
"id": "RUSTSEC-2025-1111",
"owner": "repo-maintainers",
"reason": "Temporary ignore while upstream patch is under review.",
- "ticket": "SEC-21",
+ "ticket": "RMN-21",
"expires_on": "2020-01-01",
}
],
@@ -352,7 +352,7 @@ class CiScriptsBehaviorTest(unittest.TestCase):
"pattern": r"src/security/leak_detector\.rs",
"owner": "repo-maintainers",
"reason": "Fixture pattern used in secret scanning regression tests.",
- "ticket": "SEC-13",
+ "ticket": "RMN-13",
"expires_on": "2027-01-01",
}
],
@@ -361,7 +361,7 @@ class CiScriptsBehaviorTest(unittest.TestCase):
"pattern": r"Authorization: Bearer \$\{[^}]+\}",
"owner": "repo-maintainers",
"reason": "Placeholder token pattern used in docs and snippets.",
- "ticket": "SEC-13",
+ "ticket": "RMN-13",
"expires_on": "2027-01-01",
}
],
@@ -416,7 +416,7 @@ class CiScriptsBehaviorTest(unittest.TestCase):
"pattern": r"src/security/leak_detector\.rs",
"owner": "repo-maintainers",
"reason": "Fixture pattern used in secret scanning regression tests.",
- "ticket": "SEC-13",
+ "ticket": "RMN-13",
"expires_on": "2020-01-01",
}
],
@@ -425,7 +425,7 @@ class CiScriptsBehaviorTest(unittest.TestCase):
"pattern": r"Authorization: Bearer \$\{[^}]+\}",
"owner": "repo-maintainers",
"reason": "Placeholder token pattern used in docs and snippets.",
- "ticket": "SEC-13",
+ "ticket": "RMN-13",
"expires_on": "2027-01-01",
}
],
@@ -1554,7 +1554,7 @@ class CiScriptsBehaviorTest(unittest.TestCase):
"path": "legacy/vendor",
"owner": "repo-maintainers",
"reason": "Temporary vendor mirror while upstream replaces unsafe bindings.",
- "ticket": "SEC-32",
+ "ticket": "RMN-32",
"expires_on": "2027-01-01",
}
],
@@ -1563,7 +1563,7 @@ class CiScriptsBehaviorTest(unittest.TestCase):
"pattern_id": "ffi_libc_call",
"owner": "repo-maintainers",
"reason": "Allowlisted for libc shim crate pending migration to safer wrappers.",
- "ticket": "SEC-32",
+ "ticket": "RMN-32",
"expires_on": "2027-01-01",
}
],
@@ -1620,7 +1620,7 @@ class CiScriptsBehaviorTest(unittest.TestCase):
"path": "legacy/vendor",
"owner": "repo-maintainers",
"reason": "Temporary vendor mirror while upstream replaces unsafe bindings.",
- "ticket": "SEC-32",
+ "ticket": "RMN-32",
"expires_on": "2020-01-01",
}
],
@@ -1629,7 +1629,7 @@ class CiScriptsBehaviorTest(unittest.TestCase):
"pattern_id": "ffi_libc_call",
"owner": "repo-maintainers",
"reason": "Allowlisted for libc shim crate pending migration to safer wrappers.",
- "ticket": "SEC-32",
+ "ticket": "RMN-32",
"expires_on": "2027-01-01",
}
],
diff --git a/src/agent/agent.rs b/src/agent/agent.rs
index 563211e96..984f6f434 100644
--- a/src/agent/agent.rs
+++ b/src/agent/agent.rs
@@ -3,7 +3,8 @@ use crate::agent::dispatcher::{
};
use crate::agent::memory_loader::{DefaultMemoryLoader, MemoryLoader};
use crate::agent::prompt::{PromptContext, SystemPromptBuilder};
-use crate::config::Config;
+use crate::agent::research;
+use crate::config::{Config, ResearchPhaseConfig};
use crate::memory::{self, Memory, MemoryCategory};
use crate::observability::{self, Observer, ObserverEvent};
use crate::providers::{self, ChatMessage, ChatRequest, ConversationMessage, Provider};
@@ -37,6 +38,7 @@ pub struct Agent {
classification_config: crate::config::QueryClassificationConfig,
available_hints: Vec,
route_model_by_hint: HashMap,
+ research_config: ResearchPhaseConfig,
}
pub struct AgentBuilder {
@@ -58,6 +60,7 @@ pub struct AgentBuilder {
classification_config: Option,
available_hints: Option>,
route_model_by_hint: Option>,
+ research_config: Option,
}
impl AgentBuilder {
@@ -81,6 +84,7 @@ impl AgentBuilder {
classification_config: None,
available_hints: None,
route_model_by_hint: None,
+ research_config: None,
}
}
@@ -180,6 +184,11 @@ impl AgentBuilder {
self
}
+ pub fn research_config(mut self, research_config: ResearchPhaseConfig) -> Self {
+ self.research_config = Some(research_config);
+ self
+ }
+
pub fn build(self) -> Result {
let tools = self
.tools
@@ -223,6 +232,7 @@ impl AgentBuilder {
classification_config: self.classification_config.unwrap_or_default(),
available_hints: self.available_hints.unwrap_or_default(),
route_model_by_hint: self.route_model_by_hint.unwrap_or_default(),
+ research_config: self.research_config.unwrap_or_default(),
})
}
}
@@ -342,6 +352,7 @@ impl Agent {
))
.skills_prompt_mode(config.skills.prompt_injection_mode)
.auto_save(config.memory.auto_save)
+ .research_config(config.research.clone())
.build()
}
@@ -486,11 +497,60 @@ impl Agent {
.await
.unwrap_or_default();
- let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S %Z");
- let enriched = if context.is_empty() {
- format!("[{now}] {user_message}")
+ // ── Research Phase ──────────────────────────────────────────────
+ // If enabled and triggered, run a focused research turn to gather
+ // information before the main response.
+ let research_context = if research::should_trigger(&self.research_config, user_message) {
+ if self.research_config.show_progress {
+ println!("[Research] Gathering information...");
+ }
+
+ match research::run_research_phase(
+ &self.research_config,
+ self.provider.as_ref(),
+ &self.tools,
+ user_message,
+ &self.model_name,
+ self.temperature,
+ self.observer.clone(),
+ )
+ .await
+ {
+ Ok(result) => {
+ if self.research_config.show_progress {
+ println!(
+ "[Research] Complete: {} tool calls, {} chars context",
+ result.tool_call_count,
+ result.context.len()
+ );
+ for summary in &result.tool_summaries {
+ println!(" - {}: {}", summary.tool_name, summary.result_preview);
+ }
+ }
+ if result.context.is_empty() {
+ None
+ } else {
+ Some(result.context)
+ }
+ }
+ Err(e) => {
+ tracing::warn!("Research phase failed: {}", e);
+ None
+ }
+ }
} else {
- format!("{context}[{now}] {user_message}")
+ None
+ };
+
+ let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S %Z");
+ let stamped_user_message = format!("[{now}] {user_message}");
+ let enriched = match (&context, &research_context) {
+ (c, Some(r)) if !c.is_empty() => {
+ format!("{c}\n\n{r}\n\n{stamped_user_message}")
+ }
+ (_, Some(r)) => format!("{r}\n\n{stamped_user_message}"),
+ (c, None) if !c.is_empty() => format!("{c}{stamped_user_message}"),
+ _ => stamped_user_message,
};
self.history
diff --git a/src/agent/loop_.rs b/src/agent/loop_.rs
index 3d0a8b051..ee8e7395f 100644
--- a/src/agent/loop_.rs
+++ b/src/agent/loop_.rs
@@ -12,7 +12,8 @@ use crate::tools::{self, Tool};
use crate::util::truncate_with_ellipsis;
use anyhow::Result;
use regex::{Regex, RegexSet};
-use std::collections::HashSet;
+use rustyline::error::ReadlineError;
+use std::collections::{BTreeSet, HashSet};
use std::fmt::Write;
use std::io::Write as _;
use std::sync::{Arc, LazyLock};
@@ -20,12 +21,33 @@ use std::time::{Duration, Instant};
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
+mod context;
+mod execution;
+mod history;
+mod parsing;
+
+use context::{build_context, build_hardware_context};
+use execution::{
+ execute_tools_parallel, execute_tools_sequential, should_execute_tools_in_parallel,
+ ToolExecutionOutcome,
+};
+#[cfg(test)]
+use history::{apply_compaction_summary, build_compaction_transcript};
+use history::{auto_compact_history, trim_history};
+#[allow(unused_imports)]
+use parsing::{
+ default_param_for_tool, detect_tool_call_parse_issue, extract_json_values, map_tool_name_alias,
+ parse_arguments_value, parse_glm_shortened_body, parse_glm_style_tool_calls,
+ parse_perl_style_tool_calls, parse_structured_tool_calls, parse_tool_call_value,
+ parse_tool_calls, parse_tool_calls_from_json_value, tool_call_signature, ParsedToolCall,
+};
+
/// Minimum characters per chunk when relaying LLM text to a streaming draft.
const STREAM_CHUNK_MIN_CHARS: usize = 80;
/// Default maximum agentic tool-use iterations per user message to prevent runaway loops.
/// Used as a safe fallback when `max_tool_iterations` is unset or configured as zero.
-const DEFAULT_MAX_TOOL_ITERATIONS: usize = 10;
+const DEFAULT_MAX_TOOL_ITERATIONS: usize = 20;
/// Minimum user-message length (in chars) for auto-save to memory.
/// Matches the channel-side constant in `channels/mod.rs`.
@@ -90,21 +112,50 @@ pub(crate) fn scrub_credentials(input: &str) -> String {
/// used when callers omit the parameter.
const DEFAULT_MAX_HISTORY_MESSAGES: usize = 50;
-/// Keep this many most-recent non-system messages after compaction.
-const COMPACTION_KEEP_RECENT_MESSAGES: usize = 20;
-
-/// Safety cap for compaction source transcript passed to the summarizer.
-const COMPACTION_MAX_SOURCE_CHARS: usize = 12_000;
-
-/// Max characters retained in stored compaction summary.
-const COMPACTION_MAX_SUMMARY_CHARS: usize = 2_000;
-
/// Minimum interval between progress sends to avoid flooding the draft channel.
pub(crate) const PROGRESS_MIN_INTERVAL_MS: u64 = 500;
/// Sentinel value sent through on_delta to signal the draft updater to clear accumulated text.
/// Used before streaming the final answer so progress lines are replaced by the clean response.
pub(crate) const DRAFT_CLEAR_SENTINEL: &str = "\x00CLEAR\x00";
+/// Sentinel prefix for internal progress deltas (thinking/tool execution trace).
+/// Channel layers can suppress these messages by default and only expose them
+/// when the user explicitly asks for command/tool execution details.
+pub(crate) const DRAFT_PROGRESS_SENTINEL: &str = "\x00PROGRESS\x00";
+
+tokio::task_local! {
+ static TOOL_LOOP_REPLY_TARGET: Option;
+}
+
+const AUTO_CRON_DELIVERY_CHANNELS: &[&str] = &[
+ "telegram",
+ "discord",
+ "slack",
+ "mattermost",
+ "lark",
+ "feishu",
+];
+
+const NON_CLI_APPROVAL_WAIT_TIMEOUT_SECS: u64 = 300;
+const NON_CLI_APPROVAL_POLL_INTERVAL_MS: u64 = 250;
+
+#[derive(Debug, Clone)]
+pub(crate) struct NonCliApprovalPrompt {
+ pub request_id: String,
+ pub tool_name: String,
+ pub arguments: serde_json::Value,
+}
+
+#[derive(Debug, Clone)]
+pub(crate) struct NonCliApprovalContext {
+ pub sender: String,
+ pub reply_target: String,
+ pub prompt_tx: tokio::sync::mpsc::UnboundedSender,
+}
+
+tokio::task_local! {
+ static TOOL_LOOP_NON_CLI_APPROVAL_CONTEXT: Option;
+}
/// Extract a short hint from tool call arguments for progress display.
fn truncate_tool_args_for_progress(name: &str, args: &serde_json::Value, max_len: usize) -> String {
@@ -122,6 +173,116 @@ fn truncate_tool_args_for_progress(name: &str, args: &serde_json::Value, max_len
}
}
+fn maybe_inject_cron_add_delivery(
+ tool_name: &str,
+ tool_args: &mut serde_json::Value,
+ channel_name: &str,
+ reply_target: Option<&str>,
+) {
+ if tool_name != "cron_add"
+ || !AUTO_CRON_DELIVERY_CHANNELS
+ .iter()
+ .any(|supported| supported == &channel_name)
+ {
+ return;
+ }
+
+ let Some(reply_target) = reply_target.map(str::trim).filter(|v| !v.is_empty()) else {
+ return;
+ };
+
+ let Some(args_obj) = tool_args.as_object_mut() else {
+ return;
+ };
+
+ let is_agent_job = match args_obj.get("job_type").and_then(serde_json::Value::as_str) {
+ Some("agent") => true,
+ Some(_) => false,
+ None => args_obj.contains_key("prompt"),
+ };
+ if !is_agent_job {
+ return;
+ }
+
+ let delivery = args_obj
+ .entry("delivery".to_string())
+ .or_insert_with(|| serde_json::json!({}));
+ let Some(delivery_obj) = delivery.as_object_mut() else {
+ return;
+ };
+
+ let mode = delivery_obj
+ .get("mode")
+ .and_then(serde_json::Value::as_str)
+ .unwrap_or("none");
+ if mode.eq_ignore_ascii_case("none") || mode.trim().is_empty() {
+ delivery_obj.insert(
+ "mode".to_string(),
+ serde_json::Value::String("announce".to_string()),
+ );
+ } else if !mode.eq_ignore_ascii_case("announce") {
+ // Respect explicitly chosen non-announce modes.
+ return;
+ }
+
+ let needs_channel = delivery_obj
+ .get("channel")
+ .and_then(serde_json::Value::as_str)
+ .is_none_or(|value| value.trim().is_empty());
+ if needs_channel {
+ delivery_obj.insert(
+ "channel".to_string(),
+ serde_json::Value::String(channel_name.to_string()),
+ );
+ }
+
+ let needs_target = delivery_obj
+ .get("to")
+ .and_then(serde_json::Value::as_str)
+ .is_none_or(|value| value.trim().is_empty());
+ if needs_target {
+ delivery_obj.insert(
+ "to".to_string(),
+ serde_json::Value::String(reply_target.to_string()),
+ );
+ }
+}
+
+async fn await_non_cli_approval_decision(
+ mgr: &ApprovalManager,
+ request_id: &str,
+ sender: &str,
+ channel_name: &str,
+ reply_target: &str,
+ cancellation_token: Option<&CancellationToken>,
+) -> ApprovalResponse {
+ let started = Instant::now();
+
+ loop {
+ if let Some(decision) = mgr.take_non_cli_pending_resolution(request_id) {
+ return decision;
+ }
+
+ if !mgr.has_non_cli_pending_request(request_id) {
+ // Fail closed when the request disappears without an explicit resolution.
+ return ApprovalResponse::No;
+ }
+
+ if cancellation_token.is_some_and(CancellationToken::is_cancelled) {
+ return ApprovalResponse::No;
+ }
+
+ if started.elapsed() >= Duration::from_secs(NON_CLI_APPROVAL_WAIT_TIMEOUT_SECS) {
+ let _ =
+ mgr.reject_non_cli_pending_request(request_id, sender, channel_name, reply_target);
+ let _ = mgr.take_non_cli_pending_resolution(request_id);
+ return ApprovalResponse::No;
+ }
+
+ tokio::time::sleep(Duration::from_millis(NON_CLI_APPROVAL_POLL_INTERVAL_MS)).await;
+ }
+}
+
/// Convert a tool registry to OpenAI function-calling format for native tool support.
fn tools_to_openai_format(tools_registry: &[Box]) -> Vec {
tools_registry
@@ -143,1601 +304,6 @@ fn autosave_memory_key(prefix: &str) -> String {
format!("{prefix}_{}", Uuid::new_v4())
}
-/// Trim conversation history to prevent unbounded growth.
-/// Preserves the system prompt (first message if role=system) and the most recent messages.
-fn trim_history(history: &mut Vec, max_history: usize) {
- // Nothing to trim if within limit
- let has_system = history.first().map_or(false, |m| m.role == "system");
- let non_system_count = if has_system {
- history.len() - 1
- } else {
- history.len()
- };
-
- if non_system_count <= max_history {
- return;
- }
-
- let start = if has_system { 1 } else { 0 };
- let to_remove = non_system_count - max_history;
- history.drain(start..start + to_remove);
-}
-
-fn build_compaction_transcript(messages: &[ChatMessage]) -> String {
- let mut transcript = String::new();
- for msg in messages {
- let role = msg.role.to_uppercase();
- let _ = writeln!(transcript, "{role}: {}", msg.content.trim());
- }
-
- if transcript.chars().count() > COMPACTION_MAX_SOURCE_CHARS {
- truncate_with_ellipsis(&transcript, COMPACTION_MAX_SOURCE_CHARS)
- } else {
- transcript
- }
-}
-
-fn apply_compaction_summary(
- history: &mut Vec,
- start: usize,
- compact_end: usize,
- summary: &str,
-) {
- let summary_msg = ChatMessage::assistant(format!("[Compaction summary]\n{}", summary.trim()));
- history.splice(start..compact_end, std::iter::once(summary_msg));
-}
-
-async fn auto_compact_history(
- history: &mut Vec,
- provider: &dyn Provider,
- model: &str,
- max_history: usize,
-) -> Result {
- let has_system = history.first().map_or(false, |m| m.role == "system");
- let non_system_count = if has_system {
- history.len().saturating_sub(1)
- } else {
- history.len()
- };
-
- if non_system_count <= max_history {
- return Ok(false);
- }
-
- let start = if has_system { 1 } else { 0 };
- let keep_recent = COMPACTION_KEEP_RECENT_MESSAGES.min(non_system_count);
- let compact_count = non_system_count.saturating_sub(keep_recent);
- if compact_count == 0 {
- return Ok(false);
- }
-
- let compact_end = start + compact_count;
- let to_compact: Vec = history[start..compact_end].to_vec();
- let transcript = build_compaction_transcript(&to_compact);
-
- let summarizer_system = "You are a conversation compaction engine. Summarize older chat history into concise context for future turns. Preserve: user preferences, commitments, decisions, unresolved tasks, key facts. Omit: filler, repeated chit-chat, verbose tool logs. Output plain text bullet points only.";
-
- let summarizer_user = format!(
- "Summarize the following conversation history for context preservation. Keep it short (max 12 bullet points).\n\n{}",
- transcript
- );
-
- let summary_raw = provider
- .chat_with_system(Some(summarizer_system), &summarizer_user, model, 0.2)
- .await
- .unwrap_or_else(|_| {
- // Fallback to deterministic local truncation when summarization fails.
- truncate_with_ellipsis(&transcript, COMPACTION_MAX_SUMMARY_CHARS)
- });
-
- let summary = truncate_with_ellipsis(&summary_raw, COMPACTION_MAX_SUMMARY_CHARS);
- apply_compaction_summary(history, start, compact_end, &summary);
-
- Ok(true)
-}
-
-/// Build context preamble by searching memory for relevant entries.
-/// Entries with a hybrid score below `min_relevance_score` are dropped to
-/// prevent unrelated memories from bleeding into the conversation.
-async fn build_context(mem: &dyn Memory, user_msg: &str, min_relevance_score: f64) -> String {
- let mut context = String::new();
-
- // Pull relevant memories for this message
- if let Ok(entries) = mem.recall(user_msg, 5, None).await {
- let relevant: Vec<_> = entries
- .iter()
- .filter(|e| match e.score {
- Some(score) => score >= min_relevance_score,
- None => true,
- })
- .collect();
-
- if !relevant.is_empty() {
- context.push_str("[Memory context]\n");
- for entry in &relevant {
- if memory::is_assistant_autosave_key(&entry.key) {
- continue;
- }
- let _ = writeln!(context, "- {}: {}", entry.key, entry.content);
- }
- if context == "[Memory context]\n" {
- context.clear();
- } else {
- context.push('\n');
- }
- }
- }
-
- context
-}
-
-/// Build hardware datasheet context from RAG when peripherals are enabled.
-/// Includes pin-alias lookup (e.g. "red_led" → 13) when query matches, plus retrieved chunks.
-fn build_hardware_context(
- rag: &crate::rag::HardwareRag,
- user_msg: &str,
- boards: &[String],
- chunk_limit: usize,
-) -> String {
- if rag.is_empty() || boards.is_empty() {
- return String::new();
- }
-
- let mut context = String::new();
-
- // Pin aliases: when user says "red led", inject "red_led: 13" for matching boards
- let pin_ctx = rag.pin_alias_context(user_msg, boards);
- if !pin_ctx.is_empty() {
- context.push_str(&pin_ctx);
- }
-
- let chunks = rag.retrieve(user_msg, boards, chunk_limit);
- if chunks.is_empty() && pin_ctx.is_empty() {
- return String::new();
- }
-
- if !chunks.is_empty() {
- context.push_str("[Hardware documentation]\n");
- }
- for chunk in chunks {
- let board_tag = chunk.board.as_deref().unwrap_or("generic");
- let _ = writeln!(
- context,
- "--- {} ({}) ---\n{}\n",
- chunk.source, board_tag, chunk.content
- );
- }
- context.push('\n');
- context
-}
-
-/// Find a tool by name in the registry.
-fn find_tool<'a>(tools: &'a [Box], name: &str) -> Option<&'a dyn Tool> {
- tools.iter().find(|t| t.name() == name).map(|t| t.as_ref())
-}
-
-fn parse_arguments_value(raw: Option<&serde_json::Value>) -> serde_json::Value {
- match raw {
- Some(serde_json::Value::String(s)) => serde_json::from_str::(s)
- .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new())),
- Some(value) => value.clone(),
- None => serde_json::Value::Object(serde_json::Map::new()),
- }
-}
-
-fn parse_tool_call_id(
- root: &serde_json::Value,
- function: Option<&serde_json::Value>,
-) -> Option {
- function
- .and_then(|func| func.get("id"))
- .or_else(|| root.get("id"))
- .or_else(|| root.get("tool_call_id"))
- .or_else(|| root.get("call_id"))
- .and_then(serde_json::Value::as_str)
- .map(str::trim)
- .filter(|id| !id.is_empty())
- .map(ToString::to_string)
-}
-
-fn canonicalize_json_for_tool_signature(value: &serde_json::Value) -> serde_json::Value {
- match value {
- serde_json::Value::Object(map) => {
- let mut keys: Vec = map.keys().cloned().collect();
- keys.sort_unstable();
- let mut ordered = serde_json::Map::new();
- for key in keys {
- if let Some(child) = map.get(&key) {
- ordered.insert(key, canonicalize_json_for_tool_signature(child));
- }
- }
- serde_json::Value::Object(ordered)
- }
- serde_json::Value::Array(items) => serde_json::Value::Array(
- items
- .iter()
- .map(canonicalize_json_for_tool_signature)
- .collect(),
- ),
- _ => value.clone(),
- }
-}
-
-fn tool_call_signature(name: &str, arguments: &serde_json::Value) -> (String, String) {
- let canonical_args = canonicalize_json_for_tool_signature(arguments);
- let args_json = serde_json::to_string(&canonical_args).unwrap_or_else(|_| "{}".to_string());
- (name.trim().to_ascii_lowercase(), args_json)
-}
-
-fn parse_tool_call_value(value: &serde_json::Value) -> Option {
- if let Some(function) = value.get("function") {
- let tool_call_id = parse_tool_call_id(value, Some(function));
- let name = function
- .get("name")
- .and_then(|v| v.as_str())
- .unwrap_or("")
- .trim()
- .to_string();
- if !name.is_empty() {
- let arguments = parse_arguments_value(
- function
- .get("arguments")
- .or_else(|| function.get("parameters")),
- );
- return Some(ParsedToolCall {
- name,
- arguments,
- tool_call_id: tool_call_id,
- });
- }
- }
-
- let tool_call_id = parse_tool_call_id(value, None);
- let name = value
- .get("name")
- .and_then(|v| v.as_str())
- .unwrap_or("")
- .trim()
- .to_string();
-
- if name.is_empty() {
- return None;
- }
-
- let arguments =
- parse_arguments_value(value.get("arguments").or_else(|| value.get("parameters")));
- Some(ParsedToolCall {
- name,
- arguments,
- tool_call_id: tool_call_id,
- })
-}
-
-fn parse_tool_calls_from_json_value(value: &serde_json::Value) -> Vec {
- let mut calls = Vec::new();
-
- if let Some(tool_calls) = value.get("tool_calls").and_then(|v| v.as_array()) {
- for call in tool_calls {
- if let Some(parsed) = parse_tool_call_value(call) {
- calls.push(parsed);
- }
- }
-
- if !calls.is_empty() {
- return calls;
- }
- }
-
- if let Some(array) = value.as_array() {
- for item in array {
- if let Some(parsed) = parse_tool_call_value(item) {
- calls.push(parsed);
- }
- }
- return calls;
- }
-
- if let Some(parsed) = parse_tool_call_value(value) {
- calls.push(parsed);
- }
-
- calls
-}
-
-fn is_xml_meta_tag(tag: &str) -> bool {
- let normalized = tag.to_ascii_lowercase();
- matches!(
- normalized.as_str(),
- "tool_call"
- | "toolcall"
- | "tool-call"
- | "invoke"
- | "thinking"
- | "thought"
- | "analysis"
- | "reasoning"
- | "reflection"
- )
-}
-
-/// Match opening XML tags: ``. Does NOT use backreferences.
-static XML_OPEN_TAG_RE: LazyLock =
- LazyLock::new(|| Regex::new(r"<([a-zA-Z_][a-zA-Z0-9_-]*)>").unwrap());
-
-/// MiniMax XML invoke format:
-/// `pwd `
-static MINIMAX_INVOKE_RE: LazyLock = LazyLock::new(|| {
- Regex::new(r#"(?is)]*\bname\s*=\s*(?:"([^"]+)"|'([^']+)')[^>]*>(.*?) "#)
- .unwrap()
-});
-
-static MINIMAX_PARAMETER_RE: LazyLock = LazyLock::new(|| {
- Regex::new(
- r#"(?is)]*\bname\s*=\s*(?:"([^"]+)"|'([^']+)')[^>]*>(.*?) "#,
- )
- .unwrap()
-});
-
-/// Extracts all `… ` pairs from `input`, returning `(tag_name, inner_content)`.
-/// Handles matching closing tags without regex backreferences.
-fn extract_xml_pairs(input: &str) -> Vec<(&str, &str)> {
- let mut results = Vec::new();
- let mut search_start = 0;
- while let Some(open_cap) = XML_OPEN_TAG_RE.captures(&input[search_start..]) {
- let full_open = open_cap.get(0).unwrap();
- let tag_name = open_cap.get(1).unwrap().as_str();
- let open_end = search_start + full_open.end();
-
- let closing_tag = format!("{tag_name}>");
- if let Some(close_pos) = input[open_end..].find(&closing_tag) {
- let inner = &input[open_end..open_end + close_pos];
- results.push((tag_name, inner.trim()));
- search_start = open_end + close_pos + closing_tag.len();
- } else {
- search_start = open_end;
- }
- }
- results
-}
-
-/// Parse XML-style tool calls in `` bodies.
-/// Supports both nested argument tags and JSON argument payloads:
-/// - `... `
-/// - `{"command":"pwd"} `
-fn parse_xml_tool_calls(xml_content: &str) -> Option> {
- let mut calls = Vec::new();
- let trimmed = xml_content.trim();
-
- if !trimmed.starts_with('<') || !trimmed.contains('>') {
- return None;
- }
-
- for (tool_name_str, inner_content) in extract_xml_pairs(trimmed) {
- let tool_name = tool_name_str.to_string();
- if is_xml_meta_tag(&tool_name) {
- continue;
- }
-
- if inner_content.is_empty() {
- continue;
- }
-
- let mut args = serde_json::Map::new();
-
- if let Some(first_json) = extract_json_values(inner_content).into_iter().next() {
- match first_json {
- serde_json::Value::Object(object_args) => {
- args = object_args;
- }
- other => {
- args.insert("value".to_string(), other);
- }
- }
- } else {
- for (key_str, value) in extract_xml_pairs(inner_content) {
- let key = key_str.to_string();
- if is_xml_meta_tag(&key) {
- continue;
- }
- if !value.is_empty() {
- args.insert(key, serde_json::Value::String(value.to_string()));
- }
- }
-
- if args.is_empty() {
- args.insert(
- "content".to_string(),
- serde_json::Value::String(inner_content.to_string()),
- );
- }
- }
-
- calls.push(ParsedToolCall {
- name: tool_name,
- arguments: serde_json::Value::Object(args),
- tool_call_id: None,
- });
- }
-
- if calls.is_empty() {
- None
- } else {
- Some(calls)
- }
-}
-
-/// Parse MiniMax-style XML tool calls with attributed invoke/parameter tags.
-fn parse_minimax_invoke_calls(response: &str) -> Option<(String, Vec)> {
- let mut calls = Vec::new();
- let mut text_parts = Vec::new();
- let mut last_end = 0usize;
-
- for cap in MINIMAX_INVOKE_RE.captures_iter(response) {
- let Some(full_match) = cap.get(0) else {
- continue;
- };
-
- let before = response[last_end..full_match.start()].trim();
- if !before.is_empty() {
- text_parts.push(before.to_string());
- }
-
- let name = cap
- .get(1)
- .or_else(|| cap.get(2))
- .map(|m| m.as_str().trim())
- .filter(|v| !v.is_empty());
- let body = cap.get(3).map(|m| m.as_str()).unwrap_or("").trim();
- last_end = full_match.end();
-
- let Some(name) = name else {
- continue;
- };
-
- let mut args = serde_json::Map::new();
- for param_cap in MINIMAX_PARAMETER_RE.captures_iter(body) {
- let key = param_cap
- .get(1)
- .or_else(|| param_cap.get(2))
- .map(|m| m.as_str().trim())
- .unwrap_or_default();
- if key.is_empty() {
- continue;
- }
- let value = param_cap
- .get(3)
- .map(|m| m.as_str().trim())
- .unwrap_or_default();
- if value.is_empty() {
- continue;
- }
-
- let parsed = extract_json_values(value).into_iter().next();
- args.insert(
- key.to_string(),
- parsed.unwrap_or_else(|| serde_json::Value::String(value.to_string())),
- );
- }
-
- if args.is_empty() {
- if let Some(first_json) = extract_json_values(body).into_iter().next() {
- match first_json {
- serde_json::Value::Object(obj) => args = obj,
- other => {
- args.insert("value".to_string(), other);
- }
- }
- } else if !body.is_empty() {
- args.insert(
- "content".to_string(),
- serde_json::Value::String(body.to_string()),
- );
- }
- }
-
- calls.push(ParsedToolCall {
- name: name.to_string(),
- arguments: serde_json::Value::Object(args),
- tool_call_id: None,
- });
- }
-
- if calls.is_empty() {
- return None;
- }
-
- let after = response[last_end..].trim();
- if !after.is_empty() {
- text_parts.push(after.to_string());
- }
-
- let text = text_parts
- .join("\n")
- .replace("", "")
- .replace(" ", "")
- .replace("", "")
- .replace(" ", "")
- .trim()
- .to_string();
-
- Some((text, calls))
-}
-
-const TOOL_CALL_OPEN_TAGS: [&str; 6] = [
- "",
- "",
- "",
- "",
- "",
- "",
-];
-
-const TOOL_CALL_CLOSE_TAGS: [&str; 6] = [
- " ",
- "",
- "",
- "",
- "",
- "",
-];
-
-fn find_first_tag<'a>(haystack: &str, tags: &'a [&'a str]) -> Option<(usize, &'a str)> {
- tags.iter()
- .filter_map(|tag| haystack.find(tag).map(|idx| (idx, *tag)))
- .min_by_key(|(idx, _)| *idx)
-}
-
-fn matching_tool_call_close_tag(open_tag: &str) -> Option<&'static str> {
- match open_tag {
- "" => Some(" "),
- "" => Some(" "),
- "" => Some(" "),
- "" => Some(" "),
- "" => Some(" "),
- "" => Some(" "),
- _ => None,
- }
-}
-
-fn extract_first_json_value_with_end(input: &str) -> Option<(serde_json::Value, usize)> {
- let trimmed = input.trim_start();
- let trim_offset = input.len().saturating_sub(trimmed.len());
-
- for (byte_idx, ch) in trimmed.char_indices() {
- if ch != '{' && ch != '[' {
- continue;
- }
-
- let slice = &trimmed[byte_idx..];
- let mut stream = serde_json::Deserializer::from_str(slice).into_iter::();
- if let Some(Ok(value)) = stream.next() {
- let consumed = stream.byte_offset();
- if consumed > 0 {
- return Some((value, trim_offset + byte_idx + consumed));
- }
- }
- }
-
- None
-}
-
-fn strip_leading_close_tags(mut input: &str) -> &str {
- loop {
- let trimmed = input.trim_start();
- if !trimmed.starts_with("") {
- return trimmed;
- }
-
- let Some(close_end) = trimmed.find('>') else {
- return "";
- };
- input = &trimmed[close_end + 1..];
- }
-}
-
-/// Extract JSON values from a string.
-///
-/// # Security Warning
-///
-/// This function extracts ANY JSON objects/arrays from the input. It MUST only
-/// be used on content that is already trusted to be from the LLM, such as
-/// content inside `` tags where the LLM has explicitly indicated intent
-/// to make a tool call. Do NOT use this on raw user input or content that
-/// could contain prompt injection payloads.
-fn extract_json_values(input: &str) -> Vec {
- let mut values = Vec::new();
- let trimmed = input.trim();
- if trimmed.is_empty() {
- return values;
- }
-
- if let Ok(value) = serde_json::from_str::(trimmed) {
- values.push(value);
- return values;
- }
-
- let char_positions: Vec<(usize, char)> = trimmed.char_indices().collect();
- let mut idx = 0;
- while idx < char_positions.len() {
- let (byte_idx, ch) = char_positions[idx];
- if ch == '{' || ch == '[' {
- let slice = &trimmed[byte_idx..];
- let mut stream =
- serde_json::Deserializer::from_str(slice).into_iter::();
- if let Some(Ok(value)) = stream.next() {
- let consumed = stream.byte_offset();
- if consumed > 0 {
- values.push(value);
- let next_byte = byte_idx + consumed;
- while idx < char_positions.len() && char_positions[idx].0 < next_byte {
- idx += 1;
- }
- continue;
- }
- }
- }
- idx += 1;
- }
-
- values
-}
-
-/// Find the end position of a JSON object by tracking balanced braces.
-fn find_json_end(input: &str) -> Option {
- let trimmed = input.trim_start();
- let offset = input.len() - trimmed.len();
-
- if !trimmed.starts_with('{') {
- return None;
- }
-
- let mut depth = 0;
- let mut in_string = false;
- let mut escape_next = false;
-
- for (i, ch) in trimmed.char_indices() {
- if escape_next {
- escape_next = false;
- continue;
- }
-
- match ch {
- '\\' if in_string => escape_next = true,
- '"' => in_string = !in_string,
- '{' if !in_string => depth += 1,
- '}' if !in_string => {
- depth -= 1;
- if depth == 0 {
- return Some(offset + i + ch.len_utf8());
- }
- }
- _ => {}
- }
- }
-
- None
-}
-
-/// Parse XML attribute-style tool calls from response text.
-/// This handles MiniMax and similar providers that output:
-/// ```xml
-///
-///
-/// ls
-///
-///
-/// ```
-fn parse_xml_attribute_tool_calls(response: &str) -> Vec {
- let mut calls = Vec::new();
-
- // Regex to find ... blocks
- static INVOKE_RE: LazyLock = LazyLock::new(|| {
- Regex::new(r#"(?s)]*>(.*?) "#).unwrap()
- });
-
- // Regex to find value
- static PARAM_RE: LazyLock = LazyLock::new(|| {
- Regex::new(r#"]*>([^<]*) "#).unwrap()
- });
-
- for cap in INVOKE_RE.captures_iter(response) {
- let tool_name = cap.get(1).map(|m| m.as_str()).unwrap_or("");
- let inner = cap.get(2).map(|m| m.as_str()).unwrap_or("");
-
- if tool_name.is_empty() {
- continue;
- }
-
- let mut arguments = serde_json::Map::new();
-
- for param_cap in PARAM_RE.captures_iter(inner) {
- let param_name = param_cap.get(1).map(|m| m.as_str()).unwrap_or("");
- let param_value = param_cap.get(2).map(|m| m.as_str()).unwrap_or("");
-
- if !param_name.is_empty() {
- arguments.insert(
- param_name.to_string(),
- serde_json::Value::String(param_value.to_string()),
- );
- }
- }
-
- if !arguments.is_empty() {
- calls.push(ParsedToolCall {
- name: map_tool_name_alias(tool_name).to_string(),
- arguments: serde_json::Value::Object(arguments),
- tool_call_id: None,
- });
- }
- }
-
- calls
-}
-
-/// Parse Perl/hash-ref style tool calls from response text.
-/// This handles formats like:
-/// ```text
-/// TOOL_CALL
-/// {tool => "shell", args => {
-/// --command "ls -la"
-/// --description "List current directory contents"
-/// }}
-/// /TOOL_CALL
-/// ```
-fn parse_perl_style_tool_calls(response: &str) -> Vec {
- let mut calls = Vec::new();
-
- // Regex to find TOOL_CALL blocks - handle double closing braces }}
- static PERL_RE: LazyLock =
- LazyLock::new(|| Regex::new(r"(?s)TOOL_CALL\s*\{(.+?)\}\}\s*/TOOL_CALL").unwrap());
-
- // Regex to find tool => "name" in the content
- static TOOL_NAME_RE: LazyLock =
- LazyLock::new(|| Regex::new(r#"tool\s*=>\s*"([^"]+)""#).unwrap());
-
- // Regex to find args => { ... } block
- static ARGS_BLOCK_RE: LazyLock =
- LazyLock::new(|| Regex::new(r"(?s)args\s*=>\s*\{(.+?)\}").unwrap());
-
- // Regex to find --key "value" pairs
- static ARGS_RE: LazyLock =
- LazyLock::new(|| Regex::new(r#"--(\w+)\s+"([^"]+)""#).unwrap());
-
- for cap in PERL_RE.captures_iter(response) {
- let content = cap.get(1).map(|m| m.as_str()).unwrap_or("");
-
- // Extract tool name
- let tool_name = TOOL_NAME_RE
- .captures(content)
- .and_then(|c| c.get(1))
- .map(|m| m.as_str())
- .unwrap_or("");
-
- if tool_name.is_empty() {
- continue;
- }
-
- // Extract args block
- let args_block = ARGS_BLOCK_RE
- .captures(content)
- .and_then(|c| c.get(1))
- .map(|m| m.as_str())
- .unwrap_or("");
-
- let mut arguments = serde_json::Map::new();
-
- for arg_cap in ARGS_RE.captures_iter(args_block) {
- let key = arg_cap.get(1).map(|m| m.as_str()).unwrap_or("");
- let value = arg_cap.get(2).map(|m| m.as_str()).unwrap_or("");
-
- if !key.is_empty() {
- arguments.insert(
- key.to_string(),
- serde_json::Value::String(value.to_string()),
- );
- }
- }
-
- if !arguments.is_empty() {
- calls.push(ParsedToolCall {
- name: map_tool_name_alias(tool_name).to_string(),
- arguments: serde_json::Value::Object(arguments),
- tool_call_id: None,
- });
- }
- }
-
- calls
-}
-
-/// Parse FunctionCall-style tool calls from response text.
-/// This handles formats like:
-/// ```text
-///
-/// file_read
-/// path>/Users/kylelampa/Documents/zeroclaw/README.md
-///
-/// ```
-fn parse_function_call_tool_calls(response: &str) -> Vec {
- let mut calls = Vec::new();
-
- // Regex to find blocks
- static FUNC_RE: LazyLock = LazyLock::new(|| {
- Regex::new(r"(?s)\s*(\w+)\s*([^<]+)\s* ").unwrap()
- });
-
- for cap in FUNC_RE.captures_iter(response) {
- let tool_name = cap.get(1).map(|m| m.as_str()).unwrap_or("");
- let args_text = cap.get(2).map(|m| m.as_str()).unwrap_or("");
-
- if tool_name.is_empty() {
- continue;
- }
-
- // Parse key>value pairs (e.g., path>/Users/.../file.txt)
- let mut arguments = serde_json::Map::new();
- for line in args_text.lines() {
- let line = line.trim();
- if let Some(pos) = line.find('>') {
- let key = line[..pos].trim();
- let value = line[pos + 1..].trim();
- if !key.is_empty() && !value.is_empty() {
- arguments.insert(
- key.to_string(),
- serde_json::Value::String(value.to_string()),
- );
- }
- }
- }
-
- if !arguments.is_empty() {
- calls.push(ParsedToolCall {
- name: map_tool_name_alias(tool_name).to_string(),
- arguments: serde_json::Value::Object(arguments),
- tool_call_id: None,
- });
- }
- }
-
- calls
-}
-
-/// Parse GLM-style tool calls from response text.
-/// Map tool name aliases from various LLM providers to ZeroClaw tool names.
-/// This handles variations like "fileread" -> "file_read", "bash" -> "shell", etc.
-fn map_tool_name_alias(tool_name: &str) -> &str {
- match tool_name {
- // Shell variations (including GLM aliases that map to shell)
- "shell" | "bash" | "sh" | "exec" | "command" | "cmd" | "browser_open" | "browser"
- | "web_search" => "shell",
- // Messaging variations
- "send_message" | "sendmessage" => "message_send",
- // File tool variations
- "fileread" | "file_read" | "readfile" | "read_file" | "file" => "file_read",
- "filewrite" | "file_write" | "writefile" | "write_file" => "file_write",
- "filelist" | "file_list" | "listfiles" | "list_files" => "file_list",
- // Memory variations
- "memoryrecall" | "memory_recall" | "recall" | "memrecall" => "memory_recall",
- "memorystore" | "memory_store" | "store" | "memstore" => "memory_store",
- "memoryforget" | "memory_forget" | "forget" | "memforget" => "memory_forget",
- // HTTP variations
- "http_request" | "http" | "fetch" | "curl" | "wget" => "http_request",
- _ => tool_name,
- }
-}
-
-fn build_curl_command(url: &str) -> Option {
- if !(url.starts_with("http://") || url.starts_with("https://")) {
- return None;
- }
-
- if url.chars().any(char::is_whitespace) {
- return None;
- }
-
- let escaped = url.replace('\'', r#"'\\''"#);
- Some(format!("curl -s '{}'", escaped))
-}
-
-fn parse_glm_style_tool_calls(text: &str) -> Vec<(String, serde_json::Value, Option)> {
- let mut calls = Vec::new();
-
- for line in text.lines() {
- let line = line.trim();
- if line.is_empty() {
- continue;
- }
-
- // Format: tool_name/param>value or tool_name/{json}
- if let Some(pos) = line.find('/') {
- let tool_part = &line[..pos];
- let rest = &line[pos + 1..];
-
- if tool_part.chars().all(|c| c.is_alphanumeric() || c == '_') {
- let tool_name = map_tool_name_alias(tool_part);
-
- if let Some(gt_pos) = rest.find('>') {
- let param_name = rest[..gt_pos].trim();
- let value = rest[gt_pos + 1..].trim();
-
- let arguments = match tool_name {
- "shell" => {
- if param_name == "url" {
- let Some(command) = build_curl_command(value) else {
- continue;
- };
- serde_json::json!({ "command": command })
- } else if value.starts_with("http://") || value.starts_with("https://")
- {
- if let Some(command) = build_curl_command(value) {
- serde_json::json!({ "command": command })
- } else {
- serde_json::json!({ "command": value })
- }
- } else {
- serde_json::json!({ "command": value })
- }
- }
- "http_request" => {
- serde_json::json!({"url": value, "method": "GET"})
- }
- _ => serde_json::json!({ param_name: value }),
- };
-
- calls.push((tool_name.to_string(), arguments, Some(line.to_string())));
- continue;
- }
-
- if rest.starts_with('{') {
- if let Ok(json_args) = serde_json::from_str::(rest) {
- calls.push((tool_name.to_string(), json_args, Some(line.to_string())));
- }
- }
- }
- }
-
- // Plain URL
- if let Some(command) = build_curl_command(line) {
- calls.push((
- "shell".to_string(),
- serde_json::json!({ "command": command }),
- Some(line.to_string()),
- ));
- }
- }
-
- calls
-}
-
-/// Return the canonical default parameter name for a tool.
-///
-/// When a model emits a shortened call like `shell>uname -a` (without an
-/// explicit `/param_name`), we need to infer which parameter the value maps
-/// to. This function encodes the mapping for known ZeroClaw tools.
-fn default_param_for_tool(tool: &str) -> &'static str {
- match tool {
- "shell" | "bash" | "sh" | "exec" | "command" | "cmd" => "command",
- // All file tools default to "path"
- "file_read" | "fileread" | "readfile" | "read_file" | "file" | "file_write"
- | "filewrite" | "writefile" | "write_file" | "file_edit" | "fileedit" | "editfile"
- | "edit_file" | "file_list" | "filelist" | "listfiles" | "list_files" => "path",
- // Memory recall and forget both default to "query"
- "memory_recall" | "memoryrecall" | "recall" | "memrecall" | "memory_forget"
- | "memoryforget" | "forget" | "memforget" => "query",
- "memory_store" | "memorystore" | "store" | "memstore" => "content",
- // HTTP and browser tools default to "url"
- "http_request" | "http" | "fetch" | "curl" | "wget" | "browser_open" | "browser"
- | "web_search" => "url",
- _ => "input",
- }
-}
-
-/// Parse GLM-style shortened tool call bodies found inside `` tags.
-///
-/// Handles three sub-formats that GLM-4.7 emits:
-///
-/// 1. **Shortened**: `tool_name>value` — single value mapped via
-/// [`default_param_for_tool`].
-/// 2. **YAML-like multi-line**: `tool_name>\nkey: value\nkey: value` — each
-/// subsequent `key: value` line becomes a parameter.
-/// 3. **Attribute-style**: `tool_name key="value" [/]>` — XML-like attributes.
-///
-/// Returns `None` if the body does not match any of these formats.
-fn parse_glm_shortened_body(body: &str) -> Option {
- let body = body.trim();
- if body.is_empty() {
- return None;
- }
-
- let function_style = body.find('(').and_then(|open| {
- if body.ends_with(')') && open > 0 {
- Some((body[..open].trim(), body[open + 1..body.len() - 1].trim()))
- } else {
- None
- }
- });
-
- // Check attribute-style FIRST: `tool_name key="value" />`
- // Must come before `>` check because `/>` contains `>` and would
- // misparse the tool name in the first branch.
- let (tool_raw, value_part) = if let Some((tool, args)) = function_style {
- (tool, args)
- } else if body.contains("=\"") {
- // Attribute-style: split at first whitespace to get tool name
- let split_pos = body.find(|c: char| c.is_whitespace()).unwrap_or(body.len());
- let tool = body[..split_pos].trim();
- let attrs = body[split_pos..]
- .trim()
- .trim_end_matches("/>")
- .trim_end_matches('>')
- .trim_end_matches('/')
- .trim();
- (tool, attrs)
- } else if let Some(gt_pos) = body.find('>') {
- // GLM shortened: `tool_name>value`
- let tool = body[..gt_pos].trim();
- let value = body[gt_pos + 1..].trim();
- // Strip trailing self-close markers that some models emit
- let value = value.trim_end_matches("/>").trim_end_matches('/').trim();
- (tool, value)
- } else {
- return None;
- };
-
- // Validate tool name: must be alphanumeric + underscore only
- let tool_raw = tool_raw.trim_end_matches(|c: char| c.is_whitespace());
- if tool_raw.is_empty() || !tool_raw.chars().all(|c| c.is_alphanumeric() || c == '_') {
- return None;
- }
-
- let tool_name = map_tool_name_alias(tool_raw);
-
- // Try attribute-style: `key="value" key2="value2"`
- if value_part.contains("=\"") {
- let mut args = serde_json::Map::new();
- // Simple attribute parser: key="value" pairs
- let mut rest = value_part;
- while let Some(eq_pos) = rest.find("=\"") {
- let key_start = rest[..eq_pos]
- .rfind(|c: char| c.is_whitespace())
- .map(|p| p + 1)
- .unwrap_or(0);
- let key = rest[key_start..eq_pos]
- .trim()
- .trim_matches(|c: char| c == ',' || c == ';');
- let after_quote = &rest[eq_pos + 2..];
- if let Some(end_quote) = after_quote.find('"') {
- let value = &after_quote[..end_quote];
- if !key.is_empty() {
- args.insert(
- key.to_string(),
- serde_json::Value::String(value.to_string()),
- );
- }
- rest = &after_quote[end_quote + 1..];
- } else {
- break;
- }
- }
- if !args.is_empty() {
- return Some(ParsedToolCall {
- name: tool_name.to_string(),
- arguments: serde_json::Value::Object(args),
- tool_call_id: None,
- });
- }
- }
-
- // Try YAML-style multi-line: each line is `key: value`
- if value_part.contains('\n') {
- let mut args = serde_json::Map::new();
- for line in value_part.lines() {
- let line = line.trim();
- if line.is_empty() {
- continue;
- }
- if let Some(colon_pos) = line.find(':') {
- let key = line[..colon_pos].trim();
- let value = line[colon_pos + 1..].trim();
- if !key.is_empty() && !value.is_empty() {
- // Normalize boolean-like values
- let json_value = match value {
- "true" | "yes" => serde_json::Value::Bool(true),
- "false" | "no" => serde_json::Value::Bool(false),
- _ => serde_json::Value::String(value.to_string()),
- };
- args.insert(key.to_string(), json_value);
- }
- }
- }
- if !args.is_empty() {
- return Some(ParsedToolCall {
- name: tool_name.to_string(),
- arguments: serde_json::Value::Object(args),
- tool_call_id: None,
- });
- }
- }
-
- // Single-value shortened: `tool>value`
- if !value_part.is_empty() {
- let param = default_param_for_tool(tool_raw);
- let arguments = match tool_name {
- "shell" => {
- if value_part.starts_with("http://") || value_part.starts_with("https://") {
- if let Some(cmd) = build_curl_command(value_part) {
- serde_json::json!({ "command": cmd })
- } else {
- serde_json::json!({ "command": value_part })
- }
- } else {
- serde_json::json!({ "command": value_part })
- }
- }
- "http_request" => serde_json::json!({"url": value_part, "method": "GET"}),
- _ => serde_json::json!({ param: value_part }),
- };
- return Some(ParsedToolCall {
- name: tool_name.to_string(),
- arguments,
- tool_call_id: None,
- });
- }
-
- None
-}
-
-// ── Tool-Call Parsing ─────────────────────────────────────────────────────
-// LLM responses may contain tool calls in multiple formats depending on
-// the provider. Parsing follows a priority chain:
-// 1. OpenAI-style JSON with `tool_calls` array (native API)
-// 2. XML tags: , , ,
-// 3. Markdown code blocks with `tool_call` language
-// 4. GLM-style line-based format (e.g. `shell/command>ls`)
-// SECURITY: We never fall back to extracting arbitrary JSON from the
-// response body, because that would enable prompt-injection attacks where
-// malicious content in emails/files/web pages mimics a tool call.
-
-/// Parse tool calls from an LLM response that uses XML-style function calling.
-///
-/// Expected format (common with system-prompt-guided tool use):
-/// ```text
-///
-/// {"name": "shell", "arguments": {"command": "ls"}}
-///
-/// ```
-///
-/// Also accepts common tag variants (``, ``) for model
-/// compatibility.
-///
-/// Also supports JSON with `tool_calls` array from OpenAI-format responses.
-fn parse_tool_calls(response: &str) -> (String, Vec) {
- let mut text_parts = Vec::new();
- let mut calls = Vec::new();
- let mut remaining = response;
-
- // First, try to parse as OpenAI-style JSON response with tool_calls array
- // This handles providers like Minimax that return tool_calls in native JSON format
- if let Ok(json_value) = serde_json::from_str::(response.trim()) {
- calls = parse_tool_calls_from_json_value(&json_value);
- if !calls.is_empty() {
- // If we found tool_calls, extract any content field as text
- if let Some(content) = json_value.get("content").and_then(|v| v.as_str()) {
- if !content.trim().is_empty() {
- text_parts.push(content.trim().to_string());
- }
- }
- return (text_parts.join("\n"), calls);
- }
- }
-
- if let Some((minimax_text, minimax_calls)) = parse_minimax_invoke_calls(response) {
- if !minimax_calls.is_empty() {
- return (minimax_text, minimax_calls);
- }
- }
-
- // Fall back to XML-style tool-call tag parsing.
- while let Some((start, open_tag)) = find_first_tag(remaining, &TOOL_CALL_OPEN_TAGS) {
- // Everything before the tag is text
- let before = &remaining[..start];
- if !before.trim().is_empty() {
- text_parts.push(before.trim().to_string());
- }
-
- let Some(close_tag) = matching_tool_call_close_tag(open_tag) else {
- break;
- };
-
- let after_open = &remaining[start + open_tag.len()..];
- if let Some(close_idx) = after_open.find(close_tag) {
- let inner = &after_open[..close_idx];
- let mut parsed_any = false;
-
- // Try JSON format first
- let json_values = extract_json_values(inner);
- for value in json_values {
- let parsed_calls = parse_tool_calls_from_json_value(&value);
- if !parsed_calls.is_empty() {
- parsed_any = true;
- calls.extend(parsed_calls);
- }
- }
-
- // If JSON parsing failed, try XML format (DeepSeek/GLM style)
- if !parsed_any {
- if let Some(xml_calls) = parse_xml_tool_calls(inner) {
- calls.extend(xml_calls);
- parsed_any = true;
- }
- }
-
- if !parsed_any {
- // GLM-style shortened body: `shell>uname -a` or `shell\ncommand: date`
- if let Some(glm_call) = parse_glm_shortened_body(inner) {
- calls.push(glm_call);
- parsed_any = true;
- }
- }
-
- if !parsed_any {
- tracing::warn!(
- "Malformed : expected tool-call object in tag body (JSON/XML/GLM)"
- );
- }
-
- remaining = &after_open[close_idx + close_tag.len()..];
- } else {
- // Matching close tag not found — try cross-alias close tags first.
- // Models sometimes mix open/close tag aliases (e.g. ... ).
- let mut resolved = false;
- if let Some((cross_idx, cross_tag)) = find_first_tag(after_open, &TOOL_CALL_CLOSE_TAGS)
- {
- let inner = &after_open[..cross_idx];
- let mut parsed_any = false;
-
- // Try JSON
- let json_values = extract_json_values(inner);
- for value in json_values {
- let parsed_calls = parse_tool_calls_from_json_value(&value);
- if !parsed_calls.is_empty() {
- parsed_any = true;
- calls.extend(parsed_calls);
- }
- }
-
- // Try XML
- if !parsed_any {
- if let Some(xml_calls) = parse_xml_tool_calls(inner) {
- calls.extend(xml_calls);
- parsed_any = true;
- }
- }
-
- // Try GLM shortened body
- if !parsed_any {
- if let Some(glm_call) = parse_glm_shortened_body(inner) {
- calls.push(glm_call);
- parsed_any = true;
- }
- }
-
- if parsed_any {
- remaining = &after_open[cross_idx + cross_tag.len()..];
- resolved = true;
- }
- }
-
- if resolved {
- continue;
- }
-
- // No cross-alias close tag resolved — fall back to JSON recovery
- // from unclosed tags (brace-balancing).
- if let Some(json_end) = find_json_end(after_open) {
- if let Ok(value) =
- serde_json::from_str::(&after_open[..json_end])
- {
- let parsed_calls = parse_tool_calls_from_json_value(&value);
- if !parsed_calls.is_empty() {
- calls.extend(parsed_calls);
- remaining = strip_leading_close_tags(&after_open[json_end..]);
- continue;
- }
- }
- }
-
- if let Some((value, consumed_end)) = extract_first_json_value_with_end(after_open) {
- let parsed_calls = parse_tool_calls_from_json_value(&value);
- if !parsed_calls.is_empty() {
- calls.extend(parsed_calls);
- remaining = strip_leading_close_tags(&after_open[consumed_end..]);
- continue;
- }
- }
-
- // Last resort: try GLM shortened body on everything after the open tag.
- // The model may have emitted `shell>ls` with no close tag at all.
- let glm_input = after_open.trim();
- if let Some(glm_call) = parse_glm_shortened_body(glm_input) {
- calls.push(glm_call);
- remaining = "";
- continue;
- }
-
- remaining = &remaining[start..];
- break;
- }
- }
-
- // If XML tags found nothing, try markdown code blocks with tool_call language.
- // Models behind OpenRouter sometimes output ```tool_call ... ``` or hybrid
- // ```tool_call ... instead of structured API calls or XML tags.
- if calls.is_empty() {
- static MD_TOOL_CALL_RE: LazyLock = LazyLock::new(|| {
- Regex::new(
- r"(?s)```(?:tool[_-]?call|invoke)\s*\n(.*?)(?:```|
|||)",
- )
- .unwrap()
- });
- let mut md_text_parts: Vec = Vec::new();
- let mut last_end = 0;
-
- for cap in MD_TOOL_CALL_RE.captures_iter(response) {
- let full_match = cap.get(0).unwrap();
- let before = &response[last_end..full_match.start()];
- if !before.trim().is_empty() {
- md_text_parts.push(before.trim().to_string());
- }
- let inner = &cap[1];
- let json_values = extract_json_values(inner);
- for value in json_values {
- let parsed_calls = parse_tool_calls_from_json_value(&value);
- calls.extend(parsed_calls);
- }
- last_end = full_match.end();
- }
-
- if !calls.is_empty() {
- let after = &response[last_end..];
- if !after.trim().is_empty() {
- md_text_parts.push(after.trim().to_string());
- }
- text_parts = md_text_parts;
- remaining = "";
- }
- }
-
- // Try ```tool format used by some providers (e.g., xAI grok)
- // Example: ```tool file_write\n{"path": "...", "content": "..."}\n```
- if calls.is_empty() {
- static MD_TOOL_NAME_RE: LazyLock =
- LazyLock::new(|| Regex::new(r"(?s)```tool\s+(\w+)\s*\n(.*?)(?:```|$)").unwrap());
- let mut md_text_parts: Vec = Vec::new();
- let mut last_end = 0;
-
- for cap in MD_TOOL_NAME_RE.captures_iter(response) {
- let full_match = cap.get(0).unwrap();
- let before = &response[last_end..full_match.start()];
- if !before.trim().is_empty() {
- md_text_parts.push(before.trim().to_string());
- }
- let tool_name = &cap[1];
- let inner = &cap[2];
-
- // Try to parse the inner content as JSON arguments
- let json_values = extract_json_values(inner);
- if json_values.is_empty() {
- // Log a warning if we found a tool block but couldn't parse arguments
- tracing::warn!(
- tool_name = %tool_name,
- inner = %inner.chars().take(100).collect::(),
- "Found ```tool block but could not parse JSON arguments"
- );
- } else {
- for value in json_values {
- let arguments = if value.is_object() {
- value
- } else {
- serde_json::Value::Object(serde_json::Map::new())
- };
- calls.push(ParsedToolCall {
- name: tool_name.to_string(),
- arguments,
- tool_call_id: None,
- });
- }
- }
- last_end = full_match.end();
- }
-
- if !calls.is_empty() {
- let after = &response[last_end..];
- if !after.trim().is_empty() {
- md_text_parts.push(after.trim().to_string());
- }
- text_parts = md_text_parts;
- remaining = "";
- }
- }
-
- // XML attribute-style tool calls:
- //
- //
- // ls
- //
- //
- if calls.is_empty() {
- let xml_calls = parse_xml_attribute_tool_calls(remaining);
- if !xml_calls.is_empty() {
- let mut cleaned_text = remaining.to_string();
- for call in xml_calls {
- calls.push(call);
- // Try to remove the XML from text
- if let Some(start) = cleaned_text.find("") {
- if let Some(end) = cleaned_text.find(" ") {
- let end_pos = end + "".len();
- if end_pos <= cleaned_text.len() {
- cleaned_text =
- format!("{}{}", &cleaned_text[..start], &cleaned_text[end_pos..]);
- }
- }
- }
- }
- if !cleaned_text.trim().is_empty() {
- text_parts.push(cleaned_text.trim().to_string());
- }
- remaining = "";
- }
- }
-
- // Perl/hash-ref style tool calls:
- // TOOL_CALL
- // {tool => "shell", args => {
- // --command "ls -la"
- // --description "List current directory contents"
- // }}
- // /TOOL_CALL
- if calls.is_empty() {
- let perl_calls = parse_perl_style_tool_calls(remaining);
- if !perl_calls.is_empty() {
- let mut cleaned_text = remaining.to_string();
- for call in perl_calls {
- calls.push(call);
- // Try to remove the TOOL_CALL block from text
- while let Some(start) = cleaned_text.find("TOOL_CALL") {
- if let Some(end) = cleaned_text.find("/TOOL_CALL") {
- let end_pos = end + "/TOOL_CALL".len();
- if end_pos <= cleaned_text.len() {
- cleaned_text =
- format!("{}{}", &cleaned_text[..start], &cleaned_text[end_pos..]);
- }
- } else {
- break;
- }
- }
- }
- if !cleaned_text.trim().is_empty() {
- text_parts.push(cleaned_text.trim().to_string());
- }
- remaining = "";
- }
- }
-
- //
- // file_read
- // path>/Users/...
- //
- if calls.is_empty() {
- let func_calls = parse_function_call_tool_calls(remaining);
- if !func_calls.is_empty() {
- let mut cleaned_text = remaining.to_string();
- for call in func_calls {
- calls.push(call);
- // Try to remove the FunctionCall block from text
- while let Some(start) = cleaned_text.find("") {
- if let Some(end) = cleaned_text.find(" ") {
- let end_pos = end + "".len();
- if end_pos <= cleaned_text.len() {
- cleaned_text =
- format!("{}{}", &cleaned_text[..start], &cleaned_text[end_pos..]);
- }
- } else {
- break;
- }
- }
- }
- if !cleaned_text.trim().is_empty() {
- text_parts.push(cleaned_text.trim().to_string());
- }
- remaining = "";
- }
- }
-
- // GLM-style tool calls (browser_open/url>https://..., shell/command>ls, etc.)
- if calls.is_empty() {
- let glm_calls = parse_glm_style_tool_calls(remaining);
- if !glm_calls.is_empty() {
- let mut cleaned_text = remaining.to_string();
- for (name, args, raw) in &glm_calls {
- calls.push(ParsedToolCall {
- name: name.clone(),
- arguments: args.clone(),
- tool_call_id: None,
- });
- if let Some(r) = raw {
- cleaned_text = cleaned_text.replace(r, "");
- }
- }
- if !cleaned_text.trim().is_empty() {
- text_parts.push(cleaned_text.trim().to_string());
- }
- remaining = "";
- }
- }
-
- // SECURITY: We do NOT fall back to extracting arbitrary JSON from the response
- // here. That would enable prompt injection attacks where malicious content
- // (e.g., in emails, files, or web pages) could include JSON that mimics a
- // tool call. Tool calls MUST be explicitly wrapped in either:
- // 1. OpenAI-style JSON with a "tool_calls" array
- // 2. ZeroClaw tool-call tags (, , )
- // 3. Markdown code blocks with tool_call/toolcall/tool-call language
- // 4. Explicit GLM line-based call formats (e.g. `shell/command>...`)
- // This ensures only the LLM's intentional tool calls are executed.
-
- // Remaining text after last tool call
- if !remaining.trim().is_empty() {
- text_parts.push(remaining.trim().to_string());
- }
-
- (text_parts.join("\n"), calls)
-}
-
-fn detect_tool_call_parse_issue(response: &str, parsed_calls: &[ParsedToolCall]) -> Option {
- if !parsed_calls.is_empty() {
- return None;
- }
-
- let trimmed = response.trim();
- if trimmed.is_empty() {
- return None;
- }
-
- let looks_like_tool_payload = trimmed.contains(" pattern
- || trimmed.contains("\"tool_calls\"")
- || trimmed.contains("TOOL_CALL")
- || trimmed.contains("");
-
- if looks_like_tool_payload {
- Some("response resembled a tool-call payload but no valid tool call could be parsed".into())
- } else {
- None
- }
-}
-
-fn parse_structured_tool_calls(tool_calls: &[ToolCall]) -> Vec {
- tool_calls
- .iter()
- .map(|call| ParsedToolCall {
- name: call.name.clone(),
- arguments: serde_json::from_str::(&call.arguments)
- .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new())),
- tool_call_id: Some(call.id.clone()),
- })
- .collect()
-}
-
/// Build assistant history entry in JSON format for native tool-call APIs.
/// `convert_messages` in the OpenRouter provider parses this JSON to reconstruct
/// the proper `NativeMessage` with structured `tool_calls`.
@@ -1836,13 +402,6 @@ fn build_assistant_history_with_tool_calls(text: &str, tool_calls: &[ToolCall])
parts.join("\n")
}
-#[derive(Debug, Clone)]
-struct ParsedToolCall {
- name: String,
- arguments: serde_json::Value,
- tool_call_id: Option,
-}
-
#[derive(Debug)]
pub(crate) struct ToolLoopCancelled;
@@ -1858,6 +417,14 @@ pub(crate) fn is_tool_loop_cancelled(err: &anyhow::Error) -> bool {
err.chain().any(|source| source.is::())
}
+pub(crate) fn is_tool_iteration_limit_error(err: &anyhow::Error) -> bool {
+ err.chain().any(|source| {
+ source
+ .to_string()
+ .contains("Agent exceeded maximum tool iterations")
+ })
+}
+
/// Execute a single turn of the agent loop: send messages, parse tool calls,
/// execute tools, and loop until the LLM produces a final text response.
/// When `silent` is true, suppresses stdout (for channel use).
@@ -1895,158 +462,104 @@ pub(crate) async fn agent_turn(
.await
}
-async fn execute_one_tool(
- call_name: &str,
- call_arguments: serde_json::Value,
+/// Run the tool loop with channel reply_target context, used by channel runtimes
+/// to auto-populate delivery routing for scheduled reminders.
+#[allow(clippy::too_many_arguments)]
+pub(crate) async fn run_tool_call_loop_with_reply_target(
+ provider: &dyn Provider,
+ history: &mut Vec,
tools_registry: &[Box],
observer: &dyn Observer,
- cancellation_token: Option<&CancellationToken>,
-) -> Result {
- observer.record_event(&ObserverEvent::ToolCallStart {
- tool: call_name.to_string(),
- });
- let start = Instant::now();
-
- let Some(tool) = find_tool(tools_registry, call_name) else {
- let reason = format!("Unknown tool: {call_name}");
- let duration = start.elapsed();
- observer.record_event(&ObserverEvent::ToolCall {
- tool: call_name.to_string(),
- duration,
- success: false,
- });
- return Ok(ToolExecutionOutcome {
- output: reason.clone(),
- success: false,
- error_reason: Some(scrub_credentials(&reason)),
- duration,
- });
- };
-
- let tool_future = tool.execute(call_arguments);
- let tool_result = if let Some(token) = cancellation_token {
- tokio::select! {
- () = token.cancelled() => return Err(ToolLoopCancelled.into()),
- result = tool_future => result,
- }
- } else {
- tool_future.await
- };
-
- match tool_result {
- Ok(r) => {
- let duration = start.elapsed();
- observer.record_event(&ObserverEvent::ToolCall {
- tool: call_name.to_string(),
- duration,
- success: r.success,
- });
- if r.success {
- Ok(ToolExecutionOutcome {
- output: scrub_credentials(&r.output),
- success: true,
- error_reason: None,
- duration,
- })
- } else {
- let reason = r.error.unwrap_or(r.output);
- Ok(ToolExecutionOutcome {
- output: format!("Error: {reason}"),
- success: false,
- error_reason: Some(scrub_credentials(&reason)),
- duration,
- })
- }
- }
- Err(e) => {
- let duration = start.elapsed();
- observer.record_event(&ObserverEvent::ToolCall {
- tool: call_name.to_string(),
- duration,
- success: false,
- });
- let reason = format!("Error executing {call_name}: {e}");
- Ok(ToolExecutionOutcome {
- output: reason.clone(),
- success: false,
- error_reason: Some(scrub_credentials(&reason)),
- duration,
- })
- }
- }
-}
-
-struct ToolExecutionOutcome {
- output: String,
- success: bool,
- error_reason: Option,
- duration: Duration,
-}
-
-fn should_execute_tools_in_parallel(
- tool_calls: &[ParsedToolCall],
+ provider_name: &str,
+ model: &str,
+ temperature: f64,
+ silent: bool,
approval: Option<&ApprovalManager>,
-) -> bool {
- if tool_calls.len() <= 1 {
- return false;
- }
-
- if let Some(mgr) = approval {
- if tool_calls.iter().any(|call| mgr.needs_approval(&call.name)) {
- // Approval-gated calls must keep sequential handling so the caller can
- // enforce CLI prompt/deny policy consistently.
- return false;
- }
- }
-
- true
-}
-
-async fn execute_tools_parallel(
- tool_calls: &[ParsedToolCall],
- tools_registry: &[Box],
- observer: &dyn Observer,
- cancellation_token: Option<&CancellationToken>,
-) -> Result> {
- let futures: Vec<_> = tool_calls
- .iter()
- .map(|call| {
- execute_one_tool(
- &call.name,
- call.arguments.clone(),
+ channel_name: &str,
+ reply_target: Option<&str>,
+ multimodal_config: &crate::config::MultimodalConfig,
+ max_tool_iterations: usize,
+ cancellation_token: Option,
+ on_delta: Option>,
+ hooks: Option<&crate::hooks::HookRunner>,
+ excluded_tools: &[String],
+) -> Result {
+ TOOL_LOOP_REPLY_TARGET
+ .scope(
+ reply_target.map(str::to_string),
+ run_tool_call_loop(
+ provider,
+ history,
tools_registry,
observer,
+ provider_name,
+ model,
+ temperature,
+ silent,
+ approval,
+ channel_name,
+ multimodal_config,
+ max_tool_iterations,
cancellation_token,
- )
- })
- .collect();
-
- let results = futures_util::future::join_all(futures).await;
- results.into_iter().collect()
+ on_delta,
+ hooks,
+ excluded_tools,
+ ),
+ )
+ .await
}
-async fn execute_tools_sequential(
- tool_calls: &[ParsedToolCall],
+/// Run the tool loop with optional non-CLI approval context scoped to this task.
+#[allow(clippy::too_many_arguments)]
+pub(crate) async fn run_tool_call_loop_with_non_cli_approval_context(
+ provider: &dyn Provider,
+ history: &mut Vec,
tools_registry: &[Box],
observer: &dyn Observer,
- cancellation_token: Option<&CancellationToken>,
-) -> Result> {
- let mut outcomes = Vec::with_capacity(tool_calls.len());
+ provider_name: &str,
+ model: &str,
+ temperature: f64,
+ silent: bool,
+ approval: Option<&ApprovalManager>,
+ channel_name: &str,
+ non_cli_approval_context: Option,
+ multimodal_config: &crate::config::MultimodalConfig,
+ max_tool_iterations: usize,
+ cancellation_token: Option,
+ on_delta: Option>,
+ hooks: Option<&crate::hooks::HookRunner>,
+ excluded_tools: &[String],
+) -> Result {
+ let reply_target = non_cli_approval_context
+ .as_ref()
+ .map(|ctx| ctx.reply_target.clone());
- for call in tool_calls {
- outcomes.push(
- execute_one_tool(
- &call.name,
- call.arguments.clone(),
- tools_registry,
- observer,
- cancellation_token,
- )
- .await?,
- );
- }
-
- Ok(outcomes)
+ TOOL_LOOP_NON_CLI_APPROVAL_CONTEXT
+ .scope(
+ non_cli_approval_context,
+ TOOL_LOOP_REPLY_TARGET.scope(
+ reply_target,
+ run_tool_call_loop(
+ provider,
+ history,
+ tools_registry,
+ observer,
+ provider_name,
+ model,
+ temperature,
+ silent,
+ approval,
+ channel_name,
+ multimodal_config,
+ max_tool_iterations,
+ cancellation_token,
+ on_delta,
+ hooks,
+ excluded_tools,
+ ),
+ ),
+ )
+ .await
}
// ── Agent Tool-Call Loop ──────────────────────────────────────────────────
@@ -2082,6 +595,20 @@ pub(crate) async fn run_tool_call_loop(
hooks: Option<&crate::hooks::HookRunner>,
excluded_tools: &[String],
) -> Result {
+ let non_cli_approval_context = TOOL_LOOP_NON_CLI_APPROVAL_CONTEXT
+ .try_with(Clone::clone)
+ .ok()
+ .flatten();
+ let channel_reply_target = TOOL_LOOP_REPLY_TARGET
+ .try_with(Clone::clone)
+ .ok()
+ .flatten()
+ .or_else(|| {
+ non_cli_approval_context
+ .as_ref()
+ .map(|ctx| ctx.reply_target.clone())
+ });
+
let max_iterations = if max_tool_iterations == 0 {
DEFAULT_MAX_TOOL_ITERATIONS
} else {
@@ -2096,6 +623,20 @@ pub(crate) async fn run_tool_call_loop(
let use_native_tools = provider.supports_native_tools() && !tool_specs.is_empty();
let turn_id = Uuid::new_v4().to_string();
let mut seen_tool_signatures: HashSet<(String, String)> = HashSet::new();
+ let bypass_non_cli_approval_for_turn =
+ approval.is_some_and(|mgr| channel_name != "cli" && mgr.consume_non_cli_allow_all_once());
+ if bypass_non_cli_approval_for_turn {
+ runtime_trace::record_event(
+ "approval_bypass_one_time_all_tools_consumed",
+ Some(channel_name),
+ Some(provider_name),
+ Some(model),
+ Some(&turn_id),
+ Some(true),
+ Some("consumed one-time non-cli allow-all approval token"),
+ serde_json::json!({}),
+ );
+ }
for iteration in 0..max_iterations {
if cancellation_token
@@ -2127,7 +668,7 @@ pub(crate) async fn run_tool_call_loop(
} else {
format!("\u{1f914} Thinking (round {})...\n", iteration + 1)
};
- let _ = tx.send(phase).await;
+ let _ = tx.send(format!("{DRAFT_PROGRESS_SENTINEL}{phase}")).await;
}
observer.record_event(&ObserverEvent::LlmRequest {
@@ -2327,7 +868,7 @@ pub(crate) async fn run_tool_call_loop(
if !tool_calls.is_empty() {
let _ = tx
.send(format!(
- "\u{1f4ac} Got {} tool call(s) ({llm_secs}s)\n",
+ "{DRAFT_PROGRESS_SENTINEL}\u{1f4ac} Got {} tool call(s) ({llm_secs}s)\n",
tool_calls.len()
))
.await;
@@ -2443,19 +984,89 @@ pub(crate) async fn run_tool_call_loop(
}
}
+ maybe_inject_cron_add_delivery(
+ &tool_name,
+ &mut tool_args,
+ channel_name,
+ channel_reply_target.as_deref(),
+ );
+
+ if excluded_tools.iter().any(|ex| ex == &tool_name) {
+ let blocked = format!("Tool '{tool_name}' is not available in this channel.");
+ runtime_trace::record_event(
+ "tool_call_result",
+ Some(channel_name),
+ Some(provider_name),
+ Some(model),
+ Some(&turn_id),
+ Some(false),
+ Some(&blocked),
+ serde_json::json!({
+ "iteration": iteration + 1,
+ "tool": tool_name.clone(),
+ "arguments": scrub_credentials(&tool_args.to_string()),
+ "blocked_by_channel_policy": true,
+ }),
+ );
+ ordered_results[idx] = Some((
+ tool_name.clone(),
+ call.tool_call_id.clone(),
+ ToolExecutionOutcome {
+ output: blocked.clone(),
+ success: false,
+ error_reason: Some(blocked),
+ duration: Duration::ZERO,
+ },
+ ));
+ continue;
+ }
+
// ── Approval hook ────────────────────────────────
if let Some(mgr) = approval {
- if mgr.needs_approval(&tool_name) {
+ if bypass_non_cli_approval_for_turn {
+ mgr.record_decision(
+ &tool_name,
+ &tool_args,
+ ApprovalResponse::Yes,
+ channel_name,
+ );
+ } else if mgr.needs_approval(&tool_name) {
let request = ApprovalRequest {
tool_name: tool_name.clone(),
arguments: tool_args.clone(),
};
- // Only prompt interactively on CLI; auto-approve on other channels.
let decision = if channel_name == "cli" {
mgr.prompt_cli(&request)
+ } else if let Some(ctx) = non_cli_approval_context.as_ref() {
+ let pending = mgr.create_non_cli_pending_request(
+ &tool_name,
+ &ctx.sender,
+ channel_name,
+ &ctx.reply_target,
+ Some(
+ "interactive approval required for supervised non-cli tool execution"
+ .to_string(),
+ ),
+ );
+
+ let _ = ctx.prompt_tx.send(NonCliApprovalPrompt {
+ request_id: pending.request_id.clone(),
+ tool_name: tool_name.clone(),
+ arguments: tool_args.clone(),
+ });
+
+ await_non_cli_approval_decision(
+ mgr,
+ &pending.request_id,
+ &ctx.sender,
+ channel_name,
+ &ctx.reply_target,
+ cancellation_token.as_ref(),
+ )
+ .await
} else {
- ApprovalResponse::Yes
+ ApprovalResponse::No
};
mgr.record_decision(&tool_name, &tool_args, decision, channel_name);
@@ -2548,7 +1159,9 @@ pub(crate) async fn run_tool_call_loop(
format!("\u{23f3} {}: {hint}\n", tool_name)
};
tracing::debug!(tool = %tool_name, "Sending progress start to draft");
- let _ = tx.send(progress).await;
+ let _ = tx
+ .send(format!("{DRAFT_PROGRESS_SENTINEL}{progress}"))
+ .await;
}
executable_indices.push(idx);
@@ -2619,21 +1232,24 @@ pub(crate) async fn run_tool_call_loop(
"\u{274c}"
};
tracing::debug!(tool = %call.name, secs, "Sending progress complete to draft");
- let _ = tx.send(format!("{icon} {} ({secs}s)\n", call.name)).await;
+ let _ = tx
+ .send(format!(
+ "{DRAFT_PROGRESS_SENTINEL}{icon} {} ({secs}s)\n",
+ call.name
+ ))
+ .await;
}
ordered_results[*idx] = Some((call.name.clone(), call.tool_call_id.clone(), outcome));
}
- for entry in ordered_results {
- if let Some((tool_name, tool_call_id, outcome)) = entry {
- individual_results.push((tool_call_id, outcome.output.clone()));
- let _ = writeln!(
- tool_results,
- "\n{}\n ",
- tool_name, outcome.output
- );
- }
+ for (tool_name, tool_call_id, outcome) in ordered_results.into_iter().flatten() {
+ individual_results.push((tool_call_id, outcome.output.clone()));
+ let _ = writeln!(
+ tool_results,
+ "\n{}\n ",
+ tool_name, outcome.output
+ );
}
// Add assistant message with tool calls + tool results to history.
@@ -2686,9 +1302,17 @@ pub(crate) async fn run_tool_call_loop(
anyhow::bail!("Agent exceeded maximum tool iterations ({max_iterations})")
}
-/// Build the tool instruction block for the system prompt so the LLM knows
-/// how to invoke tools.
+/// Build the tool instruction block for the system prompt from concrete tool
+/// specs so the LLM knows how to invoke tools.
pub(crate) fn build_tool_instructions(tools_registry: &[Box]) -> String {
+ let specs: Vec =
+ tools_registry.iter().map(|tool| tool.spec()).collect();
+ build_tool_instructions_from_specs(&specs)
+}
+
+/// Build the tool instruction block for the system prompt from concrete tool
+/// specs so the LLM knows how to invoke tools.
+pub(crate) fn build_tool_instructions_from_specs(tool_specs: &[crate::tools::ToolSpec]) -> String {
let mut instructions = String::new();
instructions.push_str("\n## Tool Use Protocol\n\n");
instructions.push_str("To use a tool, wrap a JSON object in tags:\n\n");
@@ -2696,26 +1320,100 @@ pub(crate) fn build_tool_instructions(tools_registry: &[Box]) -> Strin
instructions.push_str(
"CRITICAL: Output actual tags—never describe steps or give examples.\n\n",
);
- instructions.push_str("Example: User says \"what's the date?\". You MUST respond with:\n\n{\"name\":\"shell\",\"arguments\":{\"command\":\"date\"}}\n \n\n");
+ instructions.push_str(
+ "When a tool is needed, emit a real call (not prose), for example:\n\
+\n\
+{\"name\":\"tool_name\",\"arguments\":{}}\n\
+ \n\n",
+ );
instructions.push_str("You may use multiple tool calls in a single response. ");
instructions.push_str("After tool execution, results appear in tags. ");
instructions
.push_str("Continue reasoning with the results until you can give a final answer.\n\n");
instructions.push_str("### Available Tools\n\n");
- for tool in tools_registry {
+ for tool in tool_specs {
let _ = writeln!(
instructions,
"**{}**: {}\nParameters: `{}`\n",
- tool.name(),
- tool.description(),
- tool.parameters_schema()
+ tool.name, tool.description, tool.parameters
);
}
instructions
}
+/// Build shell-policy instructions for the system prompt so the model is aware
+/// of command-level execution constraints before it emits tool calls.
+pub(crate) fn build_shell_policy_instructions(autonomy: &crate::config::AutonomyConfig) -> String {
+ let mut instructions = String::new();
+ instructions.push_str("\n## Shell Policy\n\n");
+ instructions
+ .push_str("When using the `shell` tool, follow these runtime constraints exactly.\n\n");
+
+ let autonomy_label = match autonomy.level {
+ crate::security::AutonomyLevel::ReadOnly => "read_only",
+ crate::security::AutonomyLevel::Supervised => "supervised",
+ crate::security::AutonomyLevel::Full => "full",
+ };
+ let _ = writeln!(instructions, "- Autonomy level: `{autonomy_label}`");
+
+ if autonomy.level == crate::security::AutonomyLevel::ReadOnly {
+ instructions.push_str(
+ "- Shell execution is disabled in `read_only` mode. Do not emit shell tool calls.\n",
+ );
+ return instructions;
+ }
+
+ let normalized: BTreeSet = autonomy
+ .allowed_commands
+ .iter()
+ .map(|entry| entry.trim())
+ .filter(|entry| !entry.is_empty())
+ .map(ToOwned::to_owned)
+ .collect();
+
+ if normalized.contains("*") {
+ instructions.push_str(
+ "- Allowed commands: wildcard `*` is configured (any command name/path may be allowlisted).\n",
+ );
+ } else if normalized.is_empty() {
+ instructions
+ .push_str("- Allowed commands: none configured. Any shell command will be rejected.\n");
+ } else {
+ const MAX_DISPLAY_COMMANDS: usize = 64;
+ let shown: Vec = normalized
+ .iter()
+ .take(MAX_DISPLAY_COMMANDS)
+ .map(|cmd| format!("`{cmd}`"))
+ .collect();
+ let hidden = normalized.len().saturating_sub(MAX_DISPLAY_COMMANDS);
+ let _ = write!(instructions, "- Allowed commands: {}", shown.join(", "));
+ if hidden > 0 {
+ let _ = write!(instructions, " (+{hidden} more)");
+ }
+ instructions.push('\n');
+ }
+
+ if autonomy.level == crate::security::AutonomyLevel::Supervised
+ && autonomy.require_approval_for_medium_risk
+ {
+ instructions.push_str(
+ "- Medium-risk shell commands require explicit approval in `supervised` mode.\n",
+ );
+ }
+ if autonomy.block_high_risk_commands {
+ instructions.push_str(
+ "- High-risk shell commands are blocked even when command names are allowed.\n",
+ );
+ }
+ instructions.push_str(
+ "- If a requested command is outside policy, choose allowed alternatives and explain the limitation.\n",
+ );
+
+ instructions
+}
+
// ── CLI Entrypoint ───────────────────────────────────────────────────────
// Wires up all subsystems (observer, runtime, security, memory, tools,
// provider, hardware RAG, peripherals) and enters either single-shot or
@@ -2808,6 +1506,10 @@ pub async fn run(
zeroclaw_dir: config.config_path.parent().map(std::path::PathBuf::from),
secrets_encrypt: config.secrets.encrypt,
reasoning_enabled: config.runtime.reasoning_enabled,
+ reasoning_level: config.effective_provider_reasoning_level(),
+ custom_provider_api_mode: config.provider_api.map(|mode| mode.as_compatible_mode()),
+ max_tokens_override: None,
+ model_support_vision: config.model_support_vision,
};
let provider: Box = providers::create_routed_provider_with_options(
@@ -2904,6 +1606,10 @@ pub async fn run(
"browser_open",
"Open approved HTTPS URLs in system browser (allowlist-only, no scraping)",
));
+ tool_descs.push((
+ "browser",
+ "Automate browser actions (open/click/type/scroll/screenshot) with backend-aware safety checks.",
+ ));
}
if config.composio.enabled {
tool_descs.push((
@@ -2976,6 +1682,7 @@ pub async fn run(
if !native_tools {
system_prompt.push_str(&build_tool_instructions(&tools_registry));
}
+ system_prompt.push_str(&build_shell_policy_instructions(&config.autonomy));
// ── Approval manager (supervised mode) ───────────────────────
let approval_manager = if interactive {
@@ -3049,25 +1756,26 @@ pub async fn run(
// Persistent conversation history across turns
let mut history = vec![ChatMessage::system(&system_prompt)];
+ // Reusable readline editor for UTF-8 input support
+ let mut rl = rustyline::DefaultEditor::new()?;
loop {
- print!("> ");
- let _ = std::io::stdout().flush();
-
- let mut input = String::new();
- match std::io::stdin().read_line(&mut input) {
- Ok(0) => break,
- Ok(_) => {}
+ let input = match rl.readline("> ") {
+ Ok(line) => line,
+ Err(ReadlineError::Interrupted | ReadlineError::Eof) => {
+ break;
+ }
Err(e) => {
eprintln!("\nError reading input: {e}\n");
break;
}
- }
+ };
let user_input = input.trim().to_string();
if user_input.is_empty() {
continue;
}
+ rl.add_history_entry(&input)?;
match user_input.as_str() {
"/quit" | "/exit" => break,
"/help" => {
@@ -3082,18 +1790,15 @@ pub async fn run(
"This will clear the current conversation and delete all session memory."
);
println!("Core memories (long-term facts/preferences) will be preserved.");
- print!("Continue? [y/N] ");
- let _ = std::io::stdout().flush();
+ let confirm = rl.readline("Continue? [y/N] ").unwrap_or_default();
- let mut confirm = String::new();
- if std::io::stdin().read_line(&mut confirm).is_err() {
- continue;
- }
if !matches!(confirm.trim().to_lowercase().as_str(), "y" | "yes") {
println!("Cancelled.\n");
continue;
}
+ // Ensure prior prompts are not navigable after reset.
+ rl.clear_history()?;
history.clear();
history.push(ChatMessage::system(&system_prompt));
// Clear conversation and daily memory
@@ -3164,6 +1869,16 @@ pub async fn run(
{
Ok(resp) => resp,
Err(e) => {
+ if is_tool_iteration_limit_error(&e) {
+ let pause_notice = format!(
+ "⚠️ Reached tool-iteration limit ({}). Context and progress are preserved. \
+ Reply \"continue\" to resume, or increase `agent.max_tool_iterations` in config.",
+ config.agent.max_tool_iterations.max(DEFAULT_MAX_TOOL_ITERATIONS)
+ );
+ history.push(ChatMessage::assistant(&pause_notice));
+ eprintln!("\n{pause_notice}\n");
+ continue;
+ }
eprintln!("\nError: {e}\n");
continue;
}
@@ -3266,6 +1981,10 @@ pub async fn process_message(config: Config, message: &str) -> Result {
zeroclaw_dir: config.config_path.parent().map(std::path::PathBuf::from),
secrets_encrypt: config.secrets.encrypt,
reasoning_enabled: config.runtime.reasoning_enabled,
+ reasoning_level: config.effective_provider_reasoning_level(),
+ custom_provider_api_mode: config.provider_api.map(|mode| mode.as_compatible_mode()),
+ max_tokens_override: None,
+ model_support_vision: config.model_support_vision,
};
let provider: Box = providers::create_routed_provider_with_options(
provider_name,
@@ -3309,6 +2028,7 @@ pub async fn process_message(config: Config, message: &str) -> Result {
];
if config.browser.enabled {
tool_descs.push(("browser_open", "Open approved URLs in browser."));
+ tool_descs.push(("browser", "Automate browser interactions."));
}
if config.composio.enabled {
tool_descs.push(("composio", "Execute actions on 1000+ apps via Composio."));
@@ -3359,6 +2079,7 @@ pub async fn process_message(config: Config, message: &str) -> Result {
if !native_tools {
system_prompt.push_str(&build_tool_instructions(&tools_registry));
}
+ system_prompt.push_str(&build_shell_policy_instructions(&config.autonomy));
let mem_context = build_context(mem.as_ref(), message, config.memory.min_relevance_score).await;
let rag_limit = if config.agent.compact_context { 2 } else { 5 };
@@ -3422,6 +2143,70 @@ mod tests {
assert!(scrubbed.contains("\"api_key\": \"sk-1*[REDACTED]\""));
assert!(scrubbed.contains("public"));
}
+
+ #[test]
+ fn maybe_inject_cron_add_delivery_populates_agent_delivery_from_channel_context() {
+ let mut args = serde_json::json!({
+ "job_type": "agent",
+ "prompt": "remind me later"
+ });
+
+ maybe_inject_cron_add_delivery("cron_add", &mut args, "telegram", Some("-10012345"));
+
+ assert_eq!(args["delivery"]["mode"], "announce");
+ assert_eq!(args["delivery"]["channel"], "telegram");
+ assert_eq!(args["delivery"]["to"], "-10012345");
+ }
+
+ #[test]
+ fn maybe_inject_cron_add_delivery_does_not_override_explicit_target() {
+ let mut args = serde_json::json!({
+ "job_type": "agent",
+ "prompt": "remind me later",
+ "delivery": {
+ "mode": "announce",
+ "channel": "discord",
+ "to": "C123"
+ }
+ });
+
+ maybe_inject_cron_add_delivery("cron_add", &mut args, "telegram", Some("-10012345"));
+
+ assert_eq!(args["delivery"]["channel"], "discord");
+ assert_eq!(args["delivery"]["to"], "C123");
+ }
+
+ #[test]
+ fn maybe_inject_cron_add_delivery_skips_shell_jobs() {
+ let mut args = serde_json::json!({
+ "job_type": "shell",
+ "command": "echo hello"
+ });
+
+ maybe_inject_cron_add_delivery("cron_add", &mut args, "telegram", Some("-10012345"));
+
+ assert!(args.get("delivery").is_none());
+ }
+
+ #[test]
+ fn maybe_inject_cron_add_delivery_supports_lark_and_feishu_channels() {
+ let mut lark_args = serde_json::json!({
+ "job_type": "agent",
+ "prompt": "daily summary"
+ });
+ maybe_inject_cron_add_delivery("cron_add", &mut lark_args, "lark", Some("oc_xxx"));
+ assert_eq!(lark_args["delivery"]["channel"], "lark");
+ assert_eq!(lark_args["delivery"]["to"], "oc_xxx");
+
+ let mut feishu_args = serde_json::json!({
+ "job_type": "agent",
+ "prompt": "daily summary"
+ });
+ maybe_inject_cron_add_delivery("cron_add", &mut feishu_args, "feishu", Some("oc_yyy"));
+ assert_eq!(feishu_args["delivery"]["channel"], "feishu");
+ assert_eq!(feishu_args["delivery"]["to"], "oc_yyy");
+ }
+
use crate::memory::{Memory, MemoryCategory, SqliteMemory};
use crate::observability::NoopObserver;
use crate::providers::traits::ProviderCapabilities;
@@ -3943,6 +2728,266 @@ mod tests {
);
}
+ #[tokio::test]
+ async fn run_tool_call_loop_denies_supervised_tools_on_non_cli_channels() {
+ let provider = ScriptedProvider::from_text_responses(vec![
+ r#"
+{"name":"shell","arguments":{"command":"echo hi"}}
+ "#,
+ "done",
+ ]);
+
+ let active = Arc::new(AtomicUsize::new(0));
+ let max_active = Arc::new(AtomicUsize::new(0));
+ let tools_registry: Vec> = vec![Box::new(DelayTool::new(
+ "shell",
+ 50,
+ Arc::clone(&active),
+ Arc::clone(&max_active),
+ ))];
+
+ let approval_mgr = ApprovalManager::from_config(&crate::config::AutonomyConfig::default());
+
+ let mut history = vec![
+ ChatMessage::system("test-system"),
+ ChatMessage::user("run shell"),
+ ];
+ let observer = NoopObserver;
+
+ let result = run_tool_call_loop(
+ &provider,
+ &mut history,
+ &tools_registry,
+ &observer,
+ "mock-provider",
+ "mock-model",
+ 0.0,
+ true,
+ Some(&approval_mgr),
+ "telegram",
+ &crate::config::MultimodalConfig::default(),
+ 4,
+ None,
+ None,
+ None,
+ &[],
+ )
+ .await
+ .expect("tool loop should complete with denied tool execution");
+
+ assert_eq!(result, "done");
+ assert_eq!(
+ max_active.load(Ordering::SeqCst),
+ 0,
+ "shell tool must not execute when approval is unavailable on non-CLI channels"
+ );
+ }
+
+ #[tokio::test]
+ async fn run_tool_call_loop_waits_for_non_cli_approval_resolution() {
+ let provider = ScriptedProvider::from_text_responses(vec![
+ r#"
+{"name":"shell","arguments":{"command":"echo hi"}}
+ "#,
+ "done",
+ ]);
+
+ let active = Arc::new(AtomicUsize::new(0));
+ let max_active = Arc::new(AtomicUsize::new(0));
+ let tools_registry: Vec> = vec![Box::new(DelayTool::new(
+ "shell",
+ 50,
+ Arc::clone(&active),
+ Arc::clone(&max_active),
+ ))];
+
+ let approval_mgr = Arc::new(ApprovalManager::from_config(
+ &crate::config::AutonomyConfig::default(),
+ ));
+ let (prompt_tx, mut prompt_rx) =
+ tokio::sync::mpsc::unbounded_channel::();
+ let approval_mgr_for_task = Arc::clone(&approval_mgr);
+ let approval_task = tokio::spawn(async move {
+ let prompt = prompt_rx
+ .recv()
+ .await
+ .expect("approval prompt should arrive");
+ approval_mgr_for_task
+ .confirm_non_cli_pending_request(
+ &prompt.request_id,
+ "alice",
+ "telegram",
+ "chat-approval",
+ )
+ .expect("pending approval should confirm");
+ approval_mgr_for_task
+ .record_non_cli_pending_resolution(&prompt.request_id, ApprovalResponse::Yes);
+ });
+
+ let mut history = vec![
+ ChatMessage::system("test-system"),
+ ChatMessage::user("run shell"),
+ ];
+ let observer = NoopObserver;
+
+ let result = run_tool_call_loop_with_non_cli_approval_context(
+ &provider,
+ &mut history,
+ &tools_registry,
+ &observer,
+ "mock-provider",
+ "mock-model",
+ 0.0,
+ true,
+ Some(approval_mgr.as_ref()),
+ "telegram",
+ Some(NonCliApprovalContext {
+ sender: "alice".to_string(),
+ reply_target: "chat-approval".to_string(),
+ prompt_tx,
+ }),
+ &crate::config::MultimodalConfig::default(),
+ 4,
+ None,
+ None,
+ None,
+ &[],
+ )
+ .await
+ .expect("tool loop should continue after non-cli approval");
+
+ approval_task.await.expect("approval task should complete");
+ assert_eq!(result, "done");
+ assert_eq!(
+ max_active.load(Ordering::SeqCst),
+ 1,
+ "shell tool should execute after non-cli approval is resolved"
+ );
+ }
+
+ #[tokio::test]
+ async fn run_tool_call_loop_consumes_one_time_non_cli_allow_all_token() {
+ let provider = ScriptedProvider::from_text_responses(vec![
+ r#"
+{"name":"shell","arguments":{"command":"echo hi"}}
+ "#,
+ "done",
+ ]);
+
+ let active = Arc::new(AtomicUsize::new(0));
+ let max_active = Arc::new(AtomicUsize::new(0));
+ let tools_registry: Vec> = vec![Box::new(DelayTool::new(
+ "shell",
+ 50,
+ Arc::clone(&active),
+ Arc::clone(&max_active),
+ ))];
+
+ let approval_mgr = ApprovalManager::from_config(&crate::config::AutonomyConfig::default());
+ approval_mgr.grant_non_cli_allow_all_once();
+ assert_eq!(approval_mgr.non_cli_allow_all_once_remaining(), 1);
+
+ let mut history = vec![
+ ChatMessage::system("test-system"),
+ ChatMessage::user("run shell once"),
+ ];
+ let observer = NoopObserver;
+
+ let result = run_tool_call_loop(
+ &provider,
+ &mut history,
+ &tools_registry,
+ &observer,
+ "mock-provider",
+ "mock-model",
+ 0.0,
+ true,
+ Some(&approval_mgr),
+ "telegram",
+ &crate::config::MultimodalConfig::default(),
+ 4,
+ None,
+ None,
+ None,
+ &[],
+ )
+ .await
+ .expect("tool loop should consume one-time allow-all token");
+
+ assert_eq!(result, "done");
+ assert_eq!(
+ max_active.load(Ordering::SeqCst),
+ 1,
+ "shell tool should execute after consuming one-time allow-all token"
+ );
+ assert_eq!(approval_mgr.non_cli_allow_all_once_remaining(), 0);
+ }
+
+ #[tokio::test]
+ async fn run_tool_call_loop_blocks_tools_excluded_for_channel() {
+ let provider = ScriptedProvider::from_text_responses(vec![
+ r#"
+{"name":"shell","arguments":{"command":"echo hi"}}
+ "#,
+ "done",
+ ]);
+
+ let active = Arc::new(AtomicUsize::new(0));
+ let max_active = Arc::new(AtomicUsize::new(0));
+ let tools_registry: Vec> = vec![Box::new(DelayTool::new(
+ "shell",
+ 50,
+ Arc::clone(&active),
+ Arc::clone(&max_active),
+ ))];
+
+ let mut history = vec![
+ ChatMessage::system("test-system"),
+ ChatMessage::user("run shell"),
+ ];
+ let observer = NoopObserver;
+ let excluded_tools = vec!["shell".to_string()];
+
+ let result = run_tool_call_loop(
+ &provider,
+ &mut history,
+ &tools_registry,
+ &observer,
+ "mock-provider",
+ "mock-model",
+ 0.0,
+ true,
+ None,
+ "telegram",
+ &crate::config::MultimodalConfig::default(),
+ 4,
+ None,
+ None,
+ None,
+ &excluded_tools,
+ )
+ .await
+ .expect("tool loop should complete with blocked tool execution");
+
+ assert_eq!(result, "done");
+ assert_eq!(
+ max_active.load(Ordering::SeqCst),
+ 0,
+ "excluded tool must not execute even if the model requests it"
+ );
+
+ let tool_results_message = history
+ .iter()
+ .find(|msg| msg.role == "user" && msg.content.starts_with("[Tool results]"))
+ .expect("tool results message should be present");
+ assert!(
+ tool_results_message
+ .content
+ .contains("not available in this channel"),
+ "blocked reason should be visible to the model"
+ );
+ }
+
#[tokio::test]
async fn run_tool_call_loop_deduplicates_repeated_tool_calls() {
let provider = ScriptedProvider::from_text_responses(vec![
@@ -4162,6 +3207,73 @@ After text."#;
assert_eq!(calls[0].name, "memory_recall");
}
+ #[test]
+ fn parse_tool_calls_handles_openai_message_wrapper_with_content() {
+ let response = r#"{
+ "message": {
+ "role": "assistant",
+ "content": "plan \nI will call a tool.",
+ "tool_calls": [
+ {
+ "id": "chatcmpl-tool-a18c01b8849eb05d",
+ "type": "function",
+ "function": {
+ "name": "shell",
+ "arguments": "{\"command\": \"ls -la\"}"
+ }
+ }
+ ]
+ },
+ "finish_reason": "tool_calls"
+ }"#;
+
+ let (text, calls) = parse_tool_calls(response);
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0].name, "shell");
+ assert_eq!(
+ calls[0].arguments.get("command").unwrap().as_str().unwrap(),
+ "ls -la"
+ );
+ assert!(text.contains("I will call a tool."));
+ }
+
+ #[test]
+ fn parse_tool_calls_handles_openai_choices_message_wrapper() {
+ let response = r#"{
+ "id": "chatcmpl-123",
+ "choices": [
+ {
+ "index": 0,
+ "message": {
+ "role": "assistant",
+ "content": "Checking now.",
+ "tool_calls": [
+ {
+ "id": "call_1",
+ "type": "function",
+ "function": {
+ "name": "shell",
+ "arguments": "{\"command\":\"pwd\"}"
+ }
+ }
+ ]
+ },
+ "finish_reason": "tool_calls"
+ }
+ ]
+ }"#;
+
+ let (text, calls) = parse_tool_calls(response);
+ assert_eq!(text, "Checking now.");
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0].name, "shell");
+ assert_eq!(
+ calls[0].arguments.get("command").unwrap().as_str().unwrap(),
+ "pwd"
+ );
+ assert_eq!(calls[0].tool_call_id.as_deref(), Some("call_1"));
+ }
+
#[test]
fn parse_tool_calls_preserves_openai_tool_call_ids() {
let response = r#"{"tool_calls":[{"id":"call_42","function":{"name":"shell","arguments":"{\"command\":\"pwd\"}"}}]}"#;
@@ -4612,6 +3724,43 @@ Tail"#;
assert!(instructions.contains("file_write"));
}
+ #[test]
+ fn build_shell_policy_instructions_lists_allowlist() {
+ let mut autonomy = crate::config::AutonomyConfig::default();
+ autonomy.level = crate::security::AutonomyLevel::Supervised;
+ autonomy.allowed_commands = vec!["grep".into(), "cat".into(), "grep".into()];
+
+ let instructions = build_shell_policy_instructions(&autonomy);
+
+ assert!(instructions.contains("## Shell Policy"));
+ assert!(instructions.contains("Autonomy level: `supervised`"));
+ assert!(instructions.contains("`cat`"));
+ assert!(instructions.contains("`grep`"));
+ }
+
+ #[test]
+ fn build_shell_policy_instructions_handles_wildcard() {
+ let mut autonomy = crate::config::AutonomyConfig::default();
+ autonomy.level = crate::security::AutonomyLevel::Full;
+ autonomy.allowed_commands = vec!["*".into()];
+
+ let instructions = build_shell_policy_instructions(&autonomy);
+
+ assert!(instructions.contains("Autonomy level: `full`"));
+ assert!(instructions.contains("wildcard `*`"));
+ }
+
+ #[test]
+ fn build_shell_policy_instructions_read_only_disables_shell() {
+ let mut autonomy = crate::config::AutonomyConfig::default();
+ autonomy.level = crate::security::AutonomyLevel::ReadOnly;
+
+ let instructions = build_shell_policy_instructions(&autonomy);
+
+ assert!(instructions.contains("Autonomy level: `read_only`"));
+ assert!(instructions.contains("Shell execution is disabled"));
+ }
+
#[test]
fn tools_to_openai_format_produces_valid_schema() {
use crate::security::SecurityPolicy;
@@ -4987,6 +4136,36 @@ Done."#;
);
}
+ #[test]
+ fn parse_tool_call_value_recovers_shell_command_from_raw_string_arguments() {
+ let value = serde_json::json!({
+ "name": "shell",
+ "arguments": "uname -a"
+ });
+ let result = parse_tool_call_value(&value).expect("tool call should parse");
+ assert_eq!(result.name, "shell");
+ assert_eq!(
+ result.arguments.get("command").and_then(|v| v.as_str()),
+ Some("uname -a")
+ );
+ }
+
+ #[test]
+ fn parse_tool_call_value_recovers_shell_command_from_cmd_alias() {
+ let value = serde_json::json!({
+ "function": {
+ "name": "shell",
+ "arguments": {"cmd": "pwd"}
+ }
+ });
+ let result = parse_tool_call_value(&value).expect("tool call should parse");
+ assert_eq!(result.name, "shell");
+ assert_eq!(
+ result.arguments.get("command").and_then(|v| v.as_str()),
+ Some("pwd")
+ );
+ }
+
#[test]
fn parse_tool_call_value_preserves_tool_call_id_aliases() {
let value = serde_json::json!({
@@ -5027,6 +4206,22 @@ Done."#;
assert_eq!(result.len(), 2);
}
+ #[test]
+ fn parse_structured_tool_calls_recovers_shell_command_from_string_payload() {
+ let calls = vec![ToolCall {
+ id: "call_1".to_string(),
+ name: "shell".to_string(),
+ arguments: "ls -la".to_string(),
+ }];
+ let parsed = parse_structured_tool_calls(&calls);
+ assert_eq!(parsed.len(), 1);
+ assert_eq!(parsed[0].name, "shell");
+ assert_eq!(
+ parsed[0].arguments.get("command").and_then(|v| v.as_str()),
+ Some("ls -la")
+ );
+ }
+
// ═══════════════════════════════════════════════════════════════════════
// GLM-Style Tool Call Parsing
// ═══════════════════════════════════════════════════════════════════════
diff --git a/src/agent/loop_/parsing.rs b/src/agent/loop_/parsing.rs
index 907774872..2cd18c9ab 100644
--- a/src/agent/loop_/parsing.rs
+++ b/src/agent/loop_/parsing.rs
@@ -240,6 +240,27 @@ pub(super) fn parse_tool_calls_from_json_value(value: &serde_json::Value) -> Vec
}
}
+ if let Some(message) = value.get("message") {
+ let nested = parse_tool_calls_from_json_value(message);
+ if !nested.is_empty() {
+ return nested;
+ }
+ }
+
+ if let Some(choices) = value.get("choices").and_then(|v| v.as_array()) {
+ for choice in choices {
+ if let Some(message) = choice.get("message") {
+ let nested = parse_tool_calls_from_json_value(message);
+ if !nested.is_empty() {
+ calls.extend(nested);
+ }
+ }
+ }
+ if !calls.is_empty() {
+ return calls;
+ }
+ }
+
if let Some(array) = value.as_array() {
for item in array {
if let Some(parsed) = parse_tool_call_value(item) {
@@ -256,6 +277,33 @@ pub(super) fn parse_tool_calls_from_json_value(value: &serde_json::Value) -> Vec
calls
}
+fn extract_tool_text_from_json_value(value: &serde_json::Value) -> Option {
+ if let Some(content) = value
+ .get("content")
+ .and_then(serde_json::Value::as_str)
+ .map(str::trim)
+ .filter(|text| !text.is_empty())
+ {
+ return Some(content.to_string());
+ }
+
+ if let Some(message) = value.get("message") {
+ if let Some(content) = extract_tool_text_from_json_value(message) {
+ return Some(content);
+ }
+ }
+
+ if let Some(choices) = value.get("choices").and_then(|v| v.as_array()) {
+ for choice in choices {
+ if let Some(content) = extract_tool_text_from_json_value(choice) {
+ return Some(content);
+ }
+ }
+ }
+
+ None
+}
+
pub(super) fn is_xml_meta_tag(tag: &str) -> bool {
let normalized = tag.to_ascii_lowercase();
matches!(
@@ -1135,11 +1183,10 @@ pub(super) fn parse_tool_calls(response: &str) -> (String, Vec)
if let Ok(json_value) = serde_json::from_str::(response.trim()) {
calls = parse_tool_calls_from_json_value(&json_value);
if !calls.is_empty() {
- // If we found tool_calls, extract any content field as text
- if let Some(content) = json_value.get("content").and_then(|v| v.as_str()) {
- if !content.trim().is_empty() {
- text_parts.push(content.trim().to_string());
- }
+ // If we found tool_calls, extract any content field as text.
+ // Some providers wrap tool calls under `message` or `choices[*].message`.
+ if let Some(content) = extract_tool_text_from_json_value(&json_value) {
+ text_parts.push(content);
}
return (text_parts.join("\n"), calls);
}
diff --git a/src/agent/prompt.rs b/src/agent/prompt.rs
index 0ef2a5314..40f845856 100644
--- a/src/agent/prompt.rs
+++ b/src/agent/prompt.rs
@@ -107,10 +107,13 @@ impl PromptSection for IdentitySection {
"USER.md",
"HEARTBEAT.md",
"BOOTSTRAP.md",
- "MEMORY.md",
] {
inject_workspace_file(&mut prompt, ctx.workspace_dir, file);
}
+ let memory_path = ctx.workspace_dir.join("MEMORY.md");
+ if memory_path.exists() {
+ inject_workspace_file(&mut prompt, ctx.workspace_dir, "MEMORY.md");
+ }
Ok(prompt)
}
diff --git a/src/approval/mod.rs b/src/approval/mod.rs
index 79fe0880c..bb4076eed 100644
--- a/src/approval/mod.rs
+++ b/src/approval/mod.rs
@@ -3,13 +3,14 @@
//! Provides a pre-execution hook that prompts the user before tool calls,
//! with session-scoped "Always" allowlists and audit logging.
-use crate::config::AutonomyConfig;
+use crate::config::{AutonomyConfig, NonCliNaturalLanguageApprovalMode};
use crate::security::AutonomyLevel;
-use chrono::Utc;
-use parking_lot::Mutex;
+use chrono::{Duration, Utc};
+use parking_lot::{Mutex, RwLock};
use serde::{Deserialize, Serialize};
-use std::collections::HashSet;
+use std::collections::{HashMap, HashSet};
use std::io::{self, BufRead, Write};
+use uuid::Uuid;
// ── Types ────────────────────────────────────────────────────────
@@ -42,6 +43,26 @@ pub struct ApprovalLogEntry {
pub channel: String,
}
+/// A pending non-CLI approval request that still requires explicit confirmation.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PendingNonCliApprovalRequest {
+ pub request_id: String,
+ pub tool_name: String,
+ pub requested_by: String,
+ pub requested_channel: String,
+ pub requested_reply_target: String,
+ pub reason: Option,
+ pub created_at: String,
+ pub expires_at: String,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum PendingApprovalError {
+ NotFound,
+ Expired,
+ RequesterMismatch,
+}
+
// ── ApprovalManager ──────────────────────────────────────────────
/// Manages the interactive approval workflow.
@@ -50,26 +71,81 @@ pub struct ApprovalLogEntry {
/// - Maintains a session-scoped "always" allowlist
/// - Records an audit trail of all decisions
pub struct ApprovalManager {
- /// Tools that never need approval (from config).
- auto_approve: HashSet,
- /// Tools that always need approval, ignoring session allowlist.
- always_ask: HashSet,
+ /// Tools that never need approval (config + runtime updates).
+ auto_approve: RwLock>,
+ /// Tools that always need approval, ignoring session allowlist (config + runtime updates).
+ always_ask: RwLock>,
/// Autonomy level from config.
autonomy_level: AutonomyLevel,
/// Session-scoped allowlist built from "Always" responses.
session_allowlist: Mutex>,
+ /// Session-scoped allowlist for non-CLI channels after explicit human approval.
+ non_cli_allowlist: Mutex>,
+ /// One-time non-CLI bypass tokens that allow a full tool loop turn without prompts.
+ non_cli_allow_all_once_remaining: Mutex,
+ /// Optional allowlist of senders allowed to manage non-CLI approvals.
+ non_cli_approval_approvers: RwLock>,
+ /// Default natural-language handling mode for non-CLI approval-management commands.
+ non_cli_natural_language_approval_mode: RwLock,
+ /// Optional per-channel overrides for natural-language approval mode.
+ non_cli_natural_language_approval_mode_by_channel:
+ RwLock>,
+ /// Pending non-CLI approval requests awaiting explicit human confirmation.
+ pending_non_cli_requests: Mutex>,
+ /// Resolved decision snapshots for pending non-CLI requests, consumed by
+ /// waiting tool loops.
+ resolved_non_cli_requests: Mutex>,
/// Audit trail of approval decisions.
audit_log: Mutex>,
}
impl ApprovalManager {
+ fn normalize_non_cli_approvers(entries: &[String]) -> HashSet {
+ entries
+ .iter()
+ .map(|entry| entry.trim().to_string())
+ .filter(|entry| !entry.is_empty())
+ .collect()
+ }
+
+ fn normalize_non_cli_natural_language_mode_by_channel(
+ entries: &HashMap,
+ ) -> HashMap {
+ entries
+ .iter()
+ .filter_map(|(channel, mode)| {
+ let normalized = channel.trim().to_ascii_lowercase();
+ if normalized.is_empty() {
+ None
+ } else {
+ Some((normalized, *mode))
+ }
+ })
+ .collect()
+ }
+
/// Create from autonomy config.
pub fn from_config(config: &AutonomyConfig) -> Self {
Self {
- auto_approve: config.auto_approve.iter().cloned().collect(),
- always_ask: config.always_ask.iter().cloned().collect(),
+ auto_approve: RwLock::new(config.auto_approve.iter().cloned().collect()),
+ always_ask: RwLock::new(config.always_ask.iter().cloned().collect()),
autonomy_level: config.level,
session_allowlist: Mutex::new(HashSet::new()),
+ non_cli_allowlist: Mutex::new(HashSet::new()),
+ non_cli_allow_all_once_remaining: Mutex::new(0),
+ non_cli_approval_approvers: RwLock::new(Self::normalize_non_cli_approvers(
+ &config.non_cli_approval_approvers,
+ )),
+ non_cli_natural_language_approval_mode: RwLock::new(
+ config.non_cli_natural_language_approval_mode,
+ ),
+ non_cli_natural_language_approval_mode_by_channel: RwLock::new(
+ Self::normalize_non_cli_natural_language_mode_by_channel(
+ &config.non_cli_natural_language_approval_mode_by_channel,
+ ),
+ ),
+ pending_non_cli_requests: Mutex::new(HashMap::new()),
+ resolved_non_cli_requests: Mutex::new(HashMap::new()),
audit_log: Mutex::new(Vec::new()),
}
}
@@ -89,12 +165,12 @@ impl ApprovalManager {
}
// always_ask overrides everything.
- if self.always_ask.contains(tool_name) {
+ if self.always_ask.read().contains(tool_name) {
return true;
}
// auto_approve skips the prompt.
- if self.auto_approve.contains(tool_name) {
+ if self.auto_approve.read().contains(tool_name) {
return false;
}
@@ -145,6 +221,364 @@ impl ApprovalManager {
self.session_allowlist.lock().clone()
}
+ /// Grant session-scoped non-CLI approval for a specific tool.
+ pub fn grant_non_cli_session(&self, tool_name: &str) {
+ let mut allowlist = self.non_cli_allowlist.lock();
+ allowlist.insert(tool_name.to_string());
+ }
+
+ /// Revoke session-scoped non-CLI approval for a specific tool.
+ pub fn revoke_non_cli_session(&self, tool_name: &str) -> bool {
+ let mut allowlist = self.non_cli_allowlist.lock();
+ allowlist.remove(tool_name)
+ }
+
+ /// Check whether non-CLI session approval exists for a tool.
+ pub fn is_non_cli_session_granted(&self, tool_name: &str) -> bool {
+ let allowlist = self.non_cli_allowlist.lock();
+ allowlist.contains(tool_name)
+ }
+
+ /// Get the current non-CLI session allowlist.
+ pub fn non_cli_session_allowlist(&self) -> HashSet {
+ self.non_cli_allowlist.lock().clone()
+ }
+
+ /// Grant one non-CLI "allow all tools/commands for one turn" token.
+ ///
+ /// Returns the remaining token count after increment.
+ pub fn grant_non_cli_allow_all_once(&self) -> u32 {
+ let mut remaining = self.non_cli_allow_all_once_remaining.lock();
+ *remaining = remaining.saturating_add(1);
+ *remaining
+ }
+
+ /// Consume one non-CLI "allow all tools/commands for one turn" token.
+ ///
+ /// Returns `true` when a token was consumed, `false` when none existed.
+ pub fn consume_non_cli_allow_all_once(&self) -> bool {
+ let mut remaining = self.non_cli_allow_all_once_remaining.lock();
+ if *remaining == 0 {
+ return false;
+ }
+ *remaining -= 1;
+ true
+ }
+
+ /// Remaining one-time non-CLI "allow all tools/commands" tokens.
+ pub fn non_cli_allow_all_once_remaining(&self) -> u32 {
+ *self.non_cli_allow_all_once_remaining.lock()
+ }
+
+ /// Snapshot configured non-CLI approval approver entries.
+ pub fn non_cli_approval_approvers(&self) -> HashSet {
+ self.non_cli_approval_approvers.read().clone()
+ }
+
+ /// Natural-language handling mode for non-CLI approval-management commands.
+ pub fn non_cli_natural_language_approval_mode(&self) -> NonCliNaturalLanguageApprovalMode {
+ *self.non_cli_natural_language_approval_mode.read()
+ }
+
+ /// Snapshot per-channel natural-language approval mode overrides.
+ pub fn non_cli_natural_language_approval_mode_by_channel(
+ &self,
+ ) -> HashMap {
+ self.non_cli_natural_language_approval_mode_by_channel
+ .read()
+ .clone()
+ }
+
+ /// Effective natural-language approval mode for a specific channel.
+ pub fn non_cli_natural_language_approval_mode_for_channel(
+ &self,
+ channel: &str,
+ ) -> NonCliNaturalLanguageApprovalMode {
+ let normalized = channel.trim().to_ascii_lowercase();
+ self.non_cli_natural_language_approval_mode_by_channel
+ .read()
+ .get(&normalized)
+ .copied()
+ .unwrap_or_else(|| self.non_cli_natural_language_approval_mode())
+ }
+
+ /// Check whether `sender` on `channel` may manage non-CLI approvals.
+ ///
+ /// If no approver entries are configured, this defaults to `true` so
+ /// existing setups continue to behave as before.
+ pub fn is_non_cli_approval_actor_allowed(&self, channel: &str, sender: &str) -> bool {
+ let approvers = self.non_cli_approval_approvers.read();
+ if approvers.is_empty() {
+ return true;
+ }
+
+ if approvers.contains("*") || approvers.contains(sender) {
+ return true;
+ }
+
+ let exact = format!("{channel}:{sender}");
+ if approvers.contains(&exact) {
+ return true;
+ }
+
+ let any_on_channel = format!("{channel}:*");
+ if approvers.contains(&any_on_channel) {
+ return true;
+ }
+
+ let sender_any_channel = format!("*:{sender}");
+ approvers.contains(&sender_any_channel)
+ }
+
+ /// Apply runtime + persisted approval grant semantics:
+ /// add to auto_approve and remove from always_ask.
+ pub fn apply_persistent_runtime_grant(&self, tool_name: &str) {
+ {
+ let mut auto = self.auto_approve.write();
+ auto.insert(tool_name.to_string());
+ }
+ let mut always = self.always_ask.write();
+ always.remove(tool_name);
+ }
+
+ /// Apply runtime + persisted approval revoke semantics:
+ /// remove from auto_approve.
+ pub fn apply_persistent_runtime_revoke(&self, tool_name: &str) -> bool {
+ let mut auto = self.auto_approve.write();
+ auto.remove(tool_name)
+ }
+
+ /// Replace runtime-persistent non-CLI policy from config hot-reload.
+ ///
+ /// This updates the effective policy sets used by non-CLI approval commands
+ /// without restarting the daemon.
+ pub fn replace_runtime_non_cli_policy(
+ &self,
+ auto_approve: &[String],
+ always_ask: &[String],
+ non_cli_approval_approvers: &[String],
+ non_cli_natural_language_approval_mode: NonCliNaturalLanguageApprovalMode,
+ non_cli_natural_language_approval_mode_by_channel: &HashMap<
+ String,
+ NonCliNaturalLanguageApprovalMode,
+ >,
+ ) {
+ {
+ let mut auto = self.auto_approve.write();
+ *auto = auto_approve.iter().cloned().collect();
+ }
+ {
+ let mut always = self.always_ask.write();
+ *always = always_ask.iter().cloned().collect();
+ }
+ {
+ let mut approvers = self.non_cli_approval_approvers.write();
+ *approvers = Self::normalize_non_cli_approvers(non_cli_approval_approvers);
+ }
+ {
+ let mut mode = self.non_cli_natural_language_approval_mode.write();
+ *mode = non_cli_natural_language_approval_mode;
+ }
+ {
+ let mut mode_by_channel = self
+ .non_cli_natural_language_approval_mode_by_channel
+ .write();
+ *mode_by_channel = Self::normalize_non_cli_natural_language_mode_by_channel(
+ non_cli_natural_language_approval_mode_by_channel,
+ );
+ }
+ }
+
+ /// Snapshot runtime auto_approve entries.
+ pub fn auto_approve_tools(&self) -> HashSet {
+ self.auto_approve.read().clone()
+ }
+
+ /// Snapshot runtime always_ask entries.
+ pub fn always_ask_tools(&self) -> HashSet {
+ self.always_ask.read().clone()
+ }
+
+ /// Create a pending non-CLI approval request. If a matching active request
+ /// already exists for (tool, requester, channel), returns that existing request.
+ pub fn create_non_cli_pending_request(
+ &self,
+ tool_name: &str,
+ requested_by: &str,
+ requested_channel: &str,
+ requested_reply_target: &str,
+ reason: Option,
+ ) -> PendingNonCliApprovalRequest {
+ let mut pending = self.pending_non_cli_requests.lock();
+ prune_expired_pending_requests(&mut pending);
+
+ if let Some(existing) = pending
+ .values()
+ .find(|req| {
+ req.tool_name == tool_name
+ && req.requested_by == requested_by
+ && req.requested_channel == requested_channel
+ && req.requested_reply_target == requested_reply_target
+ })
+ .cloned()
+ {
+ return existing;
+ }
+
+ let now = Utc::now();
+ let expires = now + Duration::minutes(30);
+ let mut request_id = format!("apr-{}", &Uuid::new_v4().simple().to_string()[..8]);
+ while pending.contains_key(&request_id) {
+ request_id = format!("apr-{}", &Uuid::new_v4().simple().to_string()[..8]);
+ }
+
+ let req = PendingNonCliApprovalRequest {
+ request_id: request_id.clone(),
+ tool_name: tool_name.to_string(),
+ requested_by: requested_by.to_string(),
+ requested_channel: requested_channel.to_string(),
+ requested_reply_target: requested_reply_target.to_string(),
+ reason,
+ created_at: now.to_rfc3339(),
+ expires_at: expires.to_rfc3339(),
+ };
+ pending.insert(request_id, req.clone());
+ self.resolved_non_cli_requests
+ .lock()
+ .remove(&req.request_id);
+ req
+ }
+
+ /// Confirm a pending non-CLI approval request.
+ /// Confirmation must come from the same sender in the same channel.
+ pub fn confirm_non_cli_pending_request(
+ &self,
+ request_id: &str,
+ confirmed_by: &str,
+ confirmed_channel: &str,
+ confirmed_reply_target: &str,
+ ) -> Result {
+ let mut pending = self.pending_non_cli_requests.lock();
+ prune_expired_pending_requests(&mut pending);
+
+ let Some(req) = pending.remove(request_id) else {
+ return Err(PendingApprovalError::NotFound);
+ };
+
+ if is_pending_request_expired(&req) {
+ return Err(PendingApprovalError::Expired);
+ }
+
+ if req.requested_by != confirmed_by
+ || req.requested_channel != confirmed_channel
+ || req.requested_reply_target != confirmed_reply_target
+ {
+ pending.insert(req.request_id.clone(), req);
+ return Err(PendingApprovalError::RequesterMismatch);
+ }
+
+ Ok(req)
+ }
+
+ /// Reject a pending non-CLI approval request.
+ /// Rejection must come from the same sender in the same channel.
+ pub fn reject_non_cli_pending_request(
+ &self,
+ request_id: &str,
+ rejected_by: &str,
+ rejected_channel: &str,
+ rejected_reply_target: &str,
+ ) -> Result {
+ let mut pending = self.pending_non_cli_requests.lock();
+ prune_expired_pending_requests(&mut pending);
+
+ let Some(req) = pending.remove(request_id) else {
+ return Err(PendingApprovalError::NotFound);
+ };
+
+ if is_pending_request_expired(&req) {
+ return Err(PendingApprovalError::Expired);
+ }
+
+ if req.requested_by != rejected_by
+ || req.requested_channel != rejected_channel
+ || req.requested_reply_target != rejected_reply_target
+ {
+ pending.insert(req.request_id.clone(), req);
+ return Err(PendingApprovalError::RequesterMismatch);
+ }
+
+ Ok(req)
+ }
+
+ /// Return whether a pending non-CLI request still exists.
+ pub fn has_non_cli_pending_request(&self, request_id: &str) -> bool {
+ let mut pending = self.pending_non_cli_requests.lock();
+ prune_expired_pending_requests(&mut pending);
+ pending.contains_key(request_id)
+ }
+
+ /// Record a yes/no resolution for a pending non-CLI request.
+ pub fn record_non_cli_pending_resolution(&self, request_id: &str, decision: ApprovalResponse) {
+ if !matches!(decision, ApprovalResponse::Yes | ApprovalResponse::No) {
+ return;
+ }
+
+ let mut resolved = self.resolved_non_cli_requests.lock();
+ if resolved.len() >= 1024 {
+ if let Some(first_key) = resolved.keys().next().cloned() {
+ resolved.remove(&first_key);
+ }
+ }
+ resolved.insert(request_id.to_string(), decision);
+ }
+
+ /// Consume a resolved pending-request decision if present.
+ pub fn take_non_cli_pending_resolution(&self, request_id: &str) -> Option {
+ self.resolved_non_cli_requests.lock().remove(request_id)
+ }
+
+ /// List active pending non-CLI approval requests.
+ pub fn list_non_cli_pending_requests(
+ &self,
+ requested_by: Option<&str>,
+ requested_channel: Option<&str>,
+ requested_reply_target: Option<&str>,
+ ) -> Vec {
+ let mut pending = self.pending_non_cli_requests.lock();
+ prune_expired_pending_requests(&mut pending);
+
+ let mut rows = pending
+ .values()
+ .filter(|req| {
+ requested_by.map_or(true, |by| req.requested_by == by)
+ && requested_channel.map_or(true, |channel| req.requested_channel == channel)
+ && requested_reply_target.map_or(true, |reply_target| {
+ req.requested_reply_target == reply_target
+ })
+ })
+ .cloned()
+ .collect::>();
+ rows.sort_by(|a, b| a.created_at.cmp(&b.created_at));
+ rows
+ }
+
+ /// Remove all pending requests for a tool.
+ pub fn clear_non_cli_pending_requests_for_tool(&self, tool_name: &str) -> usize {
+ let mut pending = self.pending_non_cli_requests.lock();
+ prune_expired_pending_requests(&mut pending);
+ let mut resolved = self.resolved_non_cli_requests.lock();
+ let before = pending.len();
+ pending.retain(|request_id, req| {
+ let keep = req.tool_name != tool_name;
+ if !keep {
+ resolved.remove(request_id);
+ }
+ keep
+ });
+ before.saturating_sub(pending.len())
+ }
+
/// Prompt the user on the CLI and return their decision.
///
/// For non-CLI channels, returns `Yes` automatically (interactive
@@ -214,6 +648,20 @@ fn truncate_for_summary(input: &str, max_chars: usize) -> String {
}
}
+fn is_pending_request_expired(req: &PendingNonCliApprovalRequest) -> bool {
+ chrono::DateTime::parse_from_rfc3339(&req.expires_at)
+ .map(|dt| dt.with_timezone(&Utc) <= Utc::now())
+ .unwrap_or(true)
+}
+
+fn prune_expired_pending_requests(
+ pending: &mut HashMap,
+) -> usize {
+ let before = pending.len();
+ pending.retain(|_, req| !is_pending_request_expired(req));
+ before.saturating_sub(pending.len())
+}
+
// ── Tests ────────────────────────────────────────────────────────
#[cfg(test)]
@@ -323,6 +771,290 @@ mod tests {
assert!(mgr.needs_approval("file_write"));
}
+ #[test]
+ fn non_cli_session_approval_persists_across_checks() {
+ let mgr = ApprovalManager::from_config(&supervised_config());
+ assert!(!mgr.is_non_cli_session_granted("shell"));
+
+ mgr.grant_non_cli_session("shell");
+ assert!(mgr.is_non_cli_session_granted("shell"));
+ assert!(mgr.is_non_cli_session_granted("shell"));
+ }
+
+ #[test]
+ fn non_cli_session_approval_can_be_revoked() {
+ let mgr = ApprovalManager::from_config(&supervised_config());
+ mgr.grant_non_cli_session("shell");
+ assert!(mgr.is_non_cli_session_granted("shell"));
+
+ assert!(mgr.revoke_non_cli_session("shell"));
+ assert!(!mgr.is_non_cli_session_granted("shell"));
+ assert!(!mgr.revoke_non_cli_session("shell"));
+ }
+
+ #[test]
+ fn non_cli_session_allowlist_snapshot_lists_granted_tools() {
+ let mgr = ApprovalManager::from_config(&supervised_config());
+ mgr.grant_non_cli_session("shell");
+ mgr.grant_non_cli_session("file_write");
+
+ let allowlist = mgr.non_cli_session_allowlist();
+ assert!(allowlist.contains("shell"));
+ assert!(allowlist.contains("file_write"));
+ }
+
+ #[test]
+ fn non_cli_allow_all_once_tokens_are_counted_and_consumed() {
+ let mgr = ApprovalManager::from_config(&supervised_config());
+ assert_eq!(mgr.non_cli_allow_all_once_remaining(), 0);
+ assert!(!mgr.consume_non_cli_allow_all_once());
+
+ assert_eq!(mgr.grant_non_cli_allow_all_once(), 1);
+ assert_eq!(mgr.grant_non_cli_allow_all_once(), 2);
+ assert_eq!(mgr.non_cli_allow_all_once_remaining(), 2);
+
+ assert!(mgr.consume_non_cli_allow_all_once());
+ assert_eq!(mgr.non_cli_allow_all_once_remaining(), 1);
+ assert!(mgr.consume_non_cli_allow_all_once());
+ assert_eq!(mgr.non_cli_allow_all_once_remaining(), 0);
+ assert!(!mgr.consume_non_cli_allow_all_once());
+ }
+
+ #[test]
+ fn persistent_runtime_grant_updates_policy_immediately() {
+ let mgr = ApprovalManager::from_config(&supervised_config());
+ assert!(mgr.needs_approval("shell"));
+
+ mgr.apply_persistent_runtime_grant("shell");
+ assert!(!mgr.needs_approval("shell"));
+ assert!(mgr.auto_approve_tools().contains("shell"));
+ assert!(!mgr.always_ask_tools().contains("shell"));
+ }
+
+ #[test]
+ fn persistent_runtime_revoke_updates_policy_immediately() {
+ let mgr = ApprovalManager::from_config(&supervised_config());
+ assert!(!mgr.needs_approval("file_read"));
+
+ assert!(mgr.apply_persistent_runtime_revoke("file_read"));
+ assert!(mgr.needs_approval("file_read"));
+ assert!(!mgr.apply_persistent_runtime_revoke("file_read"));
+ }
+
+ #[test]
+ fn create_and_confirm_pending_non_cli_approval_request() {
+ let mgr = ApprovalManager::from_config(&supervised_config());
+ let req = mgr.create_non_cli_pending_request("shell", "alice", "telegram", "chat-1", None);
+ assert_eq!(req.tool_name, "shell");
+ assert!(req.request_id.starts_with("apr-"));
+
+ let confirmed = mgr
+ .confirm_non_cli_pending_request(&req.request_id, "alice", "telegram", "chat-1")
+ .expect("request should confirm");
+ assert_eq!(confirmed.request_id, req.request_id);
+ assert!(mgr
+ .confirm_non_cli_pending_request(&req.request_id, "alice", "telegram", "chat-1")
+ .is_err());
+ }
+
+ #[test]
+ fn create_and_reject_pending_non_cli_approval_request() {
+ let mgr = ApprovalManager::from_config(&supervised_config());
+ let req = mgr.create_non_cli_pending_request("shell", "alice", "telegram", "chat-1", None);
+
+ let rejected = mgr
+ .reject_non_cli_pending_request(&req.request_id, "alice", "telegram", "chat-1")
+ .expect("request should reject");
+ assert_eq!(rejected.request_id, req.request_id);
+ assert!(!mgr.has_non_cli_pending_request(&req.request_id));
+ }
+
+ #[test]
+ fn pending_non_cli_resolution_is_recorded_and_consumed() {
+ let mgr = ApprovalManager::from_config(&supervised_config());
+ let req = mgr.create_non_cli_pending_request("shell", "alice", "telegram", "chat-1", None);
+
+ mgr.record_non_cli_pending_resolution(&req.request_id, ApprovalResponse::Yes);
+ assert_eq!(
+ mgr.take_non_cli_pending_resolution(&req.request_id),
+ Some(ApprovalResponse::Yes)
+ );
+ assert_eq!(mgr.take_non_cli_pending_resolution(&req.request_id), None);
+ }
+
+ #[test]
+ fn pending_non_cli_approval_requires_same_sender_and_channel() {
+ let mgr = ApprovalManager::from_config(&supervised_config());
+ let req = mgr.create_non_cli_pending_request("shell", "alice", "telegram", "chat-1", None);
+
+ let err = mgr
+ .confirm_non_cli_pending_request(&req.request_id, "bob", "telegram", "chat-1")
+ .expect_err("mismatched sender should fail");
+ assert_eq!(err, PendingApprovalError::RequesterMismatch);
+
+ // Request remains pending after mismatch.
+ let pending =
+ mgr.list_non_cli_pending_requests(Some("alice"), Some("telegram"), Some("chat-1"));
+ assert_eq!(pending.len(), 1);
+
+ let err = mgr
+ .confirm_non_cli_pending_request(&req.request_id, "alice", "discord", "chat-1")
+ .expect_err("mismatched channel should fail");
+ assert_eq!(err, PendingApprovalError::RequesterMismatch);
+
+ let err = mgr
+ .confirm_non_cli_pending_request(&req.request_id, "alice", "telegram", "chat-2")
+ .expect_err("mismatched reply target should fail");
+ assert_eq!(err, PendingApprovalError::RequesterMismatch);
+ }
+
+ #[test]
+ fn list_pending_non_cli_approvals_filters_scope() {
+ let mgr = ApprovalManager::from_config(&supervised_config());
+ mgr.create_non_cli_pending_request("shell", "alice", "telegram", "chat-1", None);
+ mgr.create_non_cli_pending_request("file_write", "bob", "telegram", "chat-1", None);
+ mgr.create_non_cli_pending_request("browser_open", "alice", "discord", "chat-9", None);
+ mgr.create_non_cli_pending_request("schedule", "alice", "telegram", "chat-2", None);
+
+ let alice_telegram =
+ mgr.list_non_cli_pending_requests(Some("alice"), Some("telegram"), Some("chat-1"));
+ assert_eq!(alice_telegram.len(), 1);
+ assert_eq!(alice_telegram[0].tool_name, "shell");
+
+ let telegram_chat1 =
+ mgr.list_non_cli_pending_requests(None, Some("telegram"), Some("chat-1"));
+ assert_eq!(telegram_chat1.len(), 2);
+ }
+
+ #[test]
+ fn pending_non_cli_approval_expiry_is_pruned() {
+ let mgr = ApprovalManager::from_config(&supervised_config());
+ let req = mgr.create_non_cli_pending_request("shell", "alice", "telegram", "chat-1", None);
+
+ {
+ let mut pending = mgr.pending_non_cli_requests.lock();
+ let row = pending.get_mut(&req.request_id).expect("request row");
+ row.expires_at = (Utc::now() - Duration::minutes(1)).to_rfc3339();
+ }
+
+ let rows = mgr.list_non_cli_pending_requests(None, None, None);
+ assert!(rows.is_empty());
+ let err = mgr
+ .confirm_non_cli_pending_request(&req.request_id, "alice", "telegram", "chat-1")
+ .expect_err("expired request should not confirm");
+ assert_eq!(err, PendingApprovalError::NotFound);
+ }
+
+ #[test]
+ fn non_cli_approval_actor_defaults_to_allow_when_not_configured() {
+ let mgr = ApprovalManager::from_config(&supervised_config());
+ assert!(mgr.is_non_cli_approval_actor_allowed("telegram", "alice"));
+ assert!(mgr.is_non_cli_approval_actor_allowed("discord", "bob"));
+ }
+
+ #[test]
+ fn non_cli_natural_language_approval_mode_defaults_to_direct() {
+ let mgr = ApprovalManager::from_config(&supervised_config());
+ assert_eq!(
+ mgr.non_cli_natural_language_approval_mode(),
+ NonCliNaturalLanguageApprovalMode::Direct
+ );
+ }
+
+ #[test]
+ fn non_cli_approval_actor_allowlist_supports_exact_and_wildcards() {
+ let mut cfg = supervised_config();
+ cfg.non_cli_approval_approvers = vec![
+ "alice".to_string(),
+ "telegram:bob".to_string(),
+ "discord:*".to_string(),
+ "*:carol".to_string(),
+ ];
+ let mgr = ApprovalManager::from_config(&cfg);
+
+ assert!(mgr.is_non_cli_approval_actor_allowed("telegram", "alice"));
+ assert!(mgr.is_non_cli_approval_actor_allowed("telegram", "bob"));
+ assert!(mgr.is_non_cli_approval_actor_allowed("discord", "anyone"));
+ assert!(mgr.is_non_cli_approval_actor_allowed("matrix", "carol"));
+
+ assert!(!mgr.is_non_cli_approval_actor_allowed("telegram", "mallory"));
+ assert!(!mgr.is_non_cli_approval_actor_allowed("matrix", "bob"));
+ }
+
+ #[test]
+ fn non_cli_natural_language_approval_mode_honors_config_override() {
+ let mut cfg = supervised_config();
+ cfg.non_cli_natural_language_approval_mode =
+ NonCliNaturalLanguageApprovalMode::RequestConfirm;
+ let mgr = ApprovalManager::from_config(&cfg);
+ assert_eq!(
+ mgr.non_cli_natural_language_approval_mode(),
+ NonCliNaturalLanguageApprovalMode::RequestConfirm
+ );
+ }
+
+ #[test]
+ fn non_cli_natural_language_approval_mode_supports_per_channel_override() {
+ let mut cfg = supervised_config();
+ cfg.non_cli_natural_language_approval_mode = NonCliNaturalLanguageApprovalMode::Direct;
+ cfg.non_cli_natural_language_approval_mode_by_channel
+ .insert(
+ "discord".to_string(),
+ NonCliNaturalLanguageApprovalMode::RequestConfirm,
+ );
+ let mgr = ApprovalManager::from_config(&cfg);
+
+ assert_eq!(
+ mgr.non_cli_natural_language_approval_mode_for_channel("telegram"),
+ NonCliNaturalLanguageApprovalMode::Direct
+ );
+ assert_eq!(
+ mgr.non_cli_natural_language_approval_mode_for_channel("discord"),
+ NonCliNaturalLanguageApprovalMode::RequestConfirm
+ );
+ }
+
+ #[test]
+ fn replace_runtime_non_cli_policy_updates_modes_and_approvers() {
+ let cfg = supervised_config();
+ let mgr = ApprovalManager::from_config(&cfg);
+
+ let mut mode_overrides = HashMap::new();
+ mode_overrides.insert(
+ "telegram".to_string(),
+ NonCliNaturalLanguageApprovalMode::Disabled,
+ );
+ mode_overrides.insert(
+ "discord".to_string(),
+ NonCliNaturalLanguageApprovalMode::RequestConfirm,
+ );
+
+ mgr.replace_runtime_non_cli_policy(
+ &["mock_price".to_string()],
+ &["shell".to_string()],
+ &["telegram:alice".to_string()],
+ NonCliNaturalLanguageApprovalMode::Direct,
+ &mode_overrides,
+ );
+
+ assert!(!mgr.needs_approval("mock_price"));
+ assert!(mgr.needs_approval("shell"));
+ assert!(mgr.is_non_cli_approval_actor_allowed("telegram", "alice"));
+ assert!(!mgr.is_non_cli_approval_actor_allowed("telegram", "bob"));
+ assert_eq!(
+ mgr.non_cli_natural_language_approval_mode_for_channel("telegram"),
+ NonCliNaturalLanguageApprovalMode::Disabled
+ );
+ assert_eq!(
+ mgr.non_cli_natural_language_approval_mode_for_channel("discord"),
+ NonCliNaturalLanguageApprovalMode::RequestConfirm
+ );
+ assert_eq!(
+ mgr.non_cli_natural_language_approval_mode_for_channel("slack"),
+ NonCliNaturalLanguageApprovalMode::Direct
+ );
+ }
+
// ── audit log ────────────────────────────────────────────
#[test]
diff --git a/src/channels/discord.rs b/src/channels/discord.rs
index b96ee46a3..5faa0e050 100644
--- a/src/channels/discord.rs
+++ b/src/channels/discord.rs
@@ -1,4 +1,5 @@
use super::traits::{Channel, ChannelMessage, SendMessage};
+use anyhow::Context;
use async_trait::async_trait;
use futures_util::{SinkExt, StreamExt};
use parking_lot::Mutex;
@@ -16,6 +17,8 @@ pub struct DiscordChannel {
allowed_users: Vec,
listen_to_bots: bool,
mention_only: bool,
+ group_reply_allowed_sender_ids: Vec,
+ workspace_dir: Option,
typing_handles: Mutex>>,
}
@@ -33,10 +36,24 @@ impl DiscordChannel {
allowed_users,
listen_to_bots,
mention_only,
+ group_reply_allowed_sender_ids: Vec::new(),
+ workspace_dir: None,
typing_handles: Mutex::new(HashMap::new()),
}
}
+ /// Configure sender IDs that bypass mention gating in guild channels.
+ pub fn with_group_reply_allowed_senders(mut self, sender_ids: Vec) -> Self {
+ self.group_reply_allowed_sender_ids = normalize_group_reply_allowed_sender_ids(sender_ids);
+ self
+ }
+
+ /// Configure workspace directory used for validating local attachment paths.
+ pub fn with_workspace_dir(mut self, dir: PathBuf) -> Self {
+ self.workspace_dir = Some(dir);
+ self
+ }
+
fn http_client(&self) -> reqwest::Client {
crate::config::build_runtime_proxy_client("channel.discord")
}
@@ -48,11 +65,68 @@ impl DiscordChannel {
self.allowed_users.iter().any(|u| u == "*" || u == user_id)
}
+ fn is_group_sender_trigger_enabled(&self, sender_id: &str) -> bool {
+ let sender_id = sender_id.trim();
+ if sender_id.is_empty() {
+ return false;
+ }
+ self.group_reply_allowed_sender_ids
+ .iter()
+ .any(|entry| entry == "*" || entry == sender_id)
+ }
+
fn bot_user_id_from_token(token: &str) -> Option {
// Discord bot tokens are base64(bot_user_id).timestamp.hmac
let part = token.split('.').next()?;
base64_decode(part)
}
+
+ fn resolve_local_attachment_path(&self, target: &str) -> anyhow::Result {
+ let workspace = self.workspace_dir.as_ref().ok_or_else(|| {
+ anyhow::anyhow!("workspace_dir is not configured; local file attachments are disabled")
+ })?;
+ let workspace_root = workspace
+ .canonicalize()
+ .unwrap_or_else(|_| workspace.to_path_buf());
+
+ let target_path = if let Some(rel) = target.strip_prefix("/workspace/") {
+ workspace.join(rel)
+ } else if target == "/workspace" {
+ workspace.to_path_buf()
+ } else {
+ let path = Path::new(target);
+ if path.is_absolute() {
+ path.to_path_buf()
+ } else {
+ workspace.join(path)
+ }
+ };
+
+ let resolved = target_path
+ .canonicalize()
+ .with_context(|| format!("attachment path not found: {target}"))?;
+
+ if !resolved.starts_with(&workspace_root) {
+ anyhow::bail!("attachment path escapes workspace: {target}");
+ }
+
+ if !resolved.is_file() {
+ anyhow::bail!("attachment path is not a file: {}", resolved.display());
+ }
+
+ Ok(resolved)
+ }
+}
+
+fn normalize_group_reply_allowed_sender_ids(sender_ids: Vec) -> Vec {
+ let mut normalized = sender_ids
+ .into_iter()
+ .map(|entry| entry.trim().to_string())
+ .filter(|entry| !entry.is_empty())
+ .collect::>();
+ normalized.sort();
+ normalized.dedup();
+ normalized
}
/// Process Discord message attachments and return a string to append to the
@@ -67,7 +141,10 @@ async fn process_attachments(
) -> String {
let mut parts: Vec = Vec::new();
for att in attachments {
- let ct = att.get("content_type").and_then(|v| v.as_str());
+ let ct = att
+ .get("content_type")
+ .and_then(|v| v.as_str())
+ .unwrap_or("");
let name = att
.get("filename")
.and_then(|v| v.as_str())
@@ -76,16 +153,13 @@ async fn process_attachments(
tracing::warn!(name, "discord: attachment has no url, skipping");
continue;
};
- if is_text_like_discord_attachment(ct, name) {
+ if ct.starts_with("text/") {
match client.get(url).send().await {
- Ok(resp) if resp.status().is_success() => match resp.text().await {
- Ok(text) => {
+ Ok(resp) if resp.status().is_success() => {
+ if let Ok(text) = resp.text().await {
parts.push(format!("[{name}]\n{text}"));
}
- Err(error) => {
- tracing::warn!(name, error = %error, "discord attachment read error");
- }
- },
+ }
Ok(resp) => {
tracing::warn!(name, status = %resp.status(), "discord attachment fetch failed");
}
@@ -96,9 +170,9 @@ async fn process_attachments(
} else if ct.starts_with("image/") {
parts.push(format!("[IMAGE:{url}]"));
} else {
- tracing::warn!(
+ tracing::debug!(
name,
- content_type = ct.unwrap_or(""),
+ content_type = ct,
"discord: skipping unsupported attachment type"
);
}
@@ -191,10 +265,10 @@ fn parse_attachment_markers(message: &str) -> (String, Vec) {
fn classify_outgoing_attachments(
attachments: &[DiscordAttachment],
-) -> (Vec, Vec, Vec) {
+) -> (Vec, Vec, Vec) {
let mut local_files = Vec::new();
let mut remote_urls = Vec::new();
- let mut unresolved_markers = Vec::new();
+ let unresolved_markers = Vec::new();
for attachment in attachments {
let target = attachment.target.trim();
@@ -203,13 +277,7 @@ fn classify_outgoing_attachments(
continue;
}
- let path = Path::new(target);
- if path.exists() && path.is_file() {
- local_files.push(path.to_path_buf());
- continue;
- }
-
- unresolved_markers.push(format!("[{}:{}]", attachment.kind.marker_name(), target));
+ local_files.push(attachment.clone());
}
(local_files, remote_urls, unresolved_markers)
@@ -255,7 +323,8 @@ async fn send_discord_message_json(
.text()
.await
.unwrap_or_else(|e| format!(""));
- anyhow::bail!("Discord send message failed ({status}): {err}");
+ let sanitized = crate::providers::sanitize_api_error(&err);
+ anyhow::bail!("Discord send message failed ({status}): {sanitized}");
}
Ok(())
@@ -303,7 +372,8 @@ async fn send_discord_message_with_files(
.text()
.await
.unwrap_or_else(|e| format!(""));
- anyhow::bail!("Discord send message with files failed ({status}): {err}");
+ let sanitized = crate::providers::sanitize_api_error(&err);
+ anyhow::bail!("Discord send message with files failed ({status}): {sanitized}");
}
Ok(())
@@ -365,6 +435,7 @@ fn split_message_for_discord(message: &str) -> Vec {
chunks
}
+#[allow(clippy::cast_possible_truncation)]
fn pick_uniform_index(len: usize) -> usize {
debug_assert!(len > 0);
let upper = len as u64;
@@ -392,9 +463,10 @@ fn encode_emoji_for_discord(emoji: &str) -> String {
return emoji.to_string();
}
+ use std::fmt::Write as _;
let mut encoded = String::new();
for byte in emoji.as_bytes() {
- encoded.push_str(&format!("%{byte:02X}"));
+ write!(encoded, "%{byte:02X}").ok();
}
encoded
}
@@ -418,19 +490,19 @@ fn contains_bot_mention(content: &str, bot_user_id: &str) -> bool {
fn normalize_incoming_content(
content: &str,
- mention_only: bool,
+ require_mention: bool,
bot_user_id: &str,
) -> Option {
if content.is_empty() {
return None;
}
- if mention_only && !contains_bot_mention(content, bot_user_id) {
+ if require_mention && !contains_bot_mention(content, bot_user_id) {
return None;
}
let mut normalized = content.to_string();
- if mention_only {
+ if require_mention {
for tag in mention_tags(bot_user_id) {
normalized = normalized.replace(&tag, " ");
}
@@ -491,8 +563,28 @@ impl Channel for DiscordChannel {
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
let raw_content = super::strip_tool_call_tags(&message.content);
let (cleaned_content, parsed_attachments) = parse_attachment_markers(&raw_content);
- let (mut local_files, remote_urls, unresolved_markers) =
+ let (local_attachment_targets, remote_urls, mut unresolved_markers) =
classify_outgoing_attachments(&parsed_attachments);
+ let mut local_files = Vec::new();
+
+ for attachment in &local_attachment_targets {
+ let target = attachment.target.trim();
+ match self.resolve_local_attachment_path(target) {
+ Ok(path) => local_files.push(path),
+ Err(error) => {
+ tracing::warn!(
+ target,
+ error = %error,
+ "discord: local attachment rejected by workspace policy"
+ );
+ unresolved_markers.push(format!(
+ "[{}:{}]",
+ attachment.kind.marker_name(),
+ target
+ ));
+ }
+ }
+ }
if !unresolved_markers.is_empty() {
tracing::warn!(
@@ -701,8 +793,13 @@ impl Channel for DiscordChannel {
}
let content = d.get("content").and_then(|c| c.as_str()).unwrap_or("");
+ let is_group_message = d.get("guild_id").is_some();
+ let allow_sender_without_mention =
+ is_group_message && self.is_group_sender_trigger_enabled(author_id);
+ let require_mention =
+ self.mention_only && is_group_message && !allow_sender_without_mention;
let Some(clean_content) =
- normalize_incoming_content(content, self.mention_only, &bot_user_id)
+ normalize_incoming_content(content, require_mention, &bot_user_id)
else {
continue;
};
@@ -851,7 +948,8 @@ impl Channel for DiscordChannel {
.text()
.await
.unwrap_or_else(|e| format!(""));
- anyhow::bail!("Discord add reaction failed ({status}): {err}");
+ let sanitized = crate::providers::sanitize_api_error(&err);
+ anyhow::bail!("Discord add reaction failed ({status}): {sanitized}");
}
Ok(())
@@ -878,7 +976,8 @@ impl Channel for DiscordChannel {
.text()
.await
.unwrap_or_else(|e| format!(""));
- anyhow::bail!("Discord remove reaction failed ({status}): {err}");
+ let sanitized = crate::providers::sanitize_api_error(&err);
+ anyhow::bail!("Discord remove reaction failed ({status}): {sanitized}");
}
Ok(())
@@ -888,8 +987,6 @@ impl Channel for DiscordChannel {
#[cfg(test)]
mod tests {
use super::*;
- use wiremock::matchers::{method, path};
- use wiremock::{Mock, MockServer, ResponseTemplate};
#[test]
fn discord_channel_name() {
@@ -1019,6 +1116,28 @@ mod tests {
assert!(cleaned.is_none());
}
+ #[test]
+ fn normalize_group_reply_allowed_sender_ids_trims_and_deduplicates() {
+ let normalized = normalize_group_reply_allowed_sender_ids(vec![
+ " 111 ".into(),
+ "111".into(),
+ String::new(),
+ " ".into(),
+ "222".into(),
+ ]);
+ assert_eq!(normalized, vec!["111".to_string(), "222".to_string()]);
+ }
+
+ #[test]
+ fn group_reply_sender_override_matches_exact_and_wildcard() {
+ let ch = DiscordChannel::new("token".into(), None, vec!["*".into()], false, true)
+ .with_group_reply_allowed_senders(vec!["111".into(), "*".into()]);
+
+ assert!(ch.is_group_sender_trigger_enabled("111"));
+ assert!(ch.is_group_sender_trigger_enabled("anyone"));
+ assert!(!ch.is_group_sender_trigger_enabled(""));
+ }
+
// Message splitting tests
#[test]
@@ -1371,6 +1490,7 @@ mod tests {
}
#[test]
+ #[allow(clippy::format_collect)]
fn split_message_many_short_lines() {
// Many short lines should be batched into chunks under the limit
let msg: String = (0..500).map(|i| format!("line {i}\n")).collect();
@@ -1522,13 +1642,11 @@ mod tests {
];
let (locals, remotes, unresolved) = classify_outgoing_attachments(&attachments);
- assert_eq!(locals.len(), 1);
- assert_eq!(locals[0], file_path);
+ assert_eq!(locals.len(), 2);
+ assert_eq!(locals[0].target, file_path.to_string_lossy());
+ assert_eq!(locals[1].target, "/tmp/does-not-exist.mp4");
assert_eq!(remotes, vec!["https://example.com/remote.png".to_string()]);
- assert_eq!(
- unresolved,
- vec!["[VIDEO:/tmp/does-not-exist.mp4]".to_string()]
- );
+ assert!(unresolved.is_empty());
}
#[test]
@@ -1543,4 +1661,37 @@ mod tests {
"Done\nhttps://example.com/a.png\n[IMAGE:/tmp/missing.png]"
);
}
+
+ #[test]
+ fn with_workspace_dir_sets_field() {
+ let channel = DiscordChannel::new("fake".into(), None, vec![], false, false)
+ .with_workspace_dir(PathBuf::from("/tmp/discord-workspace"));
+ assert_eq!(
+ channel.workspace_dir.as_deref(),
+ Some(Path::new("/tmp/discord-workspace"))
+ );
+ }
+
+ #[test]
+ fn resolve_local_attachment_path_blocks_workspace_escape() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let workspace = temp.path().join("workspace");
+ std::fs::create_dir_all(&workspace).expect("workspace should exist");
+
+ let outside = temp.path().join("outside.txt");
+ std::fs::write(&outside, b"secret").expect("fixture should be written");
+
+ let channel = DiscordChannel::new("fake".into(), None, vec![], false, false)
+ .with_workspace_dir(workspace.clone());
+
+ let allowed_path = workspace.join("ok.txt");
+ std::fs::write(&allowed_path, b"ok").expect("workspace fixture should be written");
+ let allowed = channel
+ .resolve_local_attachment_path("ok.txt")
+ .expect("workspace file should be allowed");
+ assert!(allowed.starts_with(workspace.canonicalize().unwrap_or(workspace)));
+
+ let escaped = channel.resolve_local_attachment_path(outside.to_string_lossy().as_ref());
+ assert!(escaped.is_err(), "path outside workspace must be rejected");
+ }
}
diff --git a/src/channels/lark.rs b/src/channels/lark.rs
index 407079f31..e2250d3f7 100644
--- a/src/channels/lark.rs
+++ b/src/channels/lark.rs
@@ -1,5 +1,6 @@
use super::traits::{Channel, ChannelMessage, SendMessage};
use async_trait::async_trait;
+use base64::Engine;
use futures_util::{SinkExt, StreamExt};
use prost::Message as ProstMessage;
use std::collections::HashMap;
@@ -216,6 +217,8 @@ const LARK_TOKEN_REFRESH_SKEW: Duration = Duration::from_secs(120);
const LARK_DEFAULT_TOKEN_TTL: Duration = Duration::from_secs(7200);
/// Feishu/Lark API business code for expired/invalid tenant access token.
const LARK_INVALID_ACCESS_TOKEN_CODE: i64 = 99_991_663;
+const LARK_IMAGE_DOWNLOAD_FALLBACK_TEXT: &str =
+ "[Image message received but could not be downloaded]";
/// Returns true when the WebSocket frame indicates live traffic that should
/// refresh the heartbeat watchdog.
@@ -241,6 +244,17 @@ fn should_refresh_lark_tenant_token(status: reqwest::StatusCode, body: &serde_js
status == reqwest::StatusCode::UNAUTHORIZED || is_lark_invalid_access_token(body)
}
+fn parse_image_key(content: &str) -> Option {
+ serde_json::from_str::(content)
+ .ok()
+ .and_then(|value| {
+ value
+ .get("image_key")
+ .and_then(|key| key.as_str())
+ .map(str::to_string)
+ })
+}
+
fn extract_lark_token_ttl_seconds(body: &serde_json::Value) -> u64 {
let ttl = body
.get("expire")
@@ -264,18 +278,24 @@ fn next_token_refresh_deadline(now: Instant, ttl_seconds: u64) -> Instant {
now + refresh_in
}
+fn sanitize_lark_body(body: &serde_json::Value) -> String {
+ crate::providers::sanitize_api_error(&body.to_string())
+}
+
fn ensure_lark_send_success(
status: reqwest::StatusCode,
body: &serde_json::Value,
context: &str,
) -> anyhow::Result<()> {
if !status.is_success() {
- anyhow::bail!("Lark send failed {context}: status={status}, body={body}");
+ let sanitized = sanitize_lark_body(body);
+ anyhow::bail!("Lark send failed {context}: status={status}, body={sanitized}");
}
let code = extract_lark_response_code(body).unwrap_or(0);
if code != 0 {
- anyhow::bail!("Lark send failed {context}: code={code}, body={body}");
+ let sanitized = sanitize_lark_body(body);
+ anyhow::bail!("Lark send failed {context}: code={code}, body={sanitized}");
}
Ok(())
@@ -293,11 +313,11 @@ pub struct LarkChannel {
verification_token: String,
port: Option,
allowed_users: Vec,
+ group_reply_allowed_sender_ids: Vec,
/// Bot open_id resolved at runtime via `/bot/v3/info`.
resolved_bot_open_id: Arc>>,
mention_only: bool,
- /// When true, use Feishu (CN) endpoints; when false, use Lark (international).
- use_feishu: bool,
+ platform: LarkPlatform,
/// How to receive events: WebSocket long-connection or HTTP webhook.
receive_mode: crate::config::schema::LarkReceiveMode,
/// Cached tenant access token
@@ -321,6 +341,7 @@ impl LarkChannel {
verification_token,
port,
allowed_users,
+ mention_only,
LarkPlatform::Lark,
)
}
@@ -331,6 +352,7 @@ impl LarkChannel {
verification_token: String,
port: Option,
allowed_users: Vec,
+ mention_only: bool,
platform: LarkPlatform,
) -> Self {
Self {
@@ -339,9 +361,10 @@ impl LarkChannel {
verification_token,
port,
allowed_users,
+ group_reply_allowed_sender_ids: Vec::new(),
resolved_bot_open_id: Arc::new(StdRwLock::new(None)),
mention_only,
- use_feishu: true,
+ platform,
receive_mode: crate::config::schema::LarkReceiveMode::default(),
tenant_token: Arc::new(RwLock::new(None)),
ws_seen_ids: Arc::new(RwLock::new(HashMap::new())),
@@ -362,8 +385,43 @@ impl LarkChannel {
config.verification_token.clone().unwrap_or_default(),
config.port,
config.allowed_users.clone(),
- config.mention_only,
+ config.effective_group_reply_mode().requires_mention(),
+ platform,
);
+ ch.group_reply_allowed_sender_ids =
+ normalize_group_reply_allowed_sender_ids(config.group_reply_allowed_sender_ids());
+ ch.receive_mode = config.receive_mode.clone();
+ ch
+ }
+
+ pub fn from_lark_config(config: &crate::config::schema::LarkConfig) -> Self {
+ let mut ch = Self::new_with_platform(
+ config.app_id.clone(),
+ config.app_secret.clone(),
+ config.verification_token.clone().unwrap_or_default(),
+ config.port,
+ config.allowed_users.clone(),
+ config.effective_group_reply_mode().requires_mention(),
+ LarkPlatform::Lark,
+ );
+ ch.group_reply_allowed_sender_ids =
+ normalize_group_reply_allowed_sender_ids(config.group_reply_allowed_sender_ids());
+ ch.receive_mode = config.receive_mode.clone();
+ ch
+ }
+
+ pub fn from_feishu_config(config: &crate::config::schema::FeishuConfig) -> Self {
+ let mut ch = Self::new_with_platform(
+ config.app_id.clone(),
+ config.app_secret.clone(),
+ config.verification_token.clone().unwrap_or_default(),
+ config.port,
+ config.allowed_users.clone(),
+ config.effective_group_reply_mode().requires_mention(),
+ LarkPlatform::Feishu,
+ );
+ ch.group_reply_allowed_sender_ids =
+ normalize_group_reply_allowed_sender_ids(config.group_reply_allowed_sender_ids());
ch.receive_mode = config.receive_mode.clone();
ch
}
@@ -400,6 +458,10 @@ impl LarkChannel {
format!("{}/im/v1/messages/{message_id}/reactions", self.api_base())
}
+ fn image_download_url(&self, image_key: &str) -> String {
+ format!("{}/im/v1/images/{image_key}", self.api_base())
+ }
+
fn resolved_bot_open_id(&self) -> Option {
self.resolved_bot_open_id
.read()
@@ -413,6 +475,61 @@ impl LarkChannel {
}
}
+ async fn fetch_image_marker(&self, image_key: &str) -> anyhow::Result {
+ if image_key.trim().is_empty() {
+ anyhow::bail!("empty image_key");
+ }
+
+ let mut token = self.get_tenant_access_token().await?;
+ let mut retried = false;
+ let url = self.image_download_url(image_key);
+
+ loop {
+ let response = self
+ .http_client()
+ .get(&url)
+ .header("Authorization", format!("Bearer {token}"))
+ .send()
+ .await?;
+
+ let status = response.status();
+ let content_type = response
+ .headers()
+ .get(reqwest::header::CONTENT_TYPE)
+ .and_then(|value| value.to_str().ok())
+ .map(str::to_string);
+ let body = response.bytes().await?;
+
+ if status.is_success() {
+ if body.is_empty() {
+ anyhow::bail!("image payload is empty");
+ }
+ let media_type = content_type
+ .as_deref()
+ .and_then(|value| value.split(';').next())
+ .map(str::trim)
+ .filter(|value| value.starts_with("image/"))
+ .unwrap_or("image/png");
+ let encoded = base64::engine::general_purpose::STANDARD.encode(body);
+ return Ok(format!("[IMAGE:data:{media_type};base64,{encoded}]"));
+ }
+
+ let parsed = serde_json::from_slice::(&body)
+ .unwrap_or(serde_json::Value::Null);
+ if !retried && should_refresh_lark_tenant_token(status, &parsed) {
+ self.invalidate_token().await;
+ token = self.get_tenant_access_token().await?;
+ retried = true;
+ continue;
+ }
+
+ anyhow::bail!(
+ "Lark image download failed: status={status}, body={}",
+ crate::providers::sanitize_api_error(&String::from_utf8_lossy(&body))
+ );
+ }
+ }
+
async fn post_message_reaction_with_token(
&self,
message_id: &str,
@@ -484,8 +601,9 @@ impl LarkChannel {
if !response.status().is_success() {
let status = response.status();
let err_body = response.text().await.unwrap_or_default();
+ let sanitized = crate::providers::sanitize_api_error(&err_body);
tracing::warn!(
- "Lark: add reaction failed for {message_id}: status={status}, body={err_body}"
+ "Lark: add reaction failed for {message_id}: status={status}, body={sanitized}"
);
return;
}
@@ -746,6 +864,25 @@ impl LarkChannel {
Some(details) => (details.text, details.mentioned_open_ids),
None => continue,
},
+ "image" => {
+ let text = if let Some(image_key) = parse_image_key(&lark_msg.content) {
+ match self.fetch_image_marker(&image_key).await {
+ Ok(marker) => marker,
+ Err(error) => {
+ tracing::warn!(
+ "Lark WS: failed to download image {image_key}: {error}"
+ );
+ LARK_IMAGE_DOWNLOAD_FALLBACK_TEXT.to_string()
+ }
+ }
+ } else {
+ tracing::warn!(
+ "Lark WS: image content missing image_key; using fallback text"
+ );
+ LARK_IMAGE_DOWNLOAD_FALLBACK_TEXT.to_string()
+ };
+ (text, Vec::new())
+ }
_ => { tracing::debug!("Lark WS: skipping unsupported type '{}'", lark_msg.message_type); continue; }
};
@@ -759,6 +896,8 @@ impl LarkChannel {
if lark_msg.chat_type == "group"
&& !should_respond_in_group(
self.mention_only,
+ sender_open_id,
+ &self.group_reply_allowed_sender_ids,
bot_open_id.as_deref(),
&lark_msg.mentions,
&post_mentioned_open_ids,
@@ -826,7 +965,10 @@ impl LarkChannel {
let data: serde_json::Value = resp.json().await?;
if !status.is_success() {
- anyhow::bail!("Lark tenant_access_token request failed: status={status}, body={data}");
+ let sanitized = sanitize_lark_body(&data);
+ anyhow::bail!(
+ "Lark tenant_access_token request failed: status={status}, body={sanitized}"
+ );
}
let code = data.get("code").and_then(|c| c.as_i64()).unwrap_or(-1);
@@ -892,21 +1034,24 @@ impl LarkChannel {
let refreshed = self.get_tenant_access_token().await?;
let (retry_status, retry_body) = self.fetch_bot_open_id_with_token(&refreshed).await?;
if !retry_status.is_success() {
+ let sanitized = sanitize_lark_body(&retry_body);
anyhow::bail!(
- "Lark bot info request failed after token refresh: status={retry_status}, body={retry_body}"
+ "Lark bot info request failed after token refresh: status={retry_status}, body={sanitized}"
);
}
retry_body
} else {
if !status.is_success() {
- anyhow::bail!("Lark bot info request failed: status={status}, body={body}");
+ let sanitized = sanitize_lark_body(&body);
+ anyhow::bail!("Lark bot info request failed: status={status}, body={sanitized}");
}
body
};
let code = body.get("code").and_then(|c| c.as_i64()).unwrap_or(-1);
if code != 0 {
- anyhow::bail!("Lark bot info failed: code={code}, body={body}");
+ let sanitized = sanitize_lark_body(&body);
+ anyhow::bail!("Lark bot info failed: code={code}, body={sanitized}");
}
let bot_open_id = body
@@ -964,7 +1109,9 @@ impl LarkChannel {
Ok((status, parsed))
}
- /// Parse an event callback payload and extract text messages
+ /// Parse an event callback payload and extract incoming messages.
+ ///
+ /// Synchronous parser uses a non-network fallback for image messages.
pub fn parse_event_payload(&self, payload: &serde_json::Value) -> Vec {
let mut messages = Vec::new();
@@ -1000,7 +1147,7 @@ impl LarkChannel {
return messages;
}
- // Extract message content (text and post supported)
+ // Extract message content (text/post/image supported)
let msg_type = event
.pointer("/message/message_type")
.and_then(|t| t.as_str())
@@ -1041,6 +1188,7 @@ impl LarkChannel {
Some(details) => (details.text, details.mentioned_open_ids),
None => return messages,
},
+ "image" => (LARK_IMAGE_DOWNLOAD_FALLBACK_TEXT.to_string(), Vec::new()),
_ => {
tracing::debug!("Lark: skipping unsupported message type: {msg_type}");
return messages;
@@ -1051,6 +1199,8 @@ impl LarkChannel {
if chat_type == "group"
&& !should_respond_in_group(
self.mention_only,
+ open_id,
+ &self.group_reply_allowed_sender_ids,
bot_open_id.as_deref(),
&mentions,
&post_mentioned_open_ids,
@@ -1089,6 +1239,144 @@ impl LarkChannel {
messages
}
+
+ /// Async variant used by webhook runtime path.
+ /// Unlike `parse_event_payload`, this path attempts image download and
+ /// converts image content to `[IMAGE:data:...;base64,...]` markers.
+ pub async fn parse_event_payload_async(
+ &self,
+ payload: &serde_json::Value,
+ ) -> Vec {
+ let mut messages = Vec::new();
+
+ let event_type = payload
+ .pointer("/header/event_type")
+ .and_then(|e| e.as_str())
+ .unwrap_or("");
+ if event_type != "im.message.receive_v1" {
+ return messages;
+ }
+
+ let event = match payload.get("event") {
+ Some(e) => e,
+ None => return messages,
+ };
+
+ let open_id = event
+ .pointer("/sender/sender_id/open_id")
+ .and_then(|s| s.as_str())
+ .unwrap_or("");
+ if open_id.is_empty() {
+ return messages;
+ }
+ if !self.is_user_allowed(open_id) {
+ tracing::warn!("Lark: ignoring message from unauthorized user: {open_id}");
+ return messages;
+ }
+
+ let msg_type = event
+ .pointer("/message/message_type")
+ .and_then(|t| t.as_str())
+ .unwrap_or("");
+ let chat_type = event
+ .pointer("/message/chat_type")
+ .and_then(|c| c.as_str())
+ .unwrap_or("");
+ let mentions = event
+ .pointer("/message/mentions")
+ .and_then(|m| m.as_array())
+ .cloned()
+ .unwrap_or_default();
+ let content_str = event
+ .pointer("/message/content")
+ .and_then(|c| c.as_str())
+ .unwrap_or("");
+
+ let (text, post_mentioned_open_ids): (String, Vec) = match msg_type {
+ "text" => {
+ let extracted = serde_json::from_str::(content_str)
+ .ok()
+ .and_then(|v| {
+ v.get("text")
+ .and_then(|t| t.as_str())
+ .filter(|s| !s.is_empty())
+ .map(String::from)
+ });
+ match extracted {
+ Some(t) => (t, Vec::new()),
+ None => return messages,
+ }
+ }
+ "post" => match parse_post_content_details(content_str) {
+ Some(details) => (details.text, details.mentioned_open_ids),
+ None => return messages,
+ },
+ "image" => {
+ let text = if let Some(image_key) = parse_image_key(content_str) {
+ match self.fetch_image_marker(&image_key).await {
+ Ok(marker) => marker,
+ Err(error) => {
+ tracing::warn!(
+ "Lark webhook: failed to download image {image_key}: {error}"
+ );
+ LARK_IMAGE_DOWNLOAD_FALLBACK_TEXT.to_string()
+ }
+ }
+ } else {
+ tracing::warn!("Lark webhook: image message missing image_key");
+ LARK_IMAGE_DOWNLOAD_FALLBACK_TEXT.to_string()
+ };
+ (text, Vec::new())
+ }
+ _ => {
+ tracing::debug!("Lark: skipping unsupported message type: {msg_type}");
+ return messages;
+ }
+ };
+
+ let bot_open_id = self.resolved_bot_open_id();
+ if chat_type == "group"
+ && !should_respond_in_group(
+ self.mention_only,
+ open_id,
+ &self.group_reply_allowed_sender_ids,
+ bot_open_id.as_deref(),
+ &mentions,
+ &post_mentioned_open_ids,
+ )
+ {
+ return messages;
+ }
+
+ let timestamp = event
+ .pointer("/message/create_time")
+ .and_then(|t| t.as_str())
+ .and_then(|t| t.parse::().ok())
+ .map(|ms| ms / 1000)
+ .unwrap_or_else(|| {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs()
+ });
+
+ let chat_id = event
+ .pointer("/message/chat_id")
+ .and_then(|c| c.as_str())
+ .unwrap_or(open_id);
+
+ messages.push(ChannelMessage {
+ id: Uuid::new_v4().to_string(),
+ sender: chat_id.to_string(),
+ reply_target: chat_id.to_string(),
+ content: text,
+ channel: self.channel_name().to_string(),
+ timestamp,
+ thread_ts: None,
+ });
+
+ messages
+ }
}
#[async_trait]
@@ -1118,8 +1406,9 @@ impl Channel for LarkChannel {
self.send_text_once(&url, &new_token, &body).await?;
if should_refresh_lark_tenant_token(retry_status, &retry_response) {
+ let sanitized = sanitize_lark_body(&retry_response);
anyhow::bail!(
- "Lark send failed after token refresh: status={retry_status}, body={retry_response}"
+ "Lark send failed after token refresh: status={retry_status}, body={sanitized}"
);
}
@@ -1185,7 +1474,7 @@ impl LarkChannel {
}
// Parse event messages
- let messages = state.channel.parse_event_payload(&payload);
+ let messages = state.channel.parse_event_payload_async(&payload).await;
if !messages.is_empty() {
if let Some(message_id) = payload
.pointer("/event/message/message_id")
@@ -1242,6 +1531,7 @@ impl LarkChannel {
// WS helper functions
// ─────────────────────────────────────────────────────────────────────────────
+#[allow(clippy::cast_possible_truncation)]
fn pick_uniform_index(len: usize) -> usize {
debug_assert!(len > 0);
let upper = len as u64;
@@ -1568,13 +1858,41 @@ fn mention_matches_bot_open_id(mention: &serde_json::Value, bot_open_id: &str) -
.is_some_and(|value| value == bot_open_id)
}
-/// In group chats, only respond when the bot is explicitly @-mentioned.
+fn normalize_group_reply_allowed_sender_ids(sender_ids: Vec) -> Vec {
+ let mut normalized = sender_ids
+ .into_iter()
+ .map(|entry| entry.trim().to_string())
+ .filter(|entry| !entry.is_empty())
+ .collect::>();
+ normalized.sort();
+ normalized.dedup();
+ normalized
+}
+
+fn sender_has_group_reply_override(sender_open_id: &str, allowed_sender_ids: &[String]) -> bool {
+ let sender_open_id = sender_open_id.trim();
+ if sender_open_id.is_empty() {
+ return false;
+ }
+ allowed_sender_ids
+ .iter()
+ .any(|entry| entry == "*" || entry == sender_open_id)
+}
+
+/// Group-chat response policy:
+/// - sender override IDs always trigger
+/// - otherwise, mention gating applies when enabled
fn should_respond_in_group(
mention_only: bool,
+ sender_open_id: &str,
+ group_reply_allowed_sender_ids: &[String],
bot_open_id: Option<&str>,
mentions: &[serde_json::Value],
post_mentioned_open_ids: &[String],
) -> bool {
+ if sender_has_group_reply_override(sender_open_id, group_reply_allowed_sender_ids) {
+ return true;
+ }
if !mention_only {
return true;
}
@@ -1643,6 +1961,8 @@ mod tests {
})];
assert!(!should_respond_in_group(
true,
+ "ou_user",
+ &[],
Some("ou_bot"),
&mentions,
&[]
@@ -1653,6 +1973,8 @@ mod tests {
})];
assert!(should_respond_in_group(
true,
+ "ou_user",
+ &[],
Some("ou_bot"),
&mentions,
&[]
@@ -1664,19 +1986,40 @@ mod tests {
let mentions = vec![serde_json::json!({
"id": { "open_id": "ou_any" }
})];
- assert!(!should_respond_in_group(true, None, &mentions, &[]));
+ assert!(!should_respond_in_group(
+ true,
+ "ou_user",
+ &[],
+ None,
+ &mentions,
+ &[]
+ ));
}
#[test]
fn lark_group_response_allows_post_mentions_for_bot_open_id() {
assert!(should_respond_in_group(
true,
+ "ou_user",
+ &[],
Some("ou_bot"),
&[],
&[String::from("ou_bot")]
));
}
+ #[test]
+ fn lark_group_response_allows_sender_override_without_mention() {
+ assert!(should_respond_in_group(
+ true,
+ "ou_priority_user",
+ &[String::from("ou_priority_user")],
+ Some("ou_bot"),
+ &[],
+ &[]
+ ));
+ }
+
#[test]
fn lark_should_refresh_token_on_http_401() {
let body = serde_json::json!({ "code": 0 });
@@ -1836,7 +2179,7 @@ mod tests {
}
#[test]
- fn lark_parse_non_text_message_skipped() {
+ fn lark_parse_image_message_uses_fallback_text() {
let ch = LarkChannel::new(
"id".into(),
"secret".into(),
@@ -1858,7 +2201,35 @@ mod tests {
});
let msgs = ch.parse_event_payload(&payload);
- assert!(msgs.is_empty());
+ assert_eq!(msgs.len(), 1);
+ assert_eq!(msgs[0].content, LARK_IMAGE_DOWNLOAD_FALLBACK_TEXT);
+ }
+
+ #[tokio::test]
+ async fn lark_parse_event_payload_async_image_missing_key_uses_fallback_text() {
+ let ch = LarkChannel::new(
+ "id".into(),
+ "secret".into(),
+ "token".into(),
+ None,
+ vec!["*".into()],
+ true,
+ );
+ let payload = serde_json::json!({
+ "header": { "event_type": "im.message.receive_v1" },
+ "event": {
+ "sender": { "sender_id": { "open_id": "ou_user" } },
+ "message": {
+ "message_type": "image",
+ "content": "{}",
+ "chat_id": "oc_chat"
+ }
+ }
+ });
+
+ let msgs = ch.parse_event_payload_async(&payload).await;
+ assert_eq!(msgs.len(), 1);
+ assert_eq!(msgs[0].content, LARK_IMAGE_DOWNLOAD_FALLBACK_TEXT);
}
#[test]
@@ -1999,9 +2370,12 @@ mod tests {
verification_token: Some("vtoken789".into()),
allowed_users: vec!["ou_user1".into(), "ou_user2".into()],
mention_only: false,
+ group_reply: None,
use_feishu: false,
receive_mode: LarkReceiveMode::default(),
port: None,
+ draft_update_interval_ms: 3_000,
+ max_draft_edits: 20,
};
let json = serde_json::to_string(&lc).unwrap();
let parsed: LarkConfig = serde_json::from_str(&json).unwrap();
@@ -2021,9 +2395,12 @@ mod tests {
verification_token: Some("tok".into()),
allowed_users: vec!["*".into()],
mention_only: false,
+ group_reply: None,
use_feishu: false,
receive_mode: LarkReceiveMode::Webhook,
port: Some(9898),
+ draft_update_interval_ms: 3_000,
+ max_draft_edits: 20,
};
let toml_str = toml::to_string(&lc).unwrap();
let parsed: LarkConfig = toml::from_str(&toml_str).unwrap();
@@ -2055,9 +2432,12 @@ mod tests {
verification_token: Some("vtoken789".into()),
allowed_users: vec!["*".into()],
mention_only: false,
+ group_reply: None,
use_feishu: false,
receive_mode: LarkReceiveMode::Webhook,
port: Some(9898),
+ draft_update_interval_ms: 3_000,
+ max_draft_edits: 20,
};
let ch = LarkChannel::from_config(&cfg);
@@ -2078,9 +2458,13 @@ mod tests {
encrypt_key: None,
verification_token: Some("vtoken789".into()),
allowed_users: vec!["*".into()],
+ mention_only: false,
+ group_reply: None,
use_feishu: true,
receive_mode: LarkReceiveMode::Webhook,
port: Some(9898),
+ draft_update_interval_ms: 3_000,
+ max_draft_edits: 20,
};
let ch = LarkChannel::from_lark_config(&cfg);
@@ -2100,8 +2484,11 @@ mod tests {
encrypt_key: None,
verification_token: Some("vtoken789".into()),
allowed_users: vec!["*".into()],
+ group_reply: None,
receive_mode: LarkReceiveMode::Webhook,
port: Some(9898),
+ draft_update_interval_ms: 3_000,
+ max_draft_edits: 20,
};
let ch = LarkChannel::from_feishu_config(&cfg);
@@ -2272,8 +2659,11 @@ mod tests {
encrypt_key: None,
verification_token: Some("vtoken789".into()),
allowed_users: vec!["*".into()],
+ group_reply: None,
receive_mode: crate::config::schema::LarkReceiveMode::Webhook,
port: Some(9898),
+ draft_update_interval_ms: 3_000,
+ max_draft_edits: 20,
};
let ch_feishu = LarkChannel::from_feishu_config(&feishu_cfg);
assert_eq!(
diff --git a/src/channels/mod.rs b/src/channels/mod.rs
index 320e53e82..c34290169 100644
--- a/src/channels/mod.rs
+++ b/src/channels/mod.rs
@@ -42,7 +42,7 @@ pub mod whatsapp_storage;
#[cfg(feature = "whatsapp-web")]
pub mod whatsapp_web;
-pub use clawdtalk::{ClawdTalkChannel, ClawdTalkConfig};
+pub use clawdtalk::ClawdTalkChannel;
pub use cli::CliChannel;
pub use dingtalk::DingTalkChannel;
pub use discord::DiscordChannel;
@@ -67,14 +67,18 @@ pub use whatsapp::WhatsAppChannel;
#[cfg(feature = "whatsapp-web")]
pub use whatsapp_web::WhatsAppWebChannel;
-use crate::agent::loop_::{build_tool_instructions, run_tool_call_loop, scrub_credentials};
-use crate::config::Config;
+use crate::agent::loop_::{
+ build_shell_policy_instructions, build_tool_instructions_from_specs,
+ run_tool_call_loop_with_non_cli_approval_context, scrub_credentials, NonCliApprovalContext,
+};
+use crate::approval::{ApprovalManager, ApprovalResponse, PendingApprovalError};
+use crate::config::{Config, NonCliNaturalLanguageApprovalMode};
use crate::identity;
use crate::memory::{self, Memory};
use crate::observability::{self, runtime_trace, Observer};
use crate::providers::{self, ChatMessage, Provider};
use crate::runtime;
-use crate::security::SecurityPolicy;
+use crate::security::{LeakDetector, LeakResult, SecurityPolicy};
use crate::tools::{self, Tool};
use crate::util::truncate_with_ellipsis;
use anyhow::{Context, Result};
@@ -152,8 +156,19 @@ enum ChannelRuntimeCommand {
ShowModel,
SetModel(String),
NewSession,
+ RequestAllToolsOnce,
+ RequestToolApproval(String),
+ ConfirmToolApproval(String),
+ ApprovePendingRequest(String),
+ DenyToolApproval(String),
+ ListPendingApprovals,
+ ApproveTool(String),
+ UnapproveTool(String),
+ ListApprovals,
}
+const APPROVAL_ALL_TOOLS_ONCE_TOKEN: &str = "__all_tools_once__";
+
#[derive(Debug, Clone, Default, Deserialize)]
struct ModelCacheState {
entries: Vec,
@@ -184,9 +199,22 @@ struct ConfigFileStamp {
#[derive(Debug, Clone)]
struct RuntimeConfigState {
defaults: ChannelRuntimeDefaults,
+ perplexity_filter: crate::config::PerplexityFilterConfig,
last_applied_stamp: Option,
}
+#[derive(Debug, Clone)]
+struct RuntimeAutonomyPolicy {
+ auto_approve: Vec,
+ always_ask: Vec,
+ non_cli_excluded_tools: Vec,
+ non_cli_approval_approvers: Vec,
+ non_cli_natural_language_approval_mode: NonCliNaturalLanguageApprovalMode,
+ non_cli_natural_language_approval_mode_by_channel:
+ HashMap,
+ perplexity_filter: crate::config::PerplexityFilterConfig,
+}
+
fn runtime_config_store() -> &'static Mutex> {
static STORE: OnceLock>> = OnceLock::new();
STORE.get_or_init(|| Mutex::new(HashMap::new()))
@@ -223,7 +251,10 @@ struct ChannelRuntimeContext {
interrupt_on_new_message: bool,
multimodal: crate::config::MultimodalConfig,
hooks: Option>,
- non_cli_excluded_tools: Arc>,
+ non_cli_excluded_tools: Arc>>,
+ query_classification: crate::config::QueryClassificationConfig,
+ model_routes: Vec,
+ approval_manager: Arc,
}
#[derive(Clone)]
@@ -411,14 +442,168 @@ fn channel_delivery_instructions(channel_name: &str) -> Option<&'static str> {
- Keep normal text outside markers and never wrap markers in code fences.\n\
- Use tool results silently: answer the latest user message directly, and do not narrate delayed/internal tool execution bookkeeping.",
),
+ "whatsapp" => Some(
+ "When responding on WhatsApp:\n\
+ - Use *bold* for emphasis (WhatsApp uses single asterisks).\n\
+ - Be concise. No markdown headers (## etc.) — they don't render.\n\
+ - No markdown tables — use bullet lists instead.\n\
+ - For sending images, documents, videos, or audio files use markers: [IMAGE:], [DOCUMENT:], [VIDEO:], [AUDIO:]\n\
+ - The path MUST be an absolute filesystem path to a local file (e.g. [IMAGE:/home/nicolas/.zeroclaw/workspace/images/chart.png]).\n\
+ - Keep normal text outside markers and never wrap markers in code fences.\n\
+ - You can combine text and media in one response — text is sent first, then each attachment.\n\
+ - Use tool results silently: answer the latest user message directly, and do not narrate delayed/internal tool execution bookkeeping.",
+ ),
_ => None,
}
}
+fn should_expose_internal_tool_details(user_message: &str) -> bool {
+ let trimmed = user_message.trim();
+ if trimmed.is_empty() {
+ return false;
+ }
+
+ let lower = trimmed.to_ascii_lowercase();
+ let mentions_internal_details_en = lower.contains("command")
+ || lower.contains("tool call")
+ || lower.contains("function call")
+ || lower.contains("execution trace")
+ || lower.contains("internal step");
+ let mentions_internal_details_cjk = trimmed.contains("命令")
+ || trimmed.contains("工具调用")
+ || trimmed.contains("函数调用")
+ || trimmed.contains("执行过程");
+
+ // Fail closed for negated phrasing ("don't show commands", "不要显示命令").
+ const ENGLISH_NEGATIVE_HINTS: [&str; 18] = [
+ "don't show command",
+ "don't show commands",
+ "do not show command",
+ "do not show commands",
+ "don't output command",
+ "do not output command",
+ "without command",
+ "without commands",
+ "no command output",
+ "hide command",
+ "hide commands",
+ "omit command",
+ "omit commands",
+ "skip command",
+ "skip commands",
+ "don't show tool call",
+ "do not show tool call",
+ "do not show function call",
+ ];
+ if mentions_internal_details_en
+ && ENGLISH_NEGATIVE_HINTS
+ .iter()
+ .any(|hint| lower.contains(hint))
+ {
+ return false;
+ }
+
+ const CJK_NEGATIVE_HINTS: [&str; 22] = [
+ "不要输出命令",
+ "不要显示命令",
+ "不要展示命令",
+ "不要带上命令",
+ "不要附上命令",
+ "别输出命令",
+ "别显示命令",
+ "别展示命令",
+ "不要输出工具调用",
+ "不要显示工具调用",
+ "不要展示工具调用",
+ "别输出工具调用",
+ "别显示工具调用",
+ "不要输出函数调用",
+ "不要显示函数调用",
+ "不要展示函数调用",
+ "别输出函数调用",
+ "别显示函数调用",
+ "不要执行过程",
+ "不要过程",
+ "不要内部步骤",
+ "别把命令",
+ ];
+ if mentions_internal_details_cjk && CJK_NEGATIVE_HINTS.iter().any(|hint| trimmed.contains(hint))
+ {
+ return false;
+ }
+
+ const ENGLISH_HINTS: [&str; 20] = [
+ "show command",
+ "show commands",
+ "output command",
+ "output commands",
+ "print command",
+ "print commands",
+ "include command",
+ "include commands",
+ "with command",
+ "with commands",
+ "show tool call",
+ "show tool calls",
+ "show function call",
+ "show function calls",
+ "reveal tool call",
+ "reveal function call",
+ "tool call json",
+ "function call json",
+ "execution trace",
+ "internal steps",
+ ];
+ if ENGLISH_HINTS.iter().any(|hint| lower.contains(hint)) {
+ return true;
+ }
+
+ const ENGLISH_VERBS: [&str; 7] = [
+ "show", "output", "print", "include", "reveal", "display", "share",
+ ];
+ if mentions_internal_details_en && ENGLISH_VERBS.iter().any(|verb| lower.contains(verb)) {
+ return true;
+ }
+
+ const CJK_HINTS: [&str; 14] = [
+ "输出命令",
+ "显示命令",
+ "展示命令",
+ "命令发给我",
+ "带上命令",
+ "输出工具调用",
+ "显示工具调用",
+ "展示工具调用",
+ "输出函数调用",
+ "显示函数调用",
+ "展示函数调用",
+ "函数指令",
+ "工具指令",
+ "执行过程",
+ ];
+ if CJK_HINTS.iter().any(|hint| trimmed.contains(hint)) {
+ return true;
+ }
+
+ const CJK_VERBS: [&str; 9] = [
+ "输出", "显示", "展示", "发我", "给我", "带上", "附上", "贴出", "列出",
+ ];
+ mentions_internal_details_cjk && CJK_VERBS.iter().any(|verb| trimmed.contains(verb))
+}
+
+fn split_internal_progress_delta(delta: &str) -> (bool, &str) {
+ if let Some(rest) = delta.strip_prefix(crate::agent::loop_::DRAFT_PROGRESS_SENTINEL) {
+ (true, rest)
+ } else {
+ (false, delta)
+ }
+}
+
fn build_channel_system_prompt(
base_prompt: &str,
channel_name: &str,
reply_target: &str,
+ expose_internal_tool_details: bool,
) -> String {
let mut prompt = base_prompt.to_string();
@@ -430,6 +615,25 @@ fn build_channel_system_prompt(
}
}
+ if channel_name != "cli" {
+ let visibility_instruction = if expose_internal_tool_details {
+ "Execution visibility: the user explicitly requested command/tool details. \
+ You may include command lines or tool-step traces when directly relevant, \
+ but keep credentials and secrets redacted."
+ } else {
+ "Execution visibility: run tools/functions in the background and return an \
+ integrated final result. Do not reveal raw tool names, tool-call syntax, \
+ function arguments, shell commands, or internal execution traces unless the \
+ user explicitly asks for those details."
+ };
+
+ if prompt.is_empty() {
+ prompt = visibility_instruction.to_string();
+ } else {
+ prompt = format!("{prompt}\n\n{visibility_instruction}");
+ }
+ }
+
if !reply_target.is_empty() {
let context = format!(
"\n\nChannel context: You are currently responding on channel={channel_name}, \
@@ -482,13 +686,9 @@ fn supports_runtime_model_switch(channel_name: &str) -> bool {
}
fn parse_runtime_command(channel_name: &str, content: &str) -> Option {
- if !supports_runtime_model_switch(channel_name) {
- return None;
- }
-
let trimmed = content.trim();
if !trimmed.starts_with('/') {
- return None;
+ return parse_natural_language_runtime_command(trimmed);
}
let mut parts = trimmed.split_whitespace();
@@ -498,10 +698,24 @@ fn parse_runtime_command(channel_name: &str, content: &str) -> Option = parts.collect();
+ let tail = args.join(" ").trim().to_string();
match base_command.as_str() {
- "/models" => {
- if let Some(provider) = parts.next() {
+ // History reset commands are safe for all channels.
+ "/new" | "/clear" => Some(ChannelRuntimeCommand::NewSession),
+ "/approve-all-once" => Some(ChannelRuntimeCommand::RequestAllToolsOnce),
+ "/approve-request" => Some(ChannelRuntimeCommand::RequestToolApproval(tail)),
+ "/approve-confirm" => Some(ChannelRuntimeCommand::ConfirmToolApproval(tail)),
+ "/approve-allow" => Some(ChannelRuntimeCommand::ApprovePendingRequest(tail)),
+ "/approve-deny" => Some(ChannelRuntimeCommand::DenyToolApproval(tail)),
+ "/approve-pending" => Some(ChannelRuntimeCommand::ListPendingApprovals),
+ "/approve" => Some(ChannelRuntimeCommand::ApproveTool(tail)),
+ "/unapprove" => Some(ChannelRuntimeCommand::UnapproveTool(tail)),
+ "/approvals" => Some(ChannelRuntimeCommand::ListApprovals),
+ // Provider/model switching remains limited to channels with session routing.
+ "/models" if supports_runtime_model_switch(channel_name) => {
+ if let Some(provider) = args.first() {
Some(ChannelRuntimeCommand::SetProvider(
provider.trim().to_string(),
))
@@ -509,19 +723,148 @@ fn parse_runtime_command(channel_name: &str, content: &str) -> Option {
- let model = parts.collect::>().join(" ").trim().to_string();
+ "/model" if supports_runtime_model_switch(channel_name) => {
+ let model = tail;
if model.is_empty() {
Some(ChannelRuntimeCommand::ShowModel)
} else {
Some(ChannelRuntimeCommand::SetModel(model))
}
}
- "/new" => Some(ChannelRuntimeCommand::NewSession),
_ => None,
}
}
+fn is_runtime_token(value: &str) -> bool {
+ let token = value.trim();
+ !token.is_empty()
+ && token
+ .chars()
+ .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | ':'))
+}
+
+fn extract_runtime_tail_token(text: &str, prefixes: &[&str]) -> Option {
+ prefixes.iter().find_map(|prefix| {
+ text.strip_prefix(prefix).and_then(|rest| {
+ let token = rest.trim();
+ if is_runtime_token(token) {
+ Some(token.to_string())
+ } else {
+ None
+ }
+ })
+ })
+}
+
+fn contains_any_fragment(haystack: &str, fragments: &[&str]) -> bool {
+ fragments.iter().any(|fragment| haystack.contains(fragment))
+}
+
+fn is_natural_language_all_tools_once_intent(content: &str) -> bool {
+ let trimmed = content.trim();
+ if trimmed.is_empty() {
+ return false;
+ }
+
+ let lower = trimmed.to_ascii_lowercase();
+ let has_allow_verb = contains_any_fragment(&lower, &["approve", "allow"])
+ || contains_any_fragment(trimmed, &["授权", "放开", "允许"]);
+ let has_all_tools_scope = contains_any_fragment(&lower, &["all tools", "all commands"])
+ || contains_any_fragment(trimmed, &["所有工具", "全部工具", "所有命令", "全部命令"]);
+ let has_one_time_scope = contains_any_fragment(&lower, &["once", "one-time", "one time"])
+ || contains_any_fragment(trimmed, &["一次", "这次"]);
+
+ has_allow_verb && has_all_tools_scope && has_one_time_scope
+}
+
+fn approval_target_label(tool_name: &str) -> String {
+ if tool_name == APPROVAL_ALL_TOOLS_ONCE_TOKEN {
+ "all tools/commands (one-time bypass token)".to_string()
+ } else {
+ tool_name.to_string()
+ }
+}
+
+fn parse_natural_language_runtime_command(content: &str) -> Option {
+ let trimmed = content.trim();
+ if trimmed.is_empty() {
+ return None;
+ }
+
+ let lower = trimmed.to_ascii_lowercase();
+ if matches!(
+ lower.as_str(),
+ "show pending approvals" | "list pending approvals" | "pending approvals"
+ ) {
+ return Some(ChannelRuntimeCommand::ListPendingApprovals);
+ }
+ if trimmed == "查看授权"
+ || matches!(
+ lower.as_str(),
+ "show approvals" | "list approvals" | "approvals"
+ )
+ {
+ return Some(ChannelRuntimeCommand::ListApprovals);
+ }
+ if is_natural_language_all_tools_once_intent(trimmed)
+ || matches!(
+ lower.as_str(),
+ "approve all tools once" | "allow all tools once" | "approve all once"
+ )
+ {
+ return Some(ChannelRuntimeCommand::RequestAllToolsOnce);
+ }
+
+ if let Some(request_id) = extract_runtime_tail_token(&lower, &["confirm "]) {
+ return Some(ChannelRuntimeCommand::ConfirmToolApproval(request_id));
+ }
+ if let Some(request_id) = extract_runtime_tail_token(trimmed, &["确认授权 "]) {
+ return Some(ChannelRuntimeCommand::ConfirmToolApproval(request_id));
+ }
+
+ if let Some(tool) =
+ extract_runtime_tail_token(&lower, &["revoke tool ", "unapprove ", "revoke "])
+ {
+ return Some(ChannelRuntimeCommand::UnapproveTool(tool));
+ }
+ if let Some(tool) = extract_runtime_tail_token(trimmed, &["撤销工具 ", "取消授权 "]) {
+ return Some(ChannelRuntimeCommand::UnapproveTool(tool));
+ }
+
+ if let Some(tool) = extract_runtime_tail_token(&lower, &["approve tool ", "approve "]) {
+ return Some(ChannelRuntimeCommand::RequestToolApproval(tool));
+ }
+ if let Some(tool) = extract_runtime_tail_token(trimmed, &["授权工具 ", "请放开 ", "放开 "])
+ {
+ return Some(ChannelRuntimeCommand::RequestToolApproval(tool));
+ }
+
+ None
+}
+
+fn is_approval_management_command(command: &ChannelRuntimeCommand) -> bool {
+ matches!(
+ command,
+ ChannelRuntimeCommand::RequestAllToolsOnce
+ | ChannelRuntimeCommand::RequestToolApproval(_)
+ | ChannelRuntimeCommand::ConfirmToolApproval(_)
+ | ChannelRuntimeCommand::ApprovePendingRequest(_)
+ | ChannelRuntimeCommand::DenyToolApproval(_)
+ | ChannelRuntimeCommand::ListPendingApprovals
+ | ChannelRuntimeCommand::ApproveTool(_)
+ | ChannelRuntimeCommand::UnapproveTool(_)
+ | ChannelRuntimeCommand::ListApprovals
+ )
+}
+
+fn non_cli_natural_language_mode_label(mode: NonCliNaturalLanguageApprovalMode) -> &'static str {
+ match mode {
+ NonCliNaturalLanguageApprovalMode::Disabled => "disabled",
+ NonCliNaturalLanguageApprovalMode::RequestConfirm => "request_confirm",
+ NonCliNaturalLanguageApprovalMode::Direct => "direct",
+ }
+}
+
fn resolve_provider_alias(name: &str) -> Option {
let candidate = name.trim();
if candidate.is_empty() {
@@ -568,6 +911,23 @@ fn runtime_defaults_from_config(config: &Config) -> ChannelRuntimeDefaults {
}
}
+fn runtime_autonomy_policy_from_config(config: &Config) -> RuntimeAutonomyPolicy {
+ RuntimeAutonomyPolicy {
+ auto_approve: config.autonomy.auto_approve.clone(),
+ always_ask: config.autonomy.always_ask.clone(),
+ non_cli_excluded_tools: config.autonomy.non_cli_excluded_tools.clone(),
+ non_cli_approval_approvers: config.autonomy.non_cli_approval_approvers.clone(),
+ non_cli_natural_language_approval_mode: config
+ .autonomy
+ .non_cli_natural_language_approval_mode,
+ non_cli_natural_language_approval_mode_by_channel: config
+ .autonomy
+ .non_cli_natural_language_approval_mode_by_channel
+ .clone(),
+ perplexity_filter: config.security.perplexity_filter.clone(),
+ }
+}
+
fn runtime_config_path(ctx: &ChannelRuntimeContext) -> Option {
ctx.provider_runtime_options
.zeroclaw_dir
@@ -595,6 +955,91 @@ fn runtime_defaults_snapshot(ctx: &ChannelRuntimeContext) -> ChannelRuntimeDefau
}
}
+fn runtime_perplexity_filter_snapshot(
+ ctx: &ChannelRuntimeContext,
+) -> crate::config::PerplexityFilterConfig {
+ if let Some(config_path) = runtime_config_path(ctx) {
+ let store = runtime_config_store()
+ .lock()
+ .unwrap_or_else(|e| e.into_inner());
+ if let Some(state) = store.get(&config_path) {
+ return state.perplexity_filter.clone();
+ }
+ }
+ crate::config::PerplexityFilterConfig::default()
+}
+
+fn snapshot_non_cli_excluded_tools(ctx: &ChannelRuntimeContext) -> Vec {
+ ctx.non_cli_excluded_tools
+ .lock()
+ .unwrap_or_else(|e| e.into_inner())
+ .clone()
+}
+
+fn filtered_tool_specs_for_runtime(
+ tools_registry: &[Box],
+ excluded_tools: &[String],
+) -> Vec {
+ tools_registry
+ .iter()
+ .map(|tool| tool.spec())
+ .filter(|spec| !excluded_tools.iter().any(|excluded| excluded == &spec.name))
+ .collect()
+}
+
+fn build_runtime_tool_visibility_prompt(
+ tools_registry: &[Box],
+ excluded_tools: &[String],
+ native_tools: bool,
+) -> String {
+ let mut prompt = String::new();
+ let mut specs = filtered_tool_specs_for_runtime(tools_registry, excluded_tools);
+ specs.sort_by(|a, b| a.name.cmp(&b.name));
+
+ use std::fmt::Write;
+ prompt.push_str("\n## Runtime Tool Availability (Authoritative)\n\n");
+ prompt.push_str(
+ "This section is generated from current runtime policy for this message. \
+ Only the listed tools may be called in this turn.\n\n",
+ );
+
+ if specs.is_empty() {
+ prompt.push_str("- Allowed tools: (none)\n");
+ } else {
+ let _ = writeln!(prompt, "- Allowed tools ({}):", specs.len());
+ for spec in &specs {
+ let _ = writeln!(prompt, " - `{}`", spec.name);
+ }
+ }
+
+ if excluded_tools.is_empty() {
+ prompt.push_str("- Excluded by runtime policy: (none)\n\n");
+ } else {
+ let mut excluded_sorted = excluded_tools.to_vec();
+ excluded_sorted.sort();
+ let _ = writeln!(
+ prompt,
+ "- Excluded by runtime policy: {}\n",
+ excluded_sorted.join(", ")
+ );
+ }
+
+ if native_tools {
+ prompt.push_str(
+ "Tool calling for this turn uses native provider function-calling. \
+ Do not emit `` XML tags.\n",
+ );
+ } else {
+ prompt.push_str(
+ "Tool calling for this turn uses XML tool protocol below. \
+ This protocol block is generated from the same runtime policy snapshot.\n",
+ );
+ prompt.push_str(&build_tool_instructions_from_specs(&specs));
+ }
+
+ prompt
+}
+
async fn config_file_stamp(path: &Path) -> Option {
let metadata = tokio::fs::metadata(path).await.ok()?;
let modified = metadata.modified().ok()?;
@@ -621,7 +1066,9 @@ fn decrypt_optional_secret_for_runtime_reload(
Ok(())
}
-async fn load_runtime_defaults_from_config_file(path: &Path) -> Result {
+async fn load_runtime_defaults_from_config_file(
+ path: &Path,
+) -> Result<(ChannelRuntimeDefaults, RuntimeAutonomyPolicy)> {
let contents = tokio::fs::read_to_string(path)
.await
.with_context(|| format!("Failed to read {}", path.display()))?;
@@ -635,7 +1082,355 @@ async fn load_runtime_defaults_from_config_file(path: &Path) -> Result Result> {
+ let Some(config_path) = runtime_config_path(ctx) else {
+ return Ok(None);
+ };
+
+ let contents = tokio::fs::read_to_string(&config_path)
+ .await
+ .with_context(|| format!("Failed to read {}", config_path.display()))?;
+ let mut parsed: Config = toml::from_str(&contents)
+ .with_context(|| format!("Failed to parse {}", config_path.display()))?;
+ parsed.config_path = config_path.clone();
+
+ let mut changed = false;
+ if !parsed
+ .autonomy
+ .auto_approve
+ .iter()
+ .any(|entry| entry == tool_name)
+ {
+ parsed.autonomy.auto_approve.push(tool_name.to_string());
+ changed = true;
+ }
+
+ let before_always_ask = parsed.autonomy.always_ask.len();
+ parsed
+ .autonomy
+ .always_ask
+ .retain(|entry| entry != tool_name);
+ if parsed.autonomy.always_ask.len() != before_always_ask {
+ changed = true;
+ }
+
+ if changed {
+ parsed.save().await?;
+ }
+
+ Ok(Some(config_path))
+}
+
+async fn remove_non_cli_approval_from_config(
+ ctx: &ChannelRuntimeContext,
+ tool_name: &str,
+) -> Result > {
+ let Some(config_path) = runtime_config_path(ctx) else {
+ return Ok(None);
+ };
+
+ let contents = tokio::fs::read_to_string(&config_path)
+ .await
+ .with_context(|| format!("Failed to read {}", config_path.display()))?;
+ let mut parsed: Config = toml::from_str(&contents)
+ .with_context(|| format!("Failed to parse {}", config_path.display()))?;
+ parsed.config_path = config_path.clone();
+
+ let before_auto_approve = parsed.autonomy.auto_approve.len();
+ parsed
+ .autonomy
+ .auto_approve
+ .retain(|entry| entry != tool_name);
+ let removed = parsed.autonomy.auto_approve.len() != before_auto_approve;
+ if removed {
+ parsed.save().await?;
+ }
+
+ Ok(Some((config_path, removed)))
+}
+
+fn remove_non_cli_tool_exclusion_from_runtime(
+ ctx: &ChannelRuntimeContext,
+ tool_name: &str,
+) -> bool {
+ let mut excluded = ctx
+ .non_cli_excluded_tools
+ .lock()
+ .unwrap_or_else(|e| e.into_inner());
+ let before_len = excluded.len();
+ excluded.retain(|entry| entry != tool_name);
+ excluded.len() != before_len
+}
+
+async fn remove_non_cli_excluded_tool_from_config(
+ ctx: &ChannelRuntimeContext,
+ tool_name: &str,
+) -> Result > {
+ let Some(config_path) = runtime_config_path(ctx) else {
+ return Ok(None);
+ };
+
+ let contents = tokio::fs::read_to_string(&config_path)
+ .await
+ .with_context(|| format!("Failed to read {}", config_path.display()))?;
+ let mut parsed: Config = toml::from_str(&contents)
+ .with_context(|| format!("Failed to parse {}", config_path.display()))?;
+ parsed.config_path = config_path.clone();
+
+ let before_len = parsed.autonomy.non_cli_excluded_tools.len();
+ parsed
+ .autonomy
+ .non_cli_excluded_tools
+ .retain(|entry| entry != tool_name);
+ let removed = parsed.autonomy.non_cli_excluded_tools.len() != before_len;
+ if removed {
+ parsed.save().await?;
+ }
+
+ Ok(Some((config_path, removed)))
+}
+
+async fn clear_non_cli_exclusion_after_approval(
+ ctx: &ChannelRuntimeContext,
+ tool_name: &str,
+) -> Option {
+ let runtime_removed = remove_non_cli_tool_exclusion_from_runtime(ctx, tool_name);
+ match remove_non_cli_excluded_tool_from_config(ctx, tool_name).await {
+ Ok(Some((path, persisted_removed))) => match (runtime_removed, persisted_removed) {
+ (true, true) => Some(format!(
+ "Removed `{tool_name}` from `autonomy.non_cli_excluded_tools` in runtime and persisted config (`{}`).",
+ path.display()
+ )),
+ (true, false) => Some(format!(
+ "Removed `{tool_name}` from runtime `autonomy.non_cli_excluded_tools` (it was already absent from persisted config `{}`).",
+ path.display()
+ )),
+ (false, true) => Some(format!(
+ "Removed `{tool_name}` from persisted `autonomy.non_cli_excluded_tools` in `{}`.",
+ path.display()
+ )),
+ (false, false) => None,
+ },
+ Ok(None) => runtime_removed.then(|| {
+ format!(
+ "Removed `{tool_name}` from runtime `autonomy.non_cli_excluded_tools`."
+ )
+ }),
+ Err(err) => {
+ if runtime_removed {
+ Some(format!(
+ "Removed `{tool_name}` from runtime `autonomy.non_cli_excluded_tools`, but failed to persist config update: {err}"
+ ))
+ } else {
+ Some(format!(
+ "Failed to update persisted `autonomy.non_cli_excluded_tools` for `{tool_name}`: {err}"
+ ))
+ }
+ }
+ }
+}
+
+async fn describe_non_cli_approvals(
+ ctx: &ChannelRuntimeContext,
+ sender: &str,
+ channel: &str,
+ reply_target: &str,
+) -> Result {
+ let mut response = String::new();
+ response.push_str("Supervised non-CLI tool approvals:\n");
+
+ let mut runtime_auto = ctx
+ .approval_manager
+ .auto_approve_tools()
+ .into_iter()
+ .collect::>();
+ runtime_auto.sort();
+ if runtime_auto.is_empty() {
+ response.push_str("- Runtime auto_approve (effective): (none)\n");
+ } else {
+ let _ = writeln!(
+ response,
+ "- Runtime auto_approve (effective): {}",
+ runtime_auto.join(", ")
+ );
+ }
+
+ let mut runtime_always = ctx
+ .approval_manager
+ .always_ask_tools()
+ .into_iter()
+ .collect::>();
+ runtime_always.sort();
+ if runtime_always.is_empty() {
+ response.push_str("- Runtime always_ask (effective): (none)\n");
+ } else {
+ let _ = writeln!(
+ response,
+ "- Runtime always_ask (effective): {}",
+ runtime_always.join(", ")
+ );
+ }
+
+ let mut session_grants = ctx
+ .approval_manager
+ .non_cli_session_allowlist()
+ .into_iter()
+ .collect::>();
+ session_grants.sort();
+ if session_grants.is_empty() {
+ response.push_str("- Runtime session grants: (none)\n");
+ } else {
+ let _ = writeln!(
+ response,
+ "- Runtime session grants: {}",
+ session_grants.join(", ")
+ );
+ }
+ let one_time_all_tools_tokens = ctx.approval_manager.non_cli_allow_all_once_remaining();
+ let _ = writeln!(
+ response,
+ "- Runtime one-time all-tools bypass tokens: {}",
+ one_time_all_tools_tokens
+ );
+
+ let mut approval_approvers = ctx
+ .approval_manager
+ .non_cli_approval_approvers()
+ .into_iter()
+ .collect::>();
+ approval_approvers.sort();
+ if approval_approvers.is_empty() {
+ response.push_str("- Runtime non_cli_approval_approvers: (any channel-allowed sender)\n");
+ } else {
+ let _ = writeln!(
+ response,
+ "- Runtime non_cli_approval_approvers: {}",
+ approval_approvers.join(", ")
+ );
+ }
+
+ let default_mode = non_cli_natural_language_mode_label(
+ ctx.approval_manager
+ .non_cli_natural_language_approval_mode(),
+ );
+ let effective_mode = non_cli_natural_language_mode_label(
+ ctx.approval_manager
+ .non_cli_natural_language_approval_mode_for_channel(channel),
+ );
+ let _ = writeln!(
+ response,
+ "- Runtime non_cli_natural_language_approval_mode: {}",
+ default_mode
+ );
+ let _ = writeln!(
+ response,
+ "- Runtime non_cli_natural_language_approval_mode (current channel `{channel}`): {}",
+ effective_mode
+ );
+ let mut mode_overrides = ctx
+ .approval_manager
+ .non_cli_natural_language_approval_mode_by_channel()
+ .into_iter()
+ .map(|(ch, mode)| format!("{ch}={}", non_cli_natural_language_mode_label(mode)))
+ .collect::>();
+ mode_overrides.sort();
+ if mode_overrides.is_empty() {
+ response.push_str("- Runtime non_cli_natural_language_approval_mode_by_channel: (none)\n");
+ } else {
+ let _ = writeln!(
+ response,
+ "- Runtime non_cli_natural_language_approval_mode_by_channel: {}",
+ mode_overrides.join(", ")
+ );
+ }
+
+ let mut pending_requests = ctx.approval_manager.list_non_cli_pending_requests(
+ Some(sender),
+ Some(channel),
+ Some(reply_target),
+ );
+ pending_requests.sort_by(|a, b| a.created_at.cmp(&b.created_at));
+ if pending_requests.is_empty() {
+ response.push_str("- Pending approvals (sender+chat/channel scoped): (none)\n");
+ } else {
+ response.push_str("- Pending approvals (sender+chat/channel scoped):\n");
+ for req in pending_requests {
+ let reason = req
+ .reason
+ .as_deref()
+ .filter(|text| !text.trim().is_empty())
+ .unwrap_or("n/a");
+ let _ = writeln!(
+ response,
+ " - {}: tool={}, expires_at={}, reason={}",
+ req.request_id,
+ approval_target_label(&req.tool_name),
+ req.expires_at,
+ reason
+ );
+ }
+ }
+
+ let mut excluded = snapshot_non_cli_excluded_tools(ctx);
+ excluded.sort();
+ if excluded.is_empty() {
+ response.push_str("- Runtime non_cli_excluded_tools: (none)\n");
+ } else {
+ let _ = writeln!(
+ response,
+ "- Runtime non_cli_excluded_tools: {}",
+ excluded.join(", ")
+ );
+ }
+
+ let Some(config_path) = runtime_config_path(ctx) else {
+ response.push_str(
+ "- Persisted config approvals: unavailable (runtime config path not resolved)\n",
+ );
+ return Ok(response);
+ };
+
+ let contents = tokio::fs::read_to_string(&config_path)
+ .await
+ .with_context(|| format!("Failed to read {}", config_path.display()))?;
+ let parsed: Config = toml::from_str(&contents)
+ .with_context(|| format!("Failed to parse {}", config_path.display()))?;
+
+ let mut auto_approve = parsed.autonomy.auto_approve;
+ auto_approve.sort();
+ if auto_approve.is_empty() {
+ response.push_str("- Persisted autonomy.auto_approve: (none)\n");
+ } else {
+ let _ = writeln!(
+ response,
+ "- Persisted autonomy.auto_approve: {}",
+ auto_approve.join(", ")
+ );
+ }
+
+ let mut always_ask = parsed.autonomy.always_ask;
+ always_ask.sort();
+ if always_ask.is_empty() {
+ response.push_str("- Persisted autonomy.always_ask: (none)\n");
+ } else {
+ let _ = writeln!(
+ response,
+ "- Persisted autonomy.always_ask: {}",
+ always_ask.join(", ")
+ );
+ }
+
+ let _ = writeln!(response, "- Config path: {}", config_path.display());
+ Ok(response)
}
async fn maybe_apply_runtime_config_update(ctx: &ChannelRuntimeContext) -> Result<()> {
@@ -658,7 +1453,8 @@ async fn maybe_apply_runtime_config_update(ctx: &ChannelRuntimeContext) -> Resul
}
}
- let next_defaults = load_runtime_defaults_from_config_file(&config_path).await?;
+ let (next_defaults, next_autonomy_policy) =
+ load_runtime_defaults_from_config_file(&config_path).await?;
let next_default_provider = providers::create_resilient_provider_with_options(
&next_defaults.default_provider,
next_defaults.api_key.as_deref(),
@@ -692,16 +1488,38 @@ async fn maybe_apply_runtime_config_update(ctx: &ChannelRuntimeContext) -> Resul
config_path.clone(),
RuntimeConfigState {
defaults: next_defaults.clone(),
+ perplexity_filter: next_autonomy_policy.perplexity_filter.clone(),
last_applied_stamp: Some(stamp),
},
);
}
+ ctx.approval_manager.replace_runtime_non_cli_policy(
+ &next_autonomy_policy.auto_approve,
+ &next_autonomy_policy.always_ask,
+ &next_autonomy_policy.non_cli_approval_approvers,
+ next_autonomy_policy.non_cli_natural_language_approval_mode,
+ &next_autonomy_policy.non_cli_natural_language_approval_mode_by_channel,
+ );
+ {
+ let mut excluded = ctx
+ .non_cli_excluded_tools
+ .lock()
+ .unwrap_or_else(|e| e.into_inner());
+ *excluded = next_autonomy_policy.non_cli_excluded_tools.clone();
+ }
+
tracing::info!(
path = %config_path.display(),
provider = %next_defaults.default_provider,
model = %next_defaults.model,
temperature = next_defaults.temperature,
+ non_cli_approval_mode = %non_cli_natural_language_mode_label(
+ next_autonomy_policy.non_cli_natural_language_approval_mode
+ ),
+ non_cli_excluded_tools_count = next_autonomy_policy.non_cli_excluded_tools.len(),
+ perplexity_filter_enabled = next_autonomy_policy.perplexity_filter.enable_perplexity_filter,
+ perplexity_threshold = next_autonomy_policy.perplexity_filter.perplexity_threshold,
"Applied updated channel runtime config from disk"
);
@@ -725,6 +1543,33 @@ fn get_route_selection(ctx: &ChannelRuntimeContext, sender_key: &str) -> Channel
.unwrap_or_else(|| default_route_selection(ctx))
}
+/// Classify a user message and return the appropriate route selection with logging.
+/// Returns None if classification is disabled or no rules match.
+fn classify_message_route(
+ ctx: &ChannelRuntimeContext,
+ message: &str,
+) -> Option {
+ let decision =
+ crate::agent::classifier::classify_with_decision(&ctx.query_classification, message)?;
+
+ // Find the matching model route
+ let route = ctx.model_routes.iter().find(|r| r.hint == decision.hint)?;
+
+ tracing::info!(
+ target: "query_classification",
+ hint = %decision.hint,
+ model = %route.model,
+ rule_priority = decision.priority,
+ message_length = message.len(),
+ "Classified message route"
+ );
+
+ Some(ChannelRouteSelection {
+ provider: route.provider.clone(),
+ model: route.model.clone(),
+ })
+}
+
fn set_route_selection(ctx: &ChannelRuntimeContext, sender_key: &str, next: ChannelRouteSelection) {
let default_route = default_route_selection(ctx);
let mut routes = ctx
@@ -847,6 +1692,10 @@ fn is_context_window_overflow_error(err: &anyhow::Error) -> bool {
.any(|hint| lower.contains(hint))
}
+fn is_tool_iteration_limit_error(err: &anyhow::Error) -> bool {
+ crate::agent::loop_::is_tool_iteration_limit_error(err)
+}
+
fn load_cached_model_preview(workspace_dir: &Path, provider_name: &str) -> Vec {
let cache_path = workspace_dir.join("state").join(MODEL_CACHE_FILE);
let Ok(raw) = std::fs::read_to_string(cache_path) else {
@@ -945,6 +1794,19 @@ fn build_models_help_response(current: &ChannelRouteSelection, workspace_dir: &P
current.provider, current.model
);
response.push_str("\nSwitch model with `/model `.\n");
+ response.push_str("Request supervised tool approval with `/approve-request `.\n");
+ response.push_str("Request one-time all-tools approval with `/approve-all-once`.\n");
+ response.push_str("Confirm approval with `/approve-confirm `.\n");
+ response.push_str("Deny approval with `/approve-deny `.\n");
+ response.push_str("List pending requests with `/approve-pending`.\n");
+ response.push_str("Approve supervised tools with `/approve `.\n");
+ response.push_str("Revoke approval with `/unapprove `.\n");
+ response.push_str("List approval state with `/approvals`.\n");
+ response.push_str(
+ "Natural language also works (policy controlled).\n\
+ - `direct` mode (default): `授权工具 shell` grants immediately.\n\
+ - `request_confirm` mode: `授权工具 shell` then `确认授权 apr-xxxxxx`.\n",
+ );
let cached_models = load_cached_model_preview(workspace_dir, ¤t.provider);
if cached_models.is_empty() {
@@ -976,6 +1838,19 @@ fn build_providers_help_response(current: &ChannelRouteSelection) -> String {
);
response.push_str("\nSwitch provider with `/models `.\n");
response.push_str("Switch model with `/model `.\n\n");
+ response.push_str("Request supervised tool approval with `/approve-request