mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 08:53:09 +00:00
Compare commits
9
Commits
dev-zx-windows
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32a4fbf525 | ||
|
|
db82834c1c | ||
|
|
b780c46be3 | ||
|
|
b99b136c2a | ||
|
|
d4060c6dcc | ||
|
|
df53de94df | ||
|
|
d5168842de | ||
|
|
68705d96de | ||
|
|
d4c8ae5f77 |
@@ -87,3 +87,4 @@ resources/openclaw-plugins/skillshub/
|
||||
|
||||
.opencode
|
||||
.superpowers
|
||||
.playwright-mcp
|
||||
+4
-2
@@ -135,6 +135,8 @@ ClawXを初めて起動すると、**セットアップウィザード**が次
|
||||
サポートされている場合、ウィザードはシステム言語を初期選択し、対応していない場合は英語にフォールバックします。
|
||||
|
||||
> Web検索について:ClawXはAgentとGatewayの両方のポリシーレイヤーで、OpenClawの汎用 `web_search` ツールを無効にします。Moonshot(Kimi)検索も対象です。管理対象のブラウザ自動化と `web_fetch` は引き続き利用できます。
|
||||
>
|
||||
> 内部ツールについて:ClawXは両方のポリシーレイヤーで、Agentに対して `gateway`、`nodes`、`create_goal`、`get_goal`、`update_goal` も無効にします。ClawXアプリケーション自身のGateway RPCに加え、メッセージング、セッションオーケストレーション、Agent検出ツールは引き続き利用できます。
|
||||
|
||||
### プロキシ設定
|
||||
|
||||
@@ -149,8 +151,8 @@ ClawXには、Electron、OpenClaw Gateway、Telegramなどのチャネルがロ
|
||||
ClawXは **Host API統一レイヤーを備えたデュアルプロセスアーキテクチャ**を採用しています。React Rendererは単一のクライアント抽象を呼び出し、Electron Mainがプロトコル選択、Gatewayのライフサイクル、ACP Chatのstdio bridgeを管理します。
|
||||
|
||||
- **プロセスモデル**:Electron Mainがウィンドウ、Gateway監視、システム統合、更新を管理します。OpenClaw GatewayはAIオーケストレーション、チャネル、スキル機能を提供し、Rendererはローカルエンドポイントへ直接アクセスしません。
|
||||
- **設定の配信**:Gateway実行中は `config.get` / `config.set` を使い、停止中または起動中は解決済みJSON5設定を更新します。通常のプロバイダー、Agent、スキル、モデル変更ではプロセスを置き換えず、認証情報は `secrets.reload` でホットリロードされます。ハートビートが10回連続で失敗した場合は、ライフサイクルで保護された復旧を要求します。
|
||||
- **ACP Chat**:Chatは [ACP(Agent Client Protocol)](https://agentclientprotocol.com) をMainが所有するstdio bridge経由で使用し、設定リロード後の認証済み履歴リプレイ、ページ移動中のストリーミング、Mainが検証したメディア・添付ファイル・ファイルアクティビティに対応します。
|
||||
- **設定の配信**:Gateway実行中は `config.get` / `config.set` を使い、停止中または起動中は解決済みJSON5設定を更新します。通常のプロバイダー、Agent、スキル、モデル変更ではプロセスを置き換えず、認証情報は `secrets.reload` でホットリロードされます。ハートビートが4回連続で失敗した場合は、ライフサイクルで保護された復旧を要求します。
|
||||
- **ACP Chat**:Chat UIは [ACP(Agent Client Protocol)](https://agentclientprotocol.com) を介してOpenClawとやり取りし、頻繁に反復されるOpenClawの前に比較的安定したチャットプロトコル面を確保します。ACPはMainが所有するstdio bridge経由で動作し、設定リロード後の認証済み履歴リプレイ、ページ移動中のストリーミング、Mainが検証したメディア・添付ファイル・ファイルアクティビティに対応します。保護されたGateway再起動によって受理済みターンが中断された場合、パッチ済みOpenClawランタイムは復旧runを元のACP promptへ明示的に関連付け、後続のテキストとツールアクティビティを同じメモリ内ターンで継続します。その後の履歴リプレイでも、永続化されたツール境界をネイティブACP updateとして復元します。
|
||||
- **設計原則**:フロントエンドの単一入口、Mainによるトランスポート管理、再接続・タイムアウト・バックオフによるグレースフルリカバリ、安全なストレージ、CORSセーフな境界を採用しています。
|
||||
|
||||
> プロセス図、設定の調整、ACPファイルアクティビティのセマンティクス、Gatewayのトラブルシューティングについては [docs/ja-JP/architecture.md](docs/ja-JP/architecture.md) を参照してください。
|
||||
|
||||
@@ -135,6 +135,8 @@ When you launch ClawX for the first time, the **Setup Wizard** will guide you th
|
||||
The wizard preselects your system language when it is supported, and falls back to English otherwise.
|
||||
|
||||
> Web search note: ClawX disables OpenClaw's general-purpose `web_search` tool at both the agent and Gateway policy layers. This includes Moonshot (Kimi) search; managed browser automation and `web_fetch` remain available.
|
||||
>
|
||||
> Internal tool note: ClawX also disables `gateway`, `nodes`, `create_goal`, `get_goal`, and `update_goal` for agents at both policy layers. Application-owned Gateway RPCs remain available, as do messaging, session orchestration, and agent discovery tools.
|
||||
|
||||
### Proxy Settings
|
||||
|
||||
@@ -149,8 +151,8 @@ Open **Settings -> Gateway -> Proxy** to configure the default proxy, bypass rul
|
||||
ClawX uses a **dual-process architecture with a unified Host API layer**: the React renderer calls one client abstraction, while Electron Main owns protocol selection, Gateway lifecycle, and the ACP Chat stdio bridge.
|
||||
|
||||
- **Process model**: Electron Main owns the window, Gateway supervision, system integration, and updates; the OpenClaw Gateway provides AI orchestration, channel, and skill capabilities; the renderer does not access local endpoints directly.
|
||||
- **Configuration delivery**: Main uses `config.get`/`config.set` while the Gateway is running and updates the resolved JSON5 config while it is stopped or starting; ordinary provider, agent, skill, and model changes do not replace the process, credentials are hot-reloaded through `secrets.reload`, and guarded recovery starts after ten consecutive heartbeat misses.
|
||||
- **ACP Chat**: Chat uses [ACP (Agent Client Protocol)](https://agentclientprotocol.com) through a Main-owned stdio bridge, supporting authenticated history replay after config reloads, streaming across navigation, and Main-validated media, attachments, and file activity.
|
||||
- **Configuration delivery**: Main uses `config.get`/`config.set` while the Gateway is running and updates the resolved JSON5 config while it is stopped or starting; ordinary provider, agent, skill, and model changes do not replace the process, credentials are hot-reloaded through `secrets.reload`, and guarded recovery starts after four consecutive heartbeat misses.
|
||||
- **ACP Chat**: Chat UI talks to OpenClaw via [ACP (Agent Client Protocol)](https://agentclientprotocol.com), providing a relatively stable chat protocol surface in front of the rapidly iterating OpenClaw. ACP runs through a Main-owned stdio bridge, supporting authenticated history replay after config reloads, streaming across navigation, and Main-validated media, attachments, and file activity. When a guarded Gateway restart interrupts an accepted turn, the patched OpenClaw runtime explicitly links its recovery run to the original ACP prompt so subsequent text and tool activity continue in the same in-memory turn; later history replay restores persisted tool boundaries as native ACP updates.
|
||||
- **Design principles**: One frontend entry point, Main-owned transport, graceful recovery with reconnect/timeout/backoff, secure storage, and CORS-safe boundaries.
|
||||
|
||||
> For the process diagram, configuration coordination, ACP file activity semantics, and Gateway troubleshooting, see [docs/en-US/architecture.md](docs/en-US/architecture.md).
|
||||
|
||||
+4
-2
@@ -135,6 +135,8 @@ pnpm dev
|
||||
Мастер предварительно выбирает системный язык, если он поддерживается, иначе переключается на английский.
|
||||
|
||||
> Примечание о веб-поиске: ClawX отключает универсальный инструмент OpenClaw `web_search` на уровнях политик агента и Gateway. Это также относится к поиску Moonshot (Kimi); управляемая автоматизация браузера и `web_fetch` остаются доступными.
|
||||
>
|
||||
> Примечание о внутренних инструментах: ClawX также отключает для агентов `gateway`, `nodes`, `create_goal`, `get_goal` и `update_goal` на обоих уровнях политик. RPC Gateway самого приложения ClawX, а также инструменты сообщений, оркестрации сессий и обнаружения агентов остаются доступными.
|
||||
|
||||
### Настройки прокси
|
||||
|
||||
@@ -149,8 +151,8 @@ ClawX включает встроенные настройки прокси дл
|
||||
ClawX использует **двухпроцессную архитектуру с унифицированным уровнем Host API**: React Renderer обращается к единой абстракции клиента, а Electron Main управляет выбором протокола, жизненным циклом Gateway и stdio bridge для ACP Chat.
|
||||
|
||||
- **Модель процессов**: Electron Main управляет окном, наблюдением за Gateway, системной интеграцией и обновлениями; OpenClaw Gateway предоставляет возможности AI-оркестрации, каналов и навыков; Renderer не обращается к локальным эндпоинтам напрямую.
|
||||
- **Доставка конфигурации**: изменения среды выполнения используют авторитетный снимок `config.set`, поэтому обычные изменения провайдера, агента, навыка и модели не заменяют процесс Gateway; учётные данные обновляются без перезапуска через `secrets.reload`.
|
||||
- **ACP Chat**: Chat использует [ACP (Agent Client Protocol)](https://agentclientprotocol.com) через stdio bridge под управлением Main, поддерживая аутентифицированное воспроизведение истории после перезагрузки конфигурации, потоковую выдачу при навигации и медиа, вложения и файловые операции, проверенные Main.
|
||||
- **Доставка конфигурации**: изменения среды выполнения используют авторитетный снимок `config.set`, поэтому обычные изменения провайдера, агента, навыка и модели не заменяют процесс Gateway; учётные данные обновляются без перезапуска через `secrets.reload`, а защищённое восстановление запускается после четырёх последовательных пропусков heartbeat.
|
||||
- **ACP Chat**: Chat UI взаимодействует с OpenClaw через [ACP (Agent Client Protocol)](https://agentclientprotocol.com), обеспечивая относительно стабильную поверхность чат-протокола поверх часто итерируемого OpenClaw. ACP работает через stdio bridge под управлением Main, поддерживая аутентифицированное воспроизведение истории после перезагрузки конфигурации, потоковую выдачу при навигации, а также медиа, вложения и файловые операции, проверенные Main. Если защищённый перезапуск Gateway прерывает уже принятый ход, исправленная среда OpenClaw явно связывает восстановительный run с исходным ACP prompt, чтобы последующий текст и активность инструментов продолжались в том же ходе в памяти; последующее воспроизведение истории восстанавливает сохранённые границы инструментов как нативные обновления ACP.
|
||||
- **Принципы проектирования**: единая точка входа фронтенда, транспорт под управлением Main, корректное восстановление с переподключением/таймаутом/повтором, безопасное хранение и границы, защищённые от CORS.
|
||||
|
||||
> Схема процессов, координация конфигурации, семантика файловых операций ACP и устранение неполадок Gateway описаны в [docs/ru-RU/architecture.md](docs/ru-RU/architecture.md).
|
||||
|
||||
+4
-2
@@ -132,6 +132,8 @@ pnpm dev
|
||||
|
||||
> Web search 说明:ClawX 会在 Agent 和 Gateway 两层策略中禁用 OpenClaw 的通用 `web_search` 工具。
|
||||
> 这也包括 Moonshot(Kimi)搜索;受管浏览器自动化和 `web_fetch` 仍然可用。
|
||||
>
|
||||
> 内部工具说明:ClawX 还会在两层策略中对 Agent 禁用 `gateway`、`nodes`、`create_goal`、`get_goal` 和 `update_goal`。ClawX 应用自身的 Gateway RPC 不受影响,消息、会话编排和 Agent 发现工具仍然可用。
|
||||
|
||||
### 代理设置
|
||||
|
||||
@@ -150,8 +152,8 @@ ClawX 内置了代理设置,适用于需要通过本地代理客户端访问
|
||||
ClawX 采用 **双进程 + Host API 统一接入架构**:React 渲染进程只通过统一的 host-api/api-client 抽象与后端交互,协议选择、Gateway 生命周期与 ACP Chat stdio bridge 全部由 Electron 主进程统一管理。
|
||||
|
||||
- **进程模型**:Electron 主进程负责窗口、网关进程监控、系统集成与自动更新;OpenClaw Gateway 作为独立运行时进程提供 AI 编排、频道和技能能力;渲染层不直接访问本地端点。
|
||||
- **配置交付**:Gateway 运行时由 Main 使用 `config.get` / `config.set`,停止或启动中则更新解析后的 JSON5 配置;普通 Provider/Agent/Skill/模型修改不会替换进程,凭据通过 `secrets.reload` 热更新;连续 10 次心跳无响应后才会请求受生命周期保护的自动恢复。
|
||||
- **ACP Chat**:Chat UI 基于 ACP ([Agent Client Protocol](https://agentclientprotocol.com)) 与 OpenClaw 交互,从而在高速迭代的 OpenClaw 前找到相对稳定的聊天协议面。ACP 走 Main 持有的 stdio bridge,支持配置热重载后的历史回放认证、跨页面持续流式输出,以及由 Main 验证和加载的媒体/附件/文件活动(Changes)展示。
|
||||
- **配置交付**:Gateway 运行时由 Main 使用 `config.get` / `config.set`,停止或启动中则更新解析后的 JSON5 配置;普通 Provider/Agent/Skill/模型修改不会替换进程,凭据通过 `secrets.reload` 热更新;连续 4 次心跳无响应后才会请求受生命周期保护的自动恢复。
|
||||
- **ACP Chat**:Chat UI 基于 ACP ([Agent Client Protocol](https://agentclientprotocol.com)) 与 OpenClaw 交互,从而在高速迭代的 OpenClaw 前找到相对稳定的聊天协议面。ACP 走 Main 持有的 stdio bridge,支持配置热重载后的历史回放认证、跨页面持续流式输出,以及由 Main 验证和加载的媒体/附件/文件活动(Changes)展示。当受保护的 Gateway 重启中断已接收的对话轮次时,补丁后的 OpenClaw 运行时会将恢复 run 显式关联到原 ACP prompt,使后续文本和工具活动继续进入同一个内存轮次;之后的历史回放也会以原生 ACP 更新恢复持久化的工具边界。
|
||||
- **设计原则**:前端调用单一入口、Main 掌控传输策略、优雅恢复(重连/超时/退避)、安全存储与 CORS 安全。
|
||||
|
||||
> 完整架构说明(进程图、配置协调、ACP 文件活动语义与 Gateway 排障)请参阅 [docs/zh-CN/architecture.md](docs/zh-CN/architecture.md)。
|
||||
|
||||
@@ -4,9 +4,9 @@ This document provides the detailed version of the Architecture section in the R
|
||||
|
||||
ClawX uses a **dual-process architecture with a unified Host API layer**. The renderer calls one client abstraction, while protocol selection and process lifecycle are managed by Electron Main:
|
||||
|
||||
OpenClaw configuration delivery is also managed by Electron Main. While the Gateway is running, ClawX uses the authoritative snapshot returned by `config.get` as its baseline and commits changes with `config.set`. While the Gateway is stopped or starting, the same coordinator updates the resolved JSON5 configuration file without starting the Gateway. Ordinary provider, agent, channel, binding, skill, and model changes therefore do not replace the Gateway process. Full restarts are reserved for process-launch environment changes such as proxy settings and explicit user actions. Confirmed process exits and WebSocket closes retain their existing automatic reconnect paths. The first nine consecutive WebSocket heartbeat misses remain diagnostic-only so brief pong delays do not interrupt long-running work; a pong or any incoming message resets the count, while a tenth consecutive miss requests guarded automatic Gateway recovery when the lifecycle is in an auto-recoverable running state. After authentication configuration is written to SQLite, ClawX calls OpenClaw's `secrets.reload` so running agents can read new credentials without a process restart.
|
||||
OpenClaw configuration delivery is also managed by Electron Main. While the Gateway is running, ClawX uses the authoritative snapshot returned by `config.get` as its baseline and commits changes with `config.set`. While the Gateway is stopped or starting, the same coordinator updates the resolved JSON5 configuration file without starting the Gateway. Ordinary provider, agent, channel, binding, skill, and model changes therefore do not replace the Gateway process. Full restarts are reserved for process-launch environment changes such as proxy settings and explicit user actions. Confirmed process exits and WebSocket closes retain their existing automatic reconnect paths. The first three consecutive WebSocket heartbeat misses remain diagnostic-only so brief pong delays do not interrupt long-running work; a pong or any incoming message resets the count, while a fourth consecutive miss requests guarded automatic Gateway recovery when the lifecycle is in an auto-recoverable running state. After authentication configuration is written to SQLite, ClawX calls OpenClaw's `secrets.reload` so running agents can read new credentials without a process restart.
|
||||
|
||||
Chat uses an ACP stdio bridge owned by Electron Main. Main passes the same app-managed Gateway token to this local child through its private process environment, so ACP history replay remains authenticated when the runtime configuration reloads. The renderer receives typed host events and renders an in-memory ACP timeline. The Gateway remains responsible for non-Chat capabilities such as providers, models, skills, workspace, settings, diagnostics, and media configuration.
|
||||
Chat uses an ACP stdio bridge owned by Electron Main. Main passes the same app-managed Gateway token to this local child through its private process environment, so ACP history replay remains authenticated when the runtime configuration reloads. If guarded Gateway recovery interrupts an accepted main-session run, the patched OpenClaw runtime starts a distinct recovery run carrying the interrupted run id as explicit lineage. Chat and agent events preserve that lineage; the reconnecting ACP bridge adopts the new run for its pending prompt, resets per-run stream cursors, and subscribes to session-scoped tool events. The renderer remains unaware of Gateway runtime identity and continues to receive typed host events for one in-memory ACP timeline. The Gateway remains responsible for non-Chat capabilities such as providers, models, skills, workspace, settings, diagnostics, and media configuration.
|
||||
|
||||
### ACP Semantic Authority
|
||||
|
||||
@@ -16,7 +16,7 @@ A bypass is allowed only when upstream ACP has no equivalent. Such a compatibili
|
||||
|
||||
### ACP History Authority and Bounded Transcript Supplements
|
||||
|
||||
ACP `session/load` replay is the primary authority for Chat history. ClawX does not persist a second ACP ledger, reduced timeline, replay cache, or reconstructed tool history. Some OpenClaw capabilities do not yet have fully corresponding ACP implementations; for example, assistant media may be omitted from ACP and Gateway processing may remove assistant `MEDIA:` directives from the visible live reply. ClawX therefore keeps only bounded, marked, memory-only compatibility supplements:
|
||||
ACP `session/load` replay is the primary authority for Chat history. ClawX does not persist a second ACP ledger, reduced timeline, replay cache, or reconstructed tool history. If OpenClaw's structured ACP event ledger is unavailable, its ACP adapter reconstructs persisted transcript `toolCall` and `toolResult` records as native tool updates in transcript order, preserving text-tool-text boundaries; ClawX does not infer those records itself. Some OpenClaw capabilities do not yet have fully corresponding ACP implementations; for example, assistant media may be omitted from ACP and Gateway processing may remove assistant `MEDIA:` directives from the visible live reply. ClawX therefore keeps only bounded, marked, memory-only compatibility supplements:
|
||||
|
||||
- Asynchronous image-generation completions may be restored only when the same session has proven `image_generate` context and the completion evidence is trusted or approved transcript evidence.
|
||||
- General attachments may be recovered from canonical persisted assistant `__openclaw.media` facts or explicit line-leading assistant `MEDIA:` directives. This recovers attachment references and declared metadata, not the surrounding assistant message.
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
ClawXは **統合Host APIレイヤーを備えたデュアルプロセスアーキテクチャ**を採用しています。Rendererは単一のクライアント抽象を呼び出し、プロトコル選択とプロセスライフサイクルはElectron Mainが管理します。
|
||||
|
||||
OpenClawの設定配信もElectron Mainが管理します。Gateway実行中は`config.get`が返す権威あるスナップショットを基準にし、変更を`config.set`でコミットします。Gatewayが停止中または起動中の場合は、同じコーディネーターが解決済みJSON5設定ファイルを更新しますが、これを理由にGatewayを起動することはありません。そのため、通常のプロバイダー、Agent、チャネル、バインディング、スキル、モデルの変更ではGatewayプロセスを置き換えません。完全な再起動は、プロキシなどのプロセス起動環境の変更と、ユーザーによる明示的な操作に限られます。確認済みのプロセス終了とWebSocket切断では、既存の自動再接続経路が引き続き使用されます。WebSocketハートビートの連続9回までの欠落は診断のみとし、短いpong遅延で長時間実行中の処理を中断しません。pongまたは任意の受信メッセージでカウントをリセットし、10回連続で欠落した場合に、ライフサイクルが自動復旧可能なrunning状態であれば、保護されたGateway自動復旧を要求します。認証設定をSQLiteへ書き込んだ後はOpenClawの`secrets.reload`を呼び出し、実行中のAgentがプロセス再起動なしで新しい認証情報を読み取れるようにします。
|
||||
OpenClawの設定配信もElectron Mainが管理します。Gateway実行中は`config.get`が返す権威あるスナップショットを基準にし、変更を`config.set`でコミットします。Gatewayが停止中または起動中の場合は、同じコーディネーターが解決済みJSON5設定ファイルを更新しますが、これを理由にGatewayを起動することはありません。そのため、通常のプロバイダー、Agent、チャネル、バインディング、スキル、モデルの変更ではGatewayプロセスを置き換えません。完全な再起動は、プロキシなどのプロセス起動環境の変更と、ユーザーによる明示的な操作に限られます。確認済みのプロセス終了とWebSocket切断では、既存の自動再接続経路が引き続き使用されます。WebSocketハートビートの連続3回までの欠落は診断のみとし、短いpong遅延で長時間実行中の処理を中断しません。pongまたは任意の受信メッセージでカウントをリセットし、4回連続で欠落した場合に、ライフサイクルが自動復旧可能なrunning状態であれば、保護されたGateway自動復旧を要求します。認証設定をSQLiteへ書き込んだ後はOpenClawの`secrets.reload`を呼び出し、実行中のAgentがプロセス再起動なしで新しい認証情報を読み取れるようにします。
|
||||
|
||||
ChatはElectron Mainが所有するACP stdio bridgeを使用します。Mainはアプリが管理するGateway tokenをプライベートなプロセス環境経由でローカルの子プロセスへ渡すため、ランタイム設定の再読み込み後もACP履歴リプレイの認証が維持されます。Rendererは型付きhost eventを受け取り、メモリ上のACP timelineを描画します。Gatewayはproviders、models、skills、workspace、settings、diagnostics、media configurationなどの非Chat機能を引き続き担当します。
|
||||
ChatはElectron Mainが所有するACP stdio bridgeを使用します。Mainはアプリが管理するGateway tokenをプライベートなプロセス環境経由でローカルの子プロセスへ渡すため、ランタイム設定の再読み込み後もACP履歴リプレイの認証が維持されます。保護されたGateway復旧が受理済みのメインセッションrunを中断した場合、パッチ済みOpenClawランタイムは別の復旧runを開始し、中断されたrun idを明示的なlineageとして保持します。Chat eventとagent eventはそのlineageを維持し、再接続したACP bridgeはpending promptを新しいrunへ引き継ぎ、run単位のストリームカーソルをリセットしてセッション単位のtool eventを購読します。RendererはGatewayランタイムの識別子を認識せず、型付きhost eventから同じメモリ内ACP timelineを描画し続けます。Gatewayはproviders、models、skills、workspace、settings、diagnostics、media configurationなどの非Chat機能を引き続き担当します。
|
||||
|
||||
### ACPのセマンティック権威
|
||||
|
||||
@@ -16,7 +16,7 @@ ACPが提供するすべてのChatの意味とコンテキストでは、`sessio
|
||||
|
||||
### ACP履歴の権威と有界なtranscript補足
|
||||
|
||||
ACP `session/load` のリプレイがChat履歴の主要な権威です。ClawXは第二のACP ledger、縮約timeline、リプレイキャッシュ、再構成したツール履歴を永続化しません。OpenClawの一部の機能にはまだ完全に対応するACP実装がありません。たとえば、assistantメディアがACPから省略されたり、Gateway処理によってassistantの`MEDIA:`ディレクティブが表示中のライブ返信から削除されたりする場合があります。そのため、ClawXは有界で印付き、メモリのみの互換性補足だけを保持します。
|
||||
ACP `session/load` のリプレイがChat履歴の主要な権威です。ClawXは第二のACP ledger、縮約timeline、リプレイキャッシュ、再構成したツール履歴を永続化しません。OpenClawの構造化ACP event ledgerが利用できない場合、そのACP adapterは永続化済みtranscriptの`toolCall`と`toolResult`を順番どおりにネイティブなtool updateへ再構成し、text-tool-textの境界を維持します。ClawX自身はこれらの記録を推論しません。OpenClawの一部の機能にはまだ完全に対応するACP実装がありません。たとえば、assistantメディアがACPから省略されたり、Gateway処理によってassistantの`MEDIA:`ディレクティブが表示中のライブ返信から削除されたりする場合があります。そのため、ClawXは有界で印付き、メモリのみの互換性補足だけを保持します。
|
||||
|
||||
- 非同期の画像生成完了は、同じセッションに確認済みの`image_generate`コンテキストがあり、完了の証拠が信頼できるか、承認済みのtranscript証拠である場合に限り復元できます。
|
||||
- 一般の添付ファイルは、永続化されたassistantの`__openclaw.media`事実、または行頭にある明示的なassistant `MEDIA:`ディレクティブから復元できます。復元されるのは添付ファイルの参照と宣言されたメタデータだけで、周囲のassistantメッセージは復元しません。
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
ClawX использует **двухпроцессную архитектуру с унифицированным уровнем Host API**. Renderer обращается к единой абстракции клиента, а Electron Main управляет выбором протокола и жизненным циклом процессов.
|
||||
|
||||
Доставка конфигурации OpenClaw также управляется Electron Main. Когда Gateway запущен, ClawX использует авторитетный снимок из `config.get` как основу и применяет изменения через `config.set`. Когда Gateway остановлен или запускается, тот же координатор обновляет разрешённый JSON5-файл конфигурации, не запуская Gateway из-за этого обновления. Поэтому обычные изменения провайдера, агента, канала, привязки, навыка и модели не заменяют процесс Gateway. Полные перезапуски остаются только для изменений среды запуска процесса, например прокси, и явных действий пользователя. Подтверждённые завершения процесса и закрытия WebSocket используют существующие пути автоматического переподключения. Первые девять последовательных пропусков WebSocket heartbeat являются только диагностикой, поэтому краткая задержка pong не прерывает долгую операцию; pong или любое входящее сообщение сбрасывает счётчик, а при десятом последовательном пропуске запрашивается защищённое автоматическое восстановление Gateway, если его жизненный цикл находится в состоянии running с разрешённым автовосстановлением. После записи конфигурации аутентификации в SQLite ClawX вызывает `secrets.reload` OpenClaw, чтобы работающие агенты получили новые учётные данные без перезапуска процесса.
|
||||
Доставка конфигурации OpenClaw также управляется Electron Main. Когда Gateway запущен, ClawX использует авторитетный снимок из `config.get` как основу и применяет изменения через `config.set`. Когда Gateway остановлен или запускается, тот же координатор обновляет разрешённый JSON5-файл конфигурации, не запуская Gateway из-за этого обновления. Поэтому обычные изменения провайдера, агента, канала, привязки, навыка и модели не заменяют процесс Gateway. Полные перезапуски остаются только для изменений среды запуска процесса, например прокси, и явных действий пользователя. Подтверждённые завершения процесса и закрытия WebSocket используют существующие пути автоматического переподключения. Первые три последовательных пропуска WebSocket heartbeat являются только диагностикой, поэтому краткая задержка pong не прерывает долгую операцию; pong или любое входящее сообщение сбрасывает счётчик, а при четвёртом последовательном пропуске запрашивается защищённое автоматическое восстановление Gateway, если его жизненный цикл находится в состоянии running с разрешённым автовосстановлением. После записи конфигурации аутентификации в SQLite ClawX вызывает `secrets.reload` OpenClaw, чтобы работающие агенты получили новые учётные данные без перезапуска процесса.
|
||||
|
||||
Chat использует ACP stdio bridge, принадлежащий Electron Main. Main передаёт тому же локальному дочернему процессу управляемый приложением Gateway token через приватное окружение процесса, поэтому после перезагрузки конфигурации среды выполнения воспроизведение истории ACP остаётся аутентифицированным. Renderer получает типизированные host events и отображает находящуюся в памяти ACP timeline. Gateway продолжает отвечать за возможности вне Chat: providers, models, skills, workspace, settings, diagnostics и media configuration.
|
||||
Chat использует ACP stdio bridge, принадлежащий Electron Main. Main передаёт тому же локальному дочернему процессу управляемый приложением Gateway token через приватное окружение процесса, поэтому после перезагрузки конфигурации среды выполнения воспроизведение истории ACP остаётся аутентифицированным. Если защищённое восстановление Gateway прерывает принятый run основной сессии, исправленная среда OpenClaw запускает отдельный восстановительный run с явной ссылкой на id прерванного run. События Chat и agent сохраняют эту связь; переподключившийся ACP bridge принимает новый run для ожидающего prompt, сбрасывает потоковые курсоры run и подписывается на события инструментов сессии. Renderer не использует идентификатор среды Gateway и продолжает строить одну ACP timeline в памяти из типизированных host events. Gateway продолжает отвечать за возможности вне Chat: providers, models, skills, workspace, settings, diagnostics и media configuration.
|
||||
|
||||
### Семантический авторитет ACP
|
||||
|
||||
@@ -16,7 +16,7 @@ ACP является предпочтительным семантическим
|
||||
|
||||
### Авторитет истории ACP и ограниченные дополнения из transcript
|
||||
|
||||
Воспроизведение ACP `session/load` является главным источником истории Chat. ClawX не сохраняет второй ACP ledger, сокращённую timeline, кэш воспроизведения или восстановленную историю инструментов. Некоторые возможности OpenClaw пока не имеют полного соответствия в ACP. Например, media assistant может отсутствовать в ACP, а обработка Gateway может удалять директивы assistant `MEDIA:` из видимого потокового ответа. Поэтому ClawX хранит только ограниченные, помеченные дополнения совместимости в памяти:
|
||||
Воспроизведение ACP `session/load` является главным источником истории Chat. ClawX не сохраняет второй ACP ledger, сокращённую timeline, кэш воспроизведения или восстановленную историю инструментов. Если структурированный ACP event ledger OpenClaw недоступен, его ACP adapter преобразует сохранённые записи transcript `toolCall` и `toolResult` в нативные обновления инструментов в исходном порядке, сохраняя границы text-tool-text; сам ClawX эти записи не выводит. Некоторые возможности OpenClaw пока не имеют полного соответствия в ACP. Например, media assistant может отсутствовать в ACP, а обработка Gateway может удалять директивы assistant `MEDIA:` из видимого потокового ответа. Поэтому ClawX хранит только ограниченные, помеченные дополнения совместимости в памяти:
|
||||
|
||||
- Асинхронное завершение генерации изображения можно восстановить только при наличии подтверждённого контекста `image_generate` в той же сессии и доверенного либо разрешённого transcript-доказательства.
|
||||
- Обычные вложения можно восстановить из канонических сохранённых фактов assistant `__openclaw.media` или явных директив assistant `MEDIA:` в начале строки. Восстанавливаются только ссылки на вложения и объявленные метаданные, но не окружающее сообщение assistant.
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用统一客户端抽象,协议选择与进程生命周期由 Electron 主进程统一管理:
|
||||
|
||||
OpenClaw 配置交付也统一由 Electron Main 管理。Gateway 运行时,ClawX 以 `config.get` 返回的权威快照为基线,并通过 `config.set` 提交修改;Gateway 停止或启动中时,同一个协调器只更新解析后的 JSON5 配置文件,不会因此启动 Gateway。因此,普通的 Provider、Agent、Channel、绑定、Skill 和模型修改不会替换 Gateway 进程。完整重启仅保留给代理等进程启动环境变化和用户显式操作。已确认的进程退出与 WebSocket 关闭继续使用现有的自动重连路径。连续前 9 次 WebSocket 心跳无响应只更新诊断,不会因短暂的 pong 延迟中断长时间运行的任务;收到 pong 或任意消息会重置计数,连续第 10 次无响应时,只有在生命周期处于可自动恢复的 running 状态时,才会请求受保护的 Gateway 自动恢复。认证配置写入 SQLite 后,ClawX 会调用 OpenClaw 的 `secrets.reload`,让运行中的 Agent 无需重启即可读取新凭据。
|
||||
OpenClaw 配置交付也统一由 Electron Main 管理。Gateway 运行时,ClawX 以 `config.get` 返回的权威快照为基线,并通过 `config.set` 提交修改;Gateway 停止或启动中时,同一个协调器只更新解析后的 JSON5 配置文件,不会因此启动 Gateway。因此,普通的 Provider、Agent、Channel、绑定、Skill 和模型修改不会替换 Gateway 进程。完整重启仅保留给代理等进程启动环境变化和用户显式操作。已确认的进程退出与 WebSocket 关闭继续使用现有的自动重连路径。连续前 3 次 WebSocket 心跳无响应只更新诊断,不会因短暂的 pong 延迟中断长时间运行的任务;收到 pong 或任意消息会重置计数,连续第 4 次无响应时,只有在生命周期处于可自动恢复的 running 状态时,才会请求受保护的 Gateway 自动恢复。认证配置写入 SQLite 后,ClawX 会调用 OpenClaw 的 `secrets.reload`,让运行中的 Agent 无需重启即可读取新凭据。
|
||||
|
||||
Chat 使用由 Electron Main 持有的 ACP stdio bridge。Main 通过私有进程环境把同一份应用管理的 Gateway token 传给本地子进程,因此运行时配置重载后 ACP 历史回放仍能完成认证。Renderer 接收类型化 host events,并渲染内存中的 ACP timeline。Gateway 仍负责 providers、models、skills、workspace、settings、diagnostics 和 media configuration 等非 Chat 能力。
|
||||
Chat 使用由 Electron Main 持有的 ACP stdio bridge。Main 通过私有进程环境把同一份应用管理的 Gateway token 传给本地子进程,因此运行时配置重载后 ACP 历史回放仍能完成认证。如果受保护的 Gateway 恢复中断了已接收的主会话 run,补丁后的 OpenClaw 运行时会启动独立的恢复 run,并显式携带被中断 run id 作为 lineage。Chat 和 agent events 会保留该 lineage;重连后的 ACP bridge 据此将 pending prompt 接续到新 run,重置该 run 的流式游标,并订阅会话级 tool events。Renderer 不感知 Gateway 运行实例身份,仍通过类型化 host events 渲染同一个内存 ACP timeline。Gateway 继续负责 providers、models、skills、workspace、settings、diagnostics 和 media configuration 等非 Chat 能力。
|
||||
|
||||
### ACP 语义权威
|
||||
|
||||
@@ -16,7 +16,7 @@ Chat 使用由 Electron Main 持有的 ACP stdio bridge。Main 通过私有进
|
||||
|
||||
### ACP 历史权威与有界 transcript 补充
|
||||
|
||||
ACP `session/load` 回放是 Chat 历史的首要事实来源。ClawX 不会持久化第二套 ACP ledger、精简 timeline、回放缓存或重建的工具历史。OpenClaw 的部分能力目前还没有完全对应的 ACP 实现;例如,assistant 媒体可能不会出现在 ACP 中,Gateway 处理也可能从可见的实时回复中移除 assistant `MEDIA:` 指令。因此,ClawX 只保留有界、带标记、仅存于内存的兼容性补充路径:
|
||||
ACP `session/load` 回放是 Chat 历史的首要事实来源。ClawX 不会持久化第二套 ACP ledger、精简 timeline、回放缓存或重建的工具历史。当 OpenClaw 的结构化 ACP event ledger 不可用时,其 ACP adapter 会按 transcript 顺序把持久化的 `toolCall` 和 `toolResult` 记录重建为原生工具更新,并保留 text-tool-text 边界;ClawX 本身不会推断这些记录。OpenClaw 的部分能力目前还没有完全对应的 ACP 实现;例如,assistant 媒体可能不会出现在 ACP 中,Gateway 处理也可能从可见的实时回复中移除 assistant `MEDIA:` 指令。因此,ClawX 只保留有界、带标记、仅存于内存的兼容性补充路径:
|
||||
|
||||
- 只有在同一 session 中存在已确认的 `image_generate` 上下文,且完成证据可信或来自获准 transcript 证据时,才可以恢复异步图像生成结果。
|
||||
- 普通附件可以从持久化的 assistant `__openclaw.media` 规范事实或明确的行首 assistant `MEDIA:` 指令中恢复。这只恢复附件引用和声明的元数据,不恢复周围的 assistant 消息。
|
||||
|
||||
@@ -70,6 +70,20 @@ function isBaseHashConflict(error: unknown): boolean {
|
||||
return /config changed since last load; re-run config\.get and retry/i.test(message);
|
||||
}
|
||||
|
||||
function isConfigSetResponseLost(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message.includes('RPC timeout: config.set')
|
||||
|| message.includes('Gateway stopped')
|
||||
|| message.includes('Gateway not connected')
|
||||
|| message.includes('Gateway service restart')
|
||||
|| message.includes('Failed to send RPC request:');
|
||||
}
|
||||
|
||||
async function acceptPersistedConfigSetCommitIfMatched(config: OpenClawConfig): Promise<boolean> {
|
||||
const persisted = await readFileConfig(resolveOpenClawConfigPath());
|
||||
return isDeepStrictEqual(persisted.config, config);
|
||||
}
|
||||
|
||||
async function mutateRunningConfig(
|
||||
manager: ConfigDeliveryGatewayManager,
|
||||
mutator: OpenClawConfigMutator,
|
||||
@@ -92,6 +106,14 @@ async function mutateRunningConfig(
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (attempt === 0 && isBaseHashConflict(error)) continue;
|
||||
|
||||
// config.set may durably replace the file and then close the socket with
|
||||
// code 1012 before its RPC response reaches ClawX. Reconnect can restore
|
||||
// running state before the RPC timeout fires, so verify the persisted
|
||||
// snapshot whenever the response was lost instead of only while stopped.
|
||||
if (manager.getStatus().state !== 'running' || isConfigSetResponseLost(error)) {
|
||||
if (await acceptPersistedConfigSetCommitIfMatched(config)) return true;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,10 @@ import { prependPathEntry } from '../utils/env-path';
|
||||
import { copyPluginFromNodeModules, fixupPluginManifest, cpSyncSafe, buildCandidateSources, repairTrustedOfficialPluginInstallRecords, removeTrustedOfficialPluginInstallRecord, resolvePluginNpmPackagePath } from '../utils/plugin-install';
|
||||
import { safeRmSync } from '../utils/safe-fs';
|
||||
import { CLAWX_OPENAI_IMAGE_PROVIDER_KEY } from '../utils/openclaw-image-relay-constants';
|
||||
import { ensureOpenClaw2026_7_1UpgradeSnapshot } from '../utils/openclaw-upgrade-snapshot';
|
||||
import {
|
||||
ensureOpenClaw2026_7_1UpgradeSnapshot,
|
||||
quarantineLegacyUpdateCheckState,
|
||||
} from '../utils/openclaw-upgrade-snapshot';
|
||||
import { stripSystemdSupervisorEnv } from './config-sync-env';
|
||||
import { cleanupAgentsSymlinkedSkills, cleanupStalePluginRuntimeDeps } from './skills-symlink-cleanup';
|
||||
import {
|
||||
@@ -651,6 +654,19 @@ export async function prepareGatewayLaunchContext(port: number): Promise<Gateway
|
||||
}
|
||||
});
|
||||
|
||||
await measureAsync(timingsMs, 'legacyUpdateCheckCleanupMs', async () => {
|
||||
try {
|
||||
const cleanup = await quarantineLegacyUpdateCheckState();
|
||||
if (cleanup.status === 'quarantined') {
|
||||
logger.info(
|
||||
`[upgrade] Quarantined conflicting legacy update-check state: ${cleanup.sourcePath} → ${cleanup.backupPath}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('[upgrade] Failed to quarantine legacy update-check state:', error);
|
||||
}
|
||||
});
|
||||
|
||||
const appSettings = await measureAsync(timingsMs, 'settingsMs', getAllSettings);
|
||||
const prelaunchSummary = await measureAsync(timingsMs, 'prelaunchSyncMs', async () => (
|
||||
await syncGatewayConfigBeforeLaunch(appSettings, openclawDir)
|
||||
|
||||
@@ -196,7 +196,7 @@ export class GatewayManager extends EventEmitter {
|
||||
private reconnectSuccessTotal = 0;
|
||||
private static readonly HEARTBEAT_INTERVAL_MS = 60_000;
|
||||
private static readonly HEARTBEAT_TIMEOUT_MS = 30_000;
|
||||
private static readonly HEARTBEAT_MAX_MISSES = 10;
|
||||
private static readonly HEARTBEAT_MAX_MISSES = 4;
|
||||
public static readonly RESTART_COOLDOWN_MS = 5_000;
|
||||
private static readonly GATEWAY_READY_FALLBACK_PROBE_DELAYS_MS = [1_500, 3_000, 5_000, 8_000, 12_000, 30_000] as const;
|
||||
private lastRestartAt = 0;
|
||||
@@ -411,12 +411,28 @@ export class GatewayManager extends EventEmitter {
|
||||
tSpawned = Date.now();
|
||||
},
|
||||
waitForReady: async (port) => {
|
||||
const recoveringOwnedProcess = tSpawned === 0
|
||||
&& this.process?.pid != null
|
||||
&& this.ownsProcess;
|
||||
await waitForGatewayReady({
|
||||
port,
|
||||
getProcessExitCode: () => this.processExitCode,
|
||||
// A code-1012 in-process reload normally returns within seconds.
|
||||
// Do not hold the lifecycle lock for the general 2400-attempt cold
|
||||
// startup budget when the owned process is alive but no longer serves WS.
|
||||
...(recoveringOwnedProcess ? { retries: 50 } : {}),
|
||||
});
|
||||
tReady = Date.now();
|
||||
},
|
||||
terminateStaleOwnedProcess: async () => {
|
||||
const shouldReconnect = this.shouldReconnect;
|
||||
this.shouldReconnect = false;
|
||||
try {
|
||||
await this.forceTerminateOwnedProcessForQuit();
|
||||
} finally {
|
||||
this.shouldReconnect = shouldReconnect;
|
||||
}
|
||||
},
|
||||
onConnectedToManagedGateway: () => {
|
||||
this.startHealthCheck();
|
||||
const tConnected = Date.now();
|
||||
@@ -735,6 +751,10 @@ export class GatewayManager extends EventEmitter {
|
||||
logger.info('Gateway ready fallback RPC router probe succeeded');
|
||||
this.resetGatewayReadyFallback();
|
||||
this.setStatus({ gatewayReady: true });
|
||||
// A fast Gateway can emit gateway.ready before the WebSocket client is
|
||||
// attached. A successful router probe is equivalent readiness, so it
|
||||
// must also complete the one-time migration snapshot lifecycle.
|
||||
void this.cleanupOpenClawUpgradeSnapshot();
|
||||
}
|
||||
} catch (error) {
|
||||
this.capabilityMonitor.recordCoreProbe({
|
||||
@@ -1054,6 +1074,11 @@ export class GatewayManager extends EventEmitter {
|
||||
this.connectionMonitor.clear();
|
||||
this.recordSocketClose(closeCode);
|
||||
this.diagnostics.consecutiveHeartbeatMisses = 0;
|
||||
if (closeCode === 1012) {
|
||||
for (const id of [...this.pendingRequests.keys()]) {
|
||||
rejectPendingGatewayRequest(this.pendingRequests, id, new Error('Gateway service restart'));
|
||||
}
|
||||
}
|
||||
if (this.status.state === 'running') {
|
||||
this.setStatus({ state: 'stopped' });
|
||||
// On Windows, skip reconnect from WS close. The Gateway is a local
|
||||
|
||||
@@ -23,6 +23,7 @@ type StartupHooks = {
|
||||
waitForPortFree: (port: number) => Promise<void>;
|
||||
startProcess: () => Promise<void>;
|
||||
waitForReady: (port: number) => Promise<void>;
|
||||
terminateStaleOwnedProcess: () => Promise<void>;
|
||||
onConnectedToManagedGateway: () => void;
|
||||
runDoctorRepair: () => Promise<boolean>;
|
||||
onDoctorRepairSuccess: () => void;
|
||||
@@ -75,7 +76,13 @@ export async function runGatewayStartupSequence(hooks: StartupHooks): Promise<vo
|
||||
// become ready and reconnect to it.
|
||||
if (hooks.hasOwnedProcess()) {
|
||||
logger.info('Owned Gateway process still alive (likely in-process restart); waiting for it to become ready');
|
||||
await hooks.waitForReady(hooks.port);
|
||||
try {
|
||||
await hooks.waitForReady(hooks.port);
|
||||
} catch (error) {
|
||||
logger.warn('Owned Gateway process did not recover after an in-process restart; terminating the stale process');
|
||||
await hooks.terminateStaleOwnedProcess();
|
||||
throw new Error('Gateway process exited before becoming ready after an in-process restart', { cause: error });
|
||||
}
|
||||
hooks.assertLifecycle('start/wait-ready-owned');
|
||||
await connectWithStartupRetry(hooks, hooks.port);
|
||||
hooks.assertLifecycle('start/connect-owned');
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
ensureWeChatPluginInstalled,
|
||||
ensureWeComPluginInstalled,
|
||||
ensureWhatsAppPluginInstalled,
|
||||
type PluginInstallResult,
|
||||
} from '../utils/plugin-install';
|
||||
import {
|
||||
computeChannelRuntimeStatus,
|
||||
@@ -965,6 +966,43 @@ function emitChannelEvent(
|
||||
}
|
||||
}
|
||||
|
||||
const CHANNEL_PLUGIN_INSTALLERS: Record<
|
||||
string,
|
||||
() => MaybePromise<PluginInstallResult>
|
||||
> = {
|
||||
dingtalk: ensureDingTalkPluginInstalled,
|
||||
wecom: ensureWeComPluginInstalled,
|
||||
discord: ensureDiscordPluginInstalled,
|
||||
qqbot: ensureQQBotPluginInstalled,
|
||||
whatsapp: ensureWhatsAppPluginInstalled,
|
||||
feishu: ensureFeishuPluginInstalled,
|
||||
[OPENCLAW_WECHAT_CHANNEL_TYPE]: ensureWeChatPluginInstalled,
|
||||
};
|
||||
|
||||
function isPluginBackedChannel(storedChannelType: string): boolean {
|
||||
return Object.hasOwn(CHANNEL_PLUGIN_INSTALLERS, storedChannelType);
|
||||
}
|
||||
|
||||
function shouldRestartRunningGateway(ctx: ChannelsApiContext, storedChannelType: string): boolean {
|
||||
return isPluginBackedChannel(storedChannelType)
|
||||
&& ctx.gatewayManager.getStatus().state === 'running';
|
||||
}
|
||||
|
||||
function scheduleGatewayRestartForPluginChannel(
|
||||
ctx: ChannelsApiContext,
|
||||
storedChannelType: string,
|
||||
reason: 'noChange' | 'peerLinkRepairFailed' = 'noChange',
|
||||
): void {
|
||||
logger.info(
|
||||
`[channels.saveConfig] scheduling Gateway restart to activate plugin channel=${storedChannelType} reason=${reason}`,
|
||||
);
|
||||
// The config and scoped binding are already committed. Let the host request
|
||||
// return while the guarded lifecycle path performs stop/start/readiness.
|
||||
// GatewayManager owns error logging, status propagation, and restart
|
||||
// coalescing, so the Channels page can show the normal connecting state.
|
||||
ctx.gatewayManager.debouncedRestart(0);
|
||||
}
|
||||
|
||||
async function awaitWeChatQrLogin(
|
||||
ctx: ChannelsApiContext,
|
||||
sessionKey: string,
|
||||
@@ -991,8 +1029,12 @@ async function awaitWeChatQrLogin(
|
||||
baseUrl: result.baseUrl,
|
||||
userId: result.userId,
|
||||
});
|
||||
const restartGateway = shouldRestartRunningGateway(ctx, OPENCLAW_WECHAT_CHANNEL_TYPE);
|
||||
await saveChannelConfig(UI_WECHAT_CHANNEL_TYPE, { enabled: true }, normalizedAccountId);
|
||||
await ensureScopedChannelBinding(UI_WECHAT_CHANNEL_TYPE, normalizedAccountId);
|
||||
if (restartGateway) {
|
||||
scheduleGatewayRestartForPluginChannel(ctx, OPENCLAW_WECHAT_CHANNEL_TYPE);
|
||||
}
|
||||
|
||||
if (activeQrLogins.get(loginKey) !== sessionKey) return;
|
||||
emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'success', {
|
||||
@@ -1009,22 +1051,14 @@ async function awaitWeChatQrLogin(
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureChannelPluginInstalled(storedChannelType: string): Promise<void> {
|
||||
const installers: Record<string, () => MaybePromise<{ installed: boolean; warning?: string }>> = {
|
||||
dingtalk: ensureDingTalkPluginInstalled,
|
||||
wecom: ensureWeComPluginInstalled,
|
||||
discord: ensureDiscordPluginInstalled,
|
||||
qqbot: ensureQQBotPluginInstalled,
|
||||
whatsapp: ensureWhatsAppPluginInstalled,
|
||||
feishu: ensureFeishuPluginInstalled,
|
||||
[OPENCLAW_WECHAT_CHANNEL_TYPE]: ensureWeChatPluginInstalled,
|
||||
};
|
||||
const install = installers[storedChannelType];
|
||||
if (!install) return;
|
||||
async function ensureChannelPluginInstalled(storedChannelType: string): Promise<{ peerLinkOk: boolean }> {
|
||||
const install = CHANNEL_PLUGIN_INSTALLERS[storedChannelType];
|
||||
if (!install) return { peerLinkOk: true };
|
||||
const result = await install();
|
||||
if (!result.installed) {
|
||||
throw new Error(result.warning || `${toUiChannelType(storedChannelType)} plugin install failed`);
|
||||
}
|
||||
return { peerLinkOk: result.peerLinkOk !== false };
|
||||
}
|
||||
|
||||
export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceRegistry['channels'] {
|
||||
@@ -1101,15 +1135,31 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
|
||||
const accountId = optionalString(payload, 'accountId');
|
||||
await validateCanonicalAccountId(channelType, accountId, { allowLegacyConfiguredId: true });
|
||||
const storedChannelType = resolveStoredChannelType(channelType);
|
||||
await ensureChannelPluginInstalled(storedChannelType);
|
||||
const existingValues = await getChannelFormValues(channelType, accountId);
|
||||
const restartGateway = shouldRestartRunningGateway(ctx, storedChannelType);
|
||||
const [installResult, existingValues] = await Promise.all([
|
||||
ensureChannelPluginInstalled(storedChannelType),
|
||||
getChannelFormValues(channelType, accountId),
|
||||
]);
|
||||
if (isSameConfigValues(existingValues, config)) {
|
||||
await ensureScopedChannelBinding(channelType, accountId);
|
||||
return { success: true, noChange: true };
|
||||
if (restartGateway) {
|
||||
scheduleGatewayRestartForPluginChannel(ctx, storedChannelType, 'noChange');
|
||||
}
|
||||
return { success: true, noChange: true, ...(restartGateway ? { activationPending: true } : {}) };
|
||||
}
|
||||
await saveChannelConfig(channelType, config, accountId);
|
||||
await ensureScopedChannelBinding(channelType, accountId);
|
||||
return { success: true };
|
||||
if (restartGateway && !installResult.peerLinkOk) {
|
||||
scheduleGatewayRestartForPluginChannel(ctx, storedChannelType, 'peerLinkRepairFailed');
|
||||
return { success: true, activationPending: true };
|
||||
}
|
||||
// A changed running config is delivered through config.set, whose native
|
||||
// reload activates the plugin. Scheduling another full restart here races
|
||||
// that code-1012 reload and can trip OpenClaw's restart-loop breaker.
|
||||
// Keep the explicit restart above only for no-change retries, where no
|
||||
// config.set reload occurs but a newly copied plugin may still need discovery,
|
||||
// and when OpenClaw peer link repair failed after plugin install.
|
||||
return { success: true, ...(restartGateway ? { activationPending: true } : {}) };
|
||||
},
|
||||
setEnabled: async (payload) => {
|
||||
const channelType = requireString(payload, 'channelType');
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
|
||||
const OPENCLAW_DIR = join(homedir(), '.openclaw');
|
||||
const WECOM_PLUGIN_ID = 'wecom';
|
||||
// Note: QQBot is a built-in channel since OpenClaw 3.31 — no plugin ID needed.
|
||||
const WECHAT_PLUGIN_ID = OPENCLAW_WECHAT_CHANNEL_TYPE;
|
||||
const FEISHU_PLUGIN_ID_CANDIDATES = ['openclaw-lark', 'feishu-openclaw-plugin'] as const;
|
||||
const DEFAULT_ACCOUNT_ID = 'default';
|
||||
@@ -60,23 +59,13 @@ const WECHAT_ACCOUNTS_DIR = join(WECHAT_STATE_DIR, 'accounts');
|
||||
const LEGACY_WECHAT_CREDENTIALS_DIR = join(OPENCLAW_DIR, 'credentials', WECHAT_PLUGIN_ID);
|
||||
const LEGACY_WECHAT_SYNC_DIR = join(OPENCLAW_DIR, 'agents', 'default', 'sessions', '.openclaw-weixin-sync');
|
||||
|
||||
// Channels that are managed as plugins (config goes under plugins.entries, not channels)
|
||||
// External plugins whose activation lives in plugins.entries while account
|
||||
// configuration remains exclusively under channels.<id>.
|
||||
const PLUGIN_CHANNELS: string[] = ['discord', 'qqbot', 'whatsapp'];
|
||||
const LEGACY_BUILTIN_CHANNEL_PLUGIN_IDS = new Set<string>();
|
||||
const BUILTIN_CHANNEL_IDS = new Set([
|
||||
'discord',
|
||||
'telegram',
|
||||
'whatsapp',
|
||||
'slack',
|
||||
'signal',
|
||||
'imessage',
|
||||
'matrix',
|
||||
'line',
|
||||
'msteams',
|
||||
'googlechat',
|
||||
'mattermost',
|
||||
'qqbot',
|
||||
]);
|
||||
// OpenClaw 2026.7.1 bundles only these channel extensions. All other ClawX
|
||||
// channels must retain their explicit external plugin allowlist entries.
|
||||
const BUILTIN_CHANNEL_IDS = new Set(['telegram', 'imessage']);
|
||||
|
||||
// Unique credential key per channel type – used for duplicate bot detection.
|
||||
// Maps each channel type to the field that uniquely identifies a bot/account.
|
||||
@@ -156,6 +145,13 @@ function sanitizeDiscordGuilds(config: unknown): void {
|
||||
* Call before committing channel-config mutations.
|
||||
*/
|
||||
function sanitizeChannelSectionsBeforeWrite(config: OpenClawConfig): void {
|
||||
for (const pluginId of PLUGIN_CHANNELS) {
|
||||
const pluginEntry = config.plugins?.entries?.[pluginId];
|
||||
if (!pluginEntry) continue;
|
||||
delete pluginEntry.accounts;
|
||||
delete pluginEntry.defaultAccount;
|
||||
}
|
||||
|
||||
if (!config.channels) return;
|
||||
for (const channelType of CHANNELS_OMIT_DEFAULT_ACCOUNT_KEY) {
|
||||
const section = config.channels[channelType];
|
||||
@@ -361,52 +357,24 @@ function ensurePluginRegistration(currentConfig: OpenClawConfig, pluginId: strin
|
||||
if (!currentConfig.plugins.entries[pluginId]) {
|
||||
currentConfig.plugins.entries[pluginId] = {};
|
||||
}
|
||||
currentConfig.plugins.entries[pluginId].enabled = true;
|
||||
const pluginEntry = currentConfig.plugins.entries[pluginId];
|
||||
// PluginEntryConfig contains plugin activation/config metadata, not channel
|
||||
// accounts. Older ClawX versions mirrored credentials here, which OpenClaw
|
||||
// 2026.7.1 rejects as an invalid plugins.entries.<id> shape.
|
||||
delete pluginEntry.accounts;
|
||||
delete pluginEntry.defaultAccount;
|
||||
pluginEntry.enabled = true;
|
||||
}
|
||||
|
||||
function syncPluginChannelAccountMirror(currentConfig: OpenClawConfig, channelType: string): void {
|
||||
function syncPluginChannelRegistration(currentConfig: OpenClawConfig, channelType: string): void {
|
||||
if (!PLUGIN_CHANNELS.includes(channelType)) return;
|
||||
const channelSection = currentConfig.channels?.[channelType];
|
||||
if (!channelSection) {
|
||||
removePluginRegistration(currentConfig, channelType);
|
||||
return;
|
||||
}
|
||||
const pluginEntry = currentConfig.plugins?.entries?.[channelType];
|
||||
if (!pluginEntry) return;
|
||||
const accounts = getChannelAccountsMap(channelSection);
|
||||
pluginEntry.enabled = channelSection.enabled;
|
||||
pluginEntry.defaultAccount = channelSection.defaultAccount;
|
||||
if (accounts && Object.keys(accounts).length > 0) {
|
||||
pluginEntry.accounts = structuredClone(accounts);
|
||||
} else {
|
||||
delete pluginEntry.accounts;
|
||||
}
|
||||
}
|
||||
|
||||
function deletePluginChannelAccountMirror(
|
||||
currentConfig: OpenClawConfig,
|
||||
channelType: string,
|
||||
accountId: string,
|
||||
): boolean {
|
||||
if (!PLUGIN_CHANNELS.includes(channelType)) return false;
|
||||
const pluginEntry = currentConfig.plugins?.entries?.[channelType];
|
||||
if (!pluginEntry) return false;
|
||||
const accounts = getChannelAccountsMap(pluginEntry);
|
||||
if (!accounts?.[accountId]) return false;
|
||||
|
||||
delete accounts[accountId];
|
||||
const remainingAccountIds = Object.keys(accounts).sort((a, b) => {
|
||||
if (a === DEFAULT_ACCOUNT_ID) return -1;
|
||||
if (b === DEFAULT_ACCOUNT_ID) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
if (remainingAccountIds.length === 0) {
|
||||
delete pluginEntry.accounts;
|
||||
delete pluginEntry.defaultAccount;
|
||||
} else if (pluginEntry.defaultAccount === accountId) {
|
||||
pluginEntry.defaultAccount = remainingAccountIds[0];
|
||||
}
|
||||
return true;
|
||||
ensurePluginRegistration(currentConfig, channelType);
|
||||
currentConfig.plugins!.entries![channelType].enabled = channelSection.enabled !== false;
|
||||
}
|
||||
|
||||
function cleanupLegacyBuiltInChannelPluginRegistration(
|
||||
@@ -516,10 +484,6 @@ async function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType:
|
||||
ensurePluginRegistration(currentConfig, channelType);
|
||||
}
|
||||
|
||||
if (channelType === 'discord' || channelType === 'qqbot' || channelType === 'whatsapp') {
|
||||
ensurePluginRegistration(currentConfig, channelType);
|
||||
}
|
||||
|
||||
if (channelType === 'feishu') {
|
||||
const feishuPluginId = await resolveFeishuPluginId();
|
||||
if (!currentConfig.plugins) {
|
||||
@@ -610,8 +574,6 @@ async function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType:
|
||||
}
|
||||
}
|
||||
|
||||
// Note: QQBot is a built-in channel since OpenClaw 3.31 — no plugin registration needed.
|
||||
|
||||
if (channelType === WECHAT_PLUGIN_ID) {
|
||||
if (!currentConfig.plugins) {
|
||||
currentConfig.plugins = {
|
||||
@@ -854,8 +816,8 @@ export async function saveChannelConfig(
|
||||
await ensurePluginAllowlist(currentConfig, resolvedChannelType);
|
||||
syncBuiltinChannelsWithPluginAllowlist(currentConfig, [resolvedChannelType]);
|
||||
|
||||
// Plugin-based channels are mirrored into plugins.entries.<id> below,
|
||||
// but ClawX still keeps channels.<id> as the local account-list source.
|
||||
// Channel credentials always live under channels.<id>. External plugin
|
||||
// entries carry activation metadata only.
|
||||
|
||||
if (!currentConfig.channels) {
|
||||
currentConfig.channels = {};
|
||||
@@ -903,20 +865,7 @@ export async function saveChannelConfig(
|
||||
// read channels.<type>.enabled still work.
|
||||
channelSection.enabled = transformedConfig.enabled ?? channelSection.enabled ?? true;
|
||||
|
||||
// Plugin-backed channel packages read their activation/config from
|
||||
// plugins.entries.<id>. Mirror the enabled flag and account map there
|
||||
// while preserving channels.<id> for ClawX's account list UI.
|
||||
if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
|
||||
ensurePluginRegistration(currentConfig, resolvedChannelType);
|
||||
const pluginEntry = currentConfig.plugins!.entries![resolvedChannelType];
|
||||
const pluginAccounts = ensureChannelAccountsMap(pluginEntry);
|
||||
pluginEntry.defaultAccount = channelSection.defaultAccount;
|
||||
pluginEntry.enabled = channelSection.enabled;
|
||||
pluginAccounts[resolvedAccountId] = {
|
||||
...pluginAccounts[resolvedAccountId],
|
||||
...accounts[resolvedAccountId],
|
||||
};
|
||||
}
|
||||
syncPluginChannelRegistration(currentConfig, resolvedChannelType);
|
||||
|
||||
// Most OpenClaw channel plugins/built-ins also read the default
|
||||
// account's credentials from the top level of `channels.<type>`
|
||||
@@ -1024,21 +973,13 @@ export async function deleteChannelAccountConfig(channelType: string, accountId:
|
||||
deleteWeChatAccount = false;
|
||||
deletedAccount = false;
|
||||
const currentConfig = snapshot as OpenClawConfig;
|
||||
const deletedPluginAccount = deletePluginChannelAccountMirror(
|
||||
currentConfig,
|
||||
resolvedChannelType,
|
||||
accountId,
|
||||
);
|
||||
const channelSection = currentConfig.channels?.[resolvedChannelType];
|
||||
if (!channelSection) {
|
||||
if (isWechatChannelType(resolvedChannelType)) {
|
||||
removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
|
||||
deleteWeChatAccount = true;
|
||||
}
|
||||
if (deletedPluginAccount) {
|
||||
deletedAccount = true;
|
||||
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
|
||||
sanitizeChannelSectionsBeforeWrite(currentConfig);
|
||||
} else if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
|
||||
removePluginRegistration(currentConfig, resolvedChannelType);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1046,14 +987,7 @@ export async function deleteChannelAccountConfig(channelType: string, accountId:
|
||||
const existingAccounts = getChannelAccountsMap(channelSection);
|
||||
const targetsLegacyDefault = accountId === DEFAULT_ACCOUNT_ID
|
||||
&& Object.keys(getLegacyChannelPayload(channelSection)).length > 0;
|
||||
if (!existingAccounts?.[accountId] && !targetsLegacyDefault) {
|
||||
if (deletedPluginAccount) {
|
||||
deletedAccount = true;
|
||||
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
|
||||
sanitizeChannelSectionsBeforeWrite(currentConfig);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!existingAccounts?.[accountId] && !targetsLegacyDefault) return;
|
||||
const currentDefaultAccountId = typeof channelSection.defaultAccount === 'string'
|
||||
&& channelSection.defaultAccount.trim()
|
||||
? channelSection.defaultAccount.trim()
|
||||
@@ -1069,6 +1003,8 @@ export async function deleteChannelAccountConfig(channelType: string, accountId:
|
||||
delete currentConfig.channels![resolvedChannelType];
|
||||
if (isWechatChannelType(resolvedChannelType)) {
|
||||
removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
|
||||
} else if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
|
||||
removePluginRegistration(currentConfig, resolvedChannelType);
|
||||
}
|
||||
} else {
|
||||
if (channelSection.defaultAccount === accountId) {
|
||||
@@ -1096,7 +1032,7 @@ export async function deleteChannelAccountConfig(channelType: string, accountId:
|
||||
}
|
||||
}
|
||||
|
||||
syncPluginChannelAccountMirror(currentConfig, resolvedChannelType);
|
||||
syncPluginChannelRegistration(currentConfig, resolvedChannelType);
|
||||
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
|
||||
sanitizeChannelSectionsBeforeWrite(currentConfig);
|
||||
if (isWechatChannelType(resolvedChannelType)) {
|
||||
@@ -1144,6 +1080,9 @@ export async function deleteChannelConfig(channelType: string): Promise<void> {
|
||||
if (resolvedChannelType === 'wecom') {
|
||||
removePluginRegistration(currentConfig, WECOM_PLUGIN_ID);
|
||||
}
|
||||
if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
|
||||
removePluginRegistration(currentConfig, resolvedChannelType);
|
||||
}
|
||||
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
|
||||
if (isWechatChannelType(resolvedChannelType)) {
|
||||
deleteWeChat = true;
|
||||
@@ -1335,6 +1274,41 @@ export async function deleteAgentChannelAccounts(agentId: string, ownedChannelAc
|
||||
modified = false;
|
||||
const currentConfig = snapshot as OpenClawConfig;
|
||||
const channels = currentConfig.channels ?? {};
|
||||
|
||||
// Older ClawX releases could leave the only copy of Discord, QQBot,
|
||||
// or WhatsApp account credentials under plugins.entries.<id>. Migrate
|
||||
// that invalid legacy shape into channels.<id> before deleting the
|
||||
// owned account, so sibling accounts survive while PluginEntryConfig
|
||||
// is normalized back to activation metadata only.
|
||||
const legacyPluginChannelTypes = ownedChannelAccounts
|
||||
? [...ownedChannelAccounts]
|
||||
.filter((channelAccountKey) => channelAccountKey.endsWith(`:${accountId}`))
|
||||
.map((channelAccountKey) => channelAccountKey.slice(0, -accountId.length - 1))
|
||||
: PLUGIN_CHANNELS;
|
||||
for (const channelType of legacyPluginChannelTypes) {
|
||||
if (!PLUGIN_CHANNELS.includes(channelType)) continue;
|
||||
const pluginEntry = currentConfig.plugins?.entries?.[channelType];
|
||||
const pluginAccounts = pluginEntry ? getChannelAccountsMap(pluginEntry) : undefined;
|
||||
if (!pluginEntry || !pluginAccounts?.[accountId]) continue;
|
||||
|
||||
const section = channels[channelType] ?? {
|
||||
enabled: pluginEntry.enabled !== false,
|
||||
};
|
||||
const channelAccounts = ensureChannelAccountsMap(section);
|
||||
for (const [legacyAccountId, legacyAccountConfig] of Object.entries(pluginAccounts)) {
|
||||
if (!channelAccounts[legacyAccountId]) {
|
||||
channelAccounts[legacyAccountId] = structuredClone(legacyAccountConfig);
|
||||
}
|
||||
}
|
||||
if (typeof section.defaultAccount !== 'string' || !section.defaultAccount.trim()) {
|
||||
section.defaultAccount = typeof pluginEntry.defaultAccount === 'string'
|
||||
? pluginEntry.defaultAccount
|
||||
: DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
channels[channelType] = section;
|
||||
currentConfig.channels = channels;
|
||||
}
|
||||
|
||||
for (const channelType of Object.keys(channels)) {
|
||||
if (ownedChannelAccounts && !ownedChannelAccounts.has(`${channelType}:${accountId}`)) continue;
|
||||
const section = channels[channelType];
|
||||
@@ -1378,21 +1352,10 @@ export async function deleteAgentChannelAccounts(agentId: string, ownedChannelAc
|
||||
}
|
||||
}
|
||||
}
|
||||
syncPluginChannelAccountMirror(currentConfig, channelType);
|
||||
syncPluginChannelRegistration(currentConfig, channelType);
|
||||
modified = true;
|
||||
}
|
||||
|
||||
const pluginChannelTypes = ownedChannelAccounts
|
||||
? [...ownedChannelAccounts]
|
||||
.filter((channelAccountKey) => channelAccountKey.endsWith(`:${accountId}`))
|
||||
.map((channelAccountKey) => channelAccountKey.slice(0, -accountId.length - 1))
|
||||
: Object.keys(currentConfig.plugins?.entries ?? {});
|
||||
for (const channelType of pluginChannelTypes) {
|
||||
if (deletePluginChannelAccountMirror(currentConfig, channelType, accountId)) {
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
sanitizeChannelSectionsBeforeWrite(currentConfig);
|
||||
}
|
||||
@@ -1421,19 +1384,8 @@ export async function setChannelEnabled(channelType: string, enabled: boolean):
|
||||
|
||||
if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
|
||||
pluginChannel = true;
|
||||
if (enabled) {
|
||||
ensurePluginRegistration(currentConfig, resolvedChannelType);
|
||||
} else {
|
||||
const plugins = currentConfig.plugins ?? (currentConfig.plugins = {});
|
||||
const entries = plugins.entries ?? (plugins.entries = {});
|
||||
entries[resolvedChannelType] ??= {};
|
||||
}
|
||||
const entries = currentConfig.plugins?.entries;
|
||||
const pluginEntry = entries?.[resolvedChannelType];
|
||||
if (!pluginEntry) throw new Error(`Plugin entry not initialized: ${resolvedChannelType}`);
|
||||
pluginEntry.enabled = enabled;
|
||||
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
|
||||
return;
|
||||
ensurePluginRegistration(currentConfig, resolvedChannelType);
|
||||
currentConfig.plugins!.entries![resolvedChannelType].enabled = enabled;
|
||||
}
|
||||
|
||||
if (!currentConfig.channels) currentConfig.channels = {};
|
||||
|
||||
@@ -13,7 +13,7 @@ type BuildGatewayHealthSummaryOptions = {
|
||||
};
|
||||
|
||||
const CHANNEL_STATUS_FAILURE_WINDOW_MS = 2 * 60_000;
|
||||
const HEARTBEAT_MISS_THRESHOLD = 10;
|
||||
const HEARTBEAT_MISS_THRESHOLD = 4;
|
||||
|
||||
export function buildGatewayHealthSummary(
|
||||
options: BuildGatewayHealthSummaryOptions,
|
||||
|
||||
+167
-33
@@ -534,20 +534,10 @@ const FEISHU_PLUGIN_ID_CANDIDATES = ['openclaw-lark', 'feishu-openclaw-plugin']
|
||||
const VALID_COMPACTION_MODES = new Set(['default', 'safeguard']);
|
||||
/** Matches OpenClaw's 200k+ context-window recommendation (see computeContextAwareReserveTokensFloor). */
|
||||
const DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR = 50_000;
|
||||
const BUILTIN_CHANNEL_IDS = new Set([
|
||||
'discord',
|
||||
'telegram',
|
||||
'whatsapp',
|
||||
'slack',
|
||||
'signal',
|
||||
'imessage',
|
||||
'matrix',
|
||||
'line',
|
||||
'msteams',
|
||||
'googlechat',
|
||||
'mattermost',
|
||||
'qqbot',
|
||||
]);
|
||||
// OpenClaw 2026.7.1 bundles these channel extensions. Discord, WhatsApp,
|
||||
// QQBot, and the remaining catalog channels are external plugins and their
|
||||
// explicit allowlist registrations must be preserved.
|
||||
const BUILTIN_CHANNEL_IDS = new Set(['telegram', 'imessage']);
|
||||
const OPTIONAL_PROVIDER_LIKE_BUNDLED_PLUGIN_IDS = new Set([
|
||||
'alibaba',
|
||||
'deepgram',
|
||||
@@ -2889,6 +2879,13 @@ export async function updateSingleAgentModelProvider(
|
||||
*/
|
||||
const SKILL_WORKSHOP_TOOL_DENY_ENTRY = 'skill_workshop';
|
||||
const WEB_SEARCH_TOOL_DENY_ENTRY = 'web_search';
|
||||
const CONTROL_PLANE_TOOL_DENY_ENTRIES = [
|
||||
'gateway',
|
||||
'nodes',
|
||||
'create_goal',
|
||||
'get_goal',
|
||||
'update_goal',
|
||||
] as const;
|
||||
const SKILL_CREATOR_SKILL_KEY = 'skill-creator';
|
||||
|
||||
function normalizeToolDenyList(value: unknown): string[] {
|
||||
@@ -2907,6 +2904,20 @@ function ensureToolDenyIncludes(
|
||||
return { deny: [...deny, entry], modified: true };
|
||||
}
|
||||
|
||||
function ensureToolDenyIncludesAll(
|
||||
deny: string[],
|
||||
entries: readonly string[],
|
||||
): { deny: string[]; modified: boolean } {
|
||||
let current = deny;
|
||||
let modified = false;
|
||||
for (const entry of entries) {
|
||||
const result = ensureToolDenyIncludes(current, entry);
|
||||
current = result.deny;
|
||||
modified ||= result.modified;
|
||||
}
|
||||
return { deny: current, modified };
|
||||
}
|
||||
|
||||
export async function sanitizeOpenClawConfig(): Promise<void> {
|
||||
// The prelaunch file fallback must not turn a missing or corrupt config into
|
||||
// a valid-looking skeleton. The coordinator performs the successful mutation.
|
||||
@@ -2946,8 +2957,27 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
|
||||
}
|
||||
|
||||
// ── plugins section ──────────────────────────────────────────────
|
||||
// OpenClaw 2026.7.1 moved these formerly bundled channels to external
|
||||
// plugins. Recover old channel-only configs before plugin sanitization.
|
||||
let plugins = config.plugins;
|
||||
if (!plugins && isPlainRecord(config.channels)) {
|
||||
const channels = config.channels as Record<string, unknown>;
|
||||
const externalChannelIds = ['discord', 'whatsapp', 'qqbot'].filter((channelId) => {
|
||||
const section = channels[channelId];
|
||||
return isPlainRecord(section) && section.enabled !== false && Object.keys(section).length > 0;
|
||||
});
|
||||
if (externalChannelIds.length > 0) {
|
||||
plugins = {
|
||||
enabled: true,
|
||||
allow: externalChannelIds,
|
||||
entries: Object.fromEntries(externalChannelIds.map((channelId) => [channelId, { enabled: true }])),
|
||||
};
|
||||
config.plugins = plugins;
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove absolute paths in plugins that no longer exist or are bundled (preventing hardlink validation errors)
|
||||
const plugins = config.plugins;
|
||||
if (plugins) {
|
||||
if (Array.isArray(plugins)) {
|
||||
const validPlugins: unknown[] = [];
|
||||
@@ -3107,6 +3137,22 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
|
||||
toolsModified = true;
|
||||
}
|
||||
|
||||
const controlPlaneToolDenyResult = ensureToolDenyIncludesAll(
|
||||
normalizeToolDenyList(toolsConfig.deny),
|
||||
CONTROL_PLANE_TOOL_DENY_ENTRIES,
|
||||
);
|
||||
if (controlPlaneToolDenyResult.modified) {
|
||||
toolsConfig.deny = controlPlaneToolDenyResult.deny;
|
||||
toolsModified = true;
|
||||
console.log('[sanitize] Added control-plane tools to tools.deny for ClawX desktop');
|
||||
} else if (
|
||||
!Array.isArray(toolsConfig.deny)
|
||||
|| toolsConfig.deny.length !== controlPlaneToolDenyResult.deny.length
|
||||
) {
|
||||
toolsConfig.deny = controlPlaneToolDenyResult.deny;
|
||||
toolsModified = true;
|
||||
}
|
||||
|
||||
// ── tools.exec approvals (OpenClaw 3.28+) ──────────────────────
|
||||
// ClawX is a local desktop app where the user is the trusted operator.
|
||||
// Exec approval prompts add unnecessary friction in this context, so we
|
||||
@@ -3184,6 +3230,22 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
|
||||
gatewayModified = true;
|
||||
}
|
||||
|
||||
const gatewayControlPlaneToolDenyResult = ensureToolDenyIncludesAll(
|
||||
normalizeToolDenyList(gatewayTools.deny),
|
||||
CONTROL_PLANE_TOOL_DENY_ENTRIES,
|
||||
);
|
||||
if (gatewayControlPlaneToolDenyResult.modified) {
|
||||
gatewayTools.deny = gatewayControlPlaneToolDenyResult.deny;
|
||||
gatewayModified = true;
|
||||
console.log('[sanitize] Added control-plane tools to gateway.tools.deny for ClawX desktop');
|
||||
} else if (
|
||||
!Array.isArray(gatewayTools.deny)
|
||||
|| gatewayTools.deny.length !== gatewayControlPlaneToolDenyResult.deny.length
|
||||
) {
|
||||
gatewayTools.deny = gatewayControlPlaneToolDenyResult.deny;
|
||||
gatewayModified = true;
|
||||
}
|
||||
|
||||
if (gatewayModified) {
|
||||
gateway.tools = gatewayTools;
|
||||
config.gateway = gateway;
|
||||
@@ -3417,18 +3479,96 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
|
||||
modified = true;
|
||||
}
|
||||
|
||||
// ── qqbot built-in channel cleanup ──────────────────────────
|
||||
// OpenClaw 3.31 moved qqbot from a third-party plugin to a built-in
|
||||
// channel. Clean up legacy plugin entries (both bare "qqbot" and
|
||||
// manifest-declared "openclaw-qqbot") from plugins.entries.
|
||||
// plugins.allow is left untouched — having openclaw-qqbot there is harmless.
|
||||
// The channel config under channels.qqbot is preserved and works
|
||||
// identically with the built-in channel.
|
||||
const QQBOT_PLUGIN_IDS = ['qqbot', 'openclaw-qqbot'] as const;
|
||||
for (const qqbotId of QQBOT_PLUGIN_IDS) {
|
||||
if (pEntries?.[qqbotId]) {
|
||||
delete pEntries[qqbotId];
|
||||
console.log(`[sanitize] Removed built-in channel plugin from plugins.entries: ${qqbotId}`);
|
||||
// ── external channel plugin registration cleanup ────────────
|
||||
// Channel account configuration belongs under channels.<id>. OpenClaw's
|
||||
// PluginEntryConfig rejects ClawX's legacy accounts/defaultAccount mirror.
|
||||
// Migrate first: some older configs have no channels.<id> copy, and
|
||||
// deleting the plugin account map directly would lose their credentials.
|
||||
for (const pluginId of ['discord', 'whatsapp', 'qqbot'] as const) {
|
||||
const pluginEntry = pEntries[pluginId];
|
||||
if (!pluginEntry) continue;
|
||||
|
||||
const legacyAccounts = isPlainRecord(pluginEntry.accounts)
|
||||
? pluginEntry.accounts as Record<string, Record<string, unknown>>
|
||||
: null;
|
||||
if (legacyAccounts && Object.keys(legacyAccounts).length > 0) {
|
||||
const channels = isPlainRecord(config.channels)
|
||||
? config.channels as Record<string, Record<string, unknown>>
|
||||
: {};
|
||||
const existingSection = isPlainRecord(channels[pluginId])
|
||||
? channels[pluginId]
|
||||
: {};
|
||||
const channelAccounts = isPlainRecord(existingSection.accounts)
|
||||
? existingSection.accounts as Record<string, Record<string, unknown>>
|
||||
: {};
|
||||
let migratedAccount = false;
|
||||
|
||||
for (const [accountId, accountConfig] of Object.entries(legacyAccounts)) {
|
||||
if (!isPlainRecord(accountConfig) || channelAccounts[accountId]) continue;
|
||||
channelAccounts[accountId] = structuredClone(accountConfig);
|
||||
migratedAccount = true;
|
||||
}
|
||||
|
||||
if (migratedAccount) {
|
||||
existingSection.accounts = channelAccounts;
|
||||
if (existingSection.enabled === undefined) {
|
||||
existingSection.enabled = pluginEntry.enabled !== false;
|
||||
}
|
||||
if (typeof existingSection.defaultAccount !== 'string' || !existingSection.defaultAccount.trim()) {
|
||||
const legacyDefaultAccount = typeof pluginEntry.defaultAccount === 'string'
|
||||
&& channelAccounts[pluginEntry.defaultAccount]
|
||||
? pluginEntry.defaultAccount
|
||||
: Object.keys(channelAccounts).sort((a, b) => {
|
||||
if (a === 'default') return -1;
|
||||
if (b === 'default') return 1;
|
||||
return a.localeCompare(b);
|
||||
})[0];
|
||||
if (legacyDefaultAccount) {
|
||||
existingSection.defaultAccount = legacyDefaultAccount;
|
||||
}
|
||||
}
|
||||
channels[pluginId] = existingSection;
|
||||
config.channels = channels;
|
||||
modified = true;
|
||||
console.log(`[sanitize] Migrated legacy plugins.entries.${pluginId}.accounts to channels.${pluginId}.accounts`);
|
||||
}
|
||||
}
|
||||
|
||||
if ('accounts' in pluginEntry) {
|
||||
delete pluginEntry.accounts;
|
||||
modified = true;
|
||||
}
|
||||
if ('defaultAccount' in pluginEntry) {
|
||||
delete pluginEntry.defaultAccount;
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
// QQBot is an external @openclaw/qqbot plugin in OpenClaw 2026.7.1.
|
||||
// Migrate the legacy manifest id and keep one canonical active entry.
|
||||
const legacyQQBotId = 'openclaw-qqbot';
|
||||
const legacyQQBotAllowIndex = allowArr.indexOf(legacyQQBotId);
|
||||
if (legacyQQBotAllowIndex !== -1) {
|
||||
allowArr.splice(legacyQQBotAllowIndex, 1);
|
||||
modified = true;
|
||||
}
|
||||
if (pEntries[legacyQQBotId]) {
|
||||
delete pEntries[legacyQQBotId];
|
||||
modified = true;
|
||||
}
|
||||
const qqbotChannel = (config.channels as Record<string, Record<string, unknown>> | undefined)?.qqbot;
|
||||
const isQQBotConfigured = Boolean(
|
||||
qqbotChannel
|
||||
&& qqbotChannel.enabled !== false
|
||||
&& Object.keys(qqbotChannel).length > 0
|
||||
);
|
||||
if (isQQBotConfigured) {
|
||||
if (!allowArr.includes('qqbot')) {
|
||||
allowArr.push('qqbot');
|
||||
modified = true;
|
||||
}
|
||||
if (!pEntries.qqbot || pEntries.qqbot.enabled !== true) {
|
||||
pEntries.qqbot = { ...(pEntries.qqbot || {}), enabled: true };
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
@@ -3511,12 +3651,6 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
if (pEntries.whatsapp) {
|
||||
delete pEntries.whatsapp;
|
||||
console.log('[sanitize] Removed legacy plugins.entries.whatsapp for built-in channel');
|
||||
modified = true;
|
||||
}
|
||||
|
||||
// Discover all bundled extension IDs so we can clean stale bundled
|
||||
// allowlist entries from older OpenClaw versions. Re-add only the
|
||||
// ClawX-critical bundled plugins, active provider plugins, and explicitly
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { chmod, copyFile, lstat, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { basename, dirname, join, relative, resolve } from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { resolveOpenClawConfigPath, resolveOpenClawStateDir } from './paths';
|
||||
|
||||
const UPGRADE_ID = 'openclaw-2026.7.1';
|
||||
@@ -23,6 +24,12 @@ export type OpenClawUpgradeSnapshotCleanupResult = {
|
||||
snapshotDir: string;
|
||||
};
|
||||
|
||||
export type LegacyUpdateCheckCleanupResult = {
|
||||
status: 'quarantined' | 'missing' | 'deferred';
|
||||
sourcePath: string;
|
||||
backupPath?: string;
|
||||
};
|
||||
|
||||
type SnapshotOptions = {
|
||||
stateDir?: string;
|
||||
configPath?: string;
|
||||
@@ -57,6 +64,44 @@ async function copyFileIfPresent(source: string, destination: string, copied: st
|
||||
copied.push(destination);
|
||||
}
|
||||
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await lstat(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveAvailableBackupPath(basePath: string): Promise<string> {
|
||||
if (!await pathExists(basePath)) return basePath;
|
||||
|
||||
const timestamp = Date.now();
|
||||
for (let suffix = 0; suffix < 100; suffix += 1) {
|
||||
const candidate = `${basePath}.${timestamp}${suffix === 0 ? '' : `-${suffix}`}`;
|
||||
if (!await pathExists(candidate)) return candidate;
|
||||
}
|
||||
throw new Error(`Could not allocate backup path for ${basePath}`);
|
||||
}
|
||||
|
||||
function hasCanonicalUpdateCheckState(sqlitePath: string): boolean {
|
||||
let db: DatabaseSync | undefined;
|
||||
try {
|
||||
db = new DatabaseSync(sqlitePath, { readOnly: true });
|
||||
const row = db.prepare(`
|
||||
SELECT 1 AS present
|
||||
FROM update_check_state
|
||||
WHERE state_key = ?
|
||||
LIMIT 1
|
||||
`).get('default') as { present?: number } | undefined;
|
||||
return row?.present === 1;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function copyTree(
|
||||
sourceRoot: string,
|
||||
destinationRoot: string,
|
||||
@@ -161,6 +206,46 @@ export async function ensureOpenClaw2026_7_1UpgradeSnapshot(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenClaw 2026.7.1 refuses Gateway readiness when the legacy update-check JSON
|
||||
* differs from an existing canonical SQLite row. The JSON contains updater
|
||||
* bookkeeping only, and upstream would archive it when both copies match. Once
|
||||
* SQLite has the canonical row, move the legacy file out of the active state
|
||||
* root so a harmless mismatch cannot trap startup or an ineffective doctor
|
||||
* retry loop. If SQLite has no row yet, leave the JSON for upstream to import.
|
||||
*/
|
||||
export async function quarantineLegacyUpdateCheckState(
|
||||
options: Pick<SnapshotOptions, 'stateDir'> = {},
|
||||
): Promise<LegacyUpdateCheckCleanupResult> {
|
||||
const stateDir = resolve(options.stateDir ?? resolveOpenClawStateDir());
|
||||
const sourcePath = join(stateDir, 'update-check.json');
|
||||
let sourceInfo;
|
||||
try {
|
||||
sourceInfo = await lstat(sourcePath);
|
||||
} catch {
|
||||
return { status: 'missing', sourcePath };
|
||||
}
|
||||
if (!sourceInfo.isFile() && !sourceInfo.isSymbolicLink()) {
|
||||
return { status: 'deferred', sourcePath };
|
||||
}
|
||||
|
||||
const sqlitePath = join(stateDir, 'state', 'openclaw.sqlite');
|
||||
if (!hasCanonicalUpdateCheckState(sqlitePath)) {
|
||||
return { status: 'deferred', sourcePath };
|
||||
}
|
||||
|
||||
const backupDir = join(stateDir, 'backups');
|
||||
await mkdir(backupDir, { recursive: true, mode: SNAPSHOT_DIR_MODE });
|
||||
const backupPath = await resolveAvailableBackupPath(
|
||||
join(backupDir, `clawx-${UPGRADE_ID}-legacy-update-check.json`),
|
||||
);
|
||||
await rename(sourcePath, backupPath);
|
||||
if (sourceInfo.isFile()) {
|
||||
await chmod(backupPath, SNAPSHOT_FILE_MODE);
|
||||
}
|
||||
return { status: 'quarantined', sourcePath, backupPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the one-time OpenClaw 2026.7.1 pre-migration snapshot after Gateway
|
||||
* startup succeeds so duplicated config/auth/SQLite secrets do not linger.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
import { app } from 'electron';
|
||||
import path from 'node:path';
|
||||
import { existsSync, cpSync, copyFileSync, statSync, lstatSync, mkdirSync, readFileSync, writeFileSync, readdirSync, realpathSync, symlinkSync, unlinkSync } from 'node:fs';
|
||||
import { existsSync, cpSync, copyFileSync, statSync, lstatSync, mkdirSync, readFileSync, readlinkSync, writeFileSync, readdirSync, realpathSync, symlinkSync, unlinkSync } from 'node:fs';
|
||||
import { readdir, stat, copyFile, mkdir } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
@@ -417,6 +417,34 @@ function canonicalComparablePath(filePath: string): string {
|
||||
return process.platform === 'win32' ? withoutLongPathPrefix.toLowerCase() : withoutLongPathPrefix;
|
||||
}
|
||||
|
||||
function resolveSymlinkTarget(linkPath: string, target: string): string {
|
||||
return path.isAbsolute(target) ? target : path.resolve(path.dirname(linkPath), target);
|
||||
}
|
||||
|
||||
function openClawPeerLinkPointsTo(linkPath: string, openclawDir: string): boolean {
|
||||
try {
|
||||
const stat = lstatSync(fsPath(linkPath));
|
||||
if (stat.isSymbolicLink()) {
|
||||
const target = readlinkSync(fsPath(linkPath));
|
||||
const resolvedTarget = resolveSymlinkTarget(linkPath, target);
|
||||
return canonicalComparablePath(resolvedTarget) === canonicalComparablePath(openclawDir);
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
try {
|
||||
const packageJson = JSON.parse(readFileSync(fsPath(join(linkPath, 'package.json')), 'utf-8')) as { name?: unknown };
|
||||
if (packageJson.name === 'openclaw') {
|
||||
return canonicalComparablePath(linkPath) === canonicalComparablePath(openclawDir);
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialized mirrors live outside the bundled OpenClaw package tree, so
|
||||
* Node's normal package lookup cannot resolve their declared `openclaw` peer.
|
||||
@@ -458,12 +486,8 @@ export function repairPluginOpenClawPeerLink(
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (canonicalComparablePath(linkPath) === canonicalComparablePath(openclawDir)) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to lstat/creation for a missing or broken link.
|
||||
if (openClawPeerLinkPointsTo(linkPath, openclawDir)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let existing: ReturnType<typeof lstatSync> | null = null;
|
||||
@@ -495,8 +519,9 @@ export function repairPluginOpenClawPeerLink(
|
||||
}
|
||||
}
|
||||
|
||||
symlinkSync(openclawDir, fsPath(linkPath), 'junction');
|
||||
if (canonicalComparablePath(linkPath) !== canonicalComparablePath(openclawDir)) {
|
||||
const junctionTarget = path.resolve(openclawDir);
|
||||
symlinkSync(fsPath(junctionTarget), fsPath(linkPath), 'junction');
|
||||
if (!openClawPeerLinkPointsTo(linkPath, openclawDir)) {
|
||||
logger.warn(`[plugin] OpenClaw peer link audit failed after creating ${linkPath}`);
|
||||
return false;
|
||||
}
|
||||
@@ -723,28 +748,37 @@ export function copyPluginFromNodeModules(npmPkgPath: string, targetDir: string,
|
||||
|
||||
// ── Core install / upgrade logic ─────────────────────────────────────────────
|
||||
|
||||
export type PluginInstallResult = {
|
||||
installed: boolean;
|
||||
warning?: string;
|
||||
peerLinkOk?: boolean;
|
||||
};
|
||||
|
||||
export async function ensurePluginInstalled(
|
||||
pluginDirName: string,
|
||||
candidateSources: string[],
|
||||
pluginLabel: string,
|
||||
): Promise<{ installed: boolean; warning?: string }> {
|
||||
): Promise<PluginInstallResult> {
|
||||
const targetDir = join(homedir(), '.openclaw', 'extensions', pluginDirName);
|
||||
const targetManifest = join(targetDir, 'openclaw.plugin.json');
|
||||
const targetPkgJson = join(targetDir, 'package.json');
|
||||
|
||||
const sourceDir = candidateSources.find((dir) => existsSync(fsPath(join(dir, 'openclaw.plugin.json'))));
|
||||
|
||||
async function finalizeInstalledMirror(): Promise<{ installed: true; peerLinkOk: boolean }> {
|
||||
await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
|
||||
return { installed: true, peerLinkOk: repairPluginOpenClawPeerLink(targetDir) };
|
||||
}
|
||||
|
||||
// If already installed, check whether an upgrade is available
|
||||
if (existsSync(fsPath(targetManifest))) {
|
||||
if (!sourceDir) {
|
||||
await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
|
||||
return { installed: true }; // no bundled source to compare, keep existing
|
||||
return await finalizeInstalledMirror(); // no bundled source to compare, keep existing
|
||||
}
|
||||
const installedVersion = readPluginVersion(targetPkgJson);
|
||||
const sourceVersion = readPluginVersion(join(sourceDir, 'package.json'));
|
||||
if (!sourceVersion || !installedVersion || sourceVersion === installedVersion) {
|
||||
await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
|
||||
return { installed: true }; // same version or unable to compare
|
||||
return await finalizeInstalledMirror(); // same version or unable to compare
|
||||
}
|
||||
// Version differs — fall through to overwrite install
|
||||
logger.info(
|
||||
@@ -767,9 +801,9 @@ export async function ensurePluginInstalled(
|
||||
return { installed: false, warning: `Failed to install ${pluginLabel} plugin mirror (manifest missing).` };
|
||||
}
|
||||
fixupPluginManifest(targetDir);
|
||||
await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
|
||||
const installed = await finalizeInstalledMirror();
|
||||
logger.info(`Installed ${pluginLabel} plugin from bundled mirror: ${sourceDir}`);
|
||||
return { installed: true };
|
||||
return installed;
|
||||
} catch (error) {
|
||||
const diagnostic = toErrorDiagnostic(error);
|
||||
attempts.push({ attempt, ...diagnostic });
|
||||
@@ -816,8 +850,7 @@ export async function ensurePluginInstalled(
|
||||
copyPluginFromNodeModules(npmPkgPath, targetDir, npmName);
|
||||
fixupPluginManifest(targetDir);
|
||||
if (existsSync(fsPath(join(targetDir, 'openclaw.plugin.json')))) {
|
||||
await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
|
||||
return { installed: true };
|
||||
return await finalizeInstalledMirror();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
@@ -834,8 +867,7 @@ export async function ensurePluginInstalled(
|
||||
);
|
||||
}
|
||||
} else if (existsSync(fsPath(targetManifest))) {
|
||||
await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
|
||||
return { installed: true }; // same version, already installed
|
||||
return await finalizeInstalledMirror(); // same version, already installed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -870,15 +902,15 @@ export function buildCandidateSources(pluginDirName: string): string[] {
|
||||
|
||||
// ── Per-channel plugin helpers ───────────────────────────────────────────────
|
||||
|
||||
export function ensureDingTalkPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
export function ensureDingTalkPluginInstalled(): Promise<PluginInstallResult> {
|
||||
return ensurePluginInstalled('dingtalk', buildCandidateSources('dingtalk'), 'DingTalk');
|
||||
}
|
||||
|
||||
export function ensureWeComPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
export function ensureWeComPluginInstalled(): Promise<PluginInstallResult> {
|
||||
return ensurePluginInstalled('wecom', buildCandidateSources('wecom'), 'WeCom');
|
||||
}
|
||||
|
||||
export function ensureFeishuPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
export function ensureFeishuPluginInstalled(): Promise<PluginInstallResult> {
|
||||
return ensurePluginInstalled(
|
||||
'feishu-openclaw-plugin',
|
||||
buildCandidateSources('feishu-openclaw-plugin'),
|
||||
@@ -886,25 +918,23 @@ export function ensureFeishuPluginInstalled(): Promise<{ installed: boolean; war
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function ensureWeChatPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
export function ensureWeChatPluginInstalled(): Promise<PluginInstallResult> {
|
||||
return ensurePluginInstalled('openclaw-weixin', buildCandidateSources('openclaw-weixin'), 'WeChat');
|
||||
}
|
||||
|
||||
export function ensureDiscordPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
export function ensureDiscordPluginInstalled(): Promise<PluginInstallResult> {
|
||||
return ensurePluginInstalled('discord', buildCandidateSources('discord'), 'Discord');
|
||||
}
|
||||
|
||||
export function ensureQQBotPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
export function ensureQQBotPluginInstalled(): Promise<PluginInstallResult> {
|
||||
return ensurePluginInstalled('qqbot', buildCandidateSources('qqbot'), 'QQBot');
|
||||
}
|
||||
|
||||
export function ensureWhatsAppPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
export function ensureWhatsAppPluginInstalled(): Promise<PluginInstallResult> {
|
||||
return ensurePluginInstalled('whatsapp', buildCandidateSources('whatsapp'), 'WhatsApp');
|
||||
}
|
||||
|
||||
export function ensureClawXOpenAiImagePluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
export function ensureClawXOpenAiImagePluginInstalled(): Promise<PluginInstallResult> {
|
||||
return ensurePluginInstalled(
|
||||
'clawx-openai-image',
|
||||
buildCandidateSources('clawx-openai-image'),
|
||||
|
||||
@@ -6,7 +6,7 @@ Related scenario: `acp-chat-experience`
|
||||
|
||||
Related rules: `acp-chat-state-and-history`, `attachment-access-safety`, `renderer-main-boundary`
|
||||
|
||||
Related tasks: `acp-native-chat`, `acp-media-attachments`, `filter-openclaw-heartbeat-session`
|
||||
Related tasks: `acp-native-chat`, `acp-media-attachments`, `filter-openclaw-heartbeat-session`, `recover-acp-session-after-gateway-restart`
|
||||
|
||||
## Ownership
|
||||
|
||||
@@ -33,11 +33,13 @@ Renderer-visible session identity is the OpenClaw Gateway session key. Main may
|
||||
|
||||
While `session/prompt` is pending, Main retains a bounded session-id routing context and Renderer retains that prompt's reduced timeline and original client-observed turn start in memory. This lets another page or conversation be viewed without dropping the original stream or resetting elapsed time. Returning to the live conversation reactivates its existing ACP context and restores the memory snapshot without calling `session/load`; updates received during the handoff are still generation-filtered. Prompt settlement releases both live contexts, after which returning uses ordinary ACP replay plus bounded timing metadata. This is live operation state, not a second history ledger, and it is never persisted.
|
||||
|
||||
When guarded Gateway recovery interrupts an accepted main-session run, patched OpenClaw starts a distinct recovery run and marks its Chat and agent events with the interrupted run id as `resumedFromRunId`. The reconnecting ACP bridge keeps the original prompt pending for a bounded 60-second total recovery window, adopts only that explicitly linked recovery run, resets per-run text and tool state, and rebinds cancellation to the new run id. The initial disconnect check still occurs after 5 seconds: prompts whose send was never acknowledged reject then, while acknowledged prompts receive the remaining 55 seconds for Gateway startup backoff and recovery dispatch. ACP also subscribes to session tool events after reconnect, and Gateway mirrors visible recovery tools to that exact session subscription with lineage intact, so recovered tool cards preserve the surrounding text boundaries. Renderer does not reload the session or use Gateway runtime identity for this flow; normal ACP generation, session, and workspace guards remain authoritative.
|
||||
|
||||
`messageId` and `toolCallId` are opaque identities within one loaded timeline. They are not durable UI identities across loads. Timeline sequence values and DOM anchors are also local to the active snapshot.
|
||||
|
||||
## History Authority
|
||||
|
||||
ACP `session/load` replay is the primary source of Chat history. ClawX does not persist an ACP ledger, reduced timeline, replay cache, or reconstructed tool history. Full structured replay can restore tools and file activity; transcript-only fallback must not invent them.
|
||||
ACP `session/load` replay is the primary source of Chat history. ClawX does not persist an ACP ledger, reduced timeline, replay cache, or reconstructed tool history. Full structured replay restores recorded tools and file activity. When that ledger is unavailable, OpenClaw's ACP adapter maps persisted transcript `toolCall` and `toolResult` records to native ACP tool updates in transcript order, preserving assistant text segments on either side; this is upstream ACP replay, not a ClawX transcript supplement or inference path.
|
||||
|
||||
OpenClaw emits replay through ordinary `session/update` notifications and completes the replay before `session/load` returns. Main collects those raw notifications for the active load generation and returns them with the load result instead of forwarding them incrementally. Renderer temporarily groups generation-matching host events that arrive during the IPC result handoff, then runs the normal reducer over the combined batch and publishes the resulting timeline in one state update. This is an in-flight transaction buffer only, not a history cache; after load, each live update continues through the normal host-event route and is applied immediately without a Renderer batching timer. Permission requests are accepted only after the current loaded session starts a prompt, preventing load-time or handoff requests from creating invisible waiters.
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# OpenClaw Config Delivery
|
||||
|
||||
ClawX bundles OpenClaw 2026.7.1. OpenClaw owns the field-level decision between a no-op snapshot update, hot application, subsystem restart, and in-process Gateway restart.
|
||||
ClawX bundles OpenClaw 2026.7.1-2. OpenClaw owns the field-level decision between a no-op snapshot update, hot application, subsystem restart, and in-process Gateway restart.
|
||||
|
||||
Provider, Agent, Channel, skill, proxy, image-generation, and plugin-install helpers express config changes as mutators. One Main-owned coordinator owns selection of the authoritative baseline and the commit:
|
||||
|
||||
1. If Gateway is running, call `config.get` and require its runtime-shaped `config` object and `hash`. The coordinator accepts `raw` only as a compatibility fallback for older responses.
|
||||
2. Clone the runtime-shaped config, apply the mutator, and call `config.set` with the serialized result and `baseHash: hash`. Using source-shaped `raw` as the preferred baseline can misalign redacted secret paths with OpenClaw's runtime-shaped restore baseline.
|
||||
3. Retry one base-hash conflict from a fresh `config.get`; fail other RPC errors without writing around the running Gateway.
|
||||
4. Treat success as converged and do not send `SIGUSR1` or replace the process.
|
||||
3. Retry one base-hash conflict from a fresh `config.get`; fail other RPC errors without writing around the running Gateway. If `config.set` durably wrote the exact requested snapshot but its response was lost when OpenClaw began a native code-1012 reload, verify that persisted snapshot after Gateway leaves running state and accept the existing commit without replaying it.
|
||||
4. Treat success as converged and do not send `SIGUSR1` or schedule a redundant ClawX process replacement.
|
||||
5. If Gateway is stopped or starting, apply the same mutator to `resolveOpenClawConfigPath()` under the shared config lock and do not start the Gateway.
|
||||
|
||||
This is not a write-then-notify design. No provider, Agent, Channel, skill, proxy, image-generation, or plugin-install helper may write the active config independently. The coordinator prevents a locally read stale snapshot from overwriting concurrent Gateway or CLI config changes.
|
||||
@@ -16,6 +16,8 @@ Gateway WebSocket tracing must redact the complete serialized `raw` payload for
|
||||
|
||||
Coordinator-backed reads follow the same authority rule: prefer the runtime-shaped `config.get.config` object while Gateway is running and use JSON5 file parsing while it is not. Compound views derive all config-backed fields from one snapshot.
|
||||
|
||||
OpenClaw 2026.7.1 keeps auth-profile SQLite snapshots in memory. After a completed auth-store write batch, ClawX calls `secrets.reload` once when Gateway is running. `config.set` does not replace this refresh. Agent `models.json` needs no explicit RPC because OpenClaw re-reads it when its file fingerprint changes.
|
||||
OpenClaw 2026.7.1-2 keeps auth-profile SQLite snapshots in memory. After a completed auth-store write batch, ClawX calls `secrets.reload` once when Gateway is running. `config.set` does not replace this refresh. Agent `models.json` needs no explicit RPC because OpenClaw re-reads it when its file fingerprint changes.
|
||||
|
||||
Before launch, upgrade compatibility cleanup checks the canonical `state/openclaw.sqlite` update-check row. If it exists, the SQLite row is authoritative and any legacy root `update-check.json` is moved with restrictive permissions under `backups/`; otherwise the JSON remains in place for OpenClaw to import. This cleanup runs after the one-time upgrade snapshot and prevents harmless updater-bookkeeping differences from blocking Gateway readiness or triggering an ineffective doctor retry. The snapshot is removed after either the native ready event or a successful RPC-router readiness fallback, covering the race where a fast Gateway emits readiness before ClawX attaches its WebSocket client.
|
||||
|
||||
Full ClawX process replacement remains necessary after a successful coordinator commit when values are injected only at process creation, including proxy environment changes, or for explicit manual lifecycle and health/crash recovery. OpenClaw config categories must not be duplicated as a ClawX restart whitelist.
|
||||
|
||||
@@ -8,10 +8,10 @@ appliesTo:
|
||||
- gateway-backend-communication
|
||||
---
|
||||
|
||||
Main owns ACP process, SDK, routing lifecycle, and serialization of operations on the shared ACP connection; Renderer owns semantic reduction into an in-memory timeline. The local ACP child receives the authoritative `gatewayToken` from Electron store through `OPENCLAW_GATEWAY_TOKEN`; it must not rely on a separately resolved OpenClaw config credential or expose the token through CLI arguments or Renderer state. Notifications emitted during `session/load` are returned as one generation-scoped raw batch and reduced in one Renderer state commit. Renderer may temporarily buffer matching host events during the IPC result handoff, while each ordinary live prompt update continues through host events and is applied immediately without a Renderer batching timer. A pending prompt may retain a bounded Main routing context and Renderer timeline snapshot so navigation cannot drop its stream; those contexts must be keyed by session and generation, remain memory-only, and be released when the prompt settles. Permission requests are interactive only for an active prompt. Stale session generations are ignored, and ClawX does not persist a second ACP ledger or reduced Chat history.
|
||||
Main owns ACP process, SDK, routing lifecycle, and serialization of operations on the shared ACP connection; Renderer owns semantic reduction into an in-memory timeline. The local ACP child receives the authoritative `gatewayToken` from Electron store through `OPENCLAW_GATEWAY_TOKEN`; it must not rely on a separately resolved OpenClaw config credential or expose the token through CLI arguments or Renderer state. Notifications emitted during `session/load` are returned as one generation-scoped raw batch and reduced in one Renderer state commit. Renderer may temporarily buffer matching host events during the IPC result handoff, while each ordinary live prompt update continues through host events and is applied immediately without a Renderer batching timer. A pending prompt may retain a bounded Main routing context and Renderer timeline snapshot so navigation cannot drop its stream; those contexts must be keyed by session and generation, remain memory-only, and be released when the prompt settles. Gateway restart recovery must use explicit source-run lineage from trusted OpenClaw recovery provenance; ACP may adopt a new run only when `resumedFromRunId` matches its pending prompt, and Renderer must not reload a session based on Gateway runtime identity. An acknowledged prompt may remain pending for at most 60 seconds after disconnect so Gateway startup backoff and restart recovery can complete, while an unacknowledged send retains the 5-second deadline. Permission requests are interactive only for an active prompt. Stale session generations are ignored, and ClawX does not persist a second ACP ledger or reduced Chat history.
|
||||
|
||||
ACP is the preferred authority for every Chat semantic it exposes, including session routing, workspace and execution `cwd`, prompt/timeline state, and standard resource or attachment semantics. If ACP provides the value or event, implementations must not replace it with Gateway snapshots, transcript inference, local configuration, or a parallel projection. A bypass is permitted only when upstream ACP has no equivalent; it must be narrow, bounded, session- and generation-scoped, and documented with its rationale, source, limits, reconciliation behavior, and removal condition in a relevant Harness reference or rule.
|
||||
|
||||
ACP replay is the primary history authority. The only approved transcript-derived content supplements are best-effort recovery of asynchronous image-generation completions with proven `image_generate` context and recovery of explicit line-leading assistant OpenClaw `MEDIA:` attachment directives omitted by ACP. The general attachment exception does not require image-generation context, but it recovers only attachment references. When ACP replay for a cron session is completely empty, scheduled-task prompt and completion summaries may instead come from Main's typed cron-history host API. This cron exception must be anchored by Gateway `cron.runs` (with a Main-owned legacy file fallback), be generation-scoped and in memory, and never replace or duplicate non-empty ACP replay. When an anchored run summary carries OpenClaw's bounded-summary ellipsis, Main may recover that run's final assistant text from the identified run transcript only when it is longer and shares the complete persisted summary prefix; missing, mismatched, or unbounded summaries remain unchanged. A separate metadata-only supplement may annotate an ACP-replayed assistant turn with whole-turn duration because ACP `session/load` omits the original event timestamps; it cannot create turns or content. These exceptions remain marked and in memory; do not generalize them to bare paths, surrounding transcript prose, arbitrary ordinary messages, tool cards, plans, permissions, thoughts, file activity, or any parallel persisted history.
|
||||
ACP replay is the primary history authority. OpenClaw's native ACP adapter may reconstruct persisted transcript `toolCall` and `toolResult` records as standard `tool_call` and `tool_call_update` events when its structured event ledger is unavailable; ClawX consumes those events normally and does not perform that reconstruction itself. The only approved ClawX transcript-derived content supplements are best-effort recovery of asynchronous image-generation completions with proven `image_generate` context and recovery of explicit line-leading assistant OpenClaw `MEDIA:` attachment directives omitted by ACP. The general attachment exception does not require image-generation context, but it recovers only attachment references. When ACP replay for a cron session is completely empty, scheduled-task prompt and completion summaries may instead come from Main's typed cron-history host API. This cron exception must be anchored by Gateway `cron.runs` (with a Main-owned legacy file fallback), be generation-scoped and in memory, and never replace or duplicate non-empty ACP replay. When an anchored run summary carries OpenClaw's bounded-summary ellipsis, Main may recover that run's final assistant text from the identified run transcript only when it is longer and shares the complete persisted summary prefix; missing, mismatched, or unbounded summaries remain unchanged. A separate metadata-only supplement may annotate an ACP-replayed assistant turn with whole-turn duration because ACP `session/load` omits the original event timestamps; it cannot create turns or content. These exceptions remain marked and in memory; do not generalize them to bare paths, surrounding transcript prose, arbitrary ordinary messages, tool cards, plans, permissions, thoughts, file activity, or any parallel persisted history.
|
||||
|
||||
Historical transcript reads are limited to the newest `1000` message records. A successful live prompt reads content immediately and retries exactly once after `1500 ms`. General attachment and timing alignment treat history as a suffix and match the binary-free OpenClaw prompt-text projection of structured ACP user blocks by duplicate occurrence from the tail; they must not parse or globally remove user-authored resource marker text. Attachment-only empty projections remain eligible, and live content alignment also requires the current optimistic user identity. Every asynchronous result must retain the same active session, generation, supplement operation and attempt, and live turn where applicable. Unmatched, ambiguous, superseded, or stale work cannot mutate the timeline or timing annotations.
|
||||
|
||||
@@ -16,6 +16,7 @@ Rules:
|
||||
- disabling a bundled plugin is required when removing it from an allowlist is not sufficient to stop runtime loading
|
||||
- stale plugin registrations for unconfigured capabilities must be removed during sanitize or recovery paths
|
||||
- ClawX must include `web_search` in both `tools.deny` and `gateway.tools.deny`; existing deny entries remain user-owned and browser automation plus `web_fetch` remain available
|
||||
- ClawX must include `gateway`, `nodes`, `create_goal`, `get_goal`, and `update_goal` in both deny lists without blocking application-owned Gateway RPCs; it must not implicitly deny messaging, session orchestration, or agent discovery tools
|
||||
- when no embedding credentials or user-owned memory-search config exist, preserve `memory_search` through OpenClaw's explicit FTS-only provider instead of disabling the tool
|
||||
- migrations may replace only the exact legacy ClawX-managed memory-search default, must run at most once, and must preserve later user opt-outs
|
||||
- tests for config rewrites should assert the final active config, not only intermediate helper output
|
||||
|
||||
@@ -8,6 +8,10 @@ appliesTo:
|
||||
|
||||
When channel plugin ownership changes between bundled OpenClaw extensions and external `~/.openclaw/extensions/*` installs, ClawX must normalize configuration to one active plugin identity per channel.
|
||||
|
||||
The ClawX channel configuration catalog is intentionally limited to `telegram`, `discord`, `whatsapp`, `wechat`, `dingtalk`, `feishu`, `wecom`, and `qqbot`. OpenClaw may report other channel ids, but the ClawX Channels page must not expose them as configurable or editable channel groups. Filtering an unsupported runtime channel is presentation-only and must not delete or rewrite that channel's underlying OpenClaw configuration.
|
||||
|
||||
Channel credentials and account maps must remain under `channels.<id>`; `plugins.entries.<id>` is activation metadata and must not contain ClawX-generated `accounts` or `defaultAccount` fields. Discord, WhatsApp, and QQBot are external plugins in the pinned OpenClaw runtime and must retain explicit `plugins.allow` and `{ enabled }` entries. Saving changed configuration for a supported external plugin channel while Gateway is running must use the coordinator-owned `config.set` reload without scheduling a second ClawX full restart when OpenClaw peer link repair succeeds. When peer link repair fails after plugin install, Main must schedule the guarded full restart after the config commit instead of relying on the native reload alone. A no-change retry must still start the guarded full restart path after the scoped-binding commit so a newly copied or previously undiscovered plugin is loaded. Successful WeChat QR completion must likewise leave plugin activation on a single lifecycle path. The host save response may return while activation is still pending, provided it explicitly reports that state and failures are caught and surfaced through normal Gateway status/logging. If `config.set` durably commits before its response is lost to a native code-1012 reload, Main may verify that exact persisted config and treat the transaction as committed; it must not perform an out-of-band replay.
|
||||
|
||||
For Feishu/Lark specifically:
|
||||
|
||||
- a configured Feishu channel must not leave both the bundled `feishu` plugin and the legacy external `openclaw-lark` / `feishu-openclaw-plugin` registrations active at the same time
|
||||
|
||||
@@ -11,8 +11,8 @@ requiredTests:
|
||||
|
||||
WebSocket heartbeat misses are availability evidence. A short sequence is not proof that the local Gateway process is dead because long-running model, tool, compaction, and scheduled work may temporarily delay Gateway control-plane responses.
|
||||
|
||||
Misses one through nine must remain diagnostic-only: they must not terminate the socket, kill the owned Gateway process, or request `GatewayManager.restart`. A pong or any incoming Gateway message resets the sequence.
|
||||
Misses one through three must remain diagnostic-only: they must not terminate the socket, kill the owned Gateway process, or request `GatewayManager.restart`. A pong or any incoming Gateway message resets the sequence.
|
||||
|
||||
After ten consecutive missed responses, ClawX may treat the Gateway as persistently unresponsive and request the guarded `GatewayManager.restart` path only when auto-recovery is enabled and lifecycle state is still `running`. The heartbeat callback must not directly terminate the socket or process, and it must request recovery at most once per uninterrupted miss sequence.
|
||||
After four consecutive missed responses, ClawX may treat the Gateway as persistently unresponsive and request the guarded `GatewayManager.restart` path only when auto-recovery is enabled and lifecycle state is still `running`. The heartbeat callback must not directly terminate the socket or process, and it must request recovery at most once per uninterrupted miss sequence.
|
||||
|
||||
Authoritative child-process exit, WebSocket close, and Gateway restart close code 1012 signals retain their existing automatic lifecycle paths. Explicit user restart remains available.
|
||||
|
||||
@@ -14,7 +14,7 @@ ClawX must defer runtime config planning to the bundled OpenClaw Gateway.
|
||||
|
||||
The Main-owned config coordinator must own the entire read-modify-write transaction. Production helpers must not write the active OpenClaw config and then notify another layer afterward.
|
||||
|
||||
When the Gateway is running, the coordinator prefers the runtime-shaped `config.get.config` object as the mutation baseline, applies the caller's mutator, and commits through `config.set` with the returned `hash` as `baseHash`. Source-shaped `raw` is only a compatibility fallback because its redacted secret paths may not align with OpenClaw's write-side runtime snapshot. A successful mutation must not be followed by `SIGUSR1` or a ClawX process restart. Base-hash conflicts retry once from a new snapshot; other RPC failures fail closed instead of performing an out-of-band file write.
|
||||
When the Gateway is running, the coordinator prefers the runtime-shaped `config.get.config` object as the mutation baseline, applies the caller's mutator, and commits through `config.set` with the returned `hash` as `baseHash`. Source-shaped `raw` is only a compatibility fallback because its redacted secret paths may not align with OpenClaw's write-side runtime snapshot. A successful mutation must not be followed by `SIGUSR1` or a redundant ClawX process restart. Base-hash conflicts retry once from a new snapshot; other RPC failures fail closed instead of performing an out-of-band file write. When `config.set` itself durably writes the exact requested config and then a native code-1012 reload drops its response, the coordinator may verify that persisted commit after Gateway leaves running state and accept it without replaying or rewriting the mutation.
|
||||
|
||||
Coordinator mutators are replayable transformations. They must not perform filesystem writes, SQLite writes, settings writes, lifecycle actions, or other non-idempotent external effects; preload required external inputs before entering the mutator and perform follow-up effects only after a successful commit.
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ Typed generic Gateway RPC requests are validated by `electron/services/gateway-a
|
||||
|
||||
Channel/plugin migration behavior is also part of this scenario when ClawX rewrites OpenClaw config before Gateway launch. Upgrades must preserve single-owner channel registration for migrated plugin-backed channels such as Feishu/Lark.
|
||||
|
||||
ClawX's prelaunch config sanitizer also owns desktop tool policy. It must keep `web_search` in both the agent-level and Gateway-level deny lists without replacing existing deny entries or disabling managed browser automation and `web_fetch`.
|
||||
ClawX's prelaunch config sanitizer also owns desktop tool policy. It must keep `web_search` in both the agent-level and Gateway-level deny lists without replacing existing deny entries or disabling managed browser automation and `web_fetch`. It must also deny the agent-facing `gateway`, `nodes`, `create_goal`, `get_goal`, and `update_goal` tools at both layers while preserving application-owned Gateway RPCs. Messaging, session orchestration, and agent discovery tools remain available unless another explicit policy denies them.
|
||||
|
||||
Scheduled-task history is Main-owned backend data. Current OpenClaw versions must be queried through the Gateway `cron.runs` RPC; direct run-log file reads are allowed only as a compatibility fallback for older file-backed runtimes. When a run's bounded summary ends with OpenClaw's truncation ellipsis, Main may recover the complete final assistant reply from the run transcript identified by that `cron.runs` entry, but only when the transcript reply is longer and shares the entire summary prefix. When a cron base session has no ACP replay, Renderer may project that typed host result into a generation-scoped, in-memory historical ACP timeline, but must not replace or duplicate non-empty ACP replay.
|
||||
|
||||
@@ -99,4 +99,4 @@ The local HTML Preview privileged bridge is also Main-owned: Renderer may load a
|
||||
|
||||
Gateway session-catalog subscription, normalization, ordered list/event replay, attention transitions, and reconnect recovery are documented in `harness/reference/sidebar-session-attention.md`. Electron test-process isolation and global-resource scheduling are documented in `harness/reference/e2e-parallelism.md`.
|
||||
|
||||
Gateway WebSocket heartbeat misses are diagnostic availability signals for the first nine consecutive misses and must not interrupt long-running work during that window. A pong or any incoming Gateway message resets the sequence. On the tenth consecutive miss, Main may request the guarded Gateway restart path when auto-recovery is enabled and lifecycle state is still running; the heartbeat callback must not directly terminate the socket or process. Authoritative process-exit and socket-close signals retain their existing automatic lifecycle paths.
|
||||
Gateway WebSocket heartbeat misses are diagnostic availability signals for the first three consecutive misses and must not interrupt long-running work during that window. A pong or any incoming Gateway message resets the sequence. On the fourth consecutive miss, Main may request the guarded Gateway restart path when auto-recovery is enabled and lifecycle state is still running; the heartbeat callback must not directly terminate the socket or process. Authoritative process-exit and socket-close signals retain their existing automatic lifecycle paths.
|
||||
|
||||
@@ -33,7 +33,7 @@ ClawX should prefer OpenClaw-native signals over stderr string matching:
|
||||
|
||||
stderr is supporting evidence only. It should not be the primary source for deciding whether the Gateway is ready, blocked, or should be restarted.
|
||||
|
||||
WebSocket heartbeat misses show that the Gateway control plane did not answer within the observation window. The first nine consecutive misses remain diagnostic-only so transient pong delays do not interrupt long-running work. A pong or any incoming message resets the sequence. A tenth consecutive miss marks persistent unresponsiveness and may request the guarded Gateway restart path when auto-recovery is enabled and lifecycle state is still running. Process exit and socket close retain their existing automatic recovery paths.
|
||||
WebSocket heartbeat misses show that the Gateway control plane did not answer within the observation window. The first three consecutive misses remain diagnostic-only so transient pong delays do not interrupt long-running work. A pong or any incoming message resets the sequence. A fourth consecutive miss marks persistent unresponsiveness and may request the guarded Gateway restart path when auto-recovery is enabled and lifecycle state is still running. Process exit and socket close retain their existing automatic recovery paths.
|
||||
|
||||
## Failure Shape
|
||||
|
||||
@@ -278,7 +278,7 @@ pnpm exec openclaw gateway call status >/tmp/clawx-status.json
|
||||
- `health` and `status` are captured in Gateway diagnostics when available.
|
||||
- Memory doctor calls return when the memory capability is available.
|
||||
- `doctor.memory.*` and `channels.status` failures degrade their capability only and do not trigger Gateway restart.
|
||||
- The first nine consecutive heartbeat misses do not replace the Gateway process; the tenth records unresponsive diagnostics and requests one guarded restart when lifecycle auto-recovery is allowed.
|
||||
- The first three consecutive heartbeat misses do not replace the Gateway process; the fourth records unresponsive diagnostics and requests one guarded restart when lifecycle auto-recovery is allowed.
|
||||
- Logs no longer repeat stale runtime cache or escaped managed-skill symlink warnings for entries ClawX can safely clean.
|
||||
|
||||
## Required Regression Coverage
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
id: disable-internal-agent-tools
|
||||
title: Disable control-plane agent tools in ClawX
|
||||
taskType: runtime-bridge
|
||||
intent: Prevent ClawX agents from invoking Gateway, node, and goal-management control-plane tools while preserving application-owned Gateway RPCs and agent orchestration tools.
|
||||
scenario: gateway-backend-communication
|
||||
touchedAreas:
|
||||
- electron/utils/openclaw-auth.ts
|
||||
- tests/unit/openclaw-auth.test.ts
|
||||
- tests/unit/sanitize-config.test.ts
|
||||
- README.md
|
||||
- README.zh-CN.md
|
||||
- README.ja-JP.md
|
||||
- README.ru-RU.md
|
||||
- harness/specs/rules/active-config-guards.md
|
||||
- harness/specs/scenarios/gateway-backend-communication.md
|
||||
- harness/specs/tasks/disable-internal-agent-tools.md
|
||||
expectedUserBehavior:
|
||||
- ClawX agents cannot invoke gateway, nodes, create_goal, get_goal, or update_goal.
|
||||
- Message, session orchestration, subagent, and agent discovery tools remain available unless another explicit policy denies them.
|
||||
- Gateway-exposed tools cannot bypass the agent-level restriction.
|
||||
- Existing user-defined tool deny entries remain intact and in their original order.
|
||||
- ClawX application-owned Gateway RPCs continue to work because tool policy does not block internal RPC methods.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredTests:
|
||||
- tests/unit/openclaw-auth.test.ts
|
||||
- tests/unit/sanitize-config.test.ts
|
||||
acceptance:
|
||||
- Config sanitization adds every required control-plane tool to tools.deny and gateway.tools.deny without adding message or session-orchestration tools.
|
||||
- Repeated sanitization is idempotent and does not duplicate deny entries.
|
||||
- Existing deny entries are preserved in their original order.
|
||||
- Existing ClawX-required deny entries remain enforced.
|
||||
- Targeted unit tests, type checks, communication regression checks, and harness validation pass.
|
||||
docs:
|
||||
required: true
|
||||
---
|
||||
|
||||
ClawX keeps application-owned Gateway communication and agent orchestration available while preventing models from invoking selected control-plane tools directly. The restriction is enforced at both OpenClaw tool-policy layers.
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
id: fix-supported-channel-connectivity
|
||||
title: Restore supported channel configuration and plugin activation
|
||||
type: ai-coding-task
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Make every ClawX-supported plugin channel persist schema-valid configuration and become visible to the running Gateway after save or QR login.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/fix-supported-channel-connectivity.md
|
||||
- harness/specs/tasks/remove-unsupported-channel-catalog-entries.md
|
||||
- harness/specs/rules/channel-plugin-migration-guards.md
|
||||
- shared/types/channel.ts
|
||||
- shared/i18n/locales/en/channels.json
|
||||
- shared/i18n/locales/zh/channels.json
|
||||
- shared/i18n/locales/ja/channels.json
|
||||
- shared/i18n/locales/ru/channels.json
|
||||
- src/pages/Channels/index.tsx
|
||||
- electron/services/channels-api.ts
|
||||
- electron/utils/channel-config.ts
|
||||
- electron/utils/openclaw-auth.ts
|
||||
- electron/utils/plugin-install-index.ts
|
||||
- tests/unit/channels-page.test.tsx
|
||||
- tests/unit/channel-config.test.ts
|
||||
- tests/unit/openclaw-auth.test.ts
|
||||
- tests/unit/host-services.test.ts
|
||||
- tests/unit/plugin-install-index.test.ts
|
||||
- tests/e2e/channels-supported-catalog.spec.ts
|
||||
- tests/e2e/channels-plugin-save.spec.ts
|
||||
expectedUserBehavior:
|
||||
- Discord, WhatsApp, and QQBot save account credentials under channels.<id> without schema-invalid account mirrors under plugins.entries.<id>.
|
||||
- DingTalk, WeCom, Feishu/Lark, WeChat, Discord, WhatsApp, and QQBot trigger a guarded full Gateway restart when saved while the Gateway is running.
|
||||
- A no-change retry of a plugin-backed channel still performs the restart needed to discover an already copied plugin.
|
||||
- Telegram remains on the native OpenClaw config reload path without an extra ClawX restart.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
- e2e
|
||||
requiredRules:
|
||||
- channel-plugin-migration-guards
|
||||
- openclaw-config-delivery
|
||||
- gateway-readiness-policy
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- e2e-parallel-isolation
|
||||
- comms-regression
|
||||
- docs-sync
|
||||
requiredTests:
|
||||
- tests/unit/channel-config.test.ts
|
||||
- tests/unit/openclaw-auth.test.ts
|
||||
- tests/unit/host-services.test.ts
|
||||
- tests/unit/plugin-install-index.test.ts
|
||||
- tests/e2e/channels-plugin-save.spec.ts
|
||||
acceptance:
|
||||
- Plugin entries contain activation metadata only and never channel account credentials.
|
||||
- Discord, WhatsApp, and QQBot output passes the OpenClaw 2026.7.1 plugin-entry schema shape.
|
||||
- External channel plugin ids are retained in plugins.allow even when no unrelated plugin is present.
|
||||
- Trusted plugin install metadata targets OpenClaw's active state/openclaw.sqlite database.
|
||||
- Plugin-backed saves await the guarded Gateway restart path when the Gateway was running at request start.
|
||||
- No Renderer transport or direct Gateway request is added.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
@@ -44,4 +44,4 @@ docs:
|
||||
required: true
|
||||
---
|
||||
|
||||
Historical first-stage heartbeat safety task. Its observability-only recovery policy is superseded by `restore-gateway-heartbeat-recovery-after-ten-misses`, which keeps the first nine misses diagnostic-only and permits guarded recovery on the tenth consecutive miss.
|
||||
Historical first-stage heartbeat safety task. Its observability-only recovery policy is superseded by `restore-gateway-heartbeat-recovery-after-four-misses`, which keeps the first three misses diagnostic-only and permits guarded recovery on the fourth consecutive miss.
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
id: optimize-channel-delete-latency
|
||||
title: Make channel deletion responsive while preserving durable cleanup
|
||||
type: ai-coding-task
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Remove a deleted channel or account from the Channels UI immediately after confirmation while Main completes the durable OpenClaw configuration and binding cleanup.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/optimize-channel-delete-latency.md
|
||||
- harness/specs/tasks/optimize-channel-save-latency.md
|
||||
- harness/specs/tasks/fix-supported-channel-connectivity.md
|
||||
- harness/specs/tasks/remove-unsupported-channel-catalog-entries.md
|
||||
- harness/specs/rules/channel-plugin-migration-guards.md
|
||||
- shared/host-api/contract.ts
|
||||
- shared/types/channel.ts
|
||||
- shared/i18n/locales/en/channels.json
|
||||
- shared/i18n/locales/zh/channels.json
|
||||
- shared/i18n/locales/ja/channels.json
|
||||
- shared/i18n/locales/ru/channels.json
|
||||
- electron/services/channels-api.ts
|
||||
- electron/utils/channel-config.ts
|
||||
- electron/utils/openclaw-auth.ts
|
||||
- src/components/channels/ChannelConfigModal.tsx
|
||||
- src/pages/Channels/index.tsx
|
||||
- tests/unit/agent-config.test.ts
|
||||
- tests/unit/channel-config.test.ts
|
||||
- tests/unit/host-services.test.ts
|
||||
- tests/unit/openclaw-auth.test.ts
|
||||
- tests/unit/channels-page.test.tsx
|
||||
- tests/e2e/channels-plugin-save.spec.ts
|
||||
- tests/e2e/channels-supported-catalog.spec.ts
|
||||
- tests/e2e/channels-delete-latency.spec.ts
|
||||
expectedUserBehavior:
|
||||
- Confirming deletion closes the confirmation dialog and removes the target row immediately instead of blocking on Gateway/OpenClaw configuration delivery.
|
||||
- Main still durably deletes the channel configuration and associated binding.
|
||||
- A failed deletion reports an error and refreshes the file-backed channel view to restore the actual state.
|
||||
- Runtime convergence refresh remains asynchronous and does not block the delete interaction.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
- e2e
|
||||
requiredRules:
|
||||
- channel-plugin-migration-guards
|
||||
- openclaw-config-delivery
|
||||
- gateway-readiness-policy
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- e2e-parallel-isolation
|
||||
- comms-regression
|
||||
- docs-sync
|
||||
requiredTests:
|
||||
- tests/unit/channels-page.test.tsx
|
||||
- tests/e2e/channels-delete-latency.spec.ts
|
||||
acceptance:
|
||||
- The UI applies deletion optimistically before the host delete promise settles.
|
||||
- The host delete request remains the only mutation path; Renderer does not edit OpenClaw configuration directly.
|
||||
- Failure triggers a config-only refresh rather than leaving stale optimistic state.
|
||||
- No direct Renderer Gateway request or new transport path is added.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
id: optimize-channel-save-latency
|
||||
title: Return promptly after durable channel saves while activation continues
|
||||
type: ai-coding-task
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Reduce the time the channel configuration modal remains blocked by returning after configuration and binding commits, while plugin activation uses one Gateway lifecycle path without racing native config reloads.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/optimize-channel-save-latency.md
|
||||
- harness/specs/tasks/fix-supported-channel-connectivity.md
|
||||
- harness/specs/tasks/remove-unsupported-channel-catalog-entries.md
|
||||
- harness/specs/rules/channel-plugin-migration-guards.md
|
||||
- harness/specs/rules/openclaw-config-delivery.md
|
||||
- harness/reference/openclaw-config-delivery.md
|
||||
- shared/host-api/contract.ts
|
||||
- shared/types/channel.ts
|
||||
- shared/i18n/locales/en/channels.json
|
||||
- shared/i18n/locales/zh/channels.json
|
||||
- shared/i18n/locales/ja/channels.json
|
||||
- shared/i18n/locales/ru/channels.json
|
||||
- electron/services/channels-api.ts
|
||||
- electron/gateway/config-delivery.ts
|
||||
- electron/gateway/manager.ts
|
||||
- electron/gateway/startup-orchestrator.ts
|
||||
- electron/utils/channel-config.ts
|
||||
- electron/utils/openclaw-auth.ts
|
||||
- electron/utils/plugin-install.ts
|
||||
- src/components/channels/ChannelConfigModal.tsx
|
||||
- src/pages/Channels/index.tsx
|
||||
- tests/unit/channel-config.test.ts
|
||||
- tests/unit/agent-config.test.ts
|
||||
- tests/unit/host-services.test.ts
|
||||
- tests/unit/gateway-config-delivery.test.ts
|
||||
- tests/unit/gateway-startup-orchestrator.test.ts
|
||||
- tests/unit/openclaw-auth.test.ts
|
||||
- tests/unit/plugin-install.test.ts
|
||||
- tests/unit/channels-page.test.tsx
|
||||
- tests/e2e/channels-plugin-save.spec.ts
|
||||
- tests/e2e/channels-supported-catalog.spec.ts
|
||||
expectedUserBehavior:
|
||||
- Saving a plugin-backed channel returns as soon as its configuration and scoped binding are durably committed instead of waiting for Gateway stop, startup, and readiness.
|
||||
- The Channels page immediately reloads the committed local configuration and then converges to runtime connection state after the scheduled Gateway restart.
|
||||
- Changed plugin configuration uses OpenClaw's native config reload without an additional ClawX full restart when OpenClaw peer link repair succeeds; a failed peer link repair schedules the guarded restart path after the config commit, and no-change retries still use that path when plugin discovery is required.
|
||||
- A config commit whose acknowledgement is lost to native reload is verified from the durable config instead of being reported as a false save failure.
|
||||
- A stale owned process that fails to recover from an in-process restart is terminated promptly and replaced instead of holding startup for the full cold-start retry budget.
|
||||
- Restart failures remain visible through normal Gateway status and logging rather than becoming unhandled promise rejections.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
- e2e
|
||||
requiredRules:
|
||||
- channel-plugin-migration-guards
|
||||
- openclaw-config-delivery
|
||||
- gateway-readiness-policy
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- e2e-parallel-isolation
|
||||
- comms-regression
|
||||
- docs-sync
|
||||
requiredTests:
|
||||
- tests/unit/agent-config.test.ts
|
||||
- tests/unit/host-services.test.ts
|
||||
- tests/unit/gateway-config-delivery.test.ts
|
||||
- tests/unit/gateway-startup-orchestrator.test.ts
|
||||
- tests/unit/plugin-install.test.ts
|
||||
- tests/unit/channels-page.test.tsx
|
||||
- tests/e2e/channels-plugin-save.spec.ts
|
||||
acceptance:
|
||||
- The save response exposes when plugin activation is pending.
|
||||
- A changed plugin config relies on the coordinator-owned config.set reload without a redundant full restart when peer link repair succeeds, and schedules a guarded full restart after commit when peer link repair fails.
|
||||
- A no-change plugin save starts a guarded Gateway restart only after the scoped binding commit completes.
|
||||
- The save response does not await Gateway restart readiness.
|
||||
- Immediate post-save refresh is config-only and does not issue an expensive runtime probe while Gateway is restarting.
|
||||
- No Renderer transport or direct Gateway request is added.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
id: recover-acp-session-after-gateway-restart
|
||||
title: Continue an ACP prompt through Gateway restart recovery
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Preserve an accepted ACP prompt while OpenClaw replaces an interrupted run with an explicitly linked restart-recovery run.
|
||||
touchedAreas:
|
||||
- .gitignore
|
||||
- README.md
|
||||
- README.zh-CN.md
|
||||
- README.ja-JP.md
|
||||
- README.ru-RU.md
|
||||
- docs/en-US/architecture.md
|
||||
- docs/zh-CN/architecture.md
|
||||
- docs/ja-JP/architecture.md
|
||||
- docs/ru-RU/architecture.md
|
||||
- harness/reference/acp-chat.md
|
||||
- harness/specs/rules/acp-chat-state-and-history.md
|
||||
- harness/specs/tasks/recover-acp-session-after-gateway-restart.md
|
||||
- pnpm-workspace.yaml
|
||||
- pnpm-lock.yaml
|
||||
- patches/openclaw@2026.7.1-2.patch
|
||||
- tests/unit/openclaw-restart-recovery-patch.test.ts
|
||||
- tests/e2e/chat-acp-inline-timeline.spec.ts
|
||||
expectedUserBehavior:
|
||||
- An accepted ACP prompt stays pending for up to 60 seconds while the Gateway reconnects and OpenClaw starts restart recovery; a send that was never acknowledged retains the 5-second disconnect deadline.
|
||||
- Events from the explicitly linked recovery run continue the original in-memory turn and settle its original prompt.
|
||||
- Recovered text, tool activity, approvals, cancellation, and terminal state use the new run id without a Renderer session reload; tool calls keep text segments on either side distinct.
|
||||
- After ClawX restarts, ACP `session/load` restores persisted transcript tool calls and results as native tool updates in their original order between assistant text segments.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredRules:
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- gateway-readiness-policy
|
||||
- acp-chat-state-and-history
|
||||
- comms-regression
|
||||
- docs-sync
|
||||
requiredTests:
|
||||
- pnpm exec vitest run tests/unit/openclaw-restart-recovery-patch.test.ts
|
||||
- pnpm exec playwright test tests/e2e/chat-acp-inline-timeline.spec.ts --project=parallel --grep "renders ledger-style replayed ACP tool events"
|
||||
- pnpm run typecheck
|
||||
- pnpm run comms:replay
|
||||
- pnpm run comms:compare
|
||||
acceptance:
|
||||
- The unique interrupted run id is propagated only by trusted main-session restart recovery and never inferred from a session key.
|
||||
- Chat and agent events expose `resumedFromRunId`, and ACP adopts the new run only when it matches the pending prompt.
|
||||
- Adoption clears the stale disconnect deadline, resets per-run stream state, rebinds cancellation, and retains tool and approval delivery after reconnect.
|
||||
- Visible recovery tool events reach the exact session-message subscription with `resumedFromRunId`, even when no global session-event subscription exists.
|
||||
- Reconnect reconciliation waits for the exact session-message subscription request to settle, so recovery dispatch cannot race ahead of tool-event registration.
|
||||
- OpenClaw's ACP transcript fallback maps persisted assistant `toolCall` blocks and `toolResult` messages to `tool_call` and `tool_call_update` rather than flattening adjacent assistant text.
|
||||
- String-valued and structured transcript tool results retain visible output in the replayed tool card.
|
||||
- The initial 5-second disconnect check extends only acknowledged prompts to a bounded 60-second total recovery window; unacknowledged prompts still reject after 5 seconds.
|
||||
- Renderer remains unchanged and ACP replay remains the source of truth for persisted Chat history.
|
||||
docs:
|
||||
required: true
|
||||
---
|
||||
|
||||
## Original Problem
|
||||
|
||||
If the Gateway restarted while the AI was replying, ClawX could reconnect to the replacement Gateway process but the active ACP conversation did not resume. OpenClaw could start a restart-recovery run and continue producing output, while ClawX remained frozen at the last text received before the disconnect. The original `session/prompt` eventually failed or stayed disconnected from the replacement run because ACP still identified the turn by the interrupted run id and had no trusted lineage proving that the new run continued it.
|
||||
|
||||
The failure was not limited to the live UI. Even when OpenClaw completed and persisted the recovered response, restarting ClawX and selecting the same conversation could still replay only the timeline prefix that existed before the disconnect. The ACP event history associated with the original prompt had not received the replacement run's events, and transcript fallback did not preserve the complete native timeline: it projected text and thinking while dropping persisted `toolCall` and `toolResult` records. Missing tool events also removed the boundaries between assistant text segments around those calls.
|
||||
|
||||
The broken flow therefore had two related symptoms:
|
||||
|
||||
- Live recovery: Gateway connectivity returned, but the replacement run's text, tools, approvals, cancellation target, and terminal state did not settle the original in-memory ACP prompt.
|
||||
- Reload recovery: after restarting ClawX, `session/load` could reproduce the stale pre-disconnect view or an incomplete flattened response instead of the recovered conversation that OpenClaw had persisted.
|
||||
|
||||
## Fix
|
||||
|
||||
`patches/openclaw@2026.7.1-2.patch` is the ClawX-local backport applied to the pinned `openclaw@2026.7.1-2` runtime. It restores one explicit recovery chain from the interrupted run through live ACP delivery and persisted replay:
|
||||
|
||||
- Persist the active lifecycle run id before a restart and allow only trusted `main_session_restart_recovery` provenance to pass it into a distinct replacement run as `internalRestartRecoverySourceRunId`.
|
||||
- Project that source id as `resumedFromRunId` on Chat, agent, tool, and approval events. ACP adopts a replacement run only when this value exactly matches its pending prompt; it never infers lineage from a shared session key.
|
||||
- Keep an acknowledged prompt pending for a bounded 60-second total recovery window while retaining the original 5-second deadline for a send that was never acknowledged. Adoption clears the stale disconnect deadline, resets per-run text, thought, and tool state, rebinds cancellation, and lets the replacement terminal event settle the original prompt.
|
||||
- Re-register the exact `sessions.messages.subscribe` subscription before reconnect reconciliation can dispatch recovery work. Gateway mirrors visible recovery tool events to that exact subscriber, includes `resumedFromRunId`, and deduplicates clients that already receive the run-scoped event.
|
||||
- When a complete structured ACP ledger is unavailable, map persisted assistant `toolCall` blocks and `toolResult` messages to native `tool_call` and `tool_call_update` updates in transcript order. Both structured and string-valued results retain visible output, and a text-tool-text sequence reloads as two distinct assistant text segments around the tool card.
|
||||
|
||||
ClawX Renderer remains unchanged. It continues to reduce standard ACP updates into one in-memory timeline; the repair is in OpenClaw's recovery lineage, Gateway event projection, ACP prompt reconciliation, and ACP replay fallback.
|
||||
|
||||
The same source-level fixes will be submitted to the OpenClaw upstream repository as a pull request. This local generated-dist patch is a temporary compatibility measure: after the upstream PR is merged and ClawX upgrades to an OpenClaw release containing the fixes, `patches/openclaw@2026.7.1-2.patch` should be removed rather than carried forward to a newer generated bundle.
|
||||
|
||||
## Scope
|
||||
|
||||
The dependency patch backports current OpenClaw run-lineage, recovered tool delivery, and native ACP transcript replay behavior to the pinned `openclaw@2026.7.1-2` runtime. It patches generated runtime chunks and declarations, so every OpenClaw version change must regenerate and review the patch rather than carrying it forward by filename.
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
id: remove-unsupported-channel-catalog-entries
|
||||
title: Remove unsupported channels from the ClawX channel catalog
|
||||
type: ai-coding-task
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Keep the ClawX channel configuration UI limited to the eight integrations that ClawX installs and supports.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/remove-unsupported-channel-catalog-entries.md
|
||||
- harness/specs/rules/channel-plugin-migration-guards.md
|
||||
- shared/types/channel.ts
|
||||
- shared/i18n/locales/en/channels.json
|
||||
- shared/i18n/locales/zh/channels.json
|
||||
- shared/i18n/locales/ja/channels.json
|
||||
- shared/i18n/locales/ru/channels.json
|
||||
- src/pages/Channels/index.tsx
|
||||
- tests/unit/channels-page.test.tsx
|
||||
- tests/e2e/channels-supported-catalog.spec.ts
|
||||
expectedUserBehavior:
|
||||
- The Channels page offers only Telegram, Discord, WhatsApp, WeChat, DingTalk, Feishu/Lark, WeCom, and QQBot.
|
||||
- Signal, iMessage, Matrix, LINE, Microsoft Teams, Google Chat, and Mattermost are not shown as configurable or configured ClawX channels.
|
||||
- Runtime reports for unknown OpenClaw channels do not create editable cards in the ClawX Channels page.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
- e2e
|
||||
requiredRules:
|
||||
- channel-plugin-migration-guards
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- e2e-parallel-isolation
|
||||
- docs-sync
|
||||
requiredTests:
|
||||
- tests/unit/channels-page.test.tsx
|
||||
- tests/e2e/channels-supported-catalog.spec.ts
|
||||
acceptance:
|
||||
- The shared ChannelType and channel metadata catalog contain exactly the eight ClawX-supported channel ids.
|
||||
- Unsupported channel metadata and translations are removed from every supported locale.
|
||||
- The Channels page ignores unsupported channel groups returned by the runtime without deleting their OpenClaw configuration.
|
||||
- No new direct IPC or Gateway transport is introduced.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
+14
-14
@@ -1,11 +1,11 @@
|
||||
---
|
||||
id: restore-gateway-heartbeat-recovery-after-ten-misses
|
||||
title: Restore Gateway heartbeat recovery after ten misses
|
||||
id: restore-gateway-heartbeat-recovery-after-four-misses
|
||||
title: Restore Gateway heartbeat recovery after four misses
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Recover a persistently unresponsive local Gateway automatically while giving long-running work a longer heartbeat window before process replacement.
|
||||
intent: Recover a persistently unresponsive local Gateway automatically while giving long-running work a bounded heartbeat window before process replacement.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/restore-gateway-heartbeat-recovery-after-ten-misses.md
|
||||
- harness/specs/tasks/restore-gateway-heartbeat-recovery-after-four-misses.md
|
||||
- harness/specs/tasks/make-gateway-heartbeat-observability-only.md
|
||||
- harness/specs/rules/gateway-heartbeat-safety.md
|
||||
- harness/specs/scenarios/gateway-backend-communication.md
|
||||
@@ -19,9 +19,9 @@ touchedAreas:
|
||||
- README.zh-CN.md
|
||||
- README.ja-JP.md
|
||||
expectedUserBehavior:
|
||||
- One to nine consecutive missed Gateway heartbeat responses remain diagnostic-only and do not interrupt long-running work.
|
||||
- Ten consecutive missed heartbeat responses mark the Gateway unresponsive and request an automatic restart when lifecycle auto-recovery is enabled and the Gateway is still running.
|
||||
- A pong or any incoming Gateway message before the tenth miss resets the consecutive-miss counter.
|
||||
- One to three consecutive missed Gateway heartbeat responses remain diagnostic-only and do not interrupt long-running work.
|
||||
- Four consecutive missed heartbeat responses mark the Gateway unresponsive and request an automatic restart when lifecycle auto-recovery is enabled and the Gateway is still running.
|
||||
- A pong or any incoming Gateway message before the fourth miss resets the consecutive-miss counter.
|
||||
- Process exit, WebSocket close, explicit restart, and code-1012 reconnect behavior remain unchanged.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
@@ -37,15 +37,15 @@ requiredTests:
|
||||
- tests/unit/gateway-manager-diagnostics.test.ts
|
||||
- tests/unit/gateway-connection-monitor.test.ts
|
||||
acceptance:
|
||||
- The Gateway heartbeat threshold is ten consecutive misses.
|
||||
- Misses one through nine update monitor state without calling GatewayManager.restart or terminating the socket.
|
||||
- The tenth consecutive miss records timeout diagnostics and calls GatewayManager.restart exactly once when auto-recovery is allowed.
|
||||
- The tenth miss does not restart when auto-reconnect is disabled or the Gateway is not running.
|
||||
- Recovery through a pong or any incoming Gateway message resets the sequence, so only ten new consecutive misses can trigger recovery.
|
||||
- The Gateway heartbeat threshold is four consecutive misses.
|
||||
- Misses one through three update monitor state without calling GatewayManager.restart or terminating the socket.
|
||||
- The fourth consecutive miss records timeout diagnostics and calls GatewayManager.restart exactly once when auto-recovery is allowed.
|
||||
- The fourth miss does not restart when auto-reconnect is disabled or the Gateway is not running.
|
||||
- Recovery through a pong or any incoming Gateway message resets the sequence, so only four new consecutive misses can trigger recovery.
|
||||
- Automatic heartbeat recovery behaves consistently on Windows, macOS, and Linux.
|
||||
- Documentation in all maintained README translations describes the ten-miss automatic recovery policy.
|
||||
- Documentation in all maintained README translations describes the four-miss automatic recovery policy.
|
||||
docs:
|
||||
required: true
|
||||
---
|
||||
|
||||
This task supersedes the recovery policy from `make-gateway-heartbeat-observability-only`: heartbeat misses remain non-authoritative during the first nine misses, while a tenth consecutive miss is treated as persistent unresponsiveness and may request guarded lifecycle recovery.
|
||||
This task supersedes the recovery policy from `make-gateway-heartbeat-observability-only`: heartbeat misses remain non-authoritative during the first three misses, while a fourth consecutive miss is treated as persistent unresponsiveness and may request guarded lifecycle recovery.
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
id: upgrade-openclaw-2026-7-1-2
|
||||
title: Upgrade the bundled OpenClaw runtime to 2026.7.1-2
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Apply the OpenClaw 2026.7.1 correction releases without regressing ClawX channels, providers, models, ACP chat, or packaged runtime behavior.
|
||||
touchedAreas:
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
- pnpm-workspace.yaml
|
||||
- patches/openclaw@2026.7.1-2.patch
|
||||
- electron/gateway/config-sync.ts
|
||||
- electron/gateway/manager.ts
|
||||
- electron/utils/openclaw-upgrade-snapshot.ts
|
||||
- tests/unit/gateway-ready-fallback.test.ts
|
||||
- tests/unit/openclaw-bundle-config.test.ts
|
||||
- tests/unit/openclaw-upgrade-snapshot.test.ts
|
||||
- tests/unit/openclaw-restart-recovery-patch.test.ts
|
||||
- harness/reference/openclaw-config-delivery.md
|
||||
- harness/specs/tasks/upgrade-openclaw-2026-7-1.md
|
||||
- harness/specs/tasks/upgrade-openclaw-2026-7-1-2.md
|
||||
expectedUserBehavior:
|
||||
- A direct ClawX 0.5.1 upgrade from OpenClaw 2026.6.10 preserves configuration, authentication, sessions, ClawX-managed selected models, channel credentials, and managed channel plugin identities through the inherited 2026.7.1 migration.
|
||||
- Manual catalog-only model references require explicit upgrade preflight because the upstream unconfigured global model catalog is smaller in 2026.7.1 than in 2026.6.10; explicit ClawX-managed provider models are not dependent on that catalog entry remaining built in.
|
||||
- Existing configuration, authentication, sessions, models, and channel credentials remain usable after upgrading from OpenClaw 2026.7.1.
|
||||
- The supported ClawX channel catalog and effective plugin identities remain unchanged.
|
||||
- Official managed plugin updates tolerate repaired npm lock metadata and singleton-array npm view responses.
|
||||
- Codex turns continue to their authoritative terminal result after progress replies.
|
||||
- Legacy migration residue, Memory Core derived-sidecar conflicts, and guarded WSL EROFS permission results do not cause avoidable Gateway startup failures.
|
||||
- When canonical SQLite update-check state already exists, conflicting legacy update-check JSON is backed up outside the active state root before launch instead of causing a doctor retry loop.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredTests:
|
||||
- tests/unit/gateway-ready-fallback.test.ts
|
||||
- tests/unit/openclaw-bundle-config.test.ts
|
||||
- tests/unit/openclaw-upgrade-snapshot.test.ts
|
||||
- tests/unit/plugin-install.test.ts
|
||||
- tests/unit/plugin-install-index.test.ts
|
||||
- tests/unit/channel-config.test.ts
|
||||
- tests/unit/openclaw-auth.test.ts
|
||||
- tests/unit/acp-chat-service.test.ts
|
||||
- tests/unit/gateway-startup-orchestrator.test.ts
|
||||
- tests/unit/openclaw-restart-recovery-patch.test.ts
|
||||
acceptance:
|
||||
- The OpenClaw runtime is pinned to 2026.7.1-2 and resolves @openclaw/ai 2026.7.1-2.
|
||||
- External channel plugin package versions and ClawX's supported channel catalog remain unchanged because the correction release does not change channel APIs or manifests.
|
||||
- Provider and model configuration behavior remains unchanged because the correction release does not change provider or model catalog sources.
|
||||
- The bundled-runtime patch and pruning pipeline succeeds against OpenClaw 2026.7.1-2.
|
||||
- Prelaunch preserves legacy update-check JSON for upstream import when SQLite has no canonical row, and quarantines it with restrictive permissions when SQLite is already authoritative.
|
||||
- The pre-migration snapshot is removed after either the native ready event or an equivalent successful RPC-router readiness fallback, so a missed early event does not leave duplicated auth/SQLite secrets behind.
|
||||
- Type checks, targeted channel/provider/ACP tests, communication regression checks, and harness validation pass.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
|
||||
Use this task spec for the correction-release upgrade from OpenClaw 2026.7.1
|
||||
to 2026.7.1-2. It inherits the runtime and migration compatibility work captured
|
||||
in `upgrade-openclaw-2026-7-1.md` and focuses on proving that the correction
|
||||
release does not widen ClawX's channel or model surface.
|
||||
@@ -29,6 +29,7 @@ touchedAreas:
|
||||
- tests/e2e/cron-run-live-status.spec.ts
|
||||
- tests/unit/gateway-startup-recovery.test.ts
|
||||
- tests/unit/gateway-startup-orchestrator.test.ts
|
||||
- tests/unit/gateway-ready-fallback.test.ts
|
||||
- tests/unit/openclaw-cli.test.ts
|
||||
- tests/unit/openclaw-bundle-config.test.ts
|
||||
- tests/unit/openclaw-upgrade-snapshot.test.ts
|
||||
@@ -39,11 +40,13 @@ touchedAreas:
|
||||
- README.zh-CN.md
|
||||
- README.ja-JP.md
|
||||
- README.ru-RU.md
|
||||
- harness/reference/openclaw-config-delivery.md
|
||||
- harness/specs/scenarios/gateway-backend-communication.md
|
||||
- harness/specs/rules/acp-chat-state-and-history.md
|
||||
- harness/specs/tasks/upgrade-openclaw-2026-7-1.md
|
||||
- harness/specs/tasks/upgrade-openclaw-2026-7-1-2.md
|
||||
expectedUserBehavior:
|
||||
- Existing OpenClaw 2026.6.10 configuration, authentication, sessions, and channel credentials remain usable after upgrade, with a one-time pre-migration snapshot of migration-critical config/auth/SQLite state that is removed after Gateway startup succeeds.
|
||||
- Existing ClawX-managed OpenClaw 2026.6.10 configuration, authentication, sessions, selected provider models, and channel credentials remain usable after upgrade, with a one-time pre-migration snapshot of migration-critical config/auth/SQLite state that is removed after Gateway startup succeeds.
|
||||
- ClawX reconciles old managed channel-plugin install records with its current mirrored extensions, removes records for unconfigured mirrors, and links declared `openclaw` peers to the bundled runtime before OpenClaw's post-core payload smoke check.
|
||||
- ClawX starts and communicates with the bundled OpenClaw 2026.7.1 Gateway, including migration and control-plane safe-mode startup states.
|
||||
- ClawX registers the compatibility-patched WeCom mirror as a local-path install with static channel metadata so OpenClaw startup migration does not replace it with the raw mismatched npm package.
|
||||
@@ -79,6 +82,7 @@ acceptance:
|
||||
- Configured mirrored plugins that declare an `openclaw` peer have a runtime link to the current bundled OpenClaw package before migration validation; stale install records for unconfigured mirrors are removed so missing directories cannot block startup.
|
||||
- Gateway recovery performs at most one doctor repair per startup flow and does not retry fatal runtime, EX_CONFIG, invalid migration, or active migration-lease failures indefinitely.
|
||||
- Electron Main reads current cron history through Gateway `cron.runs`, retains legacy JSONL as a compatibility fallback, and supplements only empty cron ACP replay in memory without replacing non-empty replay.
|
||||
- ClawX-managed selected models remain explicit in provider configuration, but upgrade preflight treats manual catalog-only model references separately: OpenClaw's unconfigured `models list --all` catalog changes from 140 entries in 2026.6.10 to 86 in 2026.7.1, removing the built-in Venice, Fireworks, Tencent TokenHub, and Z.AI catalog groups plus several older Moonshot entries.
|
||||
- ACP 1.1 type checks, targeted runtime tests, communication regression checks, and harness validation pass.
|
||||
docs:
|
||||
required: true
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawx",
|
||||
"version": "0.5.3",
|
||||
"version": "0.5.4",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@discordjs/opus",
|
||||
@@ -175,7 +175,7 @@
|
||||
"monaco-editor": "^0.55.1",
|
||||
"mpg123-decoder": "^1.0.3",
|
||||
"ms": "^2.1.3",
|
||||
"openclaw": "2026.7.1",
|
||||
"openclaw": "2026.7.1-2",
|
||||
"opusscript": "^0.1.1",
|
||||
"pdfjs-dist": "^5.7.284",
|
||||
"playwright-core": "1.59.1",
|
||||
|
||||
@@ -0,0 +1,695 @@
|
||||
diff --git a/dist/acp-cli-BXc5GttU.js b/dist/acp-cli-BXc5GttU.js
|
||||
index 6c7de7d60ac87a733632c36a744ada0e4e48d077..62797972ce78076c545d712809d99f4a13f8e0ee 100644
|
||||
--- a/dist/acp-cli-BXc5GttU.js
|
||||
+++ b/dist/acp-cli-BXc5GttU.js
|
||||
@@ -1622,8 +1622,25 @@ function buildSessionUsageSnapshot(row) {
|
||||
}
|
||||
//#endregion
|
||||
//#region src/acp/translator.replay.ts
|
||||
+function extractToolResultReplay(message) {
|
||||
+ const toolCallId = normalizeOptionalString(message.toolCallId);
|
||||
+ if (!toolCallId) return [];
|
||||
+ const rawOutput = {
|
||||
+ content: message.content,
|
||||
+ ...message.details === void 0 ? {} : { details: message.details }
|
||||
+ };
|
||||
+ return [{
|
||||
+ sessionUpdate: "tool_call_update",
|
||||
+ toolCallId,
|
||||
+ status: message.isError === true ? "failed" : "completed",
|
||||
+ rawOutput,
|
||||
+ content: extractToolCallContent(message.content) ?? extractToolCallContent(rawOutput),
|
||||
+ locations: extractToolCallLocations(rawOutput)
|
||||
+ }];
|
||||
+}
|
||||
function extractReplayChunks(message) {
|
||||
const role = typeof message.role === "string" ? message.role : "";
|
||||
+ if (role === "toolResult") return extractToolResultReplay(message);
|
||||
if (role !== "user" && role !== "assistant") return [];
|
||||
if (typeof message.content === "string") return message.content.length > 0 ? [{
|
||||
sessionUpdate: role === "user" ? "user_message_chunk" : "agent_message_chunk",
|
||||
@@ -1641,6 +1658,22 @@ function extractReplayChunks(message) {
|
||||
});
|
||||
continue;
|
||||
}
|
||||
+ if (role === "assistant" && typedBlock.type === "toolCall") {
|
||||
+ const toolCallId = normalizeOptionalString(typedBlock.id);
|
||||
+ const name = normalizeOptionalString(typedBlock.name);
|
||||
+ if (!toolCallId) continue;
|
||||
+ const args = asOptionalRecord(typedBlock.arguments);
|
||||
+ replayChunks.push({
|
||||
+ sessionUpdate: "tool_call",
|
||||
+ toolCallId,
|
||||
+ title: formatToolTitle(name, args),
|
||||
+ status: "in_progress",
|
||||
+ rawInput: args,
|
||||
+ kind: inferToolKind(name),
|
||||
+ locations: extractToolCallLocations(args)
|
||||
+ });
|
||||
+ continue;
|
||||
+ }
|
||||
if (role === "assistant" && typedBlock.type === "thinking" && typeof typedBlock.thinking === "string" && typedBlock.thinking) replayChunks.push({
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
text: typedBlock.thinking
|
||||
@@ -1849,6 +1882,7 @@ const ACP_AGENT_INFO = {
|
||||
const MAX_PROMPT_BYTES = 2 * 1024 * 1024;
|
||||
const ACP_LOAD_SESSION_REPLAY_LIMIT = 1e6;
|
||||
const ACP_GATEWAY_DISCONNECT_GRACE_MS = 5e3;
|
||||
+const ACP_GATEWAY_ACCEPTED_PROMPT_RECOVERY_GRACE_MS = 6e4;
|
||||
function normalizedChatSendAckStatus(status) {
|
||||
return typeof status === "string" ? status.trim().toLowerCase() : "";
|
||||
}
|
||||
@@ -1959,10 +1993,13 @@ var AcpGatewayAgent = class {
|
||||
}
|
||||
handleGatewayReconnect() {
|
||||
this.log("gateway reconnected");
|
||||
+ const subscriptionReady = Promise.all([...this.pendingPrompts.values()].map((pending) => this.gateway.request("sessions.messages.subscribe", { key: pending.sessionKey }).catch((err) => {
|
||||
+ this.log(`session message subscription failed for ${pending.sessionKey}: ${String(err)}`);
|
||||
+ })));
|
||||
const disconnectContext = this.activeDisconnectContext;
|
||||
this.activeDisconnectContext = null;
|
||||
- if (!disconnectContext) return;
|
||||
- this.reconcilePendingPrompts(disconnectContext.generation, false);
|
||||
+ if (!disconnectContext) return subscriptionReady;
|
||||
+ return subscriptionReady.then(() => this.reconcilePendingPrompts(disconnectContext.generation, false));
|
||||
}
|
||||
handleGatewayDisconnect(reason) {
|
||||
this.log(`gateway disconnected: ${reason}`);
|
||||
@@ -1985,7 +2022,7 @@ var AcpGatewayAgent = class {
|
||||
this.handleExecApprovalRequestEvent(evt);
|
||||
return;
|
||||
}
|
||||
- if (evt.event === "agent") await this.handleAgentEvent(evt);
|
||||
+ if (evt.event === "agent" || evt.event === "session.tool") await this.handleAgentEvent(evt);
|
||||
}
|
||||
async initialize(params) {
|
||||
this.clientCapabilities = normalizeClientCapabilities(params.clientCapabilities);
|
||||
@@ -2299,7 +2336,8 @@ var AcpGatewayAgent = class {
|
||||
};
|
||||
sendWithProvenanceFallback().catch((err) => {
|
||||
const promptKey = this.pendingPromptKey(params.sessionId, runId);
|
||||
- if (isGatewayCloseError(err) && (this.getPendingPrompt(params.sessionId, runId) || this.settlingPromptKeys.has(promptKey))) return;
|
||||
+ const pending = this.pendingPrompts.get(params.sessionId);
|
||||
+ if (isGatewayCloseError(err) && (this.getPendingPrompt(params.sessionId, runId) || pending?.resumedRunIds?.has(runId) || this.settlingPromptKeys.has(promptKey))) return;
|
||||
this.clearApprovalRelaysForPrompt(params.sessionId, runId, { denyActive: true });
|
||||
this.pendingPrompts.delete(params.sessionId);
|
||||
this.sessionStore.clearActiveRun(params.sessionId);
|
||||
@@ -2336,6 +2374,7 @@ var AcpGatewayAgent = class {
|
||||
const data = payload.data;
|
||||
const sessionKey = payload.sessionKey;
|
||||
if (!stream || !data || !sessionKey) return;
|
||||
+ const pending = this.findPendingBySessionKey(sessionKey, runId, payload.resumedFromRunId);
|
||||
if (stream === "approval") {
|
||||
await this.handleApprovalEvent({
|
||||
sessionKey,
|
||||
@@ -2349,7 +2388,6 @@ var AcpGatewayAgent = class {
|
||||
const name = data.name;
|
||||
const toolCallId = data.toolCallId;
|
||||
if (!toolCallId) return;
|
||||
- const pending = this.findPendingBySessionKey(sessionKey, runId);
|
||||
if (!pending) return;
|
||||
if (phase === "start") {
|
||||
if (!pending.toolCalls) pending.toolCalls = /* @__PURE__ */ new Map();
|
||||
@@ -2442,13 +2480,15 @@ var AcpGatewayAgent = class {
|
||||
if (!sessionKey) return;
|
||||
this.startApprovalRelay({
|
||||
sessionKey,
|
||||
+ runId: normalizeOptionalString(request?.runId),
|
||||
+ resumedFromRunId: normalizeOptionalString(request?.resumedFromRunId),
|
||||
approvalEvent
|
||||
});
|
||||
}
|
||||
startApprovalRelay(params) {
|
||||
const approvalEvent = params.approvalEvent;
|
||||
if (this.approvalRelays.has(approvalEvent.approvalId)) return;
|
||||
- const pending = params.runId ? this.findPendingBySessionKey(params.sessionKey, params.runId) : this.findUniquePendingBySessionKey(params.sessionKey);
|
||||
+ const pending = params.runId ? this.findPendingBySessionKey(params.sessionKey, params.runId, params.resumedFromRunId) : this.findUniquePendingBySessionKey(params.sessionKey);
|
||||
if (!pending) return;
|
||||
const relay = {
|
||||
approvalId: approvalEvent.approvalId,
|
||||
@@ -2526,7 +2566,7 @@ var AcpGatewayAgent = class {
|
||||
const runId = payload.runId;
|
||||
const messageData = payload.message;
|
||||
if (!sessionKey || !state) return;
|
||||
- const pending = this.findPendingBySessionKey(sessionKey, runId);
|
||||
+ const pending = this.findPendingBySessionKey(sessionKey, runId, payload.resumedFromRunId);
|
||||
if (!pending) return;
|
||||
if (messageData && (state === "delta" || state === "final")) {
|
||||
await this.handleDeltaEvent(pending.sessionId, messageData);
|
||||
@@ -2619,18 +2659,55 @@ var AcpGatewayAgent = class {
|
||||
this.settlingPromptKeys.delete(promptKey);
|
||||
}
|
||||
}
|
||||
- findPendingBySessionKey(sessionKey, runId) {
|
||||
+ findPendingBySessionKey(sessionKey, runId, resumedFromRunId) {
|
||||
for (const pending of this.pendingPrompts.values()) {
|
||||
if (pending.sessionKey !== sessionKey) continue;
|
||||
if (runId && pending.idempotencyKey !== runId) continue;
|
||||
return pending;
|
||||
}
|
||||
+ if (runId && resumedFromRunId) for (const pending of this.pendingPrompts.values()) {
|
||||
+ if (pending.idempotencyKey !== resumedFromRunId) continue;
|
||||
+ this.reconcilePendingSessionKey(pending, sessionKey);
|
||||
+ this.adoptResumedRun(pending, runId);
|
||||
+ return pending;
|
||||
+ }
|
||||
if (runId) for (const pending of this.pendingPrompts.values()) {
|
||||
if (pending.idempotencyKey !== runId) continue;
|
||||
this.reconcilePendingSessionKey(pending, sessionKey);
|
||||
return pending;
|
||||
}
|
||||
}
|
||||
+ adoptResumedRun(pending, runId) {
|
||||
+ const previousRunId = pending.idempotencyKey;
|
||||
+ this.log(`prompt run resumed: ${previousRunId} -> ${runId}`);
|
||||
+ this.clearApprovalRelaysForPrompt(pending.sessionId, previousRunId, { denyActive: true });
|
||||
+ pending.resumedRunIds ??= /* @__PURE__ */ new Set();
|
||||
+ pending.resumedRunIds.add(previousRunId);
|
||||
+ pending.idempotencyKey = runId;
|
||||
+ pending.sendAccepted = true;
|
||||
+ pending.disconnectContext = void 0;
|
||||
+ for (const [toolCallId, toolCall] of pending.toolCalls ?? []) this.sessionUpdates.emit({
|
||||
+ sessionId: pending.sessionId,
|
||||
+ sessionKey: pending.sessionKey,
|
||||
+ ...pending.ledgerSessionId ? { ledgerSessionId: pending.ledgerSessionId } : {},
|
||||
+ runId: previousRunId,
|
||||
+ record: true,
|
||||
+ waitForDelivery: false,
|
||||
+ update: {
|
||||
+ sessionUpdate: "tool_call_update",
|
||||
+ toolCallId,
|
||||
+ status: "failed",
|
||||
+ locations: toolCall.locations
|
||||
+ }
|
||||
+ });
|
||||
+ pending.sentTextLength = 0;
|
||||
+ pending.sentText = void 0;
|
||||
+ pending.sentThoughtLength = 0;
|
||||
+ pending.sentThought = void 0;
|
||||
+ pending.toolCalls = void 0;
|
||||
+ const session = this.sessionStore.getSession(pending.sessionId);
|
||||
+ if (session?.abortController) this.sessionStore.setActiveRun(pending.sessionId, runId, session.abortController);
|
||||
+ }
|
||||
findUniquePendingBySessionKey(sessionKey) {
|
||||
let match;
|
||||
for (const pending of this.pendingPrompts.values()) {
|
||||
@@ -2652,12 +2729,12 @@ var AcpGatewayAgent = class {
|
||||
clearTimeout(this.disconnectTimer);
|
||||
this.disconnectTimer = null;
|
||||
}
|
||||
- armDisconnectTimer(disconnectContext) {
|
||||
+ armDisconnectTimer(disconnectContext, deadline = "initial") {
|
||||
this.clearDisconnectTimer();
|
||||
this.disconnectTimer = setTimeout(() => {
|
||||
this.disconnectTimer = null;
|
||||
- this.reconcilePendingPrompts(disconnectContext.generation, true);
|
||||
- }, ACP_GATEWAY_DISCONNECT_GRACE_MS);
|
||||
+ this.reconcilePendingPrompts(disconnectContext.generation, deadline);
|
||||
+ }, deadline === "initial" ? ACP_GATEWAY_DISCONNECT_GRACE_MS : ACP_GATEWAY_ACCEPTED_PROMPT_RECOVERY_GRACE_MS - ACP_GATEWAY_DISCONNECT_GRACE_MS);
|
||||
this.disconnectTimer.unref?.();
|
||||
}
|
||||
rejectPendingPrompt(pending, error) {
|
||||
@@ -2668,14 +2745,10 @@ var AcpGatewayAgent = class {
|
||||
if (this.pendingPrompts.size === 0) this.clearDisconnectTimer();
|
||||
pending.reject(error);
|
||||
}
|
||||
- clearPendingDisconnectState(pending, disconnectContext) {
|
||||
- if (pending.disconnectContext !== disconnectContext) return;
|
||||
- pending.disconnectContext = void 0;
|
||||
+ shouldRejectPendingAtDisconnectDeadline(pending, disconnectContext, deadline) {
|
||||
+ return pending.disconnectContext === disconnectContext && (!pending.sendAccepted || deadline === "accepted-recovery");
|
||||
}
|
||||
- shouldRejectPendingAtDisconnectDeadline(pending, disconnectContext) {
|
||||
- return pending.disconnectContext === disconnectContext && (!pending.sendAccepted || this.activeDisconnectContext?.generation === disconnectContext.generation);
|
||||
- }
|
||||
- async reconcilePendingPrompts(observedDisconnectGeneration, deadlineExpired) {
|
||||
+ async reconcilePendingPrompts(observedDisconnectGeneration, deadline) {
|
||||
if (this.pendingPrompts.size === 0) {
|
||||
if (this.disconnectGeneration === observedDisconnectGeneration) this.clearDisconnectTimer();
|
||||
return;
|
||||
@@ -2685,32 +2758,35 @@ var AcpGatewayAgent = class {
|
||||
for (const [sessionId, pending] of pendingEntries) {
|
||||
if (this.pendingPrompts.get(sessionId) !== pending) continue;
|
||||
if (pending.disconnectContext?.generation !== observedDisconnectGeneration) continue;
|
||||
- if (await this.reconcilePendingPrompt(sessionId, pending, deadlineExpired)) keepDisconnectTimer = true;
|
||||
+ if (await this.reconcilePendingPrompt(sessionId, pending, deadline)) keepDisconnectTimer = true;
|
||||
}
|
||||
- if (!keepDisconnectTimer && this.disconnectGeneration === observedDisconnectGeneration) this.clearDisconnectTimer();
|
||||
+ if (keepDisconnectTimer && deadline === "initial" && this.disconnectGeneration === observedDisconnectGeneration) {
|
||||
+ const disconnectContext = pendingEntries.map(([, pending]) => pending.disconnectContext).find((context) => context?.generation === observedDisconnectGeneration);
|
||||
+ if (disconnectContext) this.armDisconnectTimer(disconnectContext, "accepted-recovery");
|
||||
+ } else if (!keepDisconnectTimer && this.disconnectGeneration === observedDisconnectGeneration) this.clearDisconnectTimer();
|
||||
}
|
||||
- async reconcilePendingPrompt(sessionId, pending, deadlineExpired) {
|
||||
+ async reconcilePendingPrompt(sessionId, pending, deadline) {
|
||||
const disconnectContext = pending.disconnectContext;
|
||||
if (!disconnectContext) return false;
|
||||
+ const waitedRunId = pending.idempotencyKey;
|
||||
let result;
|
||||
try {
|
||||
result = await this.gateway.request("agent.wait", {
|
||||
- runId: pending.idempotencyKey,
|
||||
+ runId: waitedRunId,
|
||||
timeoutMs: 0
|
||||
}, { timeoutMs: null });
|
||||
} catch (err) {
|
||||
- this.log(`agent.wait reconcile failed for ${pending.idempotencyKey}: ${String(err)}`);
|
||||
- if (deadlineExpired) {
|
||||
- if (this.shouldRejectPendingAtDisconnectDeadline(pending, disconnectContext)) {
|
||||
+ this.log(`agent.wait reconcile failed for ${waitedRunId}: ${String(err)}`);
|
||||
+ if (deadline) {
|
||||
+ if (this.shouldRejectPendingAtDisconnectDeadline(pending, disconnectContext, deadline)) {
|
||||
this.rejectPendingPrompt(pending, /* @__PURE__ */ new Error(`Gateway disconnected: ${disconnectContext.reason}`));
|
||||
return false;
|
||||
}
|
||||
- this.clearPendingDisconnectState(pending, disconnectContext);
|
||||
- return false;
|
||||
+ return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
- const currentPending = this.getPendingPrompt(sessionId, pending.idempotencyKey);
|
||||
+ const currentPending = this.getPendingPrompt(sessionId, waitedRunId);
|
||||
if (!currentPending) return false;
|
||||
if (result?.status === "ok") {
|
||||
await this.finishPrompt(sessionId, currentPending, "end_turn");
|
||||
@@ -2720,15 +2796,14 @@ var AcpGatewayAgent = class {
|
||||
this.finishPrompt(sessionId, currentPending, "end_turn");
|
||||
return false;
|
||||
}
|
||||
- if (deadlineExpired) {
|
||||
- if (this.shouldRejectPendingAtDisconnectDeadline(currentPending, disconnectContext)) {
|
||||
+ if (deadline) {
|
||||
+ if (this.shouldRejectPendingAtDisconnectDeadline(currentPending, disconnectContext, deadline)) {
|
||||
const currentDisconnectContext = currentPending.disconnectContext;
|
||||
if (!currentDisconnectContext) return false;
|
||||
this.rejectPendingPrompt(currentPending, /* @__PURE__ */ new Error(`Gateway disconnected: ${currentDisconnectContext.reason}`));
|
||||
return false;
|
||||
}
|
||||
- this.clearPendingDisconnectState(currentPending, disconnectContext);
|
||||
- return false;
|
||||
+ return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -2890,13 +2965,13 @@ var AcpGatewayAgent = class {
|
||||
const replayChunks = extractReplayChunks(message);
|
||||
for (const chunk of replayChunks) await this.sessionUpdates.emit({
|
||||
sessionId,
|
||||
- update: {
|
||||
+ update: chunk.sessionUpdate === "user_message_chunk" || chunk.sessionUpdate === "agent_message_chunk" || chunk.sessionUpdate === "agent_thought_chunk" ? {
|
||||
sessionUpdate: chunk.sessionUpdate,
|
||||
content: {
|
||||
type: "text",
|
||||
text: chunk.text
|
||||
}
|
||||
- }
|
||||
+ } : chunk
|
||||
});
|
||||
}
|
||||
}
|
||||
diff --git a/dist/agent-D6kiZtPt.js b/dist/agent-D6kiZtPt.js
|
||||
index af55bc0d0faa6c4abdcb72e6f905cc3774784508..c9438a1fad0004d7e8a812e17e3af69b52844371 100644
|
||||
--- a/dist/agent-D6kiZtPt.js
|
||||
+++ b/dist/agent-D6kiZtPt.js
|
||||
@@ -941,6 +941,15 @@ const agentHandlers = {
|
||||
let resolvedGroupSpace = normalizedSpawned.groupSpace;
|
||||
let spawnedByValue;
|
||||
const inputProvenance = normalizeInputProvenance(request.inputProvenance);
|
||||
+ const isRestartRecoveryResumeRun = canUseInternalRuntimeHandoff && inputProvenance?.kind === "internal_system" && inputProvenance.sourceTool === "main_session_restart_recovery";
|
||||
+ if (request.internalRestartRecoverySourceRunId !== void 0 && !isRestartRecoveryResumeRun) {
|
||||
+ respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "restart recovery run lineage is reserved for main-session restart recovery."));
|
||||
+ return;
|
||||
+ }
|
||||
+ if (request.internalRestartRecoverySourceRunId !== void 0 && request.internalRestartRecoverySourceRunId === runId) {
|
||||
+ respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "restart recovery must use a distinct run id from its source run."));
|
||||
+ return;
|
||||
+ }
|
||||
const preserveUserFacingSessionModelState = canUseInternalRuntimeHandoff && shouldPreserveUserFacingSessionStateForInputProvenance(inputProvenance);
|
||||
const sessionEffects = requestedInternalSessionEffects ? "internal" : request.sessionEffects;
|
||||
const suppressVisibleSessionEffects = sessionEffects === "internal";
|
||||
@@ -2192,7 +2201,8 @@ const agentHandlers = {
|
||||
lifecycleGeneration
|
||||
} : {
|
||||
sessionKey: resolvedSessionKey,
|
||||
- lifecycleGeneration
|
||||
+ lifecycleGeneration,
|
||||
+ ...request.internalRestartRecoverySourceRunId ? { resumedFromRunId: request.internalRestartRecoverySourceRunId } : {}
|
||||
});
|
||||
}
|
||||
const resolvedThreadId = explicitThreadId ?? deliveryPlan.resolvedThreadId;
|
||||
diff --git a/dist/agent-events-CRggPZCM.js b/dist/agent-events-CRggPZCM.js
|
||||
index d219ef4933d12a485f9f29bc056fd8d39594d0a1..9f6c72ec51940362d08467068aff26a377d15f7b 100644
|
||||
--- a/dist/agent-events-CRggPZCM.js
|
||||
+++ b/dist/agent-events-CRggPZCM.js
|
||||
@@ -56,6 +56,7 @@ function registerAgentRunContext(runId, context) {
|
||||
if (context.lifecycleGeneration && existing.lifecycleGeneration && context.lifecycleGeneration !== existing.lifecycleGeneration) return;
|
||||
if (context.sessionKey && existing.sessionKey !== context.sessionKey) existing.sessionKey = context.sessionKey;
|
||||
if (context.sessionId && existing.sessionId !== context.sessionId) existing.sessionId = context.sessionId;
|
||||
+ if (context.resumedFromRunId && existing.resumedFromRunId !== context.resumedFromRunId) existing.resumedFromRunId = context.resumedFromRunId;
|
||||
if (context.agentId && existing.agentId !== context.agentId) existing.agentId = context.agentId;
|
||||
if (context.verboseLevel && existing.verboseLevel !== context.verboseLevel) existing.verboseLevel = context.verboseLevel;
|
||||
if (context.isControlUiVisible !== void 0) existing.isControlUiVisible = context.isControlUiVisible;
|
||||
@@ -182,11 +183,13 @@ function enrichAgentEvent(event) {
|
||||
const sessionId = event.stream === "lifecycle" ? event.sessionId ?? context?.sessionId : event.sessionId;
|
||||
const lifecycleGeneration = event.stream === "lifecycle" ? ownedLifecycleGeneration ?? state.lifecycleGeneration : ownedLifecycleGeneration;
|
||||
const agentId = event.agentId ?? context?.agentId;
|
||||
+ const resumedFromRunId = context?.resumedFromRunId;
|
||||
const enriched = {
|
||||
...event,
|
||||
sessionKey,
|
||||
...sessionId ? { sessionId } : {},
|
||||
...agentId ? { agentId } : {},
|
||||
+ ...resumedFromRunId ? { resumedFromRunId } : {},
|
||||
seq: nextSeq,
|
||||
ts: Date.now()
|
||||
};
|
||||
diff --git a/dist/agent-tools-BD8WL7ny.js b/dist/agent-tools-BD8WL7ny.js
|
||||
index c16af06126dfcd8070fa0eb203799aba7a16bd95..75fe92b0994ced97fc8f7014a76dacb0c4dedc65 100644
|
||||
--- a/dist/agent-tools-BD8WL7ny.js
|
||||
+++ b/dist/agent-tools-BD8WL7ny.js
|
||||
@@ -1132,0 +1133 @@ function createOpenClawCodingTools(options) {
|
||||
+ runId: options?.runId,
|
||||
diff --git a/dist/bash-tools-DHyGpWCr.js b/dist/bash-tools-DHyGpWCr.js
|
||||
index def4eecc41bf57190eac84a5df1dcf1d7c70664b..ba0013d6148de2410bad04b392e84a8ab3a8529d 100644
|
||||
--- a/dist/bash-tools-DHyGpWCr.js
|
||||
+++ b/dist/bash-tools-DHyGpWCr.js
|
||||
@@ -100,0 +101,3 @@ function buildExecApprovalRequestToolParams(params) {
|
||||
+ sessionId: params.sessionId,
|
||||
+ runId: params.runId,
|
||||
+ toolCallId: params.toolCallId,
|
||||
@@ -214,0 +218,3 @@ async function buildHostApprovalDecisionParams(params) {
|
||||
+ sessionId: params.sessionId,
|
||||
+ runId: params.runId,
|
||||
+ toolCallId: params.toolCallId,
|
||||
@@ -1146,0 +1153,3 @@ async function processGatewayAllowlist(params) {
|
||||
+ sessionId: params.sessionId,
|
||||
+ runId: params.runId,
|
||||
+ toolCallId: params.toolCallId,
|
||||
@@ -1879,0 +1889,2 @@ async function executeNodeHostCommand(params) {
|
||||
+ runId: params.runId,
|
||||
+ toolCallId: params.toolCallId,
|
||||
@@ -3216 +3227 @@ function createExecTool(defaults) {
|
||||
- execute: async (_toolCallId, args, signal, onUpdate) => {
|
||||
+ execute: async (toolCallId, args, signal, onUpdate) => {
|
||||
@@ -3375,0 +3387,2 @@ function createExecTool(defaults) {
|
||||
+ toolCallId,
|
||||
+ runId: defaults?.runId,
|
||||
@@ -3427,0 +3441,2 @@ function createExecTool(defaults) {
|
||||
+ runId: defaults?.runId,
|
||||
+ toolCallId,
|
||||
diff --git a/dist/exec-approval-DRfKKxhu.js b/dist/exec-approval-DRfKKxhu.js
|
||||
index f76d64d4041f579301e4ffacc38a20a7887567ef..eb4243089d8329eab00d9af9b106b46ef260df8c 100644
|
||||
--- a/dist/exec-approval-DRfKKxhu.js
|
||||
+++ b/dist/exec-approval-DRfKKxhu.js
|
||||
@@ -10,6 +10,7 @@ import { i as sanitizeExecApprovalWarningText, n as sanitizeExecApprovalDisplayT
|
||||
import { t as analyzeCommandForPolicy } from "./policy-X6MHg5ni.js";
|
||||
import { a as buildSystemRunApprovalEnvBinding, i as buildSystemRunApprovalBinding } from "./system-run-command-Bd_agqvl.js";
|
||||
import { n as resolveSystemRunApprovalRequestContext } from "./system-run-approval-context-B9ONWoZl.js";
|
||||
+import { f as getAgentRunContext } from "./agent-events-CRggPZCM.js";
|
||||
import { a as handleApprovalWaitDecision, c as listVisiblePendingApprovalRequests, f as resolvePendingApprovalRecord, i as handleApprovalResolve, l as registerPendingApprovalRecord, n as bindApprovalReviewerDeviceIds, o as handlePendingApprovalRequest, p as respondPendingApprovalLookupError, r as buildRequestedApprovalEvent, s as isApprovalRecordVisibleToClient, t as bindApprovalRequesterMetadata, u as resolveApprovalDecisionParams } from "./approval-shared-BKEyXMsJ.js";
|
||||
//#region src/infra/command-analysis/explain.ts
|
||||
function riskLabel(risk) {
|
||||
@@ -225,6 +226,8 @@ function createExecApprovalHandlers(manager, opts) {
|
||||
return;
|
||||
}
|
||||
const unavailableDecisions = normalizeExecApprovalUnavailableDecisions(p.unavailableDecisions);
|
||||
+ const requestRunId = normalizeOptionalString(p.runId);
|
||||
+ const resumedFromRunId = requestRunId ? getAgentRunContext(requestRunId)?.resumedFromRunId : void 0;
|
||||
const request = {
|
||||
command: sanitizedCommandText,
|
||||
commandPreview: host === "node" || !approvalContext.commandPreview ? void 0 : sanitizeExecApprovalDisplayText(approvalContext.commandPreview),
|
||||
@@ -248,6 +251,10 @@ function createExecApprovalHandlers(manager, opts) {
|
||||
agentId: effectiveAgentId ?? null,
|
||||
resolvedPath: p.resolvedPath ?? null,
|
||||
sessionKey: effectiveSessionKey ?? null,
|
||||
+ sessionId: normalizeOptionalString(p.sessionId) ?? null,
|
||||
+ runId: requestRunId ?? null,
|
||||
+ toolCallId: normalizeOptionalString(p.toolCallId) ?? null,
|
||||
+ ...resumedFromRunId ? { resumedFromRunId } : {},
|
||||
turnSourceChannel: normalizeOptionalString(p.turnSourceChannel) ?? null,
|
||||
turnSourceTo: normalizeOptionalString(p.turnSourceTo) ?? null,
|
||||
turnSourceAccountId: normalizeOptionalString(p.turnSourceAccountId) ?? null,
|
||||
diff --git a/dist/exec-approvals-bouecjdj.d.ts b/dist/exec-approvals-bouecjdj.d.ts
|
||||
index c0a416851d957f1edd9e675fb74d69c4f3d1fdbb..30e33fc6fe5f31ee393ad25d0f2b24990ae0b5fe 100644
|
||||
--- a/dist/exec-approvals-bouecjdj.d.ts
|
||||
+++ b/dist/exec-approvals-bouecjdj.d.ts
|
||||
@@ -382,6 +382,10 @@ type ExecApprovalRequestPayload = {
|
||||
agentId?: string | null;
|
||||
resolvedPath?: string | null;
|
||||
sessionKey?: string | null;
|
||||
+ sessionId?: string | null;
|
||||
+ runId?: string | null;
|
||||
+ resumedFromRunId?: string;
|
||||
+ toolCallId?: string | null;
|
||||
turnSourceChannel?: string | null;
|
||||
turnSourceTo?: string | null;
|
||||
turnSourceAccountId?: string | null;
|
||||
diff --git a/dist/main-session-restart-recovery-Ce8fihTV.js b/dist/main-session-restart-recovery-Ce8fihTV.js
|
||||
index ba698afb03ca7043d475948afcbb18643719a011..5c976a1d0f5fc9b85e76f65684eaba2c2b42d887 100644
|
||||
--- a/dist/main-session-restart-recovery-Ce8fihTV.js
|
||||
+++ b/dist/main-session-restart-recovery-Ce8fihTV.js
|
||||
@@ -211,6 +211,8 @@ async function markStartupOrphanedMainSessionsForRecovery(params) {
|
||||
entry,
|
||||
sessionKey
|
||||
})) continue;
|
||||
+ const sourceRunId = normalizeOptionalString(entry.lifecycleRunId);
|
||||
+ if (sourceRunId) entry.restartRecoveryDeliverySourceRunId = sourceRunId;
|
||||
entry.abortedLastRun = true;
|
||||
entry.updatedAt = Date.now();
|
||||
replacements.push({
|
||||
@@ -350,6 +352,8 @@ function resolveRestartRecoveryDeliveryContext(params) {
|
||||
}
|
||||
async function resumeMainSession(params) {
|
||||
const sanitizedPendingText = typeof params.pendingFinalDeliveryText === "string" ? sanitizePendingFinalDeliveryText(params.pendingFinalDeliveryText) : "";
|
||||
+ const sourceRunIds = [...new Set((params.entry.restartRecoveryRuns ?? []).map((run) => typeof run.runId === "string" ? run.runId.trim() : "").filter(Boolean))];
|
||||
+ const sourceRunId = normalizeOptionalString(params.entry.restartRecoveryDeliverySourceRunId) ?? (sourceRunIds.length === 1 ? sourceRunIds[0] : void 0);
|
||||
const deliveryContext = resolveRestartRecoveryDeliveryContext({
|
||||
cfg: params.cfg,
|
||||
entry: params.entry,
|
||||
@@ -359,9 +363,15 @@ async function resumeMainSession(params) {
|
||||
const agentParams = {
|
||||
message: buildResumeMessage(sanitizedPendingText),
|
||||
sessionKey: params.sessionKey,
|
||||
+ ...sourceRunId ? { internalRestartRecoverySourceRunId: sourceRunId } : {},
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
deliver: Boolean(deliveryContext),
|
||||
- lane: "main"
|
||||
+ lane: "main",
|
||||
+ inputProvenance: {
|
||||
+ kind: "internal_system",
|
||||
+ sourceSessionKey: params.sessionKey,
|
||||
+ sourceTool: "main_session_restart_recovery"
|
||||
+ }
|
||||
};
|
||||
if (deliveryContext) {
|
||||
agentParams.channel = deliveryContext.channel;
|
||||
diff --git a/dist/schema-BuOFpc7K.js b/dist/schema-BuOFpc7K.js
|
||||
index 50b16b4a3f159b698c6485e079e1c38ea41fda78..03ee242b547c3b841cc6a5819a4d0349a300228e 100644
|
||||
--- a/dist/schema-BuOFpc7K.js
|
||||
+++ b/dist/schema-BuOFpc7K.js
|
||||
@@ -138,6 +138,7 @@ const AgentInternalEventSchema = Type.Object({
|
||||
/** Stream event emitted by the agent runtime over the gateway protocol. */
|
||||
const AgentEventSchema = Type.Object({
|
||||
runId: NonEmptyString,
|
||||
+ resumedFromRunId: Type.Optional(NonEmptyString),
|
||||
seq: Type.Integer({ minimum: 0 }),
|
||||
stream: NonEmptyString,
|
||||
ts: Type.Integer({ minimum: 0 }),
|
||||
@@ -280,6 +281,7 @@ const AgentParamsSchema = Type.Object({
|
||||
])),
|
||||
acpTurnSource: Type.Optional(Type.Literal("manual_spawn")),
|
||||
internalRuntimeHandoffId: Type.Optional(NonEmptyString),
|
||||
+ internalRestartRecoverySourceRunId: Type.Optional(NonEmptyString),
|
||||
execApprovalFollowupExpectedSessionId: Type.Optional(NonEmptyString),
|
||||
internalEvents: Type.Optional(Type.Array(AgentInternalEventSchema)),
|
||||
inputProvenance: Type.Optional(InputProvenanceSchema),
|
||||
@@ -2910,0 +2913,3 @@ const ExecApprovalRequestParamsSchema = Type.Object({
|
||||
+ sessionId: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
||||
+ runId: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
||||
+ toolCallId: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
||||
@@ -3304,6 +3309,7 @@ const ChatInjectParamsSchema = Type.Object({
|
||||
/** Shared event fields preserve stream ordering and route events to the right session. */
|
||||
const ChatEventBaseSchema = {
|
||||
runId: NonEmptyString,
|
||||
+ resumedFromRunId: Type.Optional(NonEmptyString),
|
||||
sessionKey: NonEmptyString,
|
||||
agentId: Type.Optional(NonEmptyString),
|
||||
spawnedBy: Type.Optional(NonEmptyString),
|
||||
diff --git a/dist/schema-DtyqV_v0.d.ts b/dist/schema-DtyqV_v0.d.ts
|
||||
index ff8cd2829dc9f64d7fc8ec2bf77bb5f05947459d..f2f08299de269d42714b491343006250be0db4aa 100644
|
||||
--- a/dist/schema-DtyqV_v0.d.ts
|
||||
+++ b/dist/schema-DtyqV_v0.d.ts
|
||||
@@ -4,6 +4,7 @@ import { Static, TSchema, Type } from "typebox";
|
||||
/** Stream event emitted by the agent runtime over the gateway protocol. */
|
||||
declare const AgentEventSchema: Type.TObject<{
|
||||
runId: Type.TString;
|
||||
+ resumedFromRunId: Type.TOptional<Type.TString>;
|
||||
seq: Type.TInteger;
|
||||
stream: Type.TString;
|
||||
ts: Type.TInteger;
|
||||
@@ -110,6 +111,7 @@ declare const AgentParamsSchema: Type.TObject<{
|
||||
bootstrapContextRunKind: Type.TOptional<Type.TUnion<[Type.TLiteral<"default">, Type.TLiteral<"heartbeat">, Type.TLiteral<"cron">]>>;
|
||||
acpTurnSource: Type.TOptional<Type.TLiteral<"manual_spawn">>;
|
||||
internalRuntimeHandoffId: Type.TOptional<Type.TString>;
|
||||
+ internalRestartRecoverySourceRunId: Type.TOptional<Type.TString>;
|
||||
execApprovalFollowupExpectedSessionId: Type.TOptional<Type.TString>;
|
||||
internalEvents: Type.TOptional<Type.TArray<Type.TObject<{
|
||||
type: Type.TLiteral<"task_completion">;
|
||||
@@ -3065,6 +3067,7 @@ declare const ProtocolSchemas: {
|
||||
bootstrapContextRunKind: import("typebox").TOptional<import("typebox").TUnion<[import("typebox").TLiteral<"default">, import("typebox").TLiteral<"heartbeat">, import("typebox").TLiteral<"cron">]>>;
|
||||
acpTurnSource: import("typebox").TOptional<import("typebox").TLiteral<"manual_spawn">>;
|
||||
internalRuntimeHandoffId: import("typebox").TOptional<import("typebox").TString>;
|
||||
+ internalRestartRecoverySourceRunId: import("typebox").TOptional<import("typebox").TString>;
|
||||
execApprovalFollowupExpectedSessionId: import("typebox").TOptional<import("typebox").TString>;
|
||||
internalEvents: import("typebox").TOptional<import("typebox").TArray<import("typebox").TObject<{
|
||||
type: import("typebox").TLiteral<"task_completion">;
|
||||
@@ -7573,6 +7576,9 @@ declare const ExecApprovalRequestParamsSchema: Type.TObject<{
|
||||
agentId: Type.TOptional<Type.TUnion<[Type.TString, Type.TNull]>>;
|
||||
resolvedPath: Type.TOptional<Type.TUnion<[Type.TString, Type.TNull]>>;
|
||||
sessionKey: Type.TOptional<Type.TUnion<[Type.TString, Type.TNull]>>;
|
||||
+ sessionId: Type.TOptional<Type.TUnion<[Type.TString, Type.TNull]>>;
|
||||
+ runId: Type.TOptional<Type.TUnion<[Type.TString, Type.TNull]>>;
|
||||
+ toolCallId: Type.TOptional<Type.TUnion<[Type.TString, Type.TNull]>>;
|
||||
turnSourceChannel: Type.TOptional<Type.TUnion<[Type.TString, Type.TNull]>>;
|
||||
turnSourceTo: Type.TOptional<Type.TUnion<[Type.TString, Type.TNull]>>;
|
||||
turnSourceAccountId: Type.TOptional<Type.TUnion<[Type.TString, Type.TNull]>>;
|
||||
@@ -7867,6 +7873,7 @@ declare const ChatEventSchema: Type.TUnion<[Type.TObject<{
|
||||
replace: Type.TOptional<Type.TBoolean>;
|
||||
usage: Type.TOptional<Type.TUnknown>;
|
||||
runId: Type.TString;
|
||||
+ resumedFromRunId: Type.TOptional<Type.TString>;
|
||||
sessionKey: Type.TString;
|
||||
agentId: Type.TOptional<Type.TString>;
|
||||
spawnedBy: Type.TOptional<Type.TString>;
|
||||
@@ -7877,6 +7884,7 @@ declare const ChatEventSchema: Type.TUnion<[Type.TObject<{
|
||||
usage: Type.TOptional<Type.TUnknown>;
|
||||
stopReason: Type.TOptional<Type.TString>;
|
||||
runId: Type.TString;
|
||||
+ resumedFromRunId: Type.TOptional<Type.TString>;
|
||||
sessionKey: Type.TString;
|
||||
agentId: Type.TOptional<Type.TString>;
|
||||
spawnedBy: Type.TOptional<Type.TString>;
|
||||
@@ -7887,6 +7895,7 @@ declare const ChatEventSchema: Type.TUnion<[Type.TObject<{
|
||||
errorMessage: Type.TOptional<Type.TString>;
|
||||
stopReason: Type.TOptional<Type.TString>;
|
||||
runId: Type.TString;
|
||||
+ resumedFromRunId: Type.TOptional<Type.TString>;
|
||||
sessionKey: Type.TString;
|
||||
agentId: Type.TOptional<Type.TString>;
|
||||
spawnedBy: Type.TOptional<Type.TString>;
|
||||
@@ -7899,6 +7908,7 @@ declare const ChatEventSchema: Type.TUnion<[Type.TObject<{
|
||||
usage: Type.TOptional<Type.TUnknown>;
|
||||
stopReason: Type.TOptional<Type.TString>;
|
||||
runId: Type.TString;
|
||||
+ resumedFromRunId: Type.TOptional<Type.TString>;
|
||||
sessionKey: Type.TString;
|
||||
agentId: Type.TOptional<Type.TString>;
|
||||
spawnedBy: Type.TOptional<Type.TString>;
|
||||
diff --git a/dist/server-chat-wgxNCdC3.js b/dist/server-chat-wgxNCdC3.js
|
||||
index e03fecec0657ddd63833fd4804efba1d21d2f361..6fa31407a9c30d42cc9d907e2f1d862edf5bba12 100644
|
||||
--- a/dist/server-chat-wgxNCdC3.js
|
||||
+++ b/dist/server-chat-wgxNCdC3.js
|
||||
@@ -544,14 +544,21 @@ function createAgentEventHandler({ broadcast, broadcastToConnIds, nodeSendToSess
|
||||
chatRunState.deltaSentAt.set(clientRunId, now);
|
||||
};
|
||||
const sendChatPayload = (sessionKey, payload, opts) => {
|
||||
+ const payloadRecord = payload && typeof payload === "object" ? payload : void 0;
|
||||
+ const runId = typeof payloadRecord?.runId === "string" ? payloadRecord.runId : void 0;
|
||||
+ const resumedFromRunId = runId ? getAgentRunContext(runId)?.resumedFromRunId : void 0;
|
||||
+ const projectedPayload = resumedFromRunId && payloadRecord ? {
|
||||
+ ...payloadRecord,
|
||||
+ resumedFromRunId
|
||||
+ } : payload;
|
||||
const deliverySessionKey = resolveSessionDeliveryKey(sessionKey, opts?.agentId);
|
||||
if (opts?.controlUiVisible ?? true) {
|
||||
- broadcast("chat", payload, { dropIfSlow: opts?.dropIfSlow });
|
||||
- sendNodeSessionPayloadForAgent(sessionKey, "chat", payload, opts?.agentId);
|
||||
+ broadcast("chat", projectedPayload, { dropIfSlow: opts?.dropIfSlow });
|
||||
+ sendNodeSessionPayloadForAgent(sessionKey, "chat", projectedPayload, opts?.agentId);
|
||||
return;
|
||||
}
|
||||
const recipients = sessionMessageSubscribers.get(deliverySessionKey);
|
||||
- if (recipients.size > 0) broadcastToConnIds("chat", payload, recipients, { dropIfSlow: opts?.dropIfSlow });
|
||||
+ if (recipients.size > 0) broadcastToConnIds("chat", projectedPayload, recipients, { dropIfSlow: opts?.dropIfSlow });
|
||||
};
|
||||
const emitChatTerminal = (sessionKey, clientRunId, sourceRunId, seq, jobState, error, stopReason, errorKind, opts) => {
|
||||
const { text, shouldSuppressSilent } = resolveBufferedChatTextState(clientRunId, sourceRunId, { suppressLeadFragments: false });
|
||||
@@ -721,15 +728,18 @@ function createAgentEventHandler({ broadcast, broadcastToConnIds, nodeSendToSess
|
||||
}
|
||||
if (lifecyclePhase !== null && lifecyclePhase !== "error") clearPendingTerminalLifecycleError(evt.runId);
|
||||
const spawnedBy = sessionKey ? resolveSpawnedBy(sessionKey) : null;
|
||||
+ const resumedFromRunId = runContext?.resumedFromRunId;
|
||||
const agentPayload = sessionKey ? {
|
||||
...eventForClients,
|
||||
sessionKey,
|
||||
...sessionAgentId ? { agentId: sessionAgentId } : {},
|
||||
...spawnedBy && { spawnedBy },
|
||||
- ...isHeartbeat !== void 0 && { isHeartbeat }
|
||||
+ ...isHeartbeat !== void 0 && { isHeartbeat },
|
||||
+ ...resumedFromRunId ? { resumedFromRunId } : {}
|
||||
} : {
|
||||
...eventForClients,
|
||||
- ...isHeartbeat !== void 0 && { isHeartbeat }
|
||||
+ ...isHeartbeat !== void 0 && { isHeartbeat },
|
||||
+ ...resumedFromRunId ? { resumedFromRunId } : {}
|
||||
};
|
||||
const hasSessionMessageSubscribers = sessionKey ? sessionMessageSubscribers.get(resolveSessionDeliveryKey(sessionKey, sessionAgentId)).size > 0 : false;
|
||||
const last = agentRunSeq.get(evt.runId) ?? 0;
|
||||
@@ -799,7 +809,10 @@ function createAgentEventHandler({ broadcast, broadcastToConnIds, nodeSendToSess
|
||||
dropIfSlow: true
|
||||
});
|
||||
if (isControlUiVisible && sessionKey && !suppressHeartbeatToolEvents) {
|
||||
- const sessionSubscribers = excludeConnIds(sessionEventSubscribers.getAll(), runToolRecipients);
|
||||
+ const sessionSubscribers = excludeConnIds(new Set([
|
||||
+ ...sessionEventSubscribers.getAll(),
|
||||
+ ...sessionMessageSubscribers.get(resolveSessionDeliveryKey(sessionKey, sessionAgentId))
|
||||
+ ]), runToolRecipients);
|
||||
if (sessionSubscribers.size > 0) broadcastToConnIds("session.tool", {
|
||||
...agentPayload,
|
||||
...buildSessionEventSnapshot(sessionKey, void 0, sessionAgentId)
|
||||
diff --git a/dist/session-lifecycle-state-Czgc8l0p.js b/dist/session-lifecycle-state-Czgc8l0p.js
|
||||
index db68c120f69a4f2bc4121f3b75a456319fd227ed..21a1198bc7273b8aeeb8c91466a8cf85d32932ef 100644
|
||||
--- a/dist/session-lifecycle-state-Czgc8l0p.js
|
||||
+++ b/dist/session-lifecycle-state-Czgc8l0p.js
|
||||
@@ -97,10 +97,18 @@ function derivePersistedSessionLifecyclePatch(params) {
|
||||
if (remainingRuns.length > 0) return { restartRecoveryRuns: remainingRuns };
|
||||
patch.restartRecoveryRuns = void 0;
|
||||
}
|
||||
- return patch;
|
||||
+ const phase = resolveLifecyclePhase(params.event);
|
||||
+ return {
|
||||
+ ...patch,
|
||||
+ ...phase === "start" ? { lifecycleRunId: runId } : patch.status && patch.status !== "running" ? { lifecycleRunId: void 0 } : {}
|
||||
+ };
|
||||
}
|
||||
function deriveGatewaySessionLifecycleProjectionPatch(params) {
|
||||
- const { restartRecoveryRuns: _restartRecoveryRuns, ...patch } = derivePersistedSessionLifecyclePatch(params);
|
||||
+ const {
|
||||
+ restartRecoveryRuns: _restartRecoveryRuns,
|
||||
+ lifecycleRunId: _lifecycleRunId,
|
||||
+ ...patch
|
||||
+ } = derivePersistedSessionLifecyclePatch(params);
|
||||
return patch;
|
||||
}
|
||||
function isRestartRecoveryLifecycleEvent(params) {
|
||||
diff --git a/dist/store-BJJhlPrk.js b/dist/store-BJJhlPrk.js
|
||||
index 15b9c3b2aea87e1f1782e8669de218d30afd9a00..e62c2a2061b639ba7291d9c19e6e8603beaae19e 100644
|
||||
--- a/dist/store-BJJhlPrk.js
|
||||
+++ b/dist/store-BJJhlPrk.js
|
||||
@@ -147,6 +147,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEYS = /* @__PURE__ */ new Set([
|
||||
"pluginExtensionSlotKeys",
|
||||
"pluginNextTurnInjections",
|
||||
"sessionId",
|
||||
+ "lifecycleRunId",
|
||||
"lifecycleRevision",
|
||||
"updatedAt",
|
||||
"archivedAt",
|
||||
@@ -229,6 +230,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEYS = /* @__PURE__ */ new Set([
|
||||
"pendingFinalDeliveryIntentId",
|
||||
"restartRecoveryDeliveryContext",
|
||||
"restartRecoveryDeliveryRunId",
|
||||
+ "restartRecoveryDeliverySourceRunId",
|
||||
"totalTokensFresh",
|
||||
"estimatedCostUsd",
|
||||
"cacheRead",
|
||||
Generated
+43
-50
@@ -7,6 +7,11 @@ settings:
|
||||
overrides:
|
||||
isbinaryfile: ^5.0.0
|
||||
|
||||
patchedDependencies:
|
||||
openclaw@2026.7.1-2:
|
||||
hash: fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3
|
||||
path: patches/openclaw@2026.7.1-2.patch
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
@@ -65,7 +70,7 @@ importers:
|
||||
version: 1.3.7
|
||||
'@larksuite/openclaw-lark':
|
||||
specifier: 2026.7.9
|
||||
version: 2026.7.9(openclaw@2026.7.1(encoding@0.1.13))
|
||||
version: 2026.7.9(openclaw@2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13))
|
||||
'@larksuiteoapi/node-sdk':
|
||||
specifier: ^1.61.1
|
||||
version: 1.62.0
|
||||
@@ -74,13 +79,13 @@ importers:
|
||||
version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@openclaw/discord':
|
||||
specifier: 2026.7.1
|
||||
version: 2026.7.1(openclaw@2026.7.1(encoding@0.1.13))
|
||||
version: 2026.7.1(openclaw@2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13))
|
||||
'@openclaw/qqbot':
|
||||
specifier: 2026.7.1
|
||||
version: 2026.7.1(openclaw@2026.7.1(encoding@0.1.13))
|
||||
version: 2026.7.1(openclaw@2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13))
|
||||
'@openclaw/whatsapp':
|
||||
specifier: 2026.7.1
|
||||
version: 2026.7.1(openclaw@2026.7.1(encoding@0.1.13))
|
||||
version: 2026.7.1(openclaw@2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13))
|
||||
'@playwright/test':
|
||||
specifier: ^1.56.1
|
||||
version: 1.59.0
|
||||
@@ -125,7 +130,7 @@ importers:
|
||||
version: 0.34.48
|
||||
'@soimy/dingtalk':
|
||||
specifier: 3.6.6
|
||||
version: 3.6.6(openclaw@2026.7.1(encoding@0.1.13))
|
||||
version: 3.6.6(openclaw@2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13))
|
||||
'@streamdown/cjk':
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(react@19.2.4)(unified@11.0.5)
|
||||
@@ -140,7 +145,7 @@ importers:
|
||||
version: 1.1.0
|
||||
'@tencent-weixin/openclaw-weixin':
|
||||
specifier: ^2.4.6
|
||||
version: 2.4.6(openclaw@2026.7.1(encoding@0.1.13))
|
||||
version: 2.4.6(openclaw@2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13))
|
||||
'@testing-library/jest-dom':
|
||||
specifier: ^6.9.1
|
||||
version: 6.9.1
|
||||
@@ -173,7 +178,7 @@ importers:
|
||||
version: 5.2.0(vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.9.0))
|
||||
'@wecom/wecom-openclaw-plugin':
|
||||
specifier: 2026.7.2
|
||||
version: 2026.7.2(openclaw@2026.7.1(encoding@0.1.13))
|
||||
version: 2026.7.2(openclaw@2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13))
|
||||
'@whiskeysockets/baileys':
|
||||
specifier: 7.0.0-rc.9
|
||||
version: 7.0.0-rc.9(audio-decode@2.2.3)(jimp@1.6.1)(sharp@0.34.5)
|
||||
@@ -262,8 +267,8 @@ importers:
|
||||
specifier: ^2.1.3
|
||||
version: 2.1.3
|
||||
openclaw:
|
||||
specifier: 2026.7.1
|
||||
version: 2026.7.1(encoding@0.1.13)
|
||||
specifier: 2026.7.1-2
|
||||
version: 2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13)
|
||||
opusscript:
|
||||
specifier: ^0.1.1
|
||||
version: 0.1.1
|
||||
@@ -1496,8 +1501,8 @@ packages:
|
||||
resolution: {integrity: sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==}
|
||||
engines: {node: ^18.17.0 || >=20.5.0}
|
||||
|
||||
'@openclaw/ai@2026.7.1':
|
||||
resolution: {integrity: sha512-FsKy5DXSHf4qyN8Huoz/10HZRgoEwLF4uk8UWaCafaIler+q5Fsl51HcrIqIrEe0S38OT7LOaxnR++MOshAlmw==}
|
||||
'@openclaw/ai@2026.7.1-2':
|
||||
resolution: {integrity: sha512-st+NH0cxlQqdbEur//yYqM7WlYBjeEBnop3cztJTSCONKjv6LNoGguI9cH65asZG94FdM/39z857isRGPnZvEw==}
|
||||
engines: {node: '>=22.19.0'}
|
||||
|
||||
'@openclaw/discord@2026.7.1':
|
||||
@@ -4213,7 +4218,7 @@ packages:
|
||||
|
||||
glob@7.2.3:
|
||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
deprecated: Glob versions prior to v9 are no longer supported
|
||||
|
||||
global-agent@3.0.0:
|
||||
resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==}
|
||||
@@ -4672,8 +4677,8 @@ packages:
|
||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
libsignal@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7:
|
||||
resolution: {gitHosted: true, tarball: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7}
|
||||
libsignal@git+https://git@github.com:whiskeysockets/libsignal-node.git#bcea72df9ec34d9d9140ab30619cf479c7c144c7:
|
||||
resolution: {commit: bcea72df9ec34d9d9140ab30619cf479c7c144c7, repo: git@github.com:whiskeysockets/libsignal-node.git, type: git}
|
||||
version: 6.0.0
|
||||
|
||||
lie@3.3.0:
|
||||
@@ -5315,8 +5320,8 @@ packages:
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
openclaw@2026.7.1:
|
||||
resolution: {integrity: sha512-ge/Xss99CHAjPL/ikmH/UFoiOrjcxDB4sW3y9mhyCD+dYW3wzV7TKbAVdkrXFgAG2d2BjpJofP97zUZ+umxo8g==}
|
||||
openclaw@2026.7.1-2:
|
||||
resolution: {integrity: sha512-ycF3yPcbjN6bUPeaUx6Mh6vze1hQWoD3CT/wWcmD7a8xaHHHRUaAlaq+lFxMHf1ssEgODVAwjlzYqp2twkYZ7g==}
|
||||
engines: {node: '>=22.22.3 <23 || >=24.15.0 <25 || >=25.9.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -6366,10 +6371,6 @@ packages:
|
||||
resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
tar@7.5.15:
|
||||
resolution: {integrity: sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
tar@7.5.19:
|
||||
resolution: {integrity: sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -7369,7 +7370,7 @@ snapshots:
|
||||
ora: 5.4.1
|
||||
read-binary-file-arch: 1.0.6
|
||||
semver: 7.7.4
|
||||
tar: 7.5.15
|
||||
tar: 7.5.19
|
||||
yargs: 17.7.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -8021,7 +8022,7 @@ snapshots:
|
||||
|
||||
'@kurkle/color@0.3.4': {}
|
||||
|
||||
'@larksuite/openclaw-lark@2026.7.9(openclaw@2026.7.1(encoding@0.1.13))':
|
||||
'@larksuite/openclaw-lark@2026.7.9(openclaw@2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13))':
|
||||
dependencies:
|
||||
'@larksuiteoapi/node-sdk': 1.66.1
|
||||
'@sinclair/typebox': 0.34.49
|
||||
@@ -8029,7 +8030,7 @@ snapshots:
|
||||
undici-types: 8.3.0
|
||||
zod: 4.4.3
|
||||
optionalDependencies:
|
||||
openclaw: 2026.7.1(encoding@0.1.13)
|
||||
openclaw: 2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13)
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- debug
|
||||
@@ -8281,7 +8282,7 @@ snapshots:
|
||||
dependencies:
|
||||
semver: 7.7.4
|
||||
|
||||
'@openclaw/ai@2026.7.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)':
|
||||
'@openclaw/ai@2026.7.1-2(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)':
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.109.1(zod@4.4.3)
|
||||
'@google/genai': 2.10.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))
|
||||
@@ -8301,9 +8302,9 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@openclaw/discord@2026.7.1(openclaw@2026.7.1(encoding@0.1.13))':
|
||||
'@openclaw/discord@2026.7.1(openclaw@2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13))':
|
||||
optionalDependencies:
|
||||
openclaw: 2026.7.1(encoding@0.1.13)
|
||||
openclaw: 2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13)
|
||||
|
||||
'@openclaw/fs-safe@0.4.1':
|
||||
optionalDependencies:
|
||||
@@ -8314,13 +8315,13 @@ snapshots:
|
||||
dependencies:
|
||||
undici: 8.5.0
|
||||
|
||||
'@openclaw/qqbot@2026.7.1(openclaw@2026.7.1(encoding@0.1.13))':
|
||||
'@openclaw/qqbot@2026.7.1(openclaw@2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13))':
|
||||
optionalDependencies:
|
||||
openclaw: 2026.7.1(encoding@0.1.13)
|
||||
openclaw: 2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13)
|
||||
|
||||
'@openclaw/whatsapp@2026.7.1(openclaw@2026.7.1(encoding@0.1.13))':
|
||||
'@openclaw/whatsapp@2026.7.1(openclaw@2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13))':
|
||||
optionalDependencies:
|
||||
openclaw: 2026.7.1(encoding@0.1.13)
|
||||
openclaw: 2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13)
|
||||
|
||||
'@opentelemetry/semantic-conventions@1.43.0': {}
|
||||
|
||||
@@ -9004,7 +9005,7 @@ snapshots:
|
||||
- '@emnapi/core'
|
||||
- '@emnapi/runtime'
|
||||
|
||||
'@soimy/dingtalk@3.6.6(openclaw@2026.7.1(encoding@0.1.13))':
|
||||
'@soimy/dingtalk@3.6.6(openclaw@2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13))':
|
||||
dependencies:
|
||||
axios: 1.13.6(debug@4.4.3)
|
||||
dingtalk-stream: 2.1.5
|
||||
@@ -9013,7 +9014,7 @@ snapshots:
|
||||
pdf-parse: 2.4.5
|
||||
zod: 4.4.3
|
||||
optionalDependencies:
|
||||
openclaw: 2026.7.1(encoding@0.1.13)
|
||||
openclaw: 2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13)
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- debug
|
||||
@@ -9059,9 +9060,9 @@ snapshots:
|
||||
dependencies:
|
||||
qrcode-terminal: 0.12.0
|
||||
|
||||
'@tencent-weixin/openclaw-weixin@2.4.6(openclaw@2026.7.1(encoding@0.1.13))':
|
||||
'@tencent-weixin/openclaw-weixin@2.4.6(openclaw@2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13))':
|
||||
dependencies:
|
||||
openclaw: 2026.7.1(encoding@0.1.13)
|
||||
openclaw: 2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13)
|
||||
qrcode-terminal: 0.12.0
|
||||
zod: 4.4.3
|
||||
|
||||
@@ -9550,7 +9551,7 @@ snapshots:
|
||||
- debug
|
||||
- utf-8-validate
|
||||
|
||||
'@wecom/wecom-openclaw-plugin@2026.7.2(openclaw@2026.7.1(encoding@0.1.13))':
|
||||
'@wecom/wecom-openclaw-plugin@2026.7.2(openclaw@2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13))':
|
||||
dependencies:
|
||||
'@wecom/aibot-node-sdk': 1.0.6
|
||||
fast-xml-parser: 5.7.3
|
||||
@@ -9558,7 +9559,7 @@ snapshots:
|
||||
undici: 7.24.6
|
||||
zod: 4.4.3
|
||||
optionalDependencies:
|
||||
openclaw: 2026.7.1(encoding@0.1.13)
|
||||
openclaw: 2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13)
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- debug
|
||||
@@ -9570,7 +9571,7 @@ snapshots:
|
||||
'@cacheable/node-cache': 1.7.6
|
||||
'@hapi/boom': 9.1.4
|
||||
async-mutex: 0.5.0
|
||||
libsignal: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7
|
||||
libsignal: git+https://git@github.com:whiskeysockets/libsignal-node.git#bcea72df9ec34d9d9140ab30619cf479c7c144c7
|
||||
lru-cache: 11.2.7
|
||||
music-metadata: 11.12.3
|
||||
p-queue: 9.1.0
|
||||
@@ -9980,7 +9981,7 @@ snapshots:
|
||||
minipass-pipeline: 1.2.4
|
||||
p-map: 7.0.4
|
||||
ssri: 12.0.0
|
||||
tar: 7.5.15
|
||||
tar: 7.5.19
|
||||
unique-filename: 4.0.0
|
||||
|
||||
cacheable-lookup@5.0.4: {}
|
||||
@@ -11828,7 +11829,7 @@ snapshots:
|
||||
prelude-ls: 1.2.1
|
||||
type-check: 0.4.0
|
||||
|
||||
libsignal@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7:
|
||||
libsignal@git+https://git@github.com:whiskeysockets/libsignal-node.git#bcea72df9ec34d9d9140ab30619cf479c7c144c7:
|
||||
dependencies:
|
||||
curve25519-js: 0.0.4
|
||||
protobufjs: 7.5.8
|
||||
@@ -12625,7 +12626,7 @@ snapshots:
|
||||
nopt: 8.1.0
|
||||
proc-log: 5.0.0
|
||||
semver: 7.7.4
|
||||
tar: 7.5.15
|
||||
tar: 7.5.19
|
||||
tinyglobby: 0.2.15
|
||||
which: 5.0.0
|
||||
transitivePeerDependencies:
|
||||
@@ -12699,7 +12700,7 @@ snapshots:
|
||||
ws: 8.21.0
|
||||
zod: 4.4.3
|
||||
|
||||
openclaw@2026.7.1(encoding@0.1.13):
|
||||
openclaw@2026.7.1-2(patch_hash=fbdd382bdaaebbaa3b1b61bd8329bbd6cd4e7c37cbfc0b6197e696d1d0d40db3)(encoding@0.1.13):
|
||||
dependencies:
|
||||
'@agentclientprotocol/sdk': 1.1.0(zod@4.4.3)
|
||||
'@anthropic-ai/sdk': 0.109.1(zod@4.4.3)
|
||||
@@ -12714,7 +12715,7 @@ snapshots:
|
||||
'@mistralai/mistralai': 2.4.0
|
||||
'@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3)
|
||||
'@mozilla/readability': 0.6.0
|
||||
'@openclaw/ai': 2026.7.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)
|
||||
'@openclaw/ai': 2026.7.1-2(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)
|
||||
'@openclaw/fs-safe': 0.4.1
|
||||
'@openclaw/proxyline': 0.3.3(undici@8.5.0)
|
||||
'@silvia-odwyer/photon-node': 0.3.4
|
||||
@@ -13971,14 +13972,6 @@ snapshots:
|
||||
minizlib: 3.1.0
|
||||
yallist: 5.0.0
|
||||
|
||||
tar@7.5.15:
|
||||
dependencies:
|
||||
'@isaacs/fs-minipass': 4.0.1
|
||||
chownr: 3.0.0
|
||||
minipass: 7.1.3
|
||||
minizlib: 3.1.0
|
||||
yallist: 5.0.0
|
||||
|
||||
tar@7.5.19:
|
||||
dependencies:
|
||||
'@isaacs/fs-minipass': 4.0.1
|
||||
|
||||
@@ -5,3 +5,6 @@ packages:
|
||||
ignoredBuiltDependencies:
|
||||
- electron
|
||||
- esbuild
|
||||
|
||||
patchedDependencies:
|
||||
openclaw@2026.7.1-2: patches/openclaw@2026.7.1-2.patch
|
||||
|
||||
@@ -243,6 +243,8 @@ export type ChannelSaveConfigPayload = ChannelTypePayload & {
|
||||
};
|
||||
export type ChannelSaveConfigResult = HostSuccess & {
|
||||
noChange?: boolean;
|
||||
/** Configuration is committed; a guarded Gateway restart is continuing asynchronously. */
|
||||
activationPending?: boolean;
|
||||
warning?: string;
|
||||
};
|
||||
export type ChannelConfiguredResult = HostSuccess & { channels?: Array<string | JsonRecord> };
|
||||
|
||||
@@ -214,21 +214,6 @@
|
||||
"Fill in Client ID (AppKey) and Client Secret (AppSecret)"
|
||||
]
|
||||
},
|
||||
"signal": {
|
||||
"description": "Connect Signal using signal-cli",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/signal",
|
||||
"fields": {
|
||||
"phoneNumber": {
|
||||
"label": "Phone Number",
|
||||
"placeholder": "+1234567890"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Install signal-cli on your system",
|
||||
"Register or link your phone number",
|
||||
"Enter your phone number below"
|
||||
]
|
||||
},
|
||||
"feishu": {
|
||||
"description": "Connect Feishu/Lark bot via WebSocket",
|
||||
"docsUrl": "https://icnnp7d0dymg.feishu.cn/wiki/GKn8wOvHnibpPNkNkPzcAvGlnzK#GdHUdp9t9oqyegxwV8ScLvVGn1c",
|
||||
@@ -268,118 +253,6 @@
|
||||
"Enter your Bot ID (or Corp ID) and Secret to establish connection"
|
||||
]
|
||||
},
|
||||
"imessage": {
|
||||
"description": "Connect iMessage via BlueBubbles (macOS)",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/bluebubbles",
|
||||
"fields": {
|
||||
"serverUrl": {
|
||||
"label": "BlueBubbles Server URL",
|
||||
"placeholder": "http://localhost:1234"
|
||||
},
|
||||
"password": {
|
||||
"label": "Server Password",
|
||||
"placeholder": "Your server password"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Install BlueBubbles server on your Mac",
|
||||
"Note the server URL and password",
|
||||
"Enter the connection details below"
|
||||
]
|
||||
},
|
||||
"matrix": {
|
||||
"description": "Connect to Matrix protocol",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/matrix",
|
||||
"fields": {
|
||||
"homeserver": {
|
||||
"label": "Homeserver URL",
|
||||
"placeholder": "https://matrix.org"
|
||||
},
|
||||
"accessToken": {
|
||||
"label": "Access Token",
|
||||
"placeholder": "Your access token"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Create a Matrix account or use existing",
|
||||
"Get an access token from your client",
|
||||
"Enter the homeserver and token below"
|
||||
]
|
||||
},
|
||||
"line": {
|
||||
"description": "Connect LINE Messaging API",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/line",
|
||||
"fields": {
|
||||
"channelAccessToken": {
|
||||
"label": "Channel Access Token",
|
||||
"placeholder": "Your LINE channel access token"
|
||||
},
|
||||
"channelSecret": {
|
||||
"label": "Channel Secret",
|
||||
"placeholder": "Your LINE channel secret"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Go to LINE Developers Console",
|
||||
"Create a Messaging API channel",
|
||||
"Get Channel Access Token and Secret"
|
||||
]
|
||||
},
|
||||
"msteams": {
|
||||
"description": "Connect Microsoft Teams via Bot Framework",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/msteams",
|
||||
"fields": {
|
||||
"appId": {
|
||||
"label": "App ID",
|
||||
"placeholder": "Your Microsoft App ID"
|
||||
},
|
||||
"appPassword": {
|
||||
"label": "App Password",
|
||||
"placeholder": "Your Microsoft App Password"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Go to Azure Portal",
|
||||
"Register a new Bot application",
|
||||
"Get App ID and create a password",
|
||||
"Configure Teams channel"
|
||||
]
|
||||
},
|
||||
"googlechat": {
|
||||
"description": "Connect Google Chat via webhook",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/googlechat",
|
||||
"fields": {
|
||||
"serviceAccountKey": {
|
||||
"label": "Service Account JSON Path",
|
||||
"placeholder": "/path/to/service-account.json"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Create a Google Cloud project",
|
||||
"Enable Google Chat API",
|
||||
"Create a service account",
|
||||
"Download the JSON key file"
|
||||
]
|
||||
},
|
||||
"mattermost": {
|
||||
"description": "Connect Mattermost via Bot API",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/mattermost",
|
||||
"fields": {
|
||||
"serverUrl": {
|
||||
"label": "Server URL",
|
||||
"placeholder": "https://your-mattermost.com"
|
||||
},
|
||||
"botToken": {
|
||||
"label": "Bot Access Token",
|
||||
"placeholder": "Your bot access token"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Go to Mattermost Integrations",
|
||||
"Create a new Bot Account",
|
||||
"Copy the access token"
|
||||
]
|
||||
},
|
||||
"qqbot": {
|
||||
"description": "Connect QQ Bot channel (built-in since OpenClaw 3.31)",
|
||||
"docsUrl": "https://icnnp7d0dymg.feishu.cn/wiki/KPIJwlyiGiupMrkiS9ice39Zn2c",
|
||||
|
||||
@@ -214,21 +214,6 @@
|
||||
"Client ID (AppKey) と Client Secret (AppSecret) を入力します"
|
||||
]
|
||||
},
|
||||
"signal": {
|
||||
"description": "signal-cli を使用して Signal に接続します",
|
||||
"fields": {
|
||||
"phoneNumber": {
|
||||
"label": "電話番号",
|
||||
"placeholder": "+1234567890"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"システムに signal-cli をインストールします",
|
||||
"電話番号を登録またはリンクします",
|
||||
"以下に電話番号を入力します"
|
||||
],
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/signal"
|
||||
},
|
||||
"feishu": {
|
||||
"description": "WebSocket 経由で Feishu/Lark ボットに接続します",
|
||||
"docsUrl": "https://icnnp7d0dymg.feishu.cn/wiki/GKn8wOvHnibpPNkNkPzcAvGlnzK#GdHUdp9t9oqyegxwV8ScLvVGn1c",
|
||||
@@ -268,118 +253,6 @@
|
||||
"ボット ID (または 企業 ID) とシークレットを入力して接続を確立します"
|
||||
]
|
||||
},
|
||||
"imessage": {
|
||||
"description": "BlueBubbles (macOS) 経由で iMessage に接続します",
|
||||
"fields": {
|
||||
"serverUrl": {
|
||||
"label": "BlueBubbles サーバーURL",
|
||||
"placeholder": "http://localhost:1234"
|
||||
},
|
||||
"password": {
|
||||
"label": "サーバーパスワード",
|
||||
"placeholder": "サーバーのパスワード"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Mac に BlueBubbles サーバーをインストールします",
|
||||
"サーバーURLとパスワードをメモします",
|
||||
"以下に接続詳細を入力します"
|
||||
],
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/bluebubbles"
|
||||
},
|
||||
"matrix": {
|
||||
"description": "Matrix プロトコルに接続します",
|
||||
"fields": {
|
||||
"homeserver": {
|
||||
"label": "ホームサーバー URL",
|
||||
"placeholder": "https://matrix.org"
|
||||
},
|
||||
"accessToken": {
|
||||
"label": "アクセストークン",
|
||||
"placeholder": "アクセストークン"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Matrix アカウントを作成するか、既存のものを使用します",
|
||||
"クライアントからアクセストークンを取得します",
|
||||
"以下にホームサーバーとトークンを入力します"
|
||||
],
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/matrix"
|
||||
},
|
||||
"line": {
|
||||
"description": "LINE Messaging API に接続します",
|
||||
"fields": {
|
||||
"channelAccessToken": {
|
||||
"label": "チャンネルアクセストークン",
|
||||
"placeholder": "LINE チャンネルアクセストークン"
|
||||
},
|
||||
"channelSecret": {
|
||||
"label": "チャンネルシークレット",
|
||||
"placeholder": "LINE チャンネルシークレット"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"LINE Developers Console に移動します",
|
||||
"Messaging API チャンネルを作成します",
|
||||
"チャンネルアクセストークンとシークレットを取得します"
|
||||
],
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/line"
|
||||
},
|
||||
"msteams": {
|
||||
"description": "Bot Framework 経由で Microsoft Teams に接続します",
|
||||
"fields": {
|
||||
"appId": {
|
||||
"label": "App ID",
|
||||
"placeholder": "Microsoft App ID"
|
||||
},
|
||||
"appPassword": {
|
||||
"label": "App Password",
|
||||
"placeholder": "Microsoft App Password"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Azure Portal に移動します",
|
||||
"新しい Bot アプリケーションを登録します",
|
||||
"App ID を取得し、パスワードを作成します",
|
||||
"Teams チャンネルを設定します"
|
||||
],
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/msteams"
|
||||
},
|
||||
"googlechat": {
|
||||
"description": "Webhook 経由で Google Chat に接続します",
|
||||
"fields": {
|
||||
"serviceAccountKey": {
|
||||
"label": "サービスアカウント JSON パス",
|
||||
"placeholder": "/path/to/service-account.json"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Google Cloud プロジェクトを作成します",
|
||||
"Google Chat API を有効にします",
|
||||
"サービスアカウントを作成します",
|
||||
"JSON キーファイルをダウンロードします"
|
||||
],
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/googlechat"
|
||||
},
|
||||
"mattermost": {
|
||||
"description": "Bot API 経由で Mattermost に接続します",
|
||||
"fields": {
|
||||
"serverUrl": {
|
||||
"label": "サーバー URL",
|
||||
"placeholder": "https://your-mattermost.com"
|
||||
},
|
||||
"botToken": {
|
||||
"label": "ボットアクセストークン",
|
||||
"placeholder": "ボットアクセストークン"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Mattermost Integrations に移動します",
|
||||
"新しい Bot アカウントを作成します",
|
||||
"アクセストークンをコピーします"
|
||||
],
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/mattermost"
|
||||
},
|
||||
"qqbot": {
|
||||
"description": "QQ ボットチャンネルに接続します(OpenClaw 3.31 より内蔵)",
|
||||
"fields": {
|
||||
|
||||
@@ -214,21 +214,6 @@
|
||||
"Заполните Client ID (AppKey) и Client Secret (AppSecret)"
|
||||
]
|
||||
},
|
||||
"signal": {
|
||||
"description": "Подключите Signal через signal-cli",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/signal",
|
||||
"fields": {
|
||||
"phoneNumber": {
|
||||
"label": "Номер телефона",
|
||||
"placeholder": "+1234567890"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Установите signal-cli в вашей системе",
|
||||
"Зарегистрируйте или привяжите ваш номер телефона",
|
||||
"Введите ваш номер телефона ниже"
|
||||
]
|
||||
},
|
||||
"feishu": {
|
||||
"description": "Подключите бота Feishu/Lark через WebSocket",
|
||||
"docsUrl": "https://icnnp7d0dymg.feishu.cn/wiki/GKn8wOvHnibpPNkNkPzcAvGlnzK#GdHUdp9t9oqyegxwV8ScLvVGn1c",
|
||||
@@ -268,118 +253,6 @@
|
||||
"Введите ваш Bot ID (или Corp ID) и Secret для установления соединения"
|
||||
]
|
||||
},
|
||||
"imessage": {
|
||||
"description": "Подключите iMessage через BlueBubbles (macOS)",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/bluebubbles",
|
||||
"fields": {
|
||||
"serverUrl": {
|
||||
"label": "URL сервера BlueBubbles",
|
||||
"placeholder": "http://localhost:1234"
|
||||
},
|
||||
"password": {
|
||||
"label": "Пароль сервера",
|
||||
"placeholder": "Пароль вашего сервера"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Установите сервер BlueBubbles на ваш Mac",
|
||||
"Запишите URL сервера и пароль",
|
||||
"Введите данные подключения ниже"
|
||||
]
|
||||
},
|
||||
"matrix": {
|
||||
"description": "Подключитесь к протоколу Matrix",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/matrix",
|
||||
"fields": {
|
||||
"homeserver": {
|
||||
"label": "URL Homeserver",
|
||||
"placeholder": "https://matrix.org"
|
||||
},
|
||||
"accessToken": {
|
||||
"label": "Access Token",
|
||||
"placeholder": "Ваш access token"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Создайте учётную запись Matrix или используйте существующую",
|
||||
"Получите access token из вашего клиента",
|
||||
"Введите homeserver и token ниже"
|
||||
]
|
||||
},
|
||||
"line": {
|
||||
"description": "Подключите LINE Messaging API",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/line",
|
||||
"fields": {
|
||||
"channelAccessToken": {
|
||||
"label": "Channel Access Token",
|
||||
"placeholder": "Ваш LINE channel access token"
|
||||
},
|
||||
"channelSecret": {
|
||||
"label": "Channel Secret",
|
||||
"placeholder": "Ваш LINE channel secret"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Перейдите в LINE Developers Console",
|
||||
"Создайте канал Messaging API",
|
||||
"Получите Channel Access Token и Secret"
|
||||
]
|
||||
},
|
||||
"msteams": {
|
||||
"description": "Подключите Microsoft Teams через Bot Framework",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/msteams",
|
||||
"fields": {
|
||||
"appId": {
|
||||
"label": "App ID",
|
||||
"placeholder": "Ваш Microsoft App ID"
|
||||
},
|
||||
"appPassword": {
|
||||
"label": "App Password",
|
||||
"placeholder": "Ваш Microsoft App Password"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Перейдите на Azure Portal",
|
||||
"Зарегистрируйте новое приложение бота",
|
||||
"Получите App ID и создайте пароль",
|
||||
"Настройте канал Teams"
|
||||
]
|
||||
},
|
||||
"googlechat": {
|
||||
"description": "Подключите Google Chat через webhook",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/googlechat",
|
||||
"fields": {
|
||||
"serviceAccountKey": {
|
||||
"label": "Путь к JSON файлу сервисного аккаунта",
|
||||
"placeholder": "/path/to/service-account.json"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Создайте проект Google Cloud",
|
||||
"Включите Google Chat API",
|
||||
"Создайте сервисный аккаунт",
|
||||
"Скачайте файл ключа JSON"
|
||||
]
|
||||
},
|
||||
"mattermost": {
|
||||
"description": "Подключите Mattermost через Bot API",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/mattermost",
|
||||
"fields": {
|
||||
"serverUrl": {
|
||||
"label": "URL сервера",
|
||||
"placeholder": "https://your-mattermost.com"
|
||||
},
|
||||
"botToken": {
|
||||
"label": "Bot Access Token",
|
||||
"placeholder": "Ваш bot access token"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Перейдите в Mattermost Integrations",
|
||||
"Создайте новую учётную запись бота",
|
||||
"Скопируйте access token"
|
||||
]
|
||||
},
|
||||
"qqbot": {
|
||||
"description": "Подключите канал QQ Bot (встроенный с OpenClaw 3.31)",
|
||||
"docsUrl": "https://icnnp7d0dymg.feishu.cn/wiki/KPIJwlyiGiupMrkiS9ice39Zn2c",
|
||||
|
||||
@@ -214,21 +214,6 @@
|
||||
"填写 Client ID (AppKey) 和 Client Secret (AppSecret)"
|
||||
]
|
||||
},
|
||||
"signal": {
|
||||
"description": "使用 signal-cli 连接 Signal",
|
||||
"docsUrl": "https://docs.openclaw.ai/zh-CN/channels/signal",
|
||||
"fields": {
|
||||
"phoneNumber": {
|
||||
"label": "手机号码",
|
||||
"placeholder": "+1234567890"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"在您的系统上安装 signal-cli",
|
||||
"注册或链接您的手机号码",
|
||||
"在下方输入您的手机号码"
|
||||
]
|
||||
},
|
||||
"feishu": {
|
||||
"description": "通过飞书官方推出的 OpenClaw 插件连接飞书/Lark 机器人",
|
||||
"docsUrl": "https://icnnp7d0dymg.feishu.cn/wiki/GKn8wOvHnibpPNkNkPzcAvGlnzK#Py88dTltfoJc1jxAhIBcW3Pkn7b",
|
||||
@@ -269,118 +254,6 @@
|
||||
"填写 Bot ID(可选企业 ID 或者直接使用机器人专属 ID)及 Secret 即可建立连接"
|
||||
]
|
||||
},
|
||||
"imessage": {
|
||||
"description": "通过 BlueBubbles (macOS) 连接 iMessage",
|
||||
"docsUrl": "https://docs.openclaw.ai/zh-CN/channels/bluebubbles",
|
||||
"fields": {
|
||||
"serverUrl": {
|
||||
"label": "BlueBubbles 服务器地址",
|
||||
"placeholder": "http://localhost:1234"
|
||||
},
|
||||
"password": {
|
||||
"label": "服务器密码",
|
||||
"placeholder": "您的服务器密码"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"在您的 Mac 上安装 BlueBubbles 服务器",
|
||||
"记下服务器地址和密码",
|
||||
"在下方输入连接详情"
|
||||
]
|
||||
},
|
||||
"matrix": {
|
||||
"description": "连接到 Matrix 协议",
|
||||
"docsUrl": "https://docs.openclaw.ai/zh-CN/channels/matrix",
|
||||
"fields": {
|
||||
"homeserver": {
|
||||
"label": "Homeserver 地址",
|
||||
"placeholder": "https://matrix.org"
|
||||
},
|
||||
"accessToken": {
|
||||
"label": "访问令牌 (Access Token)",
|
||||
"placeholder": "您的访问令牌"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"创建一个 Matrix 账户或使用现有账户",
|
||||
"从您的客户端获取访问令牌",
|
||||
"在下方输入 Homeserver 地址和令牌"
|
||||
]
|
||||
},
|
||||
"line": {
|
||||
"description": "连接 LINE Messaging API",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/line",
|
||||
"fields": {
|
||||
"channelAccessToken": {
|
||||
"label": "频道访问令牌",
|
||||
"placeholder": "您的 LINE 频道访问令牌"
|
||||
},
|
||||
"channelSecret": {
|
||||
"label": "频道密钥",
|
||||
"placeholder": "您的 LINE 频道密钥"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"前往 LINE 开发者控制台",
|
||||
"创建一个 Messaging API 频道",
|
||||
"获取频道访问令牌和密钥"
|
||||
]
|
||||
},
|
||||
"msteams": {
|
||||
"description": "通过 Bot Framework 连接 Microsoft Teams",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/msteams",
|
||||
"fields": {
|
||||
"appId": {
|
||||
"label": "应用 ID",
|
||||
"placeholder": "您的 Microsoft 应用 ID"
|
||||
},
|
||||
"appPassword": {
|
||||
"label": "应用密码",
|
||||
"placeholder": "您的 Microsoft 应用密码"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"前往 Azure 门户",
|
||||
"注册一个新的 Bot 应用",
|
||||
"获取应用 ID 并创建密码",
|
||||
"配置 Teams 频道"
|
||||
]
|
||||
},
|
||||
"googlechat": {
|
||||
"description": "通过 Webhook 连接 Google Chat",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/googlechat",
|
||||
"fields": {
|
||||
"serviceAccountKey": {
|
||||
"label": "服务账号 JSON 路径",
|
||||
"placeholder": "/path/to/service-account.json"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"创建 Google Cloud 项目",
|
||||
"启用 Google Chat API",
|
||||
"创建服务账号",
|
||||
"下载 JSON 密钥文件"
|
||||
]
|
||||
},
|
||||
"mattermost": {
|
||||
"description": "通过 Bot API 连接 Mattermost",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/mattermost",
|
||||
"fields": {
|
||||
"serverUrl": {
|
||||
"label": "服务器地址",
|
||||
"placeholder": "https://your-mattermost.com"
|
||||
},
|
||||
"botToken": {
|
||||
"label": "机器人访问令牌",
|
||||
"placeholder": "您的机器人访问令牌"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"前往 Mattermost 集成",
|
||||
"创建一个新的 Bot 账户",
|
||||
"复制访问令牌"
|
||||
]
|
||||
},
|
||||
"qqbot": {
|
||||
"description": "连接 QQ 机器人频道(OpenClaw 3.31 起内置)",
|
||||
"docsUrl": "https://icnnp7d0dymg.feishu.cn/wiki/KPIJwlyiGiupMrkiS9ice39Zn2c",
|
||||
|
||||
+16
-236
@@ -6,22 +6,18 @@
|
||||
/**
|
||||
* Supported channel types
|
||||
*/
|
||||
export type ChannelType =
|
||||
| 'whatsapp'
|
||||
| 'wechat'
|
||||
| 'dingtalk'
|
||||
| 'telegram'
|
||||
| 'discord'
|
||||
| 'signal'
|
||||
| 'feishu'
|
||||
| 'wecom'
|
||||
| 'imessage'
|
||||
| 'matrix'
|
||||
| 'line'
|
||||
| 'msteams'
|
||||
| 'googlechat'
|
||||
| 'mattermost'
|
||||
| 'qqbot';
|
||||
export const SUPPORTED_CHANNEL_TYPES = [
|
||||
'telegram',
|
||||
'discord',
|
||||
'whatsapp',
|
||||
'wechat',
|
||||
'dingtalk',
|
||||
'feishu',
|
||||
'wecom',
|
||||
'qqbot',
|
||||
] as const;
|
||||
|
||||
export type ChannelType = (typeof SUPPORTED_CHANNEL_TYPES)[number];
|
||||
|
||||
/**
|
||||
* Channel connection status
|
||||
@@ -86,15 +82,8 @@ export const CHANNEL_ICONS: Record<ChannelType, string> = {
|
||||
dingtalk: '💬',
|
||||
telegram: '✈️',
|
||||
discord: '🎮',
|
||||
signal: '🔒',
|
||||
feishu: '🐦',
|
||||
wecom: '💼',
|
||||
imessage: '💬',
|
||||
matrix: '🔗',
|
||||
line: '🟢',
|
||||
msteams: '👔',
|
||||
googlechat: '💭',
|
||||
mattermost: '💠',
|
||||
qqbot: '🐧',
|
||||
};
|
||||
|
||||
@@ -107,15 +96,8 @@ export const CHANNEL_NAMES: Record<ChannelType, string> = {
|
||||
dingtalk: 'DingTalk',
|
||||
telegram: 'Telegram',
|
||||
discord: 'Discord',
|
||||
signal: 'Signal',
|
||||
feishu: 'Feishu / Lark',
|
||||
wecom: 'WeCom',
|
||||
imessage: 'iMessage',
|
||||
matrix: 'Matrix',
|
||||
line: 'LINE',
|
||||
msteams: 'Microsoft Teams',
|
||||
googlechat: 'Google Chat',
|
||||
mattermost: 'Mattermost',
|
||||
qqbot: 'QQ Bot',
|
||||
};
|
||||
|
||||
@@ -319,28 +301,6 @@ export const CHANNEL_META: Record<ChannelType, ChannelMeta> = {
|
||||
],
|
||||
isPlugin: true,
|
||||
},
|
||||
signal: {
|
||||
id: 'signal',
|
||||
name: 'Signal',
|
||||
icon: '🔒',
|
||||
description: 'channels:meta.signal.description',
|
||||
connectionType: 'token',
|
||||
docsUrl: 'channels:meta.signal.docsUrl',
|
||||
configFields: [
|
||||
{
|
||||
key: 'phoneNumber',
|
||||
label: 'channels:meta.signal.fields.phoneNumber.label',
|
||||
type: 'text',
|
||||
placeholder: 'channels:meta.signal.fields.phoneNumber.placeholder',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
instructions: [
|
||||
'channels:meta.signal.instructions.0',
|
||||
'channels:meta.signal.instructions.1',
|
||||
'channels:meta.signal.instructions.2',
|
||||
],
|
||||
},
|
||||
feishu: {
|
||||
id: 'feishu',
|
||||
name: 'Feishu / Lark',
|
||||
@@ -374,195 +334,15 @@ export const CHANNEL_META: Record<ChannelType, ChannelMeta> = {
|
||||
],
|
||||
isPlugin: true,
|
||||
},
|
||||
imessage: {
|
||||
id: 'imessage',
|
||||
name: 'iMessage',
|
||||
icon: '💬',
|
||||
description: 'channels:meta.imessage.description',
|
||||
connectionType: 'token',
|
||||
docsUrl: 'channels:meta.imessage.docsUrl',
|
||||
configFields: [
|
||||
{
|
||||
key: 'serverUrl',
|
||||
label: 'channels:meta.imessage.fields.serverUrl.label',
|
||||
type: 'text',
|
||||
placeholder: 'channels:meta.imessage.fields.serverUrl.placeholder',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'password',
|
||||
label: 'channels:meta.imessage.fields.password.label',
|
||||
type: 'password',
|
||||
placeholder: 'channels:meta.imessage.fields.password.placeholder',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
instructions: [
|
||||
'channels:meta.imessage.instructions.0',
|
||||
'channels:meta.imessage.instructions.1',
|
||||
'channels:meta.imessage.instructions.2',
|
||||
],
|
||||
},
|
||||
matrix: {
|
||||
id: 'matrix',
|
||||
name: 'Matrix',
|
||||
icon: '🔗',
|
||||
description: 'channels:meta.matrix.description',
|
||||
connectionType: 'token',
|
||||
docsUrl: 'channels:meta.matrix.docsUrl',
|
||||
configFields: [
|
||||
{
|
||||
key: 'homeserver',
|
||||
label: 'channels:meta.matrix.fields.homeserver.label',
|
||||
type: 'text',
|
||||
placeholder: 'channels:meta.matrix.fields.homeserver.placeholder',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'accessToken',
|
||||
label: 'channels:meta.matrix.fields.accessToken.label',
|
||||
type: 'password',
|
||||
placeholder: 'channels:meta.matrix.fields.accessToken.placeholder',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
instructions: [
|
||||
'channels:meta.matrix.instructions.0',
|
||||
'channels:meta.matrix.instructions.1',
|
||||
'channels:meta.matrix.instructions.2',
|
||||
],
|
||||
isPlugin: true,
|
||||
},
|
||||
line: {
|
||||
id: 'line',
|
||||
name: 'LINE',
|
||||
icon: '🟢',
|
||||
description: 'channels:meta.line.description',
|
||||
connectionType: 'token',
|
||||
docsUrl: 'channels:meta.line.docsUrl',
|
||||
configFields: [
|
||||
{
|
||||
key: 'channelAccessToken',
|
||||
label: 'channels:meta.line.fields.channelAccessToken.label',
|
||||
type: 'password',
|
||||
placeholder: 'channels:meta.line.fields.channelAccessToken.placeholder',
|
||||
required: true,
|
||||
envVar: 'LINE_CHANNEL_ACCESS_TOKEN',
|
||||
},
|
||||
{
|
||||
key: 'channelSecret',
|
||||
label: 'channels:meta.line.fields.channelSecret.label',
|
||||
type: 'password',
|
||||
placeholder: 'channels:meta.line.fields.channelSecret.placeholder',
|
||||
required: true,
|
||||
envVar: 'LINE_CHANNEL_SECRET',
|
||||
},
|
||||
],
|
||||
instructions: [
|
||||
'channels:meta.line.instructions.0',
|
||||
'channels:meta.line.instructions.1',
|
||||
'channels:meta.line.instructions.2',
|
||||
],
|
||||
isPlugin: true,
|
||||
},
|
||||
msteams: {
|
||||
id: 'msteams',
|
||||
name: 'Microsoft Teams',
|
||||
icon: '👔',
|
||||
description: 'channels:meta.msteams.description',
|
||||
connectionType: 'token',
|
||||
docsUrl: 'channels:meta.msteams.docsUrl',
|
||||
configFields: [
|
||||
{
|
||||
key: 'appId',
|
||||
label: 'channels:meta.msteams.fields.appId.label',
|
||||
type: 'text',
|
||||
placeholder: 'channels:meta.msteams.fields.appId.placeholder',
|
||||
required: true,
|
||||
envVar: 'MSTEAMS_APP_ID',
|
||||
},
|
||||
{
|
||||
key: 'appPassword',
|
||||
label: 'channels:meta.msteams.fields.appPassword.label',
|
||||
type: 'password',
|
||||
placeholder: 'channels:meta.msteams.fields.appPassword.placeholder',
|
||||
required: true,
|
||||
envVar: 'MSTEAMS_APP_PASSWORD',
|
||||
},
|
||||
],
|
||||
instructions: [
|
||||
'channels:meta.msteams.instructions.0',
|
||||
'channels:meta.msteams.instructions.1',
|
||||
'channels:meta.msteams.instructions.2',
|
||||
'channels:meta.msteams.instructions.3',
|
||||
],
|
||||
isPlugin: true,
|
||||
},
|
||||
googlechat: {
|
||||
id: 'googlechat',
|
||||
name: 'Google Chat',
|
||||
icon: '💭',
|
||||
description: 'channels:meta.googlechat.description',
|
||||
connectionType: 'webhook',
|
||||
docsUrl: 'channels:meta.googlechat.docsUrl',
|
||||
configFields: [
|
||||
{
|
||||
key: 'serviceAccountKey',
|
||||
label: 'channels:meta.googlechat.fields.serviceAccountKey.label',
|
||||
type: 'text',
|
||||
placeholder: 'channels:meta.googlechat.fields.serviceAccountKey.placeholder',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
instructions: [
|
||||
'channels:meta.googlechat.instructions.0',
|
||||
'channels:meta.googlechat.instructions.1',
|
||||
'channels:meta.googlechat.instructions.2',
|
||||
'channels:meta.googlechat.instructions.3',
|
||||
],
|
||||
},
|
||||
mattermost: {
|
||||
id: 'mattermost',
|
||||
name: 'Mattermost',
|
||||
icon: '💠',
|
||||
description: 'channels:meta.mattermost.description',
|
||||
connectionType: 'token',
|
||||
docsUrl: 'channels:meta.mattermost.docsUrl',
|
||||
configFields: [
|
||||
{
|
||||
key: 'serverUrl',
|
||||
label: 'channels:meta.mattermost.fields.serverUrl.label',
|
||||
type: 'text',
|
||||
placeholder: 'channels:meta.mattermost.fields.serverUrl.placeholder',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'botToken',
|
||||
label: 'channels:meta.mattermost.fields.botToken.label',
|
||||
type: 'password',
|
||||
placeholder: 'channels:meta.mattermost.fields.botToken.placeholder',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
instructions: [
|
||||
'channels:meta.mattermost.instructions.0',
|
||||
'channels:meta.mattermost.instructions.1',
|
||||
'channels:meta.mattermost.instructions.2',
|
||||
],
|
||||
isPlugin: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get primary supported channels (non-plugin, commonly used)
|
||||
* Get the channel integrations supported by ClawX.
|
||||
*/
|
||||
export function getPrimaryChannels(): ChannelType[] {
|
||||
return ['telegram', 'discord', 'whatsapp', 'wechat', 'dingtalk', 'feishu', 'wecom', 'qqbot'];
|
||||
return [...SUPPORTED_CHANNEL_TYPES];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available channels including plugins
|
||||
*/
|
||||
export function getAllChannels(): ChannelType[] {
|
||||
return Object.keys(CHANNEL_META) as ChannelType[];
|
||||
export function isSupportedChannelType(channelType: string): channelType is ChannelType {
|
||||
return (SUPPORTED_CHANNEL_TYPES as readonly string[]).includes(channelType);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useChannelsStore } from '@/stores/channels';
|
||||
|
||||
import { hostApi } from '@/lib/host-api';
|
||||
import { hostEvents } from '@/lib/host-events';
|
||||
@@ -81,7 +80,6 @@ export function ChannelConfigModal({
|
||||
onChannelSaved,
|
||||
}: ChannelConfigModalProps) {
|
||||
const { t } = useTranslation('channels');
|
||||
const { fetchChannels } = useChannelsStore();
|
||||
const [selectedType, setSelectedType] = useState<ChannelType | null>(initialSelectedType);
|
||||
const [configValues, setConfigValues] = useState<Record<string, string>>({});
|
||||
const [channelName, setChannelName] = useState('');
|
||||
@@ -192,9 +190,8 @@ export function ChannelConfigModal({
|
||||
}, [selectedType, loadingConfig, showChannelName]);
|
||||
|
||||
const finishSave = useCallback(async (channelType: ChannelType) => {
|
||||
await fetchChannels();
|
||||
await onChannelSaved?.(channelType);
|
||||
}, [fetchChannels, onChannelSaved]);
|
||||
}, [onChannelSaved]);
|
||||
|
||||
const finishSaveRef = useRef(finishSave);
|
||||
const onCloseRef = useRef(onClose);
|
||||
@@ -408,7 +405,6 @@ export function ChannelConfigModal({
|
||||
|
||||
toast.success(t('toast.channelSaved', { name: meta.name }));
|
||||
toast.success(t('toast.channelConnecting', { name: meta.name }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast.error(t('toast.configFailed', { error: String(error) }));
|
||||
|
||||
@@ -135,6 +135,12 @@ export function WebBrowserHost(): React.ReactElement | null {
|
||||
setLoading(node.isLoading());
|
||||
void navigateToPreview();
|
||||
};
|
||||
const onDomReady = () => {
|
||||
// did-attach can be missed when React mounts during guest initialization.
|
||||
// dom-ready confirms Main has registered the guest and is safe to navigate.
|
||||
attachedWebviewRef.current = node;
|
||||
void navigateToPreview();
|
||||
};
|
||||
const onDidStartLoading = () => setLoading(true);
|
||||
const onDidStopLoading = () => setLoading(false);
|
||||
const onDidFailLoad = (event: DidFailLoadEvent) => {
|
||||
@@ -149,12 +155,14 @@ export function WebBrowserHost(): React.ReactElement | null {
|
||||
};
|
||||
|
||||
node.addEventListener('did-attach', onDidAttach);
|
||||
node.addEventListener('dom-ready', onDomReady);
|
||||
node.addEventListener('did-start-loading', onDidStartLoading);
|
||||
node.addEventListener('did-stop-loading', onDidStopLoading);
|
||||
node.addEventListener('did-fail-load', onDidFailLoad);
|
||||
node.addEventListener('render-process-gone', onRenderProcessGone);
|
||||
removeListenersRef.current = () => {
|
||||
node.removeEventListener('did-attach', onDidAttach);
|
||||
node.removeEventListener('dom-ready', onDomReady);
|
||||
node.removeEventListener('did-start-loading', onDidStartLoading);
|
||||
node.removeEventListener('did-stop-loading', onDidStopLoading);
|
||||
node.removeEventListener('did-fail-load', onDidFailLoad);
|
||||
|
||||
@@ -10,7 +10,14 @@ import { hostEvents } from '@/lib/host-events';
|
||||
import { ChannelConfigModal } from '@/components/channels/ChannelConfigModal';
|
||||
import { isGatewayStopped } from '@/lib/gateway-status';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CHANNEL_ICONS, CHANNEL_NAMES, CHANNEL_META, getPrimaryChannels, type ChannelType } from '@/types/channel';
|
||||
import {
|
||||
CHANNEL_ICONS,
|
||||
CHANNEL_NAMES,
|
||||
CHANNEL_META,
|
||||
getPrimaryChannels,
|
||||
isSupportedChannelType,
|
||||
type ChannelType,
|
||||
} from '@/types/channel';
|
||||
import { usesPluginManagedQrAccounts } from '@/lib/channel-alias';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
@@ -131,7 +138,14 @@ export function Channels() {
|
||||
const displayedGatewayHealth = isStaleNotRunningHealthForRunningGateway(gatewayHealth, gatewayStatus.state)
|
||||
? DEFAULT_GATEWAY_HEALTH
|
||||
: gatewayHealth;
|
||||
const visibleChannelGroups = channelGroups;
|
||||
const visibleChannelGroups = useMemo(
|
||||
() => channelGroups.filter(
|
||||
(group): group is ChannelGroupItem & { channelType: ChannelType } => (
|
||||
isSupportedChannelType(group.channelType)
|
||||
),
|
||||
),
|
||||
[channelGroups],
|
||||
);
|
||||
const visibleAgents = agents;
|
||||
const hasStableValue = visibleChannelGroups.length > 0 || visibleAgents.length > 0;
|
||||
const isUsingStableValue = hasStableValue && (loading || Boolean(error));
|
||||
@@ -326,14 +340,10 @@ export function Channels() {
|
||||
}, [visibleChannelGroups]);
|
||||
|
||||
const configuredGroups = useMemo(() => {
|
||||
const known = displayedChannelTypes
|
||||
return displayedChannelTypes
|
||||
.map((type) => groupedByType[type])
|
||||
.filter((group): group is ChannelGroupItem => Boolean(group));
|
||||
const unknown = visibleChannelGroups.filter(
|
||||
(group) => !displayedChannelTypes.includes(group.channelType as ChannelType),
|
||||
);
|
||||
return [...known, ...unknown];
|
||||
}, [visibleChannelGroups, displayedChannelTypes, groupedByType]);
|
||||
.filter((group): group is ChannelGroupItem & { channelType: ChannelType } => Boolean(group));
|
||||
}, [displayedChannelTypes, groupedByType]);
|
||||
|
||||
const unsupportedGroups = displayedChannelTypes.filter((type) => !configuredTypes.includes(type));
|
||||
|
||||
@@ -439,19 +449,23 @@ export function Channels() {
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
const target = deleteTarget;
|
||||
|
||||
// Close the dialog and update the list before waiting for OpenClaw's
|
||||
// coordinated config delivery. Main still owns the durable mutation; on
|
||||
// failure, reload the file-backed view to restore the actual state.
|
||||
setDeleteTarget(null);
|
||||
setChannelGroups((prev) => removeDeletedTarget(prev, target));
|
||||
|
||||
try {
|
||||
await hostApi.channels.deleteConfig(deleteTarget.channelType, deleteTarget.accountId);
|
||||
setChannelGroups((prev) => removeDeletedTarget(prev, deleteTarget));
|
||||
toast.success(deleteTarget.accountId ? t('toast.accountDeleted') : t('toast.channelDeleted'));
|
||||
// Channel reload is debounced in main process; pull again shortly to
|
||||
// converge with runtime state without flashing deleted rows back in.
|
||||
await hostApi.channels.deleteConfig(target.channelType, target.accountId);
|
||||
toast.success(target.accountId ? t('toast.accountDeleted') : t('toast.channelDeleted'));
|
||||
window.setTimeout(() => {
|
||||
void fetchPageData();
|
||||
}, 1200);
|
||||
} catch (deleteError) {
|
||||
toast.error(t('toast.configFailed', { error: String(deleteError) }));
|
||||
} finally {
|
||||
setDeleteTarget(null);
|
||||
void fetchPageData({ configOnly: true });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -841,7 +855,10 @@ export function Channels() {
|
||||
setInitialConfigValuesForModal(undefined);
|
||||
}}
|
||||
onChannelSaved={async () => {
|
||||
await fetchPageData({ probe: true });
|
||||
// The host may still be restarting Gateway for plugin activation.
|
||||
// Read the committed file-backed view immediately and let the
|
||||
// existing convergence loop refresh runtime status asynchronously.
|
||||
await fetchPageData({ configOnly: true });
|
||||
scheduleConvergenceRefresh();
|
||||
setShowConfigModal(false);
|
||||
setSelectedChannelType(null);
|
||||
|
||||
@@ -289,6 +289,7 @@ export function ChatInput({
|
||||
const chatComposerStatusComponents = rendererExtensionRegistry.getChatComposerStatusComponents();
|
||||
const isGatewayUsable = gatewayStatus.state === 'running' && gatewayStatus.gatewayReady !== false;
|
||||
const inputDisabled = disabled;
|
||||
const gatewayUnavailable = !isGatewayUsable;
|
||||
const workspaceSelectorDisabled = workspaceReadOnly || inputDisabled || sending || !onSelectWorkspace;
|
||||
const skillTokenRanges = useMemo(() => findSkillTokenRanges(input), [input]);
|
||||
const openArtifactPreview = useArtifactPanel((s) => s.openPreview);
|
||||
@@ -977,7 +978,7 @@ export function ChatInput({
|
||||
isComposingRef.current = false;
|
||||
}}
|
||||
onPaste={handlePaste}
|
||||
placeholder={inputDisabled ? t('composer.gatewayDisconnectedPlaceholder') : ''}
|
||||
placeholder={inputDisabled && gatewayUnavailable ? t('composer.gatewayDisconnectedPlaceholder') : ''}
|
||||
disabled={inputDisabled}
|
||||
data-testid="chat-composer-input"
|
||||
className={cn(
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { completeSetup, expect, test } from './fixtures/electron';
|
||||
|
||||
const configuredChannels = {
|
||||
success: true,
|
||||
channels: [{
|
||||
channelType: 'feishu',
|
||||
defaultAccountId: 'default',
|
||||
status: 'connected',
|
||||
accounts: [{
|
||||
accountId: 'default',
|
||||
name: 'Primary Account',
|
||||
configured: true,
|
||||
status: 'connected',
|
||||
isDefault: true,
|
||||
}],
|
||||
}],
|
||||
};
|
||||
|
||||
test.describe('Channel deletion responsiveness', () => {
|
||||
test('closes the confirmation and removes the channel before host cleanup settles', async ({ electronApp, page }) => {
|
||||
await electronApp.evaluate(({ ipcMain }, response) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).__clawxDeletePending = false;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).__clawxResolveDelete = null;
|
||||
const originalHostInvoke = (ipcMain as unknown as {
|
||||
_invokeHandlers?: Map<string, (event: unknown, request: unknown) => Promise<unknown>>;
|
||||
})._invokeHandlers?.get('host:invoke');
|
||||
const respond = (id: unknown, data: unknown) => ({
|
||||
id: typeof id === 'string' ? id : undefined,
|
||||
ok: true,
|
||||
data,
|
||||
});
|
||||
|
||||
ipcMain.removeHandler('host:invoke');
|
||||
ipcMain.handle('host:invoke', async (event, request: {
|
||||
id?: string;
|
||||
module?: string;
|
||||
action?: string;
|
||||
}) => {
|
||||
if (request?.module === 'channels' && request.action === 'accounts') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const pending = (globalThis as any).__clawxDeletePending === true;
|
||||
return respond(request.id, pending ? { success: true, channels: [] } : response);
|
||||
}
|
||||
if (request?.module === 'agents' && request.action === 'list') {
|
||||
return respond(request.id, { success: true, agents: [] });
|
||||
}
|
||||
if (request?.module === 'channels' && request.action === 'deleteConfig') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).__clawxDeletePending = true;
|
||||
return await new Promise((resolve) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).__clawxResolveDelete = () => resolve(respond(request.id, { success: true }));
|
||||
});
|
||||
}
|
||||
return originalHostInvoke?.(event, request) ?? respond(request?.id, {});
|
||||
});
|
||||
}, configuredChannels);
|
||||
|
||||
await completeSetup(page);
|
||||
await page.getByTestId('sidebar-nav-channels').click();
|
||||
|
||||
const channelsPage = page.getByTestId('channels-page');
|
||||
await expect(channelsPage.getByTitle('Delete channel')).toBeVisible();
|
||||
await channelsPage.getByTitle('Delete channel').click();
|
||||
await page.getByTestId('confirm-dialog-confirm-button').click();
|
||||
|
||||
await expect(page.getByTestId('confirm-dialog-confirm-button')).toBeHidden();
|
||||
await expect(channelsPage.getByTitle('Delete channel')).toHaveCount(0);
|
||||
await expect.poll(async () => electronApp.evaluate(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (globalThis as any).__clawxDeletePending;
|
||||
})).toBe(true);
|
||||
|
||||
await electronApp.evaluate(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).__clawxResolveDelete?.();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { completeSetup, expect, test } from './fixtures/electron';
|
||||
|
||||
const responses = {
|
||||
channels: { success: true, channels: [] },
|
||||
agents: { success: true, agents: [] },
|
||||
validation: { success: true, valid: true, warnings: [] },
|
||||
};
|
||||
|
||||
test.describe('Plugin-backed channel save', () => {
|
||||
test('submits QQBot credentials through the typed Channels host API', async ({ electronApp, page }) => {
|
||||
await electronApp.evaluate(({ ipcMain }, fixtures) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).__clawxPluginChannelSavePayload = null;
|
||||
const originalHostInvoke = (ipcMain as unknown as {
|
||||
_invokeHandlers?: Map<string, (event: unknown, request: unknown) => Promise<unknown>>;
|
||||
})._invokeHandlers?.get('host:invoke');
|
||||
const respond = (id: unknown, data: unknown) => ({
|
||||
id: typeof id === 'string' ? id : undefined,
|
||||
ok: true,
|
||||
data,
|
||||
});
|
||||
|
||||
ipcMain.removeHandler('host:invoke');
|
||||
ipcMain.handle('host:invoke', async (event, request: {
|
||||
id?: string;
|
||||
module?: string;
|
||||
action?: string;
|
||||
payload?: unknown;
|
||||
}) => {
|
||||
if (request?.module === 'channels' && request.action === 'accounts') {
|
||||
return respond(request.id, fixtures.channels);
|
||||
}
|
||||
if (request?.module === 'agents' && request.action === 'list') {
|
||||
return respond(request.id, fixtures.agents);
|
||||
}
|
||||
if (request?.module === 'channels' && request.action === 'validateCredentials') {
|
||||
return respond(request.id, fixtures.validation);
|
||||
}
|
||||
if (request?.module === 'channels' && request.action === 'saveConfig') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).__clawxPluginChannelSavePayload = request.payload;
|
||||
return respond(request.id, { success: true, activationPending: true });
|
||||
}
|
||||
return originalHostInvoke?.(event, request) ?? respond(request?.id, {});
|
||||
});
|
||||
}, responses);
|
||||
|
||||
await completeSetup(page);
|
||||
await page.getByTestId('sidebar-nav-channels').click();
|
||||
|
||||
const channelsPage = page.getByTestId('channels-page');
|
||||
await expect(channelsPage).toBeVisible();
|
||||
await channelsPage.getByRole('button', { name: /QQ Bot/ }).click();
|
||||
|
||||
await page.locator('#appId').fill('qq-app-id');
|
||||
await page.locator('#clientSecret').fill('qq-client-secret');
|
||||
await page.getByRole('button', { name: /Save & Connect|dialog\.saveAndConnect/i }).click();
|
||||
|
||||
await expect.poll(async () => electronApp.evaluate(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (globalThis as any).__clawxPluginChannelSavePayload;
|
||||
})).toEqual({
|
||||
channelType: 'qqbot',
|
||||
config: {
|
||||
appId: 'qq-app-id',
|
||||
clientSecret: 'qq-client-secret',
|
||||
},
|
||||
});
|
||||
await expect(page.getByText(/Configure QQ Bot|dialog\.configureTitle/i)).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { completeSetup, expect, test } from './fixtures/electron';
|
||||
|
||||
const unsupportedChannelTypes = [
|
||||
'signal',
|
||||
'imessage',
|
||||
'matrix',
|
||||
'line',
|
||||
'msteams',
|
||||
'googlechat',
|
||||
'mattermost',
|
||||
];
|
||||
|
||||
const channelsResponse = {
|
||||
success: true,
|
||||
channels: [
|
||||
{
|
||||
channelType: 'feishu',
|
||||
defaultAccountId: 'default',
|
||||
status: 'connected',
|
||||
accounts: [],
|
||||
},
|
||||
...unsupportedChannelTypes.map((channelType) => ({
|
||||
channelType,
|
||||
defaultAccountId: 'default',
|
||||
status: 'connected',
|
||||
accounts: [{
|
||||
accountId: 'default',
|
||||
name: `unsupported-${channelType}`,
|
||||
configured: true,
|
||||
status: 'connected',
|
||||
isDefault: true,
|
||||
}],
|
||||
})),
|
||||
],
|
||||
};
|
||||
|
||||
test.describe('ClawX supported channel catalog', () => {
|
||||
test('does not expose unsupported runtime channels as configurable integrations', async ({ electronApp, page }) => {
|
||||
await electronApp.evaluate(({ ipcMain }, response) => {
|
||||
const originalHostInvoke = (ipcMain as unknown as {
|
||||
_invokeHandlers?: Map<string, (event: unknown, request: unknown) => Promise<unknown>>;
|
||||
})._invokeHandlers?.get('host:invoke');
|
||||
const respond = (id: unknown, data: unknown) => ({
|
||||
id: typeof id === 'string' ? id : undefined,
|
||||
ok: true,
|
||||
data,
|
||||
});
|
||||
|
||||
ipcMain.removeHandler('host:invoke');
|
||||
ipcMain.handle('host:invoke', async (event, request: {
|
||||
id?: string;
|
||||
module?: string;
|
||||
action?: string;
|
||||
}) => {
|
||||
if (request?.module === 'channels' && request.action === 'accounts') {
|
||||
return respond(request.id, response);
|
||||
}
|
||||
if (request?.module === 'agents' && request.action === 'list') {
|
||||
return respond(request.id, { success: true, agents: [] });
|
||||
}
|
||||
return originalHostInvoke?.(event, request) ?? respond(request?.id, {});
|
||||
});
|
||||
}, channelsResponse);
|
||||
|
||||
await completeSetup(page);
|
||||
await page.getByTestId('sidebar-nav-channels').click();
|
||||
|
||||
const channelsPage = page.getByTestId('channels-page');
|
||||
await expect(channelsPage).toBeVisible();
|
||||
await expect(channelsPage.getByText('Feishu / Lark')).toBeVisible();
|
||||
await expect(channelsPage.getByText('Telegram', { exact: true })).toBeVisible();
|
||||
|
||||
for (const channelType of unsupportedChannelTypes) {
|
||||
await expect(channelsPage.getByText(channelType, { exact: true })).toHaveCount(0);
|
||||
await expect(channelsPage.getByText(`unsupported-${channelType}`)).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -844,6 +844,11 @@ test.describe('ClawX ACP inline timeline', () => {
|
||||
messageId: 'history-user',
|
||||
content: [{ type: 'text', text: 'Replay the tool call' }],
|
||||
},
|
||||
{
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
messageId: 'history-assistant-before',
|
||||
content: { type: 'text', text: 'Before the historical tool' },
|
||||
},
|
||||
{
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'history-tool',
|
||||
@@ -853,9 +858,9 @@ test.describe('ClawX ACP inline timeline', () => {
|
||||
locations: [],
|
||||
},
|
||||
{
|
||||
sessionUpdate: 'agent_message',
|
||||
messageId: 'history-assistant',
|
||||
content: [{ type: 'text', text: 'Historical answer' }],
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
messageId: 'history-assistant-after',
|
||||
content: { type: 'text', text: 'After the historical tool' },
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -868,7 +873,14 @@ test.describe('ClawX ACP inline timeline', () => {
|
||||
await page.getByTestId('acp-tool-toggle').click();
|
||||
await expect(card).toHaveAttribute('data-expanded', 'true');
|
||||
await expect(card).toContainText('historical output');
|
||||
await expect(page.getByTestId('acp-assistant-turn')).toContainText('Historical answer');
|
||||
const turn = page.getByTestId('acp-assistant-turn');
|
||||
await expect(turn).toContainText('Before the historical tool');
|
||||
await expect(turn).toContainText('After the historical tool');
|
||||
const orderedParts = turn.locator('[data-testid="acp-assistant-message"], [data-testid="acp-tool-call-card"]');
|
||||
await expect(orderedParts).toHaveCount(3);
|
||||
await expect(orderedParts.nth(0)).toContainText('Before the historical tool');
|
||||
await expect(orderedParts.nth(1)).toContainText('Historical tool');
|
||||
await expect(orderedParts.nth(2)).toContainText('After the historical tool');
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
|
||||
@@ -316,8 +316,26 @@ test.describe('ClawX chat workspace context', () => {
|
||||
}
|
||||
|
||||
const workspaceSelector = page.getByTestId('chat-workspace-selector');
|
||||
const composerInput = page.getByTestId('chat-composer-input');
|
||||
await expect(workspaceSelector).toHaveText(SESSION_WORKSPACE_LABEL, { timeout: 30_000 });
|
||||
await expect(workspaceSelector).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(composerInput).toBeEnabled();
|
||||
|
||||
await composerInput.evaluate((element) => {
|
||||
const input = element as HTMLTextAreaElement;
|
||||
const state = { showedGatewayDisconnected: false };
|
||||
const observe = () => {
|
||||
if (input.placeholder === 'Gateway not connected...') {
|
||||
state.showedGatewayDisconnected = true;
|
||||
}
|
||||
};
|
||||
const observer = new MutationObserver(observe);
|
||||
observer.observe(input, { attributes: true, attributeFilter: ['placeholder'] });
|
||||
observe();
|
||||
(globalThis as unknown as {
|
||||
__newChatComposerPlaceholderObservation?: { observer: MutationObserver; state: typeof state };
|
||||
}).__newChatComposerPlaceholderObservation = { observer, state };
|
||||
});
|
||||
|
||||
await page.getByTestId('sidebar-new-chat').click();
|
||||
|
||||
@@ -333,6 +351,18 @@ test.describe('ClawX chat workspace context', () => {
|
||||
await expect(workspaceSelector).toHaveText(SESSION_WORKSPACE_LABEL);
|
||||
await expect(workspaceSelector).toHaveAttribute('title', SESSION_WORKSPACE);
|
||||
await expect(workspaceSelector).not.toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(composerInput).toBeEnabled();
|
||||
const showedGatewayDisconnected = await composerInput.evaluate(() => {
|
||||
const observation = (globalThis as unknown as {
|
||||
__newChatComposerPlaceholderObservation?: {
|
||||
observer: MutationObserver;
|
||||
state: { showedGatewayDisconnected: boolean };
|
||||
};
|
||||
}).__newChatComposerPlaceholderObservation;
|
||||
observation?.observer.disconnect();
|
||||
return observation?.state.showedGatewayDisconnected ?? false;
|
||||
});
|
||||
expect(showedGatewayDisconnected).toBe(false);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
|
||||
@@ -505,7 +505,7 @@ describe('agent config lifecycle', () => {
|
||||
expect((config.channels as Record<string, unknown>).telegram).toBeUndefined();
|
||||
});
|
||||
|
||||
it('deletes owned plugin-only credentials while preserving sibling accounts', async () => {
|
||||
it('migrates legacy plugin-only credentials while deleting the owned account', async () => {
|
||||
await writeOpenClawJson({
|
||||
agents: {
|
||||
list: [
|
||||
@@ -537,14 +537,20 @@ describe('agent config lifecycle', () => {
|
||||
await deleteAgentConfig('test2');
|
||||
|
||||
const config = await readOpenClawJson();
|
||||
const discord = ((config.plugins as {
|
||||
entries: Record<string, Record<string, unknown>>;
|
||||
}).entries).discord;
|
||||
expect(discord.defaultAccount).toBe('test3');
|
||||
expect(discord.accounts).toEqual({
|
||||
const discordChannel = (config.channels as {
|
||||
discord: Record<string, unknown>;
|
||||
}).discord;
|
||||
expect(discordChannel.defaultAccount).toBe('test3');
|
||||
expect(discordChannel.accounts).toEqual({
|
||||
test3: { enabled: true, token: 'discord-token-3' },
|
||||
});
|
||||
expect(JSON.stringify(discord)).not.toContain('discord-token-2');
|
||||
expect(discordChannel.token).toBe('discord-token-3');
|
||||
|
||||
const discordPlugin = ((config.plugins as {
|
||||
entries: Record<string, Record<string, unknown>>;
|
||||
}).entries).discord;
|
||||
expect(discordPlugin).toEqual({ enabled: true });
|
||||
expect(JSON.stringify(config)).not.toContain('discord-token-2');
|
||||
});
|
||||
|
||||
it('allows the same agent to bind multiple different channels', async () => {
|
||||
|
||||
@@ -201,14 +201,13 @@ describe('WeCom plugin configuration', () => {
|
||||
|
||||
const config = await readOpenClawJson();
|
||||
const channels = config.channels as Record<string, { enabled?: boolean; defaultAccount?: string; accounts?: Record<string, { enabled?: boolean }> }>;
|
||||
const plugins = config.plugins as { allow: string[]; entries: Record<string, { enabled?: boolean; defaultAccount?: string; accounts?: Record<string, { enabled?: boolean }> }> };
|
||||
const plugins = config.plugins as { allow: string[]; entries: Record<string, Record<string, unknown>> };
|
||||
|
||||
expect(channels.whatsapp.enabled).toBe(true);
|
||||
expect(channels.whatsapp.defaultAccount).toBe('default');
|
||||
expect(channels.whatsapp.accounts?.default?.enabled).toBe(true);
|
||||
expect(plugins.allow).toContain('whatsapp');
|
||||
expect(plugins.entries.whatsapp.enabled).toBe(true);
|
||||
expect(plugins.entries.whatsapp.accounts?.default?.enabled).toBe(true);
|
||||
expect(plugins.entries.whatsapp).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('keeps whatsapp plugin registration when saving plugin-backed config', async () => {
|
||||
@@ -244,12 +243,15 @@ describe('WeCom plugin configuration', () => {
|
||||
|
||||
const config = await readOpenClawJson();
|
||||
const channels = config.channels as Record<string, { accounts?: Record<string, unknown> }>;
|
||||
const plugins = config.plugins as { entries?: Record<string, { accounts?: Record<string, unknown> }> };
|
||||
const plugins = config.plugins as { allow?: string[]; entries?: Record<string, Record<string, unknown>> };
|
||||
|
||||
expect(channels.discord.accounts?.default).toBeDefined();
|
||||
expect(channels.qqbot.accounts?.default).toBeDefined();
|
||||
expect(plugins.entries?.discord?.accounts?.default).toBeDefined();
|
||||
expect(plugins.entries?.qqbot?.accounts?.default).toBeDefined();
|
||||
expect(plugins.entries?.whatsapp?.accounts?.default).toBeDefined();
|
||||
expect(channels.whatsapp.accounts?.default).toBeDefined();
|
||||
expect(plugins.allow).toEqual(expect.arrayContaining(['discord', 'qqbot', 'whatsapp']));
|
||||
expect(plugins.entries?.discord).toEqual({ enabled: true });
|
||||
expect(plugins.entries?.qqbot).toEqual({ enabled: true });
|
||||
expect(plugins.entries?.whatsapp).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('saves discord guild channel allowlist without schema-invalid allow flags', async () => {
|
||||
@@ -364,7 +366,7 @@ describe('WeCom plugin configuration', () => {
|
||||
expect((config.channels as Record<string, unknown>).telegram).toBeUndefined();
|
||||
});
|
||||
|
||||
it('removes deleted plugin-backed account credentials from the plugin mirror', async () => {
|
||||
it('removes legacy plugin account mirrors when deleting a channel account', async () => {
|
||||
await writeOpenClawJson({
|
||||
channels: {
|
||||
discord: {
|
||||
@@ -402,14 +404,12 @@ describe('WeCom plugin configuration', () => {
|
||||
expect(channel.accounts).toEqual({
|
||||
'agent-b': { token: 'discord-token-b', enabled: true },
|
||||
});
|
||||
expect(plugin.defaultAccount).toBe('agent-b');
|
||||
expect(plugin.accounts).toEqual({
|
||||
'agent-b': { token: 'discord-token-b', enabled: true },
|
||||
});
|
||||
expect(plugin).toEqual({ enabled: true });
|
||||
expect(JSON.stringify(plugin)).not.toContain('discord-token-a');
|
||||
expect(JSON.stringify(plugin)).not.toContain('discord-token-b');
|
||||
});
|
||||
|
||||
it('removes plugin-only account credentials while preserving sibling accounts', async () => {
|
||||
it('removes a legacy plugin-only registration without canonical channel config', async () => {
|
||||
await writeOpenClawJson({
|
||||
plugins: {
|
||||
allow: ['discord'],
|
||||
@@ -430,14 +430,7 @@ describe('WeCom plugin configuration', () => {
|
||||
await deleteChannelAccountConfig('discord', 'agent-a');
|
||||
|
||||
const config = await readOpenClawJson();
|
||||
const plugin = ((config.plugins as {
|
||||
entries: Record<string, Record<string, unknown>>;
|
||||
}).entries).discord;
|
||||
expect(plugin.defaultAccount).toBe('agent-b');
|
||||
expect(plugin.accounts).toEqual({
|
||||
'agent-b': { token: 'discord-token-b', enabled: true },
|
||||
});
|
||||
expect(JSON.stringify(plugin)).not.toContain('discord-token-a');
|
||||
expect(config.plugins).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { Channels } from '@/pages/Channels/index';
|
||||
import { CHANNEL_META, SUPPORTED_CHANNEL_TYPES } from '@shared/types/channel';
|
||||
|
||||
const hostApiCallMock = vi.fn();
|
||||
const subscribeHostEventMock = vi.fn();
|
||||
@@ -74,10 +75,12 @@ vi.mock('sonner', () => ({
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
const promise = new Promise<T>((res) => {
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve };
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe('Channels page status refresh', () => {
|
||||
@@ -129,6 +132,63 @@ describe('Channels page status refresh', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('defines exactly the eight ClawX-supported channel integrations', () => {
|
||||
expect(Object.keys(CHANNEL_META).sort()).toEqual([...SUPPORTED_CHANNEL_TYPES].sort());
|
||||
});
|
||||
|
||||
it('filters runtime channel groups that ClawX does not support', async () => {
|
||||
subscribeHostEventMock.mockImplementation(() => vi.fn());
|
||||
const unsupportedChannelTypes = [
|
||||
'signal',
|
||||
'imessage',
|
||||
'matrix',
|
||||
'line',
|
||||
'msteams',
|
||||
'googlechat',
|
||||
'mattermost',
|
||||
];
|
||||
hostApiCallMock.mockImplementation(async (path: string) => {
|
||||
if (path === 'channels.accounts') {
|
||||
return {
|
||||
success: true,
|
||||
channels: [
|
||||
{
|
||||
channelType: 'feishu',
|
||||
defaultAccountId: 'default',
|
||||
status: 'connected',
|
||||
accounts: [],
|
||||
},
|
||||
...unsupportedChannelTypes.map((channelType) => ({
|
||||
channelType,
|
||||
defaultAccountId: 'default',
|
||||
status: 'connected',
|
||||
accounts: [{
|
||||
accountId: 'default',
|
||||
name: `unsupported-${channelType}`,
|
||||
configured: true,
|
||||
status: 'connected',
|
||||
isDefault: true,
|
||||
}],
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
if (path === 'agents.list') return { success: true, agents: [] };
|
||||
throw new Error(`Unexpected host API path: ${path}`);
|
||||
});
|
||||
|
||||
render(<Channels />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Feishu / Lark')).toBeInTheDocument();
|
||||
expect(screen.getByText('Telegram')).toBeInTheDocument();
|
||||
});
|
||||
for (const channelType of unsupportedChannelTypes) {
|
||||
expect(screen.queryByText(channelType, { exact: true })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(`unsupported-${channelType}`)).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('blocks saving when custom account ID is non-canonical', async () => {
|
||||
subscribeHostEventMock.mockImplementation(() => vi.fn());
|
||||
hostApiCallMock.mockImplementation(async (path: string) => {
|
||||
@@ -211,6 +271,145 @@ describe('Channels page status refresh', () => {
|
||||
expect(saveCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('uses a config-only refresh immediately after a channel save', async () => {
|
||||
subscribeHostEventMock.mockImplementation(() => vi.fn());
|
||||
hostApiCallMock.mockImplementation(async (path: string) => {
|
||||
if (path === 'channels.accounts') {
|
||||
return { success: true, channels: [] };
|
||||
}
|
||||
if (path === 'agents.list') return { success: true, agents: [] };
|
||||
if (path === 'channels.validateCredentials') {
|
||||
return { success: true, valid: true, warnings: [] };
|
||||
}
|
||||
if (path === 'channels.saveConfig') {
|
||||
return { success: true, activationPending: true };
|
||||
}
|
||||
throw new Error(`Unexpected host API path: ${path}`);
|
||||
});
|
||||
|
||||
render(<Channels />);
|
||||
await screen.findByRole('button', { name: /QQ Bot/ });
|
||||
hostApiCallMock.mockClear();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /QQ Bot/ }));
|
||||
fireEvent.change(document.getElementById('appId') as HTMLInputElement, {
|
||||
target: { value: 'qq-app-id' },
|
||||
});
|
||||
fireEvent.change(document.getElementById('clientSecret') as HTMLInputElement, {
|
||||
target: { value: 'qq-client-secret' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'dialog.saveAndConnect' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('dialog.configureTitle')).not.toBeInTheDocument();
|
||||
});
|
||||
const postSaveAccountCalls = hostApiCallMock.mock.calls.filter(
|
||||
([path]) => path === 'channels.accounts',
|
||||
);
|
||||
expect(postSaveAccountCalls).toEqual([
|
||||
['channels.accounts', expect.objectContaining({ mode: 'config', probe: false })],
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes a channel optimistically before the host delete settles', async () => {
|
||||
subscribeHostEventMock.mockImplementation(() => vi.fn());
|
||||
const deleteDeferred = createDeferred<{ success: true }>();
|
||||
hostApiCallMock.mockImplementation(async (path: string) => {
|
||||
if (path === 'channels.accounts') {
|
||||
return {
|
||||
success: true,
|
||||
channels: [{
|
||||
channelType: 'feishu',
|
||||
defaultAccountId: 'default',
|
||||
status: 'connected',
|
||||
accounts: [{
|
||||
accountId: 'default',
|
||||
name: 'Primary Account',
|
||||
configured: true,
|
||||
status: 'connected',
|
||||
isDefault: true,
|
||||
}],
|
||||
}],
|
||||
};
|
||||
}
|
||||
if (path === 'agents.list') return { success: true, agents: [] };
|
||||
if (path === 'channels.deleteConfig') return deleteDeferred.promise;
|
||||
throw new Error(`Unexpected host API path: ${path}`);
|
||||
});
|
||||
|
||||
render(<Channels />);
|
||||
await screen.findByTitle('account.deleteChannel');
|
||||
fireEvent.click(screen.getByTitle('account.deleteChannel'));
|
||||
fireEvent.click(await screen.findByTestId('confirm-dialog-confirm-button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('confirm-dialog-confirm-button')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle('account.deleteChannel')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(toastSuccessMock).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
deleteDeferred.resolve({ success: true });
|
||||
await deleteDeferred.promise;
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(toastSuccessMock).toHaveBeenCalledWith('toast.channelDeleted');
|
||||
});
|
||||
});
|
||||
|
||||
it('restores the config-backed view when an optimistic channel delete fails', async () => {
|
||||
subscribeHostEventMock.mockImplementation(() => vi.fn());
|
||||
const deleteDeferred = createDeferred<{ success: true }>();
|
||||
hostApiCallMock.mockImplementation(async (path: string) => {
|
||||
if (path === 'channels.accounts') {
|
||||
return {
|
||||
success: true,
|
||||
channels: [{
|
||||
channelType: 'feishu',
|
||||
defaultAccountId: 'default',
|
||||
status: 'connected',
|
||||
accounts: [{
|
||||
accountId: 'default',
|
||||
name: 'Primary Account',
|
||||
configured: true,
|
||||
status: 'connected',
|
||||
isDefault: true,
|
||||
}],
|
||||
}],
|
||||
};
|
||||
}
|
||||
if (path === 'agents.list') return { success: true, agents: [] };
|
||||
if (path === 'channels.deleteConfig') return deleteDeferred.promise;
|
||||
throw new Error(`Unexpected host API path: ${path}`);
|
||||
});
|
||||
|
||||
render(<Channels />);
|
||||
await screen.findByTitle('account.deleteChannel');
|
||||
fireEvent.click(screen.getByTitle('account.deleteChannel'));
|
||||
fireEvent.click(await screen.findByTestId('confirm-dialog-confirm-button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTitle('account.deleteChannel')).not.toBeInTheDocument();
|
||||
});
|
||||
await act(async () => {
|
||||
deleteDeferred.reject(new Error('delete failed'));
|
||||
try {
|
||||
await deleteDeferred.promise;
|
||||
} catch {
|
||||
// Expected host failure.
|
||||
}
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastErrorMock).toHaveBeenCalledWith('toast.configFailed');
|
||||
expect(hostApiCallMock).toHaveBeenCalledWith(
|
||||
'channels.accounts',
|
||||
expect.objectContaining({ mode: 'config', probe: false }),
|
||||
);
|
||||
expect(screen.getByTitle('account.deleteChannel')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('refetches channel accounts when gateway channel-status events arrive', async () => {
|
||||
let channelStatusHandler: (() => void) | undefined;
|
||||
subscribeHostEventMock.mockImplementation((eventName: string, handler: () => void) => {
|
||||
|
||||
@@ -445,6 +445,78 @@ describe('OpenClaw config delivery coordinator', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a config.set commit whose response is lost to a native restart', async () => {
|
||||
const gatewayManager = createGatewayManager();
|
||||
gatewayManager.rpc.mockImplementation(async (method: string, params: unknown) => {
|
||||
if (method === 'config.get') {
|
||||
return { raw: '{ channels: {} }', hash: 'hash-1' };
|
||||
}
|
||||
if (method === 'config.set') {
|
||||
const raw = (params as { raw: string }).raw;
|
||||
await writeFile(configPath, raw, 'utf8');
|
||||
gatewayManager.getStatus.mockReturnValue({ state: 'stopped' });
|
||||
throw new Error('Gateway stopped');
|
||||
}
|
||||
throw new Error(`Unexpected RPC method: ${method}`);
|
||||
});
|
||||
registerOpenClawConfigCoordinator(gatewayManager);
|
||||
|
||||
await expect(mutateOpenClawConfig((config) => {
|
||||
(config.channels as Record<string, unknown>).feishu = { enabled: true };
|
||||
})).resolves.toBe(true);
|
||||
|
||||
expect(JSON.parse(await readFile(configPath, 'utf8'))).toEqual({
|
||||
channels: { feishu: { enabled: true } },
|
||||
});
|
||||
expect(gatewayManager.restart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts a config.set commit after RPC timeout when the snapshot was persisted', async () => {
|
||||
const gatewayManager = createGatewayManager();
|
||||
gatewayManager.rpc.mockImplementation(async (method: string, params: unknown) => {
|
||||
if (method === 'config.get') {
|
||||
return { raw: '{ channels: {} }', hash: 'hash-1' };
|
||||
}
|
||||
if (method === 'config.set') {
|
||||
const raw = (params as { raw: string }).raw;
|
||||
await writeFile(configPath, raw, 'utf8');
|
||||
throw new Error('RPC timeout: config.set');
|
||||
}
|
||||
throw new Error(`Unexpected RPC method: ${method}`);
|
||||
});
|
||||
gatewayManager.getStatus.mockReturnValue({ state: 'running' });
|
||||
registerOpenClawConfigCoordinator(gatewayManager);
|
||||
|
||||
await expect(mutateOpenClawConfig((config) => {
|
||||
(config.channels as Record<string, unknown>).feishu = { enabled: true };
|
||||
})).resolves.toBe(true);
|
||||
|
||||
expect(JSON.parse(await readFile(configPath, 'utf8'))).toEqual({
|
||||
channels: { feishu: { enabled: true } },
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a config.set commit after a 1012 service restart when the snapshot was persisted', async () => {
|
||||
const gatewayManager = createGatewayManager();
|
||||
gatewayManager.rpc.mockImplementation(async (method: string, params: unknown) => {
|
||||
if (method === 'config.get') {
|
||||
return { raw: '{ channels: {} }', hash: 'hash-1' };
|
||||
}
|
||||
if (method === 'config.set') {
|
||||
const raw = (params as { raw: string }).raw;
|
||||
await writeFile(configPath, raw, 'utf8');
|
||||
throw new Error('Gateway service restart');
|
||||
}
|
||||
throw new Error(`Unexpected RPC method: ${method}`);
|
||||
});
|
||||
gatewayManager.getStatus.mockReturnValue({ state: 'running' });
|
||||
registerOpenClawConfigCoordinator(gatewayManager);
|
||||
|
||||
await expect(mutateOpenClawConfig((config) => {
|
||||
(config.channels as Record<string, unknown>).feishu = { enabled: true };
|
||||
})).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it.each(['config.get', 'config.set'] as const)(
|
||||
'fails closed when running %s fails',
|
||||
async (failedMethod) => {
|
||||
|
||||
@@ -215,7 +215,7 @@ describe('GatewayManager diagnostics', () => {
|
||||
expect(snapshot.core.rpcRouter).toBe('blocked');
|
||||
});
|
||||
|
||||
it('marks windows gateway health unresponsive and restarts after ten heartbeat misses', async () => {
|
||||
it('marks windows gateway health unresponsive and restarts after four heartbeat misses', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
|
||||
const { GatewayManager } = await import('@electron/gateway/manager');
|
||||
@@ -239,10 +239,10 @@ describe('GatewayManager diagnostics', () => {
|
||||
const restartSpy = vi.spyOn(manager, 'restart').mockResolvedValue();
|
||||
|
||||
(manager as unknown as { startPing: () => void }).startPing();
|
||||
vi.advanceTimersByTime(660_000);
|
||||
vi.advanceTimersByTime(300_000);
|
||||
|
||||
expect(restartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(manager.getDiagnostics().consecutiveHeartbeatMisses).toBe(10);
|
||||
expect(manager.getDiagnostics().consecutiveHeartbeatMisses).toBe(4);
|
||||
|
||||
const health = buildGatewayHealthSummary({
|
||||
status: { state: 'running', port: 18789 },
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('GatewayManager heartbeat recovery', () => {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||
});
|
||||
|
||||
it('restarts only after ten consecutive heartbeat misses', async () => {
|
||||
it('restarts only after four consecutive heartbeat misses', async () => {
|
||||
const { GatewayManager } = await import('@electron/gateway/manager');
|
||||
const manager = new GatewayManager();
|
||||
|
||||
@@ -49,13 +49,13 @@ describe('GatewayManager heartbeat recovery', () => {
|
||||
const restartSpy = vi.spyOn(manager, 'restart').mockResolvedValue();
|
||||
|
||||
(manager as unknown as { startPing: () => void }).startPing();
|
||||
vi.advanceTimersByTime(659_999);
|
||||
vi.advanceTimersByTime(299_999);
|
||||
|
||||
expect(ws.ping).toHaveBeenCalledTimes(10);
|
||||
expect(ws.ping).toHaveBeenCalledTimes(4);
|
||||
expect(
|
||||
(manager as unknown as { connectionMonitor: { getConsecutiveMisses: () => number } })
|
||||
.connectionMonitor.getConsecutiveMisses(),
|
||||
).toBe(9);
|
||||
).toBe(3);
|
||||
expect(restartSpy).not.toHaveBeenCalled();
|
||||
expect(manager.getDiagnostics().consecutiveHeartbeatMisses).toBe(0);
|
||||
|
||||
@@ -63,7 +63,7 @@ describe('GatewayManager heartbeat recovery', () => {
|
||||
|
||||
expect(ws.terminate).not.toHaveBeenCalled();
|
||||
expect(restartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(manager.getDiagnostics().consecutiveHeartbeatMisses).toBe(10);
|
||||
expect(manager.getDiagnostics().consecutiveHeartbeatMisses).toBe(4);
|
||||
expect(manager.getDiagnostics().lastHeartbeatTimeoutAt).toBe(Date.now());
|
||||
|
||||
vi.advanceTimersByTime(180_000);
|
||||
@@ -72,7 +72,7 @@ describe('GatewayManager heartbeat recovery', () => {
|
||||
(manager as unknown as { connectionMonitor: { clear: () => void } }).connectionMonitor.clear();
|
||||
});
|
||||
|
||||
it('does not restart after ten misses when auto-reconnect is disabled', async () => {
|
||||
it('does not restart after four misses when auto-reconnect is disabled', async () => {
|
||||
const { GatewayManager } = await import('@electron/gateway/manager');
|
||||
const manager = new GatewayManager();
|
||||
|
||||
@@ -92,16 +92,16 @@ describe('GatewayManager heartbeat recovery', () => {
|
||||
const restartSpy = vi.spyOn(manager, 'restart').mockResolvedValue();
|
||||
|
||||
(manager as unknown as { startPing: () => void }).startPing();
|
||||
vi.advanceTimersByTime(660_000);
|
||||
vi.advanceTimersByTime(300_000);
|
||||
|
||||
expect(manager.getDiagnostics().consecutiveHeartbeatMisses).toBe(10);
|
||||
expect(manager.getDiagnostics().consecutiveHeartbeatMisses).toBe(4);
|
||||
expect(ws.terminate).not.toHaveBeenCalled();
|
||||
expect(restartSpy).not.toHaveBeenCalled();
|
||||
|
||||
(manager as unknown as { connectionMonitor: { clear: () => void } }).connectionMonitor.clear();
|
||||
});
|
||||
|
||||
it('requires ten new consecutive misses after responsiveness recovers', async () => {
|
||||
it('requires four new consecutive misses after responsiveness recovers', async () => {
|
||||
const { GatewayManager } = await import('@electron/gateway/manager');
|
||||
const manager = new GatewayManager();
|
||||
|
||||
@@ -121,29 +121,29 @@ describe('GatewayManager heartbeat recovery', () => {
|
||||
const restartSpy = vi.spyOn(manager, 'restart').mockResolvedValue();
|
||||
|
||||
(manager as unknown as { startPing: () => void }).startPing();
|
||||
vi.advanceTimersByTime(600_000);
|
||||
vi.advanceTimersByTime(240_000);
|
||||
expect(
|
||||
(manager as unknown as { connectionMonitor: { getConsecutiveMisses: () => number } })
|
||||
.connectionMonitor.getConsecutiveMisses(),
|
||||
).toBe(9);
|
||||
).toBe(3);
|
||||
|
||||
(manager as unknown as { handleMessage: (message: unknown) => void }).handleMessage('alive');
|
||||
|
||||
expect(manager.getDiagnostics().consecutiveHeartbeatMisses).toBe(0);
|
||||
expect(manager.getDiagnostics().lastAliveAt).toBe(Date.now());
|
||||
|
||||
vi.advanceTimersByTime(600_000);
|
||||
vi.advanceTimersByTime(240_000);
|
||||
expect(restartSpy).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(ws.terminate).not.toHaveBeenCalled();
|
||||
expect(restartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(manager.getDiagnostics().consecutiveHeartbeatMisses).toBe(10);
|
||||
expect(manager.getDiagnostics().consecutiveHeartbeatMisses).toBe(4);
|
||||
|
||||
(manager as unknown as { connectionMonitor: { clear: () => void } }).connectionMonitor.clear();
|
||||
});
|
||||
|
||||
it('restarts after ten consecutive heartbeat misses on windows', async () => {
|
||||
it('restarts after four consecutive heartbeat misses on windows', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
|
||||
const { GatewayManager } = await import('@electron/gateway/manager');
|
||||
@@ -165,12 +165,12 @@ describe('GatewayManager heartbeat recovery', () => {
|
||||
const restartSpy = vi.spyOn(manager, 'restart').mockResolvedValue();
|
||||
|
||||
(manager as unknown as { startPing: () => void }).startPing();
|
||||
vi.advanceTimersByTime(660_000);
|
||||
vi.advanceTimersByTime(300_000);
|
||||
|
||||
expect(ws.ping).toHaveBeenCalledTimes(10);
|
||||
expect(ws.ping).toHaveBeenCalledTimes(4);
|
||||
expect(ws.terminate).not.toHaveBeenCalled();
|
||||
expect(restartSpy).toHaveBeenCalledTimes(1);
|
||||
expect(manager.getDiagnostics().consecutiveHeartbeatMisses).toBe(10);
|
||||
expect(manager.getDiagnostics().consecutiveHeartbeatMisses).toBe(4);
|
||||
|
||||
(manager as unknown as { connectionMonitor: { clear: () => void } }).connectionMonitor.clear();
|
||||
});
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { removeUpgradeSnapshotMock } = vi.hoisted(() => ({
|
||||
removeUpgradeSnapshotMock: vi.fn(async () => ({ status: 'removed' as const, snapshotDir: '/tmp/snapshot' })),
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => '/tmp',
|
||||
@@ -23,6 +27,10 @@ vi.mock('@electron/utils/config', () => ({
|
||||
PORTS: { OPENCLAW_GATEWAY: 18789 },
|
||||
}));
|
||||
|
||||
vi.mock('@electron/utils/openclaw-upgrade-snapshot', () => ({
|
||||
removeOpenClaw2026_7_1UpgradeSnapshot: removeUpgradeSnapshotMock,
|
||||
}));
|
||||
|
||||
vi.mock('@electron/gateway/startup-orchestrator', () => ({
|
||||
runGatewayStartupSequence: vi.fn(async () => {
|
||||
throw new Error('startup unavailable in unit test');
|
||||
@@ -103,6 +111,7 @@ describe('GatewayManager gatewayReady fallback', () => {
|
||||
const readyUpdate = statusUpdates.find((u) => u.gatewayReady === true);
|
||||
expect(readyUpdate).toBeDefined();
|
||||
expect(rpcSpy).toHaveBeenCalledWith('system-presence', {}, 5_000);
|
||||
expect(removeUpgradeSnapshotMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps gatewayReady=false when fallback RPC router probe fails', async () => {
|
||||
|
||||
@@ -34,6 +34,7 @@ function createMockHooks(overrides: Partial<Parameters<typeof runGatewayStartupS
|
||||
waitForPortFree: vi.fn().mockResolvedValue(undefined),
|
||||
startProcess: vi.fn().mockResolvedValue(undefined),
|
||||
waitForReady: vi.fn().mockResolvedValue(undefined),
|
||||
terminateStaleOwnedProcess: vi.fn().mockResolvedValue(undefined),
|
||||
onConnectedToManagedGateway: vi.fn(),
|
||||
runDoctorRepair: vi.fn().mockResolvedValue(false),
|
||||
onDoctorRepairSuccess: vi.fn(),
|
||||
@@ -213,9 +214,10 @@ describe('runGatewayStartupSequence', () => {
|
||||
|
||||
await runGatewayStartupSequence(hooks);
|
||||
|
||||
// First attempt: owned-process path → waitForReady throws → retry
|
||||
// Second attempt: not owned → normal start path → succeeds
|
||||
// First attempt: owned-process path → waitForReady throws → stale process
|
||||
// is terminated and retried. Second attempt starts a fresh process.
|
||||
expect(hasOwnedProcessCalls).toBe(2);
|
||||
expect(hooks.terminateStaleOwnedProcess).toHaveBeenCalledTimes(1);
|
||||
expect(hooks.startProcess).toHaveBeenCalledTimes(1);
|
||||
expect(hooks.onConnectedToManagedGateway).toHaveBeenCalledTimes(1);
|
||||
expect(hooks.delay).toHaveBeenCalledWith(1000);
|
||||
|
||||
@@ -342,7 +342,7 @@ describe('host services', () => {
|
||||
providerServiceMock.createAccount.mockImplementation(async (account: unknown) => account);
|
||||
providerServiceMock.setDefaultAccount.mockResolvedValue(undefined);
|
||||
validateApiKeyWithProviderMock.mockResolvedValue({ valid: true });
|
||||
ensureFeishuPluginInstalledMock.mockResolvedValue({ installed: true });
|
||||
ensureFeishuPluginInstalledMock.mockResolvedValue({ installed: true, peerLinkOk: true });
|
||||
ensureWeChatPluginInstalledMock.mockResolvedValue({ installed: true });
|
||||
ensureClawXContextMock.mockResolvedValue(undefined);
|
||||
rmSync(logDir, { recursive: true, force: true });
|
||||
@@ -820,7 +820,7 @@ describe('host services', () => {
|
||||
expect(migrateLegacyChannelWideBindingMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('installs plugin, saves config, and ensures scoped binding without scheduling lifecycle work', async () => {
|
||||
it('commits a changed plugin channel save without racing the native config reload', async () => {
|
||||
listAgentsSnapshotMock.mockResolvedValue({
|
||||
agents: [{ id: 'main', name: 'Main' }],
|
||||
defaultAgentId: 'main',
|
||||
@@ -834,6 +834,7 @@ describe('host services', () => {
|
||||
getStatus: vi.fn(() => ({ state: 'running', port: 18789 })),
|
||||
debouncedRestart: vi.fn(),
|
||||
debouncedReload: vi.fn(),
|
||||
restart: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const { createChannelsApi } = await import('@electron/services/channels-api');
|
||||
|
||||
@@ -841,7 +842,7 @@ describe('host services', () => {
|
||||
channelType: 'feishu',
|
||||
accountId: 'default',
|
||||
config: { appId: 'cli_new', appSecret: 'new-secret' },
|
||||
})).resolves.toEqual({ success: true });
|
||||
})).resolves.toEqual({ success: true, activationPending: true });
|
||||
|
||||
expect(ensureFeishuPluginInstalledMock).toHaveBeenCalledTimes(1);
|
||||
expect(saveChannelConfigMock).toHaveBeenCalledWith(
|
||||
@@ -852,6 +853,62 @@ describe('host services', () => {
|
||||
expect(ensureScopedChannelBindingMock).toHaveBeenCalledWith('feishu', 'default');
|
||||
expect(gatewayManager.debouncedRestart).not.toHaveBeenCalled();
|
||||
expect(gatewayManager.debouncedReload).not.toHaveBeenCalled();
|
||||
expect(gatewayManager.restart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('schedules Gateway restart when plugin peer link repair fails on changed save', async () => {
|
||||
listAgentsSnapshotMock.mockResolvedValue({
|
||||
agents: [{ id: 'main', name: 'Main' }],
|
||||
defaultAgentId: 'main',
|
||||
defaultModelRef: null,
|
||||
configuredChannelTypes: ['feishu'],
|
||||
channelOwners: {},
|
||||
channelAccountOwners: {},
|
||||
});
|
||||
getChannelFormValuesMock.mockResolvedValue({ appId: 'old', appSecret: 'old-secret' });
|
||||
ensureFeishuPluginInstalledMock.mockResolvedValue({ installed: true, peerLinkOk: false });
|
||||
const gatewayManager = {
|
||||
getStatus: vi.fn(() => ({ state: 'running', port: 18789 })),
|
||||
debouncedRestart: vi.fn(),
|
||||
debouncedReload: vi.fn(),
|
||||
restart: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const { createChannelsApi } = await import('@electron/services/channels-api');
|
||||
|
||||
await expect(createChannelsApi({ gatewayManager: gatewayManager as never }).saveConfig({
|
||||
channelType: 'feishu',
|
||||
accountId: 'default',
|
||||
config: { appId: 'cli_new', appSecret: 'new-secret' },
|
||||
})).resolves.toEqual({ success: true, activationPending: true });
|
||||
|
||||
expect(saveChannelConfigMock).toHaveBeenCalledWith(
|
||||
'feishu',
|
||||
{ appId: 'cli_new', appSecret: 'new-secret' },
|
||||
'default',
|
||||
);
|
||||
expect(gatewayManager.debouncedRestart).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('keeps bundled Telegram on the native config reload path', async () => {
|
||||
getChannelFormValuesMock.mockResolvedValue({ botToken: 'old-token', allowedUsers: '1' });
|
||||
const gatewayManager = {
|
||||
getStatus: vi.fn(() => ({ state: 'running', port: 18789 })),
|
||||
restart: vi.fn(),
|
||||
};
|
||||
const { createChannelsApi } = await import('@electron/services/channels-api');
|
||||
|
||||
await expect(createChannelsApi({ gatewayManager: gatewayManager as never }).saveConfig({
|
||||
channelType: 'telegram',
|
||||
accountId: 'default',
|
||||
config: { botToken: 'new-token', allowedUsers: '1' },
|
||||
})).resolves.toEqual({ success: true });
|
||||
|
||||
expect(saveChannelConfigMock).toHaveBeenCalledWith(
|
||||
'telegram',
|
||||
{ botToken: 'new-token', allowedUsers: '1' },
|
||||
'default',
|
||||
);
|
||||
expect(gatewayManager.restart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes agents by awaiting config commit then removing workspace without restarting', async () => {
|
||||
@@ -1004,7 +1061,7 @@ describe('host services', () => {
|
||||
expect(gatewayManager.restart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles channel default, binding delete, enable, delete, login, and no-change without lifecycle work', async () => {
|
||||
it('handles channel actions and restarts a running Gateway for a no-change plugin save', async () => {
|
||||
getChannelFormValuesMock.mockResolvedValue({ appId: 'same', appSecret: 'same-secret' });
|
||||
listAgentsSnapshotMock.mockResolvedValue({
|
||||
agents: [{ id: 'main', name: 'Main' }],
|
||||
@@ -1033,7 +1090,7 @@ describe('host services', () => {
|
||||
channelType: 'feishu',
|
||||
accountId: 'default',
|
||||
config: { appId: 'same', appSecret: 'same-secret' },
|
||||
})).resolves.toEqual({ success: true, noChange: true });
|
||||
})).resolves.toEqual({ success: true, noChange: true, activationPending: true });
|
||||
|
||||
expect(setChannelDefaultAccountMock).toHaveBeenCalledWith('feishu', 'default');
|
||||
expect(clearChannelBindingMock).toHaveBeenCalledWith('feishu', 'default');
|
||||
@@ -1041,7 +1098,7 @@ describe('host services', () => {
|
||||
expect(deleteChannelAccountConfigMock).toHaveBeenCalledWith('feishu', 'default');
|
||||
expect(deleteChannelConfigMock).toHaveBeenCalledWith('feishu');
|
||||
expect(gatewayManager.debouncedReload).not.toHaveBeenCalled();
|
||||
expect(gatewayManager.debouncedRestart).not.toHaveBeenCalled();
|
||||
expect(gatewayManager.debouncedRestart).toHaveBeenCalledWith(0);
|
||||
expect(gatewayManager.restart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1052,7 +1109,7 @@ describe('host services', () => {
|
||||
expect(source).not.toContain('debouncedRestart(8000)');
|
||||
});
|
||||
|
||||
it('persists successful WeChat login without scheduling lifecycle work', async () => {
|
||||
it('persists successful WeChat login and restarts a running Gateway', async () => {
|
||||
startWeChatLoginSessionMock.mockResolvedValue({
|
||||
qrcodeUrl: 'https://example.com/qr',
|
||||
sessionKey: 'session-1',
|
||||
@@ -1085,9 +1142,9 @@ describe('host services', () => {
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(saveChannelConfigMock).toHaveBeenCalledWith('wechat', { enabled: true }, 'wx-account');
|
||||
expect(gatewayManager.debouncedRestart).toHaveBeenCalledWith(0);
|
||||
});
|
||||
expect(gatewayManager.debouncedReload).not.toHaveBeenCalled();
|
||||
expect(gatewayManager.debouncedRestart).not.toHaveBeenCalled();
|
||||
expect(gatewayManager.restart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -49,6 +49,16 @@ vi.mock('@electron/utils/paths', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
const CLAWX_DESKTOP_TOOL_DENY = [
|
||||
'skill_workshop',
|
||||
'web_search',
|
||||
'gateway',
|
||||
'nodes',
|
||||
'create_goal',
|
||||
'get_goal',
|
||||
'update_goal',
|
||||
];
|
||||
|
||||
async function writeOpenClawJson(config: unknown): Promise<void> {
|
||||
const openclawDir = join(testHome, '.openclaw');
|
||||
await mkdir(openclawDir, { recursive: true });
|
||||
@@ -444,10 +454,10 @@ describe('sanitizeOpenClawConfig', () => {
|
||||
// Fresh install should get tools settings enforced
|
||||
const tools = result.tools as Record<string, unknown>;
|
||||
expect(tools.profile).toBe('full');
|
||||
expect(tools.deny).toEqual(['skill_workshop', 'web_search']);
|
||||
expect(tools.deny).toEqual(CLAWX_DESKTOP_TOOL_DENY);
|
||||
const gateway = result.gateway as Record<string, unknown>;
|
||||
const gatewayTools = gateway.tools as Record<string, unknown>;
|
||||
expect(gatewayTools.deny).toEqual(['skill_workshop', 'web_search']);
|
||||
expect(gatewayTools.deny).toEqual(CLAWX_DESKTOP_TOOL_DENY);
|
||||
const skills = result.skills as Record<string, unknown>;
|
||||
const workshop = skills.workshop as Record<string, unknown>;
|
||||
const autonomous = workshop.autonomous as Record<string, unknown>;
|
||||
@@ -482,9 +492,9 @@ describe('sanitizeOpenClawConfig', () => {
|
||||
// tools settings should now be enforced
|
||||
const tools = result.tools as Record<string, unknown>;
|
||||
expect(tools.profile).toBe('full');
|
||||
expect(tools.deny).toEqual(['skill_workshop', 'web_search']);
|
||||
expect(tools.deny).toEqual(CLAWX_DESKTOP_TOOL_DENY);
|
||||
const gateway = result.gateway as Record<string, unknown>;
|
||||
expect((gateway.tools as Record<string, unknown>).deny).toEqual(['skill_workshop', 'web_search']);
|
||||
expect((gateway.tools as Record<string, unknown>).deny).toEqual(CLAWX_DESKTOP_TOOL_DENY);
|
||||
const skills = result.skills as Record<string, unknown>;
|
||||
expect(((skills.workshop as Record<string, unknown>).autonomous as Record<string, unknown>).enabled).toBe(false);
|
||||
expect((skills.entries as Record<string, Record<string, unknown>>)['skill-creator'].enabled).toBe(true);
|
||||
@@ -506,15 +516,15 @@ describe('sanitizeOpenClawConfig', () => {
|
||||
|
||||
const { sanitizeOpenClawConfig } = await import('@electron/utils/openclaw-auth');
|
||||
await sanitizeOpenClawConfig();
|
||||
await sanitizeOpenClawConfig();
|
||||
|
||||
const result = await readOpenClawJson();
|
||||
const tools = result.tools as Record<string, unknown>;
|
||||
expect(tools.deny).toEqual(['browser', 'skill_workshop', 'web_search']);
|
||||
expect(tools.deny).toEqual(['browser', ...CLAWX_DESKTOP_TOOL_DENY]);
|
||||
const gateway = result.gateway as Record<string, unknown>;
|
||||
expect((gateway.tools as Record<string, unknown>).deny).toEqual([
|
||||
'custom_gateway_tool',
|
||||
'skill_workshop',
|
||||
'web_search',
|
||||
...CLAWX_DESKTOP_TOOL_DENY,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -579,6 +589,124 @@ describe('sanitizeOpenClawConfig', () => {
|
||||
expect(telegram.botToken).toBe('telegram-token');
|
||||
});
|
||||
|
||||
it('migrates legacy plugin-only channel accounts before stripping credential mirrors', async () => {
|
||||
await writeOpenClawJson({
|
||||
plugins: {
|
||||
enabled: true,
|
||||
allow: ['discord', 'whatsapp', 'qqbot'],
|
||||
entries: {
|
||||
discord: {
|
||||
enabled: true,
|
||||
defaultAccount: 'discord-agent',
|
||||
accounts: {
|
||||
'discord-agent': { enabled: true, token: 'discord-token' },
|
||||
},
|
||||
},
|
||||
whatsapp: {
|
||||
enabled: true,
|
||||
defaultAccount: 'whatsapp-agent',
|
||||
accounts: {
|
||||
'whatsapp-agent': { enabled: true, phoneNumber: '+15555550123' },
|
||||
},
|
||||
},
|
||||
qqbot: {
|
||||
enabled: true,
|
||||
defaultAccount: 'qq-agent',
|
||||
accounts: {
|
||||
'qq-agent': { enabled: true, appId: 'qq-app', clientSecret: 'qq-secret' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { sanitizeOpenClawConfig } = await import('@electron/utils/openclaw-auth');
|
||||
await sanitizeOpenClawConfig();
|
||||
|
||||
const result = await readOpenClawJson();
|
||||
const channels = result.channels as Record<string, Record<string, unknown>>;
|
||||
expect(channels.discord.defaultAccount).toBe('discord-agent');
|
||||
expect(channels.discord.accounts).toEqual({
|
||||
'discord-agent': { enabled: true, token: 'discord-token' },
|
||||
});
|
||||
expect(channels.discord.token).toBe('discord-token');
|
||||
expect(channels.whatsapp.accounts).toEqual({
|
||||
'whatsapp-agent': { enabled: true, phoneNumber: '+15555550123' },
|
||||
});
|
||||
expect(channels.qqbot.accounts).toEqual({
|
||||
'qq-agent': { enabled: true, appId: 'qq-app', clientSecret: 'qq-secret' },
|
||||
});
|
||||
expect(channels.qqbot.appId).toBe('qq-app');
|
||||
expect(channels.qqbot.clientSecret).toBe('qq-secret');
|
||||
|
||||
const plugins = result.plugins as Record<string, unknown>;
|
||||
const entries = plugins.entries as Record<string, Record<string, unknown>>;
|
||||
expect(entries.discord).toEqual({ enabled: true });
|
||||
expect(entries.whatsapp).toEqual({ enabled: true });
|
||||
expect(entries.qqbot).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('normalizes QQBot as an external plugin without credential mirrors', async () => {
|
||||
await writeOpenClawJson({
|
||||
channels: {
|
||||
qqbot: {
|
||||
enabled: true,
|
||||
appId: 'qq-app',
|
||||
clientSecret: 'qq-secret',
|
||||
accounts: {
|
||||
default: { appId: 'qq-app', clientSecret: 'qq-secret', enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
enabled: true,
|
||||
allow: ['openclaw-qqbot'],
|
||||
entries: {
|
||||
'openclaw-qqbot': { enabled: true },
|
||||
qqbot: {
|
||||
enabled: true,
|
||||
defaultAccount: 'default',
|
||||
accounts: {
|
||||
default: { appId: 'qq-app', clientSecret: 'qq-secret', enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { sanitizeOpenClawConfig } = await import('@electron/utils/openclaw-auth');
|
||||
await sanitizeOpenClawConfig();
|
||||
|
||||
const result = await readOpenClawJson();
|
||||
const plugins = result.plugins as Record<string, unknown>;
|
||||
const entries = plugins.entries as Record<string, Record<string, unknown>>;
|
||||
expect(plugins.allow).toEqual(['qqbot']);
|
||||
expect(entries.qqbot).toEqual({ enabled: true });
|
||||
expect(entries['openclaw-qqbot']).toBeUndefined();
|
||||
expect((result.channels as Record<string, unknown>).qqbot).toBeDefined();
|
||||
});
|
||||
|
||||
it('recovers external plugin registrations for legacy channel-only configs', async () => {
|
||||
await writeOpenClawJson({
|
||||
channels: {
|
||||
discord: { enabled: true, token: 'discord-token' },
|
||||
whatsapp: { enabled: true },
|
||||
qqbot: { enabled: true, appId: 'qq-app', clientSecret: 'qq-secret' },
|
||||
},
|
||||
});
|
||||
|
||||
const { sanitizeOpenClawConfig } = await import('@electron/utils/openclaw-auth');
|
||||
await sanitizeOpenClawConfig();
|
||||
|
||||
const result = await readOpenClawJson();
|
||||
const plugins = result.plugins as Record<string, unknown>;
|
||||
const entries = plugins.entries as Record<string, Record<string, unknown>>;
|
||||
expect(plugins.allow).toEqual(expect.arrayContaining(['discord', 'whatsapp', 'qqbot']));
|
||||
expect(entries.discord).toEqual({ enabled: true });
|
||||
expect(entries.whatsapp).toEqual({ enabled: true });
|
||||
expect(entries.qqbot).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('normalizes legacy feishu plugin state to a single external plugin and removes built-in feishu', async () => {
|
||||
await writeOpenClawJson({
|
||||
channels: {
|
||||
|
||||
@@ -8,14 +8,14 @@ import { describe, expect, it } from 'vitest';
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
describe('openclaw bundle config', () => {
|
||||
it('pins the OpenClaw 2026.7.1 runtime compatibility matrix', () => {
|
||||
it('pins the OpenClaw 2026.7.1-2 runtime compatibility matrix', () => {
|
||||
const packageJson = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as {
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
};
|
||||
expect(packageJson.dependencies?.['@agentclientprotocol/sdk']).toBe('1.1.0');
|
||||
expect(packageJson.devDependencies).toMatchObject({
|
||||
openclaw: '2026.7.1',
|
||||
openclaw: '2026.7.1-2',
|
||||
electron: '40.10.6',
|
||||
'@openclaw/discord': '2026.7.1',
|
||||
'@openclaw/qqbot': '2026.7.1',
|
||||
@@ -66,7 +66,8 @@ describe('openclaw bundle config', () => {
|
||||
expect(lockfile).not.toContain("'@soimy/dingtalk@3.6.4':");
|
||||
expect(lockfile).not.toContain("'@wecom/wecom-openclaw-plugin@2026.6.23':");
|
||||
expect(lockfile).not.toContain("'@larksuite/openclaw-lark@2026.6.10':");
|
||||
expect(lockfile).toContain("'@openclaw/ai@2026.7.1':");
|
||||
expect(lockfile).not.toContain("'@openclaw/ai@2026.7.1':");
|
||||
expect(lockfile).toContain("'@openclaw/ai@2026.7.1-2':");
|
||||
});
|
||||
|
||||
it('includes Electron runtime-only packages needed in packaged builds', async () => {
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
// @vitest-environment node
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '../..');
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function assertValidUnifiedDiffHunks(patch: string): void {
|
||||
const lines = patch.split('\n');
|
||||
let hunkCount = 0;
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const header = /^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/.exec(lines[index] ?? '');
|
||||
if (!header) continue;
|
||||
|
||||
hunkCount += 1;
|
||||
const expectedOld = Number(header[1] ?? 1);
|
||||
const expectedNew = Number(header[2] ?? 1);
|
||||
let oldLines = 0;
|
||||
let newLines = 0;
|
||||
|
||||
for (index += 1; index < lines.length; index += 1) {
|
||||
const line = lines[index] ?? '';
|
||||
if (line.startsWith('@@ ') || line.startsWith('diff --git ')) {
|
||||
index -= 1;
|
||||
break;
|
||||
}
|
||||
if (line === '' && index === lines.length - 1) break;
|
||||
if (line.startsWith(' ')) {
|
||||
oldLines += 1;
|
||||
newLines += 1;
|
||||
} else if (line.startsWith('-')) {
|
||||
oldLines += 1;
|
||||
} else if (line.startsWith('+')) {
|
||||
newLines += 1;
|
||||
} else if (!line.startsWith('\\')) {
|
||||
throw new Error(`Invalid unified diff line ${index + 1}: ${line}`);
|
||||
}
|
||||
}
|
||||
|
||||
expect({ oldLines, newLines }).toEqual({
|
||||
oldLines: expectedOld,
|
||||
newLines: expectedNew,
|
||||
});
|
||||
}
|
||||
|
||||
expect(hunkCount).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
describe('OpenClaw restart recovery patch', () => {
|
||||
it('registers the pinned runtime patch through the pnpm workspace', async () => {
|
||||
const workspace = await readFile(path.join(root, 'pnpm-workspace.yaml'), 'utf8');
|
||||
const lockfile = await readFile(path.join(root, 'pnpm-lock.yaml'), 'utf8');
|
||||
const patch = await readFile(path.join(root, 'patches/openclaw@2026.7.1-2.patch'));
|
||||
const patchHash = createHash('sha256').update(patch).digest('hex');
|
||||
|
||||
expect(workspace).toContain(
|
||||
'openclaw@2026.7.1-2: patches/openclaw@2026.7.1-2.patch',
|
||||
);
|
||||
expect(lockfile).toContain(`hash: ${patchHash}`);
|
||||
expect(lockfile).toContain('path: patches/openclaw@2026.7.1-2.patch');
|
||||
});
|
||||
|
||||
it('carries trusted recovery lineage through Gateway events into ACP', async () => {
|
||||
const patch = await readFile(
|
||||
path.join(root, 'patches/openclaw@2026.7.1-2.patch'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
expect(patch).toContain('internalRestartRecoverySourceRunId');
|
||||
expect(patch).toContain('canUseInternalRuntimeHandoff && inputProvenance?.kind');
|
||||
expect(patch).toContain('resumedFromRunId');
|
||||
expect(patch).toContain('pending.sendAccepted = true');
|
||||
expect(patch).toContain('pending.disconnectContext = void 0');
|
||||
expect(patch).toContain('ACP_GATEWAY_ACCEPTED_PROMPT_RECOVERY_GRACE_MS = 6e4');
|
||||
expect(patch).toContain('deadline === "accepted-recovery"');
|
||||
expect(patch).toContain('const waitedRunId = pending.idempotencyKey');
|
||||
expect(patch).toContain('status: "failed"');
|
||||
expect(patch).toContain('getAgentRunContext(requestRunId)?.resumedFromRunId');
|
||||
expect(patch).toContain('runId: params.runId');
|
||||
expect(patch).toContain('runId: options?.runId');
|
||||
expect(patch).toContain('execute: async (toolCallId, args, signal, onUpdate)');
|
||||
expect(patch).toContain('runId: defaults?.runId');
|
||||
expect(patch).toContain('runId: Type.Optional(Type.Union([Type.String(), Type.Null()]))');
|
||||
expect(patch).toContain('toolCallId: params.toolCallId');
|
||||
expect(patch).toContain('restart recovery must use a distinct run id');
|
||||
expect(patch).toContain('entry.restartRecoveryDeliverySourceRunId = sourceRunId');
|
||||
expect(patch).not.toContain('!entry.restartRecoveryDeliverySourceRunId && sourceRunId');
|
||||
expect(patch).toContain('normalizeOptionalString(params.entry.restartRecoveryDeliverySourceRunId)');
|
||||
expect(patch).toContain('phase === "start" ? { lifecycleRunId: runId }');
|
||||
expect(patch).toContain('lifecycleRunId: _lifecycleRunId');
|
||||
expect(patch).toContain('this.gateway.request("sessions.messages.subscribe", { key: pending.sessionKey })');
|
||||
expect(patch).not.toContain('this.gateway.request("sessions.subscribe", {})');
|
||||
expect(patch).toContain('evt.event === "session.tool"');
|
||||
expect(patch).not.toContain('diff --git a/scripts/README.md');
|
||||
assertValidUnifiedDiffHunks(patch);
|
||||
});
|
||||
|
||||
it('preserves recovered tool boundaries in live delivery and transcript replay', async () => {
|
||||
const patch = await readFile(
|
||||
path.join(root, 'patches/openclaw@2026.7.1-2.patch'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
expect(patch).toContain('const resumedFromRunId = runContext?.resumedFromRunId');
|
||||
expect(patch).toContain('...sessionMessageSubscribers.get(resolveSessionDeliveryKey(sessionKey, sessionAgentId))');
|
||||
expect(patch).toContain('function extractToolResultReplay(message)');
|
||||
expect(patch).toContain('extractToolCallContent(message.content)');
|
||||
expect(patch).toContain('typedBlock.type === "toolCall"');
|
||||
expect(patch).toContain('sessionUpdate: "tool_call_update"');
|
||||
expect(patch).toContain('chunk.sessionUpdate === "user_message_chunk"');
|
||||
expect(patch).toContain('const subscriptionReady = Promise.all');
|
||||
expect(patch).toContain('subscriptionReady.then(() => this.reconcilePendingPrompts');
|
||||
});
|
||||
|
||||
it('executes the pinned transcript fallback as ordered native ACP updates', async () => {
|
||||
const bundle = await readFile(
|
||||
path.join(root, 'node_modules/openclaw/dist/acp-cli-BXc5GttU.js'),
|
||||
'utf8',
|
||||
);
|
||||
const start = bundle.indexOf('function extractToolResultReplay(message)');
|
||||
const end = bundle.indexOf('//#endregion', start);
|
||||
expect(start).toBeGreaterThanOrEqual(0);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
|
||||
const context = {
|
||||
normalizeOptionalString: (value: unknown) => (
|
||||
typeof value === 'string' && value.trim() ? value.trim() : undefined
|
||||
),
|
||||
asOptionalRecord: (value: unknown) => (
|
||||
value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: undefined
|
||||
),
|
||||
formatToolTitle: (name: string | undefined) => name ?? 'tool',
|
||||
inferToolKind: () => 'other',
|
||||
extractToolCallLocations: () => undefined,
|
||||
extractToolCallContent: (value: unknown) => (
|
||||
typeof value === 'string'
|
||||
? [{ type: 'content', content: { type: 'text', text: value } }]
|
||||
: undefined
|
||||
),
|
||||
extractReplayChunks: undefined as ((message: Record<string, unknown>) => unknown[]) | undefined,
|
||||
};
|
||||
runInNewContext(
|
||||
`${bundle.slice(start, end)}\nglobalThis.extractReplayChunks = extractReplayChunks;`,
|
||||
context,
|
||||
);
|
||||
const extractReplayChunks = context.extractReplayChunks;
|
||||
expect(extractReplayChunks).toBeTypeOf('function');
|
||||
|
||||
expect(extractReplayChunks?.({
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: 'Before tool' },
|
||||
{ type: 'toolCall', id: 'call-1', name: 'read', arguments: { path: 'src/app.ts' } },
|
||||
{ type: 'text', text: 'After tool' },
|
||||
],
|
||||
})).toEqual([
|
||||
{ sessionUpdate: 'agent_message_chunk', text: 'Before tool' },
|
||||
{
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-1',
|
||||
title: 'read',
|
||||
status: 'in_progress',
|
||||
rawInput: { path: 'src/app.ts' },
|
||||
kind: 'other',
|
||||
locations: undefined,
|
||||
},
|
||||
{ sessionUpdate: 'agent_message_chunk', text: 'After tool' },
|
||||
]);
|
||||
expect(extractReplayChunks?.({
|
||||
role: 'toolResult',
|
||||
toolCallId: 'call-1',
|
||||
content: 'plain tool output',
|
||||
})).toEqual([
|
||||
{
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'call-1',
|
||||
status: 'completed',
|
||||
rawOutput: { content: 'plain tool output' },
|
||||
content: [{ type: 'content', content: { type: 'text', text: 'plain tool output' } }],
|
||||
locations: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes execution identity through the pinned approval request builder', async () => {
|
||||
const bundle = await readFile(
|
||||
path.join(root, 'node_modules/openclaw/dist/bash-tools-DHyGpWCr.js'),
|
||||
'utf8',
|
||||
);
|
||||
const start = bundle.indexOf('function buildExecApprovalRequestToolParams(params)');
|
||||
const end = bundle.indexOf('\nfunction parseDecision', start);
|
||||
expect(start).toBeGreaterThanOrEqual(0);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
|
||||
const context = {
|
||||
DEFAULT_APPROVAL_TIMEOUT_MS: 60_000,
|
||||
buildExecApprovalRequestToolParams: undefined as ((params: Record<string, unknown>) => Record<string, unknown>) | undefined,
|
||||
};
|
||||
runInNewContext(
|
||||
`${bundle.slice(start, end)}\nglobalThis.buildExecApprovalRequestToolParams = buildExecApprovalRequestToolParams;`,
|
||||
context,
|
||||
);
|
||||
|
||||
expect(context.buildExecApprovalRequestToolParams?.({
|
||||
id: 'approval-1',
|
||||
sessionId: 'session-1',
|
||||
runId: 'recovery-run',
|
||||
toolCallId: 'tool-1',
|
||||
})).toMatchObject({
|
||||
id: 'approval-1',
|
||||
sessionId: 'session-1',
|
||||
runId: 'recovery-run',
|
||||
toolCallId: 'tool-1',
|
||||
timeoutMs: 60_000,
|
||||
twoPhase: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps all patched runtime chunks syntactically valid', async () => {
|
||||
for (const file of [
|
||||
'agent-tools-BD8WL7ny.js',
|
||||
'bash-tools-DHyGpWCr.js',
|
||||
'exec-approval-DRfKKxhu.js',
|
||||
'schema-BuOFpc7K.js',
|
||||
]) {
|
||||
await expect(execFileAsync(process.execPath, [
|
||||
'--check',
|
||||
path.join(root, 'node_modules/openclaw/dist', file),
|
||||
])).resolves.toMatchObject({ stderr: '' });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,11 @@
|
||||
import { chmod, mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
ensureOpenClaw2026_7_1UpgradeSnapshot,
|
||||
quarantineLegacyUpdateCheckState,
|
||||
removeOpenClaw2026_7_1UpgradeSnapshot,
|
||||
} from '@electron/utils/openclaw-upgrade-snapshot';
|
||||
|
||||
@@ -64,6 +66,57 @@ describe('OpenClaw 2026.7.1 upgrade snapshot', () => {
|
||||
.resolves.toBe('{"version":"old"}\n');
|
||||
});
|
||||
|
||||
it('quarantines legacy update-check JSON when SQLite is already authoritative', async () => {
|
||||
const stateDir = await createTempStateDir();
|
||||
const sourcePath = join(stateDir, 'update-check.json');
|
||||
const sqliteDir = join(stateDir, 'state');
|
||||
const sqlitePath = join(sqliteDir, 'openclaw.sqlite');
|
||||
await mkdir(sqliteDir, { recursive: true });
|
||||
await writeFile(sourcePath, '{"lastCheckedAt":"legacy"}\n');
|
||||
|
||||
const db = new DatabaseSync(sqlitePath);
|
||||
db.exec(`
|
||||
CREATE TABLE update_check_state (
|
||||
state_key TEXT PRIMARY KEY,
|
||||
last_checked_at TEXT
|
||||
);
|
||||
INSERT INTO update_check_state (state_key, last_checked_at)
|
||||
VALUES ('default', 'canonical');
|
||||
`);
|
||||
db.close();
|
||||
|
||||
const result = await quarantineLegacyUpdateCheckState({ stateDir });
|
||||
expect(result.status).toBe('quarantined');
|
||||
expect(result.backupPath).toContain('clawx-openclaw-2026.7.1-legacy-update-check.json');
|
||||
await expect(stat(sourcePath)).rejects.toThrow();
|
||||
await expect(readFile(result.backupPath!, 'utf8')).resolves.toBe('{"lastCheckedAt":"legacy"}\n');
|
||||
expect((await stat(result.backupPath!)).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('leaves legacy update-check JSON for upstream import when SQLite has no canonical row', async () => {
|
||||
const stateDir = await createTempStateDir();
|
||||
const sourcePath = join(stateDir, 'update-check.json');
|
||||
const sqliteDir = join(stateDir, 'state');
|
||||
const sqlitePath = join(sqliteDir, 'openclaw.sqlite');
|
||||
await mkdir(sqliteDir, { recursive: true });
|
||||
await writeFile(sourcePath, '{"lastCheckedAt":"legacy"}\n');
|
||||
|
||||
const db = new DatabaseSync(sqlitePath);
|
||||
db.exec(`
|
||||
CREATE TABLE update_check_state (
|
||||
state_key TEXT PRIMARY KEY,
|
||||
last_checked_at TEXT
|
||||
);
|
||||
`);
|
||||
db.close();
|
||||
|
||||
await expect(quarantineLegacyUpdateCheckState({ stateDir })).resolves.toMatchObject({
|
||||
status: 'deferred',
|
||||
sourcePath,
|
||||
});
|
||||
await expect(readFile(sourcePath, 'utf8')).resolves.toBe('{"lastCheckedAt":"legacy"}\n');
|
||||
});
|
||||
|
||||
it('removes the snapshot directory after successful cleanup', async () => {
|
||||
const stateDir = await createTempStateDir();
|
||||
const configPath = join(stateDir, 'openclaw.json');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
@@ -14,6 +15,7 @@ const {
|
||||
mockWriteFileSync,
|
||||
mockReaddirSync,
|
||||
mockRealpathSync,
|
||||
mockReadlinkSync,
|
||||
mockLoggerWarn,
|
||||
mockLoggerInfo,
|
||||
mockUpsertPluginInstallRecordsIntoSqlite,
|
||||
@@ -36,6 +38,7 @@ const {
|
||||
mockWriteFileSync: vi.fn(),
|
||||
mockReaddirSync: vi.fn(),
|
||||
mockRealpathSync: vi.fn(),
|
||||
mockReadlinkSync: vi.fn(),
|
||||
mockLoggerWarn: vi.fn(),
|
||||
mockLoggerInfo: vi.fn(),
|
||||
mockUpsertPluginInstallRecordsIntoSqlite: vi.fn(() => true),
|
||||
@@ -70,6 +73,7 @@ vi.mock('node:fs', async () => {
|
||||
writeFileSync: mockWriteFileSync,
|
||||
readdirSync: mockReaddirSync,
|
||||
realpathSync: mockRealpathSync,
|
||||
readlinkSync: mockReadlinkSync,
|
||||
};
|
||||
return {
|
||||
...mocked,
|
||||
@@ -329,7 +333,7 @@ describe('plugin installer diagnostics', () => {
|
||||
const { ensurePluginInstalled } = await import('@electron/utils/plugin-install');
|
||||
const result = await ensurePluginInstalled('whatsapp', [sourceDir], 'WhatsApp');
|
||||
|
||||
expect(result.installed).toBe(true);
|
||||
expect(result).toEqual({ installed: true, peerLinkOk: true });
|
||||
expect(mockUpsertPluginInstallRecordsIntoSqlite).toHaveBeenCalledWith({
|
||||
whatsapp: expect.objectContaining({
|
||||
installPath: targetDir,
|
||||
@@ -338,6 +342,34 @@ describe('plugin installer diagnostics', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a failed OpenClaw peer link repair for an installed mirror', async () => {
|
||||
const targetDir = '/home/test/.openclaw/extensions/qqbot';
|
||||
|
||||
mockExistsSync.mockImplementation((input: string) => {
|
||||
const value = String(input);
|
||||
return value === `${targetDir}/openclaw.plugin.json`
|
||||
|| value === `${targetDir}/package.json`;
|
||||
});
|
||||
mockReadFileSync.mockImplementation((input: string) => {
|
||||
if (String(input) === `${targetDir}/package.json`) {
|
||||
return JSON.stringify({
|
||||
name: '@openclaw/qqbot',
|
||||
version: '2026.7.1',
|
||||
peerDependencies: { openclaw: '>=2026.7.1' },
|
||||
});
|
||||
}
|
||||
return '{}';
|
||||
});
|
||||
|
||||
const { ensurePluginInstalled } = await import('@electron/utils/plugin-install');
|
||||
const result = await ensurePluginInstalled('qqbot', ['/bundle/qqbot'], 'QQBot');
|
||||
|
||||
expect(result).toEqual({ installed: true, peerLinkOk: false });
|
||||
expect(mockLoggerWarn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('runtime package missing'),
|
||||
);
|
||||
});
|
||||
|
||||
it('removes WeCom updater metadata for the patched legacy-compatible plugin id', async () => {
|
||||
const targetDir = '/home/test/.openclaw/extensions/wecom';
|
||||
configState.authoritative = {
|
||||
@@ -450,12 +482,19 @@ describe('plugin installer diagnostics', () => {
|
||||
return '{}';
|
||||
});
|
||||
mockLstatSync.mockImplementation((input: string) => {
|
||||
if (String(input) === nodeModulesDir) {
|
||||
const value = String(input);
|
||||
if (value === nodeModulesDir) {
|
||||
return {
|
||||
isDirectory: () => true,
|
||||
isSymbolicLink: () => false,
|
||||
};
|
||||
}
|
||||
if (linked && value.endsWith(`${path.sep}openclaw`)) {
|
||||
return {
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => true,
|
||||
};
|
||||
}
|
||||
const error = new Error('missing') as NodeJS.ErrnoException;
|
||||
error.code = 'ENOENT';
|
||||
throw error;
|
||||
@@ -463,9 +502,15 @@ describe('plugin installer diagnostics', () => {
|
||||
mockSymlinkSync.mockImplementation(() => {
|
||||
linked = true;
|
||||
});
|
||||
mockRealpathSync.mockImplementation((input: string) => (
|
||||
linked && String(input) === linkPath ? openclawDir : String(input)
|
||||
mockReadlinkSync.mockImplementation((input: string) => (
|
||||
linked && String(input).endsWith(`${path.sep}openclaw`) ? openclawDir : ''
|
||||
));
|
||||
mockRealpathSync.mockImplementation((input: string) => {
|
||||
if (linked && String(input).endsWith(`${path.sep}openclaw`)) {
|
||||
return `${openclawDir}-realpath-divergence`;
|
||||
}
|
||||
return String(input);
|
||||
});
|
||||
|
||||
const { repairPluginOpenClawPeerLink } = await import('@electron/utils/plugin-install');
|
||||
expect(repairPluginOpenClawPeerLink(targetDir, openclawDir)).toBe(true);
|
||||
|
||||
@@ -16,6 +16,16 @@ import { tmpdir } from 'os';
|
||||
let tempDir: string;
|
||||
let configPath: string;
|
||||
|
||||
const CLAWX_DESKTOP_TOOL_DENY = [
|
||||
'skill_workshop',
|
||||
'web_search',
|
||||
'gateway',
|
||||
'nodes',
|
||||
'create_goal',
|
||||
'get_goal',
|
||||
'update_goal',
|
||||
];
|
||||
|
||||
async function writeConfig(data: unknown): Promise<void> {
|
||||
await writeFile(configPath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
}
|
||||
@@ -49,7 +59,7 @@ function withClawXToolDefaults<T extends Record<string, unknown>>(config: T): T
|
||||
tools.profile = 'full';
|
||||
tools.sessions = sessions;
|
||||
tools.exec = exec;
|
||||
tools.deny = ['skill_workshop', 'web_search'].reduce<string[]>(
|
||||
tools.deny = CLAWX_DESKTOP_TOOL_DENY.reduce<string[]>(
|
||||
(result, entry) => result.includes(entry) ? result : [...result, entry],
|
||||
deny,
|
||||
);
|
||||
@@ -63,7 +73,7 @@ function withClawXToolDefaults<T extends Record<string, unknown>>(config: T): T
|
||||
const gatewayDeny = Array.isArray(gatewayTools.deny)
|
||||
? (gatewayTools.deny as unknown[]).filter((value): value is string => typeof value === 'string')
|
||||
: [];
|
||||
gatewayTools.deny = ['skill_workshop', 'web_search'].reduce<string[]>(
|
||||
gatewayTools.deny = CLAWX_DESKTOP_TOOL_DENY.reduce<string[]>(
|
||||
(result, entry) => result.includes(entry) ? result : [...result, entry],
|
||||
gatewayDeny,
|
||||
);
|
||||
@@ -411,7 +421,7 @@ async function sanitizeConfig(
|
||||
const deny = Array.isArray(toolsConfig.deny)
|
||||
? toolsConfig.deny.filter((value): value is string => typeof value === 'string')
|
||||
: [];
|
||||
const requiredDeny = ['skill_workshop', 'web_search'].reduce<string[]>(
|
||||
const requiredDeny = CLAWX_DESKTOP_TOOL_DENY.reduce<string[]>(
|
||||
(result, entry) => result.includes(entry) ? result : [...result, entry],
|
||||
deny,
|
||||
);
|
||||
@@ -462,7 +472,7 @@ async function sanitizeConfig(
|
||||
const gatewayDeny = Array.isArray(gatewayTools.deny)
|
||||
? gatewayTools.deny.filter((value): value is string => typeof value === 'string')
|
||||
: [];
|
||||
const requiredGatewayDeny = ['skill_workshop', 'web_search'].reduce<string[]>(
|
||||
const requiredGatewayDeny = CLAWX_DESKTOP_TOOL_DENY.reduce<string[]>(
|
||||
(result, entry) => result.includes(entry) ? result : [...result, entry],
|
||||
gatewayDeny,
|
||||
);
|
||||
|
||||
@@ -131,6 +131,21 @@ describe('HTML preview host', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the selected local HTML when the guest becomes DOM-ready', async () => {
|
||||
useArtifactPanel.setState({
|
||||
open: true,
|
||||
tab: 'preview',
|
||||
focusedFile: htmlFile(),
|
||||
htmlPreviewAnchor: makeAnchor(),
|
||||
});
|
||||
render(<WebBrowserHost />);
|
||||
|
||||
fireEvent(webview(), new Event('dom-ready'));
|
||||
await waitFor(() => {
|
||||
expect(navigate).toHaveBeenCalledWith('file:///workspace/site%20one.html');
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the guest mounted but inert when Preview is hidden', () => {
|
||||
useArtifactPanel.setState({
|
||||
open: true,
|
||||
|
||||
Reference in New Issue
Block a user