Compare commits

...
45 Commits
Author SHA1 Message Date
z0gSh1u b9c2e8eb6f test: parallelize Electron E2E safely
# Conflicts:
#	harness/specs/scenarios/gateway-backend-communication.md
2026-08-07 01:07:02 +08:00
paisley 46ff98c219 release 0.5.3 (#1229) 2026-08-06 11:10:16 +08:00
paisley 813746f5cc fix(gateway): make heartbeat observability-only (#1227) 2026-08-06 10:43:44 +08:00
ZHUO Xu c4346a4b02 fix: preserve generated images during pending tasks and refactor image part styles (#1228) 2026-08-06 10:13:28 +08:00
paisley a9088496f5 feat: disable web search in ClawX (#1226) 2026-08-05 18:20:44 +08:00
paisley 1875dac040 chore(chat): simplify composer gateway status and toolbar agent label (#1225) 2026-08-05 17:16:45 +08:00
ZHUO Xu 3f2cd9345f refactor: migrate to Streamdown and restore GPU rendering for performance, reduce Gateway restarts via optimized OpenClaw config delivery (#1224) 2026-08-05 10:25:17 +08:00
HazeandCursor bbbf6d5bb8 fix(cron): keep scheduled task message editable after inserting a skill (#1223)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 14:48:59 +08:00
paisley 6dd39e8f7c fix(cron): show full run reply from transcript instead of truncated summary (#1222) 2026-08-04 14:38:51 +08:00
paisley d5ac6ca5e5 chore: Remove ClawX Dreams integration while leaving OpenClaw memory-core config unchanged. (#1221) 2026-08-04 13:36:33 +08:00
paisley cdf75da7ce fix provider validation to use configured model (#1220) 2026-08-04 11:14:47 +08:00
paisley 8508c0cd8c fix stable turn duration across conversation switches (#1219) 2026-08-03 17:56:14 +08:00
paisley 164489da97 fix(chat): display OpenClaw transcript attachments (#1218) 2026-08-03 17:12:24 +08:00
paisley f7e025cb20 feat(chat): make question directory float over conversation (#1217) 2026-08-03 15:28:39 +08:00
paisley b564d4a57a fix(attachments): keep directory attachments available for system open (#1216) 2026-08-03 14:40:32 +08:00
paisley 0f152e022b chore:remove chat refresh button (#1215) 2026-08-03 11:59:27 +08:00
Haze 8335ca329c feat: Enhance model context window handling and improve inference logic for custom models (#1214) 2026-08-03 11:38:39 +08:00
paisley d0dfe84463 fix(chat): fix multiline composer layout and skill caret alignment (#1213) 2026-08-03 11:09:58 +08:00
paisley 2679dc7aee release v0.5.2 (#1206) 2026-07-31 11:14:19 +08:00
paisley 76f22a0e8e feat: Enable FTS-only memory search when no OpenAI embedding key is configured. (#1205) 2026-07-30 18:40:45 +08:00
paisley 9591deba7e feat: improve file previews and link opening behavior (#1204) 2026-07-30 11:20:09 +08:00
paisley 04f57b286e feat: update OpenAI OAuth default model to gpt-5.6-sol (#1203) 2026-07-29 16:10:55 +08:00
paisley 2a4426d4ff feat: Improve chat title and copy button layout on Windows (#1202) 2026-07-29 15:48:19 +08:00
paisley 82b7f445af fix: use short folder names for imported workspaces (#1201) 2026-07-29 13:43:04 +08:00
paisley 230ac15cbf fix: restore ACP slash command replies such as /status (#1200) 2026-07-28 18:36:14 +08:00
paisley 3a241cf09f fix: prevent plugin cleanup from deleting bundled OpenClaw runtime (#1197) 2026-07-28 17:30:12 +08:00
paisley 863f9caf0f fix markdown display (#1199) 2026-07-28 16:46:13 +08:00
paisley 017749b169 feat: Collapse ACP tool calls into a grouped summary after turn completes (#1198) 2026-07-28 13:13:32 +08:00
paisley 4318fdc3f1 Revert "[codex] Add runtime abstraction and cc-connect provider" (#1196) 2026-07-27 17:28:33 +08:00
Lingxuan Zuo 960f6b298d [codex] Add runtime abstraction and cc-connect provider (#1103) 2026-07-26 23:43:55 +08:00
paisley 8034c5ad31 fix: harden OpenClaw upgrade snapshot backup scope and cleanup (#1194) 2026-07-24 11:56:21 +08:00
paisley 1f26cd205d feat:upgrade openclaw to 7.1 (#1193) 2026-07-24 10:38:57 +08:00
paisley 9cdd501a80 release 0.5.1 (#1192) 2026-07-23 14:10:40 +08:00
paisley 26d20fdb35 fix: disable model ID editing for existing providers (#1191) 2026-07-23 13:43:53 +08:00
paisley 378e01ee1e feat(gateway): improve startup timing diagnostics (#1190) 2026-07-23 11:06:29 +08:00
ZHUO Xu 7cb6eb240c feat: enhance session status, file previews, open-with feature, web browser, and turn timing (#1189) 2026-07-23 11:06:01 +08:00
paisley cd0d95ad69 fix chat workspace menu and session fallback titles (#1187) 2026-07-22 15:20:52 +08:00
paisley d501bad05d fix(chat): inherit workspace when creating a new chat (#1186) 2026-07-22 14:03:02 +08:00
paisley 5d0099abaa feat: improve unavailable workspace recovery and cleanup (#1183) 2026-07-20 18:27:09 +08:00
paisley 951ca13db8 fix: keep @agent first send on target workspace Prevent reactive ACP loads from cancelling new-agent prompts by binding the target workspace on session switch and creating the target main session when missing. (#1184) 2026-07-20 17:54:31 +08:00
paisley c96b2336c6 fix(chat): preserve image generation thinking state across sessions (#1180) 2026-07-20 10:50:41 +08:00
paisley a42a7e4256 feat: support renaming imported workspaces (#1179) 2026-07-17 16:25:37 +08:00
paisley ff61f5dd72 fix: prevent model reload from interrupting ACP startup (#1178) 2026-07-17 15:16:44 +08:00
paisley 91836cd6bc fix chat follow-up auto-scroll (#1177) 2026-07-16 16:30:24 +08:00
paisley fb19cc61c5 fix markdown code highlighting (#1176) 2026-07-16 15:48:52 +08:00
419 changed files with 33040 additions and 28285 deletions
+38
View File
@@ -33,6 +33,41 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
# The runtime compatibility test executes Electron to assert its embedded
# Node and SQLite versions, so this job cannot rely on the package alone.
# Use the same extraction path as Electron E2E because install.js can
# leave a partially extracted dist directory on GitHub-hosted runners.
- name: Install Electron binary for runtime compatibility test
shell: bash
env:
force_no_cache: 'true'
run: |
set -euo pipefail
unset ELECTRON_SKIP_BINARY_DOWNLOAD
ELECTRON_DIR="$(node -p "require('path').dirname(require.resolve('electron/package.json'))")"
echo "Electron package dir: $ELECTRON_DIR"
rm -rf "$ELECTRON_DIR/dist" "$ELECTRON_DIR/path.txt"
mkdir -p "$ELECTRON_DIR/dist"
ZIP="$(cd "$ELECTRON_DIR" && node -e "
const { downloadArtifact } = require('@electron/get');
const { version } = require('./package.json');
downloadArtifact({ version, artifactName: 'electron', force: true })
.then((z) => { process.stdout.write(z); process.exit(0); })
.catch((e) => { console.error(e); process.exit(1); });
")"
ZIP_SIZE="$(stat -c%s "$ZIP")"
echo "Downloaded zip: $ZIP ($ZIP_SIZE bytes)"
unzip -oq "$ZIP" -d "$ELECTRON_DIR/dist"
echo "Extracted top-level entries: $(ls -1 "$ELECTRON_DIR/dist" | wc -l | tr -d ' ')"
if [ -f "$ELECTRON_DIR/dist/electron.d.ts" ]; then
mv "$ELECTRON_DIR/dist/electron.d.ts" "$ELECTRON_DIR/electron.d.ts"
fi
test -f "$ELECTRON_DIR/dist/electron"
chmod +x "$ELECTRON_DIR/dist/electron"
printf '%s' 'electron' > "$ELECTRON_DIR/path.txt"
- name: Generate extension bridge
run: pnpm run ext:bridge
@@ -82,6 +117,9 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Test Windows attachment open-with bridge
run: pnpm exec vitest run tests/unit/attachment-open-with.test.ts tests/unit/attachment-open-with-native.test.ts tests/unit/safe-fs.test.ts
- name: Generate extension bridge
run: pnpm run ext:bridge
+1
View File
@@ -23,6 +23,7 @@ jobs:
- windows-latest
env:
CI: 'true'
CLAWX_E2E_WORKERS: '2'
# Linux runners cannot use Electron's setuid chrome-sandbox; harmless on macOS/Windows.
ELECTRON_DISABLE_SANDBOX: '1'
+4
View File
@@ -21,12 +21,16 @@ Standard dev commands are in `package.json` scripts and `README.md`. Key ones:
| Comms baseline refresh | `pnpm run comms:baseline` |
| Comms regression compare | `pnpm run comms:compare` |
| E2E tests (Playwright) | `pnpm run test:e2e` |
| Chat performance profiles | `pnpm run perf:chat` |
| Electron Main inspector | `pnpm run profile:main` |
| Build frontend only | `pnpm run build:vite` |
### Non-obvious caveats
- **pnpm version**: The exact pnpm version is pinned via `packageManager` in `package.json`. Use `corepack enable && corepack prepare` to activate the correct version before installing.
- **Electron on headless Linux**: The dbus errors (`Failed to connect to the bus`) are expected and harmless in a headless/cloud environment. The app still runs fine with `$DISPLAY` set (e.g., `:1` via Xvfb/VNC).
- **Performance profiling**: `pnpm run perf:chat` writes synthetic Renderer/Main CPU profiles and versioned metrics under ignored Playwright `test-results/`. For live Renderer CDP use `CLAWX_REMOTE_DEBUGGING_PORT=9223 pnpm dev`; for live Main inspection use `pnpm run profile:main` and port 9229.
- **E2E parallel isolation**: Functional Electron specs run concurrently with `CLAWX_E2E_WORKERS=2` by default. Keep tests parallel-safe and test-scoped; apply `E2E_EXCLUSIVE_TAG` from `tests/e2e/parallel-policy.ts` to tests that use the real clipboard or other OS-global state, and `E2E_PERFORMANCE_TAG` to host performance profiles. Extend `tests/unit/e2e-parallel-policy.test.ts` for recognizable new global APIs.
- **`pnpm run lint` race condition**: If `pnpm run uv:download` was recently run, ESLint may fail with `ENOENT: no such file or directory, scandir '/workspace/temp_uv_extract'` because the temp directory was created and removed during download. Simply re-run lint after the download script finishes.
- **Build scripts warning**: `pnpm install` may warn about ignored build scripts for `@discordjs/opus` and `koffi`. These are optional messaging-channel dependencies and the warnings are safe to ignore.
- **`pnpm run init`**: This is a convenience script that runs `pnpm install` followed by `pnpm run uv:download`. Either run `pnpm run init` or run the two steps separately.
+36 -9
View File
@@ -93,8 +93,6 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています
私たちはアップストリームのOpenClawプロジェクトとの厳密な整合性を維持することにコミットしており、公式リリースが提供する最新の機能、安定性の改善、エコシステムの互換性に常にアクセスできることを保証します。
開発者モードを有効にすると、サイドバーにはネイティブの Dreams ページも表示され、ClawX 内で OpenClaw の記憶レビュー、夢日記、基本メンテナンス操作を扱えます。詳細な診断が必要な場合は、そのページから完全版の OpenClaw Dreams UI も開けます。
---
## 機能
@@ -103,12 +101,19 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています
インストールから最初のAIインタラクションまで、すべてのセットアップを直感的なグラフィカルインターフェースで完了できます。ターミナルコマンド不要、YAMLファイル不要、環境変数の探索も不要です。
### 💬 インテリジェントチャットインターフェース
モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴、Markdownによるリッチコンテンツレンダリング(GitHub 風テーブルKaTeX による LaTeX 数式 `$インライン$``$$ブロック$$``\(インライン\)``\[ブロック\]` を含む)に加え、マルチエージェント構成ではメイン入力欄の `@agent` から対象エージェントへ直接ルーティングできます。
モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴に加え、シンタックスハイライト付きのフェンスコード、CJK 対応の解析、GitHub 風テーブルKaTeX による LaTeX 数式`$インライン$``$$ブロック$$``\(インライン\)``\[ブロック\]`を含む、エージェント応答のストリーミング Markdown レンダリングをサポートします。ユーザー入力は常にプレーンテキストとして表示します。さらに、マルチエージェント構成ではメイン入力欄の `@agent` から対象エージェントへ直接ルーティングできます。フェンスコードはソースの改行を保持し、長い行はソフトラップされ、ストリーミング完了後にローカライズされたコピー操作を利用できます。
コンポーザーから挿入した Skill は `/skill-name` 形式のチップとして表示され、チップをクリックすると右側のプレビュー側欄でその Skill の `SKILL.md` を開けます。
`@agent` で別のエージェントを選ぶと、ClawX はデフォルトエージェントを経由せず、そのエージェント自身の会話コンテキストへ直接切り替えます。各エージェントのワークスペースは既定で分離されていますが、より強い実行時分離は OpenClaw の sandbox 設定に依存します。
セッション側欄はワークスペース優先で整理され、既定ワークスペースを先頭に固定し、その他のワークスペースは自然順に並べます。各ワークスペースは折りたたみや追加読み込みができ、行にはホバーで操作ボタンが出るまで相対アクティビティ時刻が表示されます。編集可能なチャットでは、コンポーザーのワークスペースチップから既定ワークスペースへ戻すか別フォルダーを選ぶ小さなメニューを開けます。
セッション側欄はワークスペース優先で整理され、既定ワークスペースを先頭に固定し、その他のワークスペースは自然順に並べます。各ワークスペースは折りたたみや追加読み込みができます。AI の返信中は行にスピナーが表示され、未確認の返信が完了すると青い点に変わり、会話を開くと相対アクティビティ時刻に戻ります。ホバーすると引き続き操作ボタンが表示されます。インポートしたワークスペースは側欄の見出しから名前を変更でき、新しい名前はチャット入力欄の下にも反映されます。見出しにホバーすると引き続きファイルシステムのパスを確認できます。選択中の会話に有効なワークスペースがある場合、新しいチャットはそれを引き継ぎ、最初の送信までは変更できます。編集可能な新規または未バインドのチャットでは、コンポーザーのワークスペースチップから最近使用したワークスペースと既存セッションのワークスペースの一覧を開き、既定ワークスペースへ戻すか別フォルダーを選べます。保存済みのワークスペースフォルダーが移動または削除されている場合、Chat はセッション作成を一時停止し、無効なパスを繰り返し再試行せずに既存のフォルダーを選ぶよう案内します。利用できない既定以外のグループには側欄で印が付き、確認後に削除できます。この操作ではグループ内の全セッションが完全に削除されます。セッション行の削除と画面遷移は完全削除が成功した後にのみ行われ、失敗した場合は会話と確認ダイアログが保持されるため再試行できます。OpenClaw が生成する UUID と日付のフォールバックタイトルは、そのセッション ID と一致する場合に限って欠落タイトルとして扱い、セッション名として保存せず、会話の最初のユーザーメッセージに置き換えて表示します。
各 Agent は `provider/model` の実行時設定を個別に上書きできます。上書きしていない Agent は引き続きグローバルの既定モデルを継承します。
Chat の右パネルにあるワークスペースとプレビューの各タブでは、Markdown、`.docx``.pptx` ファイルを読み取り専用でプレビューできます。Markdown ファイルのプレビューでは、同じシンタックスハイライト、ソフトラップ、コピー操作付きのフェンスコード、CJK 対応の解析、KaTeX 数式を静的レンダリングモードでサポートします。プレビューのヘッダーから選択中のファイルを ClawX の表示領域全体に拡大でき、同じボタンまたは Esc で右パネルへ戻れます。従来形式の `.doc``.ppt` はアプリ内ではプレビューせず、引き続き OS 経由で開きます。DOCX のページ区切りは Microsoft Word と異なる場合があり、PPTX プレビューではアニメーション、画面切り替え、メディア再生をサポートしません。20 MB を超える Office ファイルはアプリ内でプレビューされません。
### ローカル HTML プレビュー
Chat の右パネルにはワークスペース、プレビュー、変更だけがあり、汎用ウェブブラウザ、ホーム画面、アドレスバーはありません。許可済みのローカル `.html` / `.htm` 添付ファイル、ファイルアクティビティ、ワークスペースファイルは既定でプレビューに開きます。ファイル操作では ClawX 内蔵プレビューまたはシステムアプリを選択でき、プレビューのヘッダーから現在の HTML ファイルをシステムブラウザで開くこともできます。
すべてのリンクはクリックできません。ClawX が描画するリンクは通常のテキストとして表示され、HTML プレビュー内のリンクからもリンク装飾とポインター操作が除去されます。フォーム、スクリプトによる移動、リダイレクト、ページ内移動、ポップアップ、ダウンロード、ネットワーク要求、デバイス権限もブロックされます。自己完結したローカル HTML は表示できますが、選択中の文書から移動することはできません。
### 📡 マルチチャネル管理
複数のAIチャネルを同時に設定・監視できます。各チャネルは独立して動作するため、異なるタスクに特化したエージェントを実行できます。
現在は各チャンネルで複数アカウントを扱え、Channels ページでアカウントの Agent 紐付けやデフォルトアカウント切替を直接管理できます。
@@ -132,7 +137,7 @@ OpenAI-compatible ゲートウェイを **Custom プロバイダー** で使う
プロバイダーの編集や切り替え時、ClawX は `input: ["text", "image"]` など既存のモデル単位の能力メタデータを保持します。新しく選択した Custom プロバイダーのモデルには OpenClaw onboarding と同等の画像入力推論を適用し、不明なモデルはテキスト専用として扱います。
Custom プロバイダーのモデル行には明示的な `contextWindow` も書き込まれ(モデルファミリーから推定、例:`gpt-5.x` → 272k)、旧バージョンで保存された行は起動時に自動補完されます。これにより OpenClaw は長いセッションを "Context overflow" エラーになる前に圧縮できます。compaction 未設定の場合は `agents.defaults.compaction.mode = "safeguard"``reserveTokensFloor = 50000` が既定値として設定されますが、ユーザーが自分で設定したモデル行や圧縮設定が変更されることはありません(`reserveTokensFloor` が未設定の場合のみ補完されることがあります)。
Z.AICN / Global)は OpenClaw 組み込みの `zai` プロバイダー(`ZAI_API_KEY`)に対応し、既定モデルは `glm-5.2` です。Code Plan プリセットで Coding Plan エンドポイント(`…/api/coding/paas/v4`)へ切り替え、通常 API`…/api/paas/v4`)も利用できます。CN と Global は同じ OpenClaw ランタイムキーを共有するため同時追加できません。
互換ゲートウェイで `/models` が認証以外の理由で使えない場合、ClawX は API キー検証時に軽量な `/chat/completions` または `/responses` プローブへ自動フォールバックします。
互換ゲートウェイで `/models` が認証以外の理由で使えない場合、ClawX は API キー検証時に設定済みモデルを使った軽量な `/chat/completions` または `/responses` プローブへ自動フォールバックします。
### 🌙 アダプティブテーマ
ライトモード、ダークモード、またはシステム同期テーマ。ClawXはあなたの好みに自動的に適応します。
@@ -183,6 +188,9 @@ ClawXを初めて起動すると、**セットアップウィザード**が以
サポート対象のシステム言語がある場合、ウィザードはその言語を初期選択し、未対応の場合は英語にフォールバックします。
> Web searchについて:ClawXは、AgentとGatewayの両方のポリシーレイヤーでOpenClawの汎用`web_search`ツールを無効にします。
> Moonshot(Kimi)検索も対象です。管理対象のブラウザ自動化と`web_fetch`は引き続き利用できます。
### プロキシ設定
ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネルがローカルプロキシクライアントを介してインターネットにアクセスする必要がある環境向けに、組み込みのプロキシ設定が含まれています。
@@ -218,17 +226,22 @@ ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネ
ClawXは、**デュアルプロセス + Host API 統一アクセス**構成を採用しています。Renderer は単一クライアント抽象を呼び出し、プロトコル選択とライフサイクルは Main が管理します:
OpenClaw の設定配信も Electron Main が一元管理します。Gateway の実行中は `config.get` の正規スナップショットを基準にし、変更を `config.set` でコミットします。Gateway が停止中または起動中の場合は、同じコーディネーターが解決済みの JSON5 設定ファイルだけを更新し、Gateway を起動しません。そのため、通常の Provider、Agent、Channel、バインディング、Skill、モデル変更では Gateway プロセスを置き換えません。完全な再起動は、プロキシなどのプロセス起動環境の変更とユーザーによる明示的な操作に限定されます。確認済みのプロセス終了と WebSocket 切断では、既存の自動再接続経路が引き続き使用されます。WebSocket のハートビート欠落は診断とヘルス状態だけを更新し、Gateway プロセスを置き換えないため、pong 処理の遅延によって長時間実行中の処理が中断されることはありません。認証プロファイルを SQLite に書き込んだ後は OpenClaw の `secrets.reload` を呼び出し、実行中の Agent がプロセス再起動なしで新しい認証情報を読み取れるようにします。
Chat は Electron Main が所有する ACP stdio bridge を使用します。Renderer は型付き host event を受け取り、メモリ上の ACP timeline を描画します。Gateway は providers、models、skills、workspace、settings、diagnostics、media configuration などの非 Chat 機能を引き続き担当します。
別の会話やページを開いても、未完了の ACP 応答はストリーミングを継続します。完了前に戻ると最新のメモリ内 timeline が復元され、ライブ応答の表示が続きます。完了後は通常の ACP 履歴リプレイが引き続き唯一の正となります。
ACP Chat は標準 ACP resource を添付ファイルとして表示します。ユーザーが選択した画像は、ホバー時のオーバーレイにファイル名を表示するサムネイルとして描画され、その他の利用可能な添付カードはファイル名に続いて、淡色で省略可能なソースパスを表示します。現在の OpenClaw ACP adapter が assistant のメディアを省略した場合も、明示的な assistant の `MEDIA:` ディレクティブを、元のディレクティブを表示せずに添付カードとして復元できます。現在の workspace 外を含む既存のローカルファイル参照は、プレビューまたはオープンのたびに Electron Main で正確な session と generation に対して再検証されます。対応するローカルファイルはアプリ内でプレビューされ、それ以外のローカルファイルはユーザーのクリック後にシステムアプリで開かれます。リモートの HTTP/HTTPS 添付ファイルはクリック後に外部で開かれます。通常の文章内にある単独またはインラインのパスは添付ファイルとして扱われません
ACP の assistant ターンにはターン全体の所要時間が表示されます。ライブ計時はクライアントが観測した prompt ライフサイクルに従い、アプリ内を移動しても継続します。履歴の所要時間は Electron Main が範囲を限定した OpenClaw transcript のタイムスタンプから算出し、ACP リプレイですでに復元されたターンだけに付与します
ACP Chat は標準 ACP resource を添付ファイルとして表示します。ユーザーが選択した画像は、ホバー時のオーバーレイにファイル名を表示するサムネイルとして描画され、その他の利用可能な添付カードはファイル名に続いて、淡色で省略可能なソースパスを表示します。現在の OpenClaw ACP adapter が assistant のメディアを省略した場合も、OpenClaw が永続化した正規メディア情報と明示的な assistant の `MEDIA:` ディレクティブを、transcript 専用メタデータを表示せずに添付カードとして復元できます。現在の workspace 外を含む既存のローカルファイル参照は、プレビューまたはオープンのたびに Electron Main で正確な session と generation に対して再検証されます。AI が生成したプレビュー可能なローカル添付ファイル(20 MB 以下の `.docx``.pptx` を含む)は、読み取り専用のアプリ内プレビューを主要操作として維持し、対応アプリで開く操作と Finder、エクスプローラー、またはシステムのファイルマネージャーで表示する操作を副次メニューから利用できます。ローカル HTML 添付ファイルでは、そのメニューの先頭項目が右側のプレビューでファイルを開きます。ここでも Office プレビューには同じ制限があります。`.doc``.ppt` はシステムアプリで開く形式のままで、DOCX のページ区切りは Microsoft Word と異なる場合があり、PPTX のアニメーション、画面切り替え、メディア再生はサポートされません。対応アプリの検出は macOS と Windows のみで利用でき、Linux または検出失敗時には通知せず、ファイルの場所を表示する操作だけに切り替わります。それ以外のローカルファイル(20 MB を超える Office ファイルを含む)はユーザーのクリック後にシステムアプリで開かれます。ユーザーが選択したフォルダー添付も送信後に利用可能なまま保持され、クリックするとシステムのファイルマネージャーで開きます。ClawX はフォルダー内を読み取りまたはプレビューしません。リモートの HTTP/HTTPS 添付ファイルはクリック後に外部で開かれます。正規メディア情報がない通常の文章内の単独またはインラインパスは添付ファイルとして扱われません。
ACP Chat は、runtime が画像生成メディアを信頼できる構造化メディアとして配信した場合に、生成画像のプレビューも表示できます。信頼できる OpenClaw internal-UI 配信と画像生成タスクに関連付けられた最終返信では、テキストのみの失敗説明を含む元のユーザー向け完了テキストを保持し、汎用の画像キャプションへ置き換えません。OpenClaw の履歴リプレイ中は、同じセッションで画像生成タスク開始が記録されている場合に限り、assistant の画像 `MEDIA:` マーカーがインライン画像表示へ昇格されます。ClawX は Renderer から任意にファイルシステムへアクセスするのではなく、Electron Main のホストメディア処理を通じてプレビューを読み込みます。標準 ACP の画像と resource コンテンツは引き続き推奨パスであり、そのまま描画されます。
### ACP ファイルアクティビティのセマンティクス
- ファイルアクティビティは、成功して完了した OpenClaw の `write``edit``apply_patch` 呼び出しから投影されます。ツールの認識方法は公式 OpenClaw Chat UI に準拠し、完了した呼び出しだけに絞る処理は ClawX 固有です。
- 作成・変更されたアクティビティ行は、プレビュー可能な assistant 添付ファイルと同じファイルカードと**アプリで開く**メニューを使い、状態表示と利用可能な `+/-` 集計も保持します。HTML ファイルでは、メニューの先頭項目が右側の**プレビュー**でファイルを開きます。削除された行には **Changes** 操作だけを残します。アプリ一覧、選択アプリで開く操作、ファイル位置の表示は、workspace ルートと相対パスから Electron Main が毎回個別に再検証します。ツール由来のパスが添付ファイルに変換されたり、Renderer に正規化済みのネイティブパスが渡されたりすることはありません。
- `write` はツールが宣言したとおり、作成および全行追加の差分として表示されます。対象パスがすでに存在する可能性がある場合も同様です。
- **Changes** は、ツールが宣言したアクティビティを時系列に並べたセッション単位の記録です。Git の出力でも、検証済みソースベースラインに対する差分でもありません。
- 各ファイルについて、Changes はアシスタントの各ターンに最大 1 つの diff エディターを表示します。安全に連結できるフラグメントは合成し、独立したフラグメントは 1 つのエディターに連結しますが、完全なファイルベースラインとの差分であるとはみなしません。
@@ -254,7 +267,7 @@ ACP Chat は、runtime が画像生成メディアを信頼できる構造化メ
│ │ • モダンなコンポーネントベースUI(React 19) │ │
│ │ • Zustandによるステート管理 │ │
│ │ • 統一 host-api/api-client 呼び出し │ │
│ │ • リッチなMarkdownレンダリング │ │
│ │ • 応答はMarkdown、ユーザー入力はプレーンテキスト │ │
│ └──────────────────────────────────────────────────────────────┘ │
└──────────────────────────────┬─────────────────────────────────────┘
@@ -295,7 +308,7 @@ ACP Chat は、runtime が画像生成メディアを信頼できる構造化メ
- 単一起動保護は Electron のロックに加え、ローカルのプロセスロックファイルも併用し、デスクトップ IPC / セッションバスが不安定な環境でも重複起動を防ぎます。
- ローリングアップグレード中に旧版/新版が混在すると、単一起動保護の挙動が非対称になる場合があります。安定運用のため、デスクトップクライアントは可能な限り同一バージョンへ揃えてください。
- ただし OpenClaw Gateway の待受は常に**単一**であるべきです。`127.0.0.1:18789` を Listen しているプロセスは1つだけです。
- Gateway の readiness は `system-presence``health``status` などの OpenClaw コア信号を基準にし、memory、Dreams、チャネルの失敗はグローバルな Gateway 障害ではなく capability degradation として表示します。
- Gateway の readiness は `system-presence``health``status` などの OpenClaw コア信号を基準にし、memory またはチャネルの失敗はグローバルな Gateway 障害ではなく capability degradation として表示します。
- Listen プロセスの確認例:
- macOS/Linux: `lsof -nP -iTCP:18789 -sTCP:LISTEN`
- Windows (PowerShell): `Get-NetTCPConnection -LocalPort 18789 -State Listen`
@@ -323,7 +336,7 @@ AI を開発ワークフローに統合できます。エージェントを使
### 前提条件
- **Node.js**: 22.19以上(LTS推奨)
- **Node.js**: 対応するメジャー系列の 22.22.3以上、24.15.0以上、または25.9.0以上(Node 24 LTS推奨)
- **パッケージマネージャー**: pnpm 9以上(推奨)またはnpm
- **LinuxUbuntu/Debian**: Electron を実行する前に、必要なシステムライブラリをインストールしてください:
```bash
@@ -372,6 +385,8 @@ pnpm typecheck # TypeScriptの型チェック
pnpm test # ユニットテストを実行
pnpm run test:e2e # Electron E2E スモークテストを実行
pnpm run test:e2e:headed # 表示付きウィンドウで Electron E2E を実行
pnpm run perf:chat # 合成 Chat の Renderer/Main CPU プロファイルを取得
pnpm run profile:main # ビルド済みアプリを Main inspector の 9229 番ポートで起動
pnpm run comms:replay # 通信リプレイ指標を算出
pnpm run comms:baseline # 通信ベースラインを更新
pnpm run comms:compare # リプレイ指標をベースライン閾値と比較
@@ -387,6 +402,18 @@ pnpm package:linux # Linux向けにパッケージ化
ヘッドレス Linux では Electron テストに表示サーバーが必要です。`xvfb-run -a pnpm run test:e2e` を利用してください。
Electron E2E の機能テストはローカルと CI の両方で既定で 2 つの Playwright worker を使用します。通常の並列レーンは `CLAWX_E2E_WORKERS=<正の整数>` でマシンに合わせて調整できます。OS 全体の状態を扱うテストは 1 worker の `exclusive` project に入り、ホストのパフォーマンスプロファイルは機能テスト後に単独で実行されます。新しい E2E テストは既定で並列です。実クリップボードなどのマシン全体で共有されるリソースを使う場合は、`tests/e2e/parallel-policy.ts` の `E2E_EXCLUSIVE_TAG` を適用してください。
独占前提を必要としない通常の spec だけを実行する場合は、`pnpm exec playwright test <spec> --project=parallel --no-deps` を使用します。
### Electron パフォーマンス診断
`pnpm run perf:chat` は隔離された合成 ACP 負荷を実行し、ストリーミング応答と、リッチな静的 Markdown 会話でのサイドバーおよびスクロール操作を測定します。Playwright の `test-results/` には、バージョン付きメトリクスと Renderer/Main CPU プロファイルが出力されます。Renderer プロファイルは本番の store/render 経路とフレームペーシングを対象とします。ストリーミング Main プロファイルは Main から Renderer への IPC fanout を測定し、操作時の Main プロファイルは Renderer 操作中に Main がアイドルのままかを確認します。どちらも上流の OpenClaw/ACP サブプロセスや GPU プロセスの経路は含みません。CPU プロファイルは Chrome DevTools で開けます。アーティファクトには生成されたテスト文字列だけが含まれ、製品テレメトリーには送信されません。測定値はハードウェアに依存するため、共通の絶対閾値ではなく同じマシン上の複数回の結果を比較してください。
実際の Renderer を記録する場合は `CLAWX_REMOTE_DEBUGGING_PORT=9223 pnpm dev` で開発環境を起動し、Playwright または Chrome DevTools を `localhost:9223` に接続します。Electron Main を記録する場合は `pnpm run profile:main` を実行し、`chrome://inspect` で `localhost:9229` を設定して Electron Main target を選択します。WebSocket trace 自体を測定する場合を除き、`CLAWX_GATEWAY_WS_TRACE` は設定しないでください。
ClawX は Chromium のハードウェアアクセラレーションを既定で有効なままにし、長い文書、スクロール、レイアウトアニメーションで GPU コンポジットとラスタライズを利用します。グラフィックスドライバーに問題があるマシンでは、Chromium 標準の `--disable-gpu` コマンドラインスイッチをトラブルシューティング用のフォールバックとして利用できます。
### 通信回帰チェック
PR が通信経路(Gateway イベント、ACP Chat bridge の送受信フロー、Channel 配信、トランスポートのフォールバック)に触れる場合は、次を実行してください。
+37 -12
View File
@@ -93,8 +93,6 @@ ClawX is built directly upon the official **OpenClaw** core. Instead of requirin
We are committed to maintaining strict alignment with the upstream OpenClaw project, ensuring that you always have access to the latest capabilities, stability improvements, and ecosystem compatibility provided by the official releases.
When Developer Mode is enabled, the sidebar also provides a native Dreams page for OpenClaw memory review, dream diary inspection, and basic maintenance actions. The full upstream OpenClaw Dreams UI remains available from that page when deeper diagnostics are needed.
---
## Features
@@ -103,12 +101,19 @@ When Developer Mode is enabled, the sidebar also provides a native Dreams page f
Complete the entire setup—from installation to your first AI interaction—through an intuitive graphical interface. No terminal commands, no YAML files, no environment variable hunting.
### 💬 Intelligent Chat Interface
Communicate with AI agents through a modern chat experience. Support for multiple conversation contexts, message history, rich content rendering with Markdown (including GitHub-flavored tables and KaTeX-powered LaTeX math: `$inline$`, `$$block$$`, `\(inline\)`, and `\[block\]`), and direct `@agent` routing in the main composer for multi-agent setups.
Communicate with AI agents through a modern chat experience. Support for multiple conversation contexts, message history, assistant replies rendered as streaming Markdown with syntax-highlighted fenced code, CJK-aware parsing, GitHub-flavored tables, and KaTeX-powered LaTeX math (`$inline$`, `$$block$$`, `\(inline\)`, and `\[block\]`) while user input remains literal text, and direct `@agent` routing in the main composer for multi-agent setups. Fenced code preserves source line breaks, soft-wraps long lines, and provides a localized copy action after streaming completes.
Skills you insert from the composer appear as `/skill-name` chips; click a chip to open the preview sidebar and read that skill's `SKILL.md`.
When you target another agent with `@agent`, ClawX switches into that agent's own conversation context directly instead of relaying through the default agent. Agent workspaces stay separate by default, and stronger isolation depends on OpenClaw sandbox settings.
The session sidebar is workspace-first: the default workspace stays at the top, other workspaces sort naturally, each workspace can collapse or load more sessions, and rows show relative activity until hover reveals actions. Editable chats expose the composer workspace chip as a small menu for returning to the default workspace or choosing another folder.
The session sidebar is workspace-first: the default workspace stays at the top, other workspaces sort naturally, and each workspace can collapse or load more sessions. A row shows a spinner while the AI is replying, a blue dot when an unseen reply finishes, and its relative activity time after the conversation is opened; hovering still reveals row actions. Imported workspaces can be renamed from their sidebar header; the custom name is reflected in the chat composer while hovering the header still reveals the filesystem path. When available, a new chat inherits the selected conversation's workspace while remaining editable until first send. Editable new or unbound chats expose the composer workspace chip as a small menu that lists recent and known-session workspaces, returns to the default workspace, or chooses another folder. If a saved workspace folder was moved or deleted, Chat pauses session creation and prompts you to choose an existing folder instead of repeatedly retrying the missing path. Unavailable non-default groups are marked in the sidebar and can be removed after confirmation; this permanently deletes every session in that group. A session row is removed and navigation changes only after permanent deletion succeeds; failed deletions leave the conversation and confirmation open for retry. Synthetic OpenClaw UUID-date fallback titles are treated as missing only when they match the session ID, then replaced with the conversation's first user prompt instead of being persisted as the session name.
Each agent can also override its own `provider/model` runtime setting; agents without overrides continue inheriting the global default model.
The Workspace and Preview tabs in Chat's right panel provide read-only previews for Markdown, `.docx`, and `.pptx` files. Markdown file previews use the same syntax-highlighted, soft-wrapped, copyable fenced code, CJK-aware parsing, and KaTeX math support in static rendering mode. The Preview header can expand the selected file to the full ClawX viewport; use the same control or Escape to return to the panel. Legacy `.doc` and `.ppt` files continue to open through the operating system instead of inline. DOCX pagination may differ from Microsoft Word, and PPTX previews do not support animations, transitions, or media playback. Office files larger than 20 MB are not previewed inline.
### Local HTML Preview
The Chat right panel has Workspace, Preview, and Changes tabs; it no longer includes a general Web Browser, Home page, or address bar. Authorized local `.html` and `.htm` attachments, file activities, and Workspace files open in Preview by default. Their file actions let you choose the built-in Preview or a system application, and the Preview header can open the current HTML file in the system browser.
All links are non-clickable. Links rendered by ClawX appear as ordinary text, and links inside HTML Preview have their styling and pointer interaction removed. HTML Preview also blocks forms, script navigation, redirects, hash navigation, popups, downloads, network requests, and device permissions. It can render self-contained local HTML but cannot leave the selected document.
### 📡 Multi-Channel Management
Configure and monitor multiple AI channels simultaneously. Each channel operates independently, allowing you to run specialized agents for different tasks.
Each channel now supports multiple accounts, per-account agent binding, and switching the channel default account directly from the Channels page.
@@ -132,7 +137,7 @@ For **Custom** providers used with OpenAI-compatible gateways, you can set a cus
When you edit or switch providers, ClawX preserves existing per-model capability metadata such as `input: ["text", "image"]`. Newly selected Custom-provider models use OpenClaw onboarding-compatible image-input inference, with unknown models defaulting to text-only.
Custom-provider model rows also receive an explicit `contextWindow` (inferred from the model family, e.g. `gpt-5.x` → 272k), and rows saved by older versions are backfilled on startup, so OpenClaw can compact long sessions before they fail with "Context overflow" errors. When you have no compaction config, ClawX seeds `agents.defaults.compaction.mode = "safeguard"` and `reserveTokensFloor = 50000`; rows or configs you authored yourself are never modified (except a missing `reserveTokensFloor` may be backfilled).
Z.AI (CN / Global) maps to OpenClaw's built-in `zai` provider (`ZAI_API_KEY`). Default model is `glm-5.2`. Use the Code Plan preset for Coding Plan endpoints (`…/api/coding/paas/v4`) or the normal API endpoints (`…/api/paas/v4`); CN and Global are mutually exclusive because they share one OpenClaw runtime key.
When a compatible gateway rejects `/models` for non-auth reasons, ClawX automatically falls back to a lightweight `/chat/completions` or `/responses` probe during API key validation.
When a compatible gateway rejects `/models` for non-auth reasons, ClawX automatically falls back to a lightweight `/chat/completions` or `/responses` probe using the configured model during API key validation.
### 🌙 Adaptive Theming
Light mode, dark mode, or system-synchronized themes. ClawX adapts to your preferences automatically.
@@ -183,8 +188,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.
> Note for Moonshot (Kimi): ClawX keeps Kimi web search enabled by default.
> When Moonshot is configured, ClawX also syncs Kimi web search to the China endpoint (`https://api.moonshot.cn/v1`) in OpenClaw config.
> 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.
### Proxy Settings
@@ -221,17 +226,22 @@ Notes:
ClawX employs a **dual-process architecture** with a unified host API layer. The renderer talks to a single client abstraction, while Electron Main owns protocol selection and process lifecycle:
Electron Main also owns OpenClaw configuration delivery. While the Gateway is running, ClawX reads the authoritative `config.get` snapshot and commits changes with `config.set`; while it is stopped or starting, the same coordinator updates the resolved JSON5 config file without starting the Gateway. Ordinary provider, agent, channel, binding, skill, and model changes therefore do not replace the Gateway process. Full restarts remain 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. WebSocket heartbeat misses update diagnostics and health state but do not replace the Gateway process, so delayed pong handling cannot interrupt long-running work. Auth-profile SQLite updates use OpenClaw's `secrets.reload` RPC so running agents see new credentials without a process restart.
Chat uses an ACP stdio bridge owned by Electron Main. Renderer receives typed host events and renders an in-memory ACP timeline. Gateway remains responsible for non-Chat capabilities such as providers, models, skills, workspace, settings, diagnostics, and media configuration.
An unfinished ACP response keeps streaming when you open another conversation or page. Returning before it finishes restores the latest in-memory timeline and continues the live response; once it finishes, normal ACP history replay remains the source of truth.
ACP Chat renders standard ACP resources as attachments. User-selected images appear as thumbnails with a filename hover overlay, while other available attachment cards show the filename and a muted, truncating source path. When the current OpenClaw ACP adapter omits assistant media, explicit assistant `MEDIA:` directives can also be recovered as attachment cards without displaying the raw directive. Existing local file references, including paths outside the active workspace, are revalidated in Electron Main for the exact session and generation before every preview or open. Supported local files preview in-app; other local files open in the system application after a user click; remote HTTP and HTTPS attachments open externally after a user click. Bare or inline prose paths are not treated as attachments.
ACP assistant turns show whole-turn duration. Live timing follows the client-observed prompt lifecycle and survives in-app navigation; historical timing is derived in Electron Main from bounded OpenClaw transcript timestamps and only annotates a turn already restored by ACP replay.
ACP Chat renders standard ACP resources as attachments. User-selected images appear as thumbnails with a filename hover overlay, while other available attachment cards show the filename and a muted, truncating source path. When the current OpenClaw ACP adapter omits assistant media, canonical persisted OpenClaw media facts and explicit assistant `MEDIA:` directives can also be recovered as attachment cards without displaying transcript-only metadata. Existing local file references, including paths outside the active workspace, are revalidated in Electron Main for the exact session and generation before every preview or open. Previewable local attachments produced by the AI, including `.docx` and `.pptx` files within the 20 MB inline-preview limit, keep their primary read-only in-app preview action and provide a secondary menu for opening with compatible applications or revealing the file in Finder, File Explorer, or the system file manager. For local HTML attachments, that menu starts with an action that opens the file in the right-side Preview tab. The same Office limitations apply here: `.doc` and `.ppt` remain system-open formats, DOCX pagination may differ from Microsoft Word, and PPTX animations, transitions, and media playback are unsupported. Compatible-application discovery is available only on macOS and Windows and silently degrades to reveal-only behavior on Linux or when discovery fails. Other local files, including Office files larger than 20 MB, open in the system application after a user click. User-selected folder attachments also remain available after send and open in the system file manager; ClawX does not read or preview their contents. Remote HTTP and HTTPS attachments open externally after a user click. Bare or inline prose paths without canonical media facts are not treated as attachments.
ACP Chat can also display generated image previews when image-generation media is delivered by the runtime as trusted structured media. Trusted OpenClaw internal-UI deliveries and task-correlated final replies preserve the original user-facing completion text, including text-only failure explanations, rather than replacing it with a generic image caption. During historical OpenClaw replay, assistant image `MEDIA:` markers are promoted to the inline image experience only when they follow a recorded image-generation task start for that session. ClawX loads previews through host media handling in Electron Main, not arbitrary Renderer filesystem access. Standard ACP image and resource content remains the preferred path and renders directly.
### ACP File Activity Semantics
- File activity is projected from successful, completed OpenClaw `write`, `edit`, and `apply_patch` calls. Tool recognition follows the official OpenClaw Chat UI; filtering to completed calls is specific to ClawX.
- Created and modified activity rows use the same file-card shell and **Open with** menu as previewable assistant attachments while retaining their status and optional `+/-` summary. For HTML files, the first menu item opens the file in the right-side Preview tab. Deleted rows keep only the **Changes** action. Every application-list, selected-application, and reveal request is independently revalidated in Electron Main from the workspace root and relative path; tool-derived paths never become attachments or expose canonical native paths to Renderer.
- A `write` is shown as the tool declares it: a creation with an all-added diff, even if the path may already exist.
- **Changes** is a chronological, session-level record of tool-declared activity. It is not Git output or a verified diff against a source baseline.
- For each file, Changes renders at most one diff editor per assistant turn. Sequential fragments are composed when safe; independent fragments share one concatenated editor without claiming a complete-file baseline.
@@ -257,7 +267,7 @@ ACP Chat can also display generated image previews when image-generation media i
│ │ • Modern component-based UI (React 19) │ │
│ │ • State management with Zustand │ │
│ │ • Unified host-api/api-client calls │ │
│ │ • Rich Markdown rendering │ │
│ │ • Markdown assistant replies, literal user input │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────┬───────────────────────────────────┘
@@ -298,7 +308,7 @@ ACP Chat can also display generated image previews when image-generation media i
- Single-instance protection uses Electron's lock plus a local process-file lock fallback, preventing duplicate app launch in environments where desktop IPC/session bus is unstable.
- During rolling upgrades, mixed old/new app versions can still have asymmetric protection behavior. For best reliability, upgrade all desktop clients to the same version.
- The OpenClaw Gateway listener should still be **single-owner**: only one process should listen on `127.0.0.1:18789`.
- Gateway readiness is based on OpenClaw core signals such as `system-presence`, `health`, and `status`; memory, Dreams, or channel failures are shown as capability degradation instead of global Gateway failure.
- Gateway readiness is based on OpenClaw core signals such as `system-presence`, `health`, and `status`; memory or channel failures are shown as capability degradation instead of global Gateway failure.
- To verify the active listener:
- macOS/Linux: `lsof -nP -iTCP:18789 -sTCP:LISTEN`
- Windows (PowerShell): `Get-NetTCPConnection -LocalPort 18789 -State Listen`
@@ -326,7 +336,7 @@ Chain multiple skills together to create sophisticated automation pipelines. Pro
### Prerequisites
- **Node.js**: 22.19+ (LTS recommended)
- **Node.js**: 22.22.3+, 24.15.0+, or 25.9.0+ within the corresponding supported major line (Node 24 LTS recommended)
- **Package Manager**: pnpm 9+ (recommended) or npm
- **Linux (Ubuntu/Debian)**: Install required system libraries before running Electron:
```bash
@@ -375,6 +385,8 @@ pnpm typecheck # TypeScript validation
pnpm test # Run unit tests
pnpm run test:e2e # Run Electron E2E smoke tests with Playwright
pnpm run test:e2e:headed # Run Electron E2E tests with a visible window
pnpm run perf:chat # Capture synthetic Chat Renderer/Main CPU profiles
pnpm run profile:main # Launch the built app with Main inspector on port 9229
pnpm run comms:replay # Compute communication replay metrics
pnpm run comms:baseline # Refresh communication baseline snapshot
pnpm run comms:compare # Compare replay metrics against baseline thresholds
@@ -390,6 +402,18 @@ pnpm package:linux # Package for Linux
On headless Linux, run Electron tests under a display server such as `xvfb-run -a pnpm run test:e2e`.
Electron E2E functional specs use two Playwright workers by default both locally and in CI; set `CLAWX_E2E_WORKERS=<positive integer>` to tune the ordinary parallel lane for the machine. Tests that touch OS-global state use the one-worker `exclusive` project, and host performance profiles run alone afterward. New E2E tests are parallel by default; apply `E2E_EXCLUSIVE_TAG` from `tests/e2e/parallel-policy.ts` when a test uses the real clipboard or another machine-global resource.
For a focused ordinary spec that does not need the exclusive prerequisite, run `pnpm exec playwright test <spec> --project=parallel --no-deps`.
### Electron Performance Diagnostics
`pnpm run perf:chat` runs isolated synthetic ACP workloads for streaming and for rich static Markdown sidebar/scroll interaction. It writes versioned metrics plus Renderer and Main CPU profiles under the Playwright `test-results/` directory. The Renderer profiles cover the production store/render path and frame pacing. The streaming Main profile measures Main-to-Renderer IPC fanout; the interaction Main profile shows whether Main remains idle while Renderer interactions run. Neither includes the upstream OpenClaw/ACP subprocess or GPU-process paths. Open a CPU profile in Chrome DevTools; the artifacts contain generated fixture text only and are not product telemetry. Results are hardware-dependent, so compare repeated runs on the same machine instead of applying one cross-platform absolute threshold.
For a live Renderer recording, start development with `CLAWX_REMOTE_DEBUGGING_PORT=9223 pnpm dev` and attach Playwright or Chrome DevTools to `localhost:9223`. For a live Electron Main recording, run `pnpm run profile:main`, open `chrome://inspect`, configure `localhost:9229`, and select the Electron Main target. Leave `CLAWX_GATEWAY_WS_TRACE` unset unless WebSocket tracing itself is being measured.
ClawX leaves Chromium hardware acceleration enabled by default so long documents, scrolling, and layout animations can use GPU compositing and rasterization. Chromium still honors the native `--disable-gpu` command-line switch as a troubleshooting fallback for a machine with a broken graphics driver.
### Communication Regression Checks
When a PR changes communication paths (gateway events, ACP Chat bridge send/receive flow, channel delivery, or transport fallback), run:
@@ -412,6 +436,7 @@ from `dist/` and `dist-electron/`, so it does not require manually running
- builds the renderer and Electron bundles with `pnpm run build:vite`
- starts Electron in an isolated E2E mode with a temporary `HOME`
- uses a temporary ClawX `userData` directory
- runs ordinary spec files concurrently while fencing OS-global and performance tests
- skips heavy startup side effects such as gateway auto-start, bundled skill
installation, tray creation, and CLI auto-install
@@ -421,7 +446,7 @@ The first two baseline specs cover:
- skipping setup and navigating to the Models page inside the Electron app
Add future Electron flows under `tests/e2e/` and reuse the shared fixture in
`tests/e2e/fixtures/electron.ts`.
`tests/e2e/fixtures/electron.ts`. Keep tests parallel-safe by avoiding fixed writable paths, ports, native keychains, and other external shared state; use `E2E_EXCLUSIVE_TAG` when isolation is not possible.
### Tech Stack
| Layer | Technology |
+13 -3
View File
@@ -101,11 +101,16 @@ ClawX построен непосредственно на официально
Весь процесс — от установки до первого взаимодействия с AI — выполняется через интуитивный графический интерфейс. Без терминальных команд, без YAML-файлов, без поиска переменных окружения.
### 💬 Интеллектуальный интерфейс чата
Общайтесь с AI-агентами через современный чат. Поддержка нескольких контекстов разговора, истории сообщений, рендеринга Markdown (включая таблицы GitHub-flavored и математические формулы LaTeX через KaTeX: `$строчные$`, `$$блочные$$`, `\(строчные\)` и `\[блочные\]`) и прямая маршрутизация через `@agent` в главном поле ввода для мультиагентных конфигураций.
Общайтесь с AI-агентами через современный чат. Поддержка нескольких контекстов разговора, истории сообщений и рендеринга ответов агента в Markdown (включая таблицы GitHub-flavored и математические формулы LaTeX через KaTeX: `$строчные$`, `$$блочные$$`, `\(строчные\)` и `\[блочные\]`), при этом пользовательский ввод всегда отображается как обычный текст. Для мультиагентных конфигураций также доступна прямая маршрутизация через `@agent` в главном поле ввода.
Навыки, вставляемые из поля ввода, отображаются как чипы `/skill-name`; нажмите на чип, чтобы открыть боковую панель предпросмотра и прочитать `SKILL.md` соответствующего навыка.
При выборе другого агента через `@agent` ClawX переключается непосредственно в контекст этого агента вместо ретрансляции через агента по умолчанию. Рабочие пространства агентов по умолчанию разделены, но более строгая изоляция зависит от настроек песочницы OpenClaw.
Каждый агент может переопределить свои настройки `provider/model`; агенты без переопределения продолжают наследовать глобальную модель по умолчанию.
### Предпросмотр локального HTML
На правой панели Chat остаются только вкладки «Рабочая область», «Просмотр» и «Изменения»; универсального веб-браузера, домашней страницы и адресной строки больше нет. Разрешённые локальные вложения `.html` / `.htm`, файловые операции и файлы рабочей области по умолчанию открываются в «Просмотре». В действиях файла можно выбрать встроенный просмотр ClawX или системное приложение, а кнопка в заголовке просмотра открывает текущий HTML-файл в системном браузере.
Все ссылки некликабельны. Ссылки, отображаемые ClawX, выглядят как обычный текст; в HTML-просмотре также удаляются оформление ссылок и взаимодействие указателем. Формы, переходы из скриптов, перенаправления, переходы внутри страницы, всплывающие окна, загрузки, сетевые запросы и разрешения устройств блокируются. Самодостаточный локальный HTML отображается, но не может покинуть выбранный документ.
### 📡 Управление несколькими каналами
Настраивайте и отслеживайте несколько AI-каналов одновременно. Каждый канал работает независимо, позволяя запускать специализированных агентов для разных задач.
Каждый канал теперь поддерживает несколько учётных записей, привязку агента к учётной записи и переключение канала по умолчанию прямо на странице Каналы.
@@ -228,7 +233,7 @@ ClawX использует **двухпроцессную архитектуру
│ │ • Современный UI на компонентах (React 19) │ │
│ │ • Управление состоянием с Zustand │ │
│ │ • Унифицированные вызовы host-api/api-client │ │
│ │ • Рендеринг Markdown │ │
│ │ • Ответы в Markdown, ввод как обычный текст │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────┬──────────────────────────────────┘
@@ -297,7 +302,7 @@ ClawX использует **двухпроцессную архитектуру
### Требования
- **Node.js**: 22.19+ (рекомендуется LTS)
- **Node.js**: 22.22.3+, 24.15.0+ или 25.9.0+ в пределах соответствующей основной версии (рекомендуется Node 24 LTS)
- **Менеджер пакетов**: pnpm 9+ (рекомендуется) или npm
### Структура проекта
@@ -360,6 +365,10 @@ pnpm package:linux # Упаковать для Linux
На headless Linux запускайте тесты Electron под сервером отображения, например `xvfb-run -a pnpm run test:e2e`.
Функциональные Electron E2E-тесты локально и в CI по умолчанию используют два worker-процесса Playwright. Число worker-процессов обычной параллельной группы можно настроить через `CLAWX_E2E_WORKERS=<положительное целое>`. Тесты с глобальным состоянием ОС выполняются в однопоточном проекте `exclusive`, а профили производительности хоста запускаются отдельно после функциональных тестов. Новые E2E-тесты параллельны по умолчанию; при работе с реальным буфером обмена или другим общим ресурсом машины используйте `E2E_EXCLUSIVE_TAG` из `tests/e2e/parallel-policy.ts`.
Чтобы запустить только обычный spec без эксклюзивного предварительного этапа, используйте `pnpm exec playwright test <spec> --project=parallel --no-deps`.
### Проверка регрессии коммуникаций
Когда PR изменяет пути коммуникации (события шлюза, поток отправки/получения чата, доставка каналов или откат транспорта), запустите:
@@ -380,6 +389,7 @@ pnpm run comms:compare
- собирает рендерер и пакеты Electron с `pnpm run build:vite`
- запускает Electron в изолированном режиме E2E с временным `HOME`
- использует временный каталог `userData` ClawX
- параллельно запускает обычные spec-файлы, изолируя тесты глобальных ресурсов и производительности
- пропускает тяжёлые побочные эффекты запуска, такие как автозапуск шлюза, установку упакованных навыков, создание трея и автоустановку CLI
Первые два базовых спецификации покрывают:
+35 -11
View File
@@ -94,8 +94,6 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
我们致力于与上游 OpenClaw 项目保持严格同步,确保你始终可以使用官方发布的最新功能、稳定性改进和生态兼容性。
打开开发者模式后,侧边栏还会提供原生 Dreams 页面,可在 ClawX 内查看 OpenClaw 记忆回顾、梦境日记,并执行基础维护操作;需要更深诊断时仍可从该页面打开完整 OpenClaw Dreams UI。
---
## 功能特性
@@ -104,12 +102,19 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
从安装到第一次 AI 对话,全程通过直观的图形界面完成。无需终端命令,无需 YAML 文件,无需到处寻找环境变量。
### 💬 智能聊天界面
通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录Markdown 富文本渲染(包括 GitHub 风格表格以及由 KaTeX 渲染的 LaTeX 数学公式`$行内$``$$块级$$``\(行内\)``\[块级\]`,以及在多 Agent 场景下通过主输入框中的 `@agent` 直接路由到目标智能体。
通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录,并以流式 Markdown 渲染智能体回复,支持带语法高亮的围栏代码块、面向中日韩文本的解析、GitHub 风格表格以及由 KaTeX 渲染的 LaTeX 数学公式`$行内$``$$块级$$``\(行内\)``\[块级\]`;用户输入则始终按原始文本显示。同时支持在多 Agent 场景下通过主输入框中的 `@agent` 直接路由到目标智能体。围栏代码会保留源码换行、自动软换行,并在流式输出结束后提供本地化的复制操作。
从输入框插入的技能会以 `/技能名` 卡片形式显示;点击卡片可在右侧预览栏打开并阅读该技能的 `SKILL.md`
当你使用 `@agent` 选择其他智能体时,ClawX 会直接切换到该智能体自己的对话上下文,而不是经过默认智能体转发。各 Agent 工作区默认彼此分离,但更强的运行时隔离仍取决于 OpenClaw 的 sandbox 配置。
会话侧边栏现在以工作空间优先组织:默认工作空间固定在最上方,其它工作空间按自然顺序排列,每个工作空间都可折叠或继续加载更多会话,行内会显示相对活跃时间直到悬停时露出操作按钮。可编辑的新对话,输入框的工作空间卡片会打开一个小菜单,可切回默认工作空间或选择其它目录
会话侧边栏现在以工作空间优先组织:默认工作空间固定在最上方,其它工作空间按自然顺序排列,每个工作空间都可折叠或继续加载更多会话。AI 回复期间,会话行显示加载指示器;未查看的回复完成后显示蓝点;打开会话后恢复显示相对活跃时间悬停时仍会露出操作按钮。导入的工作空间可从侧边栏标题处重命名,新名称会同步显示在对话输入框下方,同时悬浮标题仍可查看文件系统路径。如果当前所选会话存在有效工作空间,新对话会继承该工作空间,并在首次发送前保持可编辑。对于可编辑的新对话或未绑定对话,输入框的工作空间卡片会打开一个小菜单,列出最近使用及现有会话中的工作空间,并可切回默认工作空间或选择其它目录。如果保存的工作空间文件夹已被移动或删除,Chat 会暂停创建会话并提示选择现有文件夹,而不会持续重试失效路径。不可用的非默认工作空间会在侧边栏显示标记,并可在确认后删除;该操作会永久删除分组中的全部会话。只有永久删除成功后,会话行才会移除且页面才会跳转;删除失败时会保留会话与确认框,方便重试。OpenClaw 生成的 UUID 加日期兜底标题只有在与该会话 ID 匹配时才会被视为缺失标题,随后改用会话的首条用户消息展示,而不会被持久化为会话名称
每个 Agent 还可以单独覆盖自己的 `provider/model` 运行时设置;未覆盖的 Agent 会继续继承全局默认模型。
Chat 右侧面板的工作空间和预览选项卡支持以只读方式预览 Markdown、`.docx``.pptx` 文件。Markdown 文件预览以静态渲染模式提供相同的围栏代码语法高亮、软换行与复制操作、面向中日韩文本的解析和 KaTeX 数学公式支持。预览栏顶部可将当前文件展开至 ClawX 的整个可视区域;再次点击该按钮或按 Esc 即可返回侧栏。旧版 `.doc``.ppt` 文件不会在应用内预览,而是继续通过操作系统打开。DOCX 的分页效果可能与 Microsoft Word 不同;PPTX 预览不支持动画、切换效果或媒体播放。超过 20 MB 的 Office 文件不会在应用内预览。
### 本地 HTML 预览
Chat 右侧面板只包含工作空间、预览和变更,不再提供通用网页浏览器、主页或地址栏。已授权的本地 `.html``.htm` 附件、文件活动及工作空间文件默认在预览中打开。文件操作可以选择 ClawX 内置预览或系统应用,预览标题栏也可将当前 HTML 文件交给系统浏览器打开。
所有链接都不可点击。ClawX 渲染的链接显示为普通文本,HTML 预览中的链接也会移除链接样式和指针交互。HTML 预览同时阻止表单、脚本跳转、重定向、页内跳转、弹窗、下载、网络请求和设备权限;它可以显示自包含的本地 HTML,但无法离开当前选中的文档。
### 📡 多频道管理
同时配置和监控多个 AI 频道。每个频道独立运行,允许你为不同任务运行专门的智能体。
现在每个频道支持多个账号,并可在 Channels 页面直接完成账号绑定到 Agent 与默认账号切换。
@@ -133,7 +138,7 @@ Skills 页面可展示来自多个 OpenClaw 来源的技能(托管目录、wor
编辑或切换 Provider 时,ClawX 会保留已有的模型级能力元数据,例如 `input: ["text", "image"]`。新选择的自定义 Provider 模型会使用与 OpenClaw onboarding 一致的图片输入能力推断;未知模型默认按纯文本模型处理。
自定义 Provider 的模型行还会写入显式的 `contextWindow`(按模型系列推断,例如 `gpt-5.x` → 272k),旧版本保存的模型行会在启动时自动回填,使 OpenClaw 能在长会话超限前主动压缩上下文,避免出现 "Context overflow" 报错。当你没有配置 compaction 时,ClawX 会默认写入 `agents.defaults.compaction.mode = "safeguard"``reserveTokensFloor = 50000`;你手动配置过的模型行或压缩配置永远不会被修改(仅可能回填缺失的 `reserveTokensFloor`)。
Z.AI(国内站 / 国际站)会映射到 OpenClaw 内置的 `zai` 供应商(`ZAI_API_KEY`),默认模型为 `glm-5.2`。可通过 Code Plan 预设切换到编码套餐端点(`…/api/coding/paas/v4`),或使用普通 API 端点(`…/api/paas/v4`);国内站与国际站互斥,因为它们共享同一个 OpenClaw 运行时 key。
如果兼容网关的 `/models` 因非鉴权原因不可用,ClawX 会在校验 API Key 时自动降级为轻量的 `/chat/completions``/responses` 探测。
如果兼容网关的 `/models` 因非鉴权原因不可用,ClawX 会在校验 API Key 时使用已配置的模型,自动降级为轻量的 `/chat/completions``/responses` 探测。
### 🌙 自适应主题
支持浅色模式、深色模式或跟随系统主题。ClawX 自动适应你的偏好设置。
@@ -184,8 +189,8 @@ pnpm dev
如果系统语言在支持列表中,向导会默认选中该语言;否则回退到英文。
> MoonshotKimi)说明:ClawX 默认保持开启 Kimi 的 web search。
> 当配置 Moonshot 后,ClawX 也会将 OpenClaw 配置中的 Kimi web search 同步到中国区端点(`https://api.moonshot.cn/v1`
> Web search 说明:ClawX 会在 Agent 和 Gateway 两层策略中禁用 OpenClaw 的通用 `web_search` 工具
> 这也包括 Moonshot(Kimi)搜索;受管浏览器自动化和 `web_fetch` 仍然可用
### 代理设置
@@ -222,17 +227,22 @@ ClawX 内置了代理设置,适用于需要通过本地代理客户端访问
ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用统一客户端抽象,协议选择与进程生命周期由 Electron 主进程统一管理:
OpenClaw 配置交付也统一由 Electron Main 管理。Gateway 运行时,ClawX 以 `config.get` 返回的权威快照为基线,并通过 `config.set` 提交修改;Gateway 停止或启动中时,同一个协调器只更新解析后的 JSON5 配置文件,不会因此启动 Gateway。因此,普通的 Provider、Agent、Channel、绑定、Skill 和模型修改不会替换 Gateway 进程。完整重启仅保留给代理等进程启动环境变化和用户显式操作。已确认的进程退出与 WebSocket 关闭继续使用现有的自动重连路径。WebSocket 心跳缺失只更新诊断和健康状态,不会替换 Gateway 进程,因此延迟处理 pong 不会中断长时间运行的任务。认证配置写入 SQLite 后,ClawX 会调用 OpenClaw 的 `secrets.reload`,让运行中的 Agent 无需重启即可读取新凭据。
Chat 使用由 Electron Main 持有的 ACP stdio bridge。Renderer 接收类型化 host events,并渲染内存中的 ACP timeline。Gateway 仍负责 providers、models、skills、workspace、settings、diagnostics 和 media configuration 等非 Chat 能力。
打开其它会话或页面时,尚未完成的 ACP 回复仍会继续流式接收。若在回复完成前返回,ClawX 会恢复最新的内存 timeline 并继续显示实时输出;回复完成后,普通 ACP 历史回放仍是唯一事实来源。
ACP Chat 会将标准 ACP resource 渲染为附件。用户选择的图片会显示为缩略图,并在悬停蒙层中显示文件名;其它可用的附件卡片会显示文件名,以及灰色、可截断的来源路径。当前 OpenClaw ACP adapter 遗漏 assistant 媒体时,显式的 assistant `MEDIA:` 指令也可恢复为附件卡片,且不会显示原始指令。现有本地文件引用(包括当前 workspace 外的路径)在每次预览或打开前,都会由 Electron Main 按精确的 session 和 generation 重新验证。受支持的本地文件会在应用内预览;其它本地文件会在用户点击后通过系统应用打开;远程 HTTP 和 HTTPS 附件会在用户点击后从外部打开。普通文本中的裸路径或行内路径不会被当作附件
ACP assistant 回合会显示整轮耗时。Live 计时跟随客户端观测到的 prompt 生命周期,并在应用内导航后保持连续;历史耗时由 Electron Main 根据有界的 OpenClaw transcript 时间戳计算,而且只能标注 ACP 回放已经恢复出的回合
ACP Chat 会将标准 ACP resource 渲染为附件。用户选择的图片会显示为缩略图,并在悬停蒙层中显示文件名;其它可用的附件卡片会显示文件名,以及灰色、可截断的来源路径。当前 OpenClaw ACP adapter 遗漏 assistant 媒体时,OpenClaw 持久化的规范媒体事实和显式 assistant `MEDIA:` 指令也可恢复为附件卡片,且不会显示仅用于 transcript 的元数据。现有本地文件引用(包括当前 workspace 外的路径)在每次预览或打开前,都会由 Electron Main 按精确的 session 和 generation 重新验证。AI 生成且可预览的本地附件(包括不超过 20 MB 的 `.docx``.pptx` 文件)会保留主要的只读应用内预览操作,并提供次级菜单,可通过兼容应用打开,或在 Finder、文件资源管理器或系统文件管理器中显示。对于本地 HTML 附件,该菜单第一项会在右侧预览中打开文件。Office 预览在此处也有相同限制:`.doc``.ppt` 仍通过系统应用打开,DOCX 的分页效果可能与 Microsoft Word 不同,PPTX 的动画、切换效果和媒体播放不受支持。兼容应用发现仅在 macOS 和 Windows 上可用;在 Linux 上或发现失败时,会静默降级为仅显示文件位置。其它本地文件(包括超过 20 MB 的 Office 文件)会在用户点击后通过系统应用打开。用户选择的文件夹附件在发送后也会保持可用,点击后交给系统文件管理器打开;ClawX 不会读取或预览其中内容。远程 HTTP 和 HTTPS 附件会在用户点击后从外部打开。没有规范媒体事实佐证的普通文本裸路径或行内路径不会被当作附件。
ACP Chat 也可在 runtime 以可信结构化媒体投递图像生成结果时显示生成图片预览。对于可信的 OpenClaw internal-UI 投递和与生图任务关联的最终回复,ClawX 会保留原始的用户可见完成文案,包括只有文本的失败说明,而不会统一替换成通用图片文案。历史 OpenClaw 回放中,assistant 的图片 `MEDIA:` 标记只有在同一会话已记录图像生成任务启动后才会进入内联图片体验。ClawX 通过 Electron Main 的主机媒体处理加载预览,而不是让 Renderer 任意访问文件系统。标准 ACP 图片和 resource 内容仍是首选路径,并会直接渲染。
### ACP 文件活动语义
- 文件活动由成功且已完成的 OpenClaw `write``edit``apply_patch` 调用投影而来。工具识别方式与 OpenClaw 官方 Chat UI 保持一致;仅接收已完成调用的筛选规则是 ClawX 特有的。
- 已创建和已修改的活动行与可预览的 assistant 附件共用同一种文件卡片外壳和**打开方式**菜单,同时保留状态文字及可用的 `+/-` 统计。对于 HTML 文件,菜单第一项会在右侧**预览**中打开文件;已删除的活动行只保留 **Changes** 操作。应用列表、指定应用打开和显示文件位置都会由 Electron Main 根据 workspace 根目录与相对路径分别重新验证;工具路径不会因此变成附件,Renderer 也不会获得规范化系统路径。
- `write` 按工具声明的语义显示:视为创建,并展示为全部新增的差异,即使该路径可能已经存在。
- **Changes** 是按时间顺序记录工具声明活动的会话级记录,不是 Git 输出,也不是相对于已验证源码基线的差异。
- 对每个文件,Changes 在每轮助手回复中最多展示一个 diff 编辑器。可安全串联的片段会合并,独立片段会拼接到同一个编辑器中,但不会被描述为基于完整文件基线的差异。
@@ -258,7 +268,7 @@ ACP Chat 也可在 runtime 以可信结构化媒体投递图像生成结果时
│ │ • 现代组件化 UI(React 19 │ │
│ │ • Zustand 状态管理 │ │
│ │ • 统一 host-api/api-client 调用 │ │
│ │ • Markdown 富文本渲染 │ │
│ │ • 回复使用 Markdown,用户输入按原文显示 │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────┬───────────────────────────────────┘
@@ -299,7 +309,7 @@ ACP Chat 也可在 runtime 以可信结构化媒体投递图像生成结果时
- 单实例保护同时使用 Electron 自带锁与本地进程文件锁回退机制,可在桌面会话总线异常时避免重复启动。
- 滚动升级期间若新旧版本混跑,单实例保护仍可能出现不对称行为。为保证稳定性,建议桌面客户端尽量统一升级到同一版本。
- 但 OpenClaw Gateway 监听应始终保持**单实例**:`127.0.0.1:18789` 只能有一个监听者。
- Gateway readiness 以 OpenClaw 的 `system-presence``health``status` 等核心信号为准;memory、Dreams 或频道失败会显示为能力降级,而不是全局 Gateway 故障。
- Gateway readiness 以 OpenClaw 的 `system-presence``health``status` 等核心信号为准;memory 或频道失败会显示为能力降级,而不是全局 Gateway 故障。
- 可用以下命令确认监听进程:
- macOS/Linux`lsof -nP -iTCP:18789 -sTCP:LISTEN`
- WindowsPowerShell):`Get-NetTCPConnection -LocalPort 18789 -State Listen`
@@ -327,7 +337,7 @@ ACP Chat 也可在 runtime 以可信结构化媒体投递图像生成结果时
### 前置要求
- **Node.js**22.19+(推荐 LTS 版本
- **Node.js**对应主版本范围内的 22.22.3+、24.15.0+ 或 25.9.0+(推荐 Node 24 LTS
- **包管理器**pnpm 9+(推荐)或 npm
- **LinuxUbuntu/Debian**:运行 Electron 前,请先安装所需系统库:
```bash
@@ -376,6 +386,8 @@ pnpm typecheck # TypeScript 类型检查
pnpm test # 运行单元测试
pnpm run test:e2e # 运行 Electron E2E 冒烟测试
pnpm run test:e2e:headed # 以可见窗口运行 Electron E2E 测试
pnpm run perf:chat # 采集合成 Chat 场景的 Renderer/Main CPU Profile
pnpm run profile:main # 启动构建产物并在 9229 端口调试 Main
pnpm run comms:replay # 计算通信回放指标
pnpm run comms:baseline # 刷新通信基线快照
pnpm run comms:compare # 将回放指标与基线阈值对比
@@ -391,6 +403,18 @@ pnpm package:linux # 为 Linux 打包
在无头 Linux 环境下,Electron 测试需要显示服务;可使用 `xvfb-run -a pnpm run test:e2e`。
Electron E2E 功能测试在本地和 CI 中默认使用两个 Playwright worker;可通过 `CLAWX_E2E_WORKERS=<正整数>` 按机器能力调整普通并行通道。访问操作系统全局状态的测试进入单 worker 的 `exclusive` project,主机性能采样则在功能测试结束后独占运行。新增 E2E 测试默认并行;若测试使用真实剪贴板或其他机器级共享资源,请应用 `tests/e2e/parallel-policy.ts` 中的 `E2E_EXCLUSIVE_TAG`。
如果只需运行一个不依赖独占前置阶段的普通 spec,可使用 `pnpm exec playwright test <spec> --project=parallel --no-deps`。
### Electron 性能诊断
`pnpm run perf:chat` 会运行隔离的合成 ACP 负载,分别覆盖流式响应,以及富 Markdown 静态会话中的侧栏和滚动交互,并在 Playwright 的 `test-results/` 目录输出版本化指标与 Renderer/Main CPU Profile。Renderer Profile 覆盖生产 store/render 路径和帧节奏;流式 Main Profile 测量 Main 到 Renderer 的 IPC fanout,交互 Main Profile 用于确认 Renderer 交互期间 Main 是否保持空闲。两者都不包含上游 OpenClaw/ACP 子进程或 GPU 进程路径。CPU Profile 可直接用 Chrome DevTools 打开;其中只包含生成的测试文本,不会上报为产品遥测。性能数据依赖硬件,应在同一机器上多次运行后对比,不应使用统一的跨平台绝对阈值。
录制真实 Renderer 时,使用 `CLAWX_REMOTE_DEBUGGING_PORT=9223 pnpm dev` 启动开发环境,再让 Playwright 或 Chrome DevTools 连接 `localhost:9223`。录制真实 Electron Main 时,运行 `pnpm run profile:main`,在 `chrome://inspect` 中配置 `localhost:9229` 并选择 Electron Main target。除非正在测量 WebSocket trace 本身,否则不要设置 `CLAWX_GATEWAY_WS_TRACE`。
ClawX 默认保留 Chromium 硬件加速,使长文档、滚动和布局动画能够使用 GPU 合成与光栅化。若某台机器的显卡驱动存在问题,仍可使用 Chromium 原生的 `--disable-gpu` 命令行参数作为排障回退。
### 通信回归检查
当 PR 涉及通信链路(Gateway 事件、ACP Chat bridge 收发流程、Channel 投递、传输回退)时,建议执行:
-333
View File
@@ -1,333 +0,0 @@
/**
* Gateway WebSocket Client
* Provides a typed interface for Gateway RPC calls
*/
import { GatewayManager, GatewayStatus } from './manager';
/**
* Channel types supported by OpenClaw
*/
export type ChannelType = 'whatsapp' | 'dingtalk' | 'telegram' | 'discord' | 'wechat';
/**
* Channel status
*/
export interface Channel {
id: string;
type: ChannelType;
name: string;
status: 'connected' | 'disconnected' | 'connecting' | 'error';
lastActivity?: string;
error?: string;
config?: Record<string, unknown>;
}
/**
* Skill definition
*/
export interface Skill {
id: string;
name: string;
description: string;
enabled: boolean;
category?: string;
icon?: string;
configurable?: boolean;
version?: string;
author?: string;
}
/**
* Skill bundle definition
*/
export interface SkillBundle {
id: string;
name: string;
description: string;
skills: string[];
icon?: string;
recommended?: boolean;
}
/**
* Chat message
*/
export interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: string;
channel?: string;
toolCalls?: ToolCall[];
metadata?: Record<string, unknown>;
}
/**
* Tool call in a message
*/
export interface ToolCall {
id: string;
name: string;
arguments: Record<string, unknown>;
result?: unknown;
status: 'pending' | 'running' | 'completed' | 'error';
duration?: number;
}
/**
* Cron task definition
*/
export interface CronTask {
id: string;
name: string;
schedule: string;
command: string;
enabled: boolean;
lastRun?: string;
nextRun?: string;
status: 'idle' | 'running' | 'error';
error?: string;
}
/**
* Provider configuration
*/
export interface ProviderConfig {
id: string;
name: string;
type: 'openai' | 'anthropic' | 'ollama' | 'custom';
apiKey?: string;
baseUrl?: string;
model?: string;
enabled: boolean;
}
/**
* Gateway Client
* Typed wrapper around GatewayManager for making RPC calls
*/
export class GatewayClient {
constructor(private manager: GatewayManager) { }
/**
* Get current gateway status
*/
getStatus(): GatewayStatus {
return this.manager.getStatus();
}
/**
* Check if gateway is connected
*/
isConnected(): boolean {
return this.manager.isConnected();
}
// ==================== Channel Methods ====================
/**
* List all channels
*/
async listChannels(): Promise<Channel[]> {
return this.manager.rpc<Channel[]>('channels.list');
}
/**
* Get channel by ID
*/
async getChannel(channelId: string): Promise<Channel> {
return this.manager.rpc<Channel>('channels.get', { channelId });
}
/**
* Connect a channel
*/
async connectChannel(channelId: string): Promise<void> {
return this.manager.rpc<void>('channels.connect', { channelId });
}
/**
* Disconnect a channel
*/
async disconnectChannel(channelId: string): Promise<void> {
return this.manager.rpc<void>('channels.disconnect', { channelId });
}
/**
* Get QR code for channel connection (e.g., WhatsApp)
*/
async getChannelQRCode(channelType: ChannelType): Promise<string> {
return this.manager.rpc<string>('channels.getQRCode', { channelType });
}
// ==================== Skill Methods ====================
/**
* List all skills
*/
async listSkills(): Promise<Skill[]> {
return this.manager.rpc<Skill[]>('skills.list');
}
/**
* Enable a skill
*/
async enableSkill(skillId: string): Promise<void> {
return this.manager.rpc<void>('skills.enable', { skillId });
}
/**
* Disable a skill
*/
async disableSkill(skillId: string): Promise<void> {
return this.manager.rpc<void>('skills.disable', { skillId });
}
/**
* Get skill configuration
*/
async getSkillConfig(skillId: string): Promise<Record<string, unknown>> {
return this.manager.rpc<Record<string, unknown>>('skills.getConfig', { skillId });
}
/**
* Update skill configuration
*/
async updateSkillConfig(skillId: string, config: Record<string, unknown>): Promise<void> {
return this.manager.rpc<void>('skills.updateConfig', { skillId, config });
}
// ==================== Chat Methods ====================
/**
* Send a chat message
*/
async sendMessage(content: string, channelId?: string): Promise<ChatMessage> {
return this.manager.rpc<ChatMessage>('chat.send', { content, channelId });
}
/**
* Get chat history
*/
async getChatHistory(limit = 50, offset = 0): Promise<ChatMessage[]> {
return this.manager.rpc<ChatMessage[]>('chat.history', { limit, offset });
}
/**
* Clear chat history
*/
async clearChatHistory(): Promise<void> {
return this.manager.rpc<void>('chat.clear');
}
// ==================== Cron Methods ====================
/**
* List all cron tasks
*/
async listCronTasks(): Promise<CronTask[]> {
return this.manager.rpc<CronTask[]>('cron.list');
}
/**
* Create a new cron task
*/
async createCronTask(task: Omit<CronTask, 'id' | 'status'>): Promise<CronTask> {
return this.manager.rpc<CronTask>('cron.create', task);
}
/**
* Update a cron task
*/
async updateCronTask(taskId: string, updates: Partial<CronTask>): Promise<CronTask> {
return this.manager.rpc<CronTask>('cron.update', { taskId, ...updates });
}
/**
* Delete a cron task
*/
async deleteCronTask(taskId: string): Promise<void> {
return this.manager.rpc<void>('cron.delete', { taskId });
}
/**
* Run a cron task immediately
*/
async runCronTask(taskId: string): Promise<void> {
return this.manager.rpc<void>('cron.run', { taskId });
}
// ==================== Provider Methods ====================
/**
* List configured AI providers
*/
async listProviders(): Promise<ProviderConfig[]> {
return this.manager.rpc<ProviderConfig[]>('providers.list');
}
/**
* Add or update a provider
*/
async setProvider(provider: ProviderConfig): Promise<void> {
return this.manager.rpc<void>('providers.set', provider);
}
/**
* Remove a provider
*/
async removeProvider(providerId: string): Promise<void> {
return this.manager.rpc<void>('providers.remove', { providerId });
}
/**
* Test provider connection
*/
async testProvider(providerId: string): Promise<{ success: boolean; error?: string }> {
return this.manager.rpc<{ success: boolean; error?: string }>('providers.test', { providerId });
}
// ==================== System Methods ====================
/**
* Get Gateway health status
*/
async getHealth(): Promise<{ status: string; uptime: number; version?: string }> {
return this.manager.rpc<{ status: string; uptime: number; version?: string }>('system.health');
}
/**
* Get Gateway configuration
*/
async getConfig(): Promise<Record<string, unknown>> {
return this.manager.rpc<Record<string, unknown>>('system.config');
}
/**
* Update Gateway configuration
*/
async updateConfig(config: Record<string, unknown>): Promise<void> {
return this.manager.rpc<void>('system.updateConfig', config);
}
/**
* Get Gateway version info
*/
async getVersion(): Promise<{ version: string; nodeVersion?: string; platform?: string }> {
return this.manager.rpc<{ version: string; nodeVersion?: string; platform?: string }>('system.version');
}
/**
* Get available skill bundles
*/
async getSkillBundles(): Promise<SkillBundle[]> {
return this.manager.rpc<SkillBundle[]>('skills.bundles');
}
/**
* Install a skill bundle
*/
async installBundle(bundleId: string): Promise<void> {
return this.manager.rpc<void>('skills.installBundle', { bundleId });
}
}
+289
View File
@@ -0,0 +1,289 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
import { isDeepStrictEqual } from 'node:util';
import JSON5 from 'json5';
import type { GatewayManager } from './manager';
import { withConfigLock } from '../utils/config-mutex';
import { resolveOpenClawConfigPath } from '../utils/paths';
export type OpenClawConfig = Record<string, unknown>;
/** Mutators may be replayed after a compare-and-swap conflict and must not perform external writes. */
export type OpenClawConfigMutator = (
config: OpenClawConfig,
) => void | Promise<void>;
type ConfigDeliveryGatewayManager = Pick<GatewayManager, 'getStatus' | 'rpc'>;
interface ConfigSnapshot {
config?: unknown;
raw?: unknown;
hash?: unknown;
}
interface ActiveMutationContext {
config: OpenClawConfig;
active: boolean;
sourceExists: boolean;
}
export interface OpenClawConfigSnapshot {
config: OpenClawConfig;
exists: boolean;
}
interface FileConfigSnapshot {
config: OpenClawConfig;
raw: string | undefined;
}
let gatewayManager: ConfigDeliveryGatewayManager | undefined;
let transactionTail: Promise<void> = Promise.resolve();
const activeMutation = new AsyncLocalStorage<ActiveMutationContext>();
function parseConfig(raw: string): OpenClawConfig {
const parsed = JSON5.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('OpenClaw config must be an object');
}
return parsed as OpenClawConfig;
}
function serializeConfig(config: OpenClawConfig): string {
return `${JSON.stringify(config, null, 2)}\n`;
}
function parseRunningConfigSnapshot(snapshot: ConfigSnapshot | undefined): OpenClawConfig {
if (snapshot?.config && typeof snapshot.config === 'object' && !Array.isArray(snapshot.config)) {
return structuredClone(snapshot.config) as OpenClawConfig;
}
const raw = typeof snapshot?.raw === 'string' ? snapshot.raw : '';
if (!raw.trim()) {
throw new Error('Gateway config.get returned an incomplete config snapshot');
}
return parseConfig(raw);
}
function isBaseHashConflict(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return /config changed since last load; re-run config\.get and retry/i.test(message);
}
async function mutateRunningConfig(
manager: ConfigDeliveryGatewayManager,
mutator: OpenClawConfigMutator,
): Promise<boolean> {
for (let attempt = 0; attempt < 2; attempt += 1) {
const snapshot = await manager.rpc<ConfigSnapshot>('config.get', {});
const hash = typeof snapshot?.hash === 'string' ? snapshot.hash.trim() : '';
if (!hash) {
throw new Error('Gateway config.get returned an incomplete config snapshot');
}
const config = parseRunningConfigSnapshot(snapshot);
if (!await applyMutator(config, mutator, true)) return false;
try {
await manager.rpc('config.set', {
raw: serializeConfig(config),
baseHash: hash,
});
return true;
} catch (error) {
if (attempt === 0 && isBaseHashConflict(error)) continue;
throw error;
}
}
return false;
}
async function applyMutator(
config: OpenClawConfig,
mutator: OpenClawConfigMutator,
sourceExists: boolean,
): Promise<boolean> {
const baseline = structuredClone(config);
const context: ActiveMutationContext = { config, active: true, sourceExists };
try {
await activeMutation.run(context, async () => await mutator(config));
} finally {
context.active = false;
}
return !isDeepStrictEqual(config, baseline);
}
async function applyNestedMutator(
context: ActiveMutationContext,
mutator: OpenClawConfigMutator,
): Promise<boolean> {
const baseline = structuredClone(context.config);
await mutator(context.config);
return !isDeepStrictEqual(context.config, baseline);
}
async function readFileConfig(configPath: string): Promise<FileConfigSnapshot> {
try {
const raw = await readFile(configPath, 'utf8');
return { config: parseConfig(raw), raw };
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return { config: {}, raw: undefined };
}
throw error;
}
}
async function readFileRaw(configPath: string): Promise<string | undefined> {
try {
return await readFile(configPath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
throw error;
}
}
async function removeTemporaryFile(temporaryPath: string): Promise<void> {
try {
await unlink(temporaryPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
}
async function mutateFileConfig(
manager: ConfigDeliveryGatewayManager | undefined,
mutator: OpenClawConfigMutator,
): Promise<boolean> {
return await withConfigLock(async () => {
const configPath = resolveOpenClawConfigPath();
for (let attempt = 0; attempt < 2; attempt += 1) {
const snapshot = await readFileConfig(configPath);
const changed = await applyMutator(snapshot.config, mutator, snapshot.raw !== undefined);
if (manager?.getStatus().state === 'running') {
return await mutateRunningConfig(manager, mutator);
}
if (!changed) return false;
await mkdir(dirname(configPath), { recursive: true });
const temporaryPath = `${configPath}.${process.pid}.${randomUUID()}.tmp`;
await writeFile(temporaryPath, serializeConfig(snapshot.config), {
encoding: 'utf8',
flag: 'wx',
mode: 0o600,
});
try {
if (manager?.getStatus().state === 'running') {
return await mutateRunningConfig(manager, mutator);
}
const currentRaw = await readFileRaw(configPath);
if (manager?.getStatus().state === 'running') {
return await mutateRunningConfig(manager, mutator);
}
if (currentRaw !== snapshot.raw) {
if (attempt === 0) continue;
throw new Error('OpenClaw config changed during file mutation; retry the mutation');
}
await rename(temporaryPath, configPath);
return true;
} finally {
await removeTemporaryFile(temporaryPath);
}
}
return false;
});
}
async function runMutation(mutator: OpenClawConfigMutator): Promise<boolean> {
const manager = gatewayManager;
if (manager?.getStatus().state === 'running') {
return await mutateRunningConfig(manager, mutator);
}
return await mutateFileConfig(manager, mutator);
}
async function runRead(): Promise<OpenClawConfigSnapshot> {
const manager = gatewayManager;
if (manager?.getStatus().state === 'running') {
const snapshot = await manager.rpc<ConfigSnapshot>('config.get', {});
return { config: parseRunningConfigSnapshot(snapshot), exists: true };
}
const snapshot = await readFileConfig(resolveOpenClawConfigPath());
return { config: snapshot.config, exists: snapshot.raw !== undefined };
}
async function runSecretsReload(): Promise<boolean> {
const manager = gatewayManager;
if (manager?.getStatus().state !== 'running') return false;
await manager.rpc('secrets.reload', {});
return true;
}
export function registerOpenClawConfigCoordinator(
manager: ConfigDeliveryGatewayManager,
): void {
gatewayManager = manager;
}
export function mutateOpenClawConfig(
mutator: OpenClawConfigMutator,
): Promise<boolean> {
const context = activeMutation.getStore();
if (context?.active) {
return applyNestedMutator(context, mutator);
}
const transaction = transactionTail.then(
() => runMutation(mutator),
() => runMutation(mutator),
);
transactionTail = transaction.then(
() => undefined,
() => undefined,
);
return transaction;
}
export function readOpenClawConfigSnapshot(): Promise<OpenClawConfigSnapshot> {
const context = activeMutation.getStore();
if (context?.active) {
return Promise.resolve({
config: structuredClone(context.config),
exists: context.sourceExists,
});
}
const transaction = transactionTail.then(
() => runRead(),
() => runRead(),
);
transactionTail = transaction.then(
() => undefined,
() => undefined,
);
return transaction;
}
export function reloadOpenClawSecretsIfRunning(): Promise<boolean> {
const transaction = transactionTail.then(
() => runSecretsReload(),
() => runSecretsReload(),
);
transactionTail = transaction.then(
() => undefined,
() => undefined,
);
return transaction;
}
export function resetOpenClawConfigCoordinatorForTests(): void {
gatewayManager = undefined;
transactionTail = Promise.resolve();
}
+36 -11
View File
@@ -1,6 +1,6 @@
import { app } from 'electron';
import path from 'path';
import { existsSync, readFileSync, mkdirSync, readdirSync, rmSync, symlinkSync } from 'fs';
import { existsSync, readFileSync, mkdirSync, readdirSync, symlinkSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
@@ -33,8 +33,10 @@ import { buildProxyEnv, resolveProxySettings } from '../utils/proxy';
import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy';
import { logger } from '../utils/logger';
import { prependPathEntry } from '../utils/env-path';
import { copyPluginFromNodeModules, fixupPluginManifest, cpSyncSafe, buildCandidateSources, repairTrustedOfficialPluginInstallRecords, syncTrustedOfficialPluginInstallRecord, resolvePluginNpmPackagePath } from '../utils/plugin-install';
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 { stripSystemdSupervisorEnv } from './config-sync-env';
import { cleanupAgentsSymlinkedSkills, cleanupStalePluginRuntimeDeps } from './skills-symlink-cleanup';
import {
@@ -119,7 +121,7 @@ function cleanupStaleBuiltInExtensions(): void {
if (existsSync(fsPath(extDir))) {
logger.info(`[plugin] Removing stale built-in extension copy: ${ext}`);
try {
rmSync(fsPath(extDir), { recursive: true, force: true });
safeRmSync(fsPath(extDir));
} catch (err) {
logger.warn(`[plugin] Failed to remove stale extension ${ext}:`, err);
}
@@ -191,10 +193,9 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): boolean
logger.info(`[plugin] ${isInstalled ? 'Auto-upgrading' : 'Installing'} ${channelType} plugin${isInstalled ? `: ${installedVersion}${sourceVersion}` : `: ${sourceVersion}`} (bundled)`);
try {
mkdirSync(fsPath(join(homedir(), '.openclaw', 'extensions')), { recursive: true });
rmSync(fsPath(targetDir), { recursive: true, force: true });
safeRmSync(fsPath(targetDir));
cpSyncSafe(bundledDir, targetDir);
fixupPluginManifest(targetDir);
syncTrustedOfficialPluginInstallRecord(dirName, targetDir);
} catch (err) {
logger.warn(`[plugin] Failed to ${isInstalled ? 'auto-upgrade' : 'install'} ${channelType} plugin:`, err);
succeeded = false;
@@ -203,7 +204,6 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): boolean
// Same version already installed — still patch manifest ID in case it was
// never corrected (e.g. installed before MANIFEST_ID_FIXES included this plugin).
fixupPluginManifest(targetDir);
syncTrustedOfficialPluginInstallRecord(dirName, targetDir);
}
continue;
}
@@ -217,7 +217,6 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): boolean
// Skip only if installed AND same version — but still patch manifest ID.
if (isInstalled && installedVersion && sourceVersion === installedVersion) {
fixupPluginManifest(targetDir);
syncTrustedOfficialPluginInstallRecord(dirName, targetDir);
continue;
}
@@ -227,7 +226,6 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): boolean
mkdirSync(fsPath(join(homedir(), '.openclaw', 'extensions')), { recursive: true });
copyPluginFromNodeModules(npmPkgPath, targetDir, npmName);
fixupPluginManifest(targetDir);
syncTrustedOfficialPluginInstallRecord(dirName, targetDir);
} catch (err) {
logger.warn(`[plugin] Failed to ${isInstalled ? 'auto-upgrade' : 'install'} ${channelType} plugin from node_modules:`, err);
succeeded = false;
@@ -257,7 +255,7 @@ function cleanupUnconfiguredChannelPlugins(configuredChannels: string[]): boolea
logger.info(`[plugin] Removing unconfigured channel plugin: ${channelType} (${dirName})`);
try {
rmSync(fsPath(targetDir), { recursive: true, force: true });
safeRmSync(fsPath(targetDir));
} catch (err) {
logger.warn(`[plugin] Failed to remove unconfigured channel plugin ${channelType}:`, err);
succeeded = false;
@@ -266,6 +264,18 @@ function cleanupUnconfiguredChannelPlugins(configuredChannels: string[]): boolea
return succeeded;
}
async function cleanupUnconfiguredChannelPluginInstallRecords(configuredChannels: string[]): Promise<void> {
const configuredSet = new Set(configuredChannels);
for (const [channelType, { dirName }] of Object.entries(CHANNEL_PLUGIN_MAP)) {
if (configuredSet.has(channelType)) continue;
// Metadata can outlive the directory (for example after an interrupted
// 2026.6.10 → 2026.7.1 migration). OpenClaw validates tracked records even
// when the channel is no longer configured, so reconcile this on every
// launch rather than hiding it behind the directory-maintenance cache.
await removeTrustedOfficialPluginInstallRecord(dirName);
}
}
function resolveImageGenerationPrimary(config: unknown): string | null {
if (!config || typeof config !== 'object') return null;
const agents = (config as { agents?: unknown }).agents;
@@ -526,7 +536,10 @@ export async function syncGatewayConfigBeforeLaunch(
// Always refresh trusted install metadata through ClawX — this must not
// be skipped when plugin-maintenance is cache-hit, otherwise official
// external plugins like WhatsApp fail openKeyedStore at runtime.
measureSync(timingsMs, 'trustedPluginInstallSyncMs', repairTrustedOfficialPluginInstallRecords);
await measureAsync(timingsMs, 'trustedPluginInstallSyncMs', async () => {
await cleanupUnconfiguredChannelPluginInstallRecords(configuredChannels);
await repairTrustedOfficialPluginInstallRecords();
});
} catch (err) {
logger.warn('Failed to auto-upgrade plugins:', err);
}
@@ -625,6 +638,19 @@ export async function prepareGatewayLaunchContext(port: number): Promise<Gateway
throw new Error(`OpenClaw package not found at: ${openclawDir}`);
}
await measureAsync(timingsMs, 'upgradeSnapshotMs', async () => {
try {
const snapshot = await ensureOpenClaw2026_7_1UpgradeSnapshot();
if (snapshot.status === 'created') {
logger.info(`[upgrade] Created OpenClaw 2026.7.1 pre-migration snapshot (${snapshot.files.length} files): ${snapshot.snapshotDir}`);
}
} catch (error) {
// OpenClaw also maintains migration-specific backups. Keep startup
// available if the additional ClawX safety snapshot cannot be written.
logger.warn('[upgrade] Failed to create OpenClaw 2026.7.1 pre-migration snapshot:', error);
}
});
const appSettings = await measureAsync(timingsMs, 'settingsMs', getAllSettings);
const prelaunchSummary = await measureAsync(timingsMs, 'prelaunchSyncMs', async () => (
await syncGatewayConfigBeforeLaunch(appSettings, openclawDir)
@@ -670,7 +696,6 @@ export async function prepareGatewayLaunchContext(port: number): Promise<Gateway
...proxyEnv,
OPENCLAW_GATEWAY_TOKEN: appSettings.gatewayToken,
OPENCLAW_SKIP_CHANNELS: skipChannels ? '1' : '',
CLAWDBOT_SKIP_CHANNELS: skipChannels ? '1' : '',
OPENCLAW_NO_RESPAWN: '1',
// Disable OpenClaw's interactive-shell env snapshot. When the Gateway runs
// as an Electron utilityProcess, `process.execPath` is the Electron binary,
+4 -2
View File
@@ -27,7 +27,6 @@ export function dispatchProtocolEvent(
if (normalized) {
emitter.emit('chat:runtime-event', normalized);
}
emitter.emit('notification', { method: event, params: payload });
break;
}
case 'channel.status':
@@ -53,14 +52,17 @@ export function dispatchJsonRpcNotification(
emitter: GatewayEventEmitter,
notification: JsonRpcNotification,
): void {
emitter.emit('notification', notification);
if (notification.method === 'agent') {
const normalized = normalizeGatewayChatRuntimeEvent(notification.params);
if (normalized) {
emitter.emit('chat:runtime-event', normalized);
}
} else {
emitter.emit('notification', notification);
}
switch (notification.method) {
case 'agent':
break;
case GatewayEventType.CHANNEL_STATUS_CHANGED:
emitter.emit('channel:status', notification.params as GatewayChannelStatusEvent);
break;
+89 -214
View File
@@ -24,7 +24,9 @@ import {
type GatewayLifecycleState,
getReconnectScheduleDecision,
getReconnectSkipReason,
isOpenClawFatalConfigExitCode,
} from './process-policy';
import { removeOpenClaw2026_7_1UpgradeSnapshot } from '../utils/openclaw-upgrade-snapshot';
import {
clearPendingGatewayRequests,
rejectPendingGatewayRequest,
@@ -49,12 +51,18 @@ import { launchGatewayProcess } from './process-launcher';
import { GatewayRestartController } from './restart-controller';
import { GatewayRestartGovernor } from './restart-governor';
import {
DEFAULT_GATEWAY_RELOAD_POLICY,
loadGatewayReloadPolicy,
type GatewayReloadPolicy,
} from './reload-policy';
import { classifyGatewayStderrMessage, recordGatewayStartupStderrLine } from './startup-stderr';
classifyGatewayStderrMessage,
GATEWAY_STARTUP_SLOW_STAGE_MS,
GATEWAY_STARTUP_SLOW_TOTAL_MS,
GatewayStartupTraceCollector,
recordGatewayStartupStderrLine,
} from './startup-stderr';
import { runGatewayStartupSequence } from './startup-orchestrator';
import {
hasFatalRuntimeFailureSignal,
hasInvalidConfigFailureSignal,
hasStartupMigrationLockSignal,
} from './startup-recovery';
import {
GatewayCapabilityMonitor,
type GatewayCapabilityName,
@@ -174,6 +182,7 @@ export class GatewayManager extends EventEmitter {
private startLock = false;
private lastSpawnSummary: string | null = null;
private recentStartupStderrLines: string[] = [];
private readonly startupTraceCollector = new GatewayStartupTraceCollector();
private pendingRequests: Map<string, PendingGatewayRequest> = new Map();
private deviceIdentity: DeviceIdentity | null = null;
private restartInFlight: Promise<void> | null = null;
@@ -181,21 +190,15 @@ export class GatewayManager extends EventEmitter {
private readonly lifecycleController = new GatewayLifecycleController();
private readonly restartController = new GatewayRestartController();
private readonly restartGovernor = new GatewayRestartGovernor();
private reloadDebounceTimer: NodeJS.Timeout | null = null;
private initialReadyHeartbeatRecoveryTimer: NodeJS.Timeout | null = null;
private reloadPolicy: GatewayReloadPolicy = { ...DEFAULT_GATEWAY_RELOAD_POLICY };
private reloadPolicyLoadedAt = 0;
private reloadPolicyRefreshPromise: Promise<void> | null = null;
private upgradeSnapshotCleanupAttempted = false;
private externalShutdownSupported: boolean | null = null;
private reconnectAttemptsTotal = 0;
private reconnectSuccessTotal = 0;
private static readonly RELOAD_POLICY_REFRESH_MS = 15_000;
private static readonly HEARTBEAT_INTERVAL_MS = 60_000;
private static readonly HEARTBEAT_TIMEOUT_MS = 30_000;
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 static readonly INITIAL_READY_HEARTBEAT_RECOVERY_GRACE_MS = 5 * 60_000;
private lastRestartAt = 0;
/** Set by scheduleReconnect() before calling start() to signal auto-reconnect. */
private isAutoReconnectStart = false;
@@ -239,11 +242,11 @@ export class GatewayManager extends EventEmitter {
this.on('gateway:ready', () => {
this.resetGatewayReadyFallback();
this.clearInitialReadyHeartbeatRecoveryTimer();
if (this.status.state === 'running' && !this.status.gatewayReady) {
logger.info('Gateway subsystems ready (event received)');
this.setStatus({ gatewayReady: true });
}
void this.cleanupOpenClawUpgradeSnapshot();
});
this.on('gateway:health', (payload) => {
this.capabilityMonitor.recordOpenClawHealth(payload);
@@ -327,8 +330,6 @@ export class GatewayManager extends EventEmitter {
logger.info(`Gateway start requested (port=${this.status.port})`);
this.lastSpawnSummary = null;
this.shouldReconnect = true;
await this.refreshReloadPolicy(true);
// Lazily load device identity (async file I/O + key generation).
// Must happen before connect() which uses the identity for the handshake.
await this.initDeviceIdentity();
@@ -419,12 +420,23 @@ export class GatewayManager extends EventEmitter {
onConnectedToManagedGateway: () => {
this.startHealthCheck();
const tConnected = Date.now();
logger.info('[metric] gateway.startup', {
const spawnToReadyMs = tReady && tSpawned ? tReady - tSpawned : undefined;
const startupTrace = this.startupTraceCollector.getSummary();
const startupMetric = {
configSyncMs: tSpawned ? tSpawned - t0 : undefined,
spawnToReadyMs: tReady && tSpawned ? tReady - tSpawned : undefined,
spawnToReadyMs,
readyToConnectMs: tReady ? tConnected - tReady : undefined,
totalMs: tConnected - t0,
});
openclawTrace: startupTrace,
};
logger.info('[metric] gateway.startup', startupMetric);
if (spawnToReadyMs !== undefined && spawnToReadyMs >= GATEWAY_STARTUP_SLOW_TOTAL_MS) {
logger.warn('[gateway-startup] Slow managed Gateway startup detected', {
pid: this.status.pid,
spawnToReadyMs,
openclawTrace: startupTrace,
});
}
},
runDoctorRepair: async () => await runOpenClawDoctorRepair(),
onDoctorRepairSuccess: () => {
@@ -444,7 +456,17 @@ export class GatewayManager extends EventEmitter {
error
);
this.setStatus({ state: 'error', error: String(error) });
if (this.shouldReconnect) {
const fatalStartupFailure = isOpenClawFatalConfigExitCode(this.processExitCode)
|| hasFatalRuntimeFailureSignal(error, this.recentStartupStderrLines)
|| hasStartupMigrationLockSignal(error, this.recentStartupStderrLines)
|| hasInvalidConfigFailureSignal(error, this.recentStartupStderrLines);
if (fatalStartupFailure) {
// OpenClaw 2026.7.1 uses EX_CONFIG for fatal configuration failures.
// Runtime and SQLite compatibility failures are likewise not repaired
// by restarting the same binary, so leave recovery to a manual start.
this.shouldReconnect = false;
logger.error('Gateway startup failed fatally; automatic reconnect disabled');
} else if (this.shouldReconnect) {
logger.warn('Gateway start failed; scheduling auto-reconnect recovery');
this.scheduleReconnect();
}
@@ -650,138 +672,6 @@ export class GatewayManager extends EventEmitter {
});
}
/**
* Ask the Gateway process to reload config in-place when possible.
* Falls back to restart on unsupported platforms or signaling failures.
*/
async reload(): Promise<void> {
await this.refreshReloadPolicy();
if (this.reloadPolicy.mode === 'off' || this.reloadPolicy.mode === 'restart') {
logger.info(
`[gateway-refresh] mode=reload result=policy_forced_restart policy=${this.reloadPolicy.mode}`,
);
await this.restart();
return;
}
if (this.restartController.isRestartDeferred({
state: this.status.state,
startLock: this.startLock,
})) {
this.restartController.markDeferredRestart('reload', {
state: this.status.state,
startLock: this.startLock,
});
return;
}
const pidBefore = this.process?.pid;
logger.info(`[gateway-refresh] mode=reload requested pid=${pidBefore ?? 'n/a'} state=${this.status.state}`);
if (!this.process?.pid || this.status.state !== 'running') {
logger.warn('[gateway-refresh] mode=reload result=fallback_restart cause=not_running');
logger.warn('Gateway reload requested while not running; falling back to restart');
await this.restart();
return;
}
const connectedForMs = this.status.connectedAt
? Date.now() - this.status.connectedAt
: Number.POSITIVE_INFINITY;
// Avoid signaling a process that just came up; it will already read latest config.
if (connectedForMs < 8000) {
logger.info(
`[gateway-refresh] mode=reload result=skipped_recent_connect connectedForMs=${connectedForMs} pid=${this.process.pid}`,
);
logger.info(`Gateway connected ${connectedForMs}ms ago, skipping reload signal`);
return;
}
if (process.platform === 'win32') {
// Windows does not support SIGUSR1 for in-process reload.
// Fall back to a full restart. The connectedForMs < 8000 guard above
// already skips unnecessary restarts for recently-started processes.
logger.warn('[gateway-refresh] mode=reload result=fallback_restart cause=windows');
await this.restart();
return;
}
try {
process.kill(this.process.pid, 'SIGUSR1');
logger.info(`Sent SIGUSR1 to Gateway for config reload (pid=${this.process.pid})`);
// Some gateway builds do not handle SIGUSR1 as an in-process reload.
// If process state doesn't recover quickly, fall back to restart.
await new Promise((resolve) => setTimeout(resolve, 1500));
if (this.status.state !== 'running' || !this.process?.pid) {
logger.warn('[gateway-refresh] mode=reload result=fallback_restart cause=post_signal_unhealthy');
logger.warn('Gateway did not stay running after reload signal, falling back to restart');
await this.restart();
} else {
const pidAfter = this.process.pid;
logger.info(
`[gateway-refresh] mode=reload result=applied_in_place pidBefore=${pidBefore} pidAfter=${pidAfter}`,
);
}
} catch (error) {
logger.warn('[gateway-refresh] mode=reload result=fallback_restart cause=signal_error');
logger.warn('Gateway reload signal failed, falling back to restart:', error);
await this.restart();
}
}
/**
* Debounced reload — coalesces multiple rapid config-change events into one
* in-process reload when possible.
*/
debouncedReload(delayMs?: number): void {
void this.refreshReloadPolicy();
const effectiveDelay = delayMs ?? this.reloadPolicy.debounceMs;
if (this.reloadPolicy.mode === 'off' || this.reloadPolicy.mode === 'restart') {
logger.debug(
`Gateway reload policy=${this.reloadPolicy.mode}; routing debouncedReload to debouncedRestart (${effectiveDelay}ms)`,
);
this.debouncedRestart(effectiveDelay);
return;
}
if (this.reloadDebounceTimer) {
clearTimeout(this.reloadDebounceTimer);
}
logger.debug(`Gateway reload debounced (will fire in ${effectiveDelay}ms)`);
this.reloadDebounceTimer = setTimeout(() => {
this.reloadDebounceTimer = null;
void this.reload().catch((err) => {
logger.warn('Debounced Gateway reload failed:', err);
});
}, effectiveDelay);
}
private async refreshReloadPolicy(force = false): Promise<void> {
const now = Date.now();
if (!force && now - this.reloadPolicyLoadedAt < GatewayManager.RELOAD_POLICY_REFRESH_MS) {
return;
}
if (this.reloadPolicyRefreshPromise) {
await this.reloadPolicyRefreshPromise;
return;
}
this.reloadPolicyRefreshPromise = (async () => {
const nextPolicy = await loadGatewayReloadPolicy();
this.reloadPolicy = nextPolicy;
this.reloadPolicyLoadedAt = Date.now();
})();
try {
await this.reloadPolicyRefreshPromise;
} finally {
this.reloadPolicyRefreshPromise = null;
}
}
/**
* Clear all active timers
*/
@@ -792,12 +682,7 @@ export class GatewayManager extends EventEmitter {
}
this.connectionMonitor.clear();
this.restartController.clearDebounceTimer();
if (this.reloadDebounceTimer) {
clearTimeout(this.reloadDebounceTimer);
this.reloadDebounceTimer = null;
}
this.resetGatewayReadyFallback();
this.clearInitialReadyHeartbeatRecoveryTimer();
}
private clearGatewayReadyFallbackTimer(): void {
@@ -1004,7 +889,6 @@ export class GatewayManager extends EventEmitter {
}
private recordGatewayAlive(): void {
this.clearInitialReadyHeartbeatRecoveryTimer();
this.diagnostics.lastAliveAt = Date.now();
this.diagnostics.consecutiveHeartbeatMisses = 0;
}
@@ -1039,8 +923,10 @@ export class GatewayManager extends EventEmitter {
await unloadLaunchctlGatewayService();
this.processExitCode = null;
// Per-process dedup map for stderr lines — resets on each new spawn.
// Per-process diagnostics reset on each new spawn so retries never mix
// timings or stderr deduplication state from different Gateway children.
const stderrDedup = new Map<string, number>();
this.startupTraceCollector.reset();
const { child, lastSpawnSummary } = await launchGatewayProcess({
port: this.status.port,
@@ -1050,6 +936,7 @@ export class GatewayManager extends EventEmitter {
getShouldReconnect: () => this.shouldReconnect,
onStderrLine: (line) => {
recordGatewayStartupStderrLine(this.recentStartupStderrLines, line);
const traceStage = this.startupTraceCollector.record(line);
const classified = classifyGatewayStderrMessage(line);
if (classified.level === 'drop') return;
@@ -1064,10 +951,24 @@ export class GatewayManager extends EventEmitter {
return;
}
if (traceStage) {
const message = `[gateway-startup] stage=${traceStage.name} durationMs=${traceStage.durationMs}`
+ (traceStage.totalMs === undefined ? '' : ` totalMs=${traceStage.totalMs}`);
if (traceStage.durationMs >= GATEWAY_STARTUP_SLOW_STAGE_MS) {
logger.warn(`${message} slow=true`);
} else {
logger.info(message);
}
return;
}
if (classified.level === 'debug') {
logger.debug(`[Gateway stderr] ${classified.normalized}`);
return;
}
if (classified.level === 'info') {
logger.info(`[Gateway stderr] ${classified.normalized}`);
return;
}
logger.warn(`[Gateway stderr] ${classified.normalized}`);
},
onSpawn: (pid) => {
@@ -1086,16 +987,23 @@ export class GatewayManager extends EventEmitter {
this.setStatus({ state: 'stopped' });
}
// Always attempt reconnect from process exit. scheduleReconnect()
// internally checks shouldReconnect and reconnect-timer guards, so
// calling it unconditionally is safe — intentional stop() calls set
// shouldReconnect=false which makes scheduleReconnect() no-op.
//
// On Windows, the WS close handler intentionally skips reconnect
// (to avoid racing with this exit handler). However, WS close
// fires *before* process exit and sets state='stopped', which
// previously caused this handler to also skip reconnect — leaving
// the gateway permanently dead with no recovery path.
const orchestratedStartupFailure = isOpenClawFatalConfigExitCode(code)
|| hasFatalRuntimeFailureSignal(undefined, this.recentStartupStderrLines)
|| hasStartupMigrationLockSignal(undefined, this.recentStartupStderrLines)
|| hasInvalidConfigFailureSignal(undefined, this.recentStartupStderrLines);
if (orchestratedStartupFailure) {
// During startup the orchestrator may still perform its one bounded
// doctor repair. Do not race it with an independent reconnect timer.
// If orchestration cannot recover, start() disables reconnect in its
// catch path so migration/config failures cannot create an outer loop.
if (this.status.state !== 'starting') this.shouldReconnect = false;
logger.error(`Gateway process reported a non-retriable startup condition (code=${String(code)}); reconnect not scheduled`);
return;
}
// Always attempt reconnect from non-fatal process exits.
// scheduleReconnect() internally checks shouldReconnect and timer
// guards, so intentional stop() remains a no-op.
this.scheduleReconnect();
},
onError: () => {
@@ -1227,7 +1135,7 @@ export class GatewayManager extends EventEmitter {
}
/**
* Start ping interval to keep connection alive
* Observe Gateway control-plane responsiveness without owning process recovery.
*/
private startPing(): void {
this.connectionMonitor.startPing({
@@ -1242,60 +1150,27 @@ export class GatewayManager extends EventEmitter {
onHeartbeatTimeout: ({ consecutiveMisses, timeoutMs }) => {
this.recordHeartbeatTimeout(consecutiveMisses);
const pid = this.process?.pid ?? 'unknown';
const shouldAttemptRecovery = this.shouldReconnect && this.status.state === 'running';
logger.warn(
`Gateway heartbeat: ${consecutiveMisses} consecutive pong misses ` +
`(timeout=${timeoutMs}ms, pid=${pid}, state=${this.status.state}, autoReconnect=${this.shouldReconnect}).`,
`(timeout=${timeoutMs}ms, pid=${pid}, state=${this.status.state}, autoReconnect=${this.shouldReconnect}). ` +
'No restart requested; relying on process exit and socket close recovery.',
);
if (!shouldAttemptRecovery) {
logger.warn('Gateway heartbeat recovery skipped (lifecycle is not in auto-recoverable running state)');
return;
}
const initialReadyRecoveryDelayMs = this.getInitialReadyHeartbeatRecoveryDelayMs();
if (initialReadyRecoveryDelayMs > 0) {
logger.warn(
`Gateway heartbeat recovery deferred while waiting for initial gateway.ready ` +
`(retryAfterMs=${initialReadyRecoveryDelayMs})`,
);
this.scheduleInitialReadyHeartbeatRecovery(initialReadyRecoveryDelayMs);
return;
}
logger.warn('Gateway heartbeat recovery: restarting unresponsive gateway process');
void this.restart().catch((error) => {
logger.warn('Gateway heartbeat recovery failed:', error);
});
},
});
}
private getInitialReadyHeartbeatRecoveryDelayMs(now = Date.now()): number {
if (this.status.gatewayReady || !this.status.connectedAt) return 0;
const connectedForMs = Math.max(0, now - this.status.connectedAt);
return Math.max(0, GatewayManager.INITIAL_READY_HEARTBEAT_RECOVERY_GRACE_MS - connectedForMs);
}
private async cleanupOpenClawUpgradeSnapshot(): Promise<void> {
if (this.upgradeSnapshotCleanupAttempted) return;
this.upgradeSnapshotCleanupAttempted = true;
private scheduleInitialReadyHeartbeatRecovery(delayMs: number): void {
if (this.initialReadyHeartbeatRecoveryTimer) return;
this.initialReadyHeartbeatRecoveryTimer = setTimeout(() => {
this.initialReadyHeartbeatRecoveryTimer = null;
if (
!this.shouldReconnect
|| this.status.state !== 'running'
|| this.status.gatewayReady
) {
return;
try {
const result = await removeOpenClaw2026_7_1UpgradeSnapshot();
if (result.status === 'removed') {
logger.info(`[upgrade] Removed OpenClaw 2026.7.1 pre-migration snapshot: ${result.snapshotDir}`);
}
logger.warn('Gateway heartbeat recovery: initial gateway.ready grace expired, restarting unresponsive gateway process');
void this.restart().catch((error) => {
logger.warn('Gateway heartbeat recovery failed:', error);
});
}, delayMs);
}
private clearInitialReadyHeartbeatRecoveryTimer(): void {
if (!this.initialReadyHeartbeatRecoveryTimer) return;
clearTimeout(this.initialReadyHeartbeatRecoveryTimer);
this.initialReadyHeartbeatRecoveryTimer = null;
} catch (error) {
logger.warn('[upgrade] Failed to remove OpenClaw 2026.7.1 pre-migration snapshot:', error);
}
}
/**
+17 -2
View File
@@ -80,6 +80,20 @@ const GATEWAY_FETCH_PRELOAD_SOURCE = `'use strict';
})();
`;
export function buildGatewayRuntimeEnv(
forkEnv: Record<string, string | undefined>,
): Record<string, string | undefined> {
return {
...forkEnv,
// ClawX does not expose LAN discovery, so keep Bonjour disabled even if
// the parent process inherited an explicit opt-in value.
OPENCLAW_DISABLE_BONJOUR: '1',
// OpenClaw's built-in trace contains stage names and timings only. Keep it
// enabled so packaged startup incidents are diagnosable from normal logs.
OPENCLAW_GATEWAY_STARTUP_TRACE: '1',
};
}
function ensureGatewayFetchPreload(): string {
const dest = path.join(app.getPath('userData'), 'gateway-fetch-preload.cjs');
try {
@@ -118,7 +132,7 @@ export async function launchGatewayProcess(options: {
);
const lastSpawnSummary = `mode=${mode}, entry="${entryScript}", args="${options.sanitizeSpawnArgs(gatewayArgs).join(' ')}", cwd="${openclawDir}"`;
const runtimeEnv = { ...forkEnv };
const runtimeEnv = buildGatewayRuntimeEnv(forkEnv);
// Disable OpenClaw's mDNS/Bonjour gateway advertiser unconditionally.
//
@@ -136,7 +150,8 @@ export async function launchGatewayProcess(options: {
// `startGatewayBonjourAdvertiser()` (openclaw `src/infra/bonjour.ts`,
// `isDisabledByEnv()`). Set after the `forkEnv` spread so any
// pre-existing value inherited from the user shell cannot re-enable it.
runtimeEnv.OPENCLAW_DISABLE_BONJOUR = '1';
// buildGatewayRuntimeEnv() applies both this policy and startup tracing
// before any development-only environment augmentation below.
// Only apply the fetch/child_process preload in dev mode.
// In packaged builds Electron's UtilityProcess rejects NODE_OPTIONS
+7
View File
@@ -10,6 +10,13 @@ export const DEFAULT_RECONNECT_CONFIG: ReconnectConfig = {
maxDelay: 30000,
};
/** sysexits(3) EX_CONFIG, used by OpenClaw 2026.7.1 for fatal config startup errors. */
export const OPENCLAW_EX_CONFIG_EXIT_CODE = 78;
export function isOpenClawFatalConfigExitCode(code: number | null | undefined): boolean {
return code === OPENCLAW_EX_CONFIG_EXIT_CODE;
}
export function nextLifecycleEpoch(currentEpoch: number): number {
return currentEpoch + 1;
}
-63
View File
@@ -1,63 +0,0 @@
import { readFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
export type GatewayReloadMode = 'hybrid' | 'reload' | 'restart' | 'off';
export type GatewayReloadPolicy = {
mode: GatewayReloadMode;
debounceMs: number;
};
export const DEFAULT_GATEWAY_RELOAD_POLICY: GatewayReloadPolicy = {
mode: 'hybrid',
debounceMs: 1200,
};
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
const MAX_DEBOUNCE_MS = 60_000;
function normalizeMode(value: unknown): GatewayReloadMode {
if (value === 'off' || value === 'reload' || value === 'restart' || value === 'hybrid') {
return value;
}
return DEFAULT_GATEWAY_RELOAD_POLICY.mode;
}
function normalizeDebounceMs(value: unknown): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return DEFAULT_GATEWAY_RELOAD_POLICY.debounceMs;
}
const rounded = Math.round(value);
if (rounded < 0) return 0;
if (rounded > MAX_DEBOUNCE_MS) return MAX_DEBOUNCE_MS;
return rounded;
}
export function parseGatewayReloadPolicy(config: unknown): GatewayReloadPolicy {
if (!config || typeof config !== 'object') {
return { ...DEFAULT_GATEWAY_RELOAD_POLICY };
}
const root = config as Record<string, unknown>;
const gateway = (root.gateway && typeof root.gateway === 'object'
? root.gateway
: {}) as Record<string, unknown>;
const reload = (gateway.reload && typeof gateway.reload === 'object'
? gateway.reload
: {}) as Record<string, unknown>;
return {
mode: normalizeMode(reload.mode),
debounceMs: normalizeDebounceMs(reload.debounceMs),
};
}
export async function loadGatewayReloadPolicy(): Promise<GatewayReloadPolicy> {
try {
const raw = await readFile(OPENCLAW_CONFIG_PATH, 'utf-8');
return parseGatewayReloadPolicy(JSON.parse(raw));
} catch {
return { ...DEFAULT_GATEWAY_RELOAD_POLICY };
}
}
-101
View File
@@ -1,101 +0,0 @@
type GatewayRpcRunner = (method: string, params?: unknown, timeoutMs?: number) => Promise<unknown>;
type QueuedRpc = {
run: () => Promise<void>;
};
function stableStringify(value: unknown): string {
if (value === null || typeof value !== 'object') {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map((item) => stableStringify(item)).join(',')}]`;
}
const record = value as Record<string, unknown>;
return `{${Object.keys(record).sort().map((key) => (
`${JSON.stringify(key)}:${stableStringify(record[key])}`
)).join(',')}}`;
}
export interface GatewayRpcBackpressureOptions {
maxConcurrentHistory?: number;
}
/**
* Prevents renderer fan-out from forwarding an unbounded number of expensive
* chat.history RPCs to OpenClaw. The Gateway still owns the canonical response;
* this class only coalesces duplicate in-flight history calls and runs distinct
* history requests through a small FIFO queue.
*/
export class GatewayRpcBackpressure {
private readonly maxConcurrentHistory: number;
private readonly inFlightHistory = new Map<string, Promise<unknown>>();
private readonly queue: QueuedRpc[] = [];
private activeHistory = 0;
constructor(options: GatewayRpcBackpressureOptions = {}) {
this.maxConcurrentHistory = Math.max(1, options.maxConcurrentHistory ?? 2);
}
run(
method: string,
params: unknown,
timeoutMs: number | undefined,
runner: GatewayRpcRunner,
): Promise<unknown> {
if (method !== 'chat.history') {
return runner(method, params, timeoutMs);
}
const key = `${method}:${stableStringify(params)}:${timeoutMs ?? 'default'}`;
const existing = this.inFlightHistory.get(key);
if (existing) return existing;
const promise = this.enqueueHistory(() => runner(method, params, timeoutMs))
.finally(() => {
if (this.inFlightHistory.get(key) === promise) {
this.inFlightHistory.delete(key);
}
});
this.inFlightHistory.set(key, promise);
return promise;
}
getDiagnostics(): { activeHistory: number; queuedHistory: number; inFlightHistory: number } {
return {
activeHistory: this.activeHistory,
queuedHistory: this.queue.length,
inFlightHistory: this.inFlightHistory.size,
};
}
private enqueueHistory(work: () => Promise<unknown>): Promise<unknown> {
return new Promise((resolve, reject) => {
const queued: QueuedRpc = {
run: async () => {
this.activeHistory += 1;
try {
resolve(await work());
} catch (error) {
reject(error);
} finally {
this.activeHistory -= 1;
this.drain();
}
},
};
this.queue.push(queued);
this.drain();
});
}
private drain(): void {
while (this.activeHistory < this.maxConcurrentHistory) {
const next = this.queue.shift();
if (!next) return;
void next.run();
}
}
}
+54 -6
View File
@@ -8,10 +8,25 @@
const INVALID_CONFIG_PATTERNS: RegExp[] = [
/\binvalid config\b/i,
/\bconfig invalid\b/i,
/\bfatal configuration error\b/i,
/\bunrecognized key\b/i,
/\bstartup migration(?:s)?\b.*\b(?:blocked|failed|did not complete cleanly)\b/i,
/\bmigration\b.*\bopenclaw doctor --fix\b/i,
/\brun:\s*openclaw doctor --fix\b/i,
];
const FATAL_RUNTIME_PATTERNS: RegExp[] = [
/\bNode(?:\.js)?\b.*\boutside the supported range\b/i,
/\buses SQLite\b.*\bnot WAL-reset-safe\b/i,
/\bSQLite\b.*\bWAL-reset-safe runtime required\b/i,
/\bInstall Node 24\.15\+.*\bNode 22\.22\.3\+\b/i,
];
const STARTUP_MIGRATION_LOCK_PATTERNS: RegExp[] = [
/\bstartup migrations? (?:is|are) already running\b/i,
/\bretry after the other gateway finishes\b/i,
];
const TRANSIENT_START_ERROR_PATTERNS: RegExp[] = [
/WebSocket closed before handshake/i,
/ECONNREFUSED/i,
@@ -61,6 +76,33 @@ export function hasInvalidConfigFailureSignal(
return isInvalidConfigSignal(errorText);
}
function startupFailureCandidates(startupError: unknown, startupStderrLines: string[]): string[] {
return [
...startupStderrLines,
startupError instanceof Error
? `${startupError.name}: ${startupError.message}`
: String(startupError ?? ''),
];
}
/** Returns true for OpenClaw runtime/SQLite failures that doctor cannot repair. */
export function hasFatalRuntimeFailureSignal(
startupError: unknown,
startupStderrLines: string[],
): boolean {
return startupFailureCandidates(startupError, startupStderrLines)
.some((text) => FATAL_RUNTIME_PATTERNS.some((pattern) => pattern.test(text)));
}
/** Returns true while another/stale OpenClaw startup migration lease is active. */
export function hasStartupMigrationLockSignal(
startupError: unknown,
startupStderrLines: string[],
): boolean {
return startupFailureCandidates(startupError, startupStderrLines)
.some((text) => STARTUP_MIGRATION_LOCK_PATTERNS.some((pattern) => pattern.test(text)));
}
/**
* Retry guard for one-time config repair during a single startup flow.
*/
@@ -136,12 +178,18 @@ export function getGatewayStartupRecoveryAction(options: {
attempt: number;
maxAttempts: number;
}): GatewayStartupRecoveryAction {
if (shouldAttemptConfigAutoRepair(
options.startupError,
options.startupStderrLines,
options.configRepairAttempted,
)) {
return 'repair';
if (
hasFatalRuntimeFailureSignal(options.startupError, options.startupStderrLines)
|| hasStartupMigrationLockSignal(options.startupError, options.startupStderrLines)
) {
return 'fail';
}
if (hasInvalidConfigFailureSignal(options.startupError, options.startupStderrLines)) {
// One doctor pass is the only automated repair. If the same migration or
// config failure remains afterward, stop instead of treating the generic
// process-exited error as transient.
return options.configRepairAttempted ? 'fail' : 'repair';
}
if (options.attempt < options.maxAttempts && isTransientGatewayStartError(options.startupError)) {
+107 -10
View File
@@ -1,40 +1,137 @@
export type GatewayStderrClassification = {
level: 'drop' | 'debug' | 'warn';
level: 'drop' | 'debug' | 'info' | 'warn';
normalized: string;
};
export type GatewayStartupTraceStage = {
name: string;
durationMs: number;
totalMs?: number;
};
export type GatewayStartupTraceSummary = {
stageCount: number;
lastStage?: string;
traceTotalMs?: number;
slowestStage?: string;
slowestStageMs?: number;
};
export const GATEWAY_STARTUP_SLOW_STAGE_MS = 10_000;
export const GATEWAY_STARTUP_SLOW_TOTAL_MS = 30_000;
const MAX_STDERR_LINES = 120;
const ANSI_ESCAPE_PATTERN = new RegExp(String.raw`\u001B\[[0-?]*[ -/]*[@-~]`, 'g');
const STARTUP_TRACE_PATTERN = /startup trace:\s+([^\s]+)\s+(\d+(?:\.\d+)?)ms(?:\s+total=(\d+(?:\.\d+)?)ms)?/i;
export function parseGatewayStartupTraceStage(message: string): GatewayStartupTraceStage | null {
const match = STARTUP_TRACE_PATTERN.exec(message.replace(ANSI_ESCAPE_PATTERN, ''));
if (!match) return null;
const durationMs = Number(match[2]);
const totalMs = match[3] === undefined ? undefined : Number(match[3]);
if (!Number.isFinite(durationMs) || (totalMs !== undefined && !Number.isFinite(totalMs))) {
return null;
}
return {
name: match[1]!,
durationMs,
...(totalMs === undefined ? {} : { totalMs }),
};
}
export class GatewayStartupTraceCollector {
private stageCount = 0;
private lastStage: GatewayStartupTraceStage | null = null;
private slowestStage: GatewayStartupTraceStage | null = null;
private maxTraceTotalMs: number | undefined;
reset(): void {
this.stageCount = 0;
this.lastStage = null;
this.slowestStage = null;
this.maxTraceTotalMs = undefined;
}
record(message: string): GatewayStartupTraceStage | null {
const stage = parseGatewayStartupTraceStage(message);
if (!stage) return null;
this.stageCount += 1;
this.lastStage = stage;
if (!this.slowestStage || stage.durationMs > this.slowestStage.durationMs) {
this.slowestStage = stage;
}
if (stage.totalMs !== undefined) {
this.maxTraceTotalMs = Math.max(this.maxTraceTotalMs ?? 0, stage.totalMs);
}
return stage;
}
getSummary(): GatewayStartupTraceSummary {
return {
stageCount: this.stageCount,
...(this.lastStage ? { lastStage: this.lastStage.name } : {}),
...(this.maxTraceTotalMs === undefined ? {} : { traceTotalMs: this.maxTraceTotalMs }),
...(this.slowestStage ? {
slowestStage: this.slowestStage.name,
slowestStageMs: this.slowestStage.durationMs,
} : {}),
};
}
}
export function classifyGatewayStderrMessage(message: string): GatewayStderrClassification {
const msg = message.trim();
if (!msg) {
return { level: 'drop', normalized: msg };
}
const plain = msg.replace(ANSI_ESCAPE_PATTERN, '');
// OpenClaw startup timing traces are expected diagnostics, not failures.
if (plain.includes('startup trace:')) {
return { level: 'info', normalized: msg };
}
// Known noisy lines that are not actionable for Gateway lifecycle debugging.
if (msg.includes('openclaw-control-ui') && msg.includes('token_mismatch')) {
if (plain.includes('openclaw-control-ui') && plain.includes('token_mismatch')) {
return { level: 'drop', normalized: msg };
}
if (msg.includes('closed before connect') && msg.includes('token mismatch')) {
if (plain.includes('closed before connect') && plain.includes('token mismatch')) {
return { level: 'drop', normalized: msg };
}
if (msg.includes('[ws] closed before connect') && msg.includes('code=1005')) {
if (plain.includes('[ws] closed before connect') && plain.includes('code=1005')) {
return { level: 'debug', normalized: msg };
}
if (msg.includes('security warning: dangerous config flags enabled')) {
if (
plain.includes('[ws] closed before connect')
&& plain.includes('code=1006')
&& plain.includes('phase=ws_upgrade_started')
&& plain.includes('ua=n/a')
) {
return { level: 'debug', normalized: msg };
}
if (plain.includes('security warning: dangerous config flags enabled')) {
return { level: 'debug', normalized: msg };
}
// Downgrade frequent non-fatal noise.
if (msg.includes('ExperimentalWarning')) return { level: 'debug', normalized: msg };
if (msg.includes('DeprecationWarning')) return { level: 'debug', normalized: msg };
if (msg.includes('Debugger attached')) return { level: 'debug', normalized: msg };
if (plain.includes('ExperimentalWarning')) return { level: 'debug', normalized: msg };
if (plain.includes('DeprecationWarning')) return { level: 'debug', normalized: msg };
if (plain.includes('Rename them by replacing the legacy prefix with OPENCLAW_')) {
return { level: 'debug', normalized: msg };
}
if (plain.includes('--trace-deprecation') && plain.includes('show where the warning was created')) {
return { level: 'debug', normalized: msg };
}
if (plain.includes('Debugger attached')) return { level: 'debug', normalized: msg };
// Gateway config warnings (e.g. stale plugin entries) are informational, not actionable.
if (msg.includes('Config warnings:')) return { level: 'debug', normalized: msg };
if (plain.includes('Config warnings:')) return { level: 'debug', normalized: msg };
// Electron restricts NODE_OPTIONS in packaged apps; this is expected and harmless.
if (msg.includes('node: --require is not allowed in NODE_OPTIONS')) {
if (plain.includes('node: --require is not allowed in NODE_OPTIONS')) {
return { level: 'debug', normalized: msg };
}
+10 -1
View File
@@ -9,6 +9,7 @@ const SECRET_KEYS = new Set([
'accesstoken',
'refreshtoken',
]);
const CONFIG_WRITE_METHODS = new Set(['config.set', 'config.patch', 'config.apply']);
export function isGatewayWsTraceEnabled(): boolean {
return process.env.CLAWX_GATEWAY_WS_TRACE === '1';
@@ -22,9 +23,17 @@ export function redactGatewayFrameForTrace(value: unknown): unknown {
return value;
}
const record = value as Record<string, unknown>;
const redactConfigRaw = typeof record.method === 'string' && CONFIG_WRITE_METHODS.has(record.method);
const result: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
for (const [key, item] of Object.entries(record)) {
const normalizedKey = key.toLowerCase();
if (redactConfigRaw && key === 'params' && item && typeof item === 'object' && !Array.isArray(item)) {
const params = redactGatewayFrameForTrace(item) as Record<string, unknown>;
if (Object.hasOwn(params, 'raw')) params.raw = '[redacted]';
result[key] = params;
continue;
}
result[key] = SECRET_KEYS.has(normalizedKey)
? '[redacted]'
: redactGatewayFrameForTrace(item);
+47 -34
View File
@@ -2,9 +2,10 @@
* Electron Main Process Entry
* Manages window creation, system tray, and IPC handlers
*/
import { app, BrowserWindow, nativeImage, session, shell } from 'electron';
import { app, BrowserWindow, nativeImage, session, shell, type Session } from 'electron';
import { join } from 'path';
import { GatewayManager } from '../gateway/manager';
import { registerOpenClawConfigCoordinator } from '../gateway/config-delivery';
import { registerIpcHandlers } from './ipc-handlers';
import { HostApiRegistry } from './ipc/host-invoke';
import { createTray } from './tray';
@@ -32,6 +33,8 @@ import { getMacTrafficLightPosition, syncMacTrafficLightPosition } from './traff
import { getSetting } from '../utils/store';
import { applyProxySettings } from './proxy';
import { syncLaunchAtStartupSettingFromStore } from './launch-at-startup';
import { WebBrowserGuestRegistry, installWebBrowserGuestPolicy } from './web-browser-policy';
import { configureWebBrowserSession } from './web-browser-session';
import {
clearPendingSecondInstanceFocus,
consumeMainWindowReady,
@@ -65,22 +68,6 @@ if (isE2EMode && requestedUserDataDir) {
app.setPath('userData', requestedUserDataDir);
}
// Disable GPU hardware acceleration globally for maximum stability across
// all GPU configurations (no GPU, integrated, discrete).
//
// Rationale (following VS Code's philosophy):
// - Page/file loading is async data fetching — zero GPU dependency.
// - The original per-platform GPU branching was added to avoid CPU rendering
// competing with sync I/O on Windows, but all file I/O is now async
// (fs/promises), so that concern no longer applies.
// - Software rendering is deterministic across all hardware; GPU compositing
// behaviour varies between vendors (Intel, AMD, NVIDIA, Apple Silicon) and
// driver versions, making it the #1 source of rendering bugs in Electron.
//
// Users who want GPU acceleration can pass `--enable-gpu` on the CLI or
// set `"disable-hardware-acceleration": false` in the app config (future).
app.disableHardwareAcceleration();
// On Linux, set CHROME_DESKTOP so Chromium can find the correct .desktop file.
// On Wayland this maps the running window to clawx.desktop (→ icon + app grouping);
// on X11 it supplements the StartupWMClass matching.
@@ -133,6 +120,8 @@ let mainWindow: BrowserWindow | null = null;
let gatewayManager!: GatewayManager;
let clawHubService!: ClawHubService;
const hostApiRegistry = new HostApiRegistry();
const webBrowserGuestRegistry = new WebBrowserGuestRegistry();
let webBrowserSession!: Session;
const mainWindowFocusState = createMainWindowFocusState();
const quitLifecycleState = createQuitLifecycleState();
@@ -176,8 +165,6 @@ function createWindow(): BrowserWindow {
const isMac = process.platform === 'darwin';
const isWindows = process.platform === 'win32';
const useCustomTitleBar = isWindows;
const shouldSkipSetupForE2E = process.env.CLAWX_E2E_SKIP_SETUP === '1';
const win = new BrowserWindow({
width: 1280,
height: 800,
@@ -199,6 +186,11 @@ function createWindow(): BrowserWindow {
show: false,
});
installWebBrowserGuestPolicy(win.webContents, {
browserSession: webBrowserSession,
registry: webBrowserGuestRegistry,
});
registerZoomShortcuts(win);
// Handle external links — only allow safe protocols to prevent arbitrary
@@ -217,7 +209,12 @@ function createWindow(): BrowserWindow {
return { action: 'deny' };
});
// Load the app
return win;
}
function loadMainWindow(win: BrowserWindow): void {
const shouldSkipSetupForE2E = process.env.CLAWX_E2E_SKIP_SETUP === '1';
if (process.env.VITE_DEV_SERVER_URL) {
const rendererUrl = new URL(process.env.VITE_DEV_SERVER_URL);
if (shouldSkipSetupForE2E) {
@@ -234,8 +231,6 @@ function createWindow(): BrowserWindow {
: undefined,
});
}
return win;
}
function focusWindow(win: BrowserWindow): void {
@@ -311,6 +306,11 @@ async function initialize(): Promise<void> {
`Runtime: platform=${process.platform}/${process.arch}, electron=${process.versions.electron}, node=${process.versions.node}, packaged=${app.isPackaged}, pid=${process.pid}, ppid=${process.ppid}`
);
webBrowserSession = configureWebBrowserSession({
registry: webBrowserGuestRegistry,
getMainWindow: () => mainWindow,
});
if (!isE2EMode) {
// Warm up network optimization (non-blocking)
void warmupNetworkOptimization();
@@ -331,11 +331,6 @@ async function initialize(): Promise<void> {
// Create the main window
const window = createMainWindow();
// Create system tray
if (!isE2EMode) {
createTray(window);
}
// Override security headers ONLY for the OpenClaw Gateway Control UI.
// The URL filter ensures this callback only fires for gateway requests,
// avoiding unnecessary overhead on every other HTTP response.
@@ -360,7 +355,21 @@ async function initialize(): Promise<void> {
);
// Register IPC handlers
registerIpcHandlers(gatewayManager, clawHubService, window, hostApiRegistry);
registerIpcHandlers(
gatewayManager,
clawHubService,
window,
hostApiRegistry,
webBrowserSession,
webBrowserGuestRegistry,
);
loadMainWindow(window);
// Create system tray
if (!isE2EMode) {
createTray(window);
}
// Initialize extension system
await extensionRegistry.initialize({
@@ -580,6 +589,7 @@ if (gotTheLock) {
}
gatewayManager = new GatewayManager();
registerOpenClawConfigCoordinator(gatewayManager);
clawHubService = new ClawHubService();
// Register builtin extensions and load manifest
@@ -607,16 +617,19 @@ if (gotTheLock) {
});
// Application lifecycle
app.whenReady().then(() => {
void initialize().catch((error) => {
app.whenReady().then(async () => {
try {
await initialize();
} catch (error) {
logger.error('Application initialization failed:', error);
});
return;
}
// Register activate handler AFTER app is ready to prevent
// "Cannot create BrowserWindow before app is ready" on macOS.
// Register only after initialization so activation cannot race the initial
// window or claim the single browser guest before host handlers are ready.
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createMainWindow();
loadMainWindow(createMainWindow());
} else {
focusMainWindow();
}
+42 -97
View File
@@ -2,7 +2,7 @@
* IPC Handlers
* Registers all IPC handlers for main-renderer communication
*/
import { ipcMain, BrowserWindow, shell, dialog, app } from 'electron';
import { ipcMain, BrowserWindow, shell, dialog, app, type Session } from 'electron';
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, extname, basename, resolve, sep, relative } from 'node:path';
@@ -25,8 +25,6 @@ import { resolveAgentIdFromChannel } from '../utils/agent-config';
import { resolveAccountIdFromSessionHistory } from '../utils/session-util';
import { whatsAppLoginManager } from '../utils/whatsapp-login';
import { getProviderConfig } from '../utils/provider-registry';
import { deviceOAuthManager } from '../utils/device-oauth';
import { browserOAuthManager } from '../utils/browser-oauth';
import { applyProxySettings } from './proxy';
import { syncLaunchAtStartupSettingFromStore } from './launch-at-startup';
import { getRecentTokenUsageHistory } from '../utils/token-usage';
@@ -42,7 +40,6 @@ import {
} from '../services/providers/provider-runtime-sync';
import { validateApiKeyWithProvider } from '../services/providers/provider-validation';
import { appUpdater } from './updater';
import { GatewayRpcBackpressure } from '../gateway/rpc-backpressure';
import { HostApiRegistry, registerHostInvokeHandler } from './ipc/host-invoke';
import { createAppApi } from '../services/app-api';
import { createOpenClawApi } from '../services/openclaw-api';
@@ -59,6 +56,7 @@ import { createAgentsApi } from '../services/agents-api';
import { createChatApi } from '../services/chat-api';
import { AcpSessionAccessRegistry } from '../services/acp-session-access-registry';
import { createAttachmentAccess, StagedAttachmentRegistry } from '../services/attachment-access';
import { createAttachmentOpenWithService } from '../services/attachment-open-with';
import { createCronApi } from '../services/cron-api';
import { createFilesApi } from '../services/files-api';
import { createMediaApi } from '../services/media-api';
@@ -66,6 +64,8 @@ import { createProvidersApi } from '../services/providers-api';
import { createSessionsApi } from '../services/sessions-api';
import { createSkillsApi } from '../services/skills-api';
import { createUsageApi } from '../services/usage-api';
import { createWebBrowserApi } from '../services/web-browser-api';
import type { WebBrowserGuestRegistry } from './web-browser-policy';
import {
isLaunchAtStartupKey,
isProxyKey,
@@ -75,8 +75,6 @@ import {
} from './ipc/request-helpers';
import { createMenu } from './menu';
const gatewayRpcBackpressure = new GatewayRpcBackpressure();
/**
* Register all IPC handlers
*/
@@ -85,12 +83,21 @@ export function registerIpcHandlers(
clawHubService: ClawHubService,
mainWindow: BrowserWindow,
hostApiRegistry: HostApiRegistry,
browserSession: Session,
registry: WebBrowserGuestRegistry,
): void {
// Unified request protocol (non-breaking: legacy channels remain available)
registerUnifiedRequestHandlers(gatewayManager);
// Typed host invoke handlers (new renderer facade; legacy channels remain available)
registerTypedHostHandlers(gatewayManager, clawHubService, mainWindow, hostApiRegistry);
registerTypedHostHandlers(
gatewayManager,
clawHubService,
mainWindow,
hostApiRegistry,
browserSession,
registry,
);
// Gateway handlers
registerGatewayHandlers(gatewayManager);
@@ -134,28 +141,37 @@ function registerTypedHostHandlers(
clawHubService: ClawHubService,
mainWindow: BrowserWindow,
hostApiRegistry: HostApiRegistry,
browserSession: Session,
registry: WebBrowserGuestRegistry,
): void {
const acpSessionAccessRegistry = new AcpSessionAccessRegistry();
const stagedAttachments = new StagedAttachmentRegistry();
const attachmentOpenWith = createAttachmentOpenWithService();
const attachmentAccess = createAttachmentAccess({
sessionAccessRegistry: acpSessionAccessRegistry,
stagedAttachments,
openWith: attachmentOpenWith,
});
hostApiRegistry.registerCoreServices({
app: createAppApi(),
openclaw: createOpenClawApi(),
shell: createShellApi(),
webBrowser: createWebBrowserApi({ browserSession, registry }),
dialog: createDialogApi(),
window: createWindowApi(mainWindow),
updates: createUpdatesApi(appUpdater),
uv: createUvApi(),
settings: createSettingsApi(gatewayManager),
gateway: createGatewayApi(gatewayManager, gatewayRpcBackpressure),
gateway: createGatewayApi(gatewayManager),
logs: createLogsApi(),
channels: createChannelsApi({ gatewayManager, mainWindow }),
agents: createAgentsApi({ gatewayManager }),
providers: createProvidersApi({ gatewayManager, mainWindow }),
files: createFilesApi({ attachmentAccess, stagedAttachments }),
files: createFilesApi({
attachmentAccess,
openWith: attachmentOpenWith,
stagedAttachments,
}),
media: createMediaApi({ attachmentAccess }),
sessions: createSessionsApi(),
chat: createChatApi({ gatewayManager, mainWindow, acpSessionAccessRegistry }),
@@ -277,11 +293,7 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
}
}
try {
await syncSavedProviderToRuntime(config, apiKey, gatewayManager);
} catch (err) {
console.warn('Failed to sync openclaw provider config:', err);
}
await syncSavedProviderToRuntime(config, apiKey, gatewayManager);
data = { success: true };
} catch (error) {
@@ -296,14 +308,10 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
try {
const existing = await providerService.getLegacyProvider(providerId);
await providerService.deleteLegacyProvider(providerId);
if (existing?.type) {
try {
await syncDeletedProviderToRuntime(existing, providerId, gatewayManager);
} catch (err) {
console.warn('Failed to completely remove provider from OpenClaw:', err);
}
await syncDeletedProviderToRuntime(existing, providerId, gatewayManager);
}
await providerService.deleteLegacyProvider(providerId);
data = { success: true };
} catch (error) {
data = { success: false, error: String(error) };
@@ -324,11 +332,7 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
const provider = await providerService.getLegacyProvider(providerId);
const providerType = provider?.type || providerId;
const ock = getOpenClawProviderKey(providerType, providerId);
try {
await saveProviderKeyToOpenClaw(ock, apiKey);
} catch (err) {
console.warn('Failed to save key to OpenClaw auth-profiles:', err);
}
await saveProviderKeyToOpenClaw(ock, apiKey);
data = { success: true };
} catch (error) {
data = { success: false, error: String(error) };
@@ -374,11 +378,7 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
}
}
try {
await syncUpdatedProviderToRuntime(nextConfig, apiKey, gatewayManager);
} catch (err) {
console.warn('Failed to sync openclaw config after provider update:', err);
}
await syncUpdatedProviderToRuntime(nextConfig, apiKey, gatewayManager);
data = { success: true };
} catch (error) {
@@ -408,12 +408,8 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
const provider = await providerService.getLegacyProvider(providerId);
const providerType = provider?.type || providerId;
const ock = getOpenClawProviderKey(providerType, providerId);
try {
if (ock) {
await removeProviderFromOpenClaw(ock);
}
} catch (err) {
console.warn('Failed to completely remove provider from OpenClaw:', err);
if (ock) {
await removeProviderFromOpenClaw(ock);
}
data = { success: true };
} catch (error) {
@@ -430,11 +426,7 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
await providerService.setDefaultLegacyProvider(providerId);
const provider = await providerService.getLegacyProvider(providerId);
if (provider) {
try {
await syncDefaultProviderToRuntime(providerId, gatewayManager);
} catch (err) {
console.warn('Failed to set OpenClaw default model:', err);
}
await syncDefaultProviderToRuntime(providerId, gatewayManager);
}
data = { success: true };
@@ -703,12 +695,7 @@ function registerGatewayHandlers(gatewayManager: GatewayManager): void {
// Gateway RPC call
ipcMain.handle('gateway:rpc', async (_, method: string, params?: unknown, timeoutMs?: number) => {
try {
const result = await gatewayRpcBackpressure.run(
method,
params,
timeoutMs,
(rpcMethod, rpcParams, rpcTimeoutMs) => gatewayManager.rpc(rpcMethod, rpcParams, rpcTimeoutMs),
);
const result = await gatewayManager.rpc(method, params, timeoutMs);
return { success: true, result };
} catch (error) {
logger.warn(`[gateway:rpc] ${method} failed (timeoutMs=${timeoutMs ?? 30000}): ${String(error)}`);
@@ -797,18 +784,6 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
);
};
// Listen for OAuth success to automatically restart the Gateway with new tokens/configs.
// Keep a longer debounce (8s) so provider config writes and OAuth token persistence
// can settle before applying the process-level refresh.
deviceOAuthManager.on('oauth:success', ({ provider, accountId }) => {
logger.info(`[IPC] Scheduling Gateway restart after ${provider} OAuth success for ${accountId}...`);
gatewayManager.debouncedRestart(8000);
});
browserOAuthManager.on('oauth:success', ({ provider, accountId }) => {
logger.info(`[IPC] Scheduling Gateway restart after ${provider} OAuth success for ${accountId}...`);
gatewayManager.debouncedRestart(8000);
});
// Get all providers with key info
ipcMain.handle('provider:list', async () => {
logLegacyProviderChannel('provider:list');
@@ -835,20 +810,12 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
await providerService.setLegacyProviderApiKey(config.id, trimmedKey);
// Also write to OpenClaw auth-profiles.json so the gateway can use it
try {
await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey);
} catch (err) {
console.warn('Failed to save key to OpenClaw auth-profiles:', err);
}
await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey);
}
}
// Sync the provider configuration to openclaw.json so Gateway knows about it
try {
await syncSavedProviderToRuntime(config, apiKey, gatewayManager);
} catch (err) {
console.warn('Failed to sync openclaw provider config:', err);
}
await syncSavedProviderToRuntime(config, apiKey, gatewayManager);
return { success: true };
} catch (error) {
@@ -861,16 +828,10 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
logLegacyProviderChannel('provider:delete');
try {
const existing = await providerService.getLegacyProvider(providerId);
await providerService.deleteLegacyProvider(providerId);
// Best-effort cleanup in OpenClaw auth profiles & openclaw.json config
if (existing?.type) {
try {
await syncDeletedProviderToRuntime(existing, providerId, gatewayManager);
} catch (err) {
console.warn('Failed to completely remove provider from OpenClaw:', err);
}
await syncDeletedProviderToRuntime(existing, providerId, gatewayManager);
}
await providerService.deleteLegacyProvider(providerId);
return { success: true };
} catch (error) {
@@ -887,11 +848,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
// Also write to OpenClaw auth-profiles.json
const provider = await providerService.getLegacyProvider(providerId);
const providerType = provider?.type || providerId;
try {
await syncProviderApiKeyToRuntime(providerType, providerId, apiKey);
} catch (err) {
console.warn('Failed to save key to OpenClaw auth-profiles:', err);
}
await syncProviderApiKeyToRuntime(providerType, providerId, apiKey);
return { success: true };
} catch (error) {
@@ -940,11 +897,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
}
// Sync the provider configuration to openclaw.json so Gateway knows about it
try {
await syncUpdatedProviderToRuntime(nextConfig, apiKey, gatewayManager);
} catch (err) {
console.warn('Failed to sync openclaw config after provider update:', err);
}
await syncUpdatedProviderToRuntime(nextConfig, apiKey, gatewayManager);
return { success: true };
} catch (error) {
@@ -975,11 +928,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
// Keep OpenClaw auth-profiles.json in sync with local key storage
const provider = await providerService.getLegacyProvider(providerId);
try {
await syncDeletedProviderApiKeyToRuntime(provider, providerId);
} catch (err) {
console.warn('Failed to completely remove provider from OpenClaw:', err);
}
await syncDeletedProviderApiKeyToRuntime(provider, providerId);
return { success: true };
} catch (error) {
@@ -1006,11 +955,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
await providerService.setDefaultLegacyProvider(providerId);
// Update OpenClaw config to use this provider's default model
try {
await syncDefaultProviderToRuntime(providerId, gatewayManager);
} catch (err) {
console.warn('Failed to set OpenClaw default model:', err);
}
await syncDefaultProviderToRuntime(providerId, gatewayManager);
return { success: true };
} catch (error) {
+252
View File
@@ -0,0 +1,252 @@
import type { Session, WebContents, WebPreferences } from 'electron';
import {
WEB_BROWSER_INITIAL_URL,
WEB_BROWSER_PARTITION,
WEB_BROWSER_USER_AGENT,
normalizeWebBrowserHtmlFileUrl,
} from '../../shared/web-browser';
import { logger } from '../utils/logger';
const DENY_WINDOW_OPEN = { action: 'deny' } as const;
const INERT_LINK_CSS = `
a, area {
color: inherit !important;
cursor: inherit !important;
pointer-events: none !important;
text-decoration: none !important;
}
`;
export class WebBrowserGuestRegistry {
private guest: WebContents | null = null;
private pendingAttachment = false;
beginAttachment(): boolean {
this.dropDestroyedGuest();
if (this.pendingAttachment || this.guest) {
return false;
}
this.pendingAttachment = true;
return true;
}
completeAttachment(guest: WebContents): void {
if (!this.pendingAttachment || this.guest) {
return;
}
this.pendingAttachment = false;
this.guest = guest;
guest.once('destroyed', () => {
if (this.guest === guest) {
this.guest = null;
}
});
}
cancelAttachment(): void {
this.pendingAttachment = false;
}
current(): WebContents | null {
this.dropDestroyedGuest();
return this.guest;
}
owns(contents: WebContents | null): boolean {
return contents !== null && this.current() === contents;
}
hasLiveGuest(): boolean {
return this.current() !== null;
}
private dropDestroyedGuest(): void {
if (this.guest?.isDestroyed()) {
this.guest = null;
}
}
}
export function isExpectedWebBrowserAttachment(
params: Record<string, unknown>,
): boolean {
return params.partition === WEB_BROWSER_PARTITION
&& params.src === WEB_BROWSER_INITIAL_URL
&& params.useragent === WEB_BROWSER_USER_AGENT
&& params.allowpopups !== true
&& params.preload === '';
}
export function hardenWebBrowserPreferences(preferences: WebPreferences): void {
delete preferences.preload;
preferences.nodeIntegration = false;
preferences.nodeIntegrationInSubFrames = false;
preferences.nodeIntegrationInWorker = false;
preferences.plugins = false;
preferences.allowRunningInsecureContent = false;
preferences.contextIsolation = true;
preferences.sandbox = true;
preferences.webSecurity = true;
}
export function installWebBrowserGuestPolicy(
embedder: WebContents,
options: {
browserSession: Session;
registry: WebBrowserGuestRegistry;
},
): () => void {
const { browserSession, registry } = options;
let attachmentPending = false;
let cleanupGuestPolicy: (() => void) | null = null;
const handleWillAttach = (
event: Electron.Event,
preferences: WebPreferences,
params: Record<string, unknown>,
): void => {
if (!isExpectedWebBrowserAttachment(params)) {
logger.warn('[WebBrowser] Rejected webview attachment with unexpected identity');
event.preventDefault();
return;
}
if (!registry.beginAttachment()) {
logger.warn('[WebBrowser] Rejected additional webview attachment');
event.preventDefault();
return;
}
attachmentPending = true;
hardenWebBrowserPreferences(preferences);
};
const handleDidAttach = (_event: Electron.Event, guest: WebContents): void => {
if (!attachmentPending) {
logger.warn('[WebBrowser] Ignored attached guest without a reserved slot');
return;
}
attachmentPending = false;
if (guest.getType() !== 'webview' || guest.session !== browserSession) {
logger.warn('[WebBrowser] Rejected attached guest with unexpected type or session');
registry.cancelAttachment();
return;
}
registry.completeAttachment(guest);
if (!registry.owns(guest)) {
logger.warn('[WebBrowser] Failed to register reserved guest');
return;
}
guest.setUserAgent(WEB_BROWSER_USER_AGENT);
let committedHtmlUrl = normalizeWebBrowserHtmlFileUrl(guest.getURL());
let restoringCommittedUrl = false;
const blockPageNavigation = (
details: Electron.Event<Electron.WebContentsWillFrameNavigateEventParams>,
): void => {
logger.warn(`[WebBrowser] Blocked guest navigation to ${details.url}`);
details.preventDefault();
};
const stopInvalidProgrammaticNavigation = (
details: Electron.Event<Electron.WebContentsDidStartNavigationEventParams>,
): void => {
if (!details.isMainFrame || normalizeWebBrowserHtmlFileUrl(details.url)) {
return;
}
logger.warn(`[WebBrowser] Stopped invalid programmatic navigation to ${details.url}`);
guest.stop();
};
const blockRedirect = (
details: Electron.Event<Electron.WebContentsWillRedirectEventParams>,
): void => {
logger.warn(`[WebBrowser] Blocked guest redirect to ${details.url}`);
details.preventDefault();
};
guest.setWindowOpenHandler(({ url }) => {
logger.warn(`[WebBrowser] Blocked popup target ${url}`);
return DENY_WINDOW_OPEN;
});
const makeLinksVisuallyInert = (): void => {
void guest.insertCSS(INERT_LINK_CSS, { cssOrigin: 'user' }).catch((error) => {
logger.warn('[WebBrowser] Failed to neutralize HTML links:', error);
});
};
const rememberCommittedHtml = (_event: Electron.Event, url: string): void => {
const normalizedUrl = normalizeWebBrowserHtmlFileUrl(url);
if (normalizedUrl) {
committedHtmlUrl = normalizedUrl;
}
restoringCommittedUrl = false;
};
const restoreAfterInPageNavigation = (
_event: Electron.Event,
url: string,
isMainFrame: boolean,
): void => {
if (!isMainFrame || !committedHtmlUrl || url === committedHtmlUrl || restoringCommittedUrl) {
return;
}
restoringCommittedUrl = true;
logger.warn(`[WebBrowser] Reverting blocked in-page navigation to ${url}`);
void guest.loadURL(committedHtmlUrl).catch((error) => {
restoringCommittedUrl = false;
logger.warn(`[WebBrowser] Failed to restore local HTML preview ${committedHtmlUrl}:`, error);
});
};
let cleaned = false;
const cleanup = (): void => {
if (cleaned) {
return;
}
cleaned = true;
guest.off('will-frame-navigate', blockPageNavigation);
guest.off('did-start-navigation', stopInvalidProgrammaticNavigation);
guest.off('will-redirect', blockRedirect);
guest.off('did-finish-load', makeLinksVisuallyInert);
guest.off('did-navigate', rememberCommittedHtml);
guest.off('did-navigate-in-page', restoreAfterInPageNavigation);
guest.off('destroyed', cleanup);
if (!guest.isDestroyed()) {
guest.setWindowOpenHandler(() => DENY_WINDOW_OPEN);
}
if (cleanupGuestPolicy === cleanup) {
cleanupGuestPolicy = null;
}
};
guest.on('will-frame-navigate', blockPageNavigation);
guest.on('did-start-navigation', stopInvalidProgrammaticNavigation);
guest.on('will-redirect', blockRedirect);
guest.on('did-finish-load', makeLinksVisuallyInert);
guest.on('did-navigate', rememberCommittedHtml);
guest.on('did-navigate-in-page', restoreAfterInPageNavigation);
guest.once('destroyed', cleanup);
cleanupGuestPolicy = cleanup;
};
embedder.on('will-attach-webview', handleWillAttach);
embedder.on('did-attach-webview', handleDidAttach);
return () => {
embedder.off('will-attach-webview', handleWillAttach);
embedder.off('did-attach-webview', handleDidAttach);
attachmentPending = false;
registry.cancelAttachment();
cleanupGuestPolicy?.();
};
}
+56
View File
@@ -0,0 +1,56 @@
import {
session,
type BrowserWindow,
type Session,
} from 'electron';
import {
WEB_BROWSER_PARTITION,
WEB_BROWSER_USER_AGENT,
normalizeWebBrowserHtmlFileUrl,
} from '@shared/web-browser';
import type { WebBrowserGuestRegistry } from './web-browser-policy';
const DOWNLOAD_OBSERVED_SESSIONS = new WeakSet<Session>();
export interface ConfigureWebBrowserSessionOptions {
registry: WebBrowserGuestRegistry;
getMainWindow: () => BrowserWindow | null;
getLanguage?: () => Promise<string | undefined>;
}
export function configureWebBrowserSession(
_options: ConfigureWebBrowserSessionOptions,
): Session {
const browserSession = session.fromPartition(WEB_BROWSER_PARTITION, { cache: true });
// Keep a deterministic identity even though this session may load only local HTML.
browserSession.setUserAgent(WEB_BROWSER_USER_AGENT);
browserSession.setPermissionCheckHandler(() => false);
browserSession.setPermissionRequestHandler((_contents, _permission, callback) => {
callback(false);
});
browserSession.setDevicePermissionHandler(() => false);
browserSession.setDisplayMediaRequestHandler((_request, callback) => {
callback({});
});
browserSession.webRequest.onBeforeRequest(
{ urls: ['file://*/*', 'http://*/*', 'https://*/*', 'ws://*/*', 'wss://*/*'] },
(details, callback) => {
const isNetworkRequest = /^(?:https?|wss?):/i.test(details.url);
const isInvalidMainDocument = details.resourceType === 'mainFrame'
&& normalizeWebBrowserHtmlFileUrl(details.url) === null;
callback({ cancel: isNetworkRequest || isInvalidMainDocument });
},
);
if (!DOWNLOAD_OBSERVED_SESSIONS.has(browserSession)) {
DOWNLOAD_OBSERVED_SESSIONS.add(browserSession);
browserSession.on('will-download', (event) => {
event.preventDefault();
});
}
return browserSession;
}
+7 -2
View File
@@ -393,11 +393,16 @@ export class AcpChatService {
this.historicalGeneration = null;
}
this.permissionsEnabled = true;
const messageId = payload.messageId ?? randomUUID();
const isSlashCommand = payload.message?.trimStart().startsWith('/') === true;
await connection.prompt({
sessionId: acpSessionId,
prompt,
messageId: payload.messageId ?? randomUUID(),
_meta: { sessionKey: payload.sessionKey, prefixCwd: true },
// ACP 1.1 removed messageId from the PromptRequest wire shape. Keep
// ClawX correlation metadata in the protocol extension envelope.
// OpenClaw must receive slash commands without its textual cwd prefix
// so the Gateway can classify and fold command replies into chat final.
_meta: { sessionKey: payload.sessionKey, prefixCwd: !isSlashCommand, messageId },
});
this.trace('session/prompt:success', {
sessionKey: payload.sessionKey,
+3 -33
View File
@@ -27,24 +27,7 @@ function requireString(payload: unknown, key: string): string {
return payload[key].trim();
}
function scheduleGatewayReload(ctx: AgentsApiContext, reason: string): void {
if (ctx.gatewayManager.getStatus().state !== 'stopped') {
ctx.gatewayManager.debouncedReload();
return;
}
void reason;
}
async function restartGatewayForAgentDeletion(ctx: AgentsApiContext): Promise<void> {
try {
await ctx.gatewayManager.restart();
console.log('[agents] Gateway restart completed after agent deletion');
} catch (err) {
console.warn('[agents] Gateway restart after agent deletion failed:', err);
}
}
export function createAgentsApi(ctx: AgentsApiContext): CompleteHostServiceRegistry['agents'] {
export function createAgentsApi(_ctx: AgentsApiContext): CompleteHostServiceRegistry['agents'] {
return {
list: async () => ({ success: true, ...(await listAgentsSnapshot()) }),
create: async (payload) => {
@@ -54,7 +37,6 @@ export function createAgentsApi(ctx: AgentsApiContext): CompleteHostServiceRegis
syncAllProviderAuthToRuntime().catch((err) => {
console.warn('[agents] Failed to sync provider auth after agent creation:', err);
});
scheduleGatewayReload(ctx, 'create-agent');
void ensureClawXContext({ waitForAllConfiguredWorkspaces: true }).catch((err) => {
console.warn('[agents] Failed to ensure ClawX context after agent creation:', err);
});
@@ -64,29 +46,19 @@ export function createAgentsApi(ctx: AgentsApiContext): CompleteHostServiceRegis
const agentId = requireString(payload, 'id');
const name = requireString(payload, 'name');
const snapshot = await updateAgentName(agentId, name);
scheduleGatewayReload(ctx, 'update-agent');
return { success: true, ...snapshot };
},
updateModel: async (payload) => {
const agentId = requireString(payload, 'id');
const modelRef = isRecord(payload) && typeof payload.modelRef === 'string' ? payload.modelRef : null;
const snapshot = await updateAgentModel(agentId, modelRef);
try {
await syncAllProviderAuthToRuntime();
await syncAgentModelOverrideToRuntime(agentId);
} catch (syncError) {
console.warn('[agents] Failed to sync runtime after updating agent model:', syncError);
}
// Agent model changes must be picked up by the running Gateway before
// the next send; otherwise the UI can show the new selection while the
// active runtime still answers with the previous model.
scheduleGatewayReload(ctx, 'update-agent-model');
await syncAllProviderAuthToRuntime();
await syncAgentModelOverrideToRuntime(agentId);
return { success: true, ...snapshot };
},
delete: async (payload) => {
const agentId = requireString(payload, 'id');
const { snapshot, removedEntry } = await deleteAgentConfig(agentId);
await restartGatewayForAgentDeletion(ctx);
await removeAgentWorkspaceDirectory(removedEntry).catch((err) => {
console.warn('[agents] Failed to remove workspace after agent deletion:', err);
});
@@ -96,7 +68,6 @@ export function createAgentsApi(ctx: AgentsApiContext): CompleteHostServiceRegis
const agentId = requireString(payload, 'id');
const channelType = requireString(payload, 'channelType');
const snapshot = await assignChannelToAgent(agentId, channelType);
scheduleGatewayReload(ctx, 'assign-channel');
return { success: true, ...snapshot };
},
removeChannel: async (payload) => {
@@ -122,7 +93,6 @@ export function createAgentsApi(ctx: AgentsApiContext): CompleteHostServiceRegis
await clearChannelBinding(channelType, accountId);
}
const snapshot = await listAgentsSnapshot();
scheduleGatewayReload(ctx, 'remove-agent-channel');
return { success: true, ...snapshot };
},
};
+111 -7
View File
@@ -18,8 +18,10 @@ import { fileURLToPath } from 'node:url';
import type {
AttachmentAccessError,
AttachmentFileRef,
AttachmentOpenHandlersResult,
AttachmentSourceRef,
OpenAttachmentResult,
OpenAttachmentWithPayload,
ReadAttachmentBinaryPayload,
ReadAttachmentBinaryResult,
ReadAttachmentTextResult,
@@ -32,6 +34,10 @@ import {
} from '@shared/file-preview/limits';
import type { AcpSessionAccessRegistry } from './acp-session-access-registry';
import { recordAttachmentOpenTrace } from './acp-trace';
import {
HANDLER_ID_MAX_LENGTH,
type AttachmentOpenWithService,
} from './attachment-open-with';
import {
expandPath,
resolveOpenClawConfigDir,
@@ -42,6 +48,7 @@ const MAX_REFERENCE_LENGTH = 4096;
const MAX_DISPLAY_NAME_LENGTH = 160;
const MAX_OUTGOING_RECORD_BYTES = 64 * 1024;
const SAFE_ATTACHMENT_ID = /^[A-Za-z0-9._-]+$/;
const DIRECTORY_MIME_TYPE = 'application/x-directory';
const EXT_MIME_MAP: Record<string, string> = {
'.bmp': 'image/bmp',
@@ -73,6 +80,7 @@ type AttachmentFs = {
type AttachmentShell = {
openPath: (path: string) => Promise<string>;
openExternal: (url: string) => Promise<void>;
showItemInFolder: (path: string) => void;
};
export type AttachmentAccess = {
@@ -80,6 +88,9 @@ export type AttachmentAccess = {
readAttachmentText: (ref: AttachmentFileRef) => Promise<ReadAttachmentTextResult>;
readAttachmentBinary: (payload: ReadAttachmentBinaryPayload) => Promise<ReadAttachmentBinaryResult>;
openAttachment: (ref: AttachmentSourceRef) => Promise<OpenAttachmentResult>;
listAttachmentOpenHandlers: (ref: AttachmentFileRef) => Promise<AttachmentOpenHandlersResult>;
openAttachmentWith: (payload: OpenAttachmentWithPayload) => Promise<OpenAttachmentResult>;
revealAttachment: (ref: AttachmentFileRef) => Promise<OpenAttachmentResult>;
};
type AttachmentAccessDependencies = {
@@ -89,12 +100,14 @@ type AttachmentAccessDependencies = {
configDir?: string;
fs?: AttachmentFs;
shell?: AttachmentShell;
openWith: AttachmentOpenWithService;
};
type LocalScope = 'workspace' | 'openclaw-media' | 'staging';
type ResolvedLocal = {
kind: 'local';
entryKind: 'file' | 'directory';
canonicalPath: string;
scope: LocalScope;
mimeType: string;
@@ -560,13 +573,21 @@ export function createAttachmentAccess(dependencies: AttachmentAccessDependencie
}
if (!isSamePath(canonicalCandidate, stagedPath)) throw new AttachmentFailure('invalidReference');
const stagedStat = await fs.stat(canonicalCandidate);
if (!stagedStat.isFile()) throw new AttachmentFailure('notFile');
const entryKind = stagedStat.isFile()
? 'file'
: stagedStat.isDirectory()
? 'directory'
: null;
if (!entryKind) throw new AttachmentFailure('notFile');
return {
kind: 'local',
entryKind,
canonicalPath: canonicalCandidate,
scope: 'staging',
mimeType: mimeTypeHint || mimeTypeForPath(canonicalCandidate),
size: stagedStat.size,
mimeType: entryKind === 'directory'
? DIRECTORY_MIME_TYPE
: mimeTypeHint || mimeTypeForPath(canonicalCandidate),
size: entryKind === 'directory' ? 0 : stagedStat.size,
};
}
}
@@ -578,7 +599,12 @@ export function createAttachmentAccess(dependencies: AttachmentAccessDependencie
throw new AttachmentFailure(attachmentFailure(error));
}
const targetStat = await fs.stat(canonicalCandidate);
if (!targetStat.isFile()) throw new AttachmentFailure('notFile');
const entryKind = targetStat.isFile()
? 'file'
: targetStat.isDirectory()
? 'directory'
: null;
if (!entryKind) throw new AttachmentFailure('notFile');
const workspaceRoot = mediaOnly ? null : await frozenCanonicalDirectory(context.workspaceRoot, fs);
const scope: LocalScope = workspaceRoot && isInside(canonicalCandidate, workspaceRoot)
@@ -587,10 +613,13 @@ export function createAttachmentAccess(dependencies: AttachmentAccessDependencie
return {
kind: 'local',
entryKind,
canonicalPath: canonicalCandidate,
scope,
mimeType: mimeTypeHint || mimeTypeForPath(canonicalCandidate),
size: targetStat.size,
mimeType: entryKind === 'directory'
? DIRECTORY_MIME_TYPE
: mimeTypeHint || mimeTypeForPath(canonicalCandidate),
size: entryKind === 'directory' ? 0 : targetStat.size,
};
};
@@ -613,6 +642,7 @@ export function createAttachmentAccess(dependencies: AttachmentAccessDependencie
if (!resolved) throw new AttachmentFailure('invalidReference');
return {
kind: 'local',
entryKind: 'file',
canonicalPath: resolved.path,
scope: 'openclaw-media',
mimeType: resolved.mimeType,
@@ -676,7 +706,7 @@ export function createAttachmentAccess(dependencies: AttachmentAccessDependencie
...(displayPath ? { displayPath } : {}),
mimeType: target.mimeType,
size: target.size,
target: { kind: 'local', scope: target.scope, ref },
target: { kind: 'local', scope: target.scope, entryKind: target.entryKind, ref },
};
} catch (error) {
return { ok: false, displayName, error: attachmentFailure(error) };
@@ -688,6 +718,7 @@ export function createAttachmentAccess(dependencies: AttachmentAccessDependencie
try {
const target = await resolveTarget(ref);
if (target.kind !== 'local') throw new AttachmentFailure('invalidReference');
if (target.entryKind !== 'file') throw new AttachmentFailure('notFile');
opened = await openRevalidatedLocal(target, await getFs());
if (!dependencies.sessionAccessRegistry.get(ref.sessionKey, ref.generation)) {
throw new AttachmentFailure('staleSession');
@@ -719,6 +750,7 @@ export function createAttachmentAccess(dependencies: AttachmentAccessDependencie
try {
const target = await resolveTarget(payload?.ref);
if (target.kind !== 'local') throw new AttachmentFailure('invalidReference');
if (target.entryKind !== 'file') throw new AttachmentFailure('notFile');
opened = await openRevalidatedLocal(target, await getFs());
if (!dependencies.sessionAccessRegistry.get(payload.ref.sessionKey, payload.ref.generation)) {
throw new AttachmentFailure('staleSession');
@@ -790,10 +822,82 @@ export function createAttachmentAccess(dependencies: AttachmentAccessDependencie
}
};
const requireCurrentLocalFileTarget = async (ref: AttachmentFileRef): Promise<ResolvedLocal> => {
const target = await resolveTarget(ref);
if (target.kind !== 'local') throw new AttachmentFailure('invalidReference');
if (target.entryKind !== 'file') throw new AttachmentFailure('notFile');
if (!dependencies.sessionAccessRegistry.get(ref.sessionKey, ref.generation)) {
throw new AttachmentFailure('staleSession');
}
return target;
};
const listAttachmentOpenHandlers = async (
ref: AttachmentFileRef,
): Promise<AttachmentOpenHandlersResult> => {
try {
const target = await requireCurrentLocalFileTarget(ref);
if (dependencies.openWith.platform === 'linux') {
return { ok: true, platform: 'linux', handlers: [] };
}
const handlers = await dependencies.openWith.list(target.canonicalPath);
if (!dependencies.sessionAccessRegistry.get(ref.sessionKey, ref.generation)) {
throw new AttachmentFailure('staleSession');
}
return {
ok: true,
platform: dependencies.openWith.platform,
handlers: handlers.map(({ id, name, iconDataUrl, isDefault }) => ({
handlerId: id,
name,
...(iconDataUrl ? { iconDataUrl } : {}),
isDefault,
})),
};
} catch (error) {
return { ok: false, error: attachmentFailure(error) };
}
};
const openAttachmentWith = async (
payload: OpenAttachmentWithPayload,
): Promise<OpenAttachmentResult> => {
try {
const target = await requireCurrentLocalFileTarget(payload?.ref);
if (typeof payload?.handlerId !== 'string'
|| !payload.handlerId.trim()
|| payload.handlerId.length > HANDLER_ID_MAX_LENGTH) {
throw new AttachmentFailure('invalidReference');
}
await dependencies.openWith.open(
target.canonicalPath,
payload.handlerId,
async () => (await requireCurrentLocalFileTarget(payload.ref)).canonicalPath,
);
return { ok: true };
} catch (error) {
return { ok: false, error: attachmentFailure(error) };
}
};
const revealAttachment = async (ref: AttachmentFileRef): Promise<OpenAttachmentResult> => {
try {
await requireCurrentLocalFileTarget(ref);
const revalidated = await requireCurrentLocalFileTarget(ref);
shell.showItemInFolder(revalidated.canonicalPath);
return { ok: true };
} catch (error) {
return { ok: false, error: attachmentFailure(error) };
}
};
return {
resolveAttachment,
readAttachmentText,
readAttachmentBinary,
openAttachment,
listAttachmentOpenHandlers,
openAttachmentWith,
revealAttachment,
};
}
+566
View File
@@ -0,0 +1,566 @@
import { app, type NativeImage } from 'electron';
import {
execFile as nodeExecFile,
spawn as nodeSpawn,
type ChildProcess,
type ChildProcessWithoutNullStreams,
type ExecFileException,
type ExecFileOptionsWithStringEncoding,
type SpawnOptionsWithoutStdio,
} from 'node:child_process';
import { createHash } from 'node:crypto';
import path from 'node:path';
export const PROCESS_TIMEOUT_MS = 5_000;
export const PROCESS_MAX_BUFFER_BYTES = 1_048_576;
export const HANDLER_NAME_MAX_LENGTH = 256;
export const HANDLER_ID_MAX_LENGTH = 512;
export const NATIVE_PATH_MAX_LENGTH = 4_096;
export const ICON_DATA_URL_MAX_BYTES = 65_536;
export const CACHE_TTL_MS = 300_000;
export const CACHE_MAX_ENTRIES = 128;
export type OpenWithPlatform = 'darwin' | 'win32' | 'linux';
export type SystemOpenHandler = {
id: string;
name: string;
iconDataUrl?: string;
isDefault: boolean;
};
export type AttachmentOpenWithService = {
platform: OpenWithPlatform;
list(filePath: string): Promise<SystemOpenHandler[]>;
open(
filePath: string,
handlerId: string,
revalidateFile: () => Promise<string>,
): Promise<void>;
};
type ExecFileDependency = (
file: string,
args: string[],
options: ExecFileOptionsWithStringEncoding,
callback: (error: ExecFileException | null, stdout: string, stderr: string) => void,
) => ChildProcess;
type SpawnDependency = (
command: string,
args: string[],
options: SpawnOptionsWithoutStdio,
) => ChildProcessWithoutNullStreams;
type IconImage = Pick<NativeImage, 'isEmpty' | 'toPNG'>;
export type AttachmentOpenWithDependencies = {
platform?: OpenWithPlatform;
clock?: () => number;
execFile?: ExecFileDependency;
spawn?: SpawnDependency;
loadIcon?: (filePath: string) => Promise<IconImage>;
resolveHelperPath?: () => string;
};
type NativeOpenHandler = SystemOpenHandler & {
nativeId: string;
applicationPath?: string;
iconSourcePath?: string;
};
type CacheEntry = {
createdAt: number;
handlers: NativeOpenHandler[];
};
const cacheSizeReaders = new WeakMap<AttachmentOpenWithService, () => number>();
/** @internal Test-only retention check; never exposes cache keys or values. */
export function getAttachmentOpenWithCacheSizeForTest(service: AttachmentOpenWithService): number {
return cacheSizeReaders.get(service)?.() ?? 0;
}
const WINDOWS_PUBLIC_ID = /^[a-f0-9]{64}$/;
const POWERSHELL_PREFIX_ARGS = [
'-NoLogo',
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-File',
];
const MACOS_JXA_PROGRAM = String.raw`
ObjC.import('Foundation');
ObjC.import('AppKit');
function stringValue(value) {
if (!value) return '';
try { return ObjC.unwrap(value); } catch (_) { return String(value); }
}
function iconPngBase64(workspace, bundlePath) {
try {
var image = workspace.iconForFile(bundlePath);
var targetSize = $.NSMakeSize(32, 32);
var resized = $.NSImage.alloc.initWithSize(targetSize);
resized.lockFocus;
image.drawInRectFromRectOperationFraction(
$.NSMakeRect(0, 0, 32, 32),
$.NSZeroRect,
$.NSCompositingOperationCopy,
1.0
);
resized.unlockFocus;
var bitmap = $.NSBitmapImageRep.imageRepWithData(resized.TIFFRepresentation);
if (!bitmap) return '';
var png = bitmap.representationUsingTypeProperties($.NSBitmapImageFileTypePNG, $({}));
if (!png || Number(png.length) === 0) return '';
return stringValue(png.base64EncodedStringWithOptions(0));
} catch (_) {
return '';
}
}
function run(argv) {
if (!argv || (argv.length !== 1 && argv.length !== 2)) return JSON.stringify([]);
var includeIcons = argv.length === 2 && argv[1] === 'icons';
if (argv.length === 2 && !includeIcons) return JSON.stringify([]);
var fileURL = $.NSURL.fileURLWithPath(argv[0]);
var workspace = $.NSWorkspace.sharedWorkspace;
var applicationURLs = workspace.URLsForApplicationsToOpenURL(fileURL);
var defaultURL = workspace.URLForApplicationToOpenURL(fileURL);
var records = [];
for (var index = 0; index < Number(applicationURLs.count); index += 1) {
var applicationURL = applicationURLs.objectAtIndex(index);
var bundle = $.NSBundle.bundleWithURL(applicationURL);
var bundleId = stringValue(bundle.bundleIdentifier);
var bundlePath = stringValue(applicationURL.path);
var name = stringValue($.NSFileManager.defaultManager.displayNameAtPath(bundlePath));
if (!bundleId || !name || !bundlePath) continue;
var record = {
nativeId: bundleId,
name: name,
applicationPath: bundlePath,
isDefault: Boolean(defaultURL && applicationURL.isEqual(defaultURL))
};
if (includeIcons) {
var icon = iconPngBase64(workspace, bundlePath);
if (icon) record.iconPngBase64 = icon;
}
records.push(record);
}
return JSON.stringify(records);
}
`;
function hasControlCharacters(value: string): boolean {
for (let index = 0; index < value.length; index += 1) {
const codeUnit = value.charCodeAt(index);
if (codeUnit <= 0x1f || (codeUnit >= 0x7f && codeUnit <= 0x9f)) return true;
}
return false;
}
function isBoundedString(value: unknown, maxLength: number): value is string {
return typeof value === 'string'
&& value.length > 0
&& value.length <= maxLength
&& !hasControlCharacters(value);
}
function isOptionalBoundedPath(value: unknown): value is string | undefined {
return value === undefined || isBoundedString(value, NATIVE_PATH_MAX_LENGTH);
}
function iconDataUrlFromBase64(value: unknown): string | undefined {
if (typeof value !== 'string' || value.length === 0) return undefined;
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
return undefined;
}
const iconDataUrl = `data:image/png;base64,${value}`;
if (Buffer.byteLength(iconDataUrl, 'utf8') > ICON_DATA_URL_MAX_BYTES) return undefined;
const png = Buffer.from(value, 'base64');
const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
if (png.length < pngSignature.length || !png.subarray(0, pngSignature.length).equals(pngSignature)) {
return undefined;
}
return iconDataUrl;
}
function associationKey(platform: OpenWithPlatform, filePath: string): string {
const pathApi = platform === 'win32' ? path.win32 : path.posix;
const basename = pathApi.basename(filePath).toLocaleLowerCase('en-US');
const extension = pathApi.extname(basename);
return extension || basename;
}
function processEnvironment(platform: OpenWithPlatform): NodeJS.ProcessEnv {
if (platform === 'win32') {
const systemRoot = process.env.SystemRoot || process.env.WINDIR || 'C:\\Windows';
const env: NodeJS.ProcessEnv = {
SystemRoot: systemRoot,
WINDIR: systemRoot,
PATH: [
path.win32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0'),
path.win32.join(systemRoot, 'System32'),
systemRoot,
].join(';'),
PATHEXT: '.COM;.EXE;.BAT;.CMD',
};
for (const key of ['TEMP', 'TMP', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA'] as const) {
if (process.env[key]) env[key] = process.env[key];
}
return env;
}
const env: NodeJS.ProcessEnv = { PATH: '/usr/bin:/bin:/usr/sbin:/sbin' };
for (const key of ['HOME', 'TMPDIR', 'USER', 'LOGNAME', 'LANG', 'LC_ALL'] as const) {
if (process.env[key]) env[key] = process.env[key];
}
return env;
}
function publicHandlers(handlers: NativeOpenHandler[]): SystemOpenHandler[] {
return handlers.map(({ id, name, iconDataUrl, isDefault }) => ({
id,
name,
...(iconDataUrl ? { iconDataUrl } : {}),
isDefault,
}));
}
function windowsPublicId(nativeId: string): string {
return createHash('sha256').update(`win32\0${nativeId}`, 'utf8').digest('hex');
}
function normalizeRecords(platform: OpenWithPlatform, output: string): NativeOpenHandler[] {
let parsed: unknown;
try {
parsed = JSON.parse(output.replace(/^\uFEFF/, '')) as unknown;
} catch {
return [];
}
if (!Array.isArray(parsed)) return [];
const deduplicated = new Map<string, NativeOpenHandler>();
for (const value of parsed) {
if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
const record = value as Record<string, unknown>;
if (!isBoundedString(record.nativeId, HANDLER_ID_MAX_LENGTH)) continue;
if (!isBoundedString(record.name, HANDLER_NAME_MAX_LENGTH)) continue;
if (typeof record.isDefault !== 'boolean') continue;
if (!isOptionalBoundedPath(record.applicationPath)) continue;
if (!isOptionalBoundedPath(record.iconSourcePath)) continue;
if (platform === 'darwin' && !isBoundedString(record.applicationPath, NATIVE_PATH_MAX_LENGTH)) {
continue;
}
const id = platform === 'win32' ? windowsPublicId(record.nativeId) : record.nativeId;
const iconDataUrl = platform === 'darwin'
? iconDataUrlFromBase64(record.iconPngBase64)
: undefined;
const existing = deduplicated.get(id);
if (existing) {
if (record.isDefault) existing.isDefault = true;
if (!existing.iconDataUrl && iconDataUrl) existing.iconDataUrl = iconDataUrl;
continue;
}
deduplicated.set(id, {
id,
nativeId: record.nativeId,
name: record.name,
...(record.applicationPath ? { applicationPath: record.applicationPath } : {}),
...(record.iconSourcePath ? { iconSourcePath: record.iconSourcePath } : {}),
...(iconDataUrl ? { iconDataUrl } : {}),
isDefault: record.isDefault,
});
}
const handlers = [...deduplicated.values()];
return [
...handlers.filter((handler) => handler.isDefault),
...handlers.filter((handler) => !handler.isDefault),
];
}
function defaultHelperPath(): string {
const root = app.isPackaged ? process.resourcesPath : app.getAppPath();
return path.join(root, 'resources', 'scripts', 'attachment-open-with.ps1');
}
function processError(reason: string): Error {
return new Error(`attachment-open-with:${reason}`);
}
export function createAttachmentOpenWithService(
dependencies: AttachmentOpenWithDependencies = {},
): AttachmentOpenWithService {
const platform = dependencies.platform ?? (
process.platform === 'darwin' || process.platform === 'win32' ? process.platform : 'linux'
);
const clock = dependencies.clock ?? Date.now;
const execFile = dependencies.execFile ?? (nodeExecFile as ExecFileDependency);
const spawn = dependencies.spawn ?? (nodeSpawn as SpawnDependency);
const loadIcon = dependencies.loadIcon ?? ((filePath: string) => app.getFileIcon(filePath, { size: 'normal' }));
const resolveHelperPath = dependencies.resolveHelperPath ?? defaultHelperPath;
const cache = new Map<string, CacheEntry>();
function runExec(command: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
execFile(command, args, {
timeout: PROCESS_TIMEOUT_MS,
maxBuffer: PROCESS_MAX_BUFFER_BYTES,
encoding: 'utf8',
windowsHide: true,
shell: false,
env: processEnvironment(platform),
}, (error, stdout) => {
if (error) {
reject(processError('process-failed'));
return;
}
resolve(stdout);
});
});
}
async function discover(filePath: string, includeIcons = false): Promise<NativeOpenHandler[]> {
try {
if (platform === 'darwin') {
const output = await runExec('/usr/bin/osascript', [
'-l',
'JavaScript',
'-e',
MACOS_JXA_PROGRAM,
'--',
filePath,
...(includeIcons ? ['icons'] : []),
]);
return normalizeRecords(platform, output);
}
if (platform === 'win32') {
const output = await runExec('powershell.exe', [
...POWERSHELL_PREFIX_ARGS,
resolveHelperPath(),
'list',
filePath,
]);
return normalizeRecords(platform, output);
}
return [];
} catch {
return [];
}
}
async function enrichIcons(handlers: NativeOpenHandler[]): Promise<NativeOpenHandler[]> {
if (platform === 'darwin') return handlers;
return await Promise.all(handlers.map(async (handler) => {
const iconPath = handler.iconSourcePath ?? handler.applicationPath;
if (!iconPath) return handler;
try {
const image = await loadIcon(iconPath);
if (image.isEmpty()) return handler;
const png = image.toPNG();
if (png.length === 0) return handler;
const iconDataUrl = `data:image/png;base64,${png.toString('base64')}`;
if (Buffer.byteLength(iconDataUrl, 'utf8') > ICON_DATA_URL_MAX_BYTES) return handler;
return { ...handler, iconDataUrl };
} catch {
return handler;
}
}));
}
async function list(filePath: string): Promise<SystemOpenHandler[]> {
const now = clock();
for (const [key, entry] of cache) {
if (now - entry.createdAt >= CACHE_TTL_MS) cache.delete(key);
}
if (platform === 'linux') return [];
if (!isBoundedString(filePath, NATIVE_PATH_MAX_LENGTH)) return [];
const cacheKey = `${platform}:${associationKey(platform, filePath)}`;
const cached = cache.get(cacheKey);
if (cached && now - cached.createdAt < CACHE_TTL_MS) {
return publicHandlers(cached.handlers);
}
const handlers = await enrichIcons(await discover(filePath, true));
cache.set(cacheKey, { createdAt: now, handlers });
while (cache.size > CACHE_MAX_ENTRIES) {
const oldestKey = cache.keys().next().value;
if (oldestKey === undefined) break;
cache.delete(oldestKey);
}
return publicHandlers(handlers);
}
async function openMac(
filePath: string,
handlerId: string,
revalidateFile: () => Promise<string>,
): Promise<void> {
const handlers = await discover(filePath);
const selected = handlers.find((handler) => handler.id === handlerId && handler.applicationPath);
if (!selected?.applicationPath) throw processError('unknown-handler');
const revalidatedPath = await revalidateFile();
if (!isBoundedString(revalidatedPath, NATIVE_PATH_MAX_LENGTH)) throw processError('invalid-path');
if (associationKey(platform, revalidatedPath) !== associationKey(platform, filePath)) {
throw processError('association-changed');
}
try {
await runExec('/usr/bin/open', ['-a', selected.applicationPath, revalidatedPath]);
} catch {
throw processError('invoke-failed');
}
}
function openWindows(
filePath: string,
handlerId: string,
revalidateFile: () => Promise<string>,
): Promise<void> {
if (!WINDOWS_PUBLIC_ID.test(handlerId)) return Promise.reject(processError('unknown-handler'));
return new Promise((resolve, reject) => {
let child: ChildProcessWithoutNullStreams;
try {
child = spawn('powershell.exe', [
...POWERSHELL_PREFIX_ARGS,
resolveHelperPath(),
'prepare-open',
filePath,
handlerId,
], {
windowsHide: true,
shell: false,
stdio: 'pipe',
env: processEnvironment(platform),
});
} catch {
reject(processError('helper-start-failed'));
return;
}
let settled = false;
let ready = false;
let invokeSent = false;
let outputBytes = 0;
let stdoutBuffer = '';
const finish = (error?: Error) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
if (error) reject(error);
else resolve();
};
const abort = (error: Error) => {
if (settled) return;
try {
child.kill();
} catch {
// The bounded rejection is sufficient if the process already exited.
}
finish(error);
};
const handleReady = async () => {
try {
const revalidatedPath = await revalidateFile();
if (settled) return;
if (!isBoundedString(revalidatedPath, NATIVE_PATH_MAX_LENGTH)) {
abort(processError('invalid-path'));
return;
}
if (associationKey(platform, revalidatedPath) !== associationKey(platform, filePath)) {
abort(processError('association-changed'));
return;
}
invokeSent = true;
child.stdin.end(`${JSON.stringify({ command: 'invoke', path: revalidatedPath })}\n`, 'utf8');
} catch (error) {
abort(error instanceof Error ? error : processError('revalidation-failed'));
}
};
const timeout = setTimeout(() => abort(processError('helper-timeout')), PROCESS_TIMEOUT_MS);
const accountOutput = (chunk: Buffer | string): boolean => {
outputBytes += Buffer.byteLength(chunk);
if (outputBytes > PROCESS_MAX_BUFFER_BYTES) {
abort(processError('helper-output-limit'));
return false;
}
return true;
};
child.stdout.on('data', (chunk: Buffer | string) => {
if (settled || !accountOutput(chunk)) return;
if (ready) {
abort(processError('helper-protocol'));
return;
}
stdoutBuffer += chunk.toString();
const newline = stdoutBuffer.indexOf('\n');
if (newline < 0) return;
const line = stdoutBuffer.slice(0, newline).replace(/\r$/, '');
const remainder = stdoutBuffer.slice(newline + 1);
let message: unknown;
try {
message = JSON.parse(line) as unknown;
} catch {
abort(processError('helper-protocol'));
return;
}
if (!message || typeof message !== 'object' || Array.isArray(message)) {
abort(processError('helper-protocol'));
return;
}
const record = message as Record<string, unknown>;
if (record.ready !== true || Object.keys(record).length !== 1 || remainder.trim()) {
abort(processError('helper-protocol'));
return;
}
ready = true;
stdoutBuffer = '';
void handleReady();
});
child.stderr.on('data', (chunk: Buffer | string) => {
if (!settled) accountOutput(chunk);
});
child.stdin.on('error', () => abort(processError('helper-stdin')));
child.on('error', () => abort(processError('helper-start-failed')));
child.on('close', (code) => {
if (settled) return;
if (code === 0 && invokeSent) {
finish();
return;
}
finish(processError(ready ? 'helper-failed' : 'unknown-handler'));
});
});
}
async function open(
filePath: string,
handlerId: string,
revalidateFile: () => Promise<string>,
): Promise<void> {
if (!isBoundedString(filePath, NATIVE_PATH_MAX_LENGTH)) throw processError('invalid-path');
if (!isBoundedString(handlerId, HANDLER_ID_MAX_LENGTH)) throw processError('unknown-handler');
if (platform === 'linux') throw processError('unsupported-platform');
if (platform === 'darwin') return await openMac(filePath, handlerId, revalidateFile);
return await openWindows(filePath, handlerId, revalidateFile);
}
const service = { platform, list, open };
cacheSizeReaders.set(service, () => cache.size);
return service;
}
+11 -93
View File
@@ -22,6 +22,7 @@ import {
assignChannelAccountToAgent,
clearAllBindingsForChannel,
clearChannelBinding,
ensureScopedChannelBinding as ensureAgentScopedChannelBinding,
listAgentsSnapshot,
listAgentsSnapshotFromConfig,
} from '../utils/agent-config';
@@ -153,11 +154,6 @@ const CHANNEL_TARGET_CACHE_TTL_MS = 60_000;
const CHANNEL_TARGET_CACHE_ENABLED = process.env.VITEST !== 'true';
const channelTargetCache = new Map<string, { expiresAt: number; targets: ChannelTargetOptionView[] }>();
const FORCE_RESTART_CHANNELS = new Set([
'dingtalk', 'wecom', 'whatsapp', 'feishu', 'qqbot', OPENCLAW_WECHAT_CHANNEL_TYPE,
'discord', 'telegram', 'signal', 'imessage', 'matrix', 'line', 'msteams', 'googlechat', 'mattermost',
]);
function requireString(payload: unknown, key: string): string {
if (!isRecord(payload) || typeof payload[key] !== 'string' || !payload[key].trim()) {
throw new Error(`${key} is required`);
@@ -924,83 +920,8 @@ async function listChannelTargetOptions(params: {
return targets;
}
async function readChannelBindingOwner(channelType: string, accountId?: string): Promise<string | null> {
const config = await readOpenClawConfig();
const bindings = Array.isArray((config as { bindings?: unknown }).bindings)
? (config as { bindings: unknown[] }).bindings
: [];
for (const binding of bindings) {
if (!binding || typeof binding !== 'object') continue;
const candidate = binding as {
agentId?: unknown;
match?: { channel?: unknown; accountId?: unknown } | unknown;
};
if (typeof candidate.agentId !== 'string' || !candidate.agentId.trim()) continue;
if (!candidate.match || typeof candidate.match !== 'object' || Array.isArray(candidate.match)) continue;
const match = candidate.match as { channel?: unknown; accountId?: unknown };
if (match.channel !== channelType) continue;
const bindingAccountId = typeof match.accountId === 'string' ? match.accountId.trim() : '';
if ((accountId?.trim() || '') !== bindingAccountId) continue;
return candidate.agentId;
}
return null;
}
async function migrateLegacyChannelWideBinding(channelType: string): Promise<void> {
const explicitDefaultOwner = await readChannelBindingOwner(channelType, 'default');
const legacyOwner = await readChannelBindingOwner(channelType);
if (!legacyOwner) return;
const agents = await listAgentsSnapshot();
const validAgentIds = new Set(agents.agents.map((agent) => agent.id));
const defaultOwner = explicitDefaultOwner && validAgentIds.has(explicitDefaultOwner)
? explicitDefaultOwner
: (legacyOwner && validAgentIds.has(legacyOwner) ? legacyOwner : null);
if (defaultOwner) {
await assignChannelAccountToAgent(defaultOwner, channelType, 'default');
}
await clearChannelBinding(channelType);
}
async function ensureScopedChannelBinding(channelType: string, accountId?: string): Promise<void> {
const storedChannelType = resolveStoredChannelType(channelType);
if (!accountId) return;
const agents = await listAgentsSnapshot();
if (!agents.agents || agents.agents.length === 0) return;
if (accountId === 'default') {
if (agents.agents.some((entry) => entry.id === 'main')) {
await assignChannelAccountToAgent('main', storedChannelType, 'default');
}
return;
}
if (agents.agents.some((entry) => entry.id === accountId)) {
await migrateLegacyChannelWideBinding(storedChannelType);
await assignChannelAccountToAgent(accountId, storedChannelType, accountId);
return;
}
await migrateLegacyChannelWideBinding(storedChannelType);
}
function scheduleGatewayChannelRestart(ctx: ChannelsApiContext, reason: string): void {
if (ctx.gatewayManager.getStatus().state === 'stopped') return;
ctx.gatewayManager.debouncedRestart();
void reason;
}
function scheduleGatewayChannelSaveRefresh(ctx: ChannelsApiContext, channelType: string, reason: string): void {
const storedChannelType = resolveStoredChannelType(channelType);
if (ctx.gatewayManager.getStatus().state === 'stopped') return;
if (FORCE_RESTART_CHANNELS.has(storedChannelType)) {
ctx.gatewayManager.debouncedRestart(150);
void reason;
return;
}
ctx.gatewayManager.debouncedReload(150);
void reason;
await ensureAgentScopedChannelBinding(resolveStoredChannelType(channelType), accountId);
}
function toComparableConfig(input: Record<string, unknown>): Record<string, string> {
@@ -1072,7 +993,6 @@ async function awaitWeChatQrLogin(
});
await saveChannelConfig(UI_WECHAT_CHANNEL_TYPE, { enabled: true }, normalizedAccountId);
await ensureScopedChannelBinding(UI_WECHAT_CHANNEL_TYPE, normalizedAccountId);
scheduleGatewayChannelSaveRefresh(ctx, OPENCLAW_WECHAT_CHANNEL_TYPE, `wechat:loginSuccess:${normalizedAccountId}`);
if (activeQrLogins.get(loginKey) !== sessionKey) return;
emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'success', {
@@ -1135,7 +1055,6 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
const accountId = requireString(payload, 'accountId');
await validateCanonicalAccountId(channelType, accountId, { allowLegacyConfiguredId: true });
await setChannelDefaultAccount(channelType, accountId);
scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:setDefaultAccount:${channelType}`);
return { success: true };
},
bindingSave: async (payload) => {
@@ -1148,11 +1067,16 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
throw new Error(`Agent "${agentId}" not found`);
}
const storedChannelType = resolveStoredChannelType(channelType);
if (accountId !== 'default') {
await migrateLegacyChannelWideBinding(storedChannelType);
if (accountId === 'default') {
await assignChannelAccountToAgent(agentId, storedChannelType, accountId);
} else {
await assignChannelAccountToAgent(
agentId,
storedChannelType,
accountId,
{ migrateLegacy: true },
);
}
await assignChannelAccountToAgent(agentId, storedChannelType, accountId);
scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:setBinding:${channelType}`);
return { success: true };
},
bindingDelete: async (payload) => {
@@ -1160,7 +1084,6 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
const accountId = optionalString(payload, 'accountId');
await validateCanonicalAccountId(channelType, accountId, { allowLegacyConfiguredId: true });
await clearChannelBinding(resolveStoredChannelType(channelType), accountId);
scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:clearBinding:${channelType}`);
return { success: true };
},
validateConfig: async (payload) => {
@@ -1182,19 +1105,16 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
const existingValues = await getChannelFormValues(channelType, accountId);
if (isSameConfigValues(existingValues, config)) {
await ensureScopedChannelBinding(channelType, accountId);
scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:saveConfigNoChange:${storedChannelType}`);
return { success: true, noChange: true };
}
await saveChannelConfig(channelType, config, accountId);
await ensureScopedChannelBinding(channelType, accountId);
scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:saveConfig:${storedChannelType}`);
return { success: true };
},
setEnabled: async (payload) => {
const channelType = requireString(payload, 'channelType');
const enabled = isRecord(payload) && payload.enabled === true;
await setChannelEnabled(channelType, enabled);
scheduleGatewayChannelRestart(ctx, `channel:setEnabled:${resolveStoredChannelType(channelType)}`);
return { success: true };
},
formValues: async (payload) => {
@@ -1209,11 +1129,9 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
if (accountId) {
await deleteChannelAccountConfig(channelType, accountId);
await clearChannelBinding(storedChannelType, accountId);
scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:deleteAccount:${storedChannelType}`);
} else {
await deleteChannelConfig(channelType);
await clearAllBindingsForChannel(storedChannelType);
scheduleGatewayChannelRestart(ctx, `channel:deleteConfig:${storedChannelType}`);
}
return { success: true };
},
-106
View File
@@ -1,45 +1,8 @@
import type { BrowserWindow } from 'electron';
import type { GatewayManager } from '../gateway/manager';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import { logger } from '../utils/logger';
import { createAcpChatService } from './acp-chat-service';
import type { AcpSessionAccessRegistry } from './acp-session-access-registry';
import { isRecord } from './payload-utils';
const VISION_MIME_TYPES = new Set([
'image/png',
'image/jpeg',
'image/bmp',
'image/webp',
]);
type ChatSendWithMediaPayload = {
sessionKey?: unknown;
message?: unknown;
deliver?: unknown;
idempotencyKey?: unknown;
media?: unknown;
};
type MediaPayload = {
filePath?: unknown;
mimeType?: unknown;
fileName?: unknown;
};
function normalizeMedia(media: unknown): Array<{ filePath: string; mimeType: string; fileName: string }> {
if (!Array.isArray(media)) return [];
return media.flatMap((entry): Array<{ filePath: string; mimeType: string; fileName: string }> => {
if (!isRecord(entry)) return [];
const item = entry as MediaPayload;
if (typeof item.filePath !== 'string' || !item.filePath) return [];
return [{
filePath: item.filePath,
mimeType: typeof item.mimeType === 'string' && item.mimeType ? item.mimeType : 'application/octet-stream',
fileName: typeof item.fileName === 'string' && item.fileName ? item.fileName : item.filePath.split(/[\\/]/).pop() || 'file',
}];
});
}
export function createChatApi({
gatewayManager,
@@ -53,75 +16,6 @@ export function createChatApi({
const acpChat = createAcpChatService(mainWindow, acpSessionAccessRegistry, gatewayManager);
return {
sendWithMedia: async (payload) => {
const body = isRecord(payload) ? payload as ChatSendWithMediaPayload : {};
const sessionKey = typeof body.sessionKey === 'string' ? body.sessionKey : '';
const idempotencyKey = typeof body.idempotencyKey === 'string' ? body.idempotencyKey : '';
if (!sessionKey || !idempotencyKey) {
return { success: false, error: 'Invalid chat send payload' };
}
try {
let message = typeof body.message === 'string' ? body.message : '';
const imageAttachments: Array<Record<string, unknown>> = [];
const fileReferences: string[] = [];
const media = normalizeMedia(body.media);
if (media.length > 0) {
const fsP = await import('node:fs/promises');
for (const item of media) {
const exists = await fsP.access(item.filePath).then(() => true, () => false);
logger.info(
`[chat:sendWithMedia] Processing media: name=${item.fileName}, mimeType=${item.mimeType}, exists=${exists}, isVision=${VISION_MIME_TYPES.has(item.mimeType)}`,
);
fileReferences.push(
`[media attached: ${item.filePath} (${item.mimeType}) | ${item.filePath}]`,
);
if (VISION_MIME_TYPES.has(item.mimeType)) {
const fileBuffer = await fsP.readFile(item.filePath);
const base64Data = fileBuffer.toString('base64');
logger.info(`[chat:sendWithMedia] Read ${fileBuffer.length} bytes, base64 length: ${base64Data.length}`);
imageAttachments.push({
content: base64Data,
mimeType: item.mimeType,
fileName: item.fileName,
});
}
}
}
if (fileReferences.length > 0) {
const refs = fileReferences.join('\n');
message = message ? `${message}\n\n${refs}` : refs;
}
const rpcParams: Record<string, unknown> = {
sessionKey,
message,
deliver: body.deliver ?? false,
idempotencyKey,
};
if (imageAttachments.length > 0) {
rpcParams.attachments = imageAttachments;
}
logger.info(
`[chat:sendWithMedia] Sending: messageLength=${message.length}, attachments=${imageAttachments.length}, fileRefs=${fileReferences.length}`,
);
const result = await gatewayManager.rpc('chat.send', rpcParams, 120000);
const hasRunId = isRecord(result) && typeof result.runId === 'string';
logger.info(`[chat:sendWithMedia] RPC result: runId=${hasRunId ? 'present' : 'absent'}`);
const response = hasRunId
? { runId: result.runId as string }
: undefined;
return { success: true, ...(response ? { result: response } : {}) };
} catch (error) {
logger.error(`[chat:sendWithMedia] Error: ${String(error)}`);
return { success: false, error: String(error) };
}
},
loadAcpSession: (payload) => acpChat.loadSession(payload),
sendAcpPrompt: (payload) => acpChat.sendPrompt(payload),
cancelAcpSession: (payload) => acpChat.cancelSession(payload),
+103 -19
View File
@@ -1,12 +1,14 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import type { RawMessage } from '@shared/chat/types';
import type { CronJob, CronJobDelivery, CronSchedule } from '@shared/types/cron';
import type { GatewayManager } from '../gateway/manager';
import { getOpenClawConfigDir } from '../utils/paths';
import { resolveAgentIdFromChannel } from '../utils/agent-config';
import { toOpenClawChannelType, toUiChannelType } from '../utils/channel-alias';
import { resolveAccountIdFromSessionHistory } from '../utils/session-util';
import { loadSessionTranscriptByKey } from './sessions-api';
import { isRecord } from './payload-utils';
interface GatewayCronJob {
@@ -53,13 +55,14 @@ interface CronSessionKeyParts {
interface CronSessionFallbackMessage {
id: string;
role: 'assistant' | 'system';
role: 'user' | 'assistant';
content: string;
timestamp: number;
isError?: boolean;
}
type JsonRecord = Record<string, unknown>;
const OPENCLAW_CRON_SUMMARY_TRUNCATION_MIN_CHARS = 2_000;
function parseCronSessionKey(sessionKey: string): CronSessionKeyParts | null {
if (!sessionKey.startsWith('agent:')) return null;
@@ -93,14 +96,83 @@ function formatDuration(durationMs: number | undefined): string | null {
return `${Math.round(durationMs / 1000)}s`;
}
function buildCronRunMessage(entry: CronRunLogEntry, index: number): CronSessionFallbackMessage | null {
function getMessageText(content: RawMessage['content']): string {
if (typeof content === 'string') return content.trim();
if (!Array.isArray(content)) return '';
return content
.map((block) => {
if (!block || typeof block !== 'object') return '';
const value = block as { type?: unknown; text?: unknown };
return value.type === 'text' && typeof value.text === 'string' ? value.text : '';
})
.filter(Boolean)
.join('\n')
.trim();
}
function getFinalAssistantReply(messages: RawMessage[]): string {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message?.role !== 'assistant') continue;
const text = getMessageText(message.content);
if (text) return text;
}
return '';
}
function isBoundedCronSummary(summary: string): boolean {
return summary.length >= OPENCLAW_CRON_SUMMARY_TRUNCATION_MIN_CHARS
&& summary.endsWith('…');
}
function resolveCronRunSessionKey(
parsed: CronSessionKeyParts,
entry: CronRunLogEntry,
): string | null {
const explicitSessionKey = typeof entry.sessionKey === 'string' ? entry.sessionKey.trim() : '';
if (explicitSessionKey && parseCronSessionKey(explicitSessionKey)?.runSessionId) {
return explicitSessionKey;
}
const sessionId = typeof entry.sessionId === 'string' ? entry.sessionId.trim() : '';
if (!sessionId) return null;
return `agent:${parsed.agentId}:cron:${parsed.jobId}:run:${sessionId}`;
}
async function loadFullCronRunReplies(
parsed: CronSessionKeyParts,
runs: CronRunLogEntry[],
): Promise<Map<CronRunLogEntry, string>> {
const replies = new Map<CronRunLogEntry, string>();
await Promise.all(runs.map(async (entry) => {
const summary = typeof entry.summary === 'string' ? entry.summary.trim() : '';
if (!isBoundedCronSummary(summary)) return;
const runSessionKey = resolveCronRunSessionKey(parsed, entry);
if (!runSessionKey) return;
const transcript = await loadSessionTranscriptByKey(runSessionKey, 1_000);
if (!transcript?.length) return;
const fullReply = getFinalAssistantReply(transcript);
const summaryPrefix = summary.slice(0, -1);
if (fullReply.length > summaryPrefix.length && fullReply.startsWith(summaryPrefix)) {
replies.set(entry, fullReply);
}
}));
return replies;
}
function buildCronRunMessage(
entry: CronRunLogEntry,
index: number,
fullReply?: string,
): CronSessionFallbackMessage | null {
const timestamp = normalizeTimestampMs(entry.ts) ?? normalizeTimestampMs(entry.runAtMs);
if (!timestamp) return null;
const status = typeof entry.status === 'string' ? entry.status.toLowerCase() : '';
const summary = typeof entry.summary === 'string' ? entry.summary.trim() : '';
const error = typeof entry.error === 'string' ? entry.error.trim() : '';
let content = summary || error;
let content = fullReply?.trim() || summary || error;
if (!content) {
content = status === 'error' ? 'Scheduled task failed.' : 'Scheduled task completed.';
}
@@ -117,14 +189,14 @@ function buildCronRunMessage(entry: CronRunLogEntry, index: number): CronSession
return {
id: `cron-run-${entry.sessionId ?? entry.ts ?? index}`,
role: status === 'error' ? 'system' : 'assistant',
role: 'assistant',
content,
timestamp,
...(status === 'error' ? { isError: true } : {}),
};
}
async function readCronRunLog(jobId: string): Promise<CronRunLogEntry[]> {
async function readLegacyCronRunLog(jobId: string): Promise<CronRunLogEntry[]> {
const logPath = join(getOpenClawConfigDir(), 'cron', 'runs', `${jobId}.jsonl`);
const raw = await readFile(logPath, 'utf8').catch(() => '');
if (!raw.trim()) return [];
@@ -145,6 +217,24 @@ async function readCronRunLog(jobId: string): Promise<CronRunLogEntry[]> {
return entries;
}
async function readCronRunHistory(
gatewayManager: GatewayManager,
jobId: string,
limit: number,
): Promise<CronRunLogEntry[]> {
try {
const result = await gatewayManager.rpc<{ entries?: CronRunLogEntry[] }>('cron.runs', {
id: jobId,
limit,
sortDir: 'asc',
}, 8000);
if (Array.isArray(result?.entries)) return result.entries;
} catch {
// OpenClaw versions before SQLite cron history may not expose cron.runs.
}
return readLegacyCronRunLog(jobId);
}
async function readSessionStoreEntry(
agentId: string,
sessionKey: string,
@@ -177,6 +267,7 @@ function buildCronSessionFallbackMessages(params: {
sessionKey: string;
job?: Pick<GatewayCronJob, 'name' | 'payload' | 'state'>;
runs: CronRunLogEntry[];
fullReplies?: Map<CronRunLogEntry, string>;
sessionEntry?: { label?: string; updatedAt?: number };
limit?: number;
}): CronSessionFallbackMessage[] {
@@ -204,18 +295,16 @@ function buildCronSessionFallbackMessages(params: {
: (normalizeTimestampMs(params.job?.state?.runningAtMs) ?? params.sessionEntry?.updatedAt);
if (taskName || prompt) {
const lines = [taskName ? `Scheduled task: ${taskName}` : 'Scheduled task'];
if (prompt) lines.push(`Prompt: ${prompt}`);
messages.push({
id: `cron-meta-${parsed.jobId}`,
role: 'system',
content: lines.join('\n'),
role: 'user',
content: prompt || taskName,
timestamp: Math.max(0, (firstRelevantTimestamp ?? Date.now()) - 1),
});
}
matchingRuns.forEach((entry, index) => {
const message = buildCronRunMessage(entry, index);
const message = buildCronRunMessage(entry, index, params.fullReplies?.get(entry));
if (message) messages.push(message);
});
@@ -224,17 +313,10 @@ function buildCronSessionFallbackMessages(params: {
if (runningAt) {
messages.push({
id: `cron-running-${parsed.jobId}`,
role: 'system',
role: 'assistant',
content: 'This scheduled task is still running in OpenClaw, but no chat transcript is available yet.',
timestamp: runningAt,
});
} else if (messages.length === 0) {
messages.push({
id: `cron-empty-${parsed.jobId}`,
role: 'system',
content: 'No chat transcript is available for this scheduled task yet.',
timestamp: params.sessionEntry?.updatedAt ?? Date.now(),
});
}
}
@@ -569,16 +651,18 @@ export function createCronApi({ gatewayManager }: { gatewayManager: GatewayManag
const [jobsResult, runs, sessionEntry] = await Promise.all([
gatewayManager.rpc('cron.list', { includeDisabled: true }, 8000)
.catch(() => ({ jobs: [] as GatewayCronJob[] })),
readCronRunLog(parsedSession.jobId),
readCronRunHistory(gatewayManager, parsedSession.jobId, limit),
readSessionStoreEntry(parsedSession.agentId, sessionKey),
]);
const jobs = (jobsResult as { jobs?: GatewayCronJob[] }).jobs ?? [];
const job = jobs.find((item) => item.id === parsedSession.jobId);
const fullReplies = await loadFullCronRunReplies(parsedSession, runs);
return {
messages: buildCronSessionFallbackMessages({
sessionKey,
job,
runs,
fullReplies,
sessionEntry: sessionEntry ? {
label: typeof sessionEntry.label === 'string' ? sessionEntry.label : undefined,
updatedAt: normalizeTimestampMs(sessionEntry.updatedAt),
+103 -2
View File
@@ -1,4 +1,4 @@
import { app, nativeImage } from 'electron';
import { app, nativeImage, shell } from 'electron';
import crypto from 'node:crypto';
import { constants } from 'node:fs';
import type { Stats } from 'node:fs';
@@ -21,6 +21,7 @@ import type {
FilePreviewTreeNode,
FilePreviewTreeOptions,
FileReadBinaryOptions,
WorkspaceNativeFileError,
WorkspaceFileRef,
} from '@shared/host-api/contract';
import {
@@ -34,6 +35,10 @@ import {
type AttachmentAccess,
type StagedAttachmentRegistry,
} from './attachment-access';
import {
HANDLER_ID_MAX_LENGTH,
type AttachmentOpenWithService,
} from './attachment-open-with';
import { isRecord } from './payload-utils';
const EXT_MIME_MAP: Record<string, string> = {
@@ -134,6 +139,7 @@ type WorkspaceFs = {
type FilesApiDependencies = {
workspaceFs?: WorkspaceFs;
attachmentAccess?: AttachmentAccess;
openWith?: AttachmentOpenWithService;
stagedAttachments?: StagedAttachmentRegistry;
stagingHooks?: {
beforeDestinationOpen?: (input: { stagingDir: string; destinationPath: string }) => Promise<void>;
@@ -236,6 +242,17 @@ function workspaceError(error: unknown): FilePreviewError {
return 'operationFailed';
}
function workspaceNativeError(error: unknown): WorkspaceNativeFileError {
const message = error instanceof Error ? error.message : String(error);
if (message === 'outsideSandbox' || message === 'notFound' || message === 'notFile') {
return message;
}
const mapped = workspaceError(error);
if (mapped === 'outsideSandbox') return 'outsideSandbox';
if (mapped === 'notFound') return 'notFound';
return 'operationFailed';
}
function isSamePath(left: string, right: string): boolean {
const normalizedLeft = resolve(left);
const normalizedRight = resolve(right);
@@ -312,6 +329,15 @@ async function revalidateWorkspaceTarget(
return target;
}
async function resolveWorkspaceRegularFile(
ref: WorkspaceFileRef,
fsP: WorkspaceFs,
): Promise<string> {
const resolvedTarget = await resolveWorkspaceTarget(ref, fsP);
if (!(await fsP.stat(resolvedTarget.target)).isFile()) throw new Error('notFile');
return resolvedTarget.target;
}
async function openWorkspaceTarget(ref: WorkspaceFileRef, fsP: WorkspaceFs): Promise<OpenWorkspaceTarget> {
const resolvedTarget = await resolveWorkspaceTarget(ref, fsP);
let handle: FileHandle | undefined;
@@ -586,12 +612,16 @@ export function createFilesApi(dependencies: FilesApiDependencies = {}): Complet
const fileName = basename(filePath);
const sourceStat = await fsP.stat(filePath);
if (sourceStat.isDirectory()) {
const canonicalPath = await fsP.realpath(filePath);
const canonicalStat = await fsP.stat(canonicalPath);
if (!canonicalStat.isDirectory()) throw new Error('Invalid directory attachment');
dependencies.stagedAttachments?.register(id, canonicalPath, filePath);
results.push({
id,
fileName,
mimeType: DIRECTORY_MIME_TYPE,
fileSize: 0,
stagedPath: filePath,
stagedPath: canonicalPath,
preview: null,
});
continue;
@@ -728,6 +758,64 @@ export function createFilesApi(dependencies: FilesApiDependencies = {}): Complet
await opened?.handle.close().catch(() => undefined);
}
},
listWorkspaceOpenHandlers: async (ref) => {
try {
const openWith = dependencies.openWith;
if (!openWith) throw new Error('operationFailed');
const target = await resolveWorkspaceRegularFile(ref, await getWorkspaceFs());
if (openWith.platform === 'linux') {
return { ok: true, platform: 'linux', handlers: [] };
}
const handlers = await openWith.list(target);
return {
ok: true,
platform: openWith.platform,
handlers: handlers.map(({ id, name, iconDataUrl, isDefault }) => ({
handlerId: id,
name,
...(iconDataUrl ? { iconDataUrl } : {}),
isDefault,
})),
};
} catch (error) {
return { ok: false, error: workspaceNativeError(error) };
}
},
openWorkspaceWith: async (payload) => {
try {
const openWith = dependencies.openWith;
if (!openWith) throw new Error('operationFailed');
const fsP = await getWorkspaceFs();
const target = await resolveWorkspaceRegularFile(payload?.ref, fsP);
if (typeof payload?.handlerId !== 'string'
|| !payload.handlerId.trim()
|| payload.handlerId.length > HANDLER_ID_MAX_LENGTH) {
throw new Error('operationFailed');
}
if (openWith.platform === 'linux') {
return { ok: false, error: 'unsupportedPlatform' };
}
await openWith.open(
target,
payload.handlerId,
() => resolveWorkspaceRegularFile(payload.ref, fsP),
);
return { ok: true };
} catch (error) {
return { ok: false, error: workspaceNativeError(error) };
}
},
revealWorkspaceFile: async (ref) => {
try {
const fsP = await getWorkspaceFs();
await resolveWorkspaceRegularFile(ref, fsP);
const target = await resolveWorkspaceRegularFile(ref, fsP);
shell.showItemInFolder(target);
return { ok: true };
} catch (error) {
return { ok: false, error: workspaceNativeError(error) };
}
},
resolveAttachment: async (payload) => dependencies.attachmentAccess?.resolveAttachment(payload) ?? {
ok: false,
displayName: 'attachment',
@@ -745,6 +833,19 @@ export function createFilesApi(dependencies: FilesApiDependencies = {}): Complet
ok: false,
error: 'operationFailed',
},
listAttachmentOpenHandlers: async (ref) => dependencies.attachmentAccess
?.listAttachmentOpenHandlers(ref) ?? {
ok: false,
error: 'operationFailed',
},
openAttachmentWith: async (payload) => dependencies.attachmentAccess?.openAttachmentWith(payload) ?? {
ok: false,
error: 'operationFailed',
},
revealAttachment: async (ref) => dependencies.attachmentAccess?.revealAttachment(ref) ?? {
ok: false,
error: 'operationFailed',
},
readText: async (payload) => {
try {
const { realPath: real, readOnly } = await resolveSandboxedPath(requirePath(payload), 'read');
+4 -19
View File
@@ -1,5 +1,4 @@
import type { GatewayManager } from '../gateway/manager';
import type { GatewayRpcBackpressure } from '../gateway/rpc-backpressure';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import { PORTS } from '../utils/config';
import { approvePendingLocalDeviceRequests } from '../utils/control-ui-device-pairing';
@@ -12,10 +11,6 @@ type HealthPayload = {
probe?: unknown;
};
type ControlUiPayload = {
view?: unknown;
};
type RpcPayload = {
method?: unknown;
params?: unknown;
@@ -30,10 +25,7 @@ function parseTimeoutMs(timeoutMs: unknown): number | undefined {
return timeoutMs;
}
export function createGatewayApi(
gatewayManager: GatewayManager,
gatewayRpcBackpressure: GatewayRpcBackpressure,
): CompleteHostServiceRegistry['gateway'] {
export function createGatewayApi(gatewayManager: GatewayManager): CompleteHostServiceRegistry['gateway'] {
return {
status: () => gatewayManager.getStatus(),
start: async () => {
@@ -52,13 +44,11 @@ export function createGatewayApi(
const body = isRecord(payload) ? payload as HealthPayload : {};
return gatewayManager.checkHealth({ probe: body.probe === true });
},
controlUi: async (payload) => {
const body = isRecord(payload) ? payload as ControlUiPayload : {};
controlUi: async () => {
const status = gatewayManager.getStatus();
const token = await getSetting('gatewayToken');
const port = status.port || PORTS.OPENCLAW_GATEWAY;
const view = body.view === 'dreams' ? 'dreams' : undefined;
const url = buildOpenClawControlUiUrl(port, token, { view });
const url = buildOpenClawControlUiUrl(port, token);
void approvePendingLocalDeviceRequests(gatewayManager).catch((error) => {
logger.debug(`[gateway] Control UI device auto-approve skipped: ${String(error)}`);
});
@@ -71,12 +61,7 @@ export function createGatewayApi(
throw new Error('Invalid gateway RPC method');
}
const timeoutMs = parseTimeoutMs(body.timeoutMs);
return gatewayRpcBackpressure.run(
method,
body.params,
timeoutMs,
(rpcMethod, rpcParams, rpcTimeoutMs) => gatewayManager.rpc(rpcMethod, rpcParams, rpcTimeoutMs),
);
return gatewayManager.rpc(method, body.params, timeoutMs);
},
};
}
+7 -4
View File
@@ -34,6 +34,7 @@ type ProviderPayload<Action extends keyof HostApiContract['providers']> =
type ValidationOptions = {
baseUrl?: string;
apiProtocol?: string;
modelId?: string;
};
function hasObjectChanges<T extends Record<string, unknown>>(
@@ -180,9 +181,11 @@ async function validateKey(payload: ProviderPayload<'validateKey'>): Promise<{ v
const registryBaseUrl = getProviderConfig(providerType)?.baseUrl;
const resolvedBaseUrl = options?.baseUrl || account?.baseUrl || legacyProvider?.baseUrl || registryBaseUrl;
const resolvedProtocol = options?.apiProtocol || account?.apiProtocol || legacyProvider?.apiProtocol;
const resolvedModelId = options?.modelId || account?.model || legacyProvider?.model;
return await validateApiKeyWithProvider(providerType, apiKey, {
baseUrl: resolvedBaseUrl,
apiProtocol: resolvedProtocol,
modelId: resolvedModelId,
});
} catch (error) {
return { valid: false, error: String(error) };
@@ -213,8 +216,8 @@ async function deleteProvider(payload: ProviderPayload<'delete'>, gatewayManager
const providerId = getProviderId(payload, 'delete');
try {
const existing = await providerService._getProviderInternal(providerId);
await providerService._deleteProviderInternal(providerId);
await syncDeletedProviderToRuntime(existing, providerId, gatewayManager);
await providerService._deleteProviderInternal(providerId);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
@@ -369,12 +372,12 @@ async function deleteAccount(
? 'openai'
: undefined;
if (apiKeyOnly) {
await providerService._deleteProviderApiKeyInternal(accountId);
await syncDeletedProviderApiKeyToRuntime(
existing ? providerAccountToConfig(existing) : null,
accountId,
runtimeProviderKey,
);
await providerService._deleteProviderApiKeyInternal(accountId);
return { success: true };
}
const currentDefaultAccountId = await providerService.getDefaultAccountId();
@@ -382,10 +385,9 @@ async function deleteAccount(
? selectReplacementDefaultAccount(await providerService.listAccounts(), accountId)
: undefined;
await providerService.deleteAccount(accountId);
if (replacementDefault) {
await providerService.setDefaultAccount(replacementDefault.id);
await syncDefaultProviderToRuntime(replacementDefault.id);
await providerService.setDefaultAccount(replacementDefault.id);
}
await syncDeletedProviderToRuntime(
existing ? providerAccountToConfig(existing) : null,
@@ -393,6 +395,7 @@ async function deleteAccount(
gatewayManager,
runtimeProviderKey,
);
await providerService.deleteAccount(accountId);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
@@ -30,7 +30,7 @@ import { listAgentsSnapshot } from '../../utils/agent-config';
/** OpenClaw Codex OAuth hooks only apply to the canonical `openai` provider id. */
const OPENAI_OAUTH_RUNTIME_PROVIDER = 'openai';
const OPENAI_OAUTH_DEFAULT_MODEL_REF = `${OPENAI_OAUTH_RUNTIME_PROVIDER}/gpt-5.5`;
const OPENAI_OAUTH_DEFAULT_MODEL_REF = `${OPENAI_OAUTH_RUNTIME_PROVIDER}/gpt-5.6-sol`;
/**
* Provider types that are not in the built-in provider registry (no `providerConfig.api`).
@@ -185,29 +185,6 @@ export async function getProviderFallbackModelRefs(config: ProviderConfig): Prom
return results;
}
type GatewayRefreshMode = 'reload' | 'restart';
function scheduleGatewayRefresh(
gatewayManager: GatewayManager | undefined,
message: string,
options?: { delayMs?: number; onlyIfRunning?: boolean; mode?: GatewayRefreshMode },
): void {
if (!gatewayManager) {
return;
}
if (options?.onlyIfRunning && gatewayManager.getStatus().state === 'stopped') {
return;
}
logger.info(message);
if (options?.mode === 'restart') {
gatewayManager.debouncedRestart(options?.delayMs);
return;
}
gatewayManager.debouncedReload(options?.delayMs);
}
export async function syncProviderApiKeyToRuntime(
providerType: string,
providerId: string,
@@ -522,29 +499,21 @@ export async function syncAgentModelOverrideToRuntime(agentId: string): Promise<
export async function syncSavedProviderToRuntime(
config: ProviderConfig,
apiKey: string | undefined,
gatewayManager?: GatewayManager,
_gatewayManager?: GatewayManager,
): Promise<void> {
const context = await syncProviderToRuntime(config, apiKey);
if (!context) {
return;
}
try {
await syncAgentModelsToRuntime();
} catch (err) {
logger.warn('[provider-runtime] Failed to sync per-agent model registries after provider save:', err);
}
await syncAgentModelsToRuntime();
scheduleGatewayRefresh(
gatewayManager,
`Scheduling Gateway reload after saving provider "${context.runtimeProviderKey}" config`,
);
}
export async function syncUpdatedProviderToRuntime(
config: ProviderConfig,
apiKey: string | undefined,
gatewayManager?: GatewayManager,
_gatewayManager?: GatewayManager,
): Promise<void> {
const context = await syncProviderToRuntime(config, apiKey);
if (!context) {
@@ -579,22 +548,14 @@ export async function syncUpdatedProviderToRuntime(
}
}
try {
await syncAgentModelsToRuntime();
} catch (err) {
logger.warn('[provider-runtime] Failed to sync per-agent model registries after provider update:', err);
}
await syncAgentModelsToRuntime();
scheduleGatewayRefresh(
gatewayManager,
`Scheduling Gateway reload after updating provider "${ock}" config`,
);
}
export async function syncDeletedProviderToRuntime(
provider: ProviderConfig | null,
providerId: string,
gatewayManager?: GatewayManager,
_gatewayManager?: GatewayManager,
runtimeProviderKey?: string,
): Promise<void> {
if (!provider?.type) {
@@ -604,11 +565,6 @@ export async function syncDeletedProviderToRuntime(
const ock = runtimeProviderKey ?? await resolveRuntimeProviderKey({ ...provider, id: providerId });
await removeDeletedProviderFromOpenClaw(provider, providerId, ock);
scheduleGatewayRefresh(
gatewayManager,
`Scheduling Gateway restart after deleting provider "${ock}"`,
{ mode: 'restart' },
);
}
export async function syncDeletedProviderApiKeyToRuntime(
@@ -626,7 +582,7 @@ export async function syncDeletedProviderApiKeyToRuntime(
export async function syncDefaultProviderToRuntime(
providerId: string,
gatewayManager?: GatewayManager,
_gatewayManager?: GatewayManager,
): Promise<void> {
const provider = await getProvider(providerId);
if (!provider) {
@@ -742,15 +698,7 @@ export async function syncDefaultProviderToRuntime(
fallbackModels.map((fallback) => fallback.replace(/^openai-codex\//, `${browserOAuthRuntimeProvider}/`)),
);
logger.info(`Configured openclaw.json for browser OAuth provider "${provider.id}"`);
try {
await syncAgentModelsToRuntime();
} catch (err) {
logger.warn('[provider-runtime] Failed to sync per-agent model registries after browser OAuth switch:', err);
}
scheduleGatewayRefresh(
gatewayManager,
`Scheduling Gateway reload after provider switch to "${browserOAuthRuntimeProvider}"`,
);
await syncAgentModelsToRuntime();
return;
}
@@ -775,18 +723,14 @@ export async function syncDefaultProviderToRuntime(
logger.info(`Configured openclaw.json for OAuth provider "${provider.type}"`);
try {
const defaultModelId = provider.model?.split('/').pop();
await updateAgentModelProvider(targetProviderKey, {
baseUrl,
api,
authHeader: targetProviderKey === 'minimax-portal' ? true : undefined,
apiKey: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth',
models: defaultModelId ? [piAiModelsJsonModelEntry(defaultModelId)] : [],
});
} catch (err) {
logger.warn(`Failed to update models.json for OAuth provider "${targetProviderKey}":`, err);
}
const defaultModelId = provider.model?.split('/').pop();
await updateAgentModelProvider(targetProviderKey, {
baseUrl,
api,
authHeader: targetProviderKey === 'minimax-portal' ? true : undefined,
apiKey: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth',
models: defaultModelId ? [piAiModelsJsonModelEntry(defaultModelId)] : [],
});
}
if (
@@ -803,15 +747,6 @@ export async function syncDefaultProviderToRuntime(
});
}
try {
await syncAgentModelsToRuntime();
} catch (err) {
logger.warn('[provider-runtime] Failed to sync per-agent model registries after default provider switch:', err);
}
await syncAgentModelsToRuntime();
scheduleGatewayRefresh(
gatewayManager,
`Scheduling Gateway reload after provider switch to "${ock}"`,
{ onlyIfRunning: true },
);
}
@@ -196,6 +196,7 @@ async function validateOpenAiCompatibleKey(
apiKey: string,
apiProtocol: 'openai-completions' | 'openai-responses',
baseUrl?: string,
modelId?: string,
): Promise<ValidationResult> {
const trimmedBaseUrl = baseUrl?.trim();
if (!trimmedBaseUrl) {
@@ -203,6 +204,7 @@ async function validateOpenAiCompatibleKey(
}
const headers = { Authorization: `Bearer ${apiKey}` };
const probeModel = modelId?.trim() || 'validation-probe';
const { modelsUrl, probeUrl } = resolveOpenAiProbeUrls(trimmedBaseUrl, apiProtocol);
const modelsResult = await performProviderValidationRequest(providerType, modelsUrl, headers);
@@ -211,9 +213,9 @@ async function validateOpenAiCompatibleKey(
`[clawx-validate] ${providerType} /models returned ${modelsResult.status}, falling back to ${apiProtocol} probe`,
);
if (apiProtocol === 'openai-responses') {
return await performResponsesProbe(providerType, probeUrl, headers);
return await performResponsesProbe(providerType, probeUrl, headers, probeModel);
}
return await performChatCompletionsProbe(providerType, probeUrl, headers);
return await performChatCompletionsProbe(providerType, probeUrl, headers, probeModel);
}
return modelsResult;
@@ -223,6 +225,7 @@ async function performResponsesProbe(
providerLabel: string,
url: string,
headers: Record<string, string>,
modelId: string,
): Promise<ValidationResult> {
try {
logValidationRequest(providerLabel, 'POST', url, headers);
@@ -230,7 +233,7 @@ async function performResponsesProbe(
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'validation-probe',
model: modelId,
input: 'hi',
}),
});
@@ -249,6 +252,7 @@ async function performChatCompletionsProbe(
providerLabel: string,
url: string,
headers: Record<string, string>,
modelId: string,
): Promise<ValidationResult> {
try {
logValidationRequest(providerLabel, 'POST', url, headers);
@@ -256,7 +260,7 @@ async function performChatCompletionsProbe(
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'validation-probe',
model: modelId,
messages: [{ role: 'user', content: 'hi' }],
max_tokens: 1,
}),
@@ -353,7 +357,7 @@ async function validateOpenRouterKey(
export async function validateApiKeyWithProvider(
providerType: string,
apiKey: string,
options?: { baseUrl?: string; apiProtocol?: string },
options?: { baseUrl?: string; apiProtocol?: string; modelId?: string },
): Promise<ValidationResult> {
const profile = getValidationProfile(providerType, options);
const resolvedBaseUrl = options?.baseUrl || getProviderConfig(providerType)?.baseUrl;
@@ -375,6 +379,7 @@ export async function validateApiKeyWithProvider(
trimmedKey,
'openai-completions',
resolvedBaseUrl,
options?.modelId,
);
case 'openai-responses':
return await validateOpenAiCompatibleKey(
@@ -382,6 +387,7 @@ export async function validateApiKeyWithProvider(
trimmedKey,
'openai-responses',
resolvedBaseUrl,
options?.modelId,
);
case 'google-query-key':
return await validateGoogleQueryKey(providerType, trimmedKey, resolvedBaseUrl);
+134 -12
View File
@@ -5,6 +5,7 @@ import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import { stripAcpWorkingDirectoryPrefix } from '@shared/chat/session-title';
import { isOpenClawHeartbeatPollText } from '@shared/chat/openclaw-internal';
import type { RawMessage } from '@shared/chat/types';
import type { SessionTurnTimingCandidate } from '@shared/host-api/contract';
import { resolveOpenClawStateDir } from '../utils/paths';
import { logger } from '../utils/logger';
import {
@@ -31,9 +32,17 @@ type TranscriptMessage = RawMessage;
type ParsedTranscriptLine = {
type?: string;
id?: unknown;
timestamp?: unknown;
message?: TranscriptMessage;
};
type TranscriptMessageRecord = {
id?: string;
timestamp?: unknown;
message: TranscriptMessage;
};
type SessionPayload = {
id?: unknown;
sessionKey?: unknown;
@@ -101,6 +110,77 @@ function normalizeTimestamp(value: unknown): number | null {
return value < 1e12 ? value * 1000 : value;
}
function normalizeTranscriptTimestamp(value: unknown): number | null {
if (typeof value === 'number') return normalizeTimestamp(value);
if (typeof value !== 'string' || !value.trim()) return null;
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? timestamp : null;
}
function transcriptRecordTimestamp(record: TranscriptMessageRecord): number | null {
return normalizeTranscriptTimestamp(record.timestamp)
?? normalizeTranscriptTimestamp(record.message.timestamp);
}
function normalizeTurnUserText(message: TranscriptMessage): string {
return stripAcpWorkingDirectoryPrefix(extractMessageText(message.content))
.replace(/\r\n/g, '\n')
.trim();
}
function isInternalInterSessionUser(message: TranscriptMessage): boolean {
const provenance = (message as TranscriptMessage & { provenance?: unknown }).provenance;
if (provenance && typeof provenance === 'object' && !Array.isArray(provenance)) {
const kind = (provenance as Record<string, unknown>).kind;
if (typeof kind === 'string' && kind.toLowerCase() === 'inter_session') return true;
}
return /^\[Inter-session message\]\s/.test(extractMessageText(message.content));
}
function extractTranscriptTurnTimings(records: TranscriptMessageRecord[]): SessionTurnTimingCandidate[] {
const turns: Array<{
normalizedUserText: string;
startedAt: number | null;
completedAt: number | null;
}> = [];
let current: (typeof turns)[number] | null = null;
for (const record of records) {
const role = typeof record.message.role === 'string' ? record.message.role.toLowerCase() : '';
if (role === 'user') {
if (isInternalInterSessionUser(record.message)) continue;
current = {
normalizedUserText: normalizeTurnUserText(record.message),
startedAt: transcriptRecordTimestamp(record),
completedAt: null,
};
turns.push(current);
continue;
}
if (!current || (role !== 'assistant' && role !== 'toolresult' && role !== 'tool_result')) continue;
const timestamp = transcriptRecordTimestamp(record);
if (timestamp != null && (current.completedAt == null || timestamp > current.completedAt)) {
current.completedAt = timestamp;
}
}
const occurrences = new Map<string, number>();
const candidates = new Array<SessionTurnTimingCandidate | null>(turns.length).fill(null);
for (let index = turns.length - 1; index >= 0; index -= 1) {
const turn = turns[index]!;
const userOccurrenceFromTail = (occurrences.get(turn.normalizedUserText) ?? 0) + 1;
occurrences.set(turn.normalizedUserText, userOccurrenceFromTail);
if (turn.startedAt == null || turn.completedAt == null || turn.completedAt < turn.startedAt) continue;
candidates[index] = {
normalizedUserText: turn.normalizedUserText,
userOccurrenceFromTail,
durationMs: turn.completedAt - turn.startedAt,
};
}
return candidates.filter((candidate): candidate is SessionTurnTimingCandidate => candidate != null);
}
type SqliteDatabaseLike = {
prepare: (sql: string) => {
get: (...params: unknown[]) => unknown;
@@ -171,39 +251,47 @@ async function readOpenClawAcpSessionCwds(sessionKeys: string[]): Promise<Map<st
}
}
function parseMessageLine(line: string): TranscriptMessage | null {
function parseMessageRecordLine(line: string): TranscriptMessageRecord | null {
try {
const entry = JSON.parse(line) as ParsedTranscriptLine;
if (entry.type !== 'message' || !entry.message || typeof entry.message !== 'object') {
return null;
}
return entry.message;
return {
...(typeof entry.id === 'string' ? { id: entry.id } : {}),
timestamp: entry.timestamp,
message: entry.message,
};
} catch {
return null;
}
}
function parseRecentMessagesFromTailChunk(chunk: string, readStart: number, limit: number): TranscriptMessage[] {
function parseMessageLine(line: string): TranscriptMessage | null {
return parseMessageRecordLine(line)?.message ?? null;
}
function parseRecentRecordsFromTailChunk(chunk: string, readStart: number, limit: number): TranscriptMessageRecord[] {
const lines = chunk.split(/\r?\n/);
if (readStart > 0) lines.shift();
const collected: TranscriptMessage[] = [];
const collected: TranscriptMessageRecord[] = [];
let scanned = 0;
for (let index = lines.length - 1; index >= 0; index -= 1) {
const line = lines[index];
if (!line?.trim()) continue;
scanned += 1;
if (scanned > RECENT_TRANSCRIPT_MAX_SCAN_LINES) break;
const message = parseMessageLine(line);
if (message) {
collected.push(message);
const record = parseMessageRecordLine(line);
if (record) {
collected.push(record);
if (collected.length >= limit) break;
}
}
return collected.reverse();
}
function readRecentTranscriptMessages(transcriptPath: string, limit: number): TranscriptMessage[] {
function readRecentTranscriptRecords(transcriptPath: string, limit: number): TranscriptMessageRecord[] {
const boundedLimit = Math.max(1, Math.min(Math.floor(limit), 1000));
let fd: number | null = null;
try {
@@ -217,13 +305,13 @@ function readRecentTranscriptMessages(transcriptPath: string, limit: number): Tr
const readLen = size - readStart;
const buffer = Buffer.allocUnsafe(readLen);
readSync(fd, buffer, 0, readLen, readStart);
const messages = parseRecentMessagesFromTailChunk(buffer.toString('utf8'), readStart, boundedLimit);
const records = parseRecentRecordsFromTailChunk(buffer.toString('utf8'), readStart, boundedLimit);
if (
messages.length >= boundedLimit
records.length >= boundedLimit
|| readStart === 0
|| readBytes >= RECENT_TRANSCRIPT_MAX_READ_BYTES
) {
return messages;
return records;
}
readBytes = Math.min(size, readBytes * 2);
}
@@ -233,6 +321,10 @@ function readRecentTranscriptMessages(transcriptPath: string, limit: number): Tr
}
}
function readRecentTranscriptMessages(transcriptPath: string, limit: number): TranscriptMessage[] {
return readRecentTranscriptRecords(transcriptPath, limit).map((record) => record.message);
}
async function readAllTranscriptMessages(transcriptPath: string): Promise<TranscriptMessage[]> {
const fsP = await import('node:fs/promises');
const raw = await fsP.readFile(transcriptPath, 'utf8');
@@ -381,7 +473,7 @@ async function loadSessionSummary(sessionKey: string, workspacePath: string | nu
}
}
async function loadSessionTranscriptByKey(sessionKey: string, limit: number): Promise<RawMessage[] | null> {
export async function loadSessionTranscriptByKey(sessionKey: string, limit: number): Promise<RawMessage[] | null> {
const parsed = parseSessionKey(sessionKey);
if (!parsed) return null;
@@ -397,6 +489,28 @@ async function loadSessionTranscriptByKey(sessionKey: string, limit: number): Pr
}
}
async function loadSessionTurnTimingsByKey(
sessionKey: string,
limit: number,
): Promise<SessionTurnTimingCandidate[] | null> {
const parsed = parseSessionKey(sessionKey);
if (!parsed) return null;
try {
const sessionsDir = join(resolveOpenClawStateDir(), 'agents', parsed.agentId, 'sessions');
const sessionsJson = await readSessionsJson(parsed.agentId);
const transcriptPath = resolveSessionTranscriptPathByKey(sessionKey, sessionsDir, sessionsJson);
if (!transcriptPath) return null;
// ACP session/load is authoritative for history content, but its updates omit the
// original timestamps needed to calculate a whole-turn duration. Read only bounded
// transcript timing metadata here; this must never become a second history source.
return extractTranscriptTurnTimings(readRecentTranscriptRecords(transcriptPath, limit));
} catch {
return null;
}
}
async function deleteSession(sessionKey: string): Promise<{ success: boolean; error?: string }> {
if (!sessionKey || !sessionKey.startsWith('agent:')) {
return { success: false, error: `Invalid sessionKey: ${sessionKey}` };
@@ -562,5 +676,13 @@ export function createSessionsApi(): CompleteHostServiceRegistry['sessions'] {
return { success: false, error: 'Failed to load transcript' };
}
},
turnTimings: async (payload) => {
const body = isRecord(payload) ? payload as SessionPayload : {};
const sessionKey = typeof body.sessionKey === 'string' ? body.sessionKey.trim() : '';
if (!sessionKey) return { success: false, error: 'sessionKey is required' };
const timings = await loadSessionTurnTimingsByKey(sessionKey, getLimit(payload, 1000));
if (!timings) return { success: false, error: 'Transcript not found' };
return { success: true, timings };
},
};
}
+55
View File
@@ -0,0 +1,55 @@
import { shell, type Session, type WebContents } from 'electron';
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
import type { WebBrowserGuestRegistry } from '../main/web-browser-policy';
import { normalizeWebBrowserHtmlFileUrl } from '../../shared/web-browser';
export interface WebBrowserApiDependencies {
browserSession: Session;
registry: WebBrowserGuestRegistry;
openExternal?: (url: string) => Promise<void>;
}
function requireLiveGuest(registry: WebBrowserGuestRegistry): WebContents {
const guest = registry.current();
if (!guest) {
throw new Error('Web browser guest is unavailable');
}
return guest;
}
function requireAllowedUrl(url: string): string {
const normalizedUrl = normalizeWebBrowserHtmlFileUrl(url);
if (!normalizedUrl) {
throw new Error('Only local HTML file URLs are allowed');
}
return normalizedUrl;
}
function isAbortedLoad(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false;
const loadError = error as { code?: unknown; errno?: unknown };
return loadError.code === 'ERR_ABORTED' && loadError.errno === -3;
}
export function createWebBrowserApi(
dependencies: WebBrowserApiDependencies,
): CompleteHostServiceRegistry['webBrowser'] {
const { registry } = dependencies;
const openExternal = dependencies.openExternal ?? ((url: string) => shell.openExternal(url));
return {
async navigate({ url }) {
const guest = requireLiveGuest(registry);
const allowedUrl = requireAllowedUrl(url);
try {
await guest.loadURL(allowedUrl);
} catch (error) {
if (!isAbortedLoad(error)) throw error;
}
},
async openExternal({ url }) {
await openExternal(requireAllowedUrl(url));
},
};
}
+169 -29
View File
@@ -1,50 +1,190 @@
export type ModelInputModality = 'text' | 'image';
type ContextWindowRule = {
/** Human-readable family label; kept so the table reads as documentation. */
label: string;
pattern: RegExp;
contextWindow: number;
};
/**
* Conservative context-window defaults for well-known model families, applied
* to custom-provider model rows that would otherwise carry no `contextWindow`.
* Context-window defaults for well-known model families, applied to model rows
* that would otherwise carry no `contextWindow`.
*
* Why this matters: when a model row has neither `contextTokens` nor
* `contextWindow`, OpenClaw's embedded runner skips preemptive compaction and
* context-window guarding entirely, so long sessions only fail at the provider
* with "Context overflow: prompt too large" instead of being compacted early.
*
* Accuracy matters in both directions. Under-reporting is not the safe choice:
* it makes the runner start preflight compaction long before it is needed, and
* a compaction that times out aborts the whole turn. Over-reporting pushes the
* failure to the provider as a hard overflow. Prefer the vendor's published
* figure for the family rather than a defensive guess.
*
* Ordering contract: rules are evaluated top-down and the first match wins, so
* a specific variant MUST appear above its family fallback. Note that `\b`
* treats `.` and `-` as boundaries, so /\bgpt-5\b/ also matches `gpt-5.6-sol`;
* the generation-specific rules above it are what keep that correct.
*/
const CUSTOM_MODEL_CONTEXT_WINDOW_RULES: Array<{ pattern: RegExp; contextWindow: number }> = [
{ pattern: /\bgpt-5/, contextWindow: 272_000 },
{ pattern: /\b(?:gpt-4\.1|gpt-4o|o[134])\b/, contextWindow: 128_000 },
{ pattern: /\bclaude\b|\bclaude-/, contextWindow: 200_000 },
{ pattern: /\bgemini\b/, contextWindow: 1_048_576 },
{ pattern: /\bkimi\b|moonshot/, contextWindow: 256_000 },
{ pattern: /minimax/, contextWindow: 204_800 },
{ pattern: /\bglm-5(?:\.|\b)/, contextWindow: 1_000_000 },
{ pattern: /\bglm-4/, contextWindow: 200_000 },
const CONTEXT_WINDOW_RULES: ContextWindowRule[] = [
// ── OpenAI ──────────────────────────────────────────────────────────────
{ label: 'GPT-5.6 Luna (low-latency tier)', pattern: /\bgpt-5\.6-luna\b/, contextWindow: 272_000 },
{ label: 'GPT-5.6 Sol / Terra', pattern: /\bgpt-5\.6\b/, contextWindow: 1_050_000 },
{ label: 'GPT-5.5', pattern: /\bgpt-5\.5\b/, contextWindow: 1_000_000 },
{ label: 'GPT-5 lightweight variants', pattern: /\bgpt-5[\w.]*-(?:mini|nano|turbo)\b/, contextWindow: 272_000 },
{ label: 'GPT-5 flagship', pattern: /\bgpt-5\b/, contextWindow: 400_000 },
{ label: 'GPT-4.x and o-series', pattern: /\b(?:gpt-4\.1|gpt-4o|o[134])\b/, contextWindow: 128_000 },
// ── Anthropic ───────────────────────────────────────────────────────────
{ label: 'Claude Fable 5 / Opus 5 / Sonnet 5', pattern: /\bclaude-(?:fable|opus|sonnet)-5\b/, contextWindow: 1_000_000 },
{ label: 'Claude Opus 4.8+', pattern: /\bclaude-opus-4[.-][89]\b/, contextWindow: 1_000_000 },
{ label: 'Claude Sonnet 4.6+', pattern: /\bclaude-sonnet-4[.-][6-9]\b/, contextWindow: 1_000_000 },
{ label: 'Claude Haiku and legacy Claude', pattern: /\bclaude\b|\bclaude-/, contextWindow: 200_000 },
// ── Google ──────────────────────────────────────────────────────────────
{ label: 'Gemini 1.0 (pre-million era)', pattern: /\bgemini-1\.0\b/, contextWindow: 32_768 },
{ label: 'Gemini 1.5 and newer', pattern: /\bgemini\b/, contextWindow: 1_048_576 },
// ── DeepSeek ────────────────────────────────────────────────────────────
// `deepseek-chat` / `deepseek-reasoner` are compatibility aliases that route
// to V4-Flash, so they inherit the V4 window rather than the V3 one.
{ label: 'DeepSeek V3 / R1', pattern: /\bdeepseek-(?:v3|r1)\b/, contextWindow: 128_000 },
{ label: 'DeepSeek V4 and aliases', pattern: /\bdeepseek\b/, contextWindow: 1_000_000 },
// ── Moonshot / Kimi ─────────────────────────────────────────────────────
// Only K3 reached a million tokens; K2.x tops out at 262,144.
{ label: 'Kimi K3', pattern: /\bkimi-k3\b/, contextWindow: 1_000_000 },
{ label: 'Kimi K2.x and other Moonshot', pattern: /\bkimi\b|moonshot/, contextWindow: 262_144 },
// ── Alibaba Qwen ────────────────────────────────────────────────────────
{ label: 'Qwen-Long (bulk document tier)', pattern: /\bqwen-long\b/, contextWindow: 10_000_000 },
{ label: 'Qwen 3.6+ hosted API', pattern: /\bqwen-?3\.[6-9]\b/, contextWindow: 1_000_000 },
{ label: 'Qwen 3.5 / Qwen3-Next', pattern: /\bqwen-?3\.5\b|\bqwen3-next\b/, contextWindow: 262_144 },
{ label: 'Qwen open-weight base', pattern: /\bqwen/, contextWindow: 131_072 },
// ── Z.AI GLM ────────────────────────────────────────────────────────────
{ label: 'GLM-5.2+', pattern: /\bglm-5\.[2-9]\b/, contextWindow: 1_000_000 },
{ label: 'GLM-5.0 / 5.1', pattern: /\bglm-5(?:\.[01])?\b/, contextWindow: 200_000 },
{ label: 'GLM-4.x', pattern: /\bglm-4/, contextWindow: 200_000 },
// ── MiniMax ─────────────────────────────────────────────────────────────
{ label: 'MiniMax M3+', pattern: /\bminimax-m[3-9]\b/, contextWindow: 524_288 },
{ label: 'MiniMax M2.x and earlier', pattern: /minimax/, contextWindow: 204_800 },
];
/** Safe floor for unknown custom models: high enough to avoid compaction spam. */
export const DEFAULT_CUSTOM_MODEL_CONTEXT_WINDOW = 131_072;
/**
* Fallback for hosted models we do not recognise. Set at the low end of the
* current frontier rather than at the old 128K floor: nearly every model a
* user can point a hosted provider at at this point clears 200K, and guessing
* too low triggers needless compaction on long sessions.
*/
export const DEFAULT_CUSTOM_MODEL_CONTEXT_WINDOW = 200_000;
export function inferCustomModelContextWindow(modelId: string): number {
const normalized = modelId.trim().toLowerCase();
for (const rule of CUSTOM_MODEL_CONTEXT_WINDOW_RULES) {
if (rule.pattern.test(normalized)) return rule.contextWindow;
}
return DEFAULT_CUSTOM_MODEL_CONTEXT_WINDOW;
/**
* Ceiling for locally hosted runtimes (Ollama and friends). A local `qwen3`
* tag is a quantised small model, not the hosted flagship of the same name, so
* family rules must not hand it a frontier-sized window. Kept at 128K because
* ClawX seeds `compaction.reserveTokensFloor = 50000` dropping the ceiling
* near or below that floor leaves the runner no usable budget.
*/
export const LOCAL_MODEL_CONTEXT_WINDOW = 131_072;
/**
* Ceiling for ChatGPT subscription transports (`openai-chatgpt-responses`).
*
* OAuth against a ChatGPT plan does not get the API-tier window: the backend
* enforces a far smaller per-session budget than `gpt-5.6-sol`'s published
* 1.05M. OpenClaw's own Codex catalog hard-codes 272,000 for every model on
* this transport, so we mirror that figure rather than inventing our own.
*
* This matters because ClawX writes OAuth rows into `models.providers.openai`
* while OpenClaw's cap lives on its separate `codex` provider nothing else
* would clamp the value we write.
*/
export const CHATGPT_OAUTH_CONTEXT_WINDOW = 272_000;
/** Runtime provider keys are suffixed per instance, e.g. `ollama-a1b2c3`. */
const LOCAL_PROVIDER_KEY_PATTERN = /^ollama(?:-|$)/;
/** Current and legacy spellings of the ChatGPT subscription transport. */
const SUBSCRIPTION_API_PROTOCOLS = new Set([
'openai-chatgpt-responses',
'openai-codex-responses',
]);
export type ModelCapabilityContext = {
/** OpenClaw runtime provider key, used to detect locally hosted models. */
providerKey?: string;
/** `models.providers.*.api` value, used to detect subscription transports. */
apiProtocol?: string;
};
/**
* Model ids reach us in several shapes: bare (`gpt-5.6-sol`), vendor-prefixed
* from aggregators (`openai/gpt-5.6-sol`, `deepseek-ai/DeepSeek-V3`), and
* Ollama-tagged (`qwen3:latest`). Patterns are written against the bare family
* name, so expose both forms and let callers test each.
*/
function normalizeModelId(modelId: string): { bare: string; full: string } {
const full = modelId.trim().toLowerCase();
const withoutVendor = full.slice(full.lastIndexOf('/') + 1);
const [bare] = withoutVendor.split(':');
return { bare: bare || full, full };
}
function matchesModelId(pattern: RegExp, modelId: string): boolean {
const { bare, full } = normalizeModelId(modelId);
return pattern.test(bare) || pattern.test(full);
}
function isLocalProviderKey(providerKey: string | undefined): boolean {
return providerKey != null && LOCAL_PROVIDER_KEY_PATTERN.test(providerKey.trim().toLowerCase());
}
function isSubscriptionApiProtocol(apiProtocol: string | undefined): boolean {
return apiProtocol != null && SUBSCRIPTION_API_PROTOCOLS.has(apiProtocol.trim().toLowerCase());
}
/**
* Family rules describe what the vendor's API tier offers. The transport a
* given account actually uses can be far more restrictive, so clamp rather
* than trusting the published figure.
*/
function resolveContextWindowCeiling(context: ModelCapabilityContext): number {
const ceilings: number[] = [];
if (isLocalProviderKey(context.providerKey)) ceilings.push(LOCAL_MODEL_CONTEXT_WINDOW);
if (isSubscriptionApiProtocol(context.apiProtocol)) ceilings.push(CHATGPT_OAUTH_CONTEXT_WINDOW);
return ceilings.length > 0 ? Math.min(...ceilings) : Number.POSITIVE_INFINITY;
}
export function inferCustomModelContextWindow(
modelId: string,
context: ModelCapabilityContext = {},
): number {
const ceiling = resolveContextWindowCeiling(context);
for (const rule of CONTEXT_WINDOW_RULES) {
if (matchesModelId(rule.pattern, modelId)) return Math.min(rule.contextWindow, ceiling);
}
return Math.min(DEFAULT_CUSTOM_MODEL_CONTEXT_WINDOW, ceiling);
}
const VISION_MODEL_PATTERNS: RegExp[] = [
/\b(?:gpt-4o|gpt-4\.1|gpt-[5-9]|o[134])\b/,
/\bclaude-(?:3|4|fable|sonnet|opus|haiku)\b/,
/\bgemini\b/,
/\b(?:qwen[\w.-]*-?vl|qwen-vl)\b/,
/\b(?:vision|llava|pixtral|internvl|mllama|minicpm-v|glm-4v)\b/,
/(?:^|[-_/])vl(?:[-_/]|$)/,
];
/**
* Mirrors OpenClaw 2026.5.20 custom-provider onboarding inference.
* Unknown models use the same conservative text-only fallback as non-interactive onboarding.
*/
export function inferCustomModelInputModalities(modelId: string): ModelInputModality[] {
const normalized = modelId.trim().toLowerCase();
const supportsImageInput = (
/\b(?:gpt-4o|gpt-4\.1|gpt-[5-9]|o[134])\b/.test(normalized)
|| /\bclaude-(?:3|4|sonnet|opus|haiku)\b/.test(normalized)
|| /\bgemini\b/.test(normalized)
|| /\b(?:qwen[\w.-]*-?vl|qwen-vl)\b/.test(normalized)
|| /\b(?:vision|llava|pixtral|internvl|mllama|minicpm-v|glm-4v)\b/.test(normalized)
|| /(?:^|[-_/])vl(?:[-_/]|$)/.test(normalized)
);
const supportsImageInput = VISION_MODEL_PATTERNS.some((pattern) => matchesModelId(pattern, modelId));
return supportsImageInput ? ['text', 'image'] : ['text'];
}
+4 -4
View File
@@ -31,11 +31,11 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
requiresApiKey: true,
category: 'official',
envVar: 'OPENAI_API_KEY',
defaultModelId: 'gpt-5.5',
defaultModelId: 'gpt-5.6-sol',
isOAuth: true,
supportsApiKey: true,
showModelId: true,
modelIdPlaceholder: 'gpt-5.5',
modelIdPlaceholder: 'gpt-5.6-sol',
supportedAuthModes: ['api_key', 'oauth_browser'],
defaultAuthMode: 'api_key',
supportsMultipleAccounts: true,
@@ -138,7 +138,7 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
reasoning: false,
input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 256000,
contextWindow: 262144,
maxTokens: 8192,
},
],
@@ -171,7 +171,7 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
reasoning: false,
input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 256000,
contextWindow: 262144,
maxTokens: 8192,
},
],
+223 -85
View File
@@ -1,9 +1,9 @@
import { access, copyFile, mkdir, readdir, rm } from 'fs/promises';
import { constants } from 'fs';
import { copyFile, lstat, mkdir, readdir, rm } from 'fs/promises';
import { join, normalize } from 'path';
import { deleteAgentChannelAccounts, listConfiguredChannels, readOpenClawConfig, writeOpenClawConfig } from './channel-config';
import { isDeepStrictEqual } from 'node:util';
import { mutateOpenClawConfig } from '../gateway/config-delivery';
import { deleteAgentChannelAccounts, listConfiguredChannelsFromConfig, readOpenClawConfig } from './channel-config';
import type { OpenClawConfig } from './channel-config';
import { withConfigLock } from './config-mutex';
import { expandPath, getOpenClawConfigDir } from './paths';
import * as logger from './logger';
import { toUiChannelType } from './channel-alias';
@@ -62,6 +62,11 @@ interface BindingConfig extends Record<string, unknown> {
match?: BindingMatch;
}
interface ChannelBindingConfig extends BindingConfig {
agentId: string;
match: BindingMatch & { channel: string };
}
interface ChannelSectionConfig extends Record<string, unknown> {
accounts?: Record<string, Record<string, unknown>>;
defaultAccount?: string;
@@ -147,7 +152,7 @@ function slugifyAgentId(name: string): string {
async function fileExists(path: string): Promise<boolean> {
try {
await access(path, constants.F_OK);
await lstat(path);
return true;
} catch {
return false;
@@ -217,7 +222,7 @@ function normalizeAgentsConfig(config: AgentConfigDocument): {
};
}
function isChannelBinding(binding: unknown): binding is BindingConfig {
function isChannelBinding(binding: unknown): binding is ChannelBindingConfig {
if (!binding || typeof binding !== 'object') return false;
const candidate = binding as BindingConfig;
if (typeof candidate.agentId !== 'string' || !candidate.agentId) return false;
@@ -460,7 +465,8 @@ function listConfiguredAccountIdsForChannel(config: AgentConfigDocument, channel
async function buildSnapshotFromConfig(config: AgentConfigDocument, preloadedChannels?: string[]): Promise<AgentsSnapshot> {
const { entries, defaultAgentId } = normalizeAgentsConfig(config);
const configuredChannels = preloadedChannels ?? await listConfiguredChannels();
const configuredChannels = preloadedChannels
?? await listConfiguredChannelsFromConfig(config as OpenClawConfig);
const { channelToAgent, accountToAgent } = getChannelBindingMap(config.bindings);
const defaultAgentIdNorm = normalizeAgentIdForBinding(defaultAgentId);
const channelOwners: Record<string, string> = {};
@@ -472,15 +478,11 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument, preloadedCha
for (const channelType of configuredChannels) {
const accountIds = listConfiguredAccountIdsForChannel(config, channelType);
let primaryOwner: string | undefined;
const hasExplicitAccountBindingForChannel = accountIds.some((accountId) =>
accountToAgent.has(`${channelType}:${accountId}`),
);
for (const accountId of accountIds) {
const owner =
accountToAgent.get(`${channelType}:${accountId}`)
|| (
accountId === DEFAULT_ACCOUNT_ID && !hasExplicitAccountBindingForChannel
accountId === DEFAULT_ACCOUNT_ID
? channelToAgent.get(channelType)
: undefined
);
@@ -543,16 +545,25 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument, preloadedCha
}
export async function listAgentsSnapshot(): Promise<AgentsSnapshot> {
return withConfigLock(async () => {
const config = await readOpenClawConfig() as AgentConfigDocument;
const { pruneStaleRuntimeAgentModelRefs } = await import('./openclaw-auth');
const modified = await pruneStaleRuntimeAgentModelRefs(config as unknown as Record<string, unknown>);
if (modified) {
await writeOpenClawConfig(config);
logger.info('Pruned stale runtime agent model refs from openclaw.json');
}
return buildSnapshotFromConfig(config);
let snapshot: AgentsSnapshot | undefined;
let prunedRuntimeModelRefs = false;
const {
getActiveAuthProfileProviders,
pruneStaleRuntimeAgentModelRefs,
} = await import('./openclaw-auth');
const authProfileProviders = await getActiveAuthProfileProviders();
await mutateOpenClawConfig(async (configSnapshot) => {
const config = configSnapshot as AgentConfigDocument;
prunedRuntimeModelRefs = await pruneStaleRuntimeAgentModelRefs(
config as unknown as Record<string, unknown>,
authProfileProviders,
);
snapshot = await buildSnapshotFromConfig(config);
});
if (prunedRuntimeModelRefs) {
logger.info('Pruned stale runtime agent model refs from openclaw.json');
}
return snapshot!;
}
export async function listAgentsSnapshotFromConfig(config: OpenClawConfig, configuredChannels?: string[]): Promise<AgentsSnapshot> {
@@ -589,8 +600,12 @@ export async function createAgent(
name: string,
options?: { inheritWorkspace?: boolean },
): Promise<AgentsSnapshot> {
return withConfigLock(async () => {
const config = await readOpenClawConfig() as AgentConfigDocument;
let snapshot: AgentsSnapshot | undefined;
let createdAgentId = '';
let agentToProvision: AgentListEntry | undefined;
let provisioningConfig: AgentConfigDocument | undefined;
await mutateOpenClawConfig(async (configSnapshot) => {
const config = configSnapshot as AgentConfigDocument;
const { agentsConfig, entries, syntheticMain } = normalizeAgentsConfig(config);
const normalizedName = normalizeAgentName(name);
const existingIds = new Set(entries.map((entry) => entry.id));
@@ -621,18 +636,61 @@ export async function createAgent(
list: nextEntries,
};
await provisionAgentFilesystem(config, newAgent, { inheritWorkspace: options?.inheritWorkspace });
await writeOpenClawConfig(config);
logger.info('Created agent config entry', { agentId: nextId, inheritWorkspace: !!options?.inheritWorkspace });
return buildSnapshotFromConfig(config);
createdAgentId = nextId;
agentToProvision = newAgent;
provisioningConfig = structuredClone(config);
snapshot = await buildSnapshotFromConfig(config);
});
const createdAgent = agentToProvision!;
const workspaceExisted = await fileExists(expandPath(createdAgent.workspace!));
const runtimeDirectory = join(getOpenClawConfigDir(), 'agents', createdAgent.id);
const runtimeDirectoryExisted = await fileExists(runtimeDirectory);
try {
await provisionAgentFilesystem(provisioningConfig!, createdAgent, { inheritWorkspace: options?.inheritWorkspace });
} catch (provisioningError) {
let rollbackError: unknown;
try {
await mutateOpenClawConfig((configSnapshot) => {
const config = configSnapshot as AgentConfigDocument;
const { agentsConfig, entries } = normalizeAgentsConfig(config);
const createdIndex = entries.findIndex((entry) => (
entry.id === createdAgent.id && isDeepStrictEqual(entry, createdAgent)
));
if (createdIndex === -1) return;
config.agents = {
...agentsConfig,
list: entries.filter((_, index) => index !== createdIndex),
};
});
} catch (error) {
rollbackError = error;
}
if (!workspaceExisted) {
await removeAgentWorkspaceDirectory(createdAgent);
}
if (!runtimeDirectoryExisted) {
await removeAgentRuntimeDirectory(createdAgent.id);
}
if (rollbackError) {
throw new AggregateError(
[provisioningError, rollbackError],
`Failed to provision agent "${createdAgent.id}" and roll back its config entry`,
{ cause: provisioningError },
);
}
throw provisioningError;
}
logger.info('Created agent config entry', { agentId: createdAgentId, inheritWorkspace: !!options?.inheritWorkspace });
return snapshot!;
}
export async function updateAgentName(agentId: string, name: string): Promise<AgentsSnapshot> {
return withConfigLock(async () => {
const config = await readOpenClawConfig() as AgentConfigDocument;
let snapshot: AgentsSnapshot | undefined;
const normalizedName = normalizeAgentName(name);
await mutateOpenClawConfig(async (configSnapshot) => {
const config = configSnapshot as AgentConfigDocument;
const { agentsConfig, entries } = normalizeAgentsConfig(config);
const normalizedName = normalizeAgentName(name);
const index = entries.findIndex((entry) => entry.id === agentId);
if (index === -1) {
throw new Error(`Agent "${agentId}" not found`);
@@ -648,10 +706,10 @@ export async function updateAgentName(agentId: string, name: string): Promise<Ag
list: entries,
};
await writeOpenClawConfig(config);
logger.info('Updated agent name', { agentId, name: normalizedName });
return buildSnapshotFromConfig(config);
snapshot = await buildSnapshotFromConfig(config);
});
logger.info('Updated agent name', { agentId, name: normalizedName });
return snapshot!;
}
function isValidModelRef(modelRef: string): boolean {
@@ -660,15 +718,16 @@ function isValidModelRef(modelRef: string): boolean {
}
export async function updateAgentModel(agentId: string, modelRef: string | null): Promise<AgentsSnapshot> {
return withConfigLock(async () => {
const config = await readOpenClawConfig() as AgentConfigDocument;
const normalizedModelRef = typeof modelRef === 'string' ? modelRef.trim() : '';
let snapshot: AgentsSnapshot | undefined;
await mutateOpenClawConfig(async (configSnapshot) => {
const config = configSnapshot as AgentConfigDocument;
const { agentsConfig, entries } = normalizeAgentsConfig(config);
const index = entries.findIndex((entry) => entry.id === agentId);
if (index === -1) {
throw new Error(`Agent "${agentId}" not found`);
}
const normalizedModelRef = typeof modelRef === 'string' ? modelRef.trim() : '';
const nextEntry: AgentListEntry = { ...entries[index] };
if (!normalizedModelRef) {
@@ -708,21 +767,24 @@ export async function updateAgentModel(agentId: string, modelRef: string | null)
list: entries,
};
await writeOpenClawConfig(config);
logger.info('Updated agent model', { agentId, modelRef: normalizedModelRef || null });
return buildSnapshotFromConfig(config);
snapshot = await buildSnapshotFromConfig(config);
});
logger.info('Updated agent model', { agentId, modelRef: normalizedModelRef || null });
return snapshot!;
}
export async function deleteAgentConfig(agentId: string): Promise<{ snapshot: AgentsSnapshot; removedEntry: AgentListEntry }> {
return withConfigLock(async () => {
if (agentId === MAIN_AGENT_ID) {
throw new Error('The main agent cannot be deleted');
}
if (agentId === MAIN_AGENT_ID) {
throw new Error('The main agent cannot be deleted');
}
const config = await readOpenClawConfig() as AgentConfigDocument;
let result: { snapshot: AgentsSnapshot; removedEntry: AgentListEntry } | undefined;
await mutateOpenClawConfig(async (configSnapshot) => {
const config = configSnapshot as AgentConfigDocument;
const { agentsConfig, entries, defaultAgentId } = normalizeAgentsConfig(config);
const snapshotBeforeDeletion = await buildSnapshotFromConfig(config);
const bindingsBeforeDeletion = Array.isArray(config.bindings)
? config.bindings.filter(isChannelBinding)
: [];
const removedEntry = entries.find((entry) => entry.id === agentId);
const nextEntries = entries.filter((entry) => entry.id !== agentId);
if (!removedEntry || nextEntries.length === entries.length) {
@@ -746,81 +808,87 @@ export async function deleteAgentConfig(agentId: string): Promise<{ snapshot: Ag
const normalizedAgentId = normalizeAgentIdForBinding(agentId);
const legacyAccountId = resolveAccountIdForAgent(agentId);
const { channelToAgent, accountToAgent } = getChannelBindingMap(bindingsBeforeDeletion);
const boundChannelTypes = new Set(bindingsBeforeDeletion.map((binding) => binding.match.channel));
const ownedLegacyAccounts = new Set(
Object.entries(snapshotBeforeDeletion.channelAccountOwners)
.filter(([channelAccountKey, owner]) => {
if (owner !== normalizedAgentId) return false;
const accountId = channelAccountKey.slice(channelAccountKey.indexOf(':') + 1);
return accountId === legacyAccountId;
[...boundChannelTypes]
.filter((channelType) => {
const accountOwner = accountToAgent.get(`${channelType}:${legacyAccountId}`);
const effectiveOwner = accountOwner
?? (legacyAccountId === DEFAULT_ACCOUNT_ID ? channelToAgent.get(channelType) : undefined);
return effectiveOwner === normalizedAgentId;
})
.map(([channelAccountKey]) => channelAccountKey),
.map((channelType) => `${channelType}:${legacyAccountId}`),
);
await writeOpenClawConfig(config);
await deleteAgentChannelAccounts(agentId, ownedLegacyAccounts);
await removeAgentRuntimeDirectory(agentId);
// NOTE: workspace directory is NOT deleted here intentionally.
// The caller (route handler) defers workspace removal until after
// the Gateway process has fully restarted, so that any in-flight
// process.chdir(workspace) calls complete before the directory
// disappears (otherwise process.cwd() throws ENOENT for the rest
// of the Gateway's lifetime).
logger.info('Deleted agent config entry', { agentId });
return { snapshot: await buildSnapshotFromConfig(config), removedEntry };
result = { snapshot: await buildSnapshotFromConfig(config), removedEntry };
});
await removeAgentRuntimeDirectory(agentId);
// The caller removes the workspace only after the coordinator commit above.
logger.info('Deleted agent config entry', { agentId });
return result!;
}
export async function assignChannelToAgent(agentId: string, channelType: string): Promise<AgentsSnapshot> {
return withConfigLock(async () => {
const config = await readOpenClawConfig() as AgentConfigDocument;
let snapshot: AgentsSnapshot | undefined;
const accountId = resolveAccountIdForAgent(agentId);
await mutateOpenClawConfig(async (configSnapshot) => {
const config = configSnapshot as AgentConfigDocument;
const { entries } = normalizeAgentsConfig(config);
if (!entries.some((entry) => entry.id === agentId)) {
throw new Error(`Agent "${agentId}" not found`);
}
const accountId = resolveAccountIdForAgent(agentId);
config.bindings = upsertBindingsForChannel(config.bindings, channelType, agentId, accountId);
await writeOpenClawConfig(config);
logger.info('Assigned channel to agent', { agentId, channelType, accountId });
return buildSnapshotFromConfig(config);
snapshot = await buildSnapshotFromConfig(config);
});
logger.info('Assigned channel to agent', { agentId, channelType, accountId });
return snapshot!;
}
export async function assignChannelAccountToAgent(
agentId: string,
channelType: string,
accountId: string,
options?: { migrateLegacy?: boolean },
): Promise<AgentsSnapshot> {
return withConfigLock(async () => {
const config = await readOpenClawConfig() as AgentConfigDocument;
const trimmedAccountId = accountId.trim();
if (!trimmedAccountId) {
throw new Error('accountId is required');
}
let snapshot: AgentsSnapshot | undefined;
await mutateOpenClawConfig(async (configSnapshot) => {
const config = configSnapshot as AgentConfigDocument;
const { entries } = normalizeAgentsConfig(config);
if (!entries.some((entry) => entry.id === agentId)) {
throw new Error(`Agent "${agentId}" not found`);
}
if (!accountId.trim()) {
throw new Error('accountId is required');
if (options?.migrateLegacy) {
const validAgentIds = new Set(entries.map((entry) => normalizeAgentIdForBinding(entry.id)));
migrateLegacyChannelBindingInConfig(config, channelType, validAgentIds);
}
config.bindings = upsertBindingsForChannel(config.bindings, channelType, agentId, accountId.trim());
await writeOpenClawConfig(config);
logger.info('Assigned channel account to agent', { agentId, channelType, accountId: accountId.trim() });
return buildSnapshotFromConfig(config);
config.bindings = upsertBindingsForChannel(config.bindings, channelType, agentId, trimmedAccountId);
snapshot = await buildSnapshotFromConfig(config);
});
logger.info('Assigned channel account to agent', { agentId, channelType, accountId: trimmedAccountId });
return snapshot!;
}
export async function clearChannelBinding(channelType: string, accountId?: string): Promise<AgentsSnapshot> {
return withConfigLock(async () => {
const config = await readOpenClawConfig() as AgentConfigDocument;
let snapshot: AgentsSnapshot | undefined;
await mutateOpenClawConfig(async (configSnapshot) => {
const config = configSnapshot as AgentConfigDocument;
config.bindings = upsertBindingsForChannel(config.bindings, channelType, null, accountId);
await writeOpenClawConfig(config);
logger.info('Cleared channel binding', { channelType, accountId });
return buildSnapshotFromConfig(config);
snapshot = await buildSnapshotFromConfig(config);
});
logger.info('Cleared channel binding', { channelType, accountId });
return snapshot!;
}
export async function clearAllBindingsForChannel(channelType: string): Promise<void> {
return withConfigLock(async () => {
const config = await readOpenClawConfig() as AgentConfigDocument;
await mutateOpenClawConfig((configSnapshot) => {
const config = configSnapshot as AgentConfigDocument;
if (!Array.isArray(config.bindings)) return;
const nextBindings = config.bindings.filter((binding) => {
@@ -829,7 +897,77 @@ export async function clearAllBindingsForChannel(channelType: string): Promise<v
});
config.bindings = nextBindings.length > 0 ? nextBindings : undefined;
await writeOpenClawConfig(config);
logger.info('Cleared all bindings for channel', { channelType });
});
logger.info('Cleared all bindings for channel', { channelType });
}
function migrateLegacyChannelBindingInConfig(
config: AgentConfigDocument,
channelType: string,
validAgentIds: Set<string>,
): void {
const { channelToAgent, accountToAgent } = getChannelBindingMap(config.bindings);
const legacyOwner = channelToAgent.get(channelType);
if (!legacyOwner) return;
const explicitDefaultOwner = accountToAgent.get(`${channelType}:${DEFAULT_ACCOUNT_ID}`);
const defaultOwner = explicitDefaultOwner && validAgentIds.has(explicitDefaultOwner)
? explicitDefaultOwner
: (validAgentIds.has(legacyOwner) ? legacyOwner : null);
if (defaultOwner) {
config.bindings = upsertBindingsForChannel(
config.bindings,
channelType,
defaultOwner,
DEFAULT_ACCOUNT_ID,
);
}
config.bindings = upsertBindingsForChannel(config.bindings, channelType, null);
}
export async function migrateLegacyChannelWideBinding(channelType: string): Promise<void> {
await mutateOpenClawConfig((configSnapshot) => {
const config = configSnapshot as AgentConfigDocument;
const { entries } = normalizeAgentsConfig(config);
const validAgentIds = new Set(entries.map((entry) => normalizeAgentIdForBinding(entry.id)));
migrateLegacyChannelBindingInConfig(config, channelType, validAgentIds);
});
logger.info('Migrated legacy channel-wide binding', { channelType });
}
export async function ensureScopedChannelBinding(channelType: string, accountId?: string): Promise<void> {
const normalizedAccountId = accountId?.trim();
if (!normalizedAccountId) return;
await mutateOpenClawConfig((configSnapshot) => {
const config = configSnapshot as AgentConfigDocument;
const { entries } = normalizeAgentsConfig(config);
if (entries.length === 0) return;
const validAgentIds = new Set(entries.map((entry) => normalizeAgentIdForBinding(entry.id)));
if (normalizedAccountId === DEFAULT_ACCOUNT_ID) {
const mainAgent = entries.find((entry) => entry.id === MAIN_AGENT_ID);
if (mainAgent) {
config.bindings = upsertBindingsForChannel(
config.bindings,
channelType,
mainAgent.id,
DEFAULT_ACCOUNT_ID,
);
}
return;
}
migrateLegacyChannelBindingInConfig(config, channelType, validAgentIds);
const accountAgent = entries.find((entry) => entry.id === normalizedAccountId);
if (accountAgent) {
config.bindings = upsertBindingsForChannel(
config.bindings,
channelType,
accountAgent.id,
normalizedAccountId,
);
}
});
logger.info('Ensured scoped channel binding', { channelType, accountId: normalizedAccountId });
}
+1 -1
View File
@@ -19,7 +19,7 @@ import {
export type BrowserOAuthProviderType = 'openai';
const OPENAI_RUNTIME_PROVIDER_ID = 'openai';
const OPENAI_OAUTH_DEFAULT_MODEL = 'gpt-5.5';
const OPENAI_OAUTH_DEFAULT_MODEL = 'gpt-5.6-sol';
class BrowserOAuthManager extends EventEmitter {
private activeAccountId: string | null = null;
+208 -142
View File
@@ -8,10 +8,10 @@ import { access, mkdir, readFile, writeFile, readdir, stat, rm } from 'fs/promis
import { constants } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { getOpenClawResolvedDir } from './paths';
import { mutateOpenClawConfig, readOpenClawConfigSnapshot } from '../gateway/config-delivery';
import { getOpenClawResolvedDir, resolveOpenClawConfigPath } from './paths';
import * as logger from './logger';
import { proxyAwareFetch } from './proxy-fetch';
import { withConfigLock } from './config-mutex';
import {
OPENCLAW_WECHAT_CHANNEL_TYPE,
isWechatChannelType,
@@ -20,7 +20,6 @@ import {
} from './channel-alias';
const OPENCLAW_DIR = join(homedir(), '.openclaw');
const CONFIG_FILE = join(OPENCLAW_DIR, 'openclaw.json');
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;
@@ -154,8 +153,7 @@ function sanitizeDiscordGuilds(config: unknown): void {
/**
* Strip `defaultAccount` from channel sections whose plugin schema
* declares additionalProperties:false without listing `defaultAccount`.
* Call right before every `writeOpenClawConfig` in channel-config
* mutation functions.
* Call before committing channel-config mutations.
*/
function sanitizeChannelSectionsBeforeWrite(config: OpenClawConfig): void {
if (!config.channels) return;
@@ -366,6 +364,51 @@ function ensurePluginRegistration(currentConfig: OpenClawConfig, pluginId: strin
currentConfig.plugins.entries[pluginId].enabled = true;
}
function syncPluginChannelAccountMirror(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;
}
function cleanupLegacyBuiltInChannelPluginRegistration(
currentConfig: OpenClawConfig,
channelType: string,
@@ -455,22 +498,10 @@ export interface OpenClawConfig {
// ── Config I/O ───────────────────────────────────────────────────
async function ensureConfigDir(): Promise<void> {
if (!(await fileExists(OPENCLAW_DIR))) {
await mkdir(OPENCLAW_DIR, { recursive: true });
}
}
export async function readOpenClawConfig(): Promise<OpenClawConfig> {
await ensureConfigDir();
if (!(await fileExists(CONFIG_FILE))) {
return {};
}
try {
const content = await readFile(CONFIG_FILE, 'utf-8');
return JSON.parse(content) as OpenClawConfig;
const snapshot = await readOpenClawConfigSnapshot();
return snapshot.config as OpenClawConfig;
} catch (error) {
logger.error('Failed to read OpenClaw config', error);
console.error('Failed to read OpenClaw config:', error);
@@ -478,26 +509,6 @@ export async function readOpenClawConfig(): Promise<OpenClawConfig> {
}
}
export async function writeOpenClawConfig(config: OpenClawConfig): Promise<void> {
await ensureConfigDir();
try {
// Enable graceful in-process reload authorization for SIGUSR1 flows.
const commands =
config.commands && typeof config.commands === 'object'
? { ...(config.commands as Record<string, unknown>) }
: {};
commands.restart = true;
config.commands = commands;
await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
} catch (error) {
logger.error('Failed to write OpenClaw config', error);
console.error('Failed to write OpenClaw config:', error);
throw error;
}
}
// ── Channel operations ───────────────────────────────────────────
async function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType: string): Promise<void> {
@@ -832,10 +843,12 @@ export async function saveChannelConfig(
config: ChannelConfigData,
accountId?: string,
): Promise<void> {
return withConfigLock(async () => {
const resolvedChannelType = resolveStoredChannelType(channelType);
const currentConfig = await readOpenClawConfig();
const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID;
const resolvedChannelType = resolveStoredChannelType(channelType);
const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID;
let transformedKeys: string[] = [];
await mutateOpenClawConfig(async (snapshot) => {
const currentConfig = snapshot as OpenClawConfig;
cleanupLegacyBuiltInChannelPluginRegistration(currentConfig, resolvedChannelType);
await ensurePluginAllowlist(currentConfig, resolvedChannelType);
@@ -859,6 +872,7 @@ export async function saveChannelConfig(
const existingAccountConfig = resolveAccountConfig(channelSection, resolvedAccountId);
const transformedConfig = transformChannelConfig(resolvedChannelType, config, existingAccountConfig);
transformedKeys = Object.keys(transformedConfig);
const uniqueKey = CHANNEL_UNIQUE_CREDENTIAL_KEY[resolvedChannelType];
if (uniqueKey && typeof transformedConfig[uniqueKey] === 'string') {
const rawCredentialValue = transformedConfig[uniqueKey] as string;
@@ -920,16 +934,15 @@ export async function saveChannelConfig(
}
sanitizeChannelSectionsBeforeWrite(currentConfig);
await writeOpenClawConfig(currentConfig);
logger.info('Channel config saved', {
channelType: resolvedChannelType,
accountId: resolvedAccountId,
configFile: CONFIG_FILE,
rawKeys: Object.keys(config),
transformedKeys: Object.keys(transformedConfig),
});
console.log(`Saved channel config for ${resolvedChannelType} account ${resolvedAccountId}`);
});
logger.info('Channel config saved', {
channelType: resolvedChannelType,
accountId: resolvedAccountId,
configFile: resolveOpenClawConfigPath(),
rawKeys: Object.keys(config),
transformedKeys,
});
console.log(`Saved channel config for ${resolvedChannelType} account ${resolvedAccountId}`);
}
export async function getChannelConfig(channelType: string, accountId?: string): Promise<ChannelConfigData | undefined> {
@@ -1003,35 +1016,54 @@ export async function getChannelFormValues(channelType: string, accountId?: stri
}
export async function deleteChannelAccountConfig(channelType: string, accountId: string): Promise<void> {
return withConfigLock(async () => {
const resolvedChannelType = resolveStoredChannelType(channelType);
const currentConfig = await readOpenClawConfig();
const resolvedChannelType = resolveStoredChannelType(channelType);
let deleteWeChatAccount = false;
let deletedAccount = false;
await mutateOpenClawConfig((snapshot) => {
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);
await writeOpenClawConfig(currentConfig);
await deleteWeChatAccountState(accountId);
deleteWeChatAccount = true;
}
if (deletedPluginAccount) {
deletedAccount = true;
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
sanitizeChannelSectionsBeforeWrite(currentConfig);
}
return;
}
migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID);
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;
}
const currentDefaultAccountId = typeof channelSection.defaultAccount === 'string'
&& channelSection.defaultAccount.trim()
? channelSection.defaultAccount.trim()
: DEFAULT_ACCOUNT_ID;
migrateLegacyChannelConfigToAccounts(channelSection, currentDefaultAccountId);
const accounts = getChannelAccountsMap(channelSection);
if (!accounts?.[accountId]) {
// Account not found; just ensure top-level mirror is consistent
const mirroredAccountId = typeof channelSection.defaultAccount === 'string' && channelSection.defaultAccount.trim() ? channelSection.defaultAccount : DEFAULT_ACCOUNT_ID;
const defaultAccountData = accounts?.[mirroredAccountId] ?? accounts?.[DEFAULT_ACCOUNT_ID];
if (defaultAccountData) {
for (const [key, value] of Object.entries(defaultAccountData)) {
channelSection[key] = value;
}
}
return;
}
if (!accounts?.[accountId]) return;
delete accounts[accountId];
deletedAccount = true;
if (Object.keys(accounts).length === 0) {
delete currentConfig.channels![resolvedChannelType];
@@ -1064,21 +1096,31 @@ export async function deleteChannelAccountConfig(channelType: string, accountId:
}
}
syncPluginChannelAccountMirror(currentConfig, resolvedChannelType);
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
sanitizeChannelSectionsBeforeWrite(currentConfig);
await writeOpenClawConfig(currentConfig);
if (isWechatChannelType(resolvedChannelType)) {
await deleteWeChatAccountState(accountId);
deleteWeChatAccount = true;
}
});
if (deleteWeChatAccount) {
await deleteWeChatAccountState(accountId);
}
if (deletedAccount) {
logger.info('Deleted channel account config', { channelType: resolvedChannelType, accountId });
console.log(`Deleted channel account config for ${resolvedChannelType}/${accountId}`);
});
}
}
export async function deleteChannelConfig(channelType: string): Promise<void> {
return withConfigLock(async () => {
const resolvedChannelType = resolveStoredChannelType(channelType);
const currentConfig = await readOpenClawConfig();
const resolvedChannelType = resolveStoredChannelType(channelType);
let deleteWeChat = false;
let deletedConfig: 'channel' | 'plugin' | undefined;
await mutateOpenClawConfig((snapshot) => {
deleteWeChat = false;
deletedConfig = undefined;
const currentConfig = snapshot as OpenClawConfig;
cleanupLegacyBuiltInChannelPluginRegistration(currentConfig, resolvedChannelType);
if (currentConfig.channels?.[resolvedChannelType]) {
@@ -1103,37 +1145,43 @@ export async function deleteChannelConfig(channelType: string): Promise<void> {
removePluginRegistration(currentConfig, WECOM_PLUGIN_ID);
}
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
await writeOpenClawConfig(currentConfig);
if (isWechatChannelType(resolvedChannelType)) {
await deleteWeChatState();
deleteWeChat = true;
}
console.log(`Deleted channel config for ${resolvedChannelType}`);
deletedConfig = 'channel';
} else if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
if (currentConfig.plugins?.entries?.[resolvedChannelType] || currentConfig.plugins?.allow?.includes(resolvedChannelType)) {
removePluginRegistration(currentConfig, resolvedChannelType);
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
await writeOpenClawConfig(currentConfig);
console.log(`Deleted plugin channel config for ${resolvedChannelType}`);
deletedConfig = 'plugin';
}
} else if (isWechatChannelType(resolvedChannelType)) {
removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
await writeOpenClawConfig(currentConfig);
await deleteWeChatState();
}
if (resolvedChannelType === 'whatsapp') {
try {
const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp');
if (await fileExists(whatsappDir)) {
await rm(whatsappDir, { recursive: true, force: true });
console.log('Deleted WhatsApp credentials directory');
}
} catch (error) {
console.error('Failed to delete WhatsApp credentials:', error);
}
deleteWeChat = true;
}
});
if (deleteWeChat) {
await deleteWeChatState();
}
if (deletedConfig === 'channel') {
console.log(`Deleted channel config for ${resolvedChannelType}`);
} else if (deletedConfig === 'plugin') {
console.log(`Deleted plugin channel config for ${resolvedChannelType}`);
}
if (resolvedChannelType === 'whatsapp') {
try {
const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp');
if (await fileExists(whatsappDir)) {
await rm(whatsappDir, { recursive: true, force: true });
console.log('Deleted WhatsApp credentials directory');
}
} catch (error) {
console.error('Failed to delete WhatsApp credentials:', error);
}
}
}
function channelHasAnyAccount(channelSection: ChannelConfigData): boolean {
@@ -1247,14 +1295,14 @@ export async function listConfiguredChannelAccounts(): Promise<Record<string, Co
}
export async function setChannelDefaultAccount(channelType: string, accountId: string): Promise<void> {
return withConfigLock(async () => {
const resolvedChannelType = resolveStoredChannelType(channelType);
const trimmedAccountId = accountId.trim();
if (!trimmedAccountId) {
throw new Error('accountId is required');
}
const resolvedChannelType = resolveStoredChannelType(channelType);
const trimmedAccountId = accountId.trim();
if (!trimmedAccountId) {
throw new Error('accountId is required');
}
const currentConfig = await readOpenClawConfig();
await mutateOpenClawConfig((snapshot) => {
const currentConfig = snapshot as OpenClawConfig;
const channelSection = currentConfig.channels?.[resolvedChannelType];
if (!channelSection) {
throw new Error(`Channel "${resolvedChannelType}" is not configured`);
@@ -1275,38 +1323,37 @@ export async function setChannelDefaultAccount(channelType: string, accountId: s
}
sanitizeChannelSectionsBeforeWrite(currentConfig);
await writeOpenClawConfig(currentConfig);
logger.info('Set channel default account', { channelType: resolvedChannelType, accountId: trimmedAccountId });
});
logger.info('Set channel default account', { channelType: resolvedChannelType, accountId: trimmedAccountId });
}
export async function deleteAgentChannelAccounts(agentId: string, ownedChannelAccounts?: Set<string>): Promise<void> {
return withConfigLock(async () => {
const currentConfig = await readOpenClawConfig();
if (!currentConfig.channels) return;
let modified = false;
const accountId = agentId === 'main' ? DEFAULT_ACCOUNT_ID : agentId;
const accountId = agentId === 'main' ? DEFAULT_ACCOUNT_ID : agentId;
let modified = false;
await mutateOpenClawConfig((snapshot) => {
modified = false;
const currentConfig = snapshot as OpenClawConfig;
const channels = currentConfig.channels ?? {};
for (const channelType of Object.keys(channels)) {
if (ownedChannelAccounts && !ownedChannelAccounts.has(`${channelType}:${accountId}`)) continue;
const section = channels[channelType];
const existingAccounts = getChannelAccountsMap(section);
const targetsLegacyDefault = accountId === DEFAULT_ACCOUNT_ID
&& Object.keys(getLegacyChannelPayload(section)).length > 0;
if (!existingAccounts?.[accountId] && !targetsLegacyDefault) continue;
for (const channelType of Object.keys(currentConfig.channels)) {
const section = currentConfig.channels[channelType];
migrateLegacyChannelConfigToAccounts(section, DEFAULT_ACCOUNT_ID);
const currentDefaultAccountId = typeof section.defaultAccount === 'string'
&& section.defaultAccount.trim()
? section.defaultAccount.trim()
: DEFAULT_ACCOUNT_ID;
migrateLegacyChannelConfigToAccounts(section, currentDefaultAccountId);
const accounts = getChannelAccountsMap(section);
if (!accounts?.[accountId] || (ownedChannelAccounts && !ownedChannelAccounts.has(`${channelType}:${accountId}`))) {
// Ensure top-level mirror is consistent.
const mirroredAccountId = typeof section.defaultAccount === 'string' && section.defaultAccount.trim() ? section.defaultAccount : DEFAULT_ACCOUNT_ID;
const defaultAccountData = accounts?.[mirroredAccountId] ?? accounts?.[DEFAULT_ACCOUNT_ID];
if (defaultAccountData) {
for (const [key, value] of Object.entries(defaultAccountData)) {
section[key] = value;
}
}
continue;
}
if (!accounts?.[accountId]) continue;
delete accounts[accountId];
if (Object.keys(accounts).length === 0) {
delete currentConfig.channels[channelType];
delete channels[channelType];
} else {
if (section.defaultAccount === accountId) {
const nextDefaultAccountId = Object.keys(accounts).sort((a, b) => {
@@ -1331,21 +1378,37 @@ export async function deleteAgentChannelAccounts(agentId: string, ownedChannelAc
}
}
}
syncPluginChannelAccountMirror(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);
await writeOpenClawConfig(currentConfig);
logger.info('Deleted all channel accounts for agent', { agentId, accountId });
}
});
if (modified) {
logger.info('Deleted all channel accounts for agent', { agentId, accountId });
}
}
export async function setChannelEnabled(channelType: string, enabled: boolean): Promise<void> {
return withConfigLock(async () => {
const resolvedChannelType = resolveStoredChannelType(channelType);
const currentConfig = await readOpenClawConfig();
const resolvedChannelType = resolveStoredChannelType(channelType);
let pluginChannel = false;
await mutateOpenClawConfig(async (snapshot) => {
pluginChannel = false;
const currentConfig = snapshot as OpenClawConfig;
cleanupLegacyBuiltInChannelPluginRegistration(currentConfig, resolvedChannelType);
if (isWechatChannelType(resolvedChannelType)) {
@@ -1357,6 +1420,7 @@ export async function setChannelEnabled(channelType: string, enabled: boolean):
}
if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
pluginChannel = true;
if (enabled) {
ensurePluginRegistration(currentConfig, resolvedChannelType);
} else {
@@ -1369,8 +1433,6 @@ export async function setChannelEnabled(channelType: string, enabled: boolean):
if (!pluginEntry) throw new Error(`Plugin entry not initialized: ${resolvedChannelType}`);
pluginEntry.enabled = enabled;
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
await writeOpenClawConfig(currentConfig);
console.log(`Set plugin channel ${resolvedChannelType} enabled: ${enabled}`);
return;
}
@@ -1378,32 +1440,36 @@ export async function setChannelEnabled(channelType: string, enabled: boolean):
if (!currentConfig.channels[resolvedChannelType]) currentConfig.channels[resolvedChannelType] = {};
currentConfig.channels[resolvedChannelType].enabled = enabled;
syncBuiltinChannelsWithPluginAllowlist(currentConfig, enabled ? [resolvedChannelType] : []);
await writeOpenClawConfig(currentConfig);
console.log(`Set channel ${resolvedChannelType} enabled: ${enabled}`);
});
console.log(`Set ${pluginChannel ? 'plugin channel' : 'channel'} ${resolvedChannelType} enabled: ${enabled}`);
}
export async function cleanupDanglingWeChatPluginState(): Promise<{ cleanedDanglingState: boolean }> {
return withConfigLock(async () => {
const currentConfig = await readOpenClawConfig();
let cleanedDanglingState = false;
let hasConfiguredWeChatAccounts = false;
await mutateOpenClawConfig((snapshot) => {
cleanedDanglingState = false;
hasConfiguredWeChatAccounts = false;
const currentConfig = snapshot as OpenClawConfig;
const channelSection = currentConfig.channels?.[WECHAT_PLUGIN_ID];
const hasConfiguredWeChatAccounts = channelHasConfiguredAccounts(channelSection);
hasConfiguredWeChatAccounts = channelHasConfiguredAccounts(channelSection);
const hadPluginRegistration = Boolean(
currentConfig.plugins?.entries?.[WECHAT_PLUGIN_ID]
|| currentConfig.plugins?.allow?.includes(WECHAT_PLUGIN_ID),
);
if (hasConfiguredWeChatAccounts) {
return { cleanedDanglingState: false };
return;
}
const modified = removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
if (modified) {
await writeOpenClawConfig(currentConfig);
}
await deleteWeChatState();
return { cleanedDanglingState: hadPluginRegistration || modified };
cleanedDanglingState = hadPluginRegistration || modified;
});
if (!hasConfiguredWeChatAccounts) {
await deleteWeChatState();
}
return { cleanedDanglingState };
}
// ── Validation ───────────────────────────────────────────────────
+300 -254
View File
@@ -28,10 +28,18 @@ import {
isOpenClawOAuthPluginProviderKey,
} from './provider-keys';
import { normalizePiAiModelCost, type PiAiModelCostRates } from '../shared/pi-ai-model-cost';
import { withConfigLock } from './config-mutex';
import { ensureMemorySearchDisabledDefault, hasUserMemorySearchConfig } from './openclaw-memory-search';
import {
mutateOpenClawConfig,
readOpenClawConfigSnapshot,
reloadOpenClawSecretsIfRunning,
} from '../gateway/config-delivery';
import {
ensureMemorySearchFtsDefault,
hasUserMemorySearchConfig,
MEMORY_SEARCH_FTS_MIGRATION_VERSION,
} from './openclaw-memory-search';
import { PORTS } from './config';
import { getSetting } from './store';
import { getSetting, setSetting } from './store';
import {
assertValidApiProtocol,
normalizeOpenClawApiProtocol,
@@ -426,12 +434,6 @@ async function readAuthProfiles(agentId = 'main'): Promise<AuthProfilesStore> {
const jsonStore = await readAuthProfilesJson(agentId);
if (jsonStore?.profiles && Object.keys(jsonStore.profiles).length > 0) {
try {
writeAuthProfilesToSqlite(jsonStore, agentId);
console.log(`[auth-sync] Backfilled SQLite auth store from JSON for agent "${agentId}"`);
} catch (error) {
console.warn(`Failed to backfill SQLite auth store for agent "${agentId}":`, error);
}
return jsonStore;
}
@@ -440,19 +442,27 @@ async function readAuthProfiles(agentId = 'main'): Promise<AuthProfilesStore> {
async function writeAuthProfiles(store: AuthProfilesStore, agentId = 'main'): Promise<void> {
writeAuthProfilesToSqlite(store, agentId);
await writeJsonFile(getAuthProfilesPath(agentId), store);
try {
await writeJsonFile(getAuthProfilesPath(agentId), store);
} catch (error) {
console.warn(`Failed to update compatibility auth-profiles.json for agent "${agentId}":`, error);
}
}
/** Migrate legacy JSON-only auth profiles into SQLite for all configured agents. */
export async function migrateAllAgentAuthProfilesToSqlite(): Promise<void> {
const agentIds = await discoverAgentIds();
let migrated = false;
for (const agentId of agentIds) {
try {
await migrateAuthProfilesJsonToSqliteIfNeeded(agentId);
migrated = await migrateAuthProfilesJsonToSqliteIfNeeded(agentId) || migrated;
} catch (error) {
console.warn(`Failed to migrate auth profiles to SQLite for agent "${agentId}":`, error);
}
}
if (migrated) {
await reloadOpenClawSecretsIfRunning();
}
}
function getApiKeyFromAuthProfilesStore(
@@ -520,7 +530,6 @@ async function discoverAgentIds(): Promise<string[]> {
// ── OpenClaw Config Helpers ──────────────────────────────────────
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
const FEISHU_PLUGIN_ID_CANDIDATES = ['openclaw-lark', 'feishu-openclaw-plugin'] as const;
const VALID_COMPACTION_MODES = new Set(['default', 'safeguard']);
/** Matches OpenClaw's 200k+ context-window recommendation (see computeContextAwareReserveTokensFloor). */
@@ -687,8 +696,11 @@ async function getProvidersFromAuthProfileStores(
return providers;
}
async function collectActiveProviderIdsFromConfig(config: Record<string, unknown>): Promise<Set<string>> {
const activeProviders = new Set<string>();
function collectActiveProviderIdsFromConfig(
config: Record<string, unknown>,
authProfileProviders: Iterable<string> = [],
): Set<string> {
const activeProviders = new Set(authProfileProviders);
const providers = (config.models as Record<string, unknown> | undefined)?.providers;
if (providers && typeof providers === 'object') {
for (const key of Object.keys(providers as Record<string, unknown>)) {
@@ -720,11 +732,6 @@ async function collectActiveProviderIdsFromConfig(config: Record<string, unknown
{ includeRawKeys: true },
);
const authProfileProviders = await getProvidersFromAuthProfileStores({ includeRawKeys: true });
for (const provider of authProfileProviders) {
activeProviders.add(provider);
}
for (const deprecated of DEPRECATED_PROVIDER_IDS) {
activeProviders.delete(deprecated);
}
@@ -733,7 +740,7 @@ async function collectActiveProviderIdsFromConfig(config: Record<string, unknown
}
async function readOpenClawJson(): Promise<Record<string, unknown>> {
return (await readJsonFile<Record<string, unknown>>(OPENCLAW_CONFIG_PATH)) ?? {};
return (await readOpenClawConfigSnapshot()).config;
}
async function resolveInstalledFeishuPluginId(): Promise<string | null> {
@@ -922,7 +929,10 @@ function backfillCustomProviderModelContextWindows(config: Record<string, unknow
for (const row of rows) {
if (!isPlainRecord(row) || typeof row.id !== 'string' || !row.id) continue;
if (typeof row.contextWindow === 'number' || typeof row.contextTokens === 'number') continue;
row.contextWindow = inferCustomModelContextWindow(row.id);
row.contextWindow = inferCustomModelContextWindow(row.id, {
providerKey,
apiProtocol: typeof entry.api === 'string' ? entry.api : undefined,
});
backfilled.push(`${providerKey}/${row.id}`);
}
}
@@ -930,21 +940,6 @@ function backfillCustomProviderModelContextWindows(config: Record<string, unknow
return backfilled;
}
async function writeOpenClawJson(config: Record<string, unknown>): Promise<void> {
normalizeAgentsDefaultsCompactionMode(config);
// Ensure SIGUSR1 graceful reload is authorized by OpenClaw config.
const commands = (
config.commands && typeof config.commands === 'object'
? { ...(config.commands as Record<string, unknown>) }
: {}
) as Record<string, unknown>;
commands.restart = true;
config.commands = commands;
await writeJsonFile(OPENCLAW_CONFIG_PATH, config);
}
// ── Exported Functions (all async) ───────────────────────────────
/**
@@ -991,6 +986,7 @@ export async function saveOAuthTokenToOpenClaw(
await writeAuthProfiles(store, id);
}
await reloadOpenClawSecretsIfRunning();
console.log(`Saved OAuth token for provider "${provider}" to OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`);
}
@@ -1052,6 +1048,7 @@ export async function saveProviderKeyToOpenClaw(
await writeAuthProfiles(store, id);
}
await reloadOpenClawSecretsIfRunning();
console.log(`Saved API key for provider "${provider}" to OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`);
}
@@ -1064,13 +1061,18 @@ export async function removeProviderKeyFromOpenClaw(
): Promise<void> {
const agentIds = agentId ? [agentId] : await discoverAgentIds();
if (agentIds.length === 0) agentIds.push('main');
let modified = false;
for (const id of agentIds) {
const store = await readAuthProfiles(id);
if (removeProfileFromStore(store, `${provider}:default`, 'api_key')) {
await writeAuthProfiles(store, id);
modified = true;
}
}
if (modified) {
await reloadOpenClawSecretsIfRunning();
}
console.log(`Removed API key for provider "${provider}" from OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`);
}
@@ -1128,7 +1130,6 @@ function isRuntimeGeneratedProviderKey(providerKey: string): boolean {
function pruneStaleRuntimeModelConfig(
modelCfg: Record<string, unknown>,
activeProviders: Set<string>,
context: string,
): boolean {
let modified = false;
const primary = typeof modelCfg.primary === 'string' ? modelCfg.primary.trim() : '';
@@ -1141,7 +1142,6 @@ function pruneStaleRuntimeModelConfig(
) {
delete modelCfg.primary;
modified = true;
console.log(`Removed stale runtime model ref "${primary}" from ${context}`);
}
}
@@ -1165,8 +1165,13 @@ function pruneStaleRuntimeModelConfig(
* Drop agent model refs that point at deleted custom/ollama runtime providers.
* Built-in providers are left intact because they may still resolve via auth/env.
*/
export async function pruneStaleRuntimeAgentModelRefs(config: Record<string, unknown>): Promise<boolean> {
const activeProviders = await getActiveOpenClawProviders();
export async function pruneStaleRuntimeAgentModelRefs(
config: Record<string, unknown>,
authProfileProviders?: Iterable<string>,
): Promise<boolean> {
const activeProviders = authProfileProviders
? collectActiveProviderIdsFromConfig(config, authProfileProviders)
: await getActiveOpenClawProviders();
const agents = config.agents;
if (!isPlainRecord(agents)) return false;
@@ -1174,7 +1179,7 @@ export async function pruneStaleRuntimeAgentModelRefs(config: Record<string, unk
const agentDefaults = agents.defaults;
if (isPlainRecord(agentDefaults) && isPlainRecord(agentDefaults.model)) {
if (pruneStaleRuntimeModelConfig(agentDefaults.model, activeProviders, 'agents.defaults.model')) {
if (pruneStaleRuntimeModelConfig(agentDefaults.model, activeProviders)) {
deleteModelConfigIfEmpty(agentDefaults);
modified = true;
}
@@ -1183,8 +1188,7 @@ export async function pruneStaleRuntimeAgentModelRefs(config: Record<string, unk
if (Array.isArray(agents.list)) {
for (const entry of agents.list) {
if (!isPlainRecord(entry) || !isPlainRecord(entry.model)) continue;
const agentId = typeof entry.id === 'string' ? entry.id : 'unknown';
if (pruneStaleRuntimeModelConfig(entry.model, activeProviders, `agent "${agentId}" model override`)) {
if (pruneStaleRuntimeModelConfig(entry.model, activeProviders)) {
deleteModelConfigIfEmpty(entry);
modified = true;
}
@@ -1195,50 +1199,13 @@ export async function pruneStaleRuntimeAgentModelRefs(config: Record<string, unk
}
export async function removeProviderFromOpenClaw(provider: string): Promise<void> {
// 1. Remove from auth-profiles.json.
// We must also remove entries whose raw `provider` field maps to this UI
// provider key via AUTH_PROFILE_PROVIDER_KEY_MAP (e.g. "openai-codex" → "openai").
// If those entries survive, getProvidersFromAuthProfileStores() will re-add
// the provider and trigger a re-seed loop in listAccounts().
const providerKeysToRemove = expandProviderKeysForDeletion(provider);
const agentIds = await discoverAgentIds();
if (agentIds.length === 0) agentIds.push('main');
for (const id of agentIds) {
const store = await readAuthProfiles(id);
let storeModified = false;
for (const key of providerKeysToRemove) {
if (removeProfilesForProvider(store, key)) {
storeModified = true;
}
}
if (storeModified) {
await writeAuthProfiles(store, id);
}
}
// 2. Remove from models.json (per-agent model registry used by pi-ai directly)
for (const id of agentIds) {
const modelsPath = join(homedir(), '.openclaw', 'agents', id, 'agent', 'models.json');
try {
if (await fileExists(modelsPath)) {
const raw = await readFile(modelsPath, 'utf-8');
const data = JSON.parse(raw) as Record<string, unknown>;
const providers = data.providers as Record<string, unknown> | undefined;
if (providers && providers[provider]) {
delete providers[provider];
await writeFile(modelsPath, JSON.stringify(data, null, 2), 'utf-8');
console.log(`Removed models.json entry for provider "${provider}" (agent "${id}")`);
}
}
} catch (err) {
console.warn(`Failed to remove provider ${provider} from models.json (agent "${id}"):`, err);
}
}
// 3. Remove from openclaw.json
try {
await withConfigLock(async () => {
const config = await readOpenClawJson();
let authProfilesModified = false;
// Commit the authoritative config first. If this fails, sidecar credentials
// and model registries remain untouched and the caller can safely retry.
await mutateOpenClawConfig(async (config) => {
let modified = false;
// Remove plugin registrations for OAuth providers (e.g. MiniMax).
@@ -1246,7 +1213,6 @@ export async function removeProviderFromOpenClaw(provider: string): Promise<void
const { canonicalPluginId, stalePluginIds } = getOAuthPluginRegistration(provider);
if (removePluginRegistrations(config, [canonicalPluginId, ...stalePluginIds])) {
modified = true;
console.log(`Removed OpenClaw plugin registrations for provider "${provider}"`);
}
}
@@ -1256,7 +1222,6 @@ export async function removeProviderFromOpenClaw(provider: string): Promise<void
if (providers[provider]) {
delete providers[provider];
modified = true;
console.log(`Removed OpenClaw provider config: ${provider}`);
}
const auth = (config.auth && typeof config.auth === 'object'
@@ -1277,7 +1242,6 @@ export async function removeProviderFromOpenClaw(provider: string): Promise<void
}
delete authProfiles[profileId];
modified = true;
console.log(`Removed OpenClaw auth profile: ${profileId}`);
}
}
@@ -1294,7 +1258,6 @@ export async function removeProviderFromOpenClaw(provider: string): Promise<void
if (removeProviderPrefixFromModelConfig(modelCfg, providerPrefix)) {
deleteModelConfigIfEmpty(agentDefaults);
modified = true;
console.log(`Removed deleted provider "${provider}" from agents.defaults.model`);
}
}
@@ -1302,21 +1265,69 @@ export async function removeProviderFromOpenClaw(provider: string): Promise<void
if (Array.isArray(agentList)) {
for (const entry of agentList) {
if (!isPlainRecord(entry) || !isPlainRecord(entry.model)) continue;
const agentId = typeof entry.id === 'string' ? entry.id : 'unknown';
if (removeProviderPrefixFromModelConfig(entry.model, providerPrefix)) {
deleteModelConfigIfEmpty(entry);
modified = true;
console.log(`Removed deleted provider "${provider}" from agent "${agentId}" model override`);
}
}
}
if (modified) {
await writeOpenClawJson(config);
normalizeAgentsDefaultsCompactionMode(config);
}
});
} catch (err) {
console.warn(`Failed to remove provider ${provider} from openclaw.json:`, err);
});
// Remove the provider from each per-agent model registry used by pi-ai.
for (const id of agentIds) {
const modelsPath = join(homedir(), '.openclaw', 'agents', id, 'agent', 'models.json');
if (!(await fileExists(modelsPath))) continue;
const raw = await readFile(modelsPath, 'utf-8');
const data = JSON.parse(raw) as Record<string, unknown>;
const providers = data.providers as Record<string, unknown> | undefined;
if (providers && providers[provider]) {
delete providers[provider];
await writeFile(modelsPath, JSON.stringify(data, null, 2), 'utf-8');
console.log(`Removed models.json entry for provider "${provider}" (agent "${id}")`);
}
}
// Remove auth entries whose raw provider maps to this UI provider key
// (for example "openai-codex" -> "openai"). Keep this last so every
// successful auth batch can immediately refresh the running snapshot.
let authWriteError: unknown;
try {
for (const id of agentIds) {
const store = await readAuthProfiles(id);
let storeModified = false;
for (const key of providerKeysToRemove) {
if (removeProfilesForProvider(store, key)) {
storeModified = true;
}
}
if (storeModified) {
await writeAuthProfiles(store, id);
authProfilesModified = true;
}
}
} catch (error) {
authWriteError = error;
}
if (authProfilesModified) {
try {
await reloadOpenClawSecretsIfRunning();
} catch (reloadError) {
if (authWriteError) {
throw new AggregateError(
[authWriteError, reloadError],
`Failed to remove provider "${provider}" auth profiles and refresh OpenClaw secrets`,
{ cause: reloadError },
);
}
throw reloadError;
}
}
if (authWriteError) {
throw authWriteError;
}
}
@@ -1445,8 +1456,8 @@ function migrateOpenAiCodexOAuthRuntimeToOpenAiInConfig(config: Record<string, u
export async function pruneInvalidApiProviderEntries(): Promise<string[]> {
const removed: string[] = [];
await withConfigLock(async () => {
const config = await readOpenClawJson();
await mutateOpenClawConfig((config) => {
removed.length = 0;
const models = (config.models || {}) as Record<string, unknown>;
const providers = (models.providers || {}) as Record<string, unknown>;
let modified = false;
@@ -1478,7 +1489,7 @@ export async function pruneInvalidApiProviderEntries(): Promise<string[]> {
if (modified) {
models.providers = providers;
config.models = models;
await writeOpenClawJson(config);
normalizeAgentsDefaultsCompactionMode(config);
}
});
return removed;
@@ -1508,8 +1519,7 @@ export async function setOpenClawDefaultModel(
modelOverride?: string,
fallbackModels: string[] = []
): Promise<void> {
return withConfigLock(async () => {
const config = await readOpenClawJson();
await mutateOpenClawConfig((config) => {
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
const model = normalizeModelRef(provider, modelOverride);
@@ -1589,7 +1599,7 @@ export async function setOpenClawDefaultModel(
if (!gateway.mode) gateway.mode = 'local';
config.gateway = gateway;
await writeOpenClawJson(config);
normalizeAgentsDefaultsCompactionMode(config);
console.log(`Set OpenClaw default model to "${model}" for provider "${provider}"`);
});
}
@@ -1798,8 +1808,8 @@ function healAnthropicMessagesMaxTokensInConfig(config: Record<string, unknown>)
*/
export async function ensureAnthropicMessagesModelMaxTokens(): Promise<string[]> {
const healed: string[] = [];
await withConfigLock(async () => {
const config = await readOpenClawJson();
await mutateOpenClawConfig((config) => {
healed.length = 0;
const models = (config.models || {}) as Record<string, unknown>;
const providers = (models.providers || {}) as Record<string, unknown>;
let modified = false;
@@ -1818,7 +1828,7 @@ export async function ensureAnthropicMessagesModelMaxTokens(): Promise<string[]>
if (modified) {
models.providers = providers;
config.models = models;
await writeOpenClawJson(config);
normalizeAgentsDefaultsCompactionMode(config);
}
});
return healed;
@@ -1915,7 +1925,10 @@ function upsertOpenClawProviderEntry(
input: inferCustomModelInputModalities(id),
// Without an explicit contextWindow OpenClaw cannot budget compaction
// for custom providers and long sessions die with context overflow.
contextWindow: inferCustomModelContextWindow(id),
contextWindow: inferCustomModelContextWindow(id, {
providerKey: provider,
apiProtocol: options.api,
}),
}
: {}),
}));
@@ -1981,12 +1994,11 @@ function upsertOpenClawProviderEntry(
*/
export async function ensureOpenClawProviderAgentRuntimePins(): Promise<string[]> {
let pinned: string[] = [];
await withConfigLock(async () => {
const config = await readOpenClawJson();
await mutateOpenClawConfig((config) => {
pinned = applyOpenClawProviderAgentRuntimePinsToConfig(config);
if (pinned.length > 0) {
await writeOpenClawJson(config);
normalizeAgentsDefaultsCompactionMode(config);
}
});
return pinned;
@@ -2077,8 +2089,7 @@ export async function syncProviderConfigToOpenClaw(
modelId: string | undefined,
override: RuntimeProviderConfigOverride
): Promise<void> {
return withConfigLock(async () => {
const config = await readOpenClawJson();
await mutateOpenClawConfig((config) => {
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
if (override.baseUrl && override.api) {
@@ -2099,7 +2110,7 @@ export async function syncProviderConfigToOpenClaw(
ensureOAuthPluginEnabled(config, provider);
}
await writeOpenClawJson(config);
normalizeAgentsDefaultsCompactionMode(config);
});
}
@@ -2167,9 +2178,7 @@ export async function syncOpenAiCompatibleImageRelay(params: {
apiKey?: string;
imageModelIds?: string[];
}): Promise<void> {
return withConfigLock(async () => {
const config = await readOpenClawJson();
await mutateOpenClawConfig((config) => {
if (!params.enabled) {
const models = (config.models || {}) as Record<string, unknown>;
const providers = (models.providers || {}) as Record<string, unknown>;
@@ -2186,15 +2195,24 @@ export async function syncOpenAiCompatibleImageRelay(params: {
const primary = typeof imageGenerationModel?.primary === 'string'
? imageGenerationModel.primary.trim().toLowerCase()
: '';
if (defaults && primary.startsWith(`${CLAWX_OPENAI_IMAGE_PROVIDER_KEY}/`)) {
delete defaults.imageGenerationModel;
if (defaults && imageGenerationModel && primary.startsWith(`${CLAWX_OPENAI_IMAGE_PROVIDER_KEY}/`)) {
const remainingFallbacks = Array.isArray(imageGenerationModel.fallbacks)
? imageGenerationModel.fallbacks.filter((fallback): fallback is string => (
typeof fallback === 'string'
&& !fallback.trim().toLowerCase().startsWith(`${CLAWX_OPENAI_IMAGE_PROVIDER_KEY}/`)
))
: [];
if (remainingFallbacks.length > 0) {
imageGenerationModel.primary = remainingFallbacks.shift();
} else {
delete imageGenerationModel.primary;
}
if (Array.isArray(imageGenerationModel.fallbacks)) {
imageGenerationModel.fallbacks = remainingFallbacks;
}
}
removePluginRegistrations(config, [CLAWX_OPENAI_IMAGE_PROVIDER_KEY]);
await writeOpenClawJson(config);
await removeProviderKeyFromOpenClaw(CLAWX_OPENAI_IMAGE_PROVIDER_KEY);
if (params.apiKey?.trim()) {
await saveProviderKeyToOpenClaw(CLAWX_OPENAI_IMAGE_PROVIDER_KEY, params.apiKey.trim());
}
normalizeAgentsDefaultsCompactionMode(config);
return;
}
@@ -2205,6 +2223,12 @@ export async function syncOpenAiCompatibleImageRelay(params: {
if (modelIds.length === 0) {
modelIds.push(CLAWX_OPENAI_IMAGE_DEFAULT_MODEL);
}
const existingModels = readModelsProvider(config, CLAWX_OPENAI_IMAGE_PROVIDER_KEY)?.models;
const existingModelsById = new Map(
(Array.isArray(existingModels) ? existingModels : [])
.filter((model): model is Record<string, unknown> => isPlainRecord(model) && typeof model.id === 'string')
.map((model) => [model.id as string, model]),
);
upsertOpenClawProviderEntry(config, CLAWX_OPENAI_IMAGE_PROVIDER_KEY, {
baseUrl,
api: 'openai-completions',
@@ -2212,13 +2236,24 @@ export async function syncOpenAiCompatibleImageRelay(params: {
mergeExistingModels: false,
request: { allowPrivateNetwork: true },
});
ensurePluginRegistrationEnabled(config, CLAWX_OPENAI_IMAGE_PROVIDER_KEY);
await writeOpenClawJson(config);
if (params.apiKey?.trim()) {
await saveProviderKeyToOpenClaw(CLAWX_OPENAI_IMAGE_PROVIDER_KEY, params.apiKey.trim());
const relayProvider = readModelsProvider(config, CLAWX_OPENAI_IMAGE_PROVIDER_KEY);
if (relayProvider && Array.isArray(relayProvider.models)) {
relayProvider.models = relayProvider.models.map((model) => {
if (!isPlainRecord(model) || typeof model.id !== 'string') return model;
const existing = existingModelsById.get(model.id);
return existing ? { ...model, ...existing, id: model.id } : model;
});
}
ensurePluginRegistrationEnabled(config, CLAWX_OPENAI_IMAGE_PROVIDER_KEY);
normalizeAgentsDefaultsCompactionMode(config);
});
if (!params.enabled) {
await removeProviderKeyFromOpenClaw(CLAWX_OPENAI_IMAGE_PROVIDER_KEY);
}
if (params.apiKey?.trim()) {
await saveProviderKeyToOpenClaw(CLAWX_OPENAI_IMAGE_PROVIDER_KEY, params.apiKey.trim());
}
}
export function readOpenAiCompatibleImageRelayState(
@@ -2249,8 +2284,7 @@ export async function setOpenClawDefaultModelWithOverride(
override: RuntimeProviderConfigOverride,
fallbackModels: string[] = []
): Promise<void> {
return withConfigLock(async () => {
const config = await readOpenClawJson();
await mutateOpenClawConfig((config) => {
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
const model = normalizeModelRef(provider, modelOverride);
@@ -2294,7 +2328,7 @@ export async function setOpenClawDefaultModelWithOverride(
ensureOAuthPluginEnabled(config, provider);
}
await writeOpenClawJson(config);
normalizeAgentsDefaultsCompactionMode(config);
console.log(
`Set OpenClaw default model to "${model}" for provider "${provider}" (runtime override)`
);
@@ -2309,67 +2343,24 @@ export async function setOpenClawDefaultModelWithOverride(
// These may still linger in openclaw.json from older versions.
const DEPRECATED_PROVIDER_IDS = new Set(['qwen-portal']);
export async function getActiveAuthProfileProviders(): Promise<Set<string>> {
return await getProvidersFromAuthProfileStores({ includeRawKeys: true });
}
export async function getActiveOpenClawProviders(): Promise<Set<string>> {
const activeProviders = new Set<string>();
try {
const config = await readOpenClawJson();
// 1. models.providers
const providers = (config.models as Record<string, unknown> | undefined)?.providers;
if (providers && typeof providers === 'object') {
for (const key of Object.keys(providers as Record<string, unknown>)) {
activeProviders.add(key);
}
}
// 2. plugins.entries for OAuth providers
const plugins = (config.plugins as Record<string, unknown> | undefined)?.entries;
if (plugins && typeof plugins === 'object') {
for (const [pluginId, meta] of Object.entries(plugins as Record<string, unknown>)) {
if (pluginId.endsWith('-auth') && (meta as Record<string, unknown>).enabled) {
activeProviders.add(pluginId.replace(/-auth$/, ''));
}
}
}
// 3. agents.defaults.model.primary — the default model reference encodes
// the provider prefix (e.g. "modelstudio/qwen3.6-plus" → "modelstudio").
// This covers providers that are active via OAuth or env-key but don't
// have an explicit models.providers entry.
const agents = config.agents as Record<string, unknown> | undefined;
const defaults = agents?.defaults as Record<string, unknown> | undefined;
const modelConfig = defaults?.model as Record<string, unknown> | undefined;
const primaryModel = typeof modelConfig?.primary === 'string' ? modelConfig.primary : undefined;
if (primaryModel?.includes('/')) {
activeProviders.add(primaryModel.split('/')[0]);
}
// 4. auth.profiles — OAuth/device-token based providers may exist only in
// auth-profiles without explicit models.providers entries yet.
// Raw keys (e.g. "openai-codex") are included so downstream logic can
// distinguish OAuth runtime providers from their UI alias ("openai").
const auth = config.auth as Record<string, unknown> | undefined;
addProvidersFromProfileEntries(
auth?.profiles as Record<string, unknown> | undefined,
activeProviders,
{ includeRawKeys: true },
const [config, authProfileProviders] = await Promise.all([
readOpenClawJson(),
getActiveAuthProfileProviders(),
]);
return collectActiveProviderIdsFromConfig(
config,
authProfileProviders,
);
const authProfileProviders = await getProvidersFromAuthProfileStores({ includeRawKeys: true });
for (const provider of authProfileProviders) {
activeProviders.add(provider);
}
} catch (err) {
console.warn('Failed to read openclaw.json for active providers:', err);
return new Set();
}
// Remove deprecated providers that may still linger in config/auth files.
for (const deprecated of DEPRECATED_PROVIDER_IDS) {
activeProviders.delete(deprecated);
}
return activeProviders;
}
/**
@@ -2438,9 +2429,8 @@ function applyControlUiAllowedOrigins(controlUi: Record<string, unknown>, port:
* Write the ClawX gateway token into ~/.openclaw/openclaw.json.
*/
export async function syncGatewayTokenToConfig(token: string): Promise<void> {
return withConfigLock(async () => {
const config = await readOpenClawJson();
const gatewayPort = (await getSetting('gatewayPort')) || PORTS.OPENCLAW_GATEWAY;
await mutateOpenClawConfig((config) => {
const gateway = (
config.gateway && typeof config.gateway === 'object'
? { ...(config.gateway as Record<string, unknown>) }
@@ -2462,16 +2452,15 @@ export async function syncGatewayTokenToConfig(token: string): Promise<void> {
? { ...(gateway.controlUi as Record<string, unknown>) }
: {}
) as Record<string, unknown>;
const gatewayPort = (await getSetting('gatewayPort')) || PORTS.OPENCLAW_GATEWAY;
applyControlUiAllowedOrigins(controlUi, gatewayPort);
gateway.controlUi = controlUi;
if (!gateway.mode) gateway.mode = 'local';
config.gateway = gateway;
await writeOpenClawJson(config);
console.log('Synced gateway token to openclaw.json');
normalizeAgentsDefaultsCompactionMode(config);
});
console.log('Synced gateway token to openclaw.json');
}
/**
@@ -2525,9 +2514,7 @@ function ensureWebFetchSsrfPolicyInConfig(config: Record<string, unknown>): bool
* Ensure browser automation is enabled in ~/.openclaw/openclaw.json.
*/
export async function syncBrowserConfigToOpenClaw(): Promise<void> {
return withConfigLock(async () => {
const config = await readOpenClawJson();
await mutateOpenClawConfig((config) => {
const browser = (
config.browser && typeof config.browser === 'object'
? { ...(config.browser as Record<string, unknown>) }
@@ -2563,7 +2550,7 @@ export async function syncBrowserConfigToOpenClaw(): Promise<void> {
if (!changed) return;
config.browser = browser;
await writeOpenClawJson(config);
normalizeAgentsDefaultsCompactionMode(config);
console.log('Synced browser and web_fetch config to openclaw.json');
});
}
@@ -2581,9 +2568,7 @@ export async function syncBrowserConfigToOpenClaw(): Promise<void> {
export async function syncSessionIdleMinutesToOpenClaw(): Promise<void> {
const DEFAULT_IDLE_MINUTES = 10_080; // 7 days
return withConfigLock(async () => {
const config = await readOpenClawJson();
await mutateOpenClawConfig((config) => {
const session = (
config.session && typeof config.session === 'object'
? { ...(config.session as Record<string, unknown>) }
@@ -2602,22 +2587,36 @@ export async function syncSessionIdleMinutesToOpenClaw(): Promise<void> {
session.idleMinutes = DEFAULT_IDLE_MINUTES;
config.session = session;
await writeOpenClawJson(config);
normalizeAgentsDefaultsCompactionMode(config);
console.log(`Synced session.idleMinutes=${DEFAULT_IDLE_MINUTES} (7d) to openclaw.json`);
});
}
/**
* Batch-apply gateway token, browser config, and session idle minutes in a
* single config lock + read + write cycle. Replaces three separate
* withConfigLock calls during pre-launch sync.
* single coordinator transaction. Replaces three separate config mutations
* during pre-launch sync.
*/
export async function batchSyncConfigFields(token: string): Promise<void> {
const DEFAULT_IDLE_MINUTES = 10_080; // 7 days
const gatewayPort = (await getSetting('gatewayPort')) || PORTS.OPENCLAW_GATEWAY;
const memorySearchMigrationVersion = Number(
await getSetting('memorySearchFtsMigrationVersion'),
) || 0;
const shouldMigrateLegacyMemorySearch =
memorySearchMigrationVersion < MEMORY_SEARCH_FTS_MIGRATION_VERSION;
const hasOpenAiEmbeddingKey = Boolean(await getProviderApiKeyFromOpenClaw('openai'));
let pinnedProviderRuntimes: string[] = [];
let compactionLog: string | undefined;
let memorySearchDefaultResult: 'migrated' | 'seeded' | 'unchanged' = 'unchanged';
let backfilledContextWindows: string[] = [];
return withConfigLock(async () => {
const config = await readOpenClawJson();
const changed = await mutateOpenClawConfig((config) => {
let modified = true;
pinnedProviderRuntimes = [];
compactionLog = undefined;
memorySearchDefaultResult = 'unchanged';
backfilledContextWindows = [];
// ── Gateway token + controlUi ──
const gateway = (
@@ -2640,7 +2639,6 @@ export async function batchSyncConfigFields(token: string): Promise<void> {
? { ...(gateway.controlUi as Record<string, unknown>) }
: {}
) as Record<string, unknown>;
const gatewayPort = (await getSetting('gatewayPort')) || PORTS.OPENCLAW_GATEWAY;
applyControlUiAllowedOrigins(controlUi, gatewayPort);
gateway.controlUi = controlUi;
if (!gateway.mode) gateway.mode = 'local';
@@ -2681,10 +2679,9 @@ export async function batchSyncConfigFields(token: string): Promise<void> {
modified = true;
}
const pinnedProviderRuntimes = applyOpenClawProviderAgentRuntimePinsToConfig(config);
pinnedProviderRuntimes = applyOpenClawProviderAgentRuntimePinsToConfig(config);
if (pinnedProviderRuntimes.length > 0) {
modified = true;
console.log(`[batch-sync] Pinned embedded agent runtime for models.providers entries: ${pinnedProviderRuntimes.join(', ')}`);
}
// ── Session idle minutes ──
@@ -2706,37 +2703,65 @@ export async function batchSyncConfigFields(token: string): Promise<void> {
// ── Compaction safeguard default ──
if (ensureCompactionSafeguardDefault(config)) {
modified = true;
console.log(`[batch-sync] Seeded agents.defaults.compaction.mode=safeguard reserveTokensFloor=${DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR}`);
compactionLog = `[batch-sync] Seeded agents.defaults.compaction.mode=safeguard reserveTokensFloor=${DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR}`;
} else if (backfillCompactionReserveTokensFloor(config)) {
modified = true;
console.log(`[batch-sync] Backfilled agents.defaults.compaction.reserveTokensFloor=${DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR}`);
compactionLog = `[batch-sync] Backfilled agents.defaults.compaction.reserveTokensFloor=${DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR}`;
}
// ── Memory search default ──
// OpenClaw defaults to the openai embedding provider; without a key that
// yields doctor errors and a broken memory_search tool. Seed enabled=false
// only when the user has no memorySearch config anywhere AND no OpenAI key
// (i.e. the default embedding model is unusable). Existing user config is
// never modified.
if (!hasUserMemorySearchConfig(config)
&& !(await getProviderApiKeyFromOpenClaw('openai'))
&& ensureMemorySearchDisabledDefault(config)) {
// OpenClaw 2026.7.1 supports provider=none as an explicit FTS-only mode.
// Migrate ClawX's exact legacy disabled default once, and otherwise seed
// FTS only when the user has no memorySearch config or OpenAI embedding key.
memorySearchDefaultResult = shouldMigrateLegacyMemorySearch
&& hasUserMemorySearchConfig(config)
? ensureMemorySearchFtsDefault(config, true)
: 'unchanged';
if (memorySearchDefaultResult === 'unchanged'
&& !hasUserMemorySearchConfig(config)
&& !hasOpenAiEmbeddingKey) {
memorySearchDefaultResult = ensureMemorySearchFtsDefault(config);
}
if (memorySearchDefaultResult !== 'unchanged') {
modified = true;
console.log('[batch-sync] Seeded agents.defaults.memorySearch.enabled=false (no embedding provider configured)');
}
// ── Custom provider contextWindow backfill ──
const backfilledContextWindows = backfillCustomProviderModelContextWindows(config);
backfilledContextWindows = backfillCustomProviderModelContextWindows(config);
if (backfilledContextWindows.length > 0) {
modified = true;
console.log(`[batch-sync] Backfilled contextWindow for custom provider models: ${backfilledContextWindows.join(', ')}`);
}
if (modified) {
await writeOpenClawJson(config);
console.log('Synced gateway token, browser config, web_fetch SSRF policy, and session idle to openclaw.json');
normalizeAgentsDefaultsCompactionMode(config);
}
});
if (pinnedProviderRuntimes.length > 0) {
console.log(`[batch-sync] Pinned embedded agent runtime for models.providers entries: ${pinnedProviderRuntimes.join(', ')}`);
}
if (compactionLog) {
console.log(compactionLog);
}
if (memorySearchDefaultResult !== 'unchanged') {
console.log(
`[batch-sync] ${memorySearchDefaultResult === 'migrated' ? 'Migrated' : 'Seeded'} `
+ 'agents.defaults.memorySearch to FTS-only mode',
);
}
if (backfilledContextWindows.length > 0) {
console.log(`[batch-sync] Backfilled contextWindow for custom provider models: ${backfilledContextWindows.join(', ')}`);
}
if (changed) {
console.log('Synced gateway token, browser config, web_fetch SSRF policy, and session idle to openclaw.json');
}
if (shouldMigrateLegacyMemorySearch) {
await setSetting(
'memorySearchFtsMigrationVersion',
MEMORY_SEARCH_FTS_MIGRATION_VERSION,
);
}
}
/**
@@ -2794,7 +2819,10 @@ async function updateModelsJsonProviderEntriesForAgents(
&& typeof base.contextWindow !== 'number'
&& typeof base.contextTokens !== 'number'
) {
base.contextWindow = inferCustomModelContextWindow(m.id);
base.contextWindow = inferCustomModelContextWindow(m.id, {
providerKey: providerType,
apiProtocol: entry.api,
});
}
return {
...base,
@@ -2860,6 +2888,7 @@ export async function updateSingleAgentModelProvider(
* (`runOpenClawDoctorRepair`) runs `openclaw doctor --fix` as a fallback.
*/
const SKILL_WORKSHOP_TOOL_DENY_ENTRY = 'skill_workshop';
const WEB_SEARCH_TOOL_DENY_ENTRY = 'web_search';
const SKILL_CREATOR_SKILL_KEY = 'skill-creator';
function normalizeToolDenyList(value: unknown): string[] {
@@ -2879,25 +2908,22 @@ function ensureToolDenyIncludes(
}
export async function sanitizeOpenClawConfig(): Promise<void> {
return withConfigLock(async () => {
// Skip sanitization if the config file does not exist yet.
// Creating a skeleton config here would overwrite any data written
// by the Gateway on its first run.
if (!(await fileExists(OPENCLAW_CONFIG_PATH))) {
console.log('[sanitize] openclaw.json does not exist yet, skipping sanitization');
return;
}
// The prelaunch file fallback must not turn a missing or corrupt config into
// a valid-looking skeleton. The coordinator performs the successful mutation.
let sourceExists: boolean;
try {
sourceExists = (await readOpenClawConfigSnapshot()).exists;
} catch {
console.log('[sanitize] openclaw.json could not be parsed, skipping sanitization to preserve data');
return;
}
if (!sourceExists) {
console.log('[sanitize] openclaw.json does not exist yet, skipping sanitization');
return;
}
const authProfileProviders = await getActiveAuthProfileProviders();
// Read the raw file directly instead of going through readOpenClawJson()
// which coalesces null → {}. We need to distinguish a genuinely empty
// file (valid, proceed normally) from a corrupt/unreadable file (null,
// bail out to avoid overwriting the user's data with a skeleton config).
const rawConfig = await readJsonFile<Record<string, unknown>>(OPENCLAW_CONFIG_PATH);
if (rawConfig === null) {
console.log('[sanitize] openclaw.json could not be parsed, skipping sanitization to preserve data');
return;
}
const config: Record<string, unknown> = rawConfig;
await mutateOpenClawConfig(async (config) => {
let modified = false;
// ── skills section ──────────────────────────────────────────────
@@ -2988,20 +3014,6 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
}
}
// ── commands section ───────────────────────────────────────────
// Required for SIGUSR1 in-process reload authorization.
const commands = (
config.commands && typeof config.commands === 'object'
? { ...(config.commands as Record<string, unknown>) }
: {}
) as Record<string, unknown>;
if (commands.restart !== true) {
commands.restart = true;
config.commands = commands;
modified = true;
console.log('[sanitize] Enabling commands.restart for graceful reload support');
}
// ── tools.web.search.kimi ─────────────────────────────────────
// OpenClaw moved moonshot web search config under
// plugins.entries.moonshot.config.webSearch. Migrate the old key and strip
@@ -3077,6 +3089,24 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
toolsModified = true;
}
// ClawX uses the managed browser and web_fetch for explicit navigation,
// but does not expose general-purpose internet search to agents.
const webSearchDenyResult = ensureToolDenyIncludes(
normalizeToolDenyList(toolsConfig.deny),
WEB_SEARCH_TOOL_DENY_ENTRY,
);
if (webSearchDenyResult.modified) {
toolsConfig.deny = webSearchDenyResult.deny;
toolsModified = true;
console.log('[sanitize] Added "web_search" to tools.deny for ClawX desktop');
} else if (
!Array.isArray(toolsConfig.deny)
|| toolsConfig.deny.length !== webSearchDenyResult.deny.length
) {
toolsConfig.deny = webSearchDenyResult.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
@@ -3138,6 +3168,22 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
gatewayTools.deny = gatewayDenyResult.deny;
gatewayModified = true;
}
const gatewayWebSearchDenyResult = ensureToolDenyIncludes(
normalizeToolDenyList(gatewayTools.deny),
WEB_SEARCH_TOOL_DENY_ENTRY,
);
if (gatewayWebSearchDenyResult.modified) {
gatewayTools.deny = gatewayWebSearchDenyResult.deny;
gatewayModified = true;
console.log('[sanitize] Added "web_search" to gateway.tools.deny for ClawX desktop');
} else if (
!Array.isArray(gatewayTools.deny)
|| gatewayTools.deny.length !== gatewayWebSearchDenyResult.deny.length
) {
gatewayTools.deny = gatewayWebSearchDenyResult.deny;
gatewayModified = true;
}
if (gatewayModified) {
gateway.tools = gatewayTools;
config.gateway = gateway;
@@ -3478,7 +3524,7 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
const bundled = discoverBundledPlugins();
const installedExtensionIds = await discoverInstalledExtensionPluginIds();
const loadedPluginIds = await discoverLoadedPluginIdsFromConfig(config);
const activeProviderIds = await collectActiveProviderIdsFromConfig(config);
const activeProviderIds = collectActiveProviderIdsFromConfig(config, authProfileProviders);
const explicitlyEnabledBundledPluginIds = Object.keys(pEntries)
.filter((pluginId) => {
@@ -3687,7 +3733,7 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
}
if (modified) {
await writeOpenClawJson(config);
normalizeAgentsDefaultsCompactionMode(config);
console.log('[sanitize] openclaw.json sanitized successfully');
}
});
+1 -13
View File
@@ -5,23 +5,11 @@
* (`#token=...`) and strips them after load. Query-string tokens are removed
* by the UI bootstrap but are not imported for auth.
*/
export type OpenClawControlUiView = 'dreams';
type OpenClawControlUiUrlOptions = {
view?: OpenClawControlUiView;
};
const CONTROL_UI_VIEW_PATHS: Record<OpenClawControlUiView, string> = {
dreams: '/dreaming',
};
export function buildOpenClawControlUiUrl(
port: number,
token: string,
options: OpenClawControlUiUrlOptions = {},
): string {
const path = options.view ? CONTROL_UI_VIEW_PATHS[options.view] : '/';
const url = new URL(path, `http://127.0.0.1:${port}`);
const url = new URL('/', `http://127.0.0.1:${port}`);
const trimmedToken = token.trim();
if (trimmedToken) {
+28 -21
View File
@@ -1,8 +1,8 @@
/**
* Read/write agents.defaults.imageGenerationModel and per-agent auth readiness.
*/
import { readOpenClawConfig, writeOpenClawConfig } from './channel-config';
import { withConfigLock } from './config-mutex';
import { mutateOpenClawConfig } from '../gateway/config-delivery';
import { readOpenClawConfig } from './channel-config';
import {
getOAuthTokenFromOpenClaw,
getProviderApiKeyFromOpenClaw,
@@ -10,7 +10,11 @@ import {
syncOpenAiCompatibleImageRelay,
} from './openclaw-auth';
import { ensureClawXOpenAiImagePluginInstalled } from './plugin-install';
import { listAgentsSnapshot, type AgentsSnapshot } from './agent-config';
import {
listAgentsSnapshot,
listAgentsSnapshotFromConfig,
type AgentsSnapshot,
} from './agent-config';
import { expandPath } from './paths';
import {
generateImageInProcess,
@@ -84,6 +88,7 @@ type AgentModelConfigShape = {
primary?: string;
fallbacks?: string[];
timeoutMs?: number;
[key: string]: unknown;
};
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -130,12 +135,12 @@ function parseImageGenerationModelConfig(raw: unknown): ImageGenerationModelConf
function buildImageGenerationModelConfigWrite(
config: ImageGenerationModelConfig,
existing: unknown,
): AgentModelConfigShape | undefined {
if (!config.primary && config.fallbacks.length === 0 && config.timeoutMs === null) {
return undefined;
}
const next: AgentModelConfigShape = {};
const next: AgentModelConfigShape = isRecord(existing) ? { ...existing } : {};
delete next.primary;
delete next.fallbacks;
delete next.timeoutMs;
if (config.primary) {
next.primary = config.primary;
}
@@ -145,7 +150,7 @@ function buildImageGenerationModelConfigWrite(
if (config.timeoutMs !== null) {
next.timeoutMs = config.timeoutMs;
}
return next;
return Object.keys(next).length > 0 ? next : undefined;
}
export function parseProviderFromModelRef(modelRef: string): string | null {
@@ -207,8 +212,8 @@ export async function setImageGenerationConfig(
}
}
return withConfigLock(async () => {
const config = await readOpenClawConfig();
let savedConfig: ImageGenerationModelConfig | undefined;
await mutateOpenClawConfig((config) => {
const agents = (config.agents && typeof config.agents === 'object'
? { ...(config.agents as Record<string, unknown>) }
: {}) as Record<string, unknown>;
@@ -220,7 +225,7 @@ export async function setImageGenerationConfig(
primary: next.primary,
fallbacks: [...new Set(next.fallbacks.map((ref) => ref.trim()).filter(Boolean))],
timeoutMs: next.timeoutMs,
});
}, defaults.imageGenerationModel);
if (writeValue) {
defaults.imageGenerationModel = writeValue;
@@ -234,10 +239,9 @@ export async function setImageGenerationConfig(
agents.defaults = defaults;
config.agents = agents;
await writeOpenClawConfig(config);
return readImageGenerationConfig();
savedConfig = parseImageGenerationModelConfig(defaults.imageGenerationModel);
});
return savedConfig!;
}
async function buildAgentAuthRows(
@@ -316,10 +320,10 @@ function resolveOpenAiImageRelayModelId(
}
export async function getImageGenerationSettingsSnapshot(): Promise<ImageGenerationSettingsSnapshot> {
const config = await readImageGenerationConfig();
const snapshot = await listAgentsSnapshot();
const openclawConfig = await readOpenClawConfig();
const defaults = getAgentsDefaults(openclawConfig);
const config = parseImageGenerationModelConfig(defaults?.imageGenerationModel);
const snapshot = await listAgentsSnapshotFromConfig(openclawConfig);
const autoProviderFallback = defaults?.mediaGenerationAutoProviderFallback !== false;
const providerKey = config.primary ? parseProviderFromModelRef(config.primary) : null;
@@ -348,6 +352,12 @@ export async function applyOpenAiImageRelaySettings(params: {
apiKey?: string;
model?: string | null;
}): Promise<void> {
if (params.enabled) {
const plugin = await ensureClawXOpenAiImagePluginInstalled();
if (!plugin.installed) {
throw new Error(plugin.warning || 'Failed to install ClawX OpenAI Image plugin');
}
}
const imageModelIds: string[] = [];
const explicitModel = params.model?.trim();
if (explicitModel) {
@@ -364,14 +374,11 @@ export async function applyOpenAiImageRelaySettings(params: {
apiKey: params.apiKey,
imageModelIds,
});
if (params.enabled) {
ensureClawXOpenAiImagePluginInstalled();
}
}
export async function listImageGenerationProvidersFromRuntime(): Promise<ImageGenerationProviderRow[]> {
const cfg = await readOpenClawConfig();
const snapshot = await listAgentsSnapshot();
const snapshot = await listAgentsSnapshotFromConfig(cfg);
const rows = await listImageGenerationProvidersInProcess({
config: cfg,
isProviderConfigured: (providerId) => isImageProviderAuthenticated(providerId, snapshot.defaultAgentId),
+33 -15
View File
@@ -1,14 +1,15 @@
/**
* Memory search default seeding for openclaw.json.
*
* OpenClaw enables semantic memory search by default with the `openai`
* embedding provider, so a user without an OpenAI key gets doctor errors and
* a broken memory_search tool. ClawX seeds `agents.defaults.memorySearch =
* { enabled: false }` at Gateway prelaunch — but only when the user has no
* memorySearch config anywhere (global defaults or per-agent overrides).
* Existing user config is never modified.
* OpenClaw defaults to the `openai` embedding provider. When no OpenAI key is
* available, ClawX explicitly selects OpenClaw's keyword-only FTS provider so
* memory_search remains useful without making an embedding request.
*/
export const MEMORY_SEARCH_FTS_MIGRATION_VERSION = 1;
export type MemorySearchDefaultResult = 'unchanged' | 'seeded' | 'migrated';
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
@@ -29,18 +30,35 @@ export function hasUserMemorySearchConfig(config: Record<string, unknown>): bool
}
/**
* Seed `agents.defaults.memorySearch = { enabled: false }` when the user has
* no memorySearch config at all. Mutates `config` in place and returns true
* when a change was made. Never touches existing memorySearch objects.
* Seed OpenClaw's explicit FTS-only mode when no memorySearch config exists.
* When requested, also migrate the exact legacy ClawX-managed disabled
* default. Objects with any additional fields and per-agent overrides remain
* user-owned.
*/
export function ensureMemorySearchDisabledDefault(config: Record<string, unknown>): boolean {
if (hasUserMemorySearchConfig(config)) return false;
export function ensureMemorySearchFtsDefault(
config: Record<string, unknown>,
migrateLegacyDisabledDefault = false,
): MemorySearchDefaultResult {
const agents = (isRecord(config.agents) ? config.agents : {}) as Record<string, unknown>;
const defaults = (isRecord(agents.defaults) ? agents.defaults : {}) as Record<string, unknown>;
const list = Array.isArray(agents.list) ? agents.list : [];
if (list.some((entry) => isRecord(entry) && entry.memorySearch !== undefined)) {
return 'unchanged';
}
defaults.memorySearch = { enabled: false };
const defaults = (isRecord(agents.defaults) ? agents.defaults : {}) as Record<string, unknown>;
const memorySearch = defaults.memorySearch;
if (memorySearch !== undefined) {
const isLegacyDisabledDefault = isRecord(memorySearch)
&& Object.keys(memorySearch).length === 1
&& memorySearch.enabled === false;
if (!migrateLegacyDisabledDefault || !isLegacyDisabledDefault) {
return 'unchanged';
}
}
defaults.memorySearch = { enabled: true, provider: 'none' };
agents.defaults = defaults;
config.agents = agents;
return true;
return memorySearch === undefined ? 'seeded' : 'migrated';
}
+20 -12
View File
@@ -1,7 +1,7 @@
import { readOpenClawConfig, writeOpenClawConfig } from './channel-config';
import { mutateOpenClawConfig } from '../gateway/config-delivery';
import type { OpenClawConfig } from './channel-config';
import { resolveProxySettings, type ProxySettings } from './proxy';
import { logger } from './logger';
import { withConfigLock } from './config-mutex';
interface SyncProxyOptions {
/**
@@ -19,23 +19,26 @@ export async function syncProxyConfigToOpenClaw(
settings: ProxySettings,
options: SyncProxyOptions = {},
): Promise<void> {
return withConfigLock(async () => {
const config = await readOpenClawConfig();
const resolved = resolveProxySettings(settings);
const preserveExistingWhenDisabled = options.preserveExistingWhenDisabled !== false;
const nextProxy = settings.proxyEnabled
? (resolved.allProxy || resolved.httpsProxy || resolved.httpProxy)
: '';
const syncState: { result: 'unchanged' | 'preserved' | 'updated' } = { result: 'unchanged' };
await mutateOpenClawConfig((snapshot) => {
syncState.result = 'unchanged';
const config = snapshot as OpenClawConfig;
const telegramConfig = config.channels?.telegram;
if (!telegramConfig) {
return;
}
const resolved = resolveProxySettings(settings);
const preserveExistingWhenDisabled = options.preserveExistingWhenDisabled !== false;
const nextProxy = settings.proxyEnabled
? (resolved.allProxy || resolved.httpsProxy || resolved.httpProxy)
: '';
const currentProxy = typeof telegramConfig.proxy === 'string' ? telegramConfig.proxy : '';
if (!settings.proxyEnabled && preserveExistingWhenDisabled && currentProxy) {
logger.info('Skipped Telegram proxy sync because ClawX proxy is disabled and preserve mode is enabled');
syncState.result = 'preserved';
return;
}
@@ -57,7 +60,12 @@ export async function syncProxyConfigToOpenClaw(
delete config.channels.telegram.proxy;
}
await writeOpenClawConfig(config);
logger.info(`Synced Telegram proxy to OpenClaw config (${nextProxy || 'disabled'})`);
syncState.result = 'updated';
});
if (syncState.result === 'preserved') {
logger.info('Skipped Telegram proxy sync because ClawX proxy is disabled and preserve mode is enabled');
} else if (syncState.result === 'updated') {
logger.info(`Synced Telegram proxy to OpenClaw config (${nextProxy || 'disabled'})`);
}
}
+180
View File
@@ -0,0 +1,180 @@
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 { resolveOpenClawConfigPath, resolveOpenClawStateDir } from './paths';
const UPGRADE_ID = 'openclaw-2026.7.1';
const SNAPSHOT_DIR_MODE = 0o700;
const SNAPSHOT_FILE_MODE = 0o600;
const AGENT_AUTH_BASENAMES = new Set([
'auth-profiles.json',
'openclaw-agent.sqlite',
'openclaw-agent.sqlite-wal',
'openclaw-agent.sqlite-shm',
]);
export type OpenClawUpgradeSnapshotResult = {
status: 'created' | 'exists';
snapshotDir: string;
files: string[];
};
export type OpenClawUpgradeSnapshotCleanupResult = {
status: 'removed' | 'missing';
snapshotDir: string;
};
type SnapshotOptions = {
stateDir?: string;
configPath?: string;
};
function resolveSnapshotDir(stateDir: string): string {
return join(stateDir, 'backups', `clawx-${UPGRADE_ID}-pre-migration`);
}
async function isCopyableRegularFile(path: string): Promise<boolean> {
try {
const info = await lstat(path);
return info.isFile();
} catch {
return false;
}
}
async function snapshotMarkerExists(markerPath: string): Promise<boolean> {
try {
return (await stat(markerPath)).isFile();
} catch {
return false;
}
}
async function copyFileIfPresent(source: string, destination: string, copied: string[]): Promise<void> {
if (!await isCopyableRegularFile(source)) return;
await mkdir(dirname(destination), { recursive: true, mode: SNAPSHOT_DIR_MODE });
await copyFile(source, destination);
await chmod(destination, SNAPSHOT_FILE_MODE);
copied.push(destination);
}
async function copyTree(
sourceRoot: string,
destinationRoot: string,
copied: string[],
includeFile: (name: string) => boolean,
): Promise<void> {
let entries;
try {
entries = await readdir(sourceRoot, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.isSymbolicLink()) continue;
const source = join(sourceRoot, entry.name);
const destination = join(destinationRoot, entry.name);
if (entry.isDirectory()) {
await mkdir(destination, { recursive: true, mode: SNAPSHOT_DIR_MODE });
await copyTree(source, destination, copied, includeFile);
} else if (entry.isFile() && includeFile(entry.name)) {
await copyFileIfPresent(source, destination, copied);
}
}
}
/**
* Creates a one-time pre-migration snapshot before ClawX first starts the
* OpenClaw 2026.7.1 Gateway. SQLite databases are copied together with their
* WAL/SHM sidecars; channel credentials under `credentials/` are intentionally
* excluded because this migration does not rewrite them.
*/
export async function ensureOpenClaw2026_7_1UpgradeSnapshot(
options: SnapshotOptions = {},
): Promise<OpenClawUpgradeSnapshotResult> {
const stateDir = resolve(options.stateDir ?? resolveOpenClawStateDir());
const configPath = resolve(options.configPath ?? resolveOpenClawConfigPath());
const snapshotDir = resolveSnapshotDir(stateDir);
const markerPath = join(snapshotDir, 'snapshot.json');
if (await snapshotMarkerExists(markerPath)) {
try {
const marker = JSON.parse(await readFile(markerPath, 'utf8')) as { files?: unknown };
return {
status: 'exists',
snapshotDir,
files: Array.isArray(marker.files)
? marker.files.filter((value): value is string => typeof value === 'string')
: [],
};
} catch {
// Replace malformed/incomplete snapshots below.
}
}
const tempDir = `${snapshotDir}.tmp-${process.pid}-${Date.now()}`;
const copiedDestinations: string[] = [];
await rm(tempDir, { recursive: true, force: true });
await mkdir(tempDir, { recursive: true, mode: SNAPSHOT_DIR_MODE });
try {
await copyFileIfPresent(configPath, join(tempDir, 'config', basename(configPath)), copiedDestinations);
for (const databasePath of [
join(stateDir, 'openclaw.sqlite'),
join(stateDir, 'state', 'openclaw.sqlite'),
]) {
const relativeDatabase = relative(stateDir, databasePath);
for (const suffix of ['', '-wal', '-shm']) {
await copyFileIfPresent(
`${databasePath}${suffix}`,
join(tempDir, 'state-files', `${relativeDatabase}${suffix}`),
copiedDestinations,
);
}
}
await copyTree(
join(stateDir, 'agents'),
join(tempDir, 'agents'),
copiedDestinations,
(name) => AGENT_AUTH_BASENAMES.has(name),
);
const files = copiedDestinations.map((path) => relative(tempDir, path)).sort();
await writeFile(join(tempDir, 'snapshot.json'), `${JSON.stringify({
upgrade: UPGRADE_ID,
createdAt: new Date().toISOString(),
configPath,
stateDir,
files,
}, null, 2)}\n`, { encoding: 'utf8', mode: SNAPSHOT_FILE_MODE });
await rm(snapshotDir, { recursive: true, force: true });
await mkdir(dirname(snapshotDir), { recursive: true, mode: SNAPSHOT_DIR_MODE });
await rename(tempDir, snapshotDir);
return { status: 'created', snapshotDir, files };
} catch (error) {
await rm(tempDir, { recursive: true, force: true });
throw error;
}
}
/**
* Removes the one-time OpenClaw 2026.7.1 pre-migration snapshot after Gateway
* startup succeeds so duplicated config/auth/SQLite secrets do not linger.
*/
export async function removeOpenClaw2026_7_1UpgradeSnapshot(
options: SnapshotOptions = {},
): Promise<OpenClawUpgradeSnapshotCleanupResult> {
const stateDir = resolve(options.stateDir ?? resolveOpenClawStateDir());
const snapshotDir = resolveSnapshotDir(stateDir);
const markerPath = join(snapshotDir, 'snapshot.json');
if (!await snapshotMarkerExists(markerPath)) {
return { status: 'missing', snapshotDir };
}
await rm(snapshotDir, { recursive: true, force: true });
return { status: 'removed', snapshotDir };
}
+65 -1
View File
@@ -37,7 +37,10 @@ function resolveOpenClawStateDir(): string {
}
function resolveOpenClawStateSqlitePath(): string {
return join(resolveOpenClawStateDir(), 'openclaw.sqlite');
// OpenClaw 2026.7.1 moved the shared state database under state/.
// Writing the legacy root-level database leaves Gateway migrations reading
// stale plugin records from the canonical database.
return join(resolveOpenClawStateDir(), 'state', 'openclaw.sqlite');
}
function parseInstallRecordsJson(raw: unknown): Record<string, Record<string, unknown>> {
@@ -88,6 +91,7 @@ export function upsertPluginInstallRecordsIntoSqlite(
ensureOpenClawStateDirExists();
const sqlitePath = resolveOpenClawStateSqlitePath();
mkdirSync(join(resolveOpenClawStateDir(), 'state'), { recursive: true });
let db: DatabaseSync | null = null;
try {
@@ -157,6 +161,66 @@ export function upsertPluginInstallRecordsIntoSqlite(
}
}
/**
* Remove install records that must remain ClawX-managed rather than updated
* from their raw upstream npm package. Also clean the legacy root-level DB
* previously written by ClawX before OpenClaw 2026.7.1 moved state to state/.
*/
export function removePluginInstallRecordsFromSqlite(pluginIds: string[]): boolean {
if (pluginIds.length === 0) return false;
const stateDir = resolveOpenClawStateDir();
const sqlitePaths = [
resolveOpenClawStateSqlitePath(),
join(stateDir, 'openclaw.sqlite'),
];
let changed = false;
for (const sqlitePath of sqlitePaths) {
if (!existsSync(sqlitePath)) continue;
let db: DatabaseSync | null = null;
try {
db = openStateDatabase(sqlitePath);
const row = db.prepare(`
SELECT install_records_json
FROM installed_plugin_index
WHERE index_key = ?
`).get(INSTALLED_PLUGIN_INDEX_KEY) as { install_records_json?: string } | undefined;
if (!row) continue;
const records = parseInstallRecordsJson(row.install_records_json);
let databaseChanged = false;
for (const pluginId of pluginIds) {
if (Object.hasOwn(records, pluginId)) {
delete records[pluginId];
databaseChanged = true;
}
}
if (!databaseChanged) continue;
const now = Date.now();
db.prepare(`
UPDATE installed_plugin_index
SET install_records_json = ?,
updated_at_ms = ?,
generated_at_ms = ?
WHERE index_key = ?
`).run(JSON.stringify(records), now, now, INSTALLED_PLUGIN_INDEX_KEY);
changed = true;
} catch (error) {
logger.warn(`[plugin] Failed to remove install metadata from ${sqlitePath}:`, error);
} finally {
db?.close();
}
}
if (changed) {
logger.info(`[plugin] Removed managed install metadata from SQLite for: ${pluginIds.join(', ')}`);
}
return changed;
}
/** Ensure ~/.openclaw exists before first config write in fresh installs. */
export function ensureOpenClawStateDirExists(): void {
const stateDir = resolveOpenClawStateDir();
+323 -126
View File
@@ -7,12 +7,19 @@
*/
import { app } from 'electron';
import path from 'node:path';
import { existsSync, cpSync, copyFileSync, statSync, mkdirSync, rmSync, readFileSync, writeFileSync, readdirSync, realpathSync } from 'node:fs';
import { existsSync, cpSync, copyFileSync, statSync, lstatSync, mkdirSync, readFileSync, 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';
import { logger } from './logger';
import { upsertPluginInstallRecordsIntoSqlite, ensureOpenClawStateDirExists } from './plugin-install-index';
import { getOpenClawResolvedDir } from './paths';
import { safeRmSync } from './safe-fs';
import {
upsertPluginInstallRecordsIntoSqlite,
removePluginInstallRecordsFromSqlite,
ensureOpenClawStateDirExists,
} from './plugin-install-index';
import { mutateOpenClawConfig } from '../gateway/config-delivery';
function normalizeFsPathForWindows(filePath: string): string {
if (process.platform !== 'win32') return filePath;
@@ -122,7 +129,7 @@ const MANIFEST_ID_FIXES: Record<string, string> = {
/**
* After a plugin has been copied to ~/.openclaw/extensions/<dir>, fix any
* known manifest-ID mismatches so the Gateway can load the plugin.
* Also patches package.json fields that the Gateway uses as "entry hints".
* Also keeps package.json npm metadata usable by OpenClaw's repair planner.
*/
export function fixupPluginManifest(targetDir: string): void {
// 1. Fix openclaw.plugin.json id
@@ -131,45 +138,64 @@ export function fixupPluginManifest(targetDir: string): void {
const raw = readFileSync(fsPath(manifestPath), 'utf-8');
const manifest = JSON.parse(raw);
const oldId = manifest.id as string | undefined;
let modified = false;
if (oldId && MANIFEST_ID_FIXES[oldId]) {
const newId = MANIFEST_ID_FIXES[oldId];
manifest.id = newId;
writeFileSync(fsPath(manifestPath), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
modified = true;
logger.info(`[plugin] Fixed manifest ID: ${oldId}${newId}`);
}
// OpenClaw 2026.7.1 treats configured channel plugins without a static
// channelConfigs descriptor as stale/missing and invokes its npm repair
// flow. The WeCom package has no descriptor upstream, so provide a
// permissive schema that preserves ClawX's existing channel config fields.
if (manifest.id === 'wecom' && !manifest.channelConfigs?.wecom) {
manifest.channelConfigs = {
...(manifest.channelConfigs ?? {}),
wecom: {
schema: {
type: 'object',
additionalProperties: true,
},
},
};
modified = true;
logger.info('[plugin] Added WeCom channelConfigs compatibility descriptor');
}
if (modified) {
writeFileSync(fsPath(manifestPath), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
}
} catch {
// manifest may not exist yet — ignore
}
// 2. Fix package.json fields that Gateway uses as "entry hints"
// 2. Keep package.json package-manager metadata valid
const pkgPath = join(targetDir, 'package.json');
try {
const raw = readFileSync(fsPath(pkgPath), 'utf-8');
const pkg = JSON.parse(raw);
let modified = false;
// Check if the package name contains a legacy ID that needs fixing
for (const [oldId, newId] of Object.entries(MANIFEST_ID_FIXES)) {
if (typeof pkg.name === 'string' && pkg.name.includes(oldId)) {
pkg.name = pkg.name.replace(oldId, newId);
modified = true;
}
const install = pkg.openclaw?.install;
if (install) {
if (typeof install.npmSpec === 'string' && install.npmSpec.includes(oldId)) {
install.npmSpec = install.npmSpec.replace(oldId, newId);
modified = true;
}
if (typeof install.localPath === 'string' && install.localPath.includes(oldId)) {
install.localPath = install.localPath.replace(oldId, newId);
modified = true;
}
}
// Keep the real upstream npm package name/spec even though ClawX patches
// the effective plugin id. Rewriting these to the non-existent
// `@wecom/wecom` package makes OpenClaw's repair planner fail before the
// Gateway starts. Restore metadata previously rewritten by older ClawX
// compatibility code.
if (pkg.name === '@wecom/wecom') {
pkg.name = '@wecom/wecom-openclaw-plugin';
modified = true;
}
const install = pkg.openclaw?.install;
if (install?.npmSpec === '@wecom/wecom') {
install.npmSpec = '@wecom/wecom-openclaw-plugin';
modified = true;
}
if (modified) {
writeFileSync(fsPath(pkgPath), JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
logger.info(`[plugin] Fixed package.json entry hints in ${targetDir}`);
logger.info(`[plugin] Restored package.json npm metadata in ${targetDir}`);
}
} catch {
// ignore
@@ -239,28 +265,57 @@ const PLUGIN_NPM_NAMES: Record<string, string> = {
'openclaw-weixin': '@tencent-weixin/openclaw-weixin',
};
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
/**
* Official @openclaw/* channel plugins that ClawX mirrors into
* ~/.openclaw/extensions/. OpenClaw 2026.6+ requires matching
* plugins.installs metadata so trustedOfficialInstall is true and
* runtime APIs such as openKeyedStore are available.
* Channel plugins whose ClawX-managed mirrors need synchronized install
* metadata. OpenClaw 2026.6+ reads these records from SQLite for trust checks;
* OpenClaw 2026.7.1 also uses them to decide whether startup migrations should
* update an installed plugin.
*/
const TRUSTED_OFFICIAL_EXTENSION_PLUGINS: Record<string, string> = {
whatsapp: '@openclaw/whatsapp',
discord: '@openclaw/discord',
qqbot: '@openclaw/qqbot',
type TrustedOfficialExtensionPlugin = {
npmName: string;
/** Effective manifest/config id when it differs from the mirror directory. */
pluginId?: string;
/** Path records keep OpenClaw from replacing a ClawX-patched mirror. */
recordSource?: 'npm' | 'path';
legacyPluginIds?: string[];
};
type TrustedOfficialPluginInstallRecord = {
source: 'npm';
const TRUSTED_OFFICIAL_EXTENSION_PLUGINS: Record<string, TrustedOfficialExtensionPlugin> = {
dingtalk: { npmName: '@soimy/dingtalk' },
// WeCom intentionally runs under ClawX's legacy-compatible `wecom` id even
// though the upstream package manifest still declares
// `wecom-openclaw-plugin`. Keep it path-owned so startup migration does not
// replace the compatibility-patched mirror with the raw npm package.
wecom: {
npmName: '@wecom/wecom-openclaw-plugin',
recordSource: 'path',
legacyPluginIds: ['wecom-openclaw-plugin'],
},
// @larksuite/openclaw-lark 2026.7.9 declares ./dist/index.js as `main`, but
// publishes its runtime entry as ./index.js. OpenClaw 2026.7.1 rejects old
// managed npm records during its post-core smoke check. Make ClawX's complete
// mirror the canonical path-owned payload instead.
'feishu-openclaw-plugin': {
npmName: '@larksuite/openclaw-lark',
pluginId: 'openclaw-lark',
recordSource: 'path',
legacyPluginIds: ['feishu-openclaw-plugin', 'feishu'],
},
whatsapp: { npmName: '@openclaw/whatsapp' },
discord: { npmName: '@openclaw/discord' },
qqbot: { npmName: '@openclaw/qqbot' },
'openclaw-weixin': { npmName: '@tencent-weixin/openclaw-weixin' },
'clawx-openai-image': {
npmName: 'clawx-openai-image-plugin',
recordSource: 'path',
},
};
type TrustedOfficialPluginInstallRecord = Record<string, unknown> & {
source: 'npm' | 'path';
spec: string;
installPath: string;
version: string;
resolvedName: string;
resolvedVersion: string;
resolvedSpec: string;
installedAt: string;
};
@@ -277,58 +332,198 @@ function normalizePluginInstallPathForRecord(targetDir: string): string | null {
function buildTrustedOfficialPluginInstallRecord(
pluginDirName: string,
targetDir: string,
): TrustedOfficialPluginInstallRecord | null {
const npmName = TRUSTED_OFFICIAL_EXTENSION_PLUGINS[pluginDirName];
if (!npmName) return null;
): { pluginId: string; record: TrustedOfficialPluginInstallRecord } | null {
const definition = TRUSTED_OFFICIAL_EXTENSION_PLUGINS[pluginDirName];
if (!definition) return null;
const version = readPluginVersion(join(targetDir, 'package.json'));
const installPath = normalizePluginInstallPathForRecord(targetDir);
if (!version || !installPath) return null;
const pluginId = definition.pluginId ?? pluginDirName;
const installedAt = new Date().toISOString();
if (definition.recordSource === 'path') {
return {
pluginId,
record: {
source: 'path',
spec: targetDir,
sourcePath: targetDir,
installPath,
version,
installedAt,
},
};
}
return {
source: 'npm',
spec: npmName,
installPath,
version,
resolvedName: npmName,
resolvedVersion: version,
resolvedSpec: `${npmName}@${version}`,
installedAt: new Date().toISOString(),
pluginId,
record: {
source: 'npm',
spec: definition.npmName,
installPath,
version,
resolvedName: definition.npmName,
resolvedVersion: version,
resolvedSpec: `${definition.npmName}@${version}`,
installedAt,
},
};
}
function pluginInstallRecordIds(pluginDirName: string): string[] {
const definition = TRUSTED_OFFICIAL_EXTENSION_PLUGINS[pluginDirName];
return [...new Set([
pluginDirName,
definition?.pluginId,
...(definition?.legacyPluginIds ?? []),
].filter((value): value is string => Boolean(value)))];
}
async function removeLegacyPluginInstallMetadataFromConfig(pluginIds: string[]): Promise<boolean> {
const removedIds = new Set<string>();
const changed = await mutateOpenClawConfig((config) => {
removedIds.clear();
const plugins = config.plugins;
if (!plugins || typeof plugins !== 'object' || Array.isArray(plugins)) return;
const pluginsRecord = plugins as Record<string, unknown>;
const installs = pluginsRecord.installs;
if (!installs || typeof installs !== 'object' || Array.isArray(installs)) return;
const installsRecord = installs as Record<string, unknown>;
for (const pluginId of pluginIds) {
if (!Object.hasOwn(installsRecord, pluginId)) continue;
delete installsRecord[pluginId];
removedIds.add(pluginId);
}
if (removedIds.size > 0 && Object.keys(installsRecord).length === 0) {
delete pluginsRecord.installs;
}
});
if (removedIds.size > 0) {
logger.info(`[plugin] Removed legacy config install metadata for: ${[...removedIds].join(', ')}`);
}
return changed;
}
function canonicalComparablePath(filePath: string): string {
let resolved: string;
try {
resolved = realpathSync(fsPath(filePath));
} catch {
resolved = path.resolve(filePath);
}
const withoutLongPathPrefix = resolved.replace(/^\\\\\?\\UNC\\/i, '\\\\').replace(/^\\\\\?\\/i, '');
return process.platform === 'win32' ? withoutLongPathPrefix.toLowerCase() : withoutLongPathPrefix;
}
/**
* Materialized mirrors live outside the bundled OpenClaw package tree, so
* Node's normal package lookup cannot resolve their declared `openclaw` peer.
* OpenClaw 2026.7.1 also audits this exact link before reporting Gateway ready.
*/
export function repairPluginOpenClawPeerLink(
targetDir: string,
openclawDir = getOpenClawResolvedDir(),
): boolean {
let packageJson: Record<string, unknown>;
try {
packageJson = JSON.parse(readFileSync(fsPath(join(targetDir, 'package.json')), 'utf-8')) as Record<string, unknown>;
} catch {
return false;
}
const peerDependencies = packageJson.peerDependencies;
if (
!peerDependencies
|| typeof peerDependencies !== 'object'
|| Array.isArray(peerDependencies)
|| typeof (peerDependencies as Record<string, unknown>).openclaw !== 'string'
) {
return true;
}
if (!existsSync(fsPath(join(openclawDir, 'package.json')))) {
logger.warn(`[plugin] Cannot link OpenClaw peer for ${targetDir}: runtime package missing at ${openclawDir}`);
return false;
}
const nodeModulesDir = join(targetDir, 'node_modules');
const linkPath = join(nodeModulesDir, 'openclaw');
try {
mkdirSync(fsPath(nodeModulesDir), { recursive: true });
const nodeModulesStat = lstatSync(fsPath(nodeModulesDir));
if (!nodeModulesStat.isDirectory() || nodeModulesStat.isSymbolicLink()) {
logger.warn(`[plugin] Cannot link OpenClaw peer because ${nodeModulesDir} is not a real directory`);
return false;
}
try {
if (canonicalComparablePath(linkPath) === canonicalComparablePath(openclawDir)) {
return true;
}
} catch {
// Fall through to lstat/creation for a missing or broken link.
}
let existing: ReturnType<typeof lstatSync> | null = null;
try {
existing = lstatSync(fsPath(linkPath));
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
if (existing) {
if (existing.isSymbolicLink()) {
unlinkSync(fsPath(linkPath));
} else if (existing.isDirectory()) {
let existingPackageName: unknown;
try {
existingPackageName = JSON.parse(
readFileSync(fsPath(join(linkPath, 'package.json')), 'utf-8'),
).name;
} catch {
existingPackageName = null;
}
if (existingPackageName !== 'openclaw') {
logger.warn(`[plugin] Cannot replace non-OpenClaw peer directory at ${linkPath}`);
return false;
}
safeRmSync(fsPath(linkPath));
} else {
logger.warn(`[plugin] Cannot replace non-directory OpenClaw peer at ${linkPath}`);
return false;
}
}
symlinkSync(openclawDir, fsPath(linkPath), 'junction');
if (canonicalComparablePath(linkPath) !== canonicalComparablePath(openclawDir)) {
logger.warn(`[plugin] OpenClaw peer link audit failed after creating ${linkPath}`);
return false;
}
logger.info(`[plugin] Linked OpenClaw peer: ${linkPath}${openclawDir}`);
return true;
} catch (error) {
logger.warn(`[plugin] Failed to link OpenClaw peer for ${targetDir}:`, error);
return false;
}
}
function persistTrustedOfficialPluginInstallRecordsToSqlite(
records: Record<string, Record<string, unknown>>,
): boolean {
return upsertPluginInstallRecordsIntoSqlite(records);
}
function trustedInstallRecordMatches(
existing: unknown,
expected: TrustedOfficialPluginInstallRecord,
): boolean {
if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
return false;
}
const record = existing as Record<string, unknown>;
return record.source === expected.source
&& record.spec === expected.spec
&& record.installPath === expected.installPath
&& record.version === expected.version
&& record.resolvedName === expected.resolvedName
&& record.resolvedVersion === expected.resolvedVersion
&& record.resolvedSpec === expected.resolvedSpec;
}
/**
* Write or refresh plugins.installs.<id> for a ClawX-mirrored official plugin.
* Also persists the record into openclaw.sqlite for OpenClaw 2026.6+ trust checks.
* Persist a ClawX-mirrored plugin install record in OpenClaw's canonical SQLite
* index. OpenClaw 2026.7.1 treats config-level plugins.installs as legacy
* migration input, so remove that transient copy instead of recreating it.
* Safe to call repeatedly; no-ops when metadata is already current.
*/
export function syncTrustedOfficialPluginInstallRecord(
export async function syncTrustedOfficialPluginInstallRecord(
pluginDirName: string,
targetDir: string,
): boolean {
): Promise<boolean> {
const expected = buildTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
if (!expected) return false;
@@ -336,58 +531,60 @@ export function syncTrustedOfficialPluginInstallRecord(
return false;
}
// Repair this even when install metadata already matches. A copied plugin's
// node_modules intentionally excludes host peers, and OpenClaw's migration
// smoke check runs before the Gateway can supply any runtime fallback.
repairPluginOpenClawPeerLink(targetDir);
const recordIds = pluginInstallRecordIds(pluginDirName);
let jsonChanged = false;
try {
ensureOpenClawStateDirExists();
if (!existsSync(fsPath(OPENCLAW_CONFIG_PATH))) {
return false;
}
const raw = readFileSync(fsPath(OPENCLAW_CONFIG_PATH), 'utf-8');
const config = JSON.parse(raw) as Record<string, unknown>;
let plugins = config.plugins;
if (!plugins || typeof plugins !== 'object' || Array.isArray(plugins)) {
plugins = { enabled: true, installs: {} };
config.plugins = plugins;
}
const pluginsRecord = plugins as Record<string, unknown>;
const installs = pluginsRecord.installs;
const installsRecord = installs && typeof installs === 'object' && !Array.isArray(installs)
? installs as Record<string, unknown>
: {};
const existing = installsRecord[pluginDirName];
if (!trustedInstallRecordMatches(existing, expected)) {
installsRecord[pluginDirName] = expected;
pluginsRecord.installs = installsRecord;
writeFileSync(
fsPath(OPENCLAW_CONFIG_PATH),
`${JSON.stringify(config, null, 2)}\n`,
'utf-8',
);
logger.info(`[plugin] Synced trusted install metadata for ${pluginDirName}`);
jsonChanged = true;
}
jsonChanged = await removeLegacyPluginInstallMetadataFromConfig(recordIds);
} catch (error) {
logger.warn(`[plugin] Failed to sync trusted install metadata for ${pluginDirName}:`, error);
return false;
// Keep the canonical SQLite repair available even if legacy config cleanup
// cannot be completed in this pass.
logger.warn(`[plugin] Failed to remove legacy install metadata for ${pluginDirName}:`, error);
}
// Remove aliases left by older ClawX/OpenClaw ownership conventions, but do
// not delete the canonical id first: upsert can replace npm/path ownership
// atomically without creating a missing-record window.
const staleRecordIds = recordIds.filter((pluginId) => pluginId !== expected.pluginId);
const removedLegacyRecord = removePluginInstallRecordsFromSqlite(staleRecordIds);
const sqliteChanged = persistTrustedOfficialPluginInstallRecordsToSqlite({
[pluginDirName]: expected,
[expected.pluginId]: expected.record,
});
return jsonChanged || removedLegacyRecord || sqliteChanged;
}
/**
* Remove metadata for a ClawX mirror that is no longer configured. This must
* run even when its extension directory is already missing: stale records are
* themselves enough to fail OpenClaw's post-core payload smoke check.
*/
export async function removeTrustedOfficialPluginInstallRecord(pluginDirName: string): Promise<boolean> {
const recordIds = pluginInstallRecordIds(pluginDirName);
if (recordIds.length === 0) return false;
let jsonChanged = false;
try {
jsonChanged = await removeLegacyPluginInstallMetadataFromConfig(recordIds);
} catch (error) {
logger.warn(`[plugin] Failed to remove stale config install metadata for ${pluginDirName}:`, error);
}
const sqliteChanged = removePluginInstallRecordsFromSqlite(recordIds);
return jsonChanged || sqliteChanged;
}
/** Repair trusted install metadata for all mirrored official plugins on disk. */
export function repairTrustedOfficialPluginInstallRecords(): void {
/** Repair managed install metadata and host peer links for all mirrors on disk. */
export async function repairTrustedOfficialPluginInstallRecords(): Promise<void> {
for (const pluginDirName of Object.keys(TRUSTED_OFFICIAL_EXTENSION_PLUGINS)) {
const targetDir = join(homedir(), '.openclaw', 'extensions', pluginDirName);
if (!existsSync(fsPath(join(targetDir, 'openclaw.plugin.json')))) {
continue;
}
syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
}
}
@@ -464,7 +661,7 @@ export function copyPluginFromNodeModules(npmPkgPath: string, targetDir: string,
}
// 1. Copy plugin package itself
rmSync(fsPath(targetDir), { recursive: true, force: true });
safeRmSync(fsPath(targetDir));
mkdirSync(fsPath(targetDir), { recursive: true });
cpSyncSafe(realPath, targetDir);
@@ -526,11 +723,11 @@ export function copyPluginFromNodeModules(npmPkgPath: string, targetDir: string,
// ── Core install / upgrade logic ─────────────────────────────────────────────
export function ensurePluginInstalled(
export async function ensurePluginInstalled(
pluginDirName: string,
candidateSources: string[],
pluginLabel: string,
): { installed: boolean; warning?: string } {
): Promise<{ installed: boolean; warning?: string }> {
const targetDir = join(homedir(), '.openclaw', 'extensions', pluginDirName);
const targetManifest = join(targetDir, 'openclaw.plugin.json');
const targetPkgJson = join(targetDir, 'package.json');
@@ -540,13 +737,13 @@ export function ensurePluginInstalled(
// If already installed, check whether an upgrade is available
if (existsSync(fsPath(targetManifest))) {
if (!sourceDir) {
syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
return { installed: true }; // no bundled source to compare, keep existing
}
const installedVersion = readPluginVersion(targetPkgJson);
const sourceVersion = readPluginVersion(join(sourceDir, 'package.json'));
if (!sourceVersion || !installedVersion || sourceVersion === installedVersion) {
syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
return { installed: true }; // same version or unable to compare
}
// Version differs — fall through to overwrite install
@@ -564,13 +761,13 @@ export function ensurePluginInstalled(
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
mkdirSync(fsPath(extensionsRoot), { recursive: true });
rmSync(fsPath(targetDir), { recursive: true, force: true });
safeRmSync(fsPath(targetDir));
cpSyncSafe(sourceDir, targetDir);
if (!existsSync(fsPath(join(targetDir, 'openclaw.plugin.json')))) {
return { installed: false, warning: `Failed to install ${pluginLabel} plugin mirror (manifest missing).` };
}
fixupPluginManifest(targetDir);
syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
logger.info(`Installed ${pluginLabel} plugin from bundled mirror: ${sourceDir}`);
return { installed: true };
} catch (error) {
@@ -578,7 +775,7 @@ export function ensurePluginInstalled(
attempts.push({ attempt, ...diagnostic });
if (attempt < maxAttempts) {
try {
rmSync(fsPath(targetDir), { recursive: true, force: true });
safeRmSync(fsPath(targetDir));
} catch {
// Ignore cleanup failures before retry.
}
@@ -619,7 +816,7 @@ export function ensurePluginInstalled(
copyPluginFromNodeModules(npmPkgPath, targetDir, npmName);
fixupPluginManifest(targetDir);
if (existsSync(fsPath(join(targetDir, 'openclaw.plugin.json')))) {
syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
return { installed: true };
}
} catch (err) {
@@ -637,7 +834,7 @@ export function ensurePluginInstalled(
);
}
} else if (existsSync(fsPath(targetManifest))) {
syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
return { installed: true }; // same version, already installed
}
}
@@ -673,15 +870,15 @@ export function buildCandidateSources(pluginDirName: string): string[] {
// ── Per-channel plugin helpers ───────────────────────────────────────────────
export function ensureDingTalkPluginInstalled(): { installed: boolean; warning?: string } {
export function ensureDingTalkPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
return ensurePluginInstalled('dingtalk', buildCandidateSources('dingtalk'), 'DingTalk');
}
export function ensureWeComPluginInstalled(): { installed: boolean; warning?: string } {
export function ensureWeComPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
return ensurePluginInstalled('wecom', buildCandidateSources('wecom'), 'WeCom');
}
export function ensureFeishuPluginInstalled(): { installed: boolean; warning?: string } {
export function ensureFeishuPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
return ensurePluginInstalled(
'feishu-openclaw-plugin',
buildCandidateSources('feishu-openclaw-plugin'),
@@ -691,23 +888,23 @@ export function ensureFeishuPluginInstalled(): { installed: boolean; warning?: s
export function ensureWeChatPluginInstalled(): { installed: boolean; warning?: string } {
export function ensureWeChatPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
return ensurePluginInstalled('openclaw-weixin', buildCandidateSources('openclaw-weixin'), 'WeChat');
}
export function ensureDiscordPluginInstalled(): { installed: boolean; warning?: string } {
export function ensureDiscordPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
return ensurePluginInstalled('discord', buildCandidateSources('discord'), 'Discord');
}
export function ensureQQBotPluginInstalled(): { installed: boolean; warning?: string } {
export function ensureQQBotPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
return ensurePluginInstalled('qqbot', buildCandidateSources('qqbot'), 'QQBot');
}
export function ensureWhatsAppPluginInstalled(): { installed: boolean; warning?: string } {
export function ensureWhatsAppPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
return ensurePluginInstalled('whatsapp', buildCandidateSources('whatsapp'), 'WhatsApp');
}
export function ensureClawXOpenAiImagePluginInstalled(): { installed: boolean; warning?: string } {
export function ensureClawXOpenAiImagePluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
return ensurePluginInstalled(
'clawx-openai-image',
buildCandidateSources('clawx-openai-image'),
@@ -740,7 +937,7 @@ const ALL_BUNDLED_PLUGINS = [
export async function ensureAllBundledPluginsInstalled(): Promise<void> {
for (const { fn, label } of ALL_BUNDLED_PLUGINS) {
try {
const result = fn();
const result = await fn();
if (result.warning) {
logger.warn(`[plugin] ${label}: ${result.warning}`);
}
@@ -748,5 +945,5 @@ export async function ensureAllBundledPluginsInstalled(): Promise<void> {
logger.warn(`[plugin] Failed to install/upgrade ${label} plugin:`, error);
}
}
repairTrustedOfficialPluginInstallRecords();
await repairTrustedOfficialPluginInstallRecords();
}
+128
View File
@@ -0,0 +1,128 @@
import { dirname, join } from 'node:path';
import { lstatSync, readdirSync, realpathSync, rmdirSync, unlinkSync } from 'node:fs';
function normalizeComparablePath(input: string): string {
if (process.platform === 'win32') {
return input.replace(/\\/g, '/').toLowerCase();
}
return input;
}
function isPathInside(root: string, candidate: string): boolean {
const normalizedRoot = normalizeComparablePath(root);
const normalizedCandidate = normalizeComparablePath(candidate);
const rootWithSep = normalizedRoot.endsWith('/') ? normalizedRoot : `${normalizedRoot}/`;
return normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(rootWithSep);
}
function errnoCode(error: unknown): string | undefined {
return error && typeof error === 'object'
? (error as NodeJS.ErrnoException).code
: undefined;
}
function resolveRealPath(input: string): string {
// Node's JavaScript realpath implementation can split a Windows namespaced
// path (\\?\C:\...) at the drive colon and try to lstat "C:". The native
// implementation accepts the same long-path form without reparsing it.
return realpathSync.native(input);
}
function removeLinkEntry(entryPath: string): void {
// Never recursively remove a link. In particular, an NTFS junction may point
// at the bundled OpenClaw runtime outside the plugin tree.
try {
unlinkSync(entryPath);
} catch (error) {
const code = errnoCode(error);
if (code === 'ENOENT') return;
// libuv normally unlinks Windows junctions directly. Some Windows filesystems
// report directory links as EPERM/EISDIR, where a non-recursive rmdir removes
// the junction node without traversing its target.
if (process.platform === 'win32' && (code === 'EPERM' || code === 'EISDIR')) {
rmdirSync(entryPath);
return;
}
throw error;
}
}
function removeFileEntry(entryPath: string): void {
try {
unlinkSync(entryPath);
} catch (error) {
if (errnoCode(error) !== 'ENOENT') throw error;
}
}
function removeDirectoryEntry(entryPath: string, deletionRootRealPath: string): void {
let stat;
try {
stat = lstatSync(entryPath);
} catch (error) {
if (errnoCode(error) === 'ENOENT') return;
throw error;
}
if (stat.isSymbolicLink()) {
removeLinkEntry(entryPath);
return;
}
if (stat.isDirectory()) {
// Resolve before descending. If resolution fails, propagate the error rather
// than falling back to fs.rmSync(), which could follow an outbound junction.
const entryRealPath = resolveRealPath(entryPath);
if (!isPathInside(deletionRootRealPath, entryRealPath)) {
throw new Error(`Refusing to recursively delete directory outside root: ${entryPath} -> ${entryRealPath}`);
}
for (const child of readdirSync(entryPath)) {
removeDirectoryEntry(join(entryPath, child), deletionRootRealPath);
}
rmdirSync(entryPath);
return;
}
removeFileEntry(entryPath);
}
/**
* Remove a file or directory tree without following outbound directory
* junctions/symlinks on Windows. Plain fs.rmSync({ recursive: true }) can
* traverse NTFS junctions (for example plugin node_modules/openclaw peers)
* and delete link targets outside the requested tree.
*/
export function safeRmSync(targetPath: string): void {
let stat;
try {
stat = lstatSync(targetPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
throw error;
}
if (stat.isSymbolicLink()) {
removeLinkEntry(targetPath);
return;
}
if (!stat.isDirectory()) {
removeFileEntry(targetPath);
return;
}
// Fail closed when either path cannot be resolved. Falling back to recursive
// rm here would reintroduce the junction traversal this helper prevents.
const parentRealPath = resolveRealPath(dirname(targetPath));
const deletionRootRealPath = resolveRealPath(targetPath);
if (!isPathInside(parentRealPath, deletionRootRealPath)) {
throw new Error(`Refusing to recursively delete directory outside parent: ${targetPath} -> ${deletionRootRealPath}`);
}
for (const child of readdirSync(targetPath)) {
removeDirectoryEntry(join(targetPath, child), deletionRootRealPath);
}
rmdirSync(targetPath);
}
+26 -50
View File
@@ -1,21 +1,16 @@
/**
* Skill Config Utilities
* Direct read/write access to skill configuration in ~/.openclaw/openclaw.json
* This bypasses the Gateway RPC for faster and more reliable config updates.
*
* All file I/O uses async fs/promises to avoid blocking the main thread.
* Skill configuration reads and coordinated mutations for openclaw.json.
*/
import { readFile, writeFile, access, mkdir, readdir, rm } from 'fs/promises';
import { readFile, writeFile, mkdir, readdir, rm } from 'fs/promises';
import { existsSync } from 'fs';
import { constants } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { getOpenClawDir, getOpenClawResolvedDir, getResourcesDir } from './paths';
import { logger } from './logger';
import { cpAsyncSafe } from './plugin-install';
import { withConfigLock } from './config-mutex';
import { mutateOpenClawConfig, readOpenClawConfigSnapshot } from '../gateway/config-delivery';
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
const BUNDLED_OPENCLAW_SKILL_ALLOWLIST = new Set(['skill-creator']);
export interface SkillConfigUpdates {
@@ -60,52 +55,35 @@ interface PreinstalledMarker {
installedAt: string;
}
async function fileExists(p: string): Promise<boolean> {
try { await access(p, constants.F_OK); return true; } catch { return false; }
}
/**
* Read the current OpenClaw config
*/
async function readConfig(): Promise<OpenClawConfig> {
if (!(await fileExists(OPENCLAW_CONFIG_PATH))) {
return {};
}
try {
const raw = await readFile(OPENCLAW_CONFIG_PATH, 'utf-8');
return JSON.parse(raw);
return (await readOpenClawConfigSnapshot()).config as OpenClawConfig;
} catch (err) {
console.error('Failed to read openclaw config:', err);
return {};
}
}
/**
* Write the OpenClaw config
*/
async function writeConfig(config: OpenClawConfig): Promise<void> {
const json = JSON.stringify(config, null, 2);
await writeFile(OPENCLAW_CONFIG_PATH, json, 'utf-8');
}
async function setSkillsEnabled(skillKeys: string[], enabled: boolean): Promise<void> {
if (skillKeys.length === 0) {
return;
}
return withConfigLock(async () => {
const config = await readConfig();
if (!config.skills) {
config.skills = {};
await mutateOpenClawConfig((config) => {
const skillConfig = config as OpenClawConfig;
if (!skillConfig.skills) {
skillConfig.skills = {};
}
if (!config.skills.entries) {
config.skills.entries = {};
if (!skillConfig.skills.entries) {
skillConfig.skills.entries = {};
}
for (const skillKey of skillKeys) {
const entry = config.skills.entries[skillKey] || {};
const entry = skillConfig.skills.entries[skillKey] || {};
entry.enabled = enabled;
config.skills.entries[skillKey] = entry;
skillConfig.skills.entries[skillKey] = entry;
}
await writeConfig(config);
});
}
@@ -209,12 +187,10 @@ export async function updateSkillConfigs(
updates: Array<{ skillKey: string } & SkillConfigUpdates>,
): Promise<{ success: boolean; error?: string }> {
try {
return await withConfigLock(async () => {
const config = await readConfig();
await applySkillConfigUpdates(config, updates);
await writeConfig(config);
return { success: true };
await mutateOpenClawConfig(async (config) => {
await applySkillConfigUpdates(config as OpenClawConfig, updates);
});
return { success: true };
} catch (err) {
console.error('Failed to update skill config:', err);
return { success: false, error: String(err) };
@@ -227,25 +203,25 @@ export async function removeSkillConfig(skillKey: string): Promise<{ success: bo
export async function removeSkillConfigs(skillKeys: string[]): Promise<{ success: boolean; removed: number; error?: string }> {
try {
return await withConfigLock(async () => {
const config = await readConfig();
const existingEntries = config.skills?.entries || {};
const normalizedSkillKeys = skillKeys
.map((skillKey) => skillKey.trim())
.filter(Boolean);
const removed = normalizedSkillKeys.filter((skillKey) => Object.prototype.hasOwnProperty.call(existingEntries, skillKey)).length;
const normalizedSkillKeys = skillKeys
.map((skillKey) => skillKey.trim())
.filter(Boolean);
let removed = 0;
await mutateOpenClawConfig(async (config) => {
const skillConfig = config as OpenClawConfig;
const existingEntries = skillConfig.skills?.entries || {};
removed = normalizedSkillKeys.filter((skillKey) => Object.prototype.hasOwnProperty.call(existingEntries, skillKey)).length;
if (removed === 0) {
return { success: true, removed: 0 };
return;
}
await applySkillConfigUpdates(
config,
skillConfig,
normalizedSkillKeys.map((skillKey) => ({ skillKey, remove: true })),
);
await writeConfig(config);
return { success: true, removed };
});
return { success: true, removed };
} catch (err) {
console.error('Failed to remove skill configs:', err);
return { success: false, removed: 0, error: String(err) };
+4
View File
@@ -42,6 +42,7 @@ export interface AppSettings {
proxyHttpsServer: string;
proxyAllServer: string;
proxyBypassRules: string;
memorySearchFtsMigrationVersion: number;
// Update
updateChannel: 'stable' | 'beta' | 'dev';
@@ -54,6 +55,7 @@ export interface AppSettings {
devModeUnlocked: boolean;
chatWorkspacePath: string;
recentWorkspacePaths: string[];
workspaceLabels: Record<string, string>;
// Presets
selectedBundles: string[];
@@ -95,6 +97,7 @@ function createDefaultSettings(): AppSettings {
proxyHttpsServer: '',
proxyAllServer: '',
proxyBypassRules: '<local>;localhost;127.0.0.1;::1',
memorySearchFtsMigrationVersion: 0,
// Update
updateChannel: 'stable',
@@ -107,6 +110,7 @@ function createDefaultSettings(): AppSettings {
devModeUnlocked: false,
chatWorkspacePath: DEFAULT_WORKSPACE_CWD,
recentWorkspacePaths: [DEFAULT_WORKSPACE_CWD],
workspaceLabels: {},
// Presets
selectedBundles: ['productivity', 'developer'],
+5 -14
View File
@@ -1,10 +1,10 @@
import { createRequire } from 'node:module';
import { randomUUID } from 'node:crypto';
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { deflateSync } from 'node:zlib';
import { readOpenClawConfigSnapshot } from '../gateway/config-delivery';
import { normalizeOpenClawAccountId } from './channel-alias';
import { resolveOpenClawRuntimeModulePath } from './runtime-package-resolution';
@@ -209,18 +209,9 @@ function isLoginFresh(login: ActiveLogin): boolean {
return Date.now() - login.startedAt < ACTIVE_LOGIN_TTL_MS;
}
function resolveConfigPath(): string {
const envPath = process.env.OPENCLAW_CONFIG?.trim();
if (envPath) return envPath;
return join(OPENCLAW_DIR, 'openclaw.json');
}
function loadWeChatRouteTag(accountId?: string): string | undefined {
async function loadWeChatRouteTag(accountId?: string): Promise<string | undefined> {
try {
const configPath = resolveConfigPath();
if (!existsSync(configPath)) return undefined;
const raw = readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(raw) as {
const parsed = (await readOpenClawConfigSnapshot()).config as {
channels?: Record<string, {
routeTag?: string | number;
accounts?: Record<string, { routeTag?: string | number }>;
@@ -246,7 +237,7 @@ async function fetchWeChatQrCode(apiBaseUrl: string, accountId?: string, botType
const base = apiBaseUrl.endsWith('/') ? apiBaseUrl : `${apiBaseUrl}/`;
const url = new URL(`ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`, base);
const headers: Record<string, string> = {};
const routeTag = loadWeChatRouteTag(accountId);
const routeTag = await loadWeChatRouteTag(accountId);
if (routeTag) {
headers.SKRouteTag = routeTag;
}
@@ -265,7 +256,7 @@ async function pollWeChatQrStatus(apiBaseUrl: string, qrcode: string, accountId?
const headers: Record<string, string> = {
'iLink-App-ClientVersion': '1',
};
const routeTag = loadWeChatRouteTag(accountId);
const routeTag = await loadWeChatRouteTag(accountId);
if (routeTag) {
headers.SKRouteTag = routeTag;
}
@@ -1,34 +1,38 @@
# ACP Attachment Access Control
# ACP Attachment Access Control And Open With
Status: current security and ownership reference, reviewed 2026-07-16.
Status: authoritative durable architecture and security reference, reviewed 2026-07-23.
Related scenario: `acp-chat-experience`
Related scenarios: `acp-chat-experience`, `acp-file-activity`, `gateway-backend-communication`
Related rules: `attachment-access-safety`, `session-workspace-authority`, `tool-derived-file-safety`, `renderer-main-boundary`
Related rules: `attachment-access-safety`, `session-workspace-authority`, `tool-derived-file-safety`, `renderer-main-boundary`, `backend-communication-boundary`
Related task: `acp-media-attachments`
Related tasks: `acp-media-attachments`, `acp-attachment-open-with`, `fix-acp-directory-attachments`, `unify-acp-file-cards`
## Trust Boundaries And Ownership
Renderer owns attachment parsing, timeline projection, presentation, and click routing. Electron Main owns ACP session and relative-path context, filesystem and URI validation, scoped reads, and operating-system open actions. Renderer-provided URIs, metadata, staging ids, transcript message ids, and attachment references are untrusted inputs.
Renderer owns attachment parsing, timeline projection, presentation, and click routing. Electron Main owns ACP session and relative-path context, filesystem and URI validation, scoped reads, operating-system handler discovery, and native open/reveal actions. Renderer-provided URIs, metadata, staging ids, transcript message ids, attachment references, and selected handler ids are untrusted inputs.
An attachment source reference conceptually identifies the active ACP session key, generation, original URI, and optional Main-issued staging or transcript evidence. A resolved local or remote reference repeats that routing identity. These references and Renderer-visible attachment ids are not bearer capabilities. Exact current fields, result unions, error values, and operation signatures in `shared/host-api/contract.ts` and `src/lib/acp/timeline-types.ts` are authoritative.
An attachment source reference conceptually identifies the active ACP session key, generation, original URI, and optional Main-issued staging or transcript evidence. A resolved local or remote reference repeats that routing identity. These references, Renderer-visible attachment ids, opaque file identities, and handler ids are not bearer capabilities. This Harness reference is authoritative for the durable architecture and security invariants; `shared/host-api/contract.ts` and `src/lib/acp/timeline-types.ts` define the exact current serializable fields, result unions, error values, and operation signatures.
Renderer reaches attachment operations only through the typed host API. Main exposes resolution, bounded text and binary reads, and click-initiated open. A successful resolution supplies display metadata, a non-sensitive opaque identity, and an attachment-scoped target, but it does not authorize a later read or open.
Renderer reaches attachment operations only through `src/lib/host-api.ts` and the typed Host API. The Main-owned `files` service exposes resolution, bounded text and binary reads, click-initiated open, compatible-handler listing, selected-handler open, and reveal. A successful resolution supplies display metadata, a non-sensitive opaque identity, and an attachment-scoped target, but it does not authorize a later read, list, open, or reveal.
The attachment Open With contract consists of `listAttachmentOpenHandlers(ref)`, `openAttachmentWith({ ref, handlerId })`, and `revealAttachment(ref)`. Renderer receives only platform, stable public handler identity, bounded display name, optional bounded PNG icon data URL, and default status. Main accepts no Renderer-provided canonical file path, executable or application path, association input, bundle path, icon-source path, command line, command template, helper source, or child-process environment addition.
## Grant Lifecycle
ACP session load or creation is the only operation that establishes attachment session and relative-path context. Main canonicalizes the selected workspace root and execution cwd, verifies that both are directories and cwd is contained by the workspace, and commits the context only after the ACP operation succeeds. A failed load restores the prior ACP state and prior context. Switching the active session or advancing generation replaces that context.
Every attachment operation looks up the one active Main-owned context by exact session key and generation. Attachment, preview, and open payloads cannot provide or replace the execution cwd. Generation is a revocation and race token used together with session identity; it is not a globally monotonic credential.
Every attachment operation looks up the one active Main-owned context by exact session key and generation. Attachment, preview, list, selected-handler open, and reveal payloads cannot provide or replace the execution cwd. Each operation independently resolves the original ref and checks the active session and generation. Generation is a revocation and race token used together with session identity; it is not a globally monotonic credential.
Listing never grants a durable capability. Application-specific open freshly enumerates current handlers without icons, verifies exact membership of the submitted public handler id, then calls an attachment-owned revalidation callback. That callback resolves the original ref again, requires an existing regular local file, and rechecks generation immediately before native invocation. The action is rejected if the association key changed. Reveal likewise re-resolves and revalidates immediately before `shell.showItemInFolder()`.
## Local Resolution And Special Scopes
An accepted absolute, home-relative, `file:`, or execution-cwd-relative reference may resolve to any existing regular local file, including a file outside the active workspace or managed OpenClaw directories. The target is canonicalized before use. The local `scope` returned to Renderer is classification metadata for existing UI behavior, not an authorization root:
An accepted absolute, home-relative, `file:`, or execution-cwd-relative reference may resolve to any existing regular local file or directory, including a target outside the active workspace or managed OpenClaw directories. The target is canonicalized before use, and Main returns an explicit `entryKind`. Directories are a narrow system-open-only case: Main overrides untrusted MIME and size hints with `application/x-directory` and zero, and does not permit scoped reads, Preview, Open With, reveal-as-file, outgoing-media resolution, or directory-content enumeration. The local `scope` returned to Renderer is classification metadata for existing UI behavior, not an authorization root:
- `workspace`: the canonical target is inside the active ACP workspace root. Relative references resolve from the registered execution cwd.
- `openclaw-media`: the canonical target is outside the workspace. This legacy scope name does not imply containment under an OpenClaw media root.
- `staging`: when a staging id is supplied, it must match the exact canonical file in the Main-owned staging record. The same file may also resolve from an explicit path without claiming staging identity.
- `staging`: when a staging id is supplied, it must match the exact canonical file or selected directory in the Main-owned staging record. The same target may also resolve from an explicit path without claiming staging identity.
- `remote`: a normalized HTTP or HTTPS URL without embedded credentials. Remote references remain session/generation scoped and are revalidated immediately before external open.
Gateway outgoing media remains a record-bound special case, not a general local URL alias. Main validates the outgoing attachment id, requires the URL session key and managed record `sessionKey` to equal the active ACP session key, requires the record attachment id to match, and resolves the record's original file through a managed media root. If both transcript evidence and the record carry a message id, they must agree. The literal `global` session key follows exact equality and is never a wildcard.
@@ -37,14 +41,14 @@ Gateway outgoing media remains a record-bound special case, not a general local
Main applies syntax checks before ownership checks and authorization again before each side effect. Current defenses include:
- Reject empty, NUL-containing, traversal-bearing, unknown-scheme, UNC, network-share, and overlong source references. The current source-reference bound is `4096` characters.
- Reject empty, NUL-containing, traversal-bearing, unknown-scheme, UNC, network-share, and overlong source references. The source-reference bound is 4096 characters.
- Decode percent-encoded input once through platform URL handling, then reject encoded traversal or NUL content as well.
- Accept `file:` URLs only with an empty authority or local `localhost` authority; reject remote authorities and credentials.
- Accept only HTTP and HTTPS remote URLs, require a host, reject credentials, and use the platform URL normalization for identity and open.
- Accept only HTTP and HTTPS remote URLs, require a host, reject credentials, and use platform URL normalization for identity and open.
- Resolve home-relative, absolute, Windows-drive, and execution-cwd-relative local references without treating a Renderer-provided path as an authorization root.
- Require an existing regular file and canonicalize the target. Symlink targets and files outside the workspace are allowed after canonical resolution.
- Require an existing regular file or directory and canonicalize the target. Symlink targets and targets outside the workspace are allowed after canonical resolution; all file-content and application-handler operations still require a regular file.
Main re-resolves the original reference for every operation. Scoped reads open the canonical file without following a final symlink where the platform supports it, verify that the handle is a regular file, recheck the active generation, and read through that handle. Local system open re-resolves immediately before `shell.openPath`; remote open revalidates the normalized URL and active generation before `shell.openExternal`. A prior resolve result alone never authorizes a later side effect.
Scoped reads reject directories, open the canonical file without following a final symlink where the platform supports it, verify that the handle is a regular file, recheck the active generation, and read through that handle. Local system open re-resolves the file or directory immediately before `shell.openPath`; remote open revalidates the normalized URL and active generation before `shell.openExternal`. A prior resolve, handler list, cache entry, or stable identity alone never authorizes a later side effect.
## Opaque Identity And Safe Labels
@@ -52,20 +56,117 @@ After authorization, Main returns an opaque hash derived from the canonical loca
Display labels come from approved metadata or a decoded basename. Main reduces labels to a basename, removes control and bidirectional-formatting characters, collapses whitespace to one line, applies the current length bound, and falls back to a generic attachment label. Available attachment cards separately show the decoded local path or normalized remote URL represented by the explicit source reference; unavailable cards remain basename-only. Main-owned staging metadata may provide the original user-selected display path.
## Preview And Open Routing
## Preview And Shared File Card
The shared Renderer classifier in `src/lib/file-preview-capabilities.ts` decides whether a session-valid local attachment fits an existing inline viewer and its size cap. Supported text/code, HTML, CSV, image, PDF, and spreadsheet targets use the right-side Preview panel. Unsupported, known binary, audio/video, archive, office-document, or over-limit local targets use the system application only after a user click. HTTP and HTTPS targets open externally only after a user click.
The shared Renderer classifier in `src/lib/file-preview-capabilities.ts` decides whether a session-valid local attachment fits an existing inline viewer and its size cap. Supported text/code, HTML, CSV, image, PDF, spreadsheet, and supported Office files use the right-side Preview panel. Unsupported, known binary, audio/video, archive, other office-document, over-limit file, and explicit directory targets use the system application only after a user click. HTTP and HTTPS targets open externally only after a user click.
Every attachment preview carries an attachment-scoped file reference. Preview components and rich viewers must use the attachment text or binary read operations and must not fall back to a naked path or general workspace read. Attachment previews also omit trusted workspace-browser reveal or folder actions.
Every attachment preview carries an attachment-scoped file reference. Preview components and rich viewers use the attachment text or binary read operations and must not fall back to a naked path or general workspace read. Attachment previews omit trusted workspace-browser reveal or folder actions.
## Failure Isolation
The later shared-card implementation supersedes the original attachment-local card/menu ownership. `src/pages/Chat/AcpFileCard.tsx` now owns the common `AcpFileCard` shell and target-aware `AcpFileOpenWith` menu for distinct `attachment` and `workspace` references. This sharing is presentation only: attachment authorization remains session/generation scoped, while tool-derived file activity uses independently validated workspace-scoped operations. The two reference types must never be converted into each other. Eligible local HTML menus put an action first that opens the already-authorized target in the existing right-side Preview tab; this file-only preview path is separate from native attachment operations.
An invalid, stale, missing, unsafe, or non-file reference becomes an unavailable attachment result. It cannot be previewed or opened, but it does not suppress assistant prose or independently valid attachments. A valid existing file does not become unavailable merely because it is outside the workspace. Read failures remain inside the Preview panel; local or remote open failures use the localized non-blocking Chat error path. Transcript fetch or compatibility resolution failure must not turn a successful ACP prompt into a prompt error.
For attachments, Open With is eligible only when tone is `assistant`, access is `available`, the target is `local`, and `attachmentOpenMode(...)` is `preview`. User, pending, unavailable, remote, and system-open-only attachments do not show it. The primary sibling button retains the translated `Preview <filename>` accessible name and preview behavior. The compact secondary sibling is never nested inside the primary button and must not activate preview.
Diagnostics may record bounded reason codes, source kind, session/generation routing data, and hashed identities. They must not contain transcript bodies, file content, credentials, or full sensitive paths.
Created and modified ACP file-activity rows may use the same shared menu with `WorkspaceFileRef`; deleted rows never do. Their Preview and Changes actions remain separate from Open With. Workspace operations follow the authority in `harness/reference/openclaw-file-activity.md`, not attachment grants.
## Lazy Menu Lifecycle
Discovery is user-initiated and starts only when an eligible menu opens. On every closed-to-open transition, the shared component clears old rows and starts a new request on macOS or Windows. A disabled localized loading row occupies only the application section; reveal is immediately available. Linux skips the discovery call. Closing, unmounting, changing target, or starting a newer request invalidates prior asynchronous results so a stale response cannot populate another file's menu.
Valid rows put enumerated defaults first and locale-sort the remainder with `Intl.Collator(i18n.language)`. Rows use the Main-provided 20-pixel PNG icon only after Renderer validates the expected bounded data URL shape; missing, malformed, oversized, or image-load-failed icons use the generic application icon. The application section is not truncated; the bounded-height Radix menu scrolls and retains keyboard navigation, Enter activation, Escape/outside-click dismissal, and trigger focus restoration. Reveal is the final item and is labeled for Finder on macOS, File Explorer on Windows, or the file manager on Linux.
Discovery is requested again on a later open. Main's cache may satisfy that request, but Renderer owns no authorization or native metadata cache. Only a failed user-selected application open or reveal may show a localized non-blocking toast.
## Native Helper Bounds
Native helper output is untrusted and every record is independently schema-validated. The following limits are security invariants in both the Main adapter and static Windows helper where applicable:
- Handler display name: at most 256 UTF-16 code units.
- Private native handler identity/public input: at most 512 UTF-16 code units before platform-specific public-id validation.
- Native file, bundle, executable, and icon-source path: at most 4096 UTF-16 code units.
- Icon PNG data URL: at most 64 KiB, with macOS base64 syntax and PNG signature validation before IPC.
- Process lifetime: five seconds for non-interactive discovery and the interactive Windows protocol.
- Aggregate or `execFile` output: at most 1 MiB.
- Windows stdin protocol: exactly one JSON line no longer than 8192 characters after ready.
- Presentation cache: five minutes and at most 128 association entries.
Control characters invalidate bounded names, ids, and paths. Helpers run through explicit executables and positional argument arrays with `shell: false`, hidden Windows process UI, and a minimal allowlisted Main-owned environment. Payloads, dependencies, Renderer inputs, and inherited arbitrary variables cannot add environment entries. Process failures are reduced to bounded reason codes; command arguments, private output, and native errors are not logged.
## macOS Static JXA Adapter
macOS uses `/usr/bin/osascript -l JavaScript` with a static JXA program owned in `electron/services/attachment-open-with.ts`. File paths and the icon mode are positional arguments after `--`; no Renderer or file value is interpolated into source. The program imports Foundation and AppKit and uses `NSWorkspace`/Launch Services to convert the Main-owned canonical path to a file URL, enumerate registered application URLs, and identify the default application URL.
Each valid enumerated row carries a private bundle identifier, localized display name, and bundle path. The public `handlerId` is the bundle identifier, but bundle and icon-source paths remain private to Main. Listing renders each bundle's `NSWorkspace.iconForFile()` image as a 32-point PNG in JXA; one icon failure omits only that icon, and macOS does not use Electron bundle-icon fallback.
Opening performs a fresh icon-free JXA enumeration, matches the submitted bundle identifier, calls attachment revalidation, rejects an association-key change, then invokes `/usr/bin/open` with `['-a', freshBundlePath, revalidatedPath]`. No caller can select an arbitrary application path.
## Windows Static PowerShell/C#/COM Adapter
Windows uses the checked-in static bundled `resources/scripts/attachment-open-with.ps1` helper. Development resolves it from the app resources tree and packaged builds resolve the copied resource under `process.resourcesPath`; Windows CI/native smoke and release packaging checks protect source validity and packaged-resource identity. Main starts `powershell.exe` with `-NoLogo`, `-NoProfile`, `-NonInteractive`, `-ExecutionPolicy Bypass`, `-File`, helper path, mode, and data as separate arguments. It never builds or executes PowerShell/registry command templates.
The helper's static embedded C# uses documented Shell APIs: `SHAssocEnumHandlers`, `AssocQueryString`, `IAssocHandler`, `IEnumAssocHandlers`, `IShellItem`, and Shell data-object binding. Listing derives the association from the Main-owned path, enumerates desktop and packaged handlers, reads localized UI names and icon/executable metadata when available, and marks an enumerated row default only when strict normalized executable identity matches `AssocQueryString`. Main requests Windows icons from validated private icon/executable paths through `app.getFileIcon()`; icon absence or failure does not invalidate a handler.
Windows never exposes native handler identity. Both Node and the helper derive the public opaque id as lower-case hex `SHA-256(UTF8("win32\0" + nativeIdentity))`; action input must be exactly 64 lower-case hexadecimal characters. Private identity-to-public-id relationships stay inside Main/helper memory and list cache records.
For action-time validation, Main spawns `prepare-open` with the initial pre-authorized canonical path and opaque id as separate arguments. The helper derives that path's association, enumerates exactly once, recomputes ids, retains only the matching `IAssocHandler` COM object, emits exactly one `{"ready":true}` line, and waits. Only after ready does Main re-resolve the original attachment ref and generation. If revalidation succeeds and the association key is unchanged, Main sends exactly one bounded line containing only `{ "command": "invoke", "path": <post-ready canonical path> }`. The helper rechecks the association, creates a Shell data object for that path, invokes the retained handler, and releases COM objects. Timeout, output overflow, EOF, malformed/repeated protocol, cancellation, revalidation failure, stdin failure, association change, or non-zero helper exit prevents invocation.
## Successful Empty Result On Linux
Linux intentionally has no application discovery or application-specific open in this scope. `listAttachmentOpenHandlers` still authorizes and resolves the attachment, then returns `{ ok: true, platform: 'linux', handlers: [] }` without starting a helper. The shared menu therefore contains only attachment-scoped reveal. Application-specific open is unsupported rather than falling back to a default application.
## Normalization And Presentation Cache
Main validates rows independently, removes duplicate public identities, carries a default flag across duplicates, and keeps valid enumerated default rows before other operating-system rows. Renderer locale-sorts non-default rows. Current code normalizes only rows returned by enumeration; it does not synthesize a handler that a separate default-association query names but enumeration omitted.
The durable guarded requirement is that any future default-handler insertion must use complete validated metadata from a platform API, preserve exact fresh-membership and invocation checks, and remain representable without exposing private identity or paths. It is a follow-up, not implemented behavior. Tests and documentation must not claim insertion until the adapter actually supplies and validates that row.
Main caches normalized list metadata, converted icons, and private list records for five minutes by platform plus lower-case file-association key; extensionless files use the lower-case basename. Expired entries are pruned, growth is bounded to 128 entries, and oldest entries are evicted. This discovery cache is presentation-only. Attachment authorization is never cached, failures grant nothing, and action-time validation always uses a fresh icon-free enumeration rather than the cache.
## Failure And Privacy Semantics
An invalid, stale, missing, unsafe, remote-for-local-operation, unsupported filesystem entry, or directory submitted to a file-only operation becomes an unavailable/error result. It cannot use that operation, but it does not suppress assistant prose or independently valid attachments. A valid existing file or system-open directory does not become unavailable merely because it is outside the workspace. Read failures remain inside Preview; local or remote open failures use the localized non-blocking Chat error path.
Helper startup, timeout, output, parsing, schema, association, application metadata, and icon failures must not reject attachment-card rendering. Whole discovery failure becomes an empty application section with no toast, banner, or failure row. One invalid handler is omitted; one invalid icon affects only that row. Reveal remains available and primary preview remains unchanged. Only a failed action explicitly requested by selecting an application or reveal may surface a concise localized toast.
Logs and traces must not contain transcript bodies, file content, credentials, canonical file paths, application or bundle paths, icon-source paths, native handler identities, helper source/output, command lines, or icon data. Optional open-with tracing may use only the existing opaque attachment identity and bounded allowlisted fields such as source kind or reason code.
## Non-Goals
- Application discovery or application-specific open on Linux.
- Open With on user, remote, unavailable, pending, system-open-only, or non-preview attachment cards.
- Changing preview format classification or file-size limits.
- Persisting associations or changing an operating-system default.
- Download, export, copy, or system chooser actions in this menu.
- Renderer access to canonical/native paths, private identities, helper source, or command templates.
- Converting workspace file-activity evidence into attachment authority.
- Generalizing the card/menu beyond ACP attachment and file-activity surfaces.
## Rejected Alternatives
### Native N-API Addon
A native addon could call AppKit, Launch Services, and Windows Shell APIs directly. It was rejected because architecture-specific compilation, signing, packaging, and release maintenance are disproportionate to this feature.
### Registry Or Application-Directory Parsing
Scanning macOS application directories or parsing Windows Registry command strings was rejected because it misses packaged applications, can report handlers that cannot open the file, and introduces unsafe command-template parsing. Windows must use Shell association COM APIs.
### System Open-With Dialog Only
Delegating entirely to the operating-system chooser was rejected because it does not provide the required in-card application list, icons, and independently available reveal action.
### Renderer-Owned Discovery Or Paths
Renderer-side helper execution, raw executable selection, and generic-shell reveal were rejected because they bypass attachment-scoped authorization, expose private host paths, and make stale-session revocation unenforceable.
## Validation Anchors
Authorization and race coverage lives primarily in `tests/unit/acp-session-access-registry.test.ts`, `tests/unit/attachment-access.test.ts`, `tests/unit/files-api-workspace.test.ts`, `tests/unit/acp-chat-store.test.ts`, `tests/unit/file-preview-body.test.tsx`, `tests/unit/rich-file-viewers.test.tsx`, `tests/unit/artifact-panel.test.tsx`, and `tests/e2e/chat-acp-attachments.spec.ts`.
Parser, protocol mapping, ordering, deduplication, and component behavior are covered by `tests/unit/acp-media-attachments.test.ts`, `tests/unit/acp-reducer.test.ts`, `tests/unit/acp-timeline-groups.test.ts`, and `tests/unit/acp-chat-components.test.tsx`.
- `tests/unit/attachment-open-with.test.ts`: schema normalization, 256/512/4096 bounds, control rejection, SHA-256 opacity, stable deduplication/default-first ordering, five-minute/128-entry cache, 64 KiB icons, static JXA arguments, shell-free sanitized process options, five seconds/1 MiB bounds, Linux no-process behavior, fresh macOS membership, and Windows ready/revalidate/invoke protocol.
- `tests/unit/attachment-open-with-native.test.ts`: real platform-gated static JXA and PowerShell/C#/COM smoke coverage plus packaged helper resolution.
- `tests/unit/attachment-access.test.ts`: typed per-operation attachment authorization, stale generation and remote rejection, action-time re-resolution, forged handler rejection, association-race prevention, scoped reveal, and sensitive diagnostic exclusion.
- `tests/unit/files-api-workspace.test.ts`: the distinct workspace-scoped operations consumed by the later shared `AcpFileCard` implementation.
- `tests/unit/host-api-facade.test.ts` and `tests/unit/host-services.test.ts`: typed `files` facade/service routes and absence of a legacy direct IPC path.
- `tests/unit/acp-chat-components.test.tsx`: exact attachment and file-activity eligibility, sibling controls, lazy loading, stale-request invalidation, locale ordering, icon fallback, silent discovery failure, platform reveal labels, explicit-action errors, and keyboard menu behavior.
- `tests/unit/artifact-panel.test.tsx`, `tests/unit/file-preview-body.test.tsx`, and `tests/unit/rich-file-viewers.test.tsx`: unchanged attachment-scoped preview behavior.
- `tests/e2e/chat-acp-attachments.spec.ts`: attachment card click routing, typed list/open/reveal requests, platform branches, Linux reveal-only behavior, and failure isolation.
- `tests/e2e/chat-file-changes.spec.ts`: later `AcpFileCard` workspace-target reuse and deleted-row exclusion without conflating workspace and attachment grants.
- `.github/workflows/check.yml` and `.github/workflows/release.yml`: Windows native bridge execution and packaged helper presence/hash checks.
+9 -9
View File
@@ -1,6 +1,6 @@
# ACP Chat Architecture And Timeline
Status: current architecture reference, reviewed 2026-07-15.
Status: current architecture reference, reviewed 2026-07-27.
Related scenario: `acp-chat-experience`
@@ -19,13 +19,13 @@ Chat UI -> host-api -> Main ACP service -> openclaw acp
session/update -> Main routing envelope -> Renderer reducer -> timeline -> React
```
Gateway remains responsible for non-Chat capabilities. Restricted Gateway host-event evidence may supplement asynchronous image-generation completion, but it is not a source for ordinary Chat messages or tool history.
Gateway remains responsible for non-Chat capabilities. Renderer Chat does not call ordinary Gateway `chat.history` or `chat.send`, and Main has no Chat-history polling, coalescing, or backpressure specialization. Generic Gateway RPC requests retain Main-owned validation and timeout handling before direct `GatewayManager.rpc` dispatch. Restricted Gateway host-event evidence may supplement asynchronous image-generation completion, but it is not a source for ordinary Chat messages or tool history.
## Identity And Race Protection
Renderer-visible session identity is the OpenClaw Gateway session key. Main may hold a different ACP session id returned by `newSession`; it rewrites downstream routing to the matching Gateway session key. Loads on the shared ACP connection are serialized. A routing envelope carries the session key and the Main-owned generation token for the matching load or live prompt. Renderer uses a separate local request sequence to reject stale load completions; preparing a local-only session must not advance the ACP generation. Renderer ignores updates, permission requests, and asynchronous hydration results whose session or generation matches neither the selected session nor a retained live prompt. Generation is an in-memory race token rather than a durable sequence; Main may restore the previous value when a load fails, so code must compare it together with session and current-operation state rather than assume global monotonicity.
While `session/prompt` is pending, Main retains a bounded session-id routing context and Renderer retains that prompt's reduced timeline in memory. This lets another page or conversation be viewed without dropping the original stream. 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. This is live operation state, not a second history ledger, and it is never persisted.
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.
`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.
@@ -33,9 +33,9 @@ While `session/prompt` is pending, Main retains a bounded session-id routing con
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.
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, live updates continue through the normal host-event route. Permission requests are accepted only after the current loaded session starts a prompt, preventing load-time or handoff requests from creating invisible waiters.
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.
There are exactly two approved transcript supplements. ClawX may recover asynchronous image-generation completions with proven `image_generate` context, and it may recover explicit line-leading assistant `MEDIA:` attachment directives omitted by OpenClaw ACP. Both are bounded, marked, memory-only projections. They do not authorize reconstruction of ordinary assistant text, thoughts, tool cards, plans, permissions, or file activity. See `harness/reference/acp-generated-media-and-diagnostics.md#bounded-transcript-exceptions` for the compatibility grammar, alignment, rationale, and removal condition.
There are exactly two approved transcript-derived content supplements. ClawX may recover asynchronous image-generation completions with proven `image_generate` context, and it may recover explicit line-leading assistant `MEDIA:` attachment directives omitted by OpenClaw ACP. Both are bounded, marked, memory-only projections. Separately, Main may extract metadata-only whole-turn timing because ACP replay omits original timestamps. Renderer can attach that timing only to an unambiguously matched ACP turn; it cannot reconstruct ordinary assistant text, thoughts, tool cards, plans, permissions, file activity, or missing turns. See `harness/reference/acp-generated-media-and-diagnostics.md#bounded-transcript-exceptions` for the content compatibility grammar and timing boundary.
## Timeline Model
@@ -72,7 +72,7 @@ The protocol timeline remains flat. `src/lib/acp/timeline-groups.ts` derives dis
- Assistant-side items before the first user item still form a visible assistant turn.
- Grouping never infers ownership from `messageId`, `toolCallId`, `_meta`, or synthetic persisted turn ids.
An assistant turn has one identity column and one copy action. Copy includes textual assistant segments and excludes tool output. Tool cards render inline in original order, preserve preformatted whitespace, auto-collapse one second after live completion, respect manual override, and start collapsed when historical and completed.
An assistant turn has one identity column and one copy action. Copy includes textual assistant segments and excludes tool output. Its footer may show localized whole-turn metadata: live duration runs from optimistic send until prompt settlement, while historical duration runs from the matched transcript user record to the latest assistant or tool-result record before the next real user. Missing or ambiguous timing stays hidden. Tool cards render inline in original order, preserve preformatted whitespace, auto-collapse one second after live completion, respect manual override, and start collapsed when historical and completed.
## Attachments
@@ -84,14 +84,14 @@ Renderer keeps attachment references and compatibility projections in the active
Assistant grouping lifts attachments from message, thought, and tool-output segments into one ordered turn list after all prose and process items and before file activity. This prevents an early resource block from appearing above later assistant prose. User grouping similarly renders all prose before ordered attachments. User-selected images render as Main-generated thumbnails whose hover overlay identifies the file. Other available attachment cards show the filename followed by the muted, truncating path represented by their explicit source reference; unavailable attachments remain basename-only.
Available attachment rows are semantic buttons with keyboard activation, an accessible action and safe filename, standard focus visibility, and the established hover state. Pending and unavailable rows remain announced but disabled. Supported session-valid local files use the Preview panel, other local files use the system application only after a click, and HTTP or HTTPS attachments open externally only after a click. One malformed or unavailable attachment cannot suppress prose or sibling attachments. Image-generation completion remains an inline-image experience. It shares transcript coordination and opaque resolved identities with attachment recovery but is not converted into an attachment card.
Available attachment cards contain a primary semantic action with keyboard activation, an accessible action and safe filename, standard focus visibility, and the established hover state. Eligible local assistant Preview cards may also contain a compact secondary Open With sibling button; controls are never nested and secondary interaction cannot activate Preview. Pending and unavailable rows remain announced but disabled. Supported session-valid local files use the Preview panel, other local files use the system application only after a click, and HTTP or HTTPS attachments open externally only after a click. One malformed or unavailable attachment cannot suppress prose or sibling attachments. Image-generation completion remains an inline-image experience. It shares transcript coordination and opaque resolved identities with attachment recovery but is not converted into an attachment card.
## Chat Behaviors
- The primary Chat view does not render the legacy Execution Graph.
- The primary Chat view renders process activity directly in the ordered ACP timeline.
- A recoverable initial `reply was never sent` load failure may leave an empty new-chat page usable; prompt failures remain visible.
- The working indicator follows the same sending state as the Stop action and supports reduced motion.
- The question directory is derived only from active user message segments. Duplicate text remains separate, titles use the first non-empty Markdown part, and textless entries use a localized fallback. Fewer than two questions disables navigation. Selection scrolls smoothly to the current-snapshot anchor; a missing anchor is a safe no-op. The UI caps the directory at 300 recent entries and reports the hidden count when older entries are omitted.
- The question directory is derived only from active user message segments. Duplicate text remains separate, titles use the first non-empty Markdown part, and textless entries use a localized fallback. Fewer than two questions disables navigation. When open, the directory floats above the conversation without changing the chat column width. Selection scrolls smoothly to the current-snapshot anchor; a missing anchor is a safe no-op. The UI caps the directory at 300 recent entries and reports the hidden count when older entries are omitted.
- Heartbeat-only desktop sessions are hidden only when the exact OpenClaw heartbeat sentinel is present and there is no real user content. A title such as `ClawX` or `main` is never sufficient. The guard applies to list, startup selection, refresh, and cached summary hydration without deleting OpenClaw history.
## Validation Anchors
@@ -6,7 +6,7 @@ Related scenario: `acp-chat-experience`
Related rules: `acp-chat-state-and-history`, `acp-compatibility-content-safety`, `attachment-access-safety`, `diagnostics-trace-safety`
Related tasks: `acp-image-generation-compatibility`, `acp-historical-transcript-supplement`, `acp-media-attachments`, `acp-debug-trace-channel`
Related tasks: `acp-image-generation-compatibility`, `acp-historical-transcript-supplement`, `acp-media-attachments`, `acp-debug-trace-channel`, `acp-whole-turn-duration`
## Preferred And Compatibility Paths
@@ -14,14 +14,16 @@ Standard ACP image, `resource_link`, and URI-backed `resource` content blocks ar
## Bounded Transcript Exceptions
This section is the durable rationale referenced by the transcript supplement entry point. The two exceptions are:
This section is the durable rationale referenced by the transcript supplement entry point. The two content exceptions are:
1. Image-generation completion with proven `image_generate` context. Trusted structured runtime evidence or approved transcript evidence may restore the completion caption, failure explanation, and media as the existing inline-image experience.
2. General attachment recovery from an explicit line-leading assistant `MEDIA:` directive outside fenced code blocks. This exception does not require image-generation context, but it recovers only the attachment reference, never the surrounding assistant message.
2. General attachment recovery from a canonical persisted assistant `__openclaw.media` fact or an explicit line-leading assistant `MEDIA:` directive outside fenced code blocks. This exception does not require image-generation context, but it recovers only attachment references and declared media metadata, never the surrounding assistant message.
Both exceptions use one bounded transcript fetch coordinator, keep projected state in memory, require exact active session and generation identity, and reject stale or ambiguous evidence. Existing-session load reads at most 1000 recent transcript messages. An ordinary successful live prompt performs one immediate read and one retry 1500 milliseconds later. Only an `image_generate` task recorded for that same live prompt extends the coordinator through bounded backoff while waiting for its completion artifact; accepted completion, invalidation, or retry-window exhaustion stops it. These exceptions must be removed when the distributed OpenClaw ACP adapter emits the equivalent standard content.
Both content exceptions use one bounded transcript fetch coordinator, keep projected state in memory, require exact active session and generation identity, and reject stale or ambiguous evidence. Existing-session load reads at most 1000 recent transcript messages. An ordinary successful live prompt performs one immediate read and one retry 1500 milliseconds later. Only an `image_generate` task recorded for that same live prompt extends the coordinator through bounded backoff while waiting for its completion artifact; accepted completion, invalidation, or retry-window exhaustion stops it. These exceptions must be removed when the distributed OpenClaw ACP adapter emits the equivalent standard content.
Transcript supplementation must not recover or reconstruct ordinary assistant messages, thoughts, tools, plans, permissions, file activity, or a parallel Chat history. Bare paths, inline prose paths, unknown URI schemes, incidental tool paths, and directives inside fenced code blocks are not general attachments.
The same historical coordinator may request metadata-only whole-turn timing from Main. This is necessary because ACP `session/load` supplies replay content and status but not the original timestamps needed to calculate duration. Main derives candidates from bounded transcript JSONL envelopes, and Renderer aligns them with the same normalized user text and duplicate occurrence-from-tail rule. Timing can annotate only an ACP-created turn and never recovers transcript content.
Transcript supplementation must not recover or reconstruct ordinary assistant messages, thoughts, tools, plans, permissions, file activity, or a parallel Chat history. Bare paths and inline prose paths without canonical media facts, unknown URI schemes, incidental tool paths, and directives inside fenced code blocks are not general attachments.
### Image-Generation Completion
@@ -38,11 +40,13 @@ Accepted live evidence includes structured media fields such as `mediaUrl`, `med
When trusted source-reply text exists, it is preserved whether or not media is present. If no source-reply text exists, successful media uses the localized generic caption; partial or failed thumbnail hydration uses the existing localized fallback. Raw `MEDIA:` paths are never displayed.
### Explicit MEDIA Attachments
### Canonical And Explicit MEDIA Attachments
The general attachment extractor considers normalized assistant roles only. After optional leading whitespace, a whole line must start with the case-insensitive `MEDIA:` token and contain exactly one reference. Single- or double-quoted references may contain spaces and must close with the same quote; unquoted references cannot contain whitespace. One accepted line produces one candidate, and multiple lines retain transcript order. The current source-reference bound is `4096` characters.
The general attachment extractor considers normalized assistant roles only. Its preferred transcript evidence is OpenClaw's canonical persisted `__openclaw.media` array. Each fact may contribute one ordered `path` or `url` plus bounded filename, content type, and size metadata; the containing assistant message id is retained for Main-side outgoing-record validation. Canonical facts do not authorize access: every reference and metadata value remains untrusted and passes through the existing Main attachment boundary.
Accepted reference forms are absolute POSIX paths, Windows drive paths, `file://` URIs, `~/` paths, paths relative to the registered execution cwd, and HTTP or HTTPS URLs. Relative paths are accepted only when execution cwd is available. Unknown URI schemes, malformed URLs or quotes, empty references, Markdown/list wrappers, inline prose, ordinary bare paths, and wrapped references are rejected. Markdown backtick and tilde fences follow the delimiter character and opening length; all content remains ignored until a valid close with the same delimiter and at least that length. The parser does not render the raw directive or surrounding transcript prose.
For the legacy directive form, after optional leading whitespace, a whole line must start with the case-insensitive `MEDIA:` token and contain exactly one reference. Single- or double-quoted references may contain spaces and must close with the same quote; unquoted references cannot contain whitespace. One accepted line produces one candidate, and multiple lines retain transcript order. When a canonical fact and directive identify the same URI in one assistant message, the canonical fact wins. The current source-reference bound is `4096` characters.
Accepted reference forms are absolute POSIX paths, Windows drive paths, `file://` URIs, `~/` paths, paths relative to the registered execution cwd, and HTTP or HTTPS URLs. Relative paths are accepted only when execution cwd is available. Canonical structured values may contain spaces. Unknown URI schemes, malformed URLs or quotes, empty references, Markdown/list wrappers, inline prose without canonical evidence, ordinary bare paths without canonical evidence, and wrapped directives are rejected. Markdown backtick and tilde fences follow the delimiter character and opening length; all content remains ignored until a valid close with the same delimiter and at least that length. The parser does not recover or render surrounding transcript prose.
Transcript and ACP messages are partitioned by real user boundaries; leading orphan assistant content is ineligible. OpenClaw ACP does not project assistant `MEDIA:` attachments, so ClawX must read this bounded transcript supplement. To align it without parsing user-authored marker text, each ACP user segment retains only the ordered, binary-free text blocks produced by OpenClaw's prompt flattening: text and embedded text remain text, `resource_link` becomes OpenClaw's escaped `[Resource link]` form, and image/audio/blob data is omitted. User matching then removes only the known OpenClaw working-directory envelope and normalizes line endings and surrounding whitespace; it does not use broad fuzzy matching or globally strip resource markers. Because transcript history is a bounded suffix and cross-source message ids are not durable, alignment proceeds newest-to-oldest with the tuple of normalized flattened user text and duplicate occurrence from the tail. Attachment-only empty text remains eligible under the same real-user boundary and occurrence rules. A live supplement additionally requires the optimistic ACP user identity and restricts extraction to that current turn. Missing, duplicate, or ambiguous anchors are skipped instead of assigned by ordinal offset or nearest-turn guesswork.
@@ -62,13 +66,13 @@ After successful `loadSession` for an existing session, the store may call:
hostApi.sessions.history({ sessionKey, limit: 1000 });
```
A pure image-generation extractor scans messages in transcript order. It first records an `image_generate` start from a tool result, then accepts a later internal-UI `message` tool source reply or assistant completion associated with that task. OpenClaw's runtime-generated inter-session completion trigger remains part of the originating user turn rather than starting a new end-user turn. Assistant media captions have their `MEDIA:` directives removed before display, and a task-correlated text-only assistant reply may restore a failure explanation. A message-tool reply or image completion without preceding task context is rejected. Separately, the general attachment extractor may accept explicit assistant `MEDIA:` directives without image-generation context under the restrictions above. Read failure, no accepted evidence, duplicate evidence, or a stale generation leaves the ACP timeline unchanged.
A pure image-generation extractor scans messages in transcript order. It first records an `image_generate` start from a tool result, then accepts a later internal-UI `message` tool source reply or assistant completion associated with that task. OpenClaw's runtime-generated inter-session completion trigger remains part of the originating user turn rather than starting a new end-user turn. Assistant media captions have their `MEDIA:` directives removed before display, and a task-correlated text-only assistant reply may restore a failure explanation. A message-tool reply or image completion without preceding task context is rejected. Separately, the general attachment extractor may accept canonical persisted assistant media facts or explicit assistant `MEDIA:` directives without image-generation context under the restrictions above. Read failure, no accepted evidence, duplicate evidence, or a stale generation leaves the ACP timeline unchanged.
These are the only transcript-derived Chat supplements. They must not become a general recovery mechanism for missing tool cards, file activity, plans, permissions, thoughts, or ordinary messages.
These are the only transcript-derived Chat content supplements. Metadata-only whole-turn timing is also permitted, but it must not become a general recovery mechanism for missing turns, tool cards, file activity, plans, permissions, thoughts, or ordinary messages.
## Rejected Compatibility Alternatives
Main does not manufacture ACP `agent_message_chunk` resource events from transcript evidence because that would misrepresent compatibility data as native protocol replay. The ACP page does not reuse legacy Chat path extraction or rendering because that would restore competing history authorities. Standard-ACP-only behavior is insufficient while the distributed adapter omits assistant media, but the exception remains removable when upstream emits standard resources. Bare-path or broad prose extraction is rejected because false positives would widen the local-file trust surface.
Main does not manufacture ACP `agent_message_chunk` resource events from transcript evidence because that would misrepresent compatibility data as native protocol replay. The ACP page does not reuse legacy Chat path extraction or rendering because that would restore competing history authorities. Standard-ACP-only behavior is insufficient while the distributed adapter omits assistant media, but the exception remains removable when upstream emits standard resources. Canonical persisted media facts are explicit structured evidence; bare-path or broad prose extraction without those facts remains rejected because false positives would widen the local-file trust surface.
## Trace Channel
@@ -1,12 +1,12 @@
# Chat Workspace And Navigation
Status: current workspace reference, reviewed 2026-07-13.
Status: current workspace reference, reviewed 2026-07-23.
Related scenario: `chat-workspace-and-navigation`
Related rules: `session-workspace-authority`, `ui-i18n-design-tokens`
Related rules: `session-workspace-authority`, `sidebar-session-attention-authority`, `ui-i18n-design-tokens`, `office-preview-safety`, `web-browser-security-and-lifecycle`
Related task: `chat-workspace-context`
Related tasks: `chat-workspace-context`, `sidebar-session-attention`, `office-document-preview`, `web-browser`
## Workspace Authority
@@ -18,13 +18,13 @@ OpenClaw's persisted ACP `cwd` is authoritative for a bound session. The global
The effective workspace is shared by ACP load/prompt, the composer, sidebar grouping, the right-side workspace browser, and tool-derived file activity. A bound session is read-only in the composer and is not moved when the global selection changes. Missing or unreadable bound paths show unavailable/error state instead of silently changing roots.
ClawX persists global and recent workspace selections through Main-owned settings APIs. Renderer session state may mirror the bound path for UI coordination, but must not become a competing persistent session-to-path authority. Targeted `@agent` sends may intentionally use the target agent workspace and should remain an explicit branch.
ClawX persists global and recent workspace selections plus custom display labels through Main-owned settings APIs. On editable new or unbound chats, the composer menu shows the canonical default once, followed by deduplicated non-default workspaces from the most-recent list and known session paths, then the native folder picker. Recent entries stay first; all entries use custom display labels when available, keep the full path as hover text, and update only the global selection until first send binds the session. Custom labels are keyed by canonical path and never replace path identity or ACP cwd authority. Renderer session state may mirror the bound path for UI coordination, but must not become a competing persistent session-to-path authority. Targeted `@agent` sends intentionally use the target agent workspace and remain an explicit branch. Navigation records that workspace on the target session placeholder before reactive loading; a newly targeted agent's first send creates its main ACP session and shares one load identity with the prompt so navigation cannot supersede delivery.
## First Send And Titles
First send initializes the ACP session with the selected cwd and then marks the local session as created/bound. ACP keeps `_meta.prefixCwd: true`; disabling cwd injection would break OpenClaw context. Automatic titles instead normalize away one leading `[Working directory: ...]` envelope and subsequent whitespace.
First send initializes the ACP session with the selected cwd and then marks the local session as created/bound. A fresh session generated at cold start to replace hidden heartbeat history is marked as the same kind of local placeholder; it cannot appear as a normal empty session or bypass first-send creation. Gateway event and canonical-list reconciliation preserve the local `createdLocally` marker until acknowledgement, even when OpenClaw already reports the same key with the ACP bridge display name. The acknowledgement atomically restores a raced-away placeholder when necessary, clears the marker, and seeds a missing automatic sidebar title from the raw first prompt. The newly visible row therefore never falls back to the bridge client identity while transcript title hydration catches up. Existing explicit or cached labels win. ACP keeps `_meta.prefixCwd: true`; disabling cwd injection would break OpenClaw context. Automatic titles instead normalize away one leading `[Working directory: ...]` envelope and subsequent whitespace.
Normalization applies to automatic sources such as Gateway-derived title and Main transcript summary. It never changes an explicit user label, never removes a non-leading marker, and treats the exact truncated envelope form as a missing title so a better summary can replace it.
Normalization applies to automatic sources such as Gateway-derived title and Main transcript summary. It never changes an explicit user label, never removes a non-leading marker, and treats the exact truncated envelope form as a missing title so a better summary can replace it. OpenClaw's synthetic `<first 8 session UUID characters> (YYYY-MM-DD)` fallback is also treated as missing only when it matches the row's full session id; Main's transcript summary then supplies the first user prompt. Opening and closing rename mode without changing the value must not persist any displayed fallback as an explicit label.
## Sidebar Navigation
@@ -37,18 +37,42 @@ Sessions are grouped by workspace, not by date bucket. The default workspace sor
Each group initially displays five sessions and loads five more at a time. Collapse and visible-count state are per workspace and in memory. Relative time and ordering use the same timestamp; actions replace the timestamp on hover or keyboard focus.
## Workspace Browser
Non-default workspace headers expose a rename action on hover or keyboard focus. A custom name updates both the sidebar group and the composer workspace chip; the header and chip keep the full filesystem path in their title text.
The right panel tabs remain Workspace, Preview, and Changes. The Workspace tree uses `react-arborist`, includes hidden files, uses relative path as node identity, and remains read-only: no edit, drag/drop, or multi-select. Agent and path tags replace the older `Workspace - agent` header. Home is compacted to `~`, the path's final segment remains visible, and the full value is available as a title.
Sidebar validates distinct non-default group paths through Main. A confirmed unavailable group shows a warning badge and destructive delete action; available, unresolved, and default groups do not. One confirmation hard-deletes the group's sessions sequentially across agents. Successful sessions disappear together, failed sessions remain for retry, and workspace recents/labels are removed only after the full group succeeds.
## Sidebar Session Attention
OpenClaw Gateway session rows are the sole authority for sidebar run state. ClawX subscribes to `sessions.changed`, reconciles exact session keys into the existing session catalog, and uses canonical `sessions.list` snapshots for startup and reconnect recovery. ACP prompts, ACP timeline events, and Gateway agent runtime events do not provide a second status source.
The trailing row content has strict `busy > unread > timeago` precedence. A Gateway-active row shows the localized busy indicator. An observed busy-to-idle transition shows the localized unread indicator until the conversation is opened, after which the relative activity time returns.
Read state follows visible Chat integration rather than the retained current-session key. Chat marks its session visible on mount and on each session-key change, clears visibility on unmount, and treats completion for that visible session as read. Routes such as Settings may retain the current key, but completion there remains unread. The sidebar click path also marks the session read synchronously before navigating to Chat.
The versioned attention store persists only exact-key `observedBusy` and `unread` state. This allows a later idle canonical snapshot to recover completion when ClawX previously observed the run as busy, including across an app restart. A run that starts and finishes while ClawX is fully offline cannot be inferred and must not create unread state. Run-scoped cron keys also cannot drive base-row attention because the bundled Gateway does not expose a recoverable canonical relationship.
The complete projection, persistence, list/event ordering, failure recovery, and future `sessions.patch({ unread: false })` migration are documented in `harness/reference/sidebar-session-attention.md`.
## Workspace Browser And Local HTML Preview
The right panel tabs are Workspace, Preview, and Changes. Workspace keeps the store tab value `browser`; authorized local HTML opens in `preview`. The Workspace tree uses `react-arborist`, includes hidden files, uses relative path as node identity, and remains read-only: no edit, drag/drop, or multi-select. Agent and path tags replace the older `Workspace - agent` header. Home is compacted to `~`, the path's final segment remains visible, and the full value is available as a title.
File icons come only from trusted bundled assets. Selecting a file preserves the existing preview behavior and backend boundary.
Local HTML Preview uses one hardened Electron guest as an implementation detail. The HTML anchor marks the Preview body while the route-stable host mounted by `MainLayout` owns the guest. There is no browser tab, empty guest entry, Home page, or address bar. Stable selectors are `html-preview-anchor`, `html-preview-host`, and `html-preview-webview`. Its file-only security and inert-link contract is documented separately in `harness/reference/web-browser.md`.
## Office Document Preview
The Workspace and Preview surfaces support read-only `.docx` and `.pptx` files; legacy `.doc` and `.ppt` files remain system-open-only. Extension is authoritative, and compressed DOCX/PPTX input is limited to 20 MB before Renderer parsing. Scoped workspace and attachment references use only their authorized Host API read route and never fall back to a naked path. Workspace Browser intentionally retains its existing Host-validated absolute-path read flow.
DOCX generated content is isolated and its links are non-interactive. PPTX renders one slide at a time, and kept-mounted artifact surfaces conditionally mount it so the shared Electron Renderer has a single mounted PPTX viewer. Cleanup releases ClawX-owned resources and invokes public `destroy()` exactly once, while the reviewed dependency-owned retained-resource limitation remains accepted. Exact security and lifecycle requirements are in `harness/specs/rules/office-preview-safety.md`; dependency choices, rendering decisions, user-visible limitations, and future hardening are in `harness/reference/office-document-preview.md`.
## Question Navigation
The Chat question directory belongs to the active ACP timeline rather than workspace persistence. Its current behavior is documented in `harness/reference/acp-chat.md`.
## Validation Anchors
Key tests include `tests/unit/workspace-context.test.ts`, `tests/unit/session-title.test.ts`, `tests/unit/session-buckets.test.ts`, `tests/unit/sidebar-session-buckets.test.ts`, `tests/unit/workspace-browser-body.test.tsx`, `tests/e2e/chat-workspace-context.spec.ts`, and `tests/e2e/chat-question-directory.spec.ts`.
Key tests include `tests/unit/workspace-context.test.ts`, `tests/unit/session-title.test.ts`, `tests/unit/session-buckets.test.ts`, `tests/unit/sidebar-session-buckets.test.ts`, `tests/unit/use-new-chat-action.test.tsx`, `tests/unit/chat-store-session-label-fetch.test.ts`, `tests/unit/workspace-browser-body.test.tsx`, `tests/unit/office-file-viewers.test.tsx`, `tests/unit/chat-acp-page.test.tsx`, `tests/unit/artifact-panel-store.test.ts`, `tests/unit/artifact-panel.test.tsx`, `tests/unit/main-layout.test.tsx`, `tests/unit/web-browser-host.test.tsx`, `tests/e2e/chat-workspace-context.spec.ts`, `tests/e2e/chat-acp-attachments.spec.ts`, `tests/e2e/chat-file-changes.spec.ts`, and `tests/e2e/office-document-preview.spec.ts`.
This reference consolidates the former workspace sidebar, chat workspace context, sidebar workspace UI, and ACP working-directory title designs. The later flat activity-sorted sidebar supersedes the earlier recency buckets.
+15
View File
@@ -0,0 +1,15 @@
# Electron E2E Parallelism
ClawX launches one Electron process per Playwright test with a test-scoped HOME and user-data directory. Ordinary specs can therefore run in separate workers without sharing application stores or OpenClaw files.
The Playwright project graph has three ordered lanes:
1. `exclusive` runs tests tagged `@exclusive` with one worker.
2. `parallel` runs all ordinary functional tests with the configured worker count after `exclusive` succeeds.
3. `performance` runs tests tagged `@performance` with one worker after functional tests finish.
Real clipboard tests are exclusive because Electron renderer instances read and write the same OS clipboard. Renderer performance tests run last because concurrent Electron processes distort CPU, GPU, frame-pacing, and elapsed-time evidence even when their files are otherwise isolated. `test.describe.configure({ mode: 'serial' })` is not sufficient for either case because it does not prevent another spec file or project from running at the same time.
New tests are parallel by default. A test that uses an OS-global resource must import and apply `E2E_EXCLUSIVE_TAG`; a host performance profile must use `E2E_PERFORMANCE_TAG`. Extend `tests/unit/e2e-parallel-policy.test.ts` when another recognizable global API is introduced. No static check can identify every possible external side effect, so reviewers must classify tests that use native dialogs, keychains, fixed ports, fixed writable paths, external runtimes, or other machine-global state.
Use `CLAWX_E2E_WORKERS` to override the ordinary worker count on constrained or high-capacity machines. Playwright project dependencies make a directly filtered ordinary spec run the exclusive prerequisite first; add `--project=parallel --no-deps` when a focused command intentionally needs only an audited ordinary spec. `pnpm run perf:chat` selects the performance project without running its dependencies.
@@ -0,0 +1,29 @@
# Electron Rendering Performance
Status: hardware-acceleration policy and interaction profile baselined 2026-08-01.
Related scenarios: `acp-chat-experience`, `chat-workspace-and-navigation`
Related rule: `electron-rendering-performance`
Related task: `restore-hardware-accelerated-rendering`
## Runtime Policy
ClawX leaves Electron and Chromium hardware acceleration enabled by default. Main must not call `app.disableHardwareAcceleration()` or append a global `disable-gpu` switch. Chromium owns driver detection and fallback; users with a broken driver may still launch ClawX with Chromium's native `--disable-gpu` switch.
Headless Linux and virtualized CI may report software compositing because no usable GPU is present. Tests must distinguish that environment fallback from an application-owned global disable policy. Desktop GPU assertions therefore run only where the test environment provides a real desktop GPU.
## Diagnostic Contract
`pnpm run perf:chat` covers both high-frequency ACP streaming and idle interaction with a rich static Markdown document. The interaction workload records sidebar-collapse and vertical-scroll frame intervals, Renderer performance metrics, DOM size, GPU feature status, and Renderer/Main CPU profiles. It uses generated content and writes only ignored Playwright artifacts.
For a reported desktop regression, first reproduce with the user's real conversation and record `app.isHardwareAccelerationEnabled()` plus `app.getGPUFeatureStatus()` after `gpu-info-update`. Compare repeated runs on the same machine. Main CPU profiles do not include browser/GPU process rasterization or compositing, so a profile dominated by Chromium `(program)` time must be interpreted together with frame pacing and GPU status rather than as unexplained React work.
Do not add machine-independent frame-time gates. Preserve semantic assertions, generated workload shape, and artifact schemas; compare repeated local or controlled-run medians when reviewing rendering changes.
## Validation Anchors
- Main policy: `electron/main/index.ts` and `tests/unit/main-hardware-acceleration.test.ts`.
- Desktop runtime behavior: `tests/e2e/hardware-acceleration.spec.ts`.
- Streaming and interaction profiles: `tests/e2e/renderer-performance.spec.ts` through `pnpm run perf:chat`.
+80
View File
@@ -0,0 +1,80 @@
# Markdown Rendering
Status: migration contract baselined 2026-08-01; implementation is validated by the related task.
Related scenarios: `acp-chat-experience`, `chat-workspace-and-navigation`
Related rule: `markdown-rendering-safety-and-performance`
Related task: `replace-markdown-renderer-with-streamdown`
## Rendering Ownership
ClawX has two application Markdown surfaces with distinct update behavior and one shared renderer configuration:
| Surface | Mode | Content |
| --- | --- | --- |
| ACP Chat | `streaming` | Assistant message and process Markdown parts |
| Markdown file preview | `static` | Authorized local Markdown file content |
User messages remain literal React text and tool output remains preformatted. Neither enters Streamdown. The migration is presentation-only: ACP transport, event ordering, timeline reduction, store cadence, history, and Renderer/Main boundaries do not change.
The shared plugin, rehype, component, animation, controls, and link-safety values remain module-scoped. Stable references allow Streamdown to retain completed block output instead of invalidating memoized blocks on each chunk.
## Plugin Contract
Exactly these optional capabilities are enabled:
- `@streamdown/code` for Shiki-backed fenced-code highlighting.
- `@streamdown/math` for KaTeX, configured with `singleDollarTextMath: true`.
- `@streamdown/cjk` for CJK-aware autolink and punctuation boundaries.
`@streamdown/mermaid` is not a direct dependency and is not configured. A `mermaid` fence remains an ordinary highlighted code block and never becomes a diagram, SVG, or interactive Mermaid container.
KaTeX remains a direct dependency because the math plugin requires its CSS. The application imports `katex/dist/katex.min.css` exactly once. It also imports `streamdown/styles.css` exactly once so the selected animation keyframes and data-attribute styles exist. Tailwind scans Streamdown and each installed plugin distribution, but no Mermaid distribution path.
## Content Safety
Streamdown does not expand the authority of generated content:
- User text and tool output remain literal outside the Markdown renderer.
- The shared rehype list retains Streamdown sanitization and hardening but omits raw-HTML parsing. Source HTML such as `<script>alert(1)</script>` remains visible text and does not create an element.
- Links render through `BrowserLink`, which has no interactive anchor role or navigation. Streamdown link-safety UI is disabled because links are already inert.
- ACP Markdown images continue through `isSafeAcpImageSource`; an unapproved source does not become an image request.
- Table, Mermaid, code download, and line-number controls are disabled. Fenced code alone exposes Streamdown's copy control with its label supplied through `react-i18next`; the control remains disabled while a response is streaming.
Static preview keeps `remark-frontmatter` for YAML (`---`) and TOML (`+++`) frontmatter. Parsed frontmatter is omitted from visible output. There is no custom frontmatter splitter, metadata card, or metadata `<pre>`.
## Streaming And Animation
ACP Chat repairs incomplete Markdown while the response is active, but animation state is narrower than transport state. The Renderer derives active segment IDs from the open ACP assistant message segments only while send or cancel is active. A Markdown part receives `isAnimating`, word animation, and `caret="circle"` only when all of these conditions hold:
- Its assistant segment is currently open.
- It is the segment's final part.
- Its part kind is Markdown.
Earlier parts, completed segments, thoughts, user messages, and tool output never animate. The animation is word-level `fadeIn` with `duration: 140` and `stagger: 0`; character-level animation is forbidden. Previously completed words and blocks must remain stable as later chunks arrive, and the caret disappears when the send settles.
## Presentation Contract
Chat keeps the assistant-without-bubble layout and ClawX's established prose rhythm. Scoped Streamdown selectors restore heading and horizontal-rule margins over Streamdown's root spacing utility, compact ordered, unordered, and task-list items, and remove table wrapper borders while retaining the cell grid. Fenced code preserves Shiki's source-row spans as block lines, soft-wraps long lines, uses a compact right-aligned language header with vertically centered actions, and exposes copy without download; file preview keeps its preview-specific headings and inline code. Styling uses existing ClawX surfaces, text colors, dark-mode variants, and other design tokens; Streamdown defaults must not leak broad global changes into unrelated prose.
The supported math contract includes `$...$`, `$$...$$`, `\(...\)`, and `\[...\]`. CJK tests anchor punctuation exclusion from autolinks. Code tests wait for Shiki token output rather than assuming highlighting is synchronous.
## Performance Baseline And Review
`pnpm run perf:chat` builds the production Renderer and executes a deterministic 80-turn history plus 300 streaming chunks. Before renderer changes and after migration, run it three times on the same machine and retain each generated `renderer-benchmark.json`, `renderer.cpuprofile`, and `main.cpuprofile` under ignored `test-results/` paths.
Compare before/after medians for elapsed time, Renderer TaskDuration, ScriptDuration, layout and style duration, long-task count and duration, and sampled Markdown/React CPU stacks. Median TaskDuration and ScriptDuration must each stay within 10 percent of baseline. At least one of median ScriptDuration or sampled Markdown/render CPU time must improve by 10 percent or more. A miss requires profiling animation, Shiki, and last-block costs rather than weakening the threshold. Absolute machine timings are evidence for the local comparison, not automated cross-machine gates.
Build with `pnpm exec vite build --sourcemap` and inspect chunk sizes and source maps. Streamdown and Shiki are expected costs. The review must confirm no direct Mermaid plugin and no unexpected eager Mermaid renderer chunk. Dormant code retained by Streamdown core is measured and documented rather than described as Mermaid UI support.
## Validation Anchors
Shared configuration is anchored by `src/components/markdown/streamdown-config.ts` and `tests/unit/streamdown-config.test.tsx`.
Static preview behavior is anchored by `src/components/file-preview/MarkdownPreview.tsx`, `tests/unit/markdown-preview.test.tsx`, `tests/unit/file-preview-body.test.tsx`, and `tests/e2e/markdown-file-preview.spec.ts`.
Streaming state and rendering are anchored by `src/pages/Chat/AcpTimeline.tsx`, `src/pages/Chat/AcpAssistantTurn.tsx`, `src/pages/Chat/AcpMessageSegment.tsx`, `tests/unit/acp-chat-components.test.tsx`, and `tests/e2e/chat-streamdown-rendering.spec.ts`.
Existing soft-wrap, KaTeX, plain-assistant, and table-theme behavior remains anchored by `tests/e2e/chat-code-block-wrap.spec.ts`, `tests/e2e/chat-latex-rendering.spec.ts`, `tests/e2e/chat-assistant-markdown-plain.spec.ts`, and `tests/e2e/chat-table-header-light.spec.ts`. Performance evidence is produced by `tests/e2e/renderer-performance.spec.ts` through `pnpm run perf:chat`.
@@ -0,0 +1,197 @@
# Office Document Preview
Status: implemented contract, reviewed 2026-07-23.
Related scenarios: `chat-workspace-and-navigation`, `acp-chat-experience`, `acp-file-activity`
Related rule: `office-preview-safety`
Related task: `office-document-preview`
## Format And Limit Contract
Inline Office preview is extension-authoritative. Only the OOXML extensions below enter an Office parser:
| Extension | MIME mapping | Preview kind | Parser | Compressed input limit |
| --- | --- | --- | --- | --- |
| `.docx` | `application/vnd.openxmlformats-officedocument.wordprocessingml.document` | `docx` | `docx-preview` | 20 MB (`20 * 1024 * 1024` bytes) |
| `.pptx` | `application/vnd.openxmlformats-officedocument.presentationml.presentation` | `pptx` | `pptxviewjs@1.1.9` | 20 MB (`20 * 1024 * 1024` bytes) |
The extension wins when MIME conflicts. MIME alone never opts an unknown, missing, `.doc`, or `.ppt` extension into an OOXML parser. Legacy `.doc` and `.ppt` remain system-open-only. Office previews are read-only and do not expose Source or Diff tabs.
The shared discriminated limit contract is exact:
| Preview target | Maximum accepted input |
| --- | --- |
| Text | 2 MB (`2 * 1024 * 1024` bytes) |
| DOCX or PPTX rich preview | 20 MB (`20 * 1024 * 1024` bytes) |
| Image, PDF, or sheet rich preview | 50 MB (`50 * 1024 * 1024` bytes) |
The maximum itself is accepted and one byte above it is rejected. The Office limit applies to compressed input before parsing. Known over-limit files do not mount a viewer or import a parser. Unknown-size files use the same value as the Host API read `maxBytes`, so a race-time size increase returns `tooLarge` without transferring parser input.
## Dependencies And Loading
- `docx-preview` converts DOCX bytes to the generated HTML and CSS used for page preview. Its incomplete Word layout model is an accepted fidelity limitation.
- Exactly `pptxviewjs@1.1.9` parses PPTX packages and paints one slide at a time to Canvas. The version is pinned because the reviewed global-state, scheduler, cleanup, and accepted-retention decisions are specific to 1.1.9.
- `jszip` satisfies the PPTX parser's peer dependency and is the ZIP implementation used by the selected Office parsing dependencies.
- `chart.js` supplies `chart.js/auto`, which the selected `pptxviewjs` ESM build imports to paint supported embedded charts.
`FilePreviewBody` and `WorkspaceBrowserBody` load both Office viewer components with React `lazy()`. Each viewer dynamically imports its parser only after an authorized, in-limit binary read succeeds. This keeps the viewers, `jszip`, `chart.js`, and parser code out of the synchronous chat entry path and ensures rejected input cannot trigger parser initialization.
## Read Authority
Each viewer selects exactly one existing binary route and passes the 20 MB maximum:
| Target authority | Read route |
| --- | --- |
| Ordinary local path, including Workspace Browser's already validated absolute path | `readBinaryFile(filePath, { maxBytes })` |
| Explicit `WorkspaceFileRef` | `readWorkspaceBinary({ ...workspaceFileRef, maxBytes })` |
| Explicit `AttachmentFileRef` | `readAttachmentBinary(attachmentFileRef, maxBytes)` |
A target containing both scoped reference types is invalid and fails before any read. A scoped target never retries through `filePath`, another scope, or a naked-path API. Workspace Browser deliberately retains its existing Host-validated absolute-path route; other workspace-derived file activity enters Preview with its `WorkspaceFileRef`. Remote attachments do not enter preview and no preview-specific download flow exists.
Document bytes remain inside the existing Renderer/Main Host API boundary. The Renderer passes `Uint8Array` to parser APIs and does not use direct filesystem access, document `fetch()`, parser URL loaders, temporary files, uploads, Gateway endpoints, or a Main-process or external conversion service.
## Over-Limit Authority Routing
Over-limit behavior follows the target's authority rather than the display surface:
| Target | Behavior |
| --- | --- |
| Ordinary local Preview target | Show the too-large/direct-open surface with confirmed system open and reveal actions. |
| Workspace Browser validated absolute path | Show the same confirmed direct-open and reveal actions. |
| Local authorized attachment known to be over limit | `attachmentOpenMode()` chooses the existing scoped `hostApi.files.openAttachment()` system-open flow. |
| Remote attachment | Keep the existing scoped system-open flow; do not preview or download for preview. |
| `WorkspaceFileRef` Preview target | Show `tooLarge` without naked-path shell actions. |
| Scoped attachment whose bounded read detects a race-time size increase | Show `tooLarge` without falling back to a naked path. |
This distinction is required: ordinary paths own local shell authority, while scoped references retain only their scoped Host API authority.
## DOCX Rendering
`DocxViewer` creates a new detached body container and style container for each target generation. It invokes `renderAsync()` while both are disconnected, then appends the current generation's completed containers to an open Shadow Root on the React-owned host. Generated document CSS and DOM therefore cannot rewrite ClawX layout, and stale reads or renders cannot replace the selected document.
The render options are exact:
```ts
{
className: 'clawx-docx',
inWrapper: true,
ignoreWidth: false,
ignoreHeight: false,
ignoreFonts: false,
breakPages: true,
ignoreLastRenderedPageBreak: false,
renderHeaders: true,
renderFooters: true,
renderFootnotes: true,
renderEndnotes: true,
renderChanges: false,
renderComments: false,
renderAltChunks: false,
useBase64URL: true,
experimental: false,
debug: false,
}
```
`renderAltChunks: false` prevents embedded HTML parts from entering the preview. Comments and tracked changes are disabled. `useBase64URL: true` avoids library-created Blob URL lifetime and keeps generated resources releasable with the Shadow Root containers.
Capturing `click` and `auxclick` listeners on the Shadow Root prevent the default action of every generated anchor. Hash, HTTP(S), file, and custom-protocol links are all non-interactive; DOCX preview cannot navigate the ClawX window or invoke shell authority.
Pages remain centered, vertical, authored-size paper sheets. A `ResizeObserver` resets body CSS `zoom` to `1`, measures the widest `section.clawx-docx`, and applies `Math.min(1, host.clientWidth / widestPageWidth)`. Chromium CSS zoom scales dimensions and flow together. The viewer scales down to fit but never enlarges above authored size.
On target replacement or unmount, ClawX disconnects the observer, removes generated style/body containers, clears their children, and drops its direct byte and DOM references. An uncancellable render may finish after cleanup, but generation checks prevent it from attaching stale content.
## PPTX Rendering And Scheduling
`PptxViewer` uses one React-owned Canvas keyed by target identity. Unlike DOCX resources, the initial PPTX Canvas is mounted when `pptxviewjs` renders into it; it is not first rendered detached. Target identity, committed-generation checks, and latest-request checks prevent stale work from publishing, while React key replacement or unmount detaches the obsolete Canvas.
The `PPTXViewer` constructor receives exactly these behaviorally significant options:
```ts
{
canvas,
enableThumbnails: false,
slideSizeMode: 'fit',
backgroundColor: '#ffffff',
autoChartRerenderDelayMs: 0,
}
```
Before initial rendering, the viewer waits for positive container width and height for at most 60 checks, using at most 59 animation-frame waits. It applies the current container width and height as Canvas CSS dimensions before every render. `slideSizeMode: 'fit'` preserves the source aspect ratio within the centered preview surface.
A module-level promise queue serializes all `pptxviewjs` operations across lifecycles: construction, `loadFile()`, slide-count access, initial render, restored-position render, navigation, chart refresh, resize render, and `destroy()`. Each render request carries its generation, request identity, latest slide, and latest measured size; work made obsolete before execution is skipped. A failed current render terminates that lifecycle, while a rejected obsolete render cannot replace or fail the new target.
The presentation is parsed once per mounted target. Initial rendering always paints slide index zero. If the owning surface has a stored index for the same target identity, the viewer clamps it to the loaded slide range and then renders it. Position display is one-based, navigation is disabled at boundaries and while rendering, and `onSlideIndexChange` fires only after a successful current render.
The `ResizeObserver` uses a 100 ms trailing debounce. The callback does not render directly; after the debounce it re-reads dimensions and queues a current-slide refresh. Setting `autoChartRerenderDelayMs: 0` disables the dependency's uncancellable delayed chart render. The global `chartRenderingComplete` listener coalesces events while a refresh is pending and submits refreshes through the same serialized scheduler. Cleanup removes the listener and observer and cancels owned timers and animation frames.
## Single PPTX Instance
`pptxviewjs@1.1.9` stores presentation chart and ZIP state in Renderer globals including `window.currentProcessor` and `window.currentZipData`. At most one `PptxViewer` may therefore be mounted in the shared Electron Renderer. This is a correctness requirement, not a performance preference: concurrent decks could resolve chart or package data from the wrong presentation.
Workspace and Preview surfaces remain mounted to preserve surrounding UI state, but each conditionally mounts its PPTX child only while its artifact tab is active. CSS-only hiding is insufficient. A development assertion rejects a second concurrent viewer. The owning Workspace and Preview surfaces retain slide positions in maps keyed by target identity; switching away destroys and unmounts the viewer, and switching back reparses the deck and restores the clamped position.
Cleanup queues the active instance's public `destroy()` exactly once after preceding dependency work. ClawX removes all listeners, observers, timers, animation frames, queued request references, and its direct instance and Canvas ownership. The dependency limitations below mean this does not claim full internal reclamation.
## Fullscreen Preview Surface
The Chat Preview header exposes a localized icon control that moves the selected `FilePreviewBody` into a portal filling the Renderer viewport. This is an application overlay, not Electron window fullscreen, and applies consistently to every file format supported by the Preview surface. It preserves target identity and target-keyed PPTX slide position while switching between compact panel layout and full layout.
The same header control exits fullscreen, and Escape provides a keyboard exit. Switching away from the Preview artifact tab also closes the overlay. Portal transitions may remount a viewer, but the previous PPTX lifecycle is torn down before the replacement becomes active so the single-mounted-viewer invariant remains intact.
## States And Errors
Both viewers expose four lifecycle states:
- `loading`: authorized read, lazy parser import, parse, or initial render is in progress.
- `ready`: DOCX pages or the current PPTX slide and controls are visible.
- `tooLarge`: preflight or the bounded Host API read rejects input above 20 MB.
- `error`: authority validation, read, empty input, parse, layout sizing, or render failed.
Errors use localized format-specific generic messages and never show parser exceptions, paths from parser errors, or stack traces. Corrupt, malformed, encrypted, password-protected, empty, and otherwise unsupported OOXML inputs may fail into `error`. There is no automatic retry loop. Reselecting or reopening a target creates a new load.
## Non-Goals
- Legacy `.doc` or `.ppt` parsing or conversion.
- Editing, saving, comments, tracked changes, Word search, or table-of-contents tooling.
- Pixel-identical Microsoft Word or PowerPoint layout.
- DOCX link opening or application-window navigation from generated content.
- PPTX thumbnails, slide-navigation keyboard shortcuts, animation, transitions, media playback, presenter mode, or automatic slide shows. The generic Preview surface can fill the application viewport, but it does not implement PowerPoint presenter behavior or native Electron fullscreen.
- Remote-attachment downloading for preview.
- Main-process, server, cloud, or external-service conversion.
- Changes to existing PDF, spreadsheet, image, HTML, Markdown, source, or diff behavior.
## Rejected Alternatives
- MIME-driven Office parser selection was rejected because legacy or unknown extensions must not enter an OOXML parser.
- Main-process or cloud conversion, temporary-file conversion, parser URL loading, and Renderer `fetch()` were rejected to preserve existing authority and data boundaries.
- DOCX light-DOM rendering and interactive generated links were rejected because document CSS and navigation must not gain application authority.
- Transform-only DOCX scaling was rejected because transformed pages can overlap later flow; Chromium CSS zoom scales layout dimensions with content.
- Keeping multiple PPTX viewers mounted under CSS `hidden` was rejected because shared dependency globals can cross-contaminate presentations.
- Independent initial, navigation, chart, and resize render paths were rejected because uncancelled operations can race. One serialized scheduler owns every dependency operation and render source.
- The library's delayed chart rerender was rejected in favor of an owned, coalesced event refresh. Patching `pptxviewjs` internals was also rejected; the reviewed public API and accepted limitation remain explicit.
## Accepted Risks And Limitations
- Both ZIP-based parsers run in the Renderer and can briefly occupy the UI thread on complex in-limit files.
- A 20 MB compressed cap reduces but does not eliminate ZIP expansion or high peak-memory risk.
- `docx-preview` is not Word's pagination engine; wrapping, page breaks, fonts, and layout can differ.
- `pptxviewjs` has incomplete PowerPoint fidelity for uncommon shapes, fonts, animations, transitions, and media.
- Embedded fonts depend on available platform fonts and fallbacks.
- Public `destroy()` in `pptxviewjs@1.1.9` does not fully clear internal caches, URLs, delayed work, or chart-related globals. Repeated presentation switches may retain dependency-owned memory until the Renderer exits. The single-instance rule prevents concurrent cross-presentation corruption but does not eliminate this accepted retention risk.
## Future Hardening
The current 20 MB check is a product performance guard, not a complete malicious-ZIP boundary. Future ZIP hardening may add pre-parse entry-count, expansion-ratio, and XML-complexity budgets. Such checks must preserve the same target authority routes and fail before either parser receives bytes; they are not implemented or implied by the current release.
## Validation Anchors
Format classification and limits are anchored by `shared/file-preview/limits.ts`, `src/lib/generated-files.ts`, `src/lib/file-preview-capabilities.ts`, `tests/unit/generated-files.test.ts`, and `tests/unit/open-file-utils.test.ts`.
Renderer authority, DOCX isolation/options/links/zoom, PPTX construction/sizing/scheduling/chart behavior/restoration/cleanup, stale-generation handling, and generic errors are anchored by `src/components/file-preview/DocxViewer.tsx`, `src/components/file-preview/PptxViewer.tsx`, and `tests/unit/office-file-viewers.test.tsx`.
Surface preflight, authority-specific fallback, conditional mounting, and position ownership are anchored by `src/components/file-preview/FilePreviewBody.tsx`, `src/components/file-preview/WorkspaceBrowserBody.tsx`, `src/components/file-preview/ArtifactPanel.tsx`, `src/pages/Chat/AcpTurnFileActivity.tsx`, `src/pages/Chat/AcpAttachmentPart.tsx`, `tests/unit/file-preview-body.test.tsx`, `tests/unit/workspace-browser-body.test.tsx`, `tests/unit/artifact-panel.test.tsx`, and `tests/unit/acp-chat-components.test.tsx`.
`tests/e2e/office-document-preview.spec.ts` uses real deterministic DOCX/PPTX packages to anchor Shadow Root page rendering, Canvas pixels, chart completion, slide navigation, per-target position restoration, constrained-panel resizing, viewport-filling Preview transitions, the single-mounted-viewer invariant, Host API read routes, and absence of legacy direct IPC.
@@ -0,0 +1,21 @@
# 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.
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.
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.
Gateway WebSocket tracing must redact the complete serialized `raw` payload for `config.set`, `config.patch`, and `config.apply`; key-based structural redaction cannot inspect secrets embedded inside that string.
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.
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.
+14 -10
View File
@@ -1,18 +1,18 @@
# OpenClaw File Activity
Status: current compatibility and safety reference, reviewed 2026-07-15.
Status: current compatibility and safety reference, reviewed 2026-07-23.
Related scenario: `acp-file-activity`
Related rules: `tool-derived-file-safety`, `session-workspace-authority`, `attachment-access-safety`
Related rules: `tool-derived-file-safety`, `session-workspace-authority`, `attachment-access-safety`, `office-preview-safety`
Related tasks: `restore-acp-file-activity`, `acp-media-attachments`
Related tasks: `restore-acp-file-activity`, `acp-media-attachments`, `acp-attachment-open-with`, `unify-acp-file-cards`, `office-document-preview`
## Semantics And Ownership
File activity is a pure Renderer projection over the active ACP timeline. It records file changes declared by successful OpenClaw file-editing tool calls. It is not a Git diff, a verified disk diff, or a session-start baseline.
ClawX does not scan or watch the workspace, create snapshots, infer shell/script side effects, parse arbitrary prose, call `sessions.files.list` to manufacture diffs, or persist an activity ledger. Main does not interpret tool semantics; it only performs workspace-scoped read/stat operations.
ClawX does not scan or watch the workspace, create snapshots, infer shell/script side effects, parse arbitrary prose, call `sessions.files.list` to manufacture diffs, or persist an activity ledger. Main does not interpret tool semantics; it only performs workspace-scoped read/stat and explicit native file actions.
The supported tools are exactly `write`, `edit`, and `apply_patch`. Tool identity is the trimmed lowercase segment before the first colon in OpenClaw's ACP title. Status must be `completed`. Unsupported, malformed, pending, running, failed, and cancelled calls remain ordinary tool cards but produce no file activity.
@@ -60,7 +60,7 @@ Line counts normalize CRLF, compare fragments, and sum additions/removals. Missi
Tool paths are untrusted. `workspaceRoot` is the containment boundary and `executionCwd` is the ACP working directory. Relative paths resolve against execution cwd; both relative and absolute candidates must remain lexically inside workspace root and use the same path family. Replay without authoritative root and cwd produces no projection.
Preview uses a relative reference end to end:
Preview and explicit native actions use a relative reference end to end:
```ts
type WorkspaceFileRef = {
@@ -69,19 +69,23 @@ type WorkspaceFileRef = {
};
```
Main independently canonicalizes each read/stat request, checks real paths and nearest existing parents, rejects traversal and symlink escape, and avoids following unsafe final links. Renderer lexical rejection prevents activity UI for obvious outside paths. A later Main rejection keeps the historical activity but shows localized unavailable feedback.
Main independently canonicalizes each read/stat/native-action request, checks real paths and nearest existing parents, rejects traversal, non-files, and symlink escape, and avoids following unsafe final links. Handler discovery, selected-handler open, and reveal each re-resolve the `WorkspaceFileRef`; selected-handler open performs an additional callback revalidation immediately before native invocation. Renderer lexical rejection prevents activity UI for obvious outside paths. A later Main rejection keeps the historical activity while refusing the requested file operation.
Tool-derived targets are read-only in-app previews. They never expose system open or reveal because path validation cannot be atomic with OS shell dispatch. Existing trusted workspace-browser targets may retain their established operations.
Tool-derived targets are read-only in-app previews and never use naked-path shell APIs. Created and modified activity may expose a separate Open with menu whose native actions are backed only by workspace-scoped Host API operations; deleted activity does not. For HTML, the menu first offers browser navigation to the file URL constructed from the effective workspace root and contained relative path. The native adapter receives a Main-owned canonical path and opaque handler id, and Main revalidates the workspace reference before invocation. Linux offers only workspace-scoped reveal after the browser action where eligible.
`src/pages/Chat/AcpFileCard.tsx` supplies the shared attachment/file-activity presentation shell and target-aware menu without sharing grants. In-limit DOCX/PPTX activity reaches the Office viewers through its `WorkspaceFileRef`; parsing and single-viewer constraints are documented in `harness/reference/office-document-preview.md`.
## Separation From Attachments
File activity and user-facing attachments are separate projections and security boundaries. Incidental paths in tool input or output remain tool-derived evidence: they cannot become attachment cards and retain the preview-only restrictions above. Attachment evidence must instead come from standard ACP resource content, a Main-owned user staging record, or the bounded explicit assistant `MEDIA:` exception documented in `harness/reference/acp-generated-media-and-diagnostics.md#bounded-transcript-exceptions`.
File activity and user-facing attachments are separate projections and security boundaries. Incidental paths in tool input or output remain tool-derived evidence: they cannot become attachment cards, resolve outside the workspace, or use attachment-scoped authorization. Attachment evidence must instead come from standard ACP resource content, a Main-owned user staging record, or the bounded explicit assistant `MEDIA:` exception documented in `harness/reference/acp-generated-media-and-diagnostics.md#bounded-transcript-exceptions`.
Main establishes attachment session and relative-path context only when the ACP session load or creation succeeds. Each attachment resolve, preview read, and system or external open then revalidates the exact session, generation, reference, and canonical target; unlike tool-derived file activity, explicit attachment evidence may resolve outside the workspace. This attachment-scoped operation supports click-initiated system open without weakening the separate rule that incidental tool-derived targets never expose system open or reveal. The complete boundary is documented in `harness/reference/acp-attachment-access-control.md`.
Main establishes attachment session and relative-path context only when the ACP session load or creation succeeds. Each attachment resolve, preview read, and system or external open then revalidates the exact session, generation, reference, and canonical target; unlike tool-derived file activity, explicit attachment evidence may resolve outside the workspace. File activity never enters that attachment pipeline: its explicit native actions remain restricted to the canonical workspace through `WorkspaceFileRef`. The complete attachment boundary is documented in `harness/reference/acp-attachment-access-control.md`.
## User Experience And Replay
Each assistant turn shows one file button and one summary per eligible path. Created/modified buttons open current-file Preview; deleted buttons open Changes. Changes is session-scoped, grouped by file, and shows at most one diff editor per turn and path. Empty sessions explicitly state that the session has no file changes.
Each assistant turn shows one file button and one summary per eligible path. Created/modified buttons open current-file Preview and include Open with; deleted buttons open Changes and omit Open with. Changes is session-scoped, grouped by file, and shows at most one diff editor per turn and path. Empty sessions explicitly state that the session has no file changes.
When a preview supports multiple views, its segmented switcher shares the trailing side of the file name/path header rather than consuming a separate row. HTML files expose `Preview` then `Source`, default to the sandboxed rendered preview, and preserve the same scoped read result when switching views.
Full ACP structured replay restores available activity through the same projection. Transcript-only or incomplete replay does not infer missing records. Session switch clears the projection with the active timeline.
@@ -0,0 +1,187 @@
# Sidebar Session Attention
Status: current architecture reference, reviewed 2026-07-23.
Related scenarios: `gateway-backend-communication`, `chat-workspace-and-navigation`
Related rule: `sidebar-session-attention-authority`
Related task: `sidebar-session-attention`
## Authority And Rationale
OpenClaw Gateway session rows are the sole authority for sidebar run state. The existing `useChatStore.sessions` collection remains the Renderer session catalog; attention adds presentation state, not a second catalog. This lets one projection cover ClawX prompts, channel-triggered work, and other Gateway clients whenever the Gateway projects the run onto the same catalog session key.
ACP prompt state and ACP timeline updates are scoped to an initialized agent connection and cannot observe the whole shared session catalog. Gateway `agent` lifecycle events and ClawX's local `sending` state describe other concerns and can be incomplete for work initiated elsewhere. None of them may derive or override sidebar busy or unread state. Renderer code continues to use the Main-owned Gateway connection through `hostApi`, `useGatewayStore.rpc`, and `hostEvents`; it does not open another transport.
The bundled OpenClaw 2026.6.10 behavior supporting this authority is:
1. `sessions.subscribe` enables `sessions.changed` notifications for a Gateway connection.
2. A notification includes a session snapshot when the Gateway can provide one.
3. `sessions.list` reconstructs `hasActiveRun` from the active-run registry and is the canonical recovery snapshot.
4. The OpenClaw WebUI applies reliable event snapshots and reloads the list when it cannot apply an event safely.
5. Terminal status overrides a stale active-run boolean; otherwise the boolean is authoritative, with `running` as the compatibility fallback.
When validating this upstream contract against a new OpenClaw version, inspect the session-list projection in `list.ts` and the WebUI Gateway reducer in `event.ts`, as well as the protocol definitions for subscription, event, and patch fields. Basenames are stated here because upstream source layout can move between releases.
## Catalog Normalization
`src/stores/chat/session-catalog.ts` supplies the shared allowlisted normalizer for both list rows and event patches. It trims the catalog key, lowercases and trims status, accepts finite numeric or parseable string activity timestamps, converts second timestamps to milliseconds, maps `lastChannel` before `channel`, and projects only known `ChatSession` fields. Unknown payload properties never enter the catalog.
List rows use the normalizer as complete row snapshots. Event rows use the same field conversion as presence-aware patches:
- An omitted property is not merged and leaves the existing value unchanged.
- An explicit boolean `false`, especially `hasActiveRun: false`, is retained rather than treated as absent.
- Explicit `null` clears an optional non-null `ChatSession` property; the catalog does not store literal nulls.
- A present value of an unsupported type is neither copied nor interpreted as a clear.
For a `sessions.changed` payload, identity and source selection are exact:
1. The envelope key is the first non-empty normalized top-level `sessionKey`, then top-level `key`.
2. The nested key is normalized from `session.key`.
3. If both keys exist and differ, reject the event and request a canonical reload.
4. Otherwise the resolved key is the nested key, then the envelope key.
5. A nested `session` object is the row source when present; otherwise the top-level envelope is the patch source.
6. An unknown row may be inserted only from a nested snapshot with its own non-empty, non-conflicting key. A partial envelope for an unknown key requires a reload.
7. `reason === "delete"` is accepted only with a valid envelope key and removes that exact key.
Catalog and attention identity is the normalized exact session key. Existing cron parsing may map a run-scoped key to a base key for activity sorting, but a key containing a cron run identity is never inserted, merged into, or reconciled as base-row attention. The current Gateway list cannot recover that relationship after reconnect.
## Run Projection
`projectSessionRunState` normalizes `status` with `trim().toLowerCase()` and returns `busy`, `idle`, or `unknown` in this exact order:
1. A recognized terminal status returns idle even if stale `hasActiveRun: true` is present. Bundled terminal values are `done`, `failed`, `timeout`, and `killed`; accepted aliases are `completed`, `finished`, `error`, `aborted`, and `cancelled`.
2. When present as a boolean, `hasActiveRun` returns busy for true and idle for false, regardless of another non-terminal status.
3. With no boolean, normalized `status === "running"` returns busy.
4. Every other row returns unknown and cannot create or clear an attention transition.
Attention reconciliation and sidebar presentation call this same helper. Event `phase`, activity timestamps, ACP state, and runtime events are not inputs to this projection.
## Attention State And Transitions
`src/stores/session-attention.ts` stores exact-key `{ observedBusy, unread }` records. Its Zustand persistence uses the versioned Renderer storage key `clawx.session-attention`, currently version 1. Only `bySessionKey` is persisted; `visibleSessionKey` is memory-only. Migration and merge sanitize the full persisted map and fall back to an empty map if any entry is malformed, so bad local data cannot block the sidebar.
The transition table is normative:
| Previous attention | Gateway projection | Visible Chat session | Result |
| --- | --- | --- | --- |
| Any | Busy | Any | Set `observedBusy=true`; retain the existing unread bit. |
| `observedBusy=true` | Idle | Same exact key | Set `observedBusy=false`, `unread=false`. |
| `observedBusy=true` | Idle | Different key or none | Set `observedBusy=false`, `unread=true`. |
| No observed busy | Idle | Any | Do not create unread; retain existing unread state. |
| Any | Unknown | Any | Preserve both attention fields. |
Retaining unread when another run becomes busy is intentional. The spinner hides the older dot while busy, but completion reveals unread again unless the conversation became visible. Persisting `observedBusy` also proves the restart-recovery case where ClawX observed busy, exited, and later receives an idle canonical row.
Filtered, partial, or temporarily incomplete lists do not prune missing attention entries. Only an exact deletion or explicit local session removal deletes one. Ordered transition folds commit one final attention map, avoiding intermediate spinner/dot renders.
The sidebar trailing area has strict `busy > unread > timeago` precedence:
- A live busy projection shows the localized spinner and hides unread and time.
- Idle with unread shows the localized blue dot and hides time.
- Idle and read shows the existing relative timestamp and full timestamp title.
- For an unknown live projection, persisted `observedBusy` shows the spinner, then persisted unread shows the dot, otherwise time remains visible.
The indicator labels are localized in all supported Chat locales, and the dot is not the only accessible unread indication. Attention never changes session activity ordering.
## Visible Session And Read Semantics
Read authority is the visibly mounted Chat conversation, not merely `currentSessionKey`. The Chat page calls `setVisibleSession(currentSessionKey)` while mounted and whenever the key changes, then calls `setVisibleSession(null)` on cleanup. Setting a non-null visible key atomically records visibility and clears that key's unread bit. Clearing visibility does not mark any conversation read.
A conversation is read in either case:
- Its exact key is visibly mounted in Chat when busy-to-idle is reconciled.
- The user activates its sidebar row. The click path calls `markRead` synchronously before switching/loading and navigating to Chat.
Deep links and programmatic navigation are covered by the Chat visibility effect. Settings and other routes can retain `currentSessionKey`, but because Chat is unmounted they are not read authority; completion there becomes unread.
## Gateway Epoch Subscription
`src/stores/gateway.ts` identifies a ready Gateway runtime as `${pid ?? "none"}:${connectedAt ?? "none"}:${port}`. A changed ready identity advances a numeric session-catalog generation. Leaving ready/running state clears the synchronized identity so a recovered connection establishes a new epoch.
For each ready epoch the coordinator:
1. Initializes generation-scoped event buffers and clears prior per-key and successful-list timestamp fences.
2. Calls `sessions.subscribe` once for that observed identity.
3. Forces `sessions.list` in `finally`, whether subscription succeeds or fails.
4. Logs subscription or hydration failure without blocking chat; a later ready epoch retries subscription.
Events arriving while subscription or an older list is pending are retained for the current generation. Forced epoch hydration cannot be satisfied by an older in-flight ordinary load: it queues a successor load after the old flight settles. Generation checks fence subscription, list, and replay work so a response from an old Gateway cannot install current state. Periodic Gateway status reconciliation can rediscover a missed restart and establish the corresponding epoch without repeatedly subscribing to an unchanged identity.
## List And Event Ordering
`sessions.changed` provides low-latency updates; `sessions.list` is canonical startup, reconnect, and uncertainty recovery. Every list request, including ordinary throttled loads, buffers session events in arrival order while the request is in flight.
For a successful timestamped list transaction:
1. Normalize, filter, and deduplicate the list into the candidate catalog.
2. Start attention reconciliation with the canonical list rows, excluding exact keys made uncertain by untimestamped buffered events.
3. Replay finite-timestamp buffered events in arrival order when `event.ts >= list.ts`. Equality is accepted because equal Gateway timestamps do not prove the event preceded the snapshot; only `event.ts < list.ts` is discarded.
4. Represent applied row snapshots and exact deletions as ordered attention transitions and fold them in memory.
5. Publish the final catalog and final attention result, not intermediate list/event states.
6. Schedule one forced follow-up when an applied event is partial, unsafe, or otherwise requests canonical recovery.
A successful finite `list.ts` advances the epoch's successful-list floor and each installed row's exact-key fence to `max(existingFence, list.ts)`. Outside a list flight, an event must have finite `ts`; events below the successful-list floor are discarded even for a key absent from the list. For a known exact key, an event older than its latest accepted timestamp is discarded. Equality is accepted at both fences; only strictly older timestamps are rejected. Terminal status still wins over stale `hasActiveRun` inside each accepted snapshot.
## Uncertainty And Failure Recovery
An event without finite `ts` is unorderable. It is not merged speculatively and causes one forced list recovery:
- If its exact key can be resolved, attention for that key is held unchanged while independently orderable keys may still reconcile.
- If no safe exact key can be resolved, all attention is held unchanged for that transaction.
- Missing or non-finite `list.ts` likewise prevents a reliable attention fold and schedules a follow-up list.
If `sessions.list` fails, finite current-epoch buffered events at or above the last successful-list floor are reduced against the existing catalog in arrival order. Reliable exact-key attention transitions are folded once; attention for keys touched by untimestamped events is preserved, and unscoped uncertainty preserves all attention. Applied exact deletions still clean catalog metadata and attention. One forced retry follows. If that retry also fails, the best reliable reduced state remains rather than fabricating an idle completion.
Malformed identity, conflicting nested/envelope keys, unknown partial rows, and other unsafe snapshots request throttled canonical recovery. Unknown run projection preserves the last attention state. A subscription failure does not disable list loading. A stale generation cannot recover or overwrite a newer one.
## Delete And Recreate Incarnations
An exact deletion removes the catalog row, persisted attention, cached sidebar label, and activity metadata. During buffered replay the delete occurs at its exact sequence position, so a same-key recreation starts with fresh attention before later busy and idle snapshots are folded.
Deletion also calls `clearSessionLabelHydrationTracking`. `src/stores/chat/session-label-hydration.ts` increments an in-memory incarnation included in each hydration version and clears handled/in-flight records. A recreated row with the same catalog key therefore receives a new version even if its activity timestamp and backend label are identical. Old async summary completion cannot mark the new incarnation handled or overwrite its label; new hydration can begin normally. This cleanup applies in standalone handling, successful list replay, and failed-list reduction.
## Limitations
- A run that starts and finishes while ClawX is fully closed is unobserved and cannot produce a justified unread marker with OpenClaw 2026.6.10.
- Run-scoped cron keys cannot drive base-row attention until `sessions.list` exposes a recoverable canonical relationship. They may still affect existing activity sorting.
- Local attention is presentation state, not a Gateway-wide read receipt. Another client opening a conversation does not clear ClawX's local unread bit.
- Missing rows in partial or filtered lists cannot prove deletion and therefore do not prune attention.
- Unknown Gateway state deliberately favors preserving the last indicator over guessing idle.
- The local attention store contains no messages, tool state, timelines, runtime graph, or route visibility.
## Future Gateway Unread Migration
When the bundled OpenClaw release provides durable row-level `hasActiveRun` and `unread` plus writable `sessions.patch`, replace local unread authority rather than layering Gateway unread over the current store. This migration is self-contained:
1. Confirm the upgraded protocol's list, event, patch, timestamp, and deletion semantics with source and contract tests.
2. Extend the shared allowlisted row/patch normalizer to preserve explicit boolean `unread`, including false and null/omission behavior defined by that protocol.
3. Keep the current active-run projection, exact-key catalog authority, epoch subscription, canonical hydration, event buffering, and timestamp fences.
4. Render unread directly from the normalized Gateway row. Do not infer or merge it with local `observedBusy` transitions.
5. A sidebar activation or visibly mounted Chat conversation acknowledges the exact row through the existing Main-owned RPC boundary with `sessions.patch({ unread: false })`; optimistically clearing UI is acceptable only with canonical failure recovery.
6. Remove the local transition store and retire the `clawx.session-attention` persistence key through an explicit versioned cleanup so old local bits cannot reappear.
7. Preserve `busy > unread > timeago`, visible-Chat semantics, accessibility, and exact deletion behavior in unit and Electron E2E coverage.
Do not begin this migration merely because a type exists in an unbundled upstream branch. The bundled Gateway must expose and persist the complete contract used by ClawX.
## Rejected Alternatives
- ACP prompt/timeline state: scoped to ClawX-owned agent connections and misses channel or other-client runs.
- Gateway `agent` events or local `sending`: transport/runtime lifecycle is not the canonical session catalog projection.
- `updatedAt` inference: rename, metadata, transcript maintenance, and unrelated activity would fabricate unread completions.
- `currentSessionKey` as visibility: non-Chat routes retain it and would incorrectly mark hidden conversations read.
- A second Renderer session collection: duplicates catalog authority and creates divergent merge/deletion behavior.
- Event-only state: missed notifications and reconnects require canonical `sessions.list` recovery.
- Pruning absent list rows: filtered and partial snapshots do not prove exact deletion.
- Folding run-scoped cron keys into base rows: reconnect cannot reconstruct the relationship.
- Guessing fully offline completions: there is no durable evidence in the bundled protocol.
- A Renderer-owned Gateway socket or protocol switch: violates the Main-owned communication boundary.
## Validation Anchors
Primary implementation anchors are `shared/chat/types.ts`, `src/stores/gateway.ts`, `src/stores/chat.ts`, `src/stores/chat/session-catalog.ts`, `src/stores/chat/session-status.ts`, `src/stores/chat/session-label-hydration.ts`, `src/stores/session-attention.ts`, `src/components/layout/Sidebar.tsx`, and `src/pages/Chat/index.tsx`.
Focused unit anchors are `tests/unit/session-status.test.ts`, `tests/unit/session-catalog.test.ts`, `tests/unit/session-attention.test.ts`, `tests/unit/session-label-hydration.test.ts`, `tests/unit/gateway-events.test.ts`, `tests/unit/gateway-event-dispatch.test.ts`, `tests/unit/chat-store-session-label-fetch.test.ts`, `tests/unit/chat-session-management.test.ts`, `tests/unit/sidebar-session-buckets.test.ts`, `tests/unit/i18n-locale-parity.test.ts`, and `tests/unit/harness-specs.test.ts`. End-to-end presentation and navigation are covered by `tests/e2e/chat-sidebar-session-attention.spec.ts`.
Communication changes require the task's Harness validation, communication replay/compare, typecheck, lint, Vite build, targeted unit tests, and Electron E2E test.
+40
View File
@@ -0,0 +1,40 @@
# Local HTML Preview Architecture
ClawX no longer exposes a general-purpose embedded Web Browser. The remaining Electron webview is used only to render an authorized local `.html` or `.htm` file inside the existing Preview tab.
## User flow
- Activating local HTML from an attachment, file activity, or Workspace opens Preview.
- The HTML file menu offers the built-in Preview path alongside compatible system applications.
- The Preview header offers one file-level action to open the current HTML through the system browser.
- There is no browser tab, Home page, URL input, navigation history, refresh menu, favicon, cookie/site-data UI, or blank browser entry.
## Link behavior
All links are inert:
- ClawX-rendered Markdown/content links are plain text.
- Inside HTML Preview, Main injects user-origin CSS that removes anchor and area styling and pointer interaction.
- Main also prevents navigation independently, so scripts, forms, synthetic clicks, hash navigation, redirects, and popups cannot bypass the visual restriction.
- Downloads and network requests are canceled.
## Renderer flow
HTML entry points build an ordinary `FilePreviewTarget` and call `useArtifactPanel.openPreview`. `FilePreviewBody` renders an HTML anchor in Preview. The route-stable host in `MainLayout` overlays one webview on that anchor and asks `hostApi.webBrowser.navigate` to load the selected file.
The host has no browser chrome. It exists only for an HTML `focusedFile`, remains hidden and inert when Preview is not visible, and can recover a crashed guest without restoring browsing state. Because the guest is route-stable and positioned over a Renderer anchor, it raises its stacking level above the fullscreen Preview layer whenever that anchor is portaled to the fullscreen surface.
## Main boundary
`normalizeWebBrowserHtmlFileUrl` accepts only hostless, query-free, fragment-free `file:///` URLs ending in `.html` or `.htm`. The Host API has only:
- `navigate`: load one validated local HTML URL in the registered guest.
- `openExternal`: revalidate the selected local HTML URL, then call `shell.openExternal`; it does not accept web destinations.
Main retains the exact guest identity gate, one-live-guest registry, fixed isolated `persist:clawx-web-browser` partition and User-Agent, sandbox, context isolation, web security, and disabled Node/preload surface.
The dedicated Session denies all permissions, cancels downloads, blocks network protocols, and rejects non-HTML main documents. The guest policy denies all child windows and every guest-initiated top-level or in-page navigation.
## Security consequence
The preview can execute self-contained local HTML scripts for rendering, but it cannot follow links, leave its selected document, request network data, download files, obtain device permissions, or access ClawX/Electron APIs.
@@ -8,8 +8,8 @@ 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. 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 ordinary live prompt updates continue through host events. 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. 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.
ACP replay is the primary history authority. The only approved transcript 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. Both exceptions remain marked and in memory; do not generalize them to bare paths, surrounding transcript prose, ordinary messages, tool cards, plans, permissions, thoughts, file activity, or any parallel history.
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.
The shared historical transcript read is limited to the newest `1000` messages. A successful live prompt reads immediately and retries exactly once after `1500 ms`. General attachment alignment treats that history as a suffix and matches the binary-free OpenClaw prompt-text projection of structured ACP user blocks by duplicate occurrence from the tail; it must not parse or globally remove user-authored resource marker text. Attachment-only empty projections remain eligible, and live 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.
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.
@@ -9,6 +9,6 @@ appliesTo:
Standard ACP content is authoritative and preferred. A compatibility supplement is allowed only when it is explicitly marked by source, retained in memory, backed by approved structured runtime evidence or explicit assistant transcript evidence, and accompanied by reason-coded diagnostics. Compatibility data must never be represented as a native ACP event.
Approved transcript evidence has two bounded forms: asynchronous image-generation completion with proven image-generation context, including explicit internal-UI `message` tool source replies; and general attachment recovery from whole-line, line-leading assistant OpenClaw `MEDIA:` directives outside fenced code blocks. The general form accepts only the documented local path, `file:`, execution-cwd-relative, HTTP, and HTTPS forms; quoted references may contain spaces, while unquoted references may not. It does not require image-generation context and projects only one ordered attachment reference per directive, never surrounding transcript prose. A trusted image-generation source reply may provide user-facing completion or failure text. Reject malformed or wrapped directives, bare or inline prose paths, unknown URI schemes, incidental tool paths, and unrelated assistant prose.
Approved transcript evidence has three bounded forms: asynchronous image-generation completion with proven image-generation context, including explicit internal-UI `message` tool source replies; canonical persisted assistant `__openclaw.media` facts; and general attachment recovery from whole-line, line-leading assistant OpenClaw `MEDIA:` directives outside fenced code blocks. Canonical facts and directives accept only the documented local path, `file:`, execution-cwd-relative, HTTP, and HTTPS forms. Quoted directive references may contain spaces, while unquoted directives may not; canonical structured values may contain spaces. General recovery projects only ordered attachment references and declared media metadata, never surrounding transcript prose. A trusted image-generation source reply may provide user-facing completion or failure text. Reject malformed or wrapped directives, bare or inline prose paths without canonical media facts, unknown URI schemes, incidental tool paths, and unrelated assistant prose.
Compatibility logic must not reconstruct ordinary assistant messages, thoughts, tools, plans, permissions, file activity, or a parallel Chat history. User-side OpenClaw prompt projection may be reconstructed only from structured ACP content already present in the same timeline; generated-looking user prose is not evidence and must not be stripped or parsed. Unmatched or ambiguous evidence is skipped rather than attached by guesswork. Deduplication is turn-scoped and uses only a Main-authorized opaque identity; native ACP resource content wins over equivalent compatibility evidence, generated-image evidence remains inline, and an unavailable result does not block a later available upgrade.
@@ -15,4 +15,7 @@ Rules:
- allowlists and entries must agree about which package owns a single-owner capability
- 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
- 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
@@ -7,8 +7,16 @@ appliesTo:
- gateway-backend-communication
---
Treat every Renderer attachment URI, metadata field, staging id, transcript id, and source reference as untrusted. A successful ACP load or creation establishes the Main-owned session, generation, workspace, and execution cwd used to resolve references. Main validates every resolve, scoped read, and local or remote open against the exact active session key and generation. Attachment refs, attachment ids, opaque identities, and a prior successful resolve are not bearer capabilities, and later requests cannot provide or replace the execution cwd.
Treat every Renderer attachment URI, metadata field, staging id, transcript id, source reference, and selected handler id as untrusted. A successful ACP load or creation establishes the Main-owned session, generation, workspace, and execution cwd used to resolve references. Main MUST validate every resolve, scoped read, list, selected-handler open, reveal, and local/remote open against the exact active session key and generation. Attachment refs, ids, opaque identities, handler ids, prior resolves, list results, and cache entries MUST NOT act as bearer capabilities, and later requests MUST NOT provide or replace the execution cwd.
Allow local targets only when an accepted absolute, home-relative, `file:`, or execution-cwd-relative reference resolves to an existing regular file. Local paths are not restricted to the active workspace or managed media roots; workspace/media/staging scope is classification metadata, not a containment grant. A supplied staging id must still match its Main-owned record. Outgoing media URLs additionally require exact attachment, URL-session, record-session, optional message-id, and managed original-file binding. Reject traversal, NUL, unknown or unsafe schemes, remote file authorities, credentials, malformed references, and unauthorized outgoing records. Sanitize labels, expose only opaque identities, re-resolve before every operation, and perform final file-handle and generation checks for scoped reads.
Allow local targets only when an accepted absolute, home-relative, `file:`, or execution-cwd-relative reference resolves to an existing regular file or directory. Directories MUST be identified explicitly by Main, use `application/x-directory` with zero display size, and remain limited to click-initiated system open; they MUST NOT enter scoped reads, Preview, Open With discovery/selection, reveal-as-file, outgoing media, or content enumeration. Local paths are not restricted to the active workspace or managed media roots; workspace/media/staging scope is classification metadata, not a containment grant. A staging id MUST match its Main-owned record, including the exact canonical path for a selected directory. Outgoing media URLs additionally require exact attachment, URL-session, record-session, optional message-id, and managed original-file binding. Reject traversal, NUL, unknown/unsafe schemes, remote file authorities, credentials, malformed or over-4096-character references, and unauthorized outgoing records. Sanitize labels, expose only opaque identities, re-resolve before every operation, and perform final file-handle and generation checks for scoped reads.
Attachment previews must use attachment-scoped read operations and cannot fall back to naked-path or general workspace APIs. System or external open is click-initiated and Main-owned. One unavailable or malformed attachment remains isolated from assistant prose and other attachments. See `harness/reference/acp-attachment-access-control.md`; exact TypeScript contracts and current constants remain code-authoritative.
Attachment previews MUST use attachment-scoped reads and MUST NOT fall back to naked-path or general workspace APIs. Handler list, selected-handler open, and reveal MUST remain typed attachment-scoped `files` operations routed through `src/lib/host-api.ts`; components MUST NOT add direct IPC, Gateway HTTP, raw-path shell calls, or transport switching. Each operation MUST independently resolve the original ref and active session/generation. Selected-handler open MUST perform a fresh uncached icon-free operating-system enumeration, require exact current handler membership, then re-resolve the original ref and recheck generation immediately before native invocation. It MUST reject association-key changes. A stable handler id is selection metadata, not authority. Renderer MUST NOT provide or receive a canonical path, executable/application/bundle/icon-source path, native Windows identity, association input, helper source, command line/template, or child-process environment addition.
Native adapters MUST keep their source and protocol static. macOS MUST invoke `/usr/bin/osascript -l JavaScript` with the checked-in static JXA program and positional data, use AppKit/Foundation `NSWorkspace` discovery, expose only public bundle ids, perform fresh icon-free membership validation, and invoke `/usr/bin/open -a` only with the freshly selected private bundle path and post-enumeration revalidated file. Windows MUST use the bundled static PowerShell/C# helper and documented `SHAssocEnumHandlers`/`IAssocHandler` COM protocol; it MUST derive public ids as `SHA-256(UTF8("win32\0" + nativeIdentity))`. `prepare-open` MUST receive the initial Main-owned path and 64-hex opaque id separately, enumerate exactly once, retain the matching handler, emit one exact ready record, and accept at most one post-ready invoke line containing only the freshly revalidated same-association path. Registry command parsing, dynamic helper/source interpolation, second action-time enumeration inside the retained-helper protocol, and invocation before revalidation are forbidden.
Helper and metadata bounds are mandatory: handler names at most 256 UTF-16 code units, native ids/handler inputs at most 512, native paths at most 4096, icon PNG data URLs at most 64 KiB, process lifetime five seconds, aggregate/`execFile` output at most 1 MiB, and the Windows post-ready input at most one 8192-character line. Child processes MUST use explicit executable/argument arrays, `shell: false`, and a minimal allowlisted Main-owned environment. macOS icon data MUST pass base64 and PNG-signature validation; Windows icons MUST come only from validated private paths. One invalid record or icon MUST degrade independently.
Linux handler listing MUST authorize the attachment and return the Successful Empty Result On Linux (`ok: true`, platform `linux`, empty handlers) without starting discovery; application-specific open MUST remain unsupported and reveal MUST remain attachment-scoped. Main MAY cache only normalized discovery metadata/icons/private list records for five minutes by platform and association key, with at most 128 entries. That cache is presentation-only and MUST NOT cache authorization or satisfy action-time membership validation. Normalization MUST deduplicate enumerated ids and place enumerated default rows first. Current code does not synthesize a separately reported default row; any such insertion is a guarded follow-up requiring validated metadata and unchanged fresh-membership/invocation safety.
Application discovery, helper output, metadata, and icon failures MUST be silent and isolated: omit invalid handlers, use the generic icon for one bad icon, preserve reveal, and never disable primary preview. Only failed user-selected open/reveal actions may show localized non-blocking errors. Logs/traces MUST NOT contain canonical file paths, native identities, application/bundle/icon-source paths, helper source/output, command lines, or icon data; optional traces may use only opaque attachment identity and bounded allowlisted fields. `src/pages/Chat/AcpFileCard.tsx` may share presentation and menu lifecycle with workspace file activity, but attachment and workspace reference/authorization models MUST remain distinct. See the authoritative architecture and validation map in `harness/reference/acp-attachment-access-control.md`.
@@ -0,0 +1,18 @@
---
id: e2e-parallel-isolation
title: E2E Parallel Isolation
type: ai-coding-rule
appliesTo:
- gateway-backend-communication
requiredProfiles:
- fast
- e2e
---
Electron E2E tests are parallel by default because each test owns its HOME, OpenClaw state directory, Electron user-data directory, and Host API configuration. Keep those fixtures test-scoped.
Tests that mutate OS-global state must use `E2E_EXCLUSIVE_TAG`. Tests that profile shared host CPU, GPU, display, or frame pacing must use `E2E_PERFORMANCE_TAG`. Do not use Playwright serial mode as a cross-file mutex; serial mode only orders tests within its own group.
When adding another global resource, extend the automated policy check where the resource has a recognizable API. Unknown external resources still require reviewer classification.
The project graph, environment isolation, and validation commands are documented in `harness/reference/e2e-parallelism.md`.
@@ -0,0 +1,19 @@
---
id: electron-rendering-performance
title: Electron Rendering Performance
type: ai-coding-rule
appliesTo:
- acp-chat-experience
- chat-workspace-and-navigation
requiredProfiles:
- fast
- e2e
---
Leave Electron hardware acceleration enabled by default. Do not call `app.disableHardwareAcceleration()` or globally append `disable-gpu`; Chromium must retain driver detection and its native `--disable-gpu` troubleshooting fallback. Treat software compositing reported by headless or GPU-less CI as an environment result, not a reason to force every desktop renderer onto software rasterization.
Rendering performance investigations must combine frame pacing, Renderer metrics/profile data, and `app.getGPUFeatureStatus()` captured after `gpu-info-update`. Main CPU profiles do not cover browser/GPU process rasterization. Do not attribute Chromium `(program)` samples to React without an isolated variable that changes the result.
Keep `pnpm run perf:chat` coverage for both ACP streaming and rich static Markdown interaction. The interaction workload must exercise the production sidebar width animation and vertical Chat scroll path, record generated-only artifacts, and avoid hardware-independent timing gates. Compare repeated runs on the same machine while preserving semantic E2E assertions.
The full runtime policy and validation anchors are recorded in `harness/reference/electron-rendering-performance.md`.
@@ -0,0 +1,18 @@
---
id: gateway-heartbeat-safety
title: Gateway Heartbeat Safety
type: ai-coding-rule
appliesTo:
- gateway-backend-communication
requiredTests:
- tests/unit/gateway-manager-heartbeat.test.ts
- tests/unit/gateway-manager-diagnostics.test.ts
---
WebSocket heartbeat misses are availability and diagnostic evidence, not proof that the local Gateway process is dead.
Reaching the heartbeat miss threshold must update diagnostics and health state, but must not by itself terminate the socket, kill the owned Gateway process, or request `GatewayManager.restart`. Long-running model, tool, compaction, and scheduled work may temporarily block Gateway control-plane responses while remaining valid.
Automatic lifecycle recovery remains owned by authoritative transport and process signals such as child-process exit, WebSocket close, and Gateway restart close code 1012. Explicit user restart remains available.
Do not weaken this rule by only increasing heartbeat intervals or miss thresholds. A timeout change delays false recovery but does not make missing pong frames proof of process death.
@@ -0,0 +1,23 @@
---
id: markdown-rendering-safety-and-performance
title: Markdown Rendering Safety And Performance
type: ai-coding-rule
appliesTo:
- acp-chat-experience
- chat-workspace-and-navigation
requiredProfiles:
- fast
- e2e
---
Use one module-scoped Streamdown configuration for application Markdown. ACP assistant and process Markdown uses streaming mode with incomplete-Markdown repair; Markdown file preview uses static mode. User messages and tool output remain literal and must not enter Streamdown. Enable only the code, math, and CJK plugins. Keep single-dollar math enabled, retain the direct KaTeX dependency and one application KaTeX stylesheet import, and import Streamdown styles once. Do not install or configure the Mermaid plugin; Mermaid fences remain code.
Preserve the existing content boundary. Build the rehype list from Streamdown defaults without raw-HTML parsing while retaining sanitization and hardening, so raw HTML is visible literal text and never becomes active DOM. Render links through inert `BrowserLink` and disable Streamdown link-safety UI because no anchor remains interactive. Markdown images must continue through `isSafeAcpImageSource`. Enable only the localized code-copy control; disable table, Mermaid, code-download, and line-number controls. Parse YAML and TOML frontmatter in static preview and omit it from output; do not restore the custom splitter or metadata card.
Keep plugin arrays, component maps, animation options, and security options at module scope so reference churn does not invalidate block memoization. Only the open assistant message segment's final Markdown part may set animation or caret props. Use word-level `fadeIn` with duration 140, stagger 0, and a circle caret; never animate by character. Completed segments, user messages, thoughts, earlier parts, and inactive sends must remain stable and must not acquire or restart animation.
Preserve ClawX design tokens, assistant-without-bubble layout, prose block spacing, compact lists, the cell-only table grid and themes, source-line-preserving soft-wrapped code with a compact right-aligned language header, vertically centered copy action, and existing inert-link and image styling. Add Electron E2E coverage for streaming Chat and static preview. Tests must cover incomplete Markdown, highlighted and copyable multiline code, all supported math delimiters, CJK punctuation, Mermaid-as-code, literal raw HTML, inert links, safe images, literal user and tool output, frontmatter omission, active-part-only animation, completed-block stability, and the existing visual contracts.
Capture three successful 80-turn and 300-chunk `pnpm run perf:chat` profiles before and after renderer changes on the same machine, retaining ignored Renderer metrics plus Renderer/Main CPU profiles. Compare three-run medians for elapsed time, Renderer TaskDuration, ScriptDuration, layout duration, long-task count and duration, and sampled Markdown/React stacks. TaskDuration and ScriptDuration may each regress by at most 10 percent; median ScriptDuration or sampled Markdown/render CPU time must improve by at least 10 percent. Inspect a production sourcemap build for Streamdown, Shiki, and unexpected Mermaid cost. Do not replace these relative checks with machine-specific automated timing gates.
The complete rationale, ownership, safety policy, and validation anchors are recorded in `harness/reference/markdown-rendering.md`.
@@ -0,0 +1,29 @@
---
id: office-preview-safety
title: Office Preview Safety And Lifecycle
type: ai-coding-rule
appliesTo:
- chat-workspace-and-navigation
- acp-chat-experience
- acp-file-activity
requiredProfiles:
- e2e
---
Inline Office parsing is extension-authoritative and supports only `.docx` and `.pptx`. Keep `.doc` and `.ppt` system-open-only, and never route a legacy, unknown, or missing extension into an OOXML parser from MIME alone. Use the discriminated preview-limit API: text input is capped at 2 MB, DOCX/PPTX compressed input at exactly `20 * 1024 * 1024` bytes, and image/PDF/sheet input at 50 MB. Accept the exact maximum and reject one byte above it. Known over-limit Office input must be rejected before viewer mount or parser import; unknown-size input must use the Office limit as the Host API read `maxBytes` and must not invoke a parser after `tooLarge`.
Select exactly one authorized binary-read path: ordinary paths use `readBinaryFile`, workspace references use `readWorkspaceBinary`, and attachment references use `readAttachmentBinary`. A target containing both scoped reference types is invalid and must fail before any read. Scoped reads never retry through `filePath`, another scope, or a naked-path API; Workspace Browser alone retains its existing Host-validated absolute-path read flow. Parse only `Uint8Array` bytes in the Renderer: do not add direct filesystem access, document `fetch()`, parser URL loading, temporary files, uploads, conversion services, or new IPC, Host API, or Gateway routes.
Over-limit behavior must preserve target authority. Ordinary local Preview and Workspace Browser paths may expose the existing confirmed system-open and reveal actions. A known over-limit or remote attachment stays on the existing scoped `openAttachment` system route. A `WorkspaceFileRef`, or an attachment whose bounded preview read detects a race-time size increase, shows `tooLarge` without naked-path shell actions or unscoped fallback.
Load `DocxViewer` and `PptxViewer` through React lazy imports, and defer each parser-library import until authorized bytes have arrived. Every read and parser operation must be guarded by the committed target identity and generation so stale work cannot publish into a replacement target. On target change, failure, or unmount, release all ClawX-owned direct byte, instance, DOM/Canvas, listener, observer, animation-frame, timer, and queued-request references.
DOCX renders each generation into detached body and style containers, attaching them only after the current render completes. Generated markup and styles stay inside a Shadow Root. Preserve the reviewed explicit render options, including `renderAltChunks: false`, `renderComments: false`, `renderChanges: false`, and `useBase64URL: true`. Capturing `click` and `auxclick` handling must prevent the default action of every rendered anchor regardless of URL scheme. Width fitting uses CSS zoom capped at `1`; do not expose editing or active document navigation.
PPTX renders into a React-owned Canvas keyed by target identity; it is not required to render initially into a detached Canvas. The mounted Canvas, viewer instance, load, and every render request remain target-specific and generation-guarded, and React replacement or unmount must detach the obsolete Canvas. Construct the dependency with thumbnails disabled, fit sizing, a white background, and its delayed chart rerender disabled. Wait a bounded 60 size checks for positive dimensions and synchronize Canvas CSS dimensions before every render.
Because `pptxviewjs@1.1.9` shares `window.currentProcessor` and `window.currentZipData`, the Electron Renderer may have only a single mounted `PptxViewer`. Kept-mounted surfaces must conditionally mount their PPTX child only while active; CSS hiding is insufficient. Initial, restored, navigation, chart-complete, 100 ms trailing-debounced resize, and teardown operations use the shared serialized scheduler, skip obsolete requests, and never render directly from an observer or chart event. Position is retained by target identity and reported only after successful current renders. Cleanup calls each created instance's public `destroy()` exactly once in scheduler order and removes every ClawX-owned resource.
The Chat Preview fullscreen control is a Renderer-viewport portal, not native Electron fullscreen or PPTX presenter mode. It must preserve target-keyed slide position, exit from its localized header control or Escape, close when Preview becomes inactive, and preserve the single-mounted-`PptxViewer` invariant across portal transitions.
Read, parse, sizing, and render failures must terminate in localized generic states without exposing parser exceptions or retry loops. The published dependency may retain internal URLs, delayed chart work, caches, and processor/ZIP globals after public `destroy()`. This dependency-owned retained-resource limitation and incomplete Office fidelity are accepted for the first release: do not patch or conceal them, and do not claim complete reclamation. The single-instance invariant prevents concurrent cross-presentation corruption but does not eliminate retained-resource or ZIP-expansion risk. Preserve the full durable rationale and validation anchors in `harness/reference/office-document-preview.md`.
@@ -0,0 +1,27 @@
---
id: openclaw-config-delivery
title: OpenClaw Config Delivery
type: ai-coding-rule
appliesTo:
- gateway-backend-communication
requiredProfiles:
- comms
references:
- harness/reference/openclaw-config-delivery.md
---
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.
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.
Gateway WebSocket traces must replace serialized `raw` config-write payloads with a redacted marker. They must not log credentials introduced by a mutator.
When the Gateway is stopped or starting, the same coordinator mutates the resolved config file under the shared config lock. It must not start the Gateway solely to apply a config mutation.
ClawX may replace the Gateway process for process-launch environment or argument changes, explicit user restart, application lifecycle, health/crash recovery, or a failed config-delivery fallback. Provider, Agent, Channel, binding, skill, model, and ordinary plugin-entry config changes must not carry a blanket ClawX restart policy when OpenClaw can plan them.
All coordinator file fallback reads and writes must resolve the active config through `resolveOpenClawConfigPath()` so file delivery and Gateway RPC target the same config. No other production module may write that file.
@@ -6,10 +6,15 @@ appliesTo:
- gateway-backend-communication
---
An OAuth provider account's explicit `model` is the authoritative model exposed
for that account in interactive model selectors. Historical runtime model rows
may remain available for capability preservation, but must not reappear as
alternate OAuth selections through synchronized `metadata.customModels`.
Provider model IDs created through the ClawX settings UI are immutable. Existing
provider edit forms must not submit model changes and must direct users to delete
and recreate the provider when they need a different model ID.
A single-model built-in provider account's explicit `model` is authoritative in
interactive model selectors, regardless of whether it uses an API key, device
OAuth, or browser OAuth. Historical runtime model rows may remain available for
capability preservation, but must not reappear as alternate built-in selections
through synchronized `metadata.customModels`.
Before writing a selected model ID to OpenClaw, strip one leading provider
prefix when it exactly matches the resolved runtime provider key. Preserve all
@@ -8,8 +8,16 @@ appliesTo:
- gateway-backend-communication
---
OpenClaw ACP cwd is authoritative for a bound Chat session. Global workspace selection applies only to new or unbound sessions, and consumers use one effective workspace for ACP load/prompt, composer state, sidebar grouping, workspace browsing, and file activity. Missing paths surface unavailable state instead of silently changing roots.
OpenClaw ACP cwd is authoritative for a bound Chat session. Global workspace selection applies only to new or unbound sessions, and consumers use one effective workspace for ACP load/prompt, composer state, sidebar grouping, workspace browsing, and file activity. Editable new or unbound sessions expose persisted recent selections and workspace paths represented by known sessions in the composer; the canonical default appears once, non-default paths are deduplicated, and choosing one updates only the global workspace until the first send binds the session. Main validates the effective workspace before ACP load. Missing paths surface a localized unavailable state instead of repeatedly loading or silently changing roots; only new or unbound sessions may offer an action that replaces the global workspace.
Sidebar group deletion is available only after Main confirms that a non-default workspace is unavailable. It permanently hard-deletes every successfully targeted session through the existing Main-owned session deletion boundary, keeps failed sessions visible, and never treats removal of display metadata as a substitute for deleting transcripts. Per-session deletes run sequentially so updates to an agent's sessions index cannot race.
Custom workspace names are display-only aliases keyed by canonical workspace path. They may change sidebar and composer labels, but never path-based grouping, ACP cwd, browser roots, attachment authority, or session binding.
Targeted `@agent` sends establish the target session placeholder with the target agent workspace before navigation can trigger reactive loading. A target session and its first prompt must share one session-and-workspace load identity; reactive navigation must not supersede that load or silently cancel delivery. If the target main session does not exist yet, the first targeted send creates it before prompting.
The ACP load or new-session operation is the only boundary that establishes session workspace context. Main canonicalizes the workspace root and execution cwd, registers them only after a successful load, restores the prior context after failure, and validates later attachment operations by exact session key and generation. Attachment resolve, read, preview, and open requests cannot provide or replace the execution cwd and must be revalidated in Main on every operation. Local attachment references may resolve outside the workspace; the workspace remains authoritative for relative-path resolution and the separate workspace browser and tool-derived file boundaries. Session or generation replacement revokes the prior context; attachment refs and prior resolution are not authority.
Keep `_meta.prefixCwd: true`. Remove the leading working-directory envelope only from automatic titles and narrowly defined turn matching; never alter explicit user labels, user-authored content, or user-visible transcript content.
Keep `_meta.prefixCwd: true`. Remove the leading working-directory envelope only from automatic titles and narrowly defined turn matching; never alter explicit user labels, user-authored content, or user-visible transcript content. Treat OpenClaw's `<session UUID prefix> (YYYY-MM-DD)` title as synthetic only when the prefix matches that row's full session id; hydrate it from the first user prompt instead, and never persist an unchanged rename value.
When a locally prepared ACP session is successfully created, expose it in the sidebar and seed its automatic title from the first raw user prompt in one Renderer state transition. A fresh session generated during cold-start heartbeat replacement is also a local placeholder and must use the same lifecycle. Gateway client identity such as the ACP bridge display name is transport provenance, not a conversation title, and must not become briefly visible while transcript-derived title hydration catches up. Gateway event reconciliation and canonical session-list refreshes must preserve the local `createdLocally` marker until that successful creation acknowledgement; only the acknowledgement may make the row visible. Existing non-synthetic explicit or cached labels remain authoritative.
@@ -0,0 +1,20 @@
---
id: sidebar-session-attention-authority
title: Sidebar Session Attention Authority
type: ai-coding-rule
appliesTo:
- gateway-backend-communication
- chat-workspace-and-navigation
---
Sidebar attention MUST derive only from normalized OpenClaw Gateway session rows in the existing Renderer catalog. Rows and attention MUST match by exact normalized catalog key; ACP prompt/timeline state, local sending state, and Gateway agent runtime events MUST NOT derive or override it. Run-scoped cron keys MUST NOT be folded into base-row attention while `sessions.list` cannot recover that relationship.
List rows and event patches MUST use the shared allowlisted normalizer. Event merge MUST preserve explicit `false`, clear optional fields only for explicit `null`, leave omitted fields unchanged, reject conflicting envelope/nested keys, and insert unknown rows only from reliable nested snapshots.
Run projection MUST apply terminal status before boolean `hasActiveRun`, then the `running` fallback, then unknown. Unknown MUST preserve attention. Unread MUST arise only from an observed exact-key busy-to-idle transition, never from `updatedAt`; entering busy MUST retain any older unread bit, with presentation ordered `busy > unread > timeago`.
Only exact-key observed-busy and unread presentation state MAY persist. Visibility MUST remain memory-only. A conversation is read only while its Chat view is visibly mounted or when its sidebar row is activated; retaining `currentSessionKey` on another route is not read authority.
Every ready Gateway epoch MUST call `sessions.subscribe` for `sessions.changed` and force canonical list hydration. List flights MUST buffer events, accept timestamp equality, reject strictly older epoch/per-key results, and preserve attention under unorderable or failed reconciliation until canonical recovery. Missing list rows MUST NOT prune attention. Exact deletion MUST clear attention and advance same-key label-hydration incarnation before recreation.
Algorithms, rationale, limitations, future Gateway-unread migration, and validation anchors are defined in `harness/reference/sidebar-session-attention.md`.
@@ -8,7 +8,7 @@ appliesTo:
- gateway-backend-communication
---
Treat file-tool paths as untrusted. Renderer must enforce lexical workspace containment before projection, and Main must independently enforce canonical and symlink-safe containment for every scoped read/stat operation. Tool-derived targets are read-only in-app previews and expose no system open or reveal action.
Treat file-tool paths as untrusted. Renderer must enforce lexical workspace containment before projection, and Main must independently enforce canonical and symlink-safe containment for every scoped read, stat, handler-list, selected-handler-open, and reveal operation. Tool-derived targets remain read-only in-app previews; created and modified activity may expose explicit native Open with and reveal actions only through `WorkspaceFileRef` Host API operations that freshly resolve a regular file inside the canonical workspace. An HTML activity may also construct a local file URL from the already-authorized workspace root and contained relative path for the file-only Preview route; this is preview navigation, not a native handler action or canonicalization claim. Deleted activity exposes neither action. Renderer must never send or receive a Main-canonicalized target, executable path, command, or command template.
File activity remains a record of completed canonical OpenClaw `write`, `edit`, and `apply_patch` inputs. It must not claim to be a verified disk or Git diff, scan the workspace, infer shell effects, or persist a separate ledger.
@@ -6,6 +6,7 @@ appliesTo:
- acp-chat-experience
- acp-file-activity
- gateway-backend-communication
- chat-workspace-and-navigation
---
Route every new user-visible string through `react-i18next` with matching English, Chinese, Japanese, and Russian locale coverage. Do not hardcode display text in pages or components.
@@ -13,3 +14,13 @@ Route every new user-visible string through `react-i18next` with matching Englis
Use the semantic tokens and substitutions documented in `src/styles/globals.css`: raised cards and panels use `bg-surface-modal`, recessed inputs and code surfaces use `bg-surface-input`, selected state uses `bg-black/5 dark:bg-white/10`, hover state uses `hover:bg-black/5 dark:hover:bg-white/5`, status colors pair a light `-700` shade with dark `-400`, and page H1/H2 headings use `font-serif font-normal tracking-tight`. Do not add arbitrary colors or redundant dark surface companions when a named token exists.
Interactive rows use semantic controls, keyboard activation, accessible names, visible focus styling, and disabled semantics where applicable. Attachment cards may show the decoded local path or normalized remote URL represented by explicit ACP resource or approved `MEDIA:` evidence; paths truncate visually and remain available in the title. Unavailable attachments remain basename-only, and unrelated UI or diagnostics must not expose sensitive absolute host paths.
ACP whole-turn timing uses localized unit formatting and localized running/completed labels in all four locales. It renders as persistent muted metadata in the assistant-turn footer; copy remains the hover-only action.
Multi-view file previews keep their localized segmented view switcher in the trailing side of the file name/path header instead of allocating a separate content row. The Chat Preview surface exposes a localized, icon-only fullscreen toggle in that header; fullscreen uses the whole Renderer viewport, preserves the selected target and viewer position, exits through the same control or Escape, and closes when Preview becomes inactive. HTML preview retains the `Preview` then `Source` order and defaults to the rendered preview.
Open With is eligible only for an available local assistant attachment whose primary mode is Preview, or for a created/modified workspace file-activity row; deleted activity and user, remote, unavailable, pending, or system-open-only attachments do not expose it. The compact secondary button stays inside the card's right edge as a sibling of the primary action; buttons must not be nested, visually segmented, or trigger one another. Eligible local HTML menus put the built-in Preview action first and follow it with a separator before native applications. Discovery starts on each menu open, stale responses cannot populate a changed target, reveal remains available during loading, and all valid application rows remain in a bounded scrolling menu with default-first then locale ordering. Operating-system application names are not translated. The Radix menu must support arrow navigation, Enter activation, Escape/outside dismissal, and trigger focus restoration. Open-with, built-in-preview, loading, platform reveal, and explicit action-failure labels require matching English, Chinese, Japanese, and Russian chat locale entries. Application rows use bounded native icons when available and a generic application icon for every missing, malformed, oversized, unreadable, or failed icon.
The HTML Preview external-open, fullscreen, and recovery controls require localized accessible names and matching tooltips where applicable in English, Chinese, Japanese, and Russian. The hidden HTML guest is non-interactive and absent from the accessibility tree.
Every content link is inert plain text. HTML Preview additionally removes guest anchor styling and pointer interaction while Main blocks all navigation. Local `.html` and `.htm` file cards open in the existing Preview tab by default.
@@ -0,0 +1,25 @@
---
id: web-browser-security-and-lifecycle
title: Local HTML preview security and lifecycle
appliesTo:
- shared/web-browser.ts
- shared/host-api/contract.ts
- electron/main/web-browser-policy.ts
- electron/main/web-browser-session.ts
- electron/services/web-browser-api.ts
- src/components/web-browser/**
- src/components/file-preview/**
- src/stores/artifact-panel.ts
severity: error
---
# Local HTML preview security and lifecycle
- Treat agent-produced HTML as untrusted. Keep one dedicated-session webview with no preload, Node integration, plugins, insecure content, popup capability, or ClawX bridge; require sandboxing, context isolation, and web security.
- Application navigation may load only a hostless, query-free, fragment-free `file:///` URL whose path ends in `.html` or `.htm`. Renderer must derive it from an already validated attachment or Workspace reference and call the typed Host API.
- The guest is a Preview implementation detail. Do not expose a Web Browser tab, Home page, address bar, history controls, site-data controls, general HTTP navigation, or an empty guest entry point.
- Every link is inert. Inject user-origin CSS that removes anchor/area color, decoration, pointer cursor, and pointer events. Independently prevent all guest `will-frame-navigate`, redirect, invalid programmatic, in-page, form, and script navigation.
- Deny every popup and every permission. Cancel downloads. Block HTTP(S), WebSocket, and other network requests in the dedicated Session, and reject any non-HTML main document.
- Keep the single-guest registry and exact attachment identity gate. Main may load a validated HTML file or open an explicitly supplied, independently revalidated local HTML URL through `shell.openExternal`; no general web URL or storage-management API belongs to this feature.
- The route-stable host may remain mounted while another panel tab is active, but it must be invisible, pointer-inert, accessibility-hidden, and unable to receive focus.
- All visible labels and failures use the complete English, Chinese, Japanese, and Russian locale resources and project design tokens.
+19 -3
View File
@@ -10,7 +10,10 @@ ownedPaths:
- electron/services/acp-session-access-registry.ts
- electron/services/acp-trace.ts
- electron/services/attachment-access.ts
- electron/services/attachment-open-with.ts
- electron/services/files-api.ts
- electron/main/index.ts
- resources/scripts/attachment-open-with.ps1
- src/lib/acp/**
- src/lib/file-preview-client.ts
- src/lib/file-preview-capabilities.ts
@@ -20,15 +23,25 @@ ownedPaths:
- src/pages/Chat/**
- tests/unit/acp-*.test.ts
- tests/unit/acp-*.test.tsx
- tests/unit/attachment-open-with.test.ts
- tests/unit/attachment-open-with-native.test.ts
- tests/e2e/chat-acp-inline-timeline.spec.ts
- tests/e2e/chat-acp-attachments.spec.ts
- tests/e2e/chat-run-state-events.spec.ts
- tests/e2e/chat-streamdown-rendering.spec.ts
- tests/e2e/chat-code-block-wrap.spec.ts
- tests/e2e/chat-latex-rendering.spec.ts
- tests/e2e/chat-assistant-markdown-plain.spec.ts
- tests/e2e/chat-table-header-light.spec.ts
- tests/e2e/hardware-acceleration.spec.ts
- tests/e2e/renderer-performance.spec.ts
requiredProfiles:
- fast
- comms
conditionalProfiles:
e2e:
- ACP timeline presentation changes
- Chat Markdown rendering, syntax highlighting, or animation changes
- send, cancel, permission, media, or history behavior changes
requiredRules:
- renderer-main-boundary
@@ -38,13 +51,16 @@ requiredRules:
- diagnostics-trace-safety
- session-workspace-authority
- tool-derived-file-safety
- office-preview-safety
- ui-i18n-design-tokens
- markdown-rendering-safety-and-performance
- electron-rendering-performance
- comms-regression
- docs-sync
---
ACP Chat covers session load, prompt, cancel, permission, replay, timeline reduction, assistant-turn presentation, standard ACP attachments, bounded generated-media and OpenClaw MEDIA compatibility, and Chat-specific diagnostics.
ACP Chat covers session load, prompt, cancel, permission, replay, timeline reduction, assistant-turn presentation and whole-turn duration, standard ACP attachments, bounded generated-media and OpenClaw MEDIA compatibility, and Chat-specific diagnostics. The user-visible attachment flow includes attachment-scoped preview, system open, selected-application open, reveal actions, and a first-position built-in Preview action for eligible local HTML, with platform discovery limited to macOS and Windows. Authorized local DOCX/PPTX attachments within the Office limit use scoped Preview; remote, legacy, and over-limit Office attachments retain scoped system/external-open behavior. User-selected directories remain system-open-only targets: Main may open the directory after session-scoped revalidation, but directory contents are not read, enumerated, previewed, or exposed to Open With.
Main owns ACP transport, routing, transcript retrieval, workspace grants, and session/generation-scoped attachment authorization. Renderer owns the in-memory timeline, bounded compatibility alignment, attachment presentation, and display grouping, including user-image thumbnails and user-selected source-path labels. ACP replay is authoritative except for the approved image-generation completion and explicit line-leading assistant OpenClaw `MEDIA:` attachment supplements. Standard ACP content remains preferred over compatibility projections, and incidental tool paths never enter the attachment pipeline.
Main owns ACP transport, routing, transcript retrieval and timing extraction, workspace grants, and session/generation-scoped attachment authorization. Renderer owns the in-memory timeline, bounded compatibility and timing alignment, attachment presentation, and display grouping, including user-image thumbnails and user-selected source-path labels. ACP replay remains authoritative for historical turns and content; transcript-derived timing may only annotate an unambiguously matched ACP turn. Standard ACP content remains preferred over compatibility projections, and incidental tool paths never enter the attachment pipeline.
The durable architecture, exceptions, access boundary, file-activity separation, and validation anchors are documented in `harness/reference/acp-chat.md`, `harness/reference/acp-generated-media-and-diagnostics.md`, `harness/reference/acp-attachment-access-control.md`, and `harness/reference/openclaw-file-activity.md`.
The durable architecture, exceptions, access boundary, file-activity separation, Office preview behavior, Markdown rendering, Electron rendering performance policy, and validation anchors are documented in `harness/reference/acp-chat.md`, `harness/reference/acp-generated-media-and-diagnostics.md`, `harness/reference/acp-attachment-access-control.md`, `harness/reference/openclaw-file-activity.md`, `harness/reference/office-document-preview.md`, `harness/reference/markdown-rendering.md`, and `harness/reference/electron-rendering-performance.md`.
+9 -2
View File
@@ -4,8 +4,14 @@ title: ACP OpenClaw File Activity
type: user-visible-flow
ownedPaths:
- electron/services/files-api.ts
- shared/host-api/contract.ts
- src/lib/host-api.ts
- src/lib/acp/openclaw-file-activities.ts
- src/components/file-preview/**
- src/components/web-browser/WebBrowserHost.tsx
- src/stores/artifact-panel.ts
- src/pages/Chat/AcpFileCard.tsx
- src/pages/Chat/AcpAttachmentPart.tsx
- src/pages/Chat/AcpTurnFileActivity.tsx
- tests/unit/openclaw-file-activities.test.ts
- tests/unit/files-api-workspace.test.ts
@@ -19,11 +25,12 @@ requiredRules:
- acp-chat-state-and-history
- session-workspace-authority
- tool-derived-file-safety
- office-preview-safety
- ui-i18n-design-tokens
- comms-regression
- docs-sync
---
This scenario covers per-turn file buttons and summaries, session-level Changes, replay, and workspace-scoped Preview for successful OpenClaw `write`, `edit`, and `apply_patch` calls.
This scenario covers per-turn file buttons and summaries, session-level Changes, replay, workspace-scoped Preview, and independently revalidated Open with actions for created or modified files from successful OpenClaw `write`, `edit`, and `apply_patch` calls. HTML Open with menus route the existing workspace target into the right-side Preview tab. In-limit DOCX/PPTX activity uses `WorkspaceFileRef` Preview under the Office safety contract. Deleted activity never exposes Preview or Open with.
The UI represents tool-declared activity, not a verified filesystem or Git diff. Detailed input grammar, aggregation, and path safety are documented in `harness/reference/openclaw-file-activity.md`.
The UI represents tool-declared activity, not a verified filesystem or Git diff. Detailed input grammar, aggregation, and path safety are documented in `harness/reference/openclaw-file-activity.md`; Office parsing and lifecycle constraints are in `harness/reference/office-document-preview.md`.
@@ -6,30 +6,90 @@ ownedPaths:
- shared/workspace.ts
- shared/chat/session-title.ts
- electron/services/sessions-api.ts
- electron/main/index.ts
- src/lib/workspace-context.ts
- src/hooks/use-workspace-availability.ts
- src/stores/settings.ts
- src/stores/chat.ts
- src/stores/chat/session-catalog.ts
- src/stores/session-attention.ts
- src/stores/chat/session-status.ts
- src/components/layout/Sidebar.tsx
- src/components/layout/session-buckets.ts
- src/components/file-preview/ArtifactPanel.tsx
- src/components/file-preview/WorkspaceBrowserBody.tsx
- src/components/file-preview/FilePreviewBody.tsx
- src/components/file-preview/MarkdownPreview.tsx
- src/components/file-preview/DocxViewer.tsx
- src/components/file-preview/PptxViewer.tsx
- src/components/file-preview/build-preview-target.ts
- src/components/file-preview/open-file-utils.ts
- src/lib/generated-files.ts
- src/lib/file-preview-capabilities.ts
- shared/file-preview/limits.ts
- src/pages/Chat/index.tsx
- src/pages/Chat/AcpTurnFileActivity.tsx
- src/pages/Chat/AcpAttachmentPart.tsx
- src/components/web-browser/**
- src/components/markdown/**
- src/stores/artifact-panel.ts
- src/components/layout/MainLayout.tsx
- src/pages/Chat/ChatInput.tsx
- src/pages/Chat/ChatToolbar.tsx
- shared/host-api/contract.ts
- electron/utils/store.ts
- shared/i18n/locales/*/chat.json
- tests/unit/workspace-context.test.ts
- tests/unit/session-title.test.ts
- tests/unit/session-catalog.test.ts
- tests/unit/chat-load-sessions-startup.test.ts
- tests/unit/session-attention.test.ts
- tests/unit/session-status.test.ts
- tests/unit/session-label-hydration.test.ts
- tests/unit/chat-store-session-label-fetch.test.ts
- tests/unit/sidebar-session-buckets.test.ts
- tests/unit/i18n-locale-parity.test.ts
- tests/unit/session-buckets.test.ts
- tests/unit/generated-files.test.ts
- tests/unit/open-file-utils.test.ts
- tests/unit/file-preview-body.test.tsx
- tests/unit/markdown-preview.test.tsx
- tests/unit/streamdown-config.test.tsx
- tests/unit/workspace-browser-body.test.tsx
- tests/unit/office-file-viewers.test.tsx
- tests/unit/artifact-panel.test.tsx
- tests/unit/acp-chat-components.test.tsx
- tests/e2e/chat-workspace-context.spec.ts
- tests/e2e/chat-new-session-date.spec.ts
- tests/e2e/chat-acp-inline-timeline.spec.ts
- tests/e2e/chat-question-directory.spec.ts
- tests/e2e/chat-sidebar-session-attention.spec.ts
- tests/e2e/chat-acp-attachments.spec.ts
- tests/e2e/chat-file-changes.spec.ts
- tests/e2e/office-document-preview.spec.ts
- tests/e2e/markdown-file-preview.spec.ts
- tests/e2e/hardware-acceleration.spec.ts
- tests/e2e/renderer-performance.spec.ts
requiredProfiles:
- fast
conditionalProfiles:
e2e:
- workspace selection, binding, sidebar, browser, or question navigation changes
- Markdown file-preview rendering or syntax highlighting changes
requiredRules:
- session-workspace-authority
- renderer-main-boundary
- ui-i18n-design-tokens
- sidebar-session-attention-authority
- office-preview-safety
- web-browser-security-and-lifecycle
- markdown-rendering-safety-and-performance
- electron-rendering-performance
- docs-sync
---
This scenario covers selecting a workspace for a new Chat, binding it through OpenClaw ACP cwd, restoring historical workspace context, navigating workspace-grouped sessions, browsing the effective workspace, and jumping among user questions.
This scenario covers inheriting the selected conversation's effective workspace when creating a new Chat; selecting persisted recent, known-session, or newly browsed workspaces while the new Chat remains unbound; validating workspace availability before ACP load; deriving a newly visible local-session title atomically from its first prompt; replacing matching synthetic UUID-date fallback titles with transcript prompts; recovering from deleted global or inherited workspace paths; marking unavailable non-default sidebar groups; permanently deleting their sessions after confirmation; binding workspaces through OpenClaw ACP cwd; targeting another agent without losing that agent's workspace or first prompt; restoring historical workspace context; renaming imported workspace display labels; navigating workspace-grouped sessions with busy, unread, and relative-time status; browsing the effective workspace; previewing authorized local HTML and supported Office documents under their documented safety boundaries; and jumping among user questions from an overlay that leaves the conversation width unchanged.
The current resolution, ordering, title normalization, and browser behavior are documented in `harness/reference/chat-workspace-and-navigation.md`.
Workspace file browsing keeps the store value `browser`; local HTML uses the existing `preview` tab and has no independent browser tab or toolbar. Current workspace resolution, ordering, title normalization, and file-browser behavior are documented in `harness/reference/chat-workspace-and-navigation.md`; the HTML guest contract is documented in `harness/reference/web-browser.md`; static Markdown rendering and safety requirements are documented in `harness/reference/markdown-rendering.md`; desktop compositing and interaction profiling requirements are documented in `harness/reference/electron-rendering-performance.md`.
DOCX and PPTX files are accepted as read-only inline previews only at or below the 20 MB compressed-input boundary. Scoped workspace and attachment references retain their authorized read route without naked-path fallback, while Workspace Browser retains its Host-validated absolute-path flow. PPTX visibility must preserve the single mounted PPTX viewer invariant across the kept-mounted Workspace and Preview surfaces. Workspace ownership remains in `harness/reference/chat-workspace-and-navigation.md`; the complete Office contract is `harness/reference/office-document-preview.md`.
@@ -8,11 +8,31 @@ ownedPaths:
- src/stores/gateway.ts
- src/stores/chat.ts
- src/stores/chat/**
- src/stores/session-attention.ts
- src/stores/chat/session-status.ts
- src/stores/chat/session-catalog.ts
- electron/main/ipc/**
- electron/services/**
- electron/gateway/**
- electron/preload/**
- electron/utils/**
- tests/unit/session-attention.test.ts
- tests/unit/session-status.test.ts
- tests/unit/session-catalog.test.ts
- tests/unit/gateway-events.test.ts
- tests/unit/gateway-event-dispatch.test.ts
- tests/unit/chat-session-management.test.ts
- tests/unit/chat-store-session-label-fetch.test.ts
- tests/unit/session-label-hydration.test.ts
- tests/e2e/chat-sidebar-session-attention.spec.ts
- shared/web-browser.ts
- electron/main/web-browser-policy.ts
- electron/main/web-browser-session.ts
- electron/services/web-browser-api.ts
- tests/unit/web-browser-url.test.ts
- tests/unit/web-browser-policy.test.ts
- tests/unit/web-browser-session.test.ts
- tests/unit/web-browser-api.test.ts
requiredProfiles:
- fast
- comms
@@ -22,19 +42,25 @@ conditionalProfiles:
- user-visible gateway status changes
- user-visible chat send/receive behavior changes
- channels/agents/settings UI depends on new backend response shape
- Web Browser guest, navigation, session, permission, or data policy changes
requiredRules:
- openclaw-config-delivery
- renderer-main-boundary
- backend-communication-boundary
- api-client-transport-policy
- host-api-fallback-policy
- host-events-fallback-policy
- gateway-readiness-policy
- gateway-heartbeat-safety
- channel-plugin-migration-guards
- capability-owner-resolution
- active-config-guards
- provider-default-invariant
- provider-model-metadata-preservation
- provider-model-selection-authority
- sidebar-session-attention-authority
- web-browser-security-and-lifecycle
- e2e-parallel-isolation
- comms-regression
- docs-sync
forbiddenPatterns:
@@ -52,6 +78,8 @@ forbiddenPatterns:
Gateway backend communication covers all ClawX paths that move data between the visual desktop UI and OpenClaw runtime/backend services.
Coordinator-owned OpenClaw config mutations and their `config.get`/`config.set` transaction contract are documented in `harness/reference/openclaw-config-delivery.md`.
Allowed flow:
Renderer page/component -> `src/lib/host-api.ts` or `src/lib/api-client.ts` -> Electron Main typed host service or IPC handler -> Main-owned OpenClaw Gateway WebSocket -> runtime result -> store/UI.
@@ -59,4 +87,16 @@ Renderer code must not own transport selection, direct IPC channels, direct Gate
Renderer code must not create direct Gateway WebSocket connections. Gateway frame diagnostics must be emitted by Main-process Gateway logging.
Typed generic Gateway RPC requests are validated by `electron/services/gateway-api.ts` and delegated directly to `GatewayManager.rpc`, including an optional positive finite timeout. This path has no Renderer Chat history/send specialization, polling queue, coalescing, or backpressure layer. ACP `session/load`, `session/prompt`, and `session/cancel` own ordinary Chat history and composer behavior independently.
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`.
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.
The local HTML Preview privileged bridge is also Main-owned: Renderer may load a validated local HTML file or open that current file externally through the typed Host API. The guest is an implementation detail of the existing `preview` tab; there is no `web-browser` artifact tab or general address navigation. The durable guest contract is `harness/reference/web-browser.md`.
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 only. They may mark health unresponsive, but must not terminate the socket or replace the Gateway process; authoritative process-exit and socket-close signals retain automatic lifecycle recovery ownership.
@@ -7,12 +7,12 @@ ownedPaths:
- electron/utils/openclaw-auth.ts
- electron/utils/paths.ts
- src/stores/gateway.ts
- src/pages/Dreams/**
requiredProfiles:
- fast
- comms
requiredRules:
- gateway-readiness-policy
- gateway-heartbeat-safety
- renderer-main-boundary
- backend-communication-boundary
- api-client-transport-policy
@@ -20,7 +20,7 @@ requiredRules:
- docs-sync
---
Use this spec when ClawX shows the Gateway as starting/running but UI data does not refresh, Dreams cannot load, or Gateway RPC calls time out after a restart.
Use this spec when ClawX shows the Gateway as starting/running but UI data does not refresh, memory-backed data cannot load, or Gateway RPC calls time out after a restart.
ClawX should prefer OpenClaw-native signals over stderr string matching:
@@ -28,20 +28,24 @@ ClawX should prefer OpenClaw-native signals over stderr string matching:
- `health` provides the Gateway health snapshot; use cached `probe:false` first.
- `status` provides presence, health, stateVersion, uptime, and session defaults.
- `channels.status` is the channel capability signal.
- `doctor.memory.status` is the memory/dreams capability signal.
- `doctor.memory.status` is the memory capability signal.
- `gateway.ready`, `health`, and `presence` events should update ClawX's main-process capability cache.
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 likewise prove only that the Gateway control plane did not answer within the observation window. They update heartbeat diagnostics and may mark health unresponsive, but do not terminate the socket or restart the process. Process exit and socket close remain the authoritative automatic recovery signals, so long-running work is not killed solely because pong handling is delayed.
## Failure Shape
Treat these as the same incident family until proven otherwise:
- `Gateway ready fallback triggered; probing RPC router before marking ready`
- `Gateway ready fallback RPC router probe failed: RPC timeout: system-presence`
- `[gateway-startup] ... slow=true`
- `[gateway-startup] Slow managed Gateway startup detected`
- `[gateway:rpc] doctor.memory.status failed`
- `[gateway:rpc] doctor.memory.dreamDiary failed`
- `chat.history unavailable during gateway startup`
- `sessions.list unavailable during gateway startup`
- Port `18789` is listening, but Gateway HTTP or WebSocket RPC does not return.
Important distinction:
@@ -56,7 +60,7 @@ Capability failures are not Gateway core failures:
- `doctor.memory.status` timeout means memory capability degraded until `system-presence` also fails.
- `channels.status` timeout means channel capability degraded until `system-presence` also fails.
- dreams cron unavailable, missing memory files, stale session keys, or provider credential errors do not trigger Gateway restart by themselves.
- memory-core cron unavailable, missing memory files, stale session keys, or provider credential errors do not trigger Gateway restart by themselves.
## Fast Triage
@@ -73,6 +77,14 @@ lsof -nP -iTCP:5173 -sTCP:LISTEN || true
tail -n 160 "$HOME/Library/Application Support/clawx/logs/clawx-$(date +%F).log"
```
ClawX-owned Gateway children enable `OPENCLAW_GATEWAY_STARTUP_TRACE=1` automatically. Normal duration-bearing trace lines are normalized as informational records:
```text
[gateway-startup] stage=plugins.bootstrap durationMs=... totalMs=...
```
Stages taking at least 10 seconds are marked `slow=true`. A spawn-to-handshake duration of at least 30 seconds emits `Slow managed Gateway startup detected` with the longest observed OpenClaw stage. The existing `gateway.startup` metric also includes the compact `openclawTrace` summary. Startup trace records contain stage names and timings only; do not add environment values, config payloads, or provider secrets to these diagnostics.
3. Probe OpenClaw-native signals in this order. Redirect output for memory-related calls because successful responses may contain user data:
```bash
@@ -179,14 +191,14 @@ Expected mitigation:
Symptoms:
- Gateway handshake completes, but `system-presence`, `chat.history`, or `doctor.memory.*` times out during the first minutes.
- Gateway handshake completes, but `system-presence`, `sessions.list`, or `doctor.memory.*` times out during the first minutes.
- Logs mention cron repair, channel account checks, session lock cleanup, memory-core cron reconciliation, or active embedded/task runs.
Expected behavior:
- Do not mark Gateway fully ready from a pure timer fallback.
- The fallback must probe `system-presence` before emitting ready.
- Heartbeat recovery may defer restart during the initial grace window, but it should not loop restart while the Gateway is still performing startup work.
- Heartbeat misses remain observable during startup work, but do not trigger process restart.
### Capability Degraded But Core Alive
@@ -194,7 +206,7 @@ Symptoms:
- `system-presence`, `health`, or `status` succeeds.
- `doctor.memory.status`, `doctor.memory.dreamDiary`, or `channels.status` times out.
- stderr may mention dreams cron unavailable, missing memory files, stale session keys, or credentials provider errors.
- stderr may mention memory-core cron unavailable, missing memory files, stale session keys, or credentials provider errors.
Expected behavior:
@@ -225,14 +237,21 @@ Expected handling:
pnpm exec tsx -e "import { sanitizeOpenClawConfig } from './electron/utils/openclaw-auth.ts'; import { cleanupAgentsSymlinkedSkills, cleanupStalePluginRuntimeDeps } from './electron/gateway/skills-symlink-cleanup.ts'; sanitizeOpenClawConfig().then(() => { console.log(cleanupAgentsSymlinkedSkills()); console.log(cleanupStalePluginRuntimeDeps()); });"
```
4. Restart the app or Gateway and watch for the startup metric:
4. Restart the app or Gateway and watch for the startup metric and trace summary:
```text
[metric] gateway.startup {
"configSyncMs": ...,
"spawnToReadyMs": ...,
"readyToConnectMs": ...,
"totalMs": ...
"totalMs": ...,
"openclawTrace": {
"stageCount": ...,
"lastStage": ...,
"traceTotalMs": ...,
"slowestStage": ...,
"slowestStageMs": ...
}
}
```
@@ -249,7 +268,7 @@ pnpm exec openclaw gateway call health --params '{"probe":false}' >/tmp/clawx-he
pnpm exec openclaw gateway call status >/tmp/clawx-status.json
```
7. Only after `system-presence` succeeds, verify feature-specific RPCs such as Dreams, memory doctor calls, or channel probes.
7. Only after `system-presence` succeeds, verify feature-specific RPCs such as memory doctor calls or channel probes.
## Acceptance Criteria
@@ -257,9 +276,9 @@ pnpm exec openclaw gateway call status >/tmp/clawx-status.json
- `configSyncMs` stays small relative to total startup time.
- `system-presence` succeeds after startup settles.
- `health` and `status` are captured in Gateway diagnostics when available.
- Dreams page can refresh once the Gateway process is running and RPC-ready.
- `doctor.memory.status` and `doctor.memory.dreamDiary` return when Dreams is enabled.
- 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.
- Consecutive heartbeat misses update diagnostics and health state without terminating the socket or replacing the Gateway process.
- Logs no longer repeat stale runtime cache or escaped managed-skill symlink warnings for entries ClawX can safely clean.
## Required Regression Coverage
@@ -270,11 +289,10 @@ For fixes in this area, run:
pnpm run typecheck
pnpm run lint:check
pnpm exec vitest run tests/unit/openclaw-auth.test.ts tests/unit/skills-symlink-cleanup.test.ts tests/unit/gateway-manager-heartbeat.test.ts tests/unit/gateway-ready-fallback.test.ts
pnpm exec playwright test tests/e2e/openclaw-dreams.spec.ts
pnpm run build:vite
```
If the change touches Gateway send/receive, fallback, readiness, or chat history, also run:
If the change touches Gateway send/receive, generic RPC dispatch, fallback, or readiness, also run:
```bash
pnpm run comms:replay
@@ -0,0 +1,111 @@
---
id: acp-attachment-open-with
title: Add secure platform attachment open-with actions
scenario: gateway-backend-communication
taskType: runtime-bridge
intent: Add an in-card Open with action for previewable local assistant attachments while keeping authorization and native application handling in Electron Main.
touchedAreas:
- harness/specs/tasks/acp-attachment-open-with.md
- harness/specs/scenarios/acp-chat-experience.md
- harness/specs/rules/backend-communication-boundary.md
- harness/specs/rules/attachment-access-safety.md
- harness/specs/rules/ui-i18n-design-tokens.md
- harness/reference/acp-attachment-access-control.md
- electron/services/attachment-open-with.ts
- resources/scripts/attachment-open-with.ps1
- .github/workflows/check.yml
- .github/workflows/release.yml
- shared/host-api/contract.ts
- electron/services/attachment-access.ts
- electron/services/files-api.ts
- electron/main/ipc-handlers.ts
- src/lib/host-api.ts
- src/pages/Chat/AcpFileCard.tsx
- src/pages/Chat/AcpAttachmentPart.tsx
- shared/i18n/locales/en/chat.json
- shared/i18n/locales/zh/chat.json
- shared/i18n/locales/ja/chat.json
- shared/i18n/locales/ru/chat.json
- tests/unit/attachment-open-with.test.ts
- tests/unit/attachment-open-with-native.test.ts
- tests/unit/attachment-access.test.ts
- tests/unit/host-api-facade.test.ts
- tests/unit/host-services.test.ts
- tests/unit/acp-chat-components.test.tsx
- tests/unit/artifact-panel.test.tsx
- tests/unit/rich-file-viewers.test.tsx
- tests/e2e/fixtures/electron.ts
- tests/e2e/chat-acp-attachments.spec.ts
- tests/e2e/chat-file-changes.spec.ts
- README.md
- README.zh-CN.md
- README.ja-JP.md
expectedUserBehavior:
- Previewable local assistant attachments retain their primary in-app preview action and expose a separate Open with menu.
- macOS and Windows list compatible applications with the default first, native icons when available, and a generic icon fallback.
- Linux exposes the same secondary control with only the reveal-in-file-manager action.
- Discovery and icon failures remain silent while preview and reveal stay usable.
requiredProfiles:
- fast
- comms
- e2e
requiredRules:
- renderer-main-boundary
- backend-communication-boundary
- host-api-fallback-policy
- attachment-access-safety
- ui-i18n-design-tokens
- comms-regression
- docs-sync
requiredTests:
- pnpm exec vitest run tests/unit/harness-specs.test.ts tests/unit/attachment-open-with.test.ts tests/unit/attachment-open-with-native.test.ts tests/unit/attachment-access.test.ts tests/unit/host-api-facade.test.ts tests/unit/host-services.test.ts tests/unit/acp-chat-components.test.tsx tests/unit/artifact-panel.test.tsx tests/unit/rich-file-viewers.test.tsx
- pnpm run typecheck
- pnpm run lint:check
- pnpm run build:vite
- pnpm exec playwright test tests/e2e/chat-acp-attachments.spec.ts
- pnpm run comms:replay
- pnpm run comms:compare
- pnpm harness validate --spec harness/specs/tasks/acp-attachment-open-with.md
- pnpm harness run --spec harness/specs/tasks/acp-attachment-open-with.md
- pnpm run harness:ci
acceptance:
- Eligible local assistant preview cards use the shared AcpFileCard shell with a compact secondary Open with sibling control, retain the primary Preview accessible name and behavior, and never activate preview from the secondary interaction.
- macOS and Windows list every valid compatible handler, deduplicate by stable identity, put the default first, locale-sort the remainder, and use native or generic application icons.
- macOS uses static JXA with public bundle IDs and Main-private bundle paths; Windows uses the static bundled PowerShell/C#/COM protocol with SHA-256 opaque IDs and post-ready invocation only.
- Linux performs no application discovery or application-specific open, returns a successful empty handler result, and presents only attachment-scoped reveal.
- Main independently re-resolves the attachment ref, active session, and generation for list, selected-handler open, and reveal, then freshly validates handler membership immediately before application-specific open.
- Windows prepare-open receives an initial Main-owned canonical path and opaque handler ID as separate arguments for fresh association enumeration, then invokes only with the post-ready revalidated path after rejecting any association-key change.
- Discovery, malformed metadata, and per-icon failures degrade silently without blocking the primary preview or reveal action.
- Renderer receives no canonical attachment path, executable or application path, command line, or command template and supplies only an attachment ref and stable handler identity.
- Native child processes receive only a sanitized Main-owned environment with no user-provided additions, and logs or traces contain no canonical file, application/bundle/icon-source path, command line, or icon data; optional traces use only opaque attachment identity and bounded fields.
- Helper records and protocol enforce the durable 256/512/4096 text/path limits, 64 KiB icon cap, five seconds process lifetime, 1 MiB output cap, and one 8192-character Windows post-ready line.
- Handler discovery caching remains presentation-only for five minutes and cannot replace attachment authorization or fresh action-time membership validation.
- Unit, Electron E2E, typecheck, lint, build, communication regression, and harness validation pass.
docs:
required: true
---
## Scope
This task covers the Main-owned platform adapters and attachment authorization operations, the typed host facade, the eligible ACP attachment in-card control now owned by shared `AcpFileCard`, four-locale menu behavior, native packaging checks, and focused regression coverage.
The authoritative durable requirements are `harness/reference/acp-attachment-access-control.md`, `harness/specs/scenarios/acp-chat-experience.md`, `harness/specs/rules/attachment-access-safety.md`, `harness/specs/rules/backend-communication-boundary.md`, and `harness/specs/rules/ui-i18n-design-tokens.md`. The later shared-card work supersedes only Renderer presentation ownership: attachment refs and workspace refs retain distinct Main authorization models.
## Out Of Scope
- Adding application discovery on Linux.
- Changing preview format classification or the behavior of user, remote, unavailable, or system-open-only attachments.
- Sending native paths or commands to Renderer or persisting operating-system application associations.
- Claiming that normalization inserts a default handler omitted by operating-system enumeration; current code only orders and deduplicates valid enumerated rows, and guarded insertion remains follow-up work.
- Modifying legacy Chat or OpenClaw.
## Acceptance Traceability
| Acceptance behavior | Test or durable rule |
| --- | --- |
| Deterministic handler normalization, presentation-only caching, 256/512/4096 and process/protocol bounds, icon degradation, sanitized environment, static JXA, SHA-256 Windows IDs, Main-owned association input, and post-ready invocation | `tests/unit/attachment-open-with.test.ts`, `attachment-access-safety` |
| Real macOS and Windows native bridge validity, static bundled helper resolution, and packaged-resource identity; native CI smoke allows cold PowerShell compilation overhead while mocked service tests enforce the production process timeout | `tests/unit/attachment-open-with-native.test.ts`, `tests/unit/attachment-open-with.test.ts`, `.github/workflows/check.yml`, `.github/workflows/release.yml` |
| Per-operation attachment authorization, generation revalidation, forged-handler rejection, scoped reveal, and sensitive diagnostic-payload exclusion | `tests/unit/attachment-access.test.ts`, `attachment-access-safety` |
| Shared `AcpFileCard` sibling controls, exact attachment eligibility, lazy/repeated discovery, stale-result rejection, sorting, icon fallback, silent failure, localization, and keyboard interaction | `tests/unit/acp-chat-components.test.tsx`, `ui-i18n-design-tokens` |
| End-to-end click routing, typed host requests, platform menu behavior, and failure isolation | `tests/e2e/chat-acp-attachments.spec.ts` |
| Later shared-card workspace reuse without widening attachment authority | `tests/unit/files-api-workspace.test.ts`, `tests/e2e/chat-file-changes.spec.ts`, `harness/reference/openclaw-file-activity.md` |
@@ -14,9 +14,14 @@ touchedAreas:
- src/lib/acp/reducer.ts
- src/lib/acp/timeline-types.ts
- src/stores/acp-chat-session.ts
- src/pages/Chat/index.tsx
- src/pages/Chat/ChatInput.tsx
- src/pages/Chat/AcpImagePart.tsx
- tests/unit/acp-image-generation-compat.test.ts
- tests/unit/acp-reducer.test.ts
- tests/unit/acp-chat-store.test.ts
- tests/unit/chat-acp-page.test.tsx
- tests/unit/chat-input.test.tsx
- tests/e2e/chat-run-state-events.spec.ts
- shared/i18n/locales/en/chat.json
- shared/i18n/locales/zh/chat.json
@@ -27,6 +32,7 @@ touchedAreas:
- README.ja-JP.md
expectedUserBehavior:
- ACP Chat first shows the image_generate background task start tool result.
- After the normal thinking state ends, the composer shows a distinct image-generation indicator until the generated image or a failure reply is rendered, including after switching away from and back to the conversation; users may edit a draft while another send is prevented.
- When OpenClaw later exposes a trusted internal-UI source reply through ACP or Gateway host events, ClawX preserves its exact user-facing text instead of replacing it with a generic caption.
- Successful replies include the hydrated image preview, while text-only generation failures remain visible as assistant replies.
- Arbitrary local paths and generic MEDIA: prose without approved image-generation context are not rendered as images.
@@ -57,6 +63,11 @@ acceptance:
- Internal-UI sourceReply text is authoritative for both successful media replies and text-only failure replies.
- ClawX hydrates previews through hostApi.media.thumbnails before rendering images.
- Duplicate completion records do not create duplicate assistant image replies.
- Live background image generation shows its dedicated generating label until its success or failure completion is projected, without changing the existing sending/thinking behavior.
- Switching conversations preserves each live image-generation pending state and restores its indicator on return.
- Previously rendered generated images remain visible while a later image-generation task is pending, including across session reloads and navigation.
- Completion evidence received during a session reload is projected after the new load generation is active instead of being overwritten or dropped as stale.
- Completion evidence received while the image conversation is inactive is deferred to that conversation, and a second prompt cannot be sent until the image task settles.
- Stale preview resolution does not append to a different active session or generation.
docs:
required: true
+13 -12
View File
@@ -1,15 +1,11 @@
---
id: acp-media-attachments
title: Render ACP resources and bounded OpenClaw MEDIA attachments
title: Render ACP resources and bounded OpenClaw media attachments
scenario: gateway-backend-communication
taskType: runtime-bridge
intent: Render standard ACP resources and recover only explicit OpenClaw MEDIA attachments omitted by the distributed ACP adapter through a bounded transcript compatibility projection.
intent: Render standard ACP resources and recover canonical persisted OpenClaw media facts or explicit MEDIA attachments omitted by the distributed ACP adapter through a bounded transcript compatibility projection.
touchedAreas:
- package.json
- docs/specs/2026-07-14-acp-media-attachments-design.md
- docs/plans/2026-07-14-acp-media-attachments.md
- docs/plans/2026-07-15-harness-spec-consolidation.md
- docs/plans/2026-07-16-acp-media-attached-turn-alignment.md
- harness/specs/tasks/acp-media-attachments.md
- harness/specs/tasks/fix-acp-history-load-races.md
- harness/specs/tasks/fix-acp-media-attached-turn-alignment.md
@@ -27,6 +23,7 @@ touchedAreas:
- harness/reference/acp-generated-media-and-diagnostics.md
- harness/reference/openclaw-file-activity.md
- shared/acp-chat/types.ts
- shared/chat/types.ts
- shared/host-api/contract.ts
- shared/file-preview/limits.ts
- electron/services/acp-session-access-registry.ts
@@ -79,7 +76,7 @@ touchedAreas:
- tests/unit/acp-host-contract.test.ts
- tests/unit/acp-chat-service.test.ts
- tests/unit/chat-acp-page.test.tsx
- tests/unit/chat-page-execution-graph.test.tsx
- tests/unit/chat-acp-inline-timeline.test.tsx
- tests/unit/attachment-access.test.ts
- tests/unit/files-api-workspace.test.ts
- tests/unit/media-api.test.ts
@@ -101,7 +98,6 @@ touchedAreas:
- tests/e2e/chat-acp-inline-timeline.spec.ts
- tests/e2e/chat-assistant-markdown-plain.spec.ts
- tests/e2e/chat-code-block-wrap.spec.ts
- tests/e2e/chat-history-startup-retry.spec.ts
- tests/e2e/chat-latex-rendering.spec.ts
- tests/e2e/chat-new-session-date.spec.ts
- tests/e2e/chat-question-directory.spec.ts
@@ -109,7 +105,7 @@ touchedAreas:
- tests/e2e/chat-scroll-pin-bottom.spec.ts
- tests/e2e/chat-scroll-to-latest.spec.ts
- tests/e2e/chat-table-header-light.spec.ts
- tests/e2e/chat-task-visualizer.spec.ts
- tests/e2e/chat-acp-process-timeline.spec.ts
- tests/e2e/chat-workspace-context.spec.ts
- tests/e2e/cron-run-live-status.spec.ts
- tests/e2e/fixtures/electron.ts
@@ -119,7 +115,9 @@ touchedAreas:
- README.ja-JP.md
expectedUserBehavior:
- Standard ACP resource_link and URI-backed resource content renders as paperclip attachment cards.
- Canonical assistant `__openclaw.media` facts render as attachment cards even when visible prose only mentions the output path.
- Explicit assistant OpenClaw MEDIA directives omitted by ACP are recovered for live completions and historical session loads without displaying the raw directive.
- Completed turn durations use transcript timing for both live completion and historical reload so navigating between conversations does not change the displayed value.
- MEDIA recovery remains aligned when the triggering ACP user turn contains structured resources, images, or no text.
- Attachment rows render after assistant prose and preserve declaration order.
- User image attachments render as thumbnails with a filename overlay on hover.
@@ -161,6 +159,7 @@ requiredTests:
- pnpm run harness:ci
acceptance:
- A standard ACP resource_link or URI-backed resource renders an actionable paperclip attachment card.
- A canonical persisted assistant `__openclaw.media` fact renders an actionable attachment card and carries its declared filename, content type, size, and transcript message identity into Main validation.
- User image attachments render as thumbnails with the filename revealed by a hover overlay.
- User non-image attachments render the filename followed by a muted, truncating source path and no MIME label.
- The reported OpenClaw MEDIA directive for budget_sample.xlsx renders an attachment in ACP Chat even though OpenClaw ACP emits no resource block.
@@ -173,6 +172,7 @@ acceptance:
- Arbitrary prose paths do not become attachments.
- Existing local references outside the workspace can be previewed or opened after exact session/generation validation and per-operation Main re-resolution.
- Live and historical paths deduplicate and reject stale session or generation results.
- A completed live turn is reconciled to the same transcript-derived duration used after session navigation.
- Native ACP resources take precedence over transcript compatibility evidence.
- Attachment access remains bound to Main-owned session, generation, target revalidation, and outgoing-record authority on every operation.
- Attachment rows use semantic controls with safe accessible labels, keyboard activation, and disabled unavailable states.
@@ -186,13 +186,13 @@ docs:
## Scope
Standard ACP resource content is the preferred attachment source. The OpenClaw transcript path is a bounded compatibility exception for explicit assistant `MEDIA:` directives that the distributed ACP adapter omits; it is not a second Chat history source.
Standard ACP resource content is the preferred attachment source. The OpenClaw transcript path is a bounded compatibility exception for canonical persisted assistant `__openclaw.media` facts and explicit assistant `MEDIA:` directives that the distributed ACP adapter omits; it is not a second Chat history source.
## Out Of Scope
- Modifying OpenClaw or its distributed package.
- Reconstructing ordinary messages, tools, plans, permissions, thoughts, or file activity from transcripts.
- Extracting bare paths or inline paths from ordinary assistant prose.
- Treating bare paths or inline paths from ordinary assistant prose as attachment evidence without a canonical persisted media fact.
- Persisting a synthetic ACP attachment ledger or compatibility cache.
## Acceptance Traceability
@@ -200,7 +200,8 @@ Standard ACP resource content is the preferred attachment source. The OpenClaw t
| Acceptance behavior | Test or durable rule |
| --- | --- |
| Standard ACP resources render actionable cards | `tests/unit/acp-reducer.test.ts`, `tests/unit/acp-chat-components.test.tsx`, `tests/e2e/chat-acp-attachments.spec.ts` |
| Explicit OpenClaw `MEDIA:` recovery and hidden raw directives | `tests/unit/acp-media-attachments.test.ts`, `tests/unit/acp-chat-store.test.ts`, `tests/e2e/chat-acp-attachments.spec.ts` |
| Canonical persisted OpenClaw media facts, explicit `MEDIA:` recovery, and hidden raw directives | `tests/unit/acp-media-attachments.test.ts`, `tests/unit/acp-chat-store.test.ts`, `tests/e2e/chat-acp-attachments.spec.ts` |
| Stable completed-turn duration across live completion and session navigation | `tests/unit/acp-chat-store.test.ts`, `tests/unit/acp-turn-timings.test.ts`, `tests/e2e/chat-acp-inline-timeline.spec.ts` |
| Explicit parser grammar rejects fenced, wrapped, inline, malformed, unknown-scheme, and overlong values | `tests/unit/acp-media-attachments.test.ts`, `acp-compatibility-content-safety` |
| Transcript suffix alignment uses normalized user text and occurrence from the tail without guessing | `tests/unit/acp-media-attachments.test.ts`, `tests/unit/acp-chat-store.test.ts`, `acp-chat-state-and-history` |
| Attached and attachment-only user turns use binary-free structured prompt projection | `tests/unit/acp-media-attachments.test.ts`, `tests/unit/acp-reducer.test.ts`, `tests/unit/acp-chat-store.test.ts`, `tests/e2e/chat-acp-attachments.spec.ts`, `acp-chat-state-and-history` |
+5 -7
View File
@@ -30,15 +30,14 @@ touchedAreas:
- tests/unit/acp-*.test.tsx
- tests/unit/chat-input.test.tsx
- tests/unit/chat-acp-page.test.tsx
- tests/unit/chat-page-execution-graph.test.tsx
- tests/unit/chat-acp-inline-timeline.test.tsx
- tests/unit/host-api-facade.test.ts
- tests/unit/host-events.test.ts
- tests/unit/host-services.test.ts
- tests/unit/openclaw-cli.test.ts
- tests/unit/task-visualization.test.ts
- tests/e2e/chat-acp-inline-timeline.spec.ts
- tests/e2e/chat-run-state-events.spec.ts
- tests/e2e/chat-task-visualizer.spec.ts
- tests/e2e/chat-acp-process-timeline.spec.ts
- README.md
- README.zh-CN.md
- README.ja-JP.md
@@ -46,7 +45,6 @@ expectedUserBehavior:
- Opening a Chat session loads history through ACP session/load replay.
- Sending a Chat prompt uses ACP session/prompt, shows an optimistic user segment, and coalesces it with the ACP user echo.
- Thinking, tool calls, permission requests, plans, generated files, and generated images appear as inline timeline blocks in ACP event order.
- The old Execution Graph aggregation is not used for the ACP Chat path.
- Renderer does not call Gateway HTTP or WebSocket endpoints directly.
- Gateway-backed models, providers, plugins, skills, doctor, workspace, settings, and media configuration continue to work.
requiredProfiles:
@@ -67,9 +65,9 @@ requiredRules:
requiredTests:
- pnpm run typecheck
- pnpm exec vitest run tests/unit/acp-host-contract.test.ts tests/unit/acp-chat-service.test.ts tests/unit/acp-reducer.test.ts tests/unit/acp-chat-store.test.ts tests/unit/acp-chat-components.test.tsx tests/unit/chat-acp-page.test.tsx
- pnpm exec vitest run tests/unit/host-api-facade.test.ts tests/unit/host-events.test.ts tests/unit/openclaw-cli.test.ts tests/unit/host-services.test.ts tests/unit/chat-page-execution-graph.test.tsx tests/unit/task-visualization.test.ts
- pnpm exec vitest run tests/unit/host-api-facade.test.ts tests/unit/host-events.test.ts tests/unit/openclaw-cli.test.ts tests/unit/host-services.test.ts tests/unit/chat-acp-inline-timeline.test.tsx
- pnpm exec playwright test tests/e2e/chat-acp-inline-timeline.spec.ts
- pnpm exec playwright test tests/e2e/chat-run-state-events.spec.ts tests/e2e/chat-task-visualizer.spec.ts
- pnpm exec playwright test tests/e2e/chat-run-state-events.spec.ts tests/e2e/chat-acp-process-timeline.spec.ts
- pnpm run comms:replay
- pnpm run comms:compare
acceptance:
@@ -77,7 +75,7 @@ acceptance:
- Main forwards ACP SessionNotification envelopes and permission request envelopes without translating text, thinking, tools, or media into legacy Chat events.
- Renderer reduces ACP notifications into an in-memory ordered timeline.
- No ClawX ACP replay ledger, Chat history cache, or reduced timeline persistence is introduced.
- The primary Chat page does not use gateway:chat-message or chat:runtime-event as ordinary Chat timeline sources; restricted image-generation compatibility evidence remains allowed.
- The primary Chat page uses ACP notifications and replay as its ordinary timeline sources; bounded image-generation compatibility evidence remains allowed.
- Inline process blocks preserve ordering between assistant message segments.
docs:
required: true
@@ -0,0 +1,43 @@
---
id: acp-slash-command-replies
title: Preserve ACP slash command replies
scenario: gateway-backend-communication
taskType: runtime-bridge
intent: Let OpenClaw recognize slash commands sent through the ACP bridge so command replies are projected into the visible chat timeline.
touchedAreas:
- harness/specs/tasks/acp-slash-command-replies.md
- electron/services/acp-chat-service.ts
- tests/unit/acp-chat-service.test.ts
- tests/e2e/chat-acp-slash-command-replies.spec.ts
expectedUserBehavior:
- Sending /status in ClawX produces a visible assistant status reply.
- Existing slash commands such as /compact continue to produce visible replies.
- Ordinary prompts continue to receive the working-directory prefix used by OpenClaw ACP.
requiredProfiles:
- fast
- comms
- e2e
requiredRules:
- renderer-main-boundary
- backend-communication-boundary
- host-events-fallback-policy
- comms-regression
- docs-sync
requiredTests:
- pnpm exec vitest run tests/unit/acp-chat-service.test.ts
- pnpm exec playwright test tests/e2e/chat-acp-slash-command-replies.spec.ts
- pnpm run typecheck
- pnpm run comms:replay
- pnpm run comms:compare
acceptance:
- ACP prompts whose trimmed text starts with / disable OpenClaw's cwd text prefix.
- Ordinary ACP prompts retain the cwd text prefix.
- Slash command replies continue through the existing ACP session-update timeline path without transcript reconstruction or synthetic Renderer replies.
- Renderer does not add direct IPC, Gateway HTTP, or Gateway WebSocket calls.
docs:
required: false
---
OpenClaw classifies text slash commands before folding streamed command blocks into
the final chat message. A working-directory text prefix prevents that classification
and can leave commands such as `/status` without a visible ACP assistant reply.
@@ -0,0 +1,90 @@
---
id: acp-whole-turn-duration
title: Show live and historical ACP whole-turn duration
scenario: gateway-backend-communication
taskType: runtime-bridge
intent: Show one whole-turn duration for ACP assistant turns, using Renderer-observed timing while live and a Main-owned transcript timing supplement after historical ACP replay.
touchedAreas:
- harness/specs/tasks/acp-whole-turn-duration.md
- harness/specs/scenarios/acp-chat-experience.md
- harness/specs/rules/acp-chat-state-and-history.md
- harness/specs/rules/ui-i18n-design-tokens.md
- harness/reference/acp-chat.md
- harness/reference/acp-generated-media-and-diagnostics.md
- electron/services/sessions-api.ts
- shared/host-api/contract.ts
- src/lib/host-api.ts
- src/lib/acp/openclaw-media-compat.ts
- src/lib/acp/turn-timings.ts
- src/lib/acp/transcript-supplement.ts
- src/lib/acp/timeline-groups.ts
- src/stores/acp-chat-session.ts
- src/pages/Chat/AcpTimeline.tsx
- src/pages/Chat/AcpAssistantTurn.tsx
- src/pages/Chat/index.tsx
- shared/i18n/locales/en/chat.json
- shared/i18n/locales/zh/chat.json
- shared/i18n/locales/ja/chat.json
- shared/i18n/locales/ru/chat.json
- tests/unit/sessions-api-workspace.test.ts
- tests/unit/host-api-facade.test.ts
- tests/unit/acp-turn-timings.test.ts
- tests/unit/acp-chat-store.test.ts
- tests/unit/acp-timeline-groups.test.ts
- tests/unit/acp-chat-components.test.tsx
- tests/e2e/chat-acp-inline-timeline.spec.ts
- tests/e2e/chat-acp-attachments.spec.ts
- README.md
- README.zh-CN.md
- README.ja-JP.md
expectedUserBehavior:
- A running ACP assistant turn shows elapsed whole-turn time without resetting when the user navigates away and returns.
- A completed live turn freezes the observed duration when the ACP prompt settles.
- Historical assistant turns show transcript-derived duration only when a bounded transcript turn aligns unambiguously with a turn already created by ACP replay.
- Missing, stale, incomplete, or ambiguous transcript timing never creates or changes Chat content and leaves duration hidden.
requiredProfiles:
- fast
- comms
- e2e
requiredRules:
- renderer-main-boundary
- backend-communication-boundary
- api-client-transport-policy
- host-api-fallback-policy
- acp-chat-state-and-history
- ui-i18n-design-tokens
- comms-regression
- docs-sync
requiredTests:
- pnpm exec vitest run tests/unit/sessions-api-workspace.test.ts tests/unit/acp-turn-timings.test.ts tests/unit/acp-chat-store.test.ts tests/unit/acp-timeline-groups.test.ts tests/unit/acp-chat-components.test.tsx
- pnpm run typecheck
- pnpm run lint:check
- pnpm run build:vite
- pnpm exec playwright test tests/e2e/chat-acp-inline-timeline.spec.ts
- pnpm run comms:replay
- pnpm run comms:compare
- pnpm harness validate --spec harness/specs/tasks/acp-whole-turn-duration.md
- pnpm harness run --spec harness/specs/tasks/acp-whole-turn-duration.md
- pnpm run harness:ci
acceptance:
- ACP session/load replay remains the sole authority for historical turn existence, content, and ordering.
- Main parses bounded OpenClaw transcript records and returns only timing candidates with normalized user anchors; Renderer never reads JSONL directly.
- A code comment at the transcript timing entry point explains that ACP loadSession does not expose enough timestamps to calculate whole-turn duration, requiring transcript supplementation.
- Historical timing uses the real user record as its start and the latest assistant or tool-result record before the next real user as its end, excluding internal inter-session user records.
- Renderer aligns timing by normalized ACP prompt text and duplicate occurrence from the tail, and rejects missing or ambiguous matches.
- Live timing starts with the optimistic user turn, survives the existing memory-only navigation snapshot, freezes on successful prompt settlement, and is removed with a failed optimistic turn.
- Duration text is localized in English, Chinese, Japanese, and Russian and uses established muted metadata styling.
- Unit, Electron E2E, typecheck, lint, build, communication regression, and harness validation pass.
docs:
required: true
---
## Scope
This task adds metadata-only whole-turn timing to ACP Chat. Transcript records may annotate an ACP-replayed turn but may not manufacture timeline items or become a parallel history source.
## Out Of Scope
- Provider-side model latency, time-to-first-token, reasoning duration, and individual tool duration.
- Modifying OpenClaw or relying on its private ACP SQLite replay schema.
- Reconstructing missing ACP messages or process items from transcript content.
@@ -0,0 +1,63 @@
---
id: add-chat-performance-diagnostics
title: Add chat performance diagnostics
scenario: gateway-backend-communication
taskType: runtime-bridge
intent: Establish repeatable Renderer and Electron Main profiling for ACP chat streaming, then remove measured per-update work without changing OpenClaw.
touchedAreas:
- harness/specs/tasks/add-chat-performance-diagnostics.md
- tests/e2e/renderer-performance.spec.ts
- tests/e2e/fixtures/electron.ts
- src/pages/Chat/**
- src/lib/acp/**
- src/stores/acp-chat-session.ts
- electron/services/acp-trace.ts
- electron/gateway/event-dispatch.ts
- electron/main/index.ts
- tests/unit/**
- package.json
- README.md
- README.zh-CN.md
- README.ja-JP.md
- AGENTS.md
expectedUserBehavior:
- ACP chat output remains semantically identical while long streaming responses stay responsive.
- Developers can capture bounded Renderer and Main CPU profiles from a deterministic synthetic chat workload.
- Profiling uses isolated synthetic data and never modifies OpenClaw.
requiredProfiles:
- fast
- comms
requiredRules:
- renderer-main-boundary
- backend-communication-boundary
- host-events-fallback-policy
- diagnostics-trace-safety
- comms-regression
- docs-sync
requiredTests:
- pnpm run perf:chat
- pnpm exec vitest run tests/unit/acp-chat-store.test.ts tests/unit/acp-trace.test.ts tests/unit/gateway-event-dispatch.test.ts
- pnpm run typecheck
- pnpm run build:vite
- pnpm run comms:replay
- pnpm run comms:compare
acceptance:
- The performance command writes versioned Renderer metrics plus standard Renderer and Main CPU profile artifacts.
- The synthetic workload covers a populated ACP timeline and a growing live Markdown response.
- Performance artifacts contain only generated fixture content and remain outside source control.
- Each confirmed optimization has focused regression coverage and preserves ACP event ordering and rendered output.
- Renderer continues to receive backend events through the existing typed host event boundary.
docs:
required: true
---
## Scope
This task creates a deterministic profiling loop for Electron Main and Renderer, records a baseline, and applies only optimizations supported by those recordings.
## Out of Scope
- Changes to the OpenClaw source tree or bundled package.
- Hardware-independent absolute timing gates.
- Uploading CPU profiles or performance traces as product telemetry.
- GPU-process or native-code profiling.
@@ -0,0 +1,43 @@
---
id: apply-acp-stream-updates-immediately
title: Apply live ACP stream updates immediately
scenario: gateway-backend-communication
taskType: runtime-bridge
intent: Remove Renderer-side live ACP chunk batching so Streamdown receives each accepted host event without a 16 ms queue.
touchedAreas:
- harness/specs/tasks/apply-acp-stream-updates-immediately.md
- harness/reference/acp-chat.md
- harness/specs/rules/acp-chat-state-and-history.md
- src/stores/acp-chat-session.ts
- tests/unit/acp-chat-store.test.ts
- tests/e2e/chat-streamdown-rendering.spec.ts
expectedUserBehavior:
- Live assistant output advances as each ACP host event arrives instead of waiting for a Renderer batching timer.
- Stream ordering, stale-generation rejection, permission handling, and completed prompt behavior remain unchanged.
requiredProfiles:
- fast
- comms
requiredRules:
- renderer-main-boundary
- backend-communication-boundary
- host-events-fallback-policy
- acp-chat-state-and-history
- comms-regression
requiredTests:
- pnpm exec vitest run tests/unit/acp-chat-store.test.ts
- pnpm exec playwright test tests/e2e/chat-streamdown-rendering.spec.ts
- pnpm run typecheck
- pnpm run comms:replay
- pnpm run comms:compare
acceptance:
- Each accepted live ACP session update is applied synchronously through the existing typed host-event subscription.
- Two consecutive live ACP chunks produce two ordered Store notifications rather than one timer-batched notification.
- Historical session replay retains its existing generation-scoped reduction behavior.
- The Renderer introduces no replacement queue, timer, transport, or fallback path.
docs:
required: false
---
## Scope
This task changes only the Renderer Store update cadence for live ACP host events. It does not change ACP transport, Main-process routing, timeline reduction semantics, Streamdown configuration, or persisted history authority.
@@ -1,71 +0,0 @@
---
id: chat-tool-events-runtime-pipeline
title: Make Chat runtime event-first with Main-owned Gateway communication
scenario: gateway-backend-communication
taskType: runtime-bridge
intent: Move Chat send/history/control and streamed runtime events to a Main-owned pipeline, consume OpenClaw tool events as the active-run source of truth, simplify Execution Graph active rendering, and remove the legacy dual-track Chat store path.
touchedAreas:
- harness/specs/tasks/chat-tool-events-runtime-pipeline.md
- electron/api/routes/gateway.ts
- electron/gateway/chat-runtime-events.ts
- electron/gateway/event-dispatch.ts
- electron/gateway/manager.ts
- electron/gateway/ws-client.ts
- electron/main/index.ts
- electron/main/ipc-handlers.ts
- electron/preload/index.ts
- shared/chat-runtime-events.ts
- src/lib/host-events.ts
- src/pages/Chat/index.tsx
- src/pages/Chat/image-generation-status.ts
- src/pages/Chat/task-visualization.ts
- src/stores/chat.ts
- src/stores/chat/**
- src/stores/chat/runtime-graph.ts
- src/stores/gateway.ts
- tests/e2e/chat-run-state-events.spec.ts
- tests/e2e/chat-task-visualizer.spec.ts
- tests/unit/chat-page-execution-graph.test.tsx
- tests/unit/chat-runtime-event-handlers.test.ts
- tests/unit/chat-store-history-retry.test.ts
- tests/unit/chat-store-session-label-fetch.test.ts
- tests/unit/gateway-event-dispatch.test.ts
- tests/unit/gateway-events.test.ts
- tests/unit/host-events.test.ts
- tests/unit/image-generation-status.test.ts
- tests/unit/task-visualization.test.ts
expectedUserBehavior:
- Chat send/history/abort flows no longer depend on renderer direct Gateway WebSocket transport.
- Active chat runs stream tool lifecycle and related process updates through Main-owned runtime events.
- Execution Graph for the active run reflects streamed runtime events instead of inferring the live timeline from history polling.
- Final assistant reply continues to render as a normal chat bubble, while Execution Graph focuses on process steps.
- Tool-produced file artifacts continue to surface under the final assistant message.
- Historical sessions still reconstruct process graphs from transcript/message history as a fallback.
requiredProfiles:
- fast
- comms
requiredRules:
- renderer-main-boundary
- backend-communication-boundary
- api-client-transport-policy
- host-api-fallback-policy
- host-events-fallback-policy
- gateway-readiness-policy
requiredTests:
- pnpm run typecheck
- pnpm run lint
- tests/unit/gateway-event-dispatch.test.ts
- tests/unit/chat-runtime-event-handlers.test.ts
- tests/e2e/chat-task-visualizer.spec.ts
- pnpm run comms:replay
- pnpm run comms:compare
acceptance:
- Renderer Chat code uses Host API / Host events rather than renderer-owned Gateway transport.
- Main Gateway connection declares the capability needed to receive streamed tool events.
- Main normalizes OpenClaw chat/agent runtime events before forwarding them to the renderer.
- Active-run Execution Graph is driven by streamed runtime events and only updates existing steps by stable runtime identifiers.
- Default chat history polling is removed from the active-run happy path.
- Chat store no longer maintains separate live logic in both the monolithic store and an unused duplicate action path.
docs:
required: false
---
@@ -47,7 +47,7 @@ touchedAreas:
- tests/unit/session-title.test.ts
- tests/unit/host-services.test.ts
- tests/unit/chat-store-session-label-fetch.test.ts
- tests/unit/chat-store-history-retry.test.ts
- tests/unit/chat-session-management.test.ts
- tests/unit/sessions-api-workspace.test.ts
- tests/unit/chat-acp-page.test.tsx
- tests/unit/workspace-browser-body.test.tsx
@@ -55,11 +55,15 @@ touchedAreas:
- tests/e2e/chat-workspace-context.spec.ts
expectedUserBehavior:
- New chat sessions use the globally selected workspace until their first send.
- Editable new chats list persisted recent and known session workspaces in the composer menu, alongside the default workspace and native folder picker.
- First send initializes the OpenClaw ACP session with the selected cwd.
- Existing sessions use OpenClaw ACP cwd as their read-only workspace context.
- Historical sessions with recoverable OpenClaw cwd group under their real cwd.
- Sessions without recoverable cwd group under the default workspace label.
- Imported workspace display names can be renamed without changing their authoritative paths.
- Renamed workspace labels stay synchronized between the sidebar and chat composer, while hover text exposes the path.
- OpenClaw ACP cwd injection remains enabled, while automatic conversation titles omit its leading working-directory envelope.
- OpenClaw UUID-date fallback titles are replaced by the transcript's first user prompt and are never persisted by an unchanged rename.
- Renderer continues to use host-api and never calls direct IPC or Gateway HTTP.
requiredProfiles:
- fast
@@ -75,7 +79,7 @@ requiredRules:
requiredTests:
- pnpm harness validate --spec harness/specs/tasks/chat-workspace-context.md
- pnpm run typecheck
- pnpm exec vitest run tests/unit/workspace-context.test.ts tests/unit/session-title.test.ts tests/unit/host-services.test.ts tests/unit/chat-store-session-label-fetch.test.ts tests/unit/chat-store-history-retry.test.ts tests/unit/sidebar-session-buckets.test.ts tests/unit/sessions-api-workspace.test.ts tests/unit/session-buckets.test.ts tests/unit/chat-acp-page.test.tsx tests/unit/workspace-browser-body.test.tsx
- pnpm exec vitest run tests/unit/workspace-context.test.ts tests/unit/session-title.test.ts tests/unit/host-services.test.ts tests/unit/chat-store-session-label-fetch.test.ts tests/unit/chat-session-management.test.ts tests/unit/sidebar-session-buckets.test.ts tests/unit/sessions-api-workspace.test.ts tests/unit/session-buckets.test.ts tests/unit/chat-acp-page.test.tsx tests/unit/workspace-browser-body.test.tsx
- pnpm run build:vite
- pnpm exec playwright test tests/e2e/chat-workspace-context.spec.ts
- pnpm run comms:replay
@@ -83,10 +87,13 @@ requiredTests:
acceptance:
- OpenClaw ACP cwd is the authoritative session workspace when available.
- ClawX only persists global workspace selection and recent workspaces.
- The editable composer workspace menu shows deduplicated recent and known-session non-default workspaces with their custom display labels.
- Bound session footer workspace is read-only.
- Right workspace tree root matches effective chat workspace.
- Sidebar groups sessions by workspace, then sorts each flat group by the shared activity timestamp without date buckets.
- Custom workspace labels persist through Main-owned settings and never replace path identity.
- Explicit user session labels remain unchanged even when they begin with a working-directory-looking string.
- A UUID-date fallback matching the OpenClaw session id is not treated as an explicit or derived user-facing title.
docs:
required: true
---
@@ -68,4 +68,3 @@ for the model. Try /reset (or /new) ...". Users also had no
- Renderer UI for editing contextWindow per model.
- Writing `maxTokens` for non-anthropic providers (changes request payloads).
- Backfill for non-custom (registry/ollama) provider entries.
- Image attachment compression in `chat:sendWithMedia`.
@@ -0,0 +1,68 @@
---
id: delete-unavailable-chat-workspace
title: Delete unavailable chat workspace groups
scenario: gateway-backend-communication
taskType: runtime-bridge
intent: Mark unavailable non-default workspace groups in the Chat sidebar and let users permanently delete every session in such a group without risking concurrent session-index writes.
touchedAreas:
- harness/specs/tasks/delete-unavailable-chat-workspace.md
- harness/specs/tasks/prompt-for-missing-chat-workspace.md
- harness/specs/scenarios/chat-workspace-and-navigation.md
- harness/specs/rules/session-workspace-authority.md
- harness/reference/chat-workspace-and-navigation.md
- README.md
- README.zh-CN.md
- README.ja-JP.md
- shared/i18n/locales/en/chat.json
- shared/i18n/locales/zh/chat.json
- shared/i18n/locales/ja/chat.json
- shared/i18n/locales/ru/chat.json
- shared/chat/types.ts
- src/components/layout/Sidebar.tsx
- src/hooks/use-workspace-availability.ts
- src/pages/Chat/index.tsx
- src/stores/chat.ts
- src/stores/settings.ts
- tests/unit/use-workspace-availability.test.tsx
- tests/unit/chat-acp-page.test.tsx
- tests/unit/chat-acp-inline-timeline.test.tsx
- tests/unit/chat-session-management.test.ts
- tests/unit/settings-store.test.ts
- tests/e2e/chat-workspace-context.spec.ts
expectedUserBehavior:
- Sidebar validates each distinct non-default workspace through the Main-owned files host API.
- Only confirmed unavailable non-default workspace groups show an unavailable badge and destructive delete action.
- One confirmation explains that every session in the workspace group will be permanently deleted.
- Session deletion is sequential across all agents in the group so sessions.json updates cannot race.
- Successful deletions disappear together; failed deletions remain visible and can be retried.
- Deleting the selected group switches Chat once to a remaining session or the default new-session context.
- Successful group deletion removes the path from recent workspaces and custom labels, and resets the global new-chat workspace when needed.
- Default and available workspace groups never expose the group delete action.
requiredProfiles:
- fast
- comms
requiredRules:
- renderer-main-boundary
- backend-communication-boundary
- api-client-transport-policy
- session-workspace-authority
- ui-i18n-design-tokens
- comms-regression
- docs-sync
requiredTests:
- pnpm harness validate --spec harness/specs/tasks/delete-unavailable-chat-workspace.md
- pnpm exec vitest run tests/unit/use-workspace-availability.test.tsx tests/unit/chat-session-management.test.ts tests/unit/settings-store.test.ts
- pnpm exec playwright test tests/e2e/chat-workspace-context.spec.ts
- pnpm run typecheck
- pnpm run build:vite
- pnpm run comms:replay
- pnpm run comms:compare
acceptance:
- Workspace availability remains Main-authoritative and is not inferred from renderer state.
- Group deletion is impossible until a non-default workspace has been confirmed unavailable.
- Bulk deletion reuses the existing hard-delete host operation sequentially and reports partial failure.
- Settings cleanup never changes workspace authority for surviving bound sessions.
- User-facing copy is complete in English, Chinese, Japanese, and Russian.
docs:
required: true
---
@@ -0,0 +1,57 @@
---
id: disable-provider-model-id-edit
title: Prevent editing provider model IDs
scenario: gateway-backend-communication
taskType: runtime-bridge
intent: Prevent stale runtime model IDs by making an existing provider's model ID immutable and telling users to recreate the provider when they need a different ID.
touchedAreas:
- harness/specs/tasks/disable-provider-model-id-edit.md
- harness/specs/tasks/fix-api-key-model-picker-stale-id.md
- harness/specs/rules/provider-model-selection-authority.md
- src/components/settings/ProvidersSettings.tsx
- src/lib/model-options.ts
- shared/i18n/locales/en/settings.json
- shared/i18n/locales/zh/settings.json
- shared/i18n/locales/ja/settings.json
- shared/i18n/locales/ru/settings.json
- tests/unit/model-options.test.ts
- tests/e2e/chat-model-picker.spec.ts
- tests/e2e/provider-lifecycle.spec.ts
expectedUserBehavior:
- The model ID remains configurable while adding a provider.
- The model ID field is disabled when editing an existing provider.
- A localized hint tells users to delete and recreate the provider to use another model ID.
- Saving edits to other provider fields never submits a model ID change.
requiredProfiles:
- fast
- comms
- e2e
requiredRules:
- provider-model-selection-authority
- ui-i18n-design-tokens
requiredTests:
- tests/unit/model-options.test.ts
- tests/e2e/chat-model-picker.spec.ts
- tests/e2e/provider-lifecycle.spec.ts
acceptance:
- Existing provider model ID inputs are disabled for every provider type.
- Code Plan edit controls cannot indirectly change the model ID.
- The edit save payload cannot include a model update.
- The explanatory hint has complete en, zh, ja, and ru translations.
- Focused tests and harness validation pass.
docs:
required: false
---
## Scope
- Disable the model ID field in the existing-provider edit form.
- Prevent edit-save logic and Code Plan controls from changing the model ID.
- Display a short localized recreation hint.
- Keep model ID entry unchanged in the add-provider flow.
## Out Of Scope
- Migrating historical model IDs already written to OpenClaw.
- Changing provider creation or deletion behavior.
- Adding a model-ID migration workflow.

Some files were not shown because too many files have changed in this diff Show More